Coverage for aiocoap / resource.py: 82%

209 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-17 12:28 +0000

1# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors 

2# 

3# SPDX-License-Identifier: MIT 

4 

5"""Basic resource implementations 

6 

7A resource in URL / CoAP / REST terminology is the thing identified by a URI. 

8 

9Here, a :class:`.Resource` is the place where server functionality is 

10implemented. In many cases, there exists one persistent Resource object for a 

11given resource (eg. a ``TimeResource()`` is responsible for serving the 

12``/time`` location). On the other hand, an aiocoap server context accepts only 

13one thing as its serversite, and that is a Resource too (typically of the 

14:class:`Site` class). 

15 

16Resources are most easily implemented by deriving from :class:`.Resource` and 

17implementing ``render_get``, ``render_post`` and similar coroutine methods. 

18Those take a single request message object and must return a 

19:class:`aiocoap.Message` object or raise an 

20:class:`.error.RenderableError` (eg. ``raise UnsupportedMediaType()``). 

21 

22To serve more than one resource on a site, use the :class:`Site` class to 

23dispatch requests based on the Uri-Path header. 

24""" 

25 

26import hashlib 

27import warnings 

28 

29from . import message 

30from . import meta 

31from . import error 

32from . import interfaces 

33from .numbers.contentformat import ContentFormat 

34from .numbers.codes import Code 

35from .numbers import uri_path_abbrev 

36from .pipe import Pipe 

37from .util import DeprecationWarning 

38from .util.linkformat import Link, LinkFormat 

39 

40 

41def hashing_etag(request: message.Message, response: message.Message): 

42 """Helper function for render_get handlers that allows them to use ETags based 

43 on the payload's hash value 

44 

45 Run this on your request and response before returning from render_get; it is 

46 safe to use this function with all kinds of responses, it will only act on 

47 2.05 Content messages (and those with no code set, which defaults to that 

48 for GET requests). The hash used are the first 8 bytes of the sha1 sum of 

49 the payload. 

50 

51 Note that this method is not ideal from a server performance point of view 

52 (a file server, for example, might want to hash only the stat() result of a 

53 file instead of reading it in full), but it saves bandwidth for the simple 

54 cases. 

55 

56 >>> from aiocoap import * 

57 >>> req = Message(code=GET) 

58 >>> hash_of_hello = b'\\xaa\\xf4\\xc6\\x1d\\xdc\\xc5\\xe8\\xa2' 

59 >>> req.opt.etags = [hash_of_hello] 

60 >>> resp = Message(code=CONTENT) 

61 >>> resp.payload = b'hello' 

62 >>> hashing_etag(req, resp) 

63 >>> resp # doctest: +ELLIPSIS 

64 <aiocoap.Message: 2.03 Valid outgoing, 1 option(s)...> 

65 """ 

66 

67 if response.code != Code.CONTENT and response.code is not None: 

68 return 

69 

70 response.opt.etag = hashlib.sha1(response.payload).digest()[:8] 

71 if request.opt.etags is not None and response.opt.etag in request.opt.etags: 

72 response.code = Code.VALID 

73 response.payload = b"" 

74 

75 

76class _ExposesWellknownAttributes: 

77 def get_link_description(self): 

78 # FIXME which formats are acceptable, and how much escaping and 

79 # list-to-separated-string conversion needs to happen here 

80 ret = {} 

81 if hasattr(self, "ct"): 

82 ret["ct"] = str(self.ct) 

83 if hasattr(self, "rt"): 

84 ret["rt"] = self.rt 

85 if hasattr(self, "if_"): 

86 ret["if"] = self.if_ 

87 return ret 

88 

89 

90class Resource(_ExposesWellknownAttributes, interfaces.Resource): 

91 """Simple base implementation of the :class:`interfaces.Resource` 

92 interface 

93 

94 The render method delegates content creation to ``render_$method`` methods 

95 (``render_get``, ``render_put`` etc), and responds appropriately to 

96 unsupported methods. Those messages may return messages without a response 

97 code, the default render method will set an appropriate successful code 

98 ("Content" for GET/FETCH, "Deleted" for DELETE, "Changed" for anything 

99 else). The render method will also fill in the request's no_response code 

100 into the response (see :meth:`.interfaces.Resource.render`) if none was 

101 set. 

102 

103 Moreover, this class provides a ``get_link_description`` method as used by 

104 .well-known/core to expose a resource's ``.ct``, ``.rt`` and ``.if_`` 

105 (alternative name for ``if`` as that's a Python keyword) attributes. 

106 Details can be added by overriding the method to return a more 

107 comprehensive dictionary, and resources can be hidden completely by 

108 returning None. 

109 """ 

