Coverage for aiocoap/resource.py: 80%

198 statements  

« prev     ^ index     » next       coverage.py v7.6.3, created at 2024-10-15 22:10 +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 links = self.listgenerator() 

247 

248 if self.impl_info is not None: 

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

250 

251 filters = [] 

252 for q in request.opt.uri_query: 

253 try: 

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

255 except ValueError: 

256 continue # no =, not a relevant filter 

257 

258 if v.endswith("*"): 

259 

260 def matchexp(x, v=v): 

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

262 else: 

263 

264 def matchexp(x, v=v): 

265 return x == v 

266 

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

268 filters.append( 

269 lambda link: any( 

270 matchexp(part) 

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

272 ) 

273 ) 

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

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

276 else: 

277 filters.append( 

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

279 ) 

280 

281 while filters: 

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

283 links.links = list(links.links) 

284 

285 response = link_format_to_message(request, links) 

286 

287 if ( 

288 request.opt.uri_query 

289 and not links.links 

290 and request.remote.is_multicast_locally 

291 ): 

292 if request.opt.no_response is None: 

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

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

295 # 2.xx" option. 

296 response.opt.no_response = 0x02 

297 

298 return response 

299 

300 

301class PathCapable: 

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

303 option, and can thus be given requests for 

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

305 

306 

307class Site(interfaces.ObservableResource, PathCapable): 

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

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

310 server. 

311 

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

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

314 

315 For example, the site at 

316 

317 >>> site = Site() 

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

319 

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

321 

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

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

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

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

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

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

328 single slash after the the given path part. 

329 

330 For example, 

331 

332 >>> batch = Site() 

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

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

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

336 >>> s = Site() 

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

338 

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

340 </batch/light2> and </batch/>. 

341 

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

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

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

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

346 resources from the site. 

347 """ 

348 

349 def __init__(self): 

350 self._resources = {} 

351 self._subsites = {} 

352 

353 async def needs_blockwise_assembly(self, request): 

354 try: 

355 child, subrequest = self._find_child_and_pathstripped_message(request) 

356 except KeyError: 

357 return True 

358 else: 

359 return await child.needs_blockwise_assembly(subrequest) 

360 

361 def _find_child_and_pathstripped_message(self, request): 

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

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

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

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

366 KeyError. 

367 

368 While producing stripped messages, this adds a ._original_request_uri 

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

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

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

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

373 

374 original_request_uri = getattr( 

375 request, 

376 "_original_request_uri", 

377 request.get_request_uri(local_is_server=True), 

378 ) 

379 

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

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

382 stripped._original_request_uri = original_request_uri 

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

384 

385 if not request.opt.uri_path: 

386 raise KeyError() 

387 

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

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

390 while path: 

391 if path in self._subsites: 

392 res = self._subsites[path] 

393 if remainder == [""]: 

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

395 remainder = [] 

396 stripped = request.copy(uri_path=remainder) 

397 stripped._original_request_uri = original_request_uri 

398 return res, stripped 

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

400 path = path[:-1] 

401 raise KeyError() 

402 

403 async def render(self, request): 

404 try: 

405 child, subrequest = self._find_child_and_pathstripped_message(request) 

406 except KeyError: 

407 raise error.NotFound() 

408 else: 

409 return await child.render(subrequest) 

410 

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

412 try: 

413 child, subrequest = self._find_child_and_pathstripped_message(request) 

414 except KeyError: 

415 return 

416 

417 try: 

418 await child.add_observation(subrequest, serverobservation) 

419 except AttributeError: 

420 pass 

421 

422 def add_resource(self, path, resource): 

423 if isinstance(path, str): 

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

425 if isinstance(resource, PathCapable): 

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

427 else: 

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

429 

430 def remove_resource(self, path): 

431 try: 

432 del self._subsites[tuple(path)] 

433 except KeyError: 

434 del self._resources[tuple(path)] 

435 

436 def get_resources_as_linkheader(self): 

437 links = [] 

438 

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

440 if hasattr(resource, "get_link_description"): 

441 details = resource.get_link_description() 

442 else: 

443 details = {} 

444 if details is None: 

445 continue 

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

447 

448 links.append(lh) 

449 

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

451 if hasattr(resource, "get_resources_as_linkheader"): 

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

453 links.append( 

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

455 ) 

456 return LinkFormat(links) 

457 

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

459 try: 

460 child, subrequest = self._find_child_and_pathstripped_message( 

461 request.request 

462 ) 

463 except KeyError: 

464 raise error.NotFound() 

465 else: 

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

467 # It probably is. 

468 request.request = subrequest 

469 return await child.render_to_pipe(request)