Coverage for aiocoap/resource.py: 80%

199 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-16 16:09 +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 . import numbers 

34from .pipe import Pipe 

35 

36def hashing_etag(request, response): 

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

38 on the payload's hash value 

39 

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

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

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

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

44 the payload. 

45 

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

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

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

49 cases. 

50 

51 >>> from aiocoap import * 

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

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

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

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

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

57 >>> hashing_etag(req, resp) 

58 >>> resp # doctest: +ELLIPSIS 

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

60 """ 

61 

62 if response.code != numbers.codes.CONTENT and response.code is not None: 

63 return 

64 

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

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

67 response.code = numbers.codes.VALID 

68 response.payload = b'' 

69 

70class _ExposesWellknownAttributes: 

71 def get_link_description(self): 

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

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

74 ret = {} 

75 if hasattr(self, 'ct'): 

76 ret['ct'] = str(self.ct) 

77 if hasattr(self, 'rt'): 

78 ret['rt'] = self.rt 

79 if hasattr(self, 'if_'): 

80 ret['if'] = self.if_ 

81 return ret 

82 

83class Resource(_ExposesWellknownAttributes, interfaces.Resource): 

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

85 interface 

86 

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

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

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

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

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

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

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

94 set. 

95 

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

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

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

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

100 comprehensive dictionary, and resources can be hidden completely by 

101 returning None. 

102 """ 

103 

104 async def needs_blockwise_assembly(self, request): 

105 return True 

106 

107 async def render(self, request): 

108 if not request.code.is_request(): 

109 raise error.UnsupportedMethod() 

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

111 if not m: 

112 raise error.UnallowedMethod() 

113 

114 response = await m(request) 

115 

116 if response is message.NoResponse: 

117 warnings.warn("Returning NoResponse is deprecated, please return a" 

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

119 DeprecationWarning) 

120 response = message.Message(no_response=26) 

121 

122 if response.code is None: 

123 if request.code in (numbers.codes.GET, numbers.codes.FETCH): 

124 response_default = numbers.codes.CONTENT 

125 elif request.code == numbers.codes.DELETE: 

126 response_default = numbers.codes.DELETED 

127 else: 

128 response_default = numbers.codes.CHANGED 

129 response.code = response_default 

130 

131 if response.opt.no_response is None: 

132 response.opt.no_response = request.opt.no_response 

133 

134 return response 

135 

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

137 # Silence the deprecation warning 

138 if isinstance(self, interfaces.ObservableResource): 

139 # See interfaces.Resource.render_to_pipe 

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

141 return await interfaces.Resource._render_to_pipe(self, request) 

142 

143class ObservableResource(Resource, interfaces.ObservableResource): 

144 def __init__(self): 

145 super(ObservableResource, self).__init__() 

146 self._observations = set() 

147 

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

149 self._observations.add(serverobservation) 

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

151 self._observations.remove(serverobservation) 

152 self.update_observation_count(len(self._observations)) 

153 serverobservation.accept(_cancel) 

154 self.update_observation_count(len(self._observations)) 

155 

156 def update_observation_count(self, newcount): 

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

158 on the resource changes.""" 

159 

160 def updated_state(self, response=None): 

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

162 should be sent to observers.""" 

163 

164 for o in self._observations: 

165 o.trigger(response) 

166 

167 def get_link_description(self): 

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

169 link['obs'] = None 

170 return link 

171 

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

173 # Silence the deprecation warning 

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

175 

176def link_format_to_message(request, linkformat, 

177 default_ct=numbers.ContentFormat.LINKFORMAT): 

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

179 suitable conent format from a given request. 

180 

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

182 

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

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

185 

186 ct = request.opt.accept 

187 if ct is None: 

188 ct = default_ct 

189 

190 if ct == numbers.ContentFormat.LINKFORMAT: 

191 payload = str(linkformat).encode('utf8') 

192 else: 

193 return message.Message(code=numbers.NOT_ACCEPTABLE) 

194 

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

196 

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

198# link_format_to_message as their final step in the request handler 

199link_format_to_message.supported_ct = " ".join(str(int(x)) for x in ( 

200 numbers.ContentFormat.LINKFORMAT, 

201 )) 

202 

203class WKCResource(Resource): 

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

205 

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

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

208 

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

210 constructor; typically, that function would be a bound 

211 Site.get_resources_as_linkheader() method. 

212 

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

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

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

216 

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

218 

219 ct = link_format_to_message.supported_ct 

220 

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

222 super().__init__(**kwargs) 

223 self.listgenerator = listgenerator 

224 self.impl_info = impl_info 

225 

226 async def render_get(self, request): 

227 links = self.listgenerator() 

228 

229 if self.impl_info is not None: 

230 from .util.linkformat import Link 

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