110 

111 async def needs_blockwise_assembly(self, request): 

112 return True 

113 

114 async def render(self, request): 

115 assert request.direction is message.Direction.INCOMING 

116 if not request.code.is_request(): 

117 raise error.UnsupportedMethod() 

118 m = getattr(self, "render_%s" % str(request.code).lower(), None) 

119 if not m: 

120 raise error.UnallowedMethod() 

121 

122 response = await m(request) 

123 

124 if response is message.NoResponse: 

125 warnings.warn( 

126 "Returning NoResponse is deprecated, please return a" 

127 " regular response with a no_response option set.", 

128 DeprecationWarning, 

129 ) 

130 response = message.Message(no_response=26) 

131 

132 if response.code is None: 

133 if request.code in (Code.GET, Code.FETCH): 

134 response_default = Code.CONTENT 

135 elif request.code == Code.DELETE: 

136 response_default = Code.DELETED 

137 else: 

138 response_default = Code.CHANGED 

139 response.code = response_default 

140 

141 if response.opt.no_response is None: 

142 response.opt.no_response = request.opt.no_response 

143 

144 return response 

145 

146 async def render_to_pipe(self, pipe: Pipe): 

147 # Silence the deprecation warning 

148 if isinstance(self, interfaces.ObservableResource): 

149 # See interfaces.Resource.render_to_pipe 

150 return await interfaces.ObservableResource._render_to_pipe(self, pipe) 

151 return await interfaces.Resource._render_to_pipe(self, pipe) 

152 

153 

154class ObservableResource(Resource, interfaces.ObservableResource): 

155 def __init__(self): 

156 super(ObservableResource, self).__init__() 

157 self._observations = set() 

158 

159 async def add_observation(self, request, serverobservation): 

160 self._observations.add(serverobservation) 

161 

162 def _cancel(self=self, obs=serverobservation): 

163 self._observations.remove(serverobservation) 

164 self.update_observation_count(len(self._observations)) 

165 

166 serverobservation.accept(_cancel) 

167 self.update_observation_count(len(self._observations)) 

168 

169 def update_observation_count(self, newcount): 

170 """Hook into this method to be notified when the number of observations 

171 on the resource changes.""" 

172 

173 def updated_state(self, response=None): 

174 """Call this whenever the resource was updated, and a notification 

175 should be sent to observers.""" 

176 

177 for o in self._observations: 

178 o.trigger(response) 

179 

180 def get_link_description(self): 

181 link = super(ObservableResource, self).get_link_description() 

182 link["obs"] = None 

183 return link 

184 

185 async def render_to_pipe(self, request: Pipe): 

186 # Silence the deprecation warning 

187 return await interfaces.ObservableResource._render_to_pipe(self, request) 

188 

189 

190def link_format_to_message( 

191 request: message.Message, 

192 linkformat: LinkFormat, 

193 default_ct=ContentFormat.LINKFORMAT, 

194) -> message.Message: 

195 """Given a LinkFormat object, render it to a response message, picking a 

196 suitable content format from a given request. 

197 

198 It returns a Not Acceptable response if something unsupported was queried. 

199 

200 It makes no attempt to modify the URI reference literals encoded in the 

201 LinkFormat object; they have to be suitably prepared by the caller.""" 

202 

203 ct = request.opt.accept 

204 if ct is None: 

205 ct = default_ct 

206 

207 if ct == ContentFormat.LINKFORMAT: 

208 payload = str(linkformat).encode("utf8") 

209 else: 

210 return message.Message(code=Code.NOT_ACCEPTABLE) 

211 

212 return message.Message(payload=payload, content_format=ct) 

213 

214 

215# Convenience attribute to set as ct on resources that use 

216# link_format_to_message as their final step in the request handler 

217# 

218# mypy doesn't like attributes on functions, requiring some `type: ignore` for 

219# this, but the alternatives (a __call__able class) have worse docs. 

220link_format_to_message.supported_ct = " ".join( # type: ignore 

221 str(int(x)) for x in (ContentFormat.LINKFORMAT,) 

222) 

223 

224 

225class WKCResource(Resource): 

226 """Read-only dynamic resource list, suitable as .well-known/core. 

227 

228 This resource renders a link_header.LinkHeader object (which describes a 

229 collection of resources) as application/link-format (RFC 6690). 

230 

231 The list to be rendered is obtained from a function passed into the 

232 constructor; typically, that function would be a bound 

233 Site.get_resources_as_linkheader() method. 

234 

235 This resource also provides server `implementation information link`_; 

236 server authors are invited to override this by passing an own URI as the 

237 `impl_info` parameter, and can disable it by passing None. 

238 

239 .. _`implementation information link`: https://tools.ietf.org/html/draft-bormann-t2trg-rel-impl-00""" 

