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 hashlib 31import warnings 32 33from . import message 34from . import meta 35from . import error 36from . import interfaces 37from . import numbers 38 39def hashing_etag(request, response): 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 != numbers.codes.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 = numbers.codes.VALID 71 response.payload = b'' 72 73class _ExposesWellknownAttributes: 74 def get_link_description(self): 75 ## FIXME which formats are acceptable, and how much escaping and 76 # list-to-separated-string conversion needs to happen here 77 ret = {} 78 if hasattr(self, 'ct'): 79 ret['ct'] = str(self.ct) 80 if hasattr(self, 'rt'): 81 ret['rt'] = self.rt 82 if hasattr(self, 'if_'): 83 ret['if'] = self.if_ 84 return ret 85 86class Resource(_ExposesWellknownAttributes, interfaces.Resource): 87 """Simple base implementation of the :class:`interfaces.Resource` 88 interface 89 90 The render method delegates content creation to ``render_$method`` methods 91 (``render_get``, ``render_put`` etc), and responds appropriately to 92 unsupported methods. Those messages may return messages without a response 93 code, the default render method will set an appropriate successful code 94 ("Content" for GET/FETCH, "Deleted" for DELETE, "Changed" for anything 95 else). The render method will also fill in the request's no_response code 96 into the response (see :meth:`.interfaces.Resource.render`) if none was 97 set. 98 99 Moreover, this class provides a ``get_link_description`` method as used by 100 .well-known/core to expose a resource's ``.ct``, ``.rt`` and ``.if_`` 101 (alternative name for ``if`` as that's a Python keyword) attributes. 102 Details can be added by overriding the method to return a more 103 comprehensive dictionary, and resources can be hidden completely by 104 returning None. 105 """ 106 107 async def needs_blockwise_assembly(self, request): 108 return True 109 110 async def render(self, request): 111 if not request.code.is_request(): 112 raise error.UnsupportedMethod() 113 m = getattr(self, 'render_%s' % str(request.code).lower(), None) 114 if not m: 115 raise error.UnallowedMethod() 116 117 response = await m(request) 118 119 if response is message.NoResponse: 120 warnings.warn("Returning NoResponse is deprecated, please return a" 121 " regular response with a no_response option set.", 122 DeprecationWarning) 123 response = message.Message(no_response=26) 124 125 if response.code is None: 126 if request.code in (numbers.codes.GET, numbers.codes.FETCH): 127 response_default = numbers.codes.CONTENT 128 elif request.code == numbers.codes.DELETE: 129 response_default = numbers.codes.DELETED 130 else: 131 response_default = numbers.codes.CHANGED 132 response.code = response_default 133 134 if response.opt.no_response is None: 135 response.opt.no_response = request.opt.no_response 136 137 return response 138 139class ObservableResource(Resource, interfaces.ObservableResource): 140 def __init__(self): 141 super(ObservableResource, self).__init__() 142 self._observations = set() 143 144 async def add_observation(self, request, serverobservation): 145 self._observations.add(serverobservation) 146 def _cancel(self=self, obs=serverobservation): 147 self._observations.remove(serverobservation) 148 self.update_observation_count(len(self._observations)) 149 serverobservation.accept(_cancel) 150 self.update_observation_count(len(self._observations)) 151 152 def update_observation_count(self, newcount): 153 """Hook into this method to be notified when the number of observations 154 on the resource changes.""" 155 156 def updated_state(self, response=None): 157 """Call this whenever the resource was updated, and a notification 158 should be sent to observers.""" 159 160 for o in self._observations: 161 o.trigger(response) 162 163 def get_link_description(self): 164 link = super(ObservableResource, self).get_link_description() 165 link['obs'] = None 166 return link 167 168def link_format_to_message(request, linkformat, 169 default_ct=numbers.media_types_rev['application/link-format']): 170 """Given a LinkFormat object, render it to a response message, picking a 171 suitable conent format from a given request. 172 173 It returns a Not Acceptable response if something unsupported was queried. 174 175 It makes no attempt to modify the URI reference literals encoded in the 176 LinkFormat object; they have to be suitably prepared by the caller.""" 177 178 ct = request.opt.accept 179 if ct is None: 180 ct = default_ct 181 182 if ct == numbers.media_types_rev['application/link-format']: 183 payload = str(linkformat).encode('utf8') 184 elif ct == numbers.media_types_rev['application/link-format+cbor']: 185 payload = linkformat.as_cbor_bytes() 186 elif ct == numbers.media_types_rev['application/link-format+json']: 187 payload = linkformat.as_json_string().encode('utf8') 188 else: 189 return message.Message(code=numbers.NOT_ACCEPTABLE) 190 191 return message.Message(payload=payload, content_format=ct) 192 193# Convenience attribute to set as ct on resources that use 194# link_format_to_message as their final step in the request handler 195link_format_to_message.supported_ct = " ".join(str(x) for x in ( 196 numbers.media_types_rev['application/link-format'], 197 numbers.media_types_rev['application/link-format+cbor'], 198 numbers.media_types_rev['application/link-format+json'], 199 )) 200 201class WKCResource(Resource): 202 """Read-only dynamic resource list, suitable as .well-known/core. 203 204 This resource renders a link_header.LinkHeader object (which describes a 205 collection of resources) as application/link-format (RFC 6690). 206 207 The list to be rendered is obtained from a function passed into the 208 constructor; typically, that function would be a bound 209 Site.get_resources_as_linkheader() method. 210 211 This resource also provides server `implementation information link`_; 212 server authors are invited to override this by passing an own URI as the 213 `impl_info` parameter, and can disable it by passing None. 214 215 .. _`implementation information link`: https://tools.ietf.org/html/draft-bormann-t2trg-rel-impl-00""" 216 217 ct = link_format_to_message.supported_ct 218 219 def __init__(self, listgenerator, impl_info=meta.library_uri): 220 self.listgenerator = listgenerator 221 self.impl_info = impl_info 222 223 async def render_get(self, request): 224 links = self.listgenerator() 225 226 if self.impl_info is not None: 227 from .util.linkformat import Link 228 links.links = links.links + [Link(href=self.impl_info, rel="impl-info")] 229 230 filters = [] 231 for q in request.opt.uri_query: 232 try: 233 k, v = q.split('=', 1) 234 except ValueError: 235 continue # no =, not a relevant filter 236 237 if v.endswith('*'): 238 matchexp = lambda x: x.startswith(v[:-1]) 239 else: 240 matchexp = lambda x: x == v 241 242 if k in ('rt', 'if', 'ct'): 243 filters.append(lambda link: any(matchexp(part) for part in (" ".join(getattr(link, k, ()))).split(" "))) 244 elif k in ('href',): # x.href is single valued 245 filters.append(lambda link: matchexp(getattr(link, k))) 246 else: 247 filters.append(lambda link: any(matchexp(part) for part in getattr(link, k, ()))) 248 249 while filters: 250 links.links = filter(filters.pop(), links.links) 251 links.links = list(links.links) 252 253 response = link_format_to_message(request, links) 254 255 if request.opt.uri_query and not links.links and \ 256 request.remote.is_multicast_locally: 257 if request.opt.no_response is None: 258 # If the filter does not match, multicast requests should not 259 # be responded to -- that's equivalent to a "no_response on 260 # 2.xx" option. 261 response.opt.no_response = 0x02 262 263 return response 264 265class PathCapable: 266 """Class that indicates that a resource promises to parse the uri_path 267 option, and can thus be given requests for :meth:`.render`\ ing that 268 contain a uri_path""" 269 270class Site(interfaces.ObservableResource, PathCapable): 271 """Typical root element that gets passed to a :class:`Context` and contains 272 all the resources that can be found when the endpoint gets accessed as a 273 server. 274 275 This provides easy registration of statical resources. Add resources at 276 absolute locations using the :meth:`.add_resource` method. 277 278 For example, the site at 279 280 >>> site = Site() 281 >>> site.add_resource(["hello"], Resource()) 282 283 will have requests to </hello> rendered by the new resource. 284 285 You can add another Site (or another instance of :class:`PathCapable`) as 286 well, those will be nested and integrally reported in a WKCResource. The 287 path of a site should not end with an empty string (ie. a slash in the URI) 288 -- the child site's own root resource will then have the trailing slash 289 address. Subsites can not have link-header attributes on their own (eg. 290 `rt`) and will never respond to a request that does not at least contain a 291 single slash after the the given path part. 292 293 For example, 294 295 >>> batch = Site() 296 >>> batch.add_resource(["light1"], Resource()) 297 >>> batch.add_resource(["light2"], Resource()) 298 >>> batch.add_resource([], Resource()) 299 >>> s = Site() 300 >>> s.add_resource(["batch"], batch) 301 302 will have the three created resources rendered at </batch/light1>, 303 </batch/light2> and </batch/>. 304 305 If it is necessary to respond to requests to </batch> or report its 306 attributes in .well-known/core in addition to the above, a non-PathCapable 307 resource can be added with the same path. This is usually considered an odd 308 design, not fully supported, and for example doesn't support removal of 309 resources from the site. 310 """ 311 312 def __init__(self): 313 self._resources = {} 314 self._subsites = {} 315 316 async def needs_blockwise_assembly(self, request): 317 try: 318 child, subrequest = self._find_child_and_pathstripped_message(request) 319 except KeyError: 320 return True 321 else: 322 return await child.needs_blockwise_assembly(subrequest) 323 324 def _find_child_and_pathstripped_message(self, request): 325 """Given a request, find the child that will handle it, and strip all 326 path components from the request that are covered by the child's 327 position within the site. Returns the child and a request with a path 328 shortened by the components in the child's path, or raises a 329 KeyError. 330 331 While producing stripped messages, this adds a ._original_request_uri 332 attribute to the messages which holds the request URI before the 333 stripping is started. That allows internal components to access the 334 original URI until there is a variation of the request API that allows 335 accessing this in a better usable way.""" 336 337 original_request_uri = getattr(request, '_original_request_uri', 338 request.get_request_uri(local_is_server=True)) 339 340 if request.opt.uri_path in self._resources: 341 stripped = request.copy(uri_path=()) 342 stripped._original_request_uri = original_request_uri 343 return self._resources[request.opt.uri_path], stripped 344 345 if not request.opt.uri_path: 346 raise KeyError() 347 348 remainder = [request.opt.uri_path[-1]] 349 path = request.opt.uri_path[:-1] 350 while path: 351 if path in self._subsites: 352 res = self._subsites[path] 353 if remainder == [""]: 354 # sub-sites should see their root resource like sites 355 remainder = [] 356 stripped = request.copy(uri_path=remainder) 357 stripped._original_request_uri = original_request_uri 358 return res, stripped 359 remainder.insert(0, path[-1]) 360 path = path[:-1] 361 raise KeyError() 362 363 async def render(self, request): 364 try: 365 child, subrequest = self._find_child_and_pathstripped_message(request) 366 except KeyError: 367 raise error.NotFound() 368 else: 369 return await child.render(subrequest) 370 371 async def add_observation(self, request, serverobservation): 372 try: 373 child, subrequest = self._find_child_and_pathstripped_message(request) 374 except KeyError: 375 return 376 377 try: 378 await child.add_observation(subrequest, serverobservation) 379 except AttributeError: 380 pass 381 382 def add_resource(self, path, resource): 383 if isinstance(path, str): 384 raise ValueError("Paths should be tuples or lists of strings") 385 if isinstance(resource, PathCapable): 386 self._subsites[tuple(path)] = resource 387 else: 388 self._resources[tuple(path)] = resource 389 390 def remove_resource(self, path): 391 try: 392 del self._subsites[tuple(path)] 393 except KeyError: 394 del self._resources[tuple(path)] 395 396 def get_resources_as_linkheader(self): 397 from .util.linkformat import Link, LinkFormat 398 399 links = [] 400 401 for path, resource in self._resources.items(): 402 if hasattr(resource, "get_link_description"): 403 details = resource.get_link_description() 404 else: 405 details = {} 406 if details is None: 407 continue 408 lh = Link('/' + '/'.join(path), **details) 409 410 links.append(lh) 411 412 for path, resource in self._subsites.items(): 413 if hasattr(resource, "get_resources_as_linkheader"): 414 for l in resource.get_resources_as_linkheader().links: 415 links.append(Link('/' + '/'.join(path) + l.href, l.attr_pairs)) 416 return LinkFormat(links) 417