Coverage for aiocoap/resource.py: 86%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

229 statements  

1# This file is part of the Python aiocoap library project. 

2# 

3# Copyright (c) 2012-2014 Maciej Wasilak <http://sixpinetrees.blogspot.com/>, 

4# 2013-2014 Christian Amsüss <c.amsuess@energyharvesting.at> 

5# 

6# aiocoap is free software, this file is published under the MIT license as 

7# described in the accompanying LICENSE file. 

8 

9"""Basic resource implementations 

10 

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

12 

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

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

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

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

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

18:class:`Site` class). 

19 

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

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

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

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

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

25 

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

27dispatch requests based on the Uri-Path header. 

28""" 

29 

30import asyncio 

31import hashlib 

32import warnings 

33 

34from . import message 

35from . import meta 

36from . import error 

37from . import interfaces 

38from . import numbers 

39from .plumbingrequest import PlumbingRequest 

40from .protocol import ServerObservation 

41 

42def hashing_etag(request, response): 

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

44 on the payload's hash value 

45 

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

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

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

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

50 the payload. 

51 

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

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

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

55 cases. 

56 

57 >>> from aiocoap import * 

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

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

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

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

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

63 >>> hashing_etag(req, resp) 

64 >>> resp # doctest: +ELLIPSIS 

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

66 """ 

67 

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

69 return 

70 

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

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

73 response.code = numbers.codes.VALID 

74 response.payload = b'' 

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 

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 if not request.code.is_request(): 

115 raise error.UnsupportedMethod() 

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

117 if not m: 

118 raise error.UnallowedMethod() 

119 

120 response = await m(request) 

121 

122 if response is message.NoResponse: 

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

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

125 DeprecationWarning) 

126 response = message.Message(no_response=26) 

127 

128 if response.code is None: 

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

130 response_default = numbers.codes.CONTENT 

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

132 response_default = numbers.codes.DELETED 

133 else: 

134 response_default = numbers.codes.CHANGED 

135 response.code = response_default 

136 

137 if response.opt.no_response is None: 

138 response.opt.no_response = request.opt.no_response 

139 

140 return response 

141 

142 async def can_render_to_plumbingrequest(self, request): 

143 # Should over time become True for all cases 

144 return not await self.needs_blockwise_assembly(request) 

145 

146 async def render_to_plumbingrequest(self, request: PlumbingRequest): 

147 request.add_response(await self.render(request.request), is_last=True) 

148 

149class ObservableResource(Resource, interfaces.ObservableResource): 

150 def __init__(self): 

151 super(ObservableResource, self).__init__() 

152 self._observations = set() 

153 

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

155 self._observations.add(serverobservation) 

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

157 self._observations.remove(serverobservation) 

158 self.update_observation_count(len(self._observations)) 

159 serverobservation.accept(_cancel) 

160 self.update_observation_count(len(self._observations)) 

161 

162 def update_observation_count(self, newcount): 

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

164 on the resource changes.""" 

165 

166 def updated_state(self, response=None): 

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

168 should be sent to observers.""" 

169 

170 for o in self._observations: 

171 o.trigger(response) 

172 

173 def get_link_description(self): 

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

175 link['obs'] = None 

176 return link 

177 

178 async def render_to_plumbingrequest(self, pr): 

179 if pr.request.opt.observe != 0: 

180 return await super().render_to_plumbingrequest(pr) 

181 

182 servobs = ServerObservation() 

183 await self.add_observation(pr.request, servobs) 

184 

185 try: 

186 first_response = await self.render(pr.request) 

187 

188 if not servobs._accepted or servobs._early_deregister: 

189 pr.add_response(first_response, is_last=True) 

190 return 

191 

192 # FIXME: observation numbers should actually not be per 

193 # asyncio.task, but per (remote, token). if a client renews an 

194 # observation (possibly with a new ETag or whatever is deemed 

195 # legal), the new observation events should still carry larger 

196 # numbers. (if they did not, the client might be tempted to discard 

197 # them). 

198 first_response.opt.observe = next_observation_number = 0 

199 pr.add_response(first_response, is_last=False) 

200 

201 while True: 

202 await servobs._trigger 

203 # if you wonder why the lines around this are not just `response = 

204 # await servobs._trigger`, have a look at the 'double' tests in 

205 # test_observe.py: A later triggering could have replaced 

206 # servobs._trigger in the meantime. 

207 response = servobs._trigger.result() 

208 servobs._trigger = asyncio.get_running_loop().create_future() 

209 

210 if response is None: 

211 response = await self.render(pr.request) 

212 

213 is_last = servobs._late_deregister or not response.code.is_successful() 

214 if not is_last: 

215 next_observation_number += 1 

216 response.opt.observe = next_observation_number 

217 

218 pr.add_response(response, is_last=is_last) 

219 

220 if is_last: 

221 return 

222 finally: 

223 servobs._cancellation_callback() 

224 

225def link_format_to_message(request, linkformat, 

226 default_ct=numbers.ContentFormat.LINKFORMAT): 

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

228 suitable conent format from a given request. 

229 

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

231 

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

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

234 

235 ct = request.opt.accept 

236 if ct is None: 

237 ct = default_ct 

238 

239 if ct == numbers.ContentFormat.LINKFORMAT: 

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

241 else: 

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

243 

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

245 

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

247# link_format_to_message as their final step in the request handler 

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

249 numbers.ContentFormat.LINKFORMAT, 

250 )) 

251 

252class WKCResource(Resource): 

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

254 

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

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

257 

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

259 constructor; typically, that function would be a bound 

260 Site.get_resources_as_linkheader() method. 

261 

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

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

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

265 

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

267 

268 ct = link_format_to_message.supported_ct 

269 

270 def __init__(self, listgenerator, impl_info=meta.library_uri): 

271 self.listgenerator = listgenerator 

272 self.impl_info = impl_info 

273 

274 async def render_get(self, request): 

275 links = self.listgenerator() 

276 

277 if self.impl_info is not None: 

278 from .util.linkformat import Link 

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

280 

281 filters = [] 

282 for q in request.opt.uri_query: 

283 try: 

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

285 except ValueError: 

286 continue # no =, not a relevant filter 

287 

288 if v.endswith('*'): 

289 def matchexp(x, v=v): 

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

291 else: 

292 def matchexp(x, v=v): 

293 return x == v 

294 

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

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

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

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

299 else: 

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

301 

302 while filters: 

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

304 links.links = list(links.links) 

305 

306 response = link_format_to_message(request, links) 

307 

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

309 request.remote.is_multicast_locally: 

310 if request.opt.no_response is None: 

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

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

313 # 2.xx" option. 

314 response.opt.no_response = 0x02 

315 

316 return response 

317 

318class PathCapable: 

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

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

321 contain a uri_path""" 

