Coverage for aiocoap/resource.py: 82%

208 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-30 11:17 +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.linkformat import Link, LinkFormat 

38 

39 

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

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

42 on the payload's hash value 

43 

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

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

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

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

48 the payload. 

49 

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

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

52 file instead of reading it in full), but it saves bandwith for the simple 

53 cases. 

54 

55 >>> from aiocoap import * 

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

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

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

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

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

61 >>> hashing_etag(req, resp) 

62 >>> resp # doctest: +ELLIPSIS 

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

64 """ 

65 

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

67 return 

68 

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

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

71 response.code = Code.VALID 

72 response.payload = b"" 

73 

74 

75class _ExposesWellknownAttributes: 

76 def get_link_description(self): 

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

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

79 ret = {} 

80 if hasattr(self, "ct"): 

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

82 if hasattr(self, "rt"): 

83 ret["rt"] = self.rt 

84 if hasattr(self, "if_"): 

85 ret["if"] = self.if_ 

86 return ret 

87 

88 

89class Resource(_ExposesWellknownAttributes, interfaces.Resource): 

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

91 interface 

92 

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

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

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

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

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

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

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

100 set. 

101 

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

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

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

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

106 comprehensive dictionary, and resources can be hidden completely by 

107 returning None. 

108 """ 

109 

110 async def needs_blockwise_assembly(self, request): 

111 return True 

112 

113 async def render(self, request): 

114 assert request.direction is message.Direction.INCOMING 

115 if not request.code.is_request(): 

116 raise error.UnsupportedMethod() 

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

118 if not m: 

119 raise error.UnallowedMethod() 

120 

121 response = await m(request) 

122 

123 if response is message.NoResponse: 

124 warnings.warn( 

125 "Returning NoResponse is deprecated, please return a" 

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

127 DeprecationWarning, 

128 ) 

129 response = message.Message(no_response=26) 

130 

131 if response.code is None: 

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

133 response_default = Code.CONTENT 

134 elif request.code == Code.DELETE: 

135 response_default = Code.DELETED 

136 else: 

137 response_default = Code.CHANGED 

138 response.code = response_default 

139 

140 if response.opt.no_response is None: 

141 response.opt.no_response = request.opt.no_response 

142 

143 return response 

144 

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

146 # Silence the deprecation warning 

147 if isinstance(self, interfaces.ObservableResource): 

148 # See interfaces.Resource.render_to_pipe 

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

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

151 

152 

153class ObservableResource(Resource, interfaces.ObservableResource): 

154 def __init__(self): 

155 super(ObservableResource, self).__init__() 

156 self._observations = set() 

157 

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

159 self._observations.add(serverobservation) 

160 

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

162 self._observations.remove(serverobservation) 

163 self.update_observation_count(len(self._observations)) 

164 

165 serverobservation.accept(_cancel) 

166 self.update_observation_count(len(self._observations)) 

167 

168 def update_observation_count(self, newcount): 

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

170 on the resource changes.""" 

171 

172 def updated_state(self, response=None): 

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

174 should be sent to observers.""" 

175 

176 for o in self._observations: 

177 o.trigger(response) 

178 

179 def get_link_description(self): 

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

181 link["obs"] = None 

182 return link 

183 

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

185 # Silence the deprecation warning 

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

187 

188 

189def link_format_to_message( 

190 request: message.Message, 

191 linkformat: LinkFormat, 

192 default_ct=ContentFormat.LINKFORMAT, 

193) -> message.Message: 

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

195 suitable conent format from a given request. 

196 

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

198 

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

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

201 

202 ct = request.opt.accept 

203 if ct is None: 

204 ct = default_ct 

205 

206 if ct == ContentFormat.LINKFORMAT: 

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

208 else: 

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

210 

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

212 

213 

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

215# link_format_to_message as their final step in the request handler 

216# 

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

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

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

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

221) 

222 

223 

224class WKCResource(Resource): 

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

226 

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

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

229 

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

231 constructor; typically, that function would be a bound 

232 Site.get_resources_as_linkheader() method. 

233 

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

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

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

237 

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

239 

240 ct = link_format_to_message.supported_ct # type: ignore 

241 

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

243 super().__init__(**kwargs) 

244 self.listgenerator = listgenerator 

245 self.impl_info = impl_info 

246 

247 async def render_get(self, request): 

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

249 # adjustments may be due in oscore_sitewrapper's 

250 # get_resources_as_linkheader 

251 links = self.listgenerator() 

252 

253 if self.impl_info is not None: 

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

255 

256 filters = [] 

257 for q in request.opt.uri_query: 

258 try: 

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

260 except ValueError: 

