Coverage for aiocoap/resource.py: 82%
208 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-30 11:17 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-30 11:17 +0000
1# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors
2#
3# SPDX-License-Identifier: MIT
5"""Basic resource implementations
7A resource in URL / CoAP / REST terminology is the thing identified by a URI.
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).
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()``).
22To serve more than one resource on a site, use the :class:`Site` class to
23dispatch requests based on the Uri-Path header.
24"""
26import hashlib
27import warnings
29from . import message
30from . import meta
31from . import error
32from . import interfaces
33from .numbers.contentformat import ContentFormat
34from .numbers.codes import Code
35from .numbers import uri_path_abbrev
36from .pipe import Pipe
37from .util.linkformat import Link, LinkFormat
40def hashing_etag(request: message.Message, response: message.Message):
41 """Helper function for render_get handlers that allows them to use ETags based
42 on the payload's hash value
44 Run this on your request and response before returning from render_get; it is
45 safe to use this function with all kinds of responses, it will only act on
46 2.05 Content messages (and those with no code set, which defaults to that
47 for GET requests). The hash used are the first 8 bytes of the sha1 sum of
48 the payload.
50 Note that this method is not ideal from a server performance point of view
51 (a file server, for example, might want to hash only the stat() result of a
52 file instead of reading it in full), but it saves bandwith for the simple
53 cases.
55 >>> from aiocoap import *
56 >>> req = Message(code=GET)
57 >>> hash_of_hello = b'\\xaa\\xf4\\xc6\\x1d\\xdc\\xc5\\xe8\\xa2'
58 >>> req.opt.etags = [hash_of_hello]
59 >>> resp = Message(code=CONTENT)
60 >>> resp.payload = b'hello'
61 >>> hashing_etag(req, resp)
62 >>> resp # doctest: +ELLIPSIS
63 <aiocoap.Message: 2.03 Valid outgoing, 1 option(s)...>
64 """
66 if response.code != Code.CONTENT and response.code is not None:
67 return
69 response.opt.etag = hashlib.sha1(response.payload).digest()[:8]
70 if request.opt.etags is not None and response.opt.etag in request.opt.etags:
71 response.code = Code.VALID
72 response.payload = b""
75class _ExposesWellknownAttributes:
76 def get_link_description(self):
77 # FIXME which formats are acceptable, and how much escaping and
78 # list-to-separated-string conversion needs to happen here
79 ret = {}
80 if hasattr(self, "ct"):
81 ret["ct"] = str(self.ct)
82 if hasattr(self, "rt"):
83 ret["rt"] = self.rt
84 if hasattr(self, "if_"):
85 ret["if"] = self.if_
86 return ret
89class Resource(_ExposesWellknownAttributes, interfaces.Resource):
90 """Simple base implementation of the :class:`interfaces.Resource`
91 interface
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.
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 """
110 async def needs_blockwise_assembly(self, request):
111 return True
113 async def render(self, request):
114 assert request.direction is message.Direction.INCOMING
115 if not request.code.is_request():
116 raise error.UnsupportedMethod()
117 m = getattr(self, "render_%s" % str(request.code).lower(), None)
118 if not m:
119 raise error.UnallowedMethod()
121 response = await m(request)
123 if response is message.NoResponse:
124 warnings.warn(
125 "Returning NoResponse is deprecated, please return a"
126 " regular response with a no_response option set.",
127 DeprecationWarning,
128 )
129 response = message.Message(no_response=26)
131 if response.code is None:
132 if request.code in (Code.GET, Code.FETCH):
133 response_default = Code.CONTENT
134 elif request.code == Code.DELETE:
135 response_default = Code.DELETED
136 else:
137 response_default = Code.CHANGED
138 response.code = response_default
140 if response.opt.no_response is None:
141 response.opt.no_response = request.opt.no_response
143 return response
145 async def render_to_pipe(self, pipe: Pipe):
146 # Silence the deprecation warning
147 if isinstance(self, interfaces.ObservableResource):
148 # See interfaces.Resource.render_to_pipe
149 return await interfaces.ObservableResource._render_to_pipe(self, pipe)
150 return await interfaces.Resource._render_to_pipe(self, pipe)
153class ObservableResource(Resource, interfaces.ObservableResource):
154 def __init__(self):
155 super(ObservableResource, self).__init__()
156 self._observations = set()
158 async def add_observation(self, request, serverobservation):
159 self._observations.add(serverobservation)
161 def _cancel(self=self, obs=serverobservation):
162 self._observations.remove(serverobservation)
163 self.update_observation_count(len(self._observations))
165 serverobservation.accept(_cancel)
166 self.update_observation_count(len(self._observations))
168 def update_observation_count(self, newcount):
169 """Hook into this method to be notified when the number of observations
170 on the resource changes."""
172 def updated_state(self, response=None):
173 """Call this whenever the resource was updated, and a notification
174 should be sent to observers."""
176 for o in self._observations:
177 o.trigger(response)
179 def get_link_description(self):
180 link = super(ObservableResource, self).get_link_description()
181 link["obs"] = None
182 return link
184 async def render_to_pipe(self, request: Pipe):
185 # Silence the deprecation warning
186 return await interfaces.ObservableResource._render_to_pipe(self, request)
189def link_format_to_message(
190 request: message.Message,
191 linkformat: LinkFormat,
192 default_ct=ContentFormat.LINKFORMAT,
193) -> message.Message:
194 """Given a LinkFormat object, render it to a response message, picking a
195 suitable conent format from a given request.
197 It returns a Not Acceptable response if something unsupported was queried.
199 It makes no attempt to modify the URI reference literals encoded in the
200 LinkFormat object; they have to be suitably prepared by the caller."""
202 ct = request.opt.accept
203 if ct is None:
204 ct = default_ct
206 if ct == ContentFormat.LINKFORMAT:
207 payload = str(linkformat).encode("utf8")
208 else:
209 return message.Message(code=Code.NOT_ACCEPTABLE)
211 return message.Message(payload=payload, content_format=ct)
214# Convenience attribute to set as ct on resources that use
215# link_format_to_message as their final step in the request handler
216#
217# mypy doesn't like attributes on functions, requiring some `type: ignore` for
218# this, but the alternatives (a __call__able class) have worse docs.
219link_format_to_message.supported_ct = " ".join( # type: ignore
220 str(int(x)) for x in (ContentFormat.LINKFORMAT,)
221)
224class WKCResource(Resource):
225 """Read-only dynamic resource list, suitable as .well-known/core.
227 This resource renders a link_header.LinkHeader object (which describes a
228 collection of resources) as application/link-format (RFC 6690).
230 The list to be rendered is obtained from a function passed into the
231 constructor; typically, that function would be a bound
232 Site.get_resources_as_linkheader() method.
234 This resource also provides server `implementation information link`_;
235 server authors are invited to override this by passing an own URI as the
236 `impl_info` parameter, and can disable it by passing None.
238 .. _`implementation information link`: https://tools.ietf.org/html/draft-bormann-t2trg-rel-impl-00"""
240 ct = link_format_to_message.supported_ct # type: ignore
242 def __init__(self, listgenerator, impl_info=meta.library_uri, **kwargs):
243 super().__init__(**kwargs)
244 self.listgenerator = listgenerator
245 self.impl_info = impl_info
247 async def render_get(self, request):
248 # If this is ever filtered to the request's authenticated claims,
249 # adjustments may be due in oscore_sitewrapper's
250 # get_resources_as_linkheader
251 links = self.listgenerator()
253 if self.impl_info is not None:
254 links.links = links.links + [Link(href=self.impl_info, rel="impl-info")]
256 filters = []
257 for q in request.opt.uri_query:
258 try:
259 k, v = q.split("=", 1)
260 except ValueError:
261 continue # no =, not a relevant filter
263 if v.endswith("*"):
265 def matchexp(x, v=v):
266 return x.startswith(v[:-1])
267 else:
269 def matchexp(x, v=v):
270 return x == v
272 if k in ("rt", "if", "ct"):
273 filters.append(
274 lambda link: any(
275 matchexp(part)
276 for part in (" ".join(getattr(link, k, ()))).split(" ")
277 )
278 )
279 elif k in ("href",): # x.href is single valued
280 filters.append(lambda link: matchexp(getattr(link, k)))
281 else:
282 filters.append(
283 lambda link: any(matchexp(part) for part in getattr(link, k, ()))
284 )
286 while filters:
287 links.links = filter(filters.pop(), links.links)
288 links.links = list(links.links)
290 response = link_format_to_message(request, links)
292 if (
293 request.opt.uri_query
294 and not links.links
295 and request.remote.is_multicast_locally
296 ):
297 if request.opt.no_response is None:
298 # If the filter does not match, multicast requests should not
299 # be responded to -- that's equivalent to a "no_response on
300 # 2.xx" option.
301 response.opt.no_response = 0x02
303 return response
306class PathCapable:
307 """Class that indicates that a resource promises to parse the uri_path
308 option, and can thus be given requests for
309 :meth:`~.interfaces.Resource.render`-ing that contain a uri_path"""
312class Site(interfaces.ObservableResource, PathCapable):
313 """Typical root element that gets passed to a :class:`Context` and contains
314 all the resources that can be found when the endpoint gets accessed as a
315 server.
317 This provides easy registration of statical resources. Add resources at
318 absolute locations using the :meth:`.add_resource` method.
320 For example, the site at
322 >>> site = Site()
323 >>> site.add_resource(["hello"], Resource())
325 will have requests to </hello> rendered by the new resource.
327 You can add another Site (or another instance of :class:`PathCapable`) as
328 well, those will be nested and integrally reported in a WKCResource. The
329 path of a site should not end with an empty string (ie. a slash in the URI)
330 -- the child site's own root resource will then have the trailing slash
331 address. Subsites can not have link-header attributes on their own (eg.
332 `rt`) and will never respond to a request that does not at least contain a
333 single slash after the the given path part.
335 For example,
337 >>> batch = Site()
338 >>> batch.add_resource(["light1"], Resource())
339 >>> batch.add_resource(["light2"], Resource())
340 >>> batch.add_resource([], Resource())
341 >>> s = Site()
342 >>> s.add_resource(["batch"], batch)
344 will have the three created resources rendered at </batch/light1>,
345 </batch/light2> and </batch/>.
347 If it is necessary to respond to requests to </batch> or report its
348 attributes in .well-known/core in addition to the above, a non-PathCapable
349 resource can be added with the same path. This is usually considered an odd
350 design, not fully supported, and for example doesn't support removal of
351 resources from the site.
352 """
354 def __init__(self):
355 self._resources = {}
356 self._subsites = {}
358 async def needs_blockwise_assembly(self, request):
359 try:
360 child, subrequest = self._find_child_and_pathstripped_message(request)
361 except KeyError:
362 return True
363 else:
364 return await child.needs_blockwise_assembly(subrequest)
366 def _find_child_and_pathstripped_message(self, request):
367 """Given a request, find the child that will handle it, and strip all
368 path components from the request that are covered by the child's
369 position within the site. Returns the child and a request with a path
370 shortened by the components in the child's path, or raises a
371 KeyError.
373 While producing stripped messages, this adds a ._original_request_uri
374 attribute to the messages which holds the request URI before the
375 stripping is started. That allows internal components to access the
376 original URI until there is a variation of the request API that allows
377 accessing this in a better usable way."""
379 original_request_uri = getattr(
380 request,
381 "_original_request_uri",
382 request.get_request_uri(),
383 )
385 if request.opt.uri_path in self._resources:
386 stripped = request.copy(uri_path=())
387 stripped._original_request_uri = original_request_uri
388 return self._resources[request.opt.uri_path], stripped
390 if not request.opt.uri_path:
391 raise KeyError()
393 remainder = [request.opt.uri_path[-1]]
394 path = request.opt.uri_path[:-1]
395 while path:
396 if path in self._subsites:
397 res = self._subsites[path]
398 if remainder == [""]:
399 # sub-sites should see their root resource like sites
400 remainder = []
401 stripped = request.copy(uri_path=remainder)
402 stripped._original_request_uri = original_request_uri
403 return res, stripped
404 remainder.insert(0, path[-1])
405 path = path[:-1]
406 raise KeyError()
408 async def render(self, request):
409 try:
410 child, subrequest = self._find_child_and_pathstripped_message(request)
411 except KeyError:
412 raise error.NotFound()
413 else:
414 return await child.render(subrequest)
416 async def add_observation(self, request, serverobservation):
417 try:
418 child, subrequest = self._find_child_and_pathstripped_message(request)
419 except KeyError:
420 return
422 try:
423 await child.add_observation(subrequest, serverobservation)
424 except AttributeError:
425 pass
427 def add_resource(self, path, resource):
428 if isinstance(path, str):
429 raise ValueError("Paths should be tuples or lists of strings")
430 if isinstance(resource, PathCapable):
431 self._subsites[tuple(path)] = resource
432 else:
433 self._resources[tuple(path)] = resource
435 def remove_resource(self, path):
436 try:
437 del self._subsites[tuple(path)]
438 except KeyError:
439 del self._resources[tuple(path)]
441 def get_resources_as_linkheader(self):
442 links = []
444 for path, resource in self._resources.items():
445 if hasattr(resource, "get_link_description"):
446 details = resource.get_link_description()
447 else:
448 details = {}
449 if details is None:
450 continue
451 lh = Link("/" + "/".join(path), **details)
453 links.append(lh)
455 for path, resource in self._subsites.items():
456 if hasattr(resource, "get_resources_as_linkheader"):
457 for link in resource.get_resources_as_linkheader().links:
458 links.append(
459 Link("/" + "/".join(path) + link.href, link.attr_pairs)
460 )
461 return LinkFormat(links)
463 async def render_to_pipe(self, request: Pipe):
464 # As soon as we use the provided render_to_pipe that fans out to
465 # needs_blockwise_assembly etc, we'll have multiple methods that all
466 # need to agree on how the path is processed, even before we go into
467 # the site dispatch -- so we better resolve this now (exercising our
468 # liberties as a library proxy) and provide a consistent view.
469 if request.request.opt.uri_path_abbrev is not None:
470 if request.request.opt.uri_path:
471 # Conflicting options
472 raise error.BadOption()
473 try:
474 request.request.opt.uri_path = uri_path_abbrev._map[
475 request.request.opt.uri_path_abbrev
476 ]
477 except KeyError:
478 # Unknown option
479 raise error.BadOption() from None
480 request.request.opt.uri_path_abbrev = None
482 try:
483 child, subrequest = self._find_child_and_pathstripped_message(
484 request.request
485 )
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_pipe(request)