322 

323class Site(interfaces.ObservableResource, PathCapable): 

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

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

326 server. 

327 

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

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

330 

331 For example, the site at 

332 

333 >>> site = Site() 

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

335 

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

337 

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

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

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

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

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

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

344 single slash after the the given path part. 

345 

346 For example, 

347 

348 >>> batch = Site() 

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

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

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

352 >>> s = Site() 

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

354 

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

356 </batch/light2> and </batch/>. 

357 

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

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

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

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

362 resources from the site. 

363 """ 

364 

365 def __init__(self): 

366 self._resources = {} 

367 self._subsites = {} 

368 

369 async def needs_blockwise_assembly(self, request): 

370 try: 

371 child, subrequest = self._find_child_and_pathstripped_message(request) 

372 except KeyError: 

373 return True 

374 else: 

375 return await child.needs_blockwise_assembly(subrequest) 

376 

377 def _find_child_and_pathstripped_message(self, request): 

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

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

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

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

382 KeyError. 

383 

384 While producing stripped messages, this adds a ._original_request_uri 

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

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

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

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

389 

390 original_request_uri = getattr(request, '_original_request_uri', 

391 request.get_request_uri(local_is_server=True)) 

392 

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

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

395 stripped._original_request_uri = original_request_uri 

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

397 

398 if not request.opt.uri_path: 

399 raise KeyError() 

400 

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

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

403 while path: 

404 if path in self._subsites: 

405 res = self._subsites[path] 

406 if remainder == [""]: 

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

408 remainder = [] 

409 stripped = request.copy(uri_path=remainder) 

410 stripped._original_request_uri = original_request_uri 

411 return res, stripped 

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

413 path = path[:-1] 

414 raise KeyError() 

415 

416 async def render(self, request): 

417 try: 

418 child, subrequest = self._find_child_and_pathstripped_message(request) 

419 except KeyError: 

420 raise error.NotFound() 

421 else: 

422 return await child.render(subrequest) 

423 

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

425 try: 

426 child, subrequest = self._find_child_and_pathstripped_message(request) 

427 except KeyError: 

428 return 

429 

430 try: 

431 await child.add_observation(subrequest, serverobservation) 

432 except AttributeError: 

433 pass 

434 

435 def add_resource(self, path, resource): 

436 if isinstance(path, str): 

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

438 if isinstance(resource, PathCapable): 

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

440 else: 

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

442 

443 def remove_resource(self, path): 

444 try: 

445 del self._subsites[tuple(path)] 

446 except KeyError: 

447 del self._resources[tuple(path)] 

448 

449 def get_resources_as_linkheader(self): 

450 from .util.linkformat import Link, LinkFormat 

451 

452 links = [] 

453 

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

455 if hasattr(resource, "get_link_description"): 

456 details = resource.get_link_description() 

457 else: 

458 details = {} 

459 if details is None: 

460 continue 

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

462 

463 links.append(lh) 

464 

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

466 if hasattr(resource, "get_resources_as_linkheader"): 

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

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

469 return LinkFormat(links) 

470 

471 async def can_render_to_plumbingrequest(self, request): 

472 try: 

473 child, subrequest = self._find_child_and_pathstripped_message(request) 

474 except KeyError: 

475 # It could by virtue of just raising and the raised errors still 

476 # being handled in the Context -- not doing that yet as the OSCORE 

477 # wrappers would still require the exception handling to run 

478 # through renderable errors. 

479 return False 

480 else: 

481 return await child.can_render_to_plumbingrequest(subrequest) 

482 

483 async def render_to_plumbingrequest(self, request: PlumbingRequest): 

484 try: 

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

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_plumbingrequest(request)