240 

241 ct = link_format_to_message.supported_ct # type: ignore 

242 

243 def __init__(self, listgenerator, impl_info=meta.library_uri, **kwargs): 

244 super().__init__(**kwargs) 

245 self.listgenerator = listgenerator 

246 self.impl_info = impl_info 

247 

248 async def render_get(self, request): 

249 # If this is ever filtered to the request's authenticated claims, 

250 # adjustments may be due in oscore_sitewrapper's 

251 # get_resources_as_linkheader 

252 links = self.listgenerator() 

253 

254 if self.impl_info is not None: 

255 links.links = links.links + [Link(href=self.impl_info, rel="impl-info")] 

256 

257 filters = [] 

258 for q in request.opt.uri_query: 

259 try: 

260 k, v = q.split("=", 1) 

261 except ValueError: 

262 continue # no =, not a relevant filter 

263 

264 if v.endswith("*"): 

265 

266 def matchexp(x, v=v): 

267 return x.startswith(v[:-1]) 

268 else: 

269 

270 def matchexp(x, v=v): 

271 return x == v 

272 

273 if k in ("rt", "if", "ct"): 

274 filters.append( 

275 lambda link: any( 

276 matchexp(part) 

277 for part in (" ".join(getattr(link, k, ()))).split(" ") 

278 ) 

279 ) 

280 elif k in ("href",): # x.href is single valued 

281 filters.append(lambda link: matchexp(getattr(link, k))) 

282 else: 

283 filters.append( 

284 lambda link: any(matchexp(part) for part in getattr(link, k, ())) 

285 ) 

286 

287 while filters: 

288 links.links = filter(filters.pop(), links.links) 

289 links.links = list(links.links) 

290 

291 response = link_format_to_message(request, links) 

292 

293 if ( 

294 request.opt.uri_query 

295 and not links.links 

296 and request.remote.is_multicast_locally 

297 ): 

298 if request.opt.no_response is None: 

299 # If the filter does not match, multicast requests should not 

300 # be responded to -- that's equivalent to a "no_response on 

301 # 2.xx" option. 

302 response.opt.no_response = 0x02 

303 

304 return response 

305 

306 

307class PathCapable: 

308 """Class that indicates that a resource promises to parse the uri_path 

309 option, and can thus be given requests for 

310 :meth:`~.interfaces.Resource.render`-ing that contain a uri_path""" 

311 

312 

313class Site(interfaces.ObservableResource, PathCapable): 

314 """Typical root element that gets passed to a :class:`Context` and contains 

315 all the resources that can be found when the endpoint gets accessed as a 

316 server. 

317 

318 This provides easy registration of statical resources. Add resources at 

319 absolute locations using the :meth:`.add_resource` method. 

320 

321 For example, the site at 

322 

323 >>> site = Site() 

324 >>> site.add_resource(["hello"], Resource()) 

325 

326 will have requests to </hello> rendered by the new resource. 

327 

328 You can add another Site (or another instance of :class:`PathCapable`) as 

329 well, those will be nested and integrally reported in a WKCResource. The 

330 path of a site should not end with an empty string (ie. a slash in the URI) 

331 -- the child site's own root resource will then have the trailing slash 

332 address. Subsites can not have link-header attributes on their own (eg. 

333 `rt`) and will never respond to a request that does not at least contain a 

334 single slash after the the given path part. 

335 

336 For example, 

337 

338 >>> batch = Site() 

339 >>> batch.add_resource(["light1"], Resource()) 

340 >>> batch.add_resource(["light2"], Resource()) 

341 >>> batch.add_resource([], Resource()) 

342 >>> s = Site() 

343 >>> s.add_resource(["batch"], batch) 

344 

345 will have the three created resources rendered at </batch/light1>, 

346 </batch/light2> and </batch/>. 

347 

348 If it is necessary to respond to requests to </batch> or report its 

349 attributes in .well-known/core in addition to the above, a non-PathCapable 

350 resource can be added with the same path. This is usually considered an odd 

351 design, not fully supported, and for example doesn't support removal of 

352 resources from the site. 

353 """ 

354 

355 def __init__(self): 

356 self._resources = {} 

357 self._subsites = {} 

358 

359 async def needs_blockwise_assembly(self, request): 

360 try: 

361 child, subrequest = self._find_child_and_pathstripped_message(request) 