261 continue # no =, not a relevant filter 

262 

263 if v.endswith("*"): 

264 

265 def matchexp(x, v=v): 

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

267 else: 

268 

269 def matchexp(x, v=v): 

270 return x == v 

271 

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

273 filters.append( 

274 lambda link: any( 

275 matchexp(part) 

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

277 ) 

278 ) 

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

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

281 else: 

282 filters.append( 

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

284 ) 

285 

286 while filters: 

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

288 links.links = list(links.links) 

289 

290 response = link_format_to_message(request, links) 

291 

292 if ( 

293 request.opt.uri_query 

294 and not links.links 

295 and request.remote.is_multicast_locally 

296 ): 

297 if request.opt.no_response is None: 

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

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

300 # 2.xx" option. 

301 response.opt.no_response = 0x02 

302 

303 return response 

304 

305 

306class PathCapable: 

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

308 option, and can thus be given requests for 

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

310 

311 

312class Site(interfaces.ObservableResource, PathCapable): 

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

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

315 server. 

316 

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

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

319 

320 For example, the site at 

321 

322 >>> site = Site() 

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

324 

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

326 

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

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

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

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

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

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

333 single slash after the the given path part. 

334 

335 For example, 

336 

337 >>> batch = Site() 

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

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

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

341 >>> s = Site() 

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

343 

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

345 </batch/light2> and </batch/>. 

346 

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

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

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

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

351 resources from the site. 

352 """ 

353 

354 def __init__(self): 

355 self._resources = {} 

356 self._subsites = {} 

357 

358 async def needs_blockwise_assembly(self, request): 

359 try: 

360 child, subrequest = self._find_child_and_pathstripped_message(request) 

361 except KeyError: 

362 return True 

363 else: 

364 return await child.needs_blockwise_assembly(subrequest) 

365 

366 def _find_child_and_pathstripped_message(self, request): 

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

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

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

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

371 KeyError. 

372 

373 While producing stripped messages, this adds a ._original_request_uri 

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

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

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

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

378 

379 original_request_uri = getattr( 

380 request, 

381 "_original_request_uri", 

382 request.get_request_uri(), 

383 ) 

384 

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

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

387 stripped._original_request_uri = original_request_uri 

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

389 

390 if not request.opt.uri_path: 

391 raise KeyError() 

392 

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

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

395 while path: 

396 if path in self._subsites: 

397 res = self._subsites[path] 

398 if remainder == [""]: 

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

400 remainder = [] 

401 stripped = request.copy(uri_path=remainder) 

402 stripped._original_request_uri = original_request_uri 

403 return res, stripped 

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

405 path = path[:-1] 

406 raise KeyError() 

407 

408 async def render(self, request): 

409 try: 

410 child, subrequest = self._find_child_and_pathstripped_message(request) 

411 except KeyError: 

412 raise error.NotFound() 

413 else: 

414 return await child.render(subrequest) 

415 

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

417 try: 

418 child, subrequest = self._find_child_and_pathstripped_message(request) 

419 except KeyError: 

420 return 

421 

422 try: 

423 await child.add_observation(subrequest, serverobservation) 

424 except AttributeError: 

425 pass 

426 

427 def add_resource(self, path, resource): 

428 if isinstance(path, str): 

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

430 if isinstance(resource, PathCapable): 

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

432 else: 

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

434 

435 def remove_resource(self, path): 

436 try: 

437 del self._subsites[tuple(path)] 

438 except KeyError: 

439 del self._resources[tuple(path)] 

440 

441 def get_resources_as_linkheader(self): 

442 links = [] 

443 

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

445 if hasattr(resource, "get_link_description"): 

446 details = resource.get_link_description() 

447 else: 

448 details = {} 

449 if details is None: 

450 continue 

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

452 

453 links.append(lh) 

454 

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

456 if hasattr(resource, "get_resources_as_linkheader"): 

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

458 links.append( 

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

460 ) 

461 return LinkFormat(links) 

462 

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

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

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

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

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

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

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

470 if request.request.opt.uri_path: 

471 # Conflicting options 

472 raise error.BadOption() 

473 try: 

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

475 request.request.opt.uri_path_abbrev 

476 ] 

477 except KeyError: 

478 # Unknown option 

479 raise error.BadOption() from None 

480 request.request.opt.uri_path_abbrev = None 

481 

482 try: 

483 child, subrequest = self._find_child_and_pathstripped_message( 

484 request.request 

485 ) 

486 except KeyError: 

487 raise error.NotFound() 

488 else: 

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

490 # It probably is. 

491 request.request = subrequest 

492 return await child.render_to_pipe(request)