232 

233 filters = [] 

234 for q in request.opt.uri_query: 

235 try: 

236 k, v = q.split('=', 1) 

237 except ValueError: 

238 continue # no =, not a relevant filter 

239 

240 if v.endswith('*'): 

241 def matchexp(x, v=v): 

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

243 else: 

244 def matchexp(x, v=v): 

245 return x == v 

246 

247 if k in ('rt', 'if', 'ct'): 

248 filters.append(lambda link: any(matchexp(part) for part in (" ".join(getattr(link, k, ()))).split(" "))) 

249 elif k in ('href',): # x.href is single valued 

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

251 else: 

252 filters.append(lambda link: any(matchexp(part) for part in getattr(link, k, ()))) 

253 

254 while filters: 

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

256 links.links = list(links.links) 

257 

258 response = link_format_to_message(request, links) 

259 

260 if request.opt.uri_query and not links.links and \ 

261 request.remote.is_multicast_locally: 

262 if request.opt.no_response is None: 

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

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

265 # 2.xx" option. 

266 response.opt.no_response = 0x02 

267 

268 return response 

269 

270class PathCapable: 

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

272 option, and can thus be given requests for :meth:`.render`-ing that 

273 contain a uri_path""" 

274 

275class Site(interfaces.ObservableResource, PathCapable): 

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

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

278 server. 

279 

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

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

282 

283 For example, the site at 

284 

285 >>> site = Site() 

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

287 

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

289 

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

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

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

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

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

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

296 single slash after the the given path part. 

297 

298 For example, 

299 

300 >>> batch = Site() 

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

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

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

304 >>> s = Site() 

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

306 

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

308 </batch/light2> and </batch/>. 

309 

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

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

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

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

314 resources from the site. 

315 """ 

316 

317 def __init__(self): 

318 self._resources = {} 

319 self._subsites = {} 

320 

321 async def needs_blockwise_assembly(self, request): 

322 try: 

323 child, subrequest = self._find_child_and_pathstripped_message(request) 

324 except KeyError: 

325 return True 

326 else: 

327 return await child.needs_blockwise_assembly(subrequest) 

328 

329 def _find_child_and_pathstripped_message(self, request): 

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

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

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

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

334 KeyError. 

335 

336 While producing stripped messages, this adds a ._original_request_uri 

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

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

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

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

341 

342 original_request_uri = getattr(request, '_original_request_uri', 

343 request.get_request_uri(local_is_server=True)) 

344 

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

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

347 stripped._original_request_uri = original_request_uri 

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

349 

350 if not request.opt.uri_path: 

351 raise KeyError() 

352 

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

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

355 while path: 

356 if path in self._subsites: 

357 res = self._subsites[path] 

358 if remainder == [""]: 

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

360 remainder = [] 

361 stripped = request.copy(uri_path=remainder) 

362 stripped._original_request_uri = original_request_uri 

363 return res, stripped 

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

365 path = path[:-1] 

366 raise KeyError() 

367 

368 async def render(self, request): 

369 try: 

370 child, subrequest = self._find_child_and_pathstripped_message(request) 

371 except KeyError: 

372 raise error.NotFound() 

373 else: 

374 return await child.render(subrequest) 

375 

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

377 try: 

378 child, subrequest = self._find_child_and_pathstripped_message(request) 

379 except KeyError: 

380 return 

381 

382 try: 

383 await child.add_observation(subrequest, serverobservation) 

384 except AttributeError: 

385 pass 

386 

387 def add_resource(self, path, resource): 

388 if isinstance(path, str): 

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

390 if isinstance(resource, PathCapable): 

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

392 else: 

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

394 

395 def remove_resource(self, path): 

396 try: 

397 del self._subsites[tuple(path)] 

398 except KeyError: 

399 del self._resources[tuple(path)] 

400 

401 def get_resources_as_linkheader(self): 

402 from .util.linkformat import Link, LinkFormat 

403 

404 links = [] 

405 

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

407 if hasattr(resource, "get_link_description"): 

408 details = resource.get_link_description() 

409 else: 

410 details = {} 

411 if details is None: 

412 continue 

413 lh = Link('/' + '/'.join(path), **details) 

414 

415 links.append(lh) 

416 

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

418 if hasattr(resource, "get_resources_as_linkheader"): 

419 for l in resource.get_resources_as_linkheader().links: 

420 links.append(Link('/' + '/'.join(path) + l.href, l.attr_pairs)) 

421 return LinkFormat(links) 

422 

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

424 try: 

425 child, subrequest = self._find_child_and_pathstripped_message(request.request) 

426 except KeyError: 

427 raise error.NotFound() 

428 else: 

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

430 # It probably is. 

431 request.request = subrequest 

432 return await child.render_to_pipe(request)