362 except KeyError: 

363 return True 

364 else: 

365 return await child.needs_blockwise_assembly(subrequest) 

366 

367 def _find_child_and_pathstripped_message(self, request): 

368 """Given a request, find the child that will handle it, and strip all 

369 path components from the request that are covered by the child's 

370 position within the site. Returns the child and a request with a path 

371 shortened by the components in the child's path, or raises a 

372 KeyError. 

373 

374 While producing stripped messages, this adds a ._original_request_path 

375 attribute to the messages which holds the request URI before the 

376 stripping is started. That allows internal components to access the 

377 original URI until there is a variation of the request API that allows 

378 accessing this in a better usable way.""" 

379 

380 original_request_path = getattr( 

381 request, 

382 "_original_request_path", 

383 request.opt.uri_path, 

384 ) 

385 

386 if request.opt.uri_path in self._resources: 

387 stripped = request.copy(uri_path=()) 

388 stripped._original_request_path = original_request_path 

389 return self._resources[request.opt.uri_path], stripped 

390 

391 if not request.opt.uri_path: 

392 raise KeyError() 

393 

394 remainder = [request.opt.uri_path[-1]] 

395 path = request.opt.uri_path[:-1] 

396 while path: 

397 if path in self._subsites: 

398 res = self._subsites[path] 

399 if remainder == [""]: 

400 # sub-sites should see their root resource like sites 

401 remainder = [] 

402 stripped = request.copy(uri_path=remainder) 

403 stripped._original_request_path = original_request_path 

404 return res, stripped 

405 remainder.insert(0, path[-1]) 

406 path = path[:-1] 

407 raise KeyError() 

408 

409 async def render(self, request): 

410 try: 

411 child, subrequest = self._find_child_and_pathstripped_message(request) 

412 except KeyError: 

413 raise error.NotFound() 

414 else: 

415 return await child.render(subrequest) 

416 

417 async def add_observation(self, request, serverobservation): 

418 try: 

419 child, subrequest = self._find_child_and_pathstripped_message(request) 

420 except KeyError: 

421 return 

422 

423 try: 

424 await child.add_observation(subrequest, serverobservation) 

425 except AttributeError: 

426 pass 

427 

428 def add_resource(self, path, resource): 

429 if isinstance(path, str): 

430 raise ValueError("Paths should be tuples or lists of strings") 

431 if isinstance(resource, PathCapable): 

432 self._subsites[tuple(path)] = resource 

433 else: 

434 self._resources[tuple(path)] = resource 

435 

436 def remove_resource(self, path): 

437 try: 

438 del self._subsites[tuple(path)] 

439 except KeyError: 

440 del self._resources[tuple(path)] 

441 

442 def get_resources_as_linkheader(self): 

443 links = [] 

444 

445 for path, resource in self._resources.items(): 

446 if hasattr(resource, "get_link_description"): 

447 details = resource.get_link_description() 

448 else: 

449 details = {} 

450 if details is None: 

451 continue 

452 lh = Link("/" + "/".join(path), **details) 

453 

454 links.append(lh) 

455 

456 for path, resource in self._subsites.items(): 

457 if hasattr(resource, "get_resources_as_linkheader"): 

458 for link in resource.get_resources_as_linkheader().links: 

459 links.append( 

460 Link("/" + "/".join(path) + link.href, link.attr_pairs) 

461 ) 

462 return LinkFormat(links) 

463 

464 async def render_to_pipe(self, request: Pipe): 

465 # As soon as we use the provided render_to_pipe that fans out to 

466 # needs_blockwise_assembly etc, we'll have multiple methods that all 

467 # need to agree on how the path is processed, even before we go into 

468 # the site dispatch -- so we better resolve this now (exercising our 

469 # liberties as a library proxy) and provide a consistent view. 

470 if request.request.opt.uri_path_abbrev is not None: 

471 if request.request.opt.uri_path: 

472 # Conflicting options 

473 raise error.BadOption() 

474 try: 

475 request.request.opt.uri_path = uri_path_abbrev._map[ 

476 request.request.opt.uri_path_abbrev 

477 ] 

478 except KeyError: 

479 # Unknown option 

480 raise error.BadOption() from None 

481 request.request.opt.uri_path_abbrev = None 

482 

483 try: 

484 child, subrequest = self._find_child_and_pathstripped_message( 

485 request.request 

486 ) 

487 except KeyError: 

488 raise error.NotFound() 

489 else: 

490 # FIXME consider carefully whether this switching-around is good. 

491 # It probably is. 

492 request.request = subrequest 

493 return await child.render_to_pipe(request)