Coverage for src/aiocoap/resource.py: 0%

198 statements  

« prev     ^ index     » next       coverage.py v7.7.0, created at 2025-03-20 17:26 +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 .pipe import Pipe 

36from .util.linkformat import Link, LinkFormat 

37 

38 

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

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

41 on the payload's hash value 

42 

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

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

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

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

47 the payload. 

48 

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

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

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

52 cases. 

53 

54 >>> from aiocoap import * 

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

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

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

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

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

60 >>> hashing_etag(req, resp) 

61 >>> resp # doctest: +ELLIPSIS 

62 <aiocoap.Message at ... 2.03 Valid ... 1 option(s)> 

63 """ 

64 

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

66 return 

67 

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

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

70 response.code = Code.VALID 

71 response.payload = b"" 

72 

73 

74class _ExposesWellknownAttributes: 

75 def get_link_description(self): 

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

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

78 ret = {} 

79 if hasattr(self, "ct"): 

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

81 if hasattr(self, "rt"): 

82 ret["rt"] = self.rt 

83 if hasattr(self, "if_"): 

84 ret["if"] = self.if_ 

85 return ret 

86 

87 

88class Resource(_ExposesWellknownAttributes, interfaces.Resource): 

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

90 interface 

91 

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

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

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

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

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

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

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

99 set. 

100 

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

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

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

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

105 comprehensive dictionary, and resources can be hidden completely by 

106 returning None. 

107 """ 

108 

109 async def needs_blockwise_assembly(self, request): 

110 return True 

111 

112 async def render(self, request): 

113 if not request.code.is_request(): 

114 raise error.UnsupportedMethod() 

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

116 if not m: 

117 raise error.UnallowedMethod() 

118 

119 response = await m(request) 

120 

121 if response is message.NoResponse: 

122 warnings.warn( 

123 "Returning NoResponse is deprecated, please return a" 

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

125 DeprecationWarning, 

126 ) 

127 response = message.Message(no_response=26) 

128 

129 if response.code is None: 

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

131 response_default = Code.CONTENT 

132 elif request.code == Code.DELETE: 

133 response_default = Code.DELETED 

134 else: 

135 response_default = Code.CHANGED 

136 response.code = response_default 

137 

138 if response.opt.no_response is None: 

139 response.opt.no_response = request.opt.no_response 

140 

141 return response 

142 

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

144 # Silence the deprecation warning 

145 if isinstance(self, interfaces.ObservableResource): 

146 # See interfaces.Resource.render_to_pipe 

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

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

149 

150 

151class ObservableResource(Resource, interfaces.ObservableResource): 

152 def __init__(self): 

153 super(ObservableResource, self).__init__() 

154 self._observations = set() 

155 

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

157 self._observations.add(serverobservation) 

158 

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

160 self._observations.remove(serverobservation) 

161 self.update_observation_count(len(self._observations)) 

162 

163 serverobservation.accept(_cancel) 

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

165 

166 def update_observation_count(self, newcount): 

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

168 on the resource changes.""" 

169 

170 def updated_state(self, response=None): 

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

172 should be sent to observers.""" 

173 

174 for o in self._observations: 

175 o.trigger(response) 

176 

177 def get_link_description(self): 

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

179 link["obs"] = None 

180 return link 

181 

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

183 # Silence the deprecation warning 

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

185 

186 

187def link_format_to_message( 

188 request: message.Message, 

189 linkformat: LinkFormat, 

190 default_ct=ContentFormat.LINKFORMAT, 

191) -> message.Message: 

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

193 suitable conent format from a given request. 

194 

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

196 

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

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

199 

200 ct = request.opt.accept 

201 if ct is None: 

202 ct = default_ct 

203 

204 if ct == ContentFormat.LINKFORMAT: 

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

206 else: 

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

208 

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

210 

211 

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

213# link_format_to_message as their final step in the request handler 

214# 

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

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

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

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

219) 

220 

221 

222class WKCResource(Resource): 

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

224 

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

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

227 

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

229 constructor; typically, that function would be a bound 

230 Site.get_resources_as_linkheader() method. 

231 

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

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

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

235 

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

237 

238 ct = link_format_to_message.supported_ct # type: ignore 

239 

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

241 super().__init__(**kwargs) 

242 self.listgenerator = listgenerator 

243 self.impl_info = impl_info 

244 

245 async def render_get(self, request): 

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

247 # adjustments may be due in oscore_sitewrapper's 

248 # get_resources_as_linkheader 

