# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors # # SPDX-License-Identifier: MIT """Basic resource implementations A resource in URL / CoAP / REST terminology is the thing identified by a URI. Here, a :class:`.Resource` is the place where server functionality is implemented. In many cases, there exists one persistent Resource object for a given resource (eg. a ``TimeResource()`` is responsible for serving the ``/time`` location). On the other hand, an aiocoap server context accepts only one thing as its serversite, and that is a Resource too (typically of the :class:`Site` class). Resources are most easily implemented by deriving from :class:`.Resource` and implementing ``render_get``, ``render_post`` and similar coroutine methods. Those take a single request message object and must return a :class:`aiocoap.Message` object or raise an :class:`.error.RenderableError` (eg. ``raise UnsupportedMediaType()``). To serve more than one resource on a site, use the :class:`Site` class to dispatch requests based on the Uri-Path header. """ import hashlib import warnings from . import message from . import meta from . import error from . import interfaces from .numbers.contentformat import ContentFormat from .numbers.codes import Code from .numbers import uri_path_abbrev from .pipe import Pipe from .util.linkformat import Link, LinkFormat def hashing_etag(request: message.Message, response: message.Message): """Helper function for render_get handlers that allows them to use ETags based on the payload's hash value Run this on your request and response before returning from render_get; it is safe to use this function with all kinds of responses, it will only act on 2.05 Content messages (and those with no code set, which defaults to that for GET requests). The hash used are the first 8 bytes of the sha1 sum of the payload. Note that this method is not ideal from a server performance point of view (a file server, for example, might want to hash only the stat() result of a file instead of reading it in full), but it saves bandwidth for the simple cases. >>> from aiocoap import * >>> req = Message(code=GET) >>> hash_of_hello = b'\\xaa\\xf4\\xc6\\x1d\\xdc\\xc5\\xe8\\xa2' >>> req.opt.etags = [hash_of_hello] >>> resp = Message(code=CONTENT) >>> resp.payload = b'hello' >>> hashing_etag(req, resp) >>> resp # doctest: +ELLIPSIS """ if response.code != Code.CONTENT and response.code is not None: return response.opt.etag = hashlib.sha1(response.payload).digest()[:8] if request.opt.etags is not None and response.opt.etag in request.opt.etags: response.code = Code.VALID response.payload = b"" class _ExposesWellknownAttributes: def get_link_description(self): # FIXME which formats are acceptable, and how much escaping and # list-to-separated-string conversion needs to happen here ret = {} if hasattr(self, "ct"): ret["ct"] = str(self.ct) if hasattr(self, "rt"): ret["rt"] = self.rt if hasattr(self, "if_"): ret["if"] = self.if_ return ret class Resource(_ExposesWellknownAttributes, interfaces.Resource): """Simple base implementation of the :class:`interfaces.Resource` interface The render method delegates content creation to ``render_$method`` methods (``render_get``, ``render_put`` etc), and responds appropriately to unsupported methods. Those messages may return messages without a response code, the default render method will set an appropriate successful code ("Content" for GET/FETCH, "Deleted" for DELETE, "Changed" for anything else). The render method will also fill in the request's no_response code into the response (see :meth:`.interfaces.Resource.render`) if none was set. Moreover, this class provides a ``get_link_description`` method as used by .well-known/core to expose a resource's ``.ct``, ``.rt`` and ``.if_`` (alternative name for ``if`` as that's a Python keyword) attributes. Details can be added by overriding the method to return a more comprehensive dictionary, and resources can be hidden completely by returning None. """ async def needs_blockwise_assembly(self, request): return True async def render(self, request): assert request.direction is message.Direction.INCOMING if not request.code.is_request(): raise error.UnsupportedMethod() m = getattr(self, "render_%s" % str(request.code).lower(), None) if not m: raise error.UnallowedMethod() response = await m(request) if response is message.NoResponse: warnings.warn( "Returning NoResponse is deprecated, please return a" " regular response with a no_response option set.", DeprecationWarning, ) response = message.Message(no_response=26) if response.code is None: if request.code in (Code.GET, Code.FETCH): response_default = Code.CONTENT elif request.code == Code.DELETE: response_default = Code.DELETED else: response_default = Code.CHANGED response.code = response_default if response.opt.no_response is None: response.opt.no_response = request.opt.no_response return response async def render_to_pipe(self, pipe: Pipe): # Silence the deprecation warning if isinstance(self, interfaces.ObservableResource): # See interfaces.Resource.render_to_pipe return await interfaces.ObservableResource._render_to_pipe(self, pipe) return await interfaces.Resource._render_to_pipe(self, pipe) class ObservableResource(Resource, interfaces.ObservableResource): def __init__(self): super(ObservableResource, self).__init__() self._observations = set() async def add_observation(self, request, serverobservation): self._observations.add(serverobservation) def _cancel(self=self, obs=serverobservation): self._observations.remove(serverobservation) self.update_observation_count(len(self._observations)) serverobservation.accept(_cancel) self.update_observation_count(len(self._observations)) def update_observation_count(self, newcount): """Hook into this method to be notified when the number of observations on the resource changes.""" def updated_state(self, response=None): """Call this whenever the resource was updated, and a notification should be sent to observers.""" for o in self._observations: o.trigger(response) def get_link_description(self): link = super(ObservableResource, self).get_link_description() link["obs"] = None return link async def render_to_pipe(self, request: Pipe): # Silence the deprecation warning return await interfaces.ObservableResource._render_to_pipe(self, request) def link_format_to_message( request: message.Message, linkformat: LinkFormat, default_ct=ContentFormat.LINKFORMAT, ) -> message.Message: """Given a LinkFormat object, render it to a response message, picking a suitable content format from a given request. It returns a Not Acceptable response if something unsupported was queried. It makes no attempt to modify the URI reference literals encoded in the LinkFormat object; they have to be suitably prepared by the caller.""" ct = request.opt.accept if ct is None: ct = default_ct if ct == ContentFormat.LINKFORMAT: payload = str(linkformat).encode("utf8") else: return message.Message(code=Code.NOT_ACCEPTABLE) return message.Message(payload=payload, content_format=ct) # Convenience attribute to set as ct on resources that use # link_format_to_message as their final step in the request handler # # mypy doesn't like attributes on functions, requiring some `type: ignore` for # this, but the alternatives (a __call__able class) have worse docs. link_format_to_message.supported_ct = " ".join( # type: ignore str(int(x)) for x in (ContentFormat.LINKFORMAT,) ) class WKCResource(Resource): """Read-only dynamic resource list, suitable as .well-known/core. This resource renders a link_header.LinkHeader object (which describes a collection of resources) as application/link-format (RFC 6690). The list to be rendered is obtained from a function passed into the constructor; typically, that function would be a bound Site.get_resources_as_linkheader() method. This resource also provides server `implementation information link`_; server authors are invited to override this by passing an own URI as the `impl_info` parameter, and can disable it by passing None. .. _`implementation information link`: https://tools.ietf.org/html/draft-bormann-t2trg-rel-impl-00""" ct = link_format_to_message.supported_ct # type: ignore def __init__(self, listgenerator, impl_info=meta.library_uri, **kwargs): super().__init__(**kwargs) self.listgenerator = listgenerator self.impl_info = impl_info async def render_get(self, request): # If this is ever filtered to the request's authenticated claims, # adjustments may be due in oscore_sitewrapper's # get_resources_as_linkheader links = self.listgenerator() if self.impl_info is not None: links.links = links.links + [Link(href=self.impl_info, rel="impl-info")] filters = [] for q in request.opt.uri_query: try: k, v = q.split("=", 1) except ValueError: continue # no =, not a relevant filter if v.endswith("*"): def matchexp(x, v=v): return x.startswith(v[:-1]) else: def matchexp(x, v=v): return x == v if k in ("rt", "if", "ct"): filters.append( lambda link: any( matchexp(part) for part in (" ".join(getattr(link, k, ()))).split(" ") ) ) elif k in ("href",): # x.href is single valued filters.append(lambda link: matchexp(getattr(link, k))) else: filters.append( lambda link: any(matchexp(part) for part in getattr(link, k, ())) ) while filters: links.links = filter(filters.pop(), links.links) links.links = list(links.links) response = link_format_to_message(request, links) if ( request.opt.uri_query and not links.links and request.remote.is_multicast_locally ): if request.opt.no_response is None: # If the filter does not match, multicast requests should not # be responded to -- that's equivalent to a "no_response on # 2.xx" option. response.opt.no_response = 0x02 return response class PathCapable: """Class that indicates that a resource promises to parse the uri_path option, and can thus be given requests for :meth:`~.interfaces.Resource.render`-ing that contain a uri_path""" class Site(interfaces.ObservableResource, PathCapable): """Typical root element that gets passed to a :class:`Context` and contains all the resources that can be found when the endpoint gets accessed as a server. This provides easy registration of statical resources. Add resources at absolute locations using the :meth:`.add_resource` method. For example, the site at >>> site = Site() >>> site.add_resource(["hello"], Resource()) will have requests to rendered by the new resource. You can add another Site (or another instance of :class:`PathCapable`) as well, those will be nested and integrally reported in a WKCResource. The path of a site should not end with an empty string (ie. a slash in the URI) -- the child site's own root resource will then have the trailing slash address. Subsites can not have link-header attributes on their own (eg. `rt`) and will never respond to a request that does not at least contain a single slash after the the given path part. For example, >>> batch = Site() >>> batch.add_resource(["light1"], Resource()) >>> batch.add_resource(["light2"], Resource()) >>> batch.add_resource([], Resource()) >>> s = Site() >>> s.add_resource(["batch"], batch) will have the three created resources rendered at , and . If it is necessary to respond to requests to or report its attributes in .well-known/core in addition to the above, a non-PathCapable resource can be added with the same path. This is usually considered an odd design, not fully supported, and for example doesn't support removal of resources from the site. """ def __init__(self): self._resources = {} self._subsites = {} async def needs_blockwise_assembly(self, request): try: child, subrequest = self._find_child_and_pathstripped_message(request) except KeyError: return True else: return await child.needs_blockwise_assembly(subrequest) def _find_child_and_pathstripped_message(self, request): """Given a request, find the child that will handle it, and strip all path components from the request that are covered by the child's position within the site. Returns the child and a request with a path shortened by the components in the child's path, or raises a KeyError. While producing stripped messages, this adds a ._original_request_uri attribute to the messages which holds the request URI before the stripping is started. That allows internal components to access the original URI until there is a variation of the request API that allows accessing this in a better usable way.""" original_request_uri = getattr( request, "_original_request_uri", request.get_request_uri(), ) if request.opt.uri_path in self._resources: stripped = request.copy(uri_path=()) stripped._original_request_uri = original_request_uri return self._resources[request.opt.uri_path], stripped if not request.opt.uri_path: raise KeyError() remainder = [request.opt.uri_path[-1]] path = request.opt.uri_path[:-1] while path: if path in self._subsites: res = self._subsites[path] if remainder == [""]: # sub-sites should see their root resource like sites remainder = [] stripped = request.copy(uri_path=remainder) stripped._original_request_uri = original_request_uri return res, stripped remainder.insert(0, path[-1]) path = path[:-1] raise KeyError() async def render(self, request): try: child, subrequest = self._find_child_and_pathstripped_message(request) except KeyError: raise error.NotFound() else: return await child.render(subrequest) async def add_observation(self, request, serverobservation): try: child, subrequest = self._find_child_and_pathstripped_message(request) except KeyError: return try: await child.add_observation(subrequest, serverobservation) except AttributeError: pass def add_resource(self, path, resource): if isinstance(path, str): raise ValueError("Paths should be tuples or lists of strings") if isinstance(resource, PathCapable): self._subsites[tuple(path)] = resource else: self._resources[tuple(path)] = resource def remove_resource(self, path): try: del self._subsites[tuple(path)] except KeyError: del self._resources[tuple(path)] def get_resources_as_linkheader(self): links = [] for path, resource in self._resources.items(): if hasattr(resource, "get_link_description"): details = resource.get_link_description() else: details = {} if details is None: continue lh = Link("/" + "/".join(path), **details) links.append(lh) for path, resource in self._subsites.items(): if hasattr(resource, "get_resources_as_linkheader"): for link in resource.get_resources_as_linkheader().links: links.append( Link("/" + "/".join(path) + link.href, link.attr_pairs) ) return LinkFormat(links) async def render_to_pipe(self, request: Pipe): # As soon as we use the provided render_to_pipe that fans out to # needs_blockwise_assembly etc, we'll have multiple methods that all # need to agree on how the path is processed, even before we go into # the site dispatch -- so we better resolve this now (exercising our # liberties as a library proxy) and provide a consistent view. if request.request.opt.uri_path_abbrev is not None: if request.request.opt.uri_path: # Conflicting options raise error.BadOption() try: request.request.opt.uri_path = uri_path_abbrev._map[ request.request.opt.uri_path_abbrev ] except KeyError: # Unknown option raise error.BadOption() from None request.request.opt.uri_path_abbrev = None try: child, subrequest = self._find_child_and_pathstripped_message( request.request ) except KeyError: raise error.NotFound() else: # FIXME consider carefully whether this switching-around is good. # It probably is. request.request = subrequest return await child.render_to_pipe(request)