249 links = self.listgenerator() 

250 

251 if self.impl_info is not None: 

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

253 

254 filters = [] 

255 for q in request.opt.uri_query: 

256 try: 

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

258 except ValueError: 

259 continue # no =, not a relevant filter 

260 

261 if v.endswith("*"): 

262 

263 def matchexp(x, v=v): 

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

265 else: 

266 

267 def matchexp(x, v=v): 

268 return x == v 

269 

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

271 filters.append( 

272 lambda link: any( 

273 matchexp(part) 

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

275 ) 

276 ) 

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

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

279 else: 

280 filters.append( 

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

282 ) 

283 

284 while filters: 

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

286 links.links = list(links.links) 

287 

288 response = link_format_to_message(request, links) 

289 

290 if ( 

291 request.opt.uri_query 

292 and not links.links 

293 and request.remote.is_multicast_locally 

294 ): 

295 if request.opt.no_response is None: 

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

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

298 # 2.xx" option. 

299 response.opt.no_response = 0x02 

300 

301 return response 

302 

303 

304class PathCapable: 

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

306 option, and can thus be given requests for 

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

308 

309 

310class Site(interfaces.ObservableResource, PathCapable): 

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

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

313 server. 

314 

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

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

317 

318 For example, the site at 

319 

320 >>> site = Site() 

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

322 

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

324 

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

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

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

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

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

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

331 single slash after the the given path part. 

332 

333 For example, 

334 

335 >>> batch = Site() 

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

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

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

339 >>> s = Site() 

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

341 

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

343 </batch/light2> and </batch/>. 

344 

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

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

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

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

349 resources from the site. 

350 """ 

351 

352 def __init__(self): 

353 self._resources = {} 

354 self._subsites = {} 

355 

356 async def needs_blockwise_assembly(self, request): 

357 try: 

358 child, subrequest = self._find_child_and_pathstripped_message(request) 

359 except KeyError: 

360 return True 

361 else: 

362 return await child.needs_blockwise_assembly(subrequest) 

363 

364 def _find_child_and_pathstripped_message(self, request): 

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

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

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

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

369 KeyError. 

370 

371 While producing stripped messages, this adds a ._original_request_uri 

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

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

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

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

376 

377 original_request_uri = getattr( 

378 request, 

379 "_original_request_uri", 

380 request.get_request_uri(local_is_server=True), 

381 ) 

382 

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

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

385 stripped._original_request_uri = original_request_uri 

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

387 

388 if not request.opt.uri_path: 

389 raise KeyError() 

390 

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

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

393 while path: 

394 if path in self._subsites: 

395 res = self._subsites[path] 

396 if remainder == [""]: 

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

398 remainder = [] 

399 stripped = request.copy(uri_path=remainder) 

400 stripped._original_request_uri = original_request_uri 

401 return res, stripped 

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

403 path = path[:-1] 

404 raise KeyError() 

405 

406 async def render(self, request): 

407 try: 

408 child, subrequest = self._find_child_and_pathstripped_message(request) 

409 except KeyError: 

410 raise error.NotFound() 

411 else: 

412 return await child.render(subrequest) 

413 

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

415 try: 

416 child, subrequest = self._find_child_and_pathstripped_message(request) 

417 except KeyError: 

418 return 

419 

420 try: 

421 await child.add_observation(subrequest, serverobservation) 

422 except AttributeError: 

423 pass 

424 

425 def add_resource(self, path, resource): 

426 if isinstance(path, str): 

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

428 if isinstance(resource, PathCapable): 

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

430 else: 

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

432 

433 def remove_resource(self, path): 

434 try: 

435 del self._subsites[tuple(path)] 

436 except KeyError: 

437 del self._resources[tuple(path)] 

438 

439 def get_resources_as_linkheader(self): 

440 links = [] 

441 

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

443 if hasattr(resource, "get_link_description"): 

444 details = resource.get_link_description() 

445 else: 

446 details = {} 

447 if details is None: 

448 continue 

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

450 

451 links.append(lh) 

452 

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

454 if hasattr(resource, "get_resources_as_linkheader"): 

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

456 links.append( 

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

458 ) 

459 return LinkFormat(links) 

460 

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

462 try: 

463 child, subrequest = self._find_child_and_pathstripped_message( 

464 request.request 

465 ) 

466 except KeyError: 

467 raise error.NotFound() 

468 else: 

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

470 # It probably is. 

471 request.request = subrequest 

472 return await child.render_to_pipe(request)