Coverage for aiocoap / resource.py: 82%
209 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 12:28 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 12:28 +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 import DeprecationWarning
38from .util.linkformat import Link, LinkFormat
41def hashing_etag(request: message.Message, response: message.Message):
42 """Helper function for render_get handlers that allows them to use ETags based
43 on the payload's hash value
45 Run this on your request and response before returning from render_get; it is
46 safe to use this function with all kinds of responses, it will only act on
47 2.05 Content messages (and those with no code set, which defaults to that
48 for GET requests). The hash used are the first 8 bytes of the sha1 sum of
49 the payload.
51 Note that this method is not ideal from a server performance point of view
52 (a file server, for example, might want to hash only the stat() result of a
53 file instead of reading it in full), but it saves bandwidth for the simple
54 cases.
56 >>> from aiocoap import *
57 >>> req = Message(code=GET)
58 >>> hash_of_hello = b'\\xaa\\xf4\\xc6\\x1d\\xdc\\xc5\\xe8\\xa2'
59 >>> req.opt.etags = [hash_of_hello]
60 >>> resp = Message(code=CONTENT)
61 >>> resp.payload = b'hello'
62 >>> hashing_etag(req, resp)
63 >>> resp # doctest: +ELLIPSIS
64 <aiocoap.Message: 2.03 Valid outgoing, 1 option(s)...>
65 """
67 if response.code != Code.CONTENT and response.code is not None:
68 return
70 response.opt.etag = hashlib.sha1(response.payload).digest()[:8]
71 if request.opt.etags is not None and response.opt.etag in request.opt.etags:
72 response.code = Code.VALID
73 response.payload = b""
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
90class Resource(_ExposesWellknownAttributes, interfaces.Resource):
91 """Simple base implementation of the :class:`interfaces.Resource`
92 interface
94 The render method delegates content creation to ``render_$method`` methods
95 (``render_get``, ``render_put`` etc), and responds appropriately to
96 unsupported methods. Those messages may return messages without a response
97 code, the default render method will set an appropriate successful code
98 ("Content" for GET/FETCH, "Deleted" for DELETE, "Changed" for anything
99 else). The render method will also fill in the request's no_response code
100 into the response (see :meth:`.interfaces.Resource.render`) if none was
101 set.
103 Moreover, this class provides a ``get_link_description`` method as used by
104 .well-known/core to expose a resource's ``.ct``, ``.rt`` and ``.if_``
105 (alternative name for ``if`` as that's a Python keyword) attributes.
106 Details can be added by overriding the method to return a more
107 comprehensive dictionary, and resources can be hidden completely by
108 returning None.
109 """
111 async def needs_blockwise_assembly(self, request):
112 return True
114 async def render(self, request):
115 assert request.direction is message.Direction.INCOMING
116 if not request.code.is_request():
117 raise error.UnsupportedMethod()
118 m = getattr(self, "render_%s" % str(request.code).lower(), None)
119 if not m:
120 raise error.UnallowedMethod()
122 response = await m(request)
124 if response is message.NoResponse:
125 warnings.warn(
126 "Returning NoResponse is deprecated, please return a"
127 " regular response with a no_response option set.",
128 DeprecationWarning,
129 )
130 response = message.Message(no_response=26)
132 if response.code is None:
133 if request.code in (Code.GET, Code.FETCH):
134 response_default = Code.CONTENT
135 elif request.code == Code.DELETE:
136 response_default = Code.DELETED
137 else:
138 response_default = Code.CHANGED
139 response.code = response_default
141 if response.opt.no_response is None:
142 response.opt.no_response = request.opt.no_response
144 return response
146 async def render_to_pipe(self, pipe: Pipe):
147 # Silence the deprecation warning
148 if isinstance(self, interfaces.ObservableResource):
149 # See interfaces.Resource.render_to_pipe
150 return await interfaces.ObservableResource._render_to_pipe(self, pipe)
151 return await interfaces.Resource._render_to_pipe(self, pipe)
154class ObservableResource(Resource, interfaces.ObservableResource):
155 def __init__(self):
156 super(ObservableResource, self).__init__()
157 self._observations = set()
159 async def add_observation(self, request, serverobservation):
160 self._observations.add(serverobservation)
162 def _cancel(self=self, obs=serverobservation):
163 self._observations.remove(serverobservation)
164 self.update_observation_count(len(self._observations))
166 serverobservation.accept(_cancel)
167 self.update_observation_count(len(self._observations))
169 def update_observation_count(self, newcount):
170 """Hook into this method to be notified when the number of observations
171 on the resource changes."""
173 def updated_state(self, response=None):
174 """Call this whenever the resource was updated, and a notification
175 should be sent to observers."""
177 for o in self._observations:
178 o.trigger(response)
180 def get_link_description(self):
181 link = super(ObservableResource, self).get_link_description()
182 link["obs"] = None
183 return link
185 async def render_to_pipe(self, request: Pipe):
186 # Silence the deprecation warning
187 return await interfaces.ObservableResource._render_to_pipe(self, request)
190def link_format_to_message(
191 request: message.Message,
192 linkformat: LinkFormat,
193 default_ct=ContentFormat.LINKFORMAT,
194) -> message.Message:
195 """Given a LinkFormat object, render it to a response message, picking a
196 suitable content format from a given request.
198 It returns a Not Acceptable response if something unsupported was queried.
200 It makes no attempt to modify the URI reference literals encoded in the
201 LinkFormat object; they have to be suitably prepared by the caller."""
203 ct = request.opt.accept
204 if ct is None:
205 ct = default_ct
207 if ct == ContentFormat.LINKFORMAT:
208 payload = str(linkformat).encode("utf8")
209 else:
210 return message.Message(code=Code.NOT_ACCEPTABLE)
212 return message.Message(payload=payload, content_format=ct)
215# Convenience attribute to set as ct on resources that use
216# link_format_to_message as their final step in the request handler
217#
218# mypy doesn't like attributes on functions, requiring some `type: ignore` for
219# this, but the alternatives (a __call__able class) have worse docs.
220link_format_to_message.supported_ct = " ".join( # type: ignore
221 str(int(x)) for x in (ContentFormat.LINKFORMAT,)
222)
225class WKCResource(Resource):
226 """Read-only dynamic resource list, suitable as .well-known/core.
228 This resource renders a link_header.LinkHeader object (which describes a
229 collection of resources) as application/link-format (RFC 6690).
231 The list to be rendered is obtained from a function passed into the
232 constructor; typically, that function would be a bound
233 Site.get_resources_as_linkheader() method.
235 This resource also provides server `implementation information link`_;
236 server authors are invited to override this by passing an own URI as the
237 `impl_info` parameter, and can disable it by passing None.
239 .. _`implementation information link`: https://tools.ietf.org/html/draft-bormann-t2trg-rel-impl-00"""
241 ct = link_format_to_message.supported_ct # type: ignore
243 def __init__(self, listgenerator, impl_info=meta.library_uri, **kwargs):
244 super().__init__(**kwargs)
245 self.listgenerator = listgenerator
246 self.impl_info = impl_info
248 async def render_get(self, request):
249 # If this is ever filtered to the request's authenticated claims,
250 # adjustments may be due in oscore_sitewrapper's
251 # get_resources_as_linkheader
252 links = self.listgenerator()
254 if self.impl_info is not None:
255 links.links = links.links + [Link(href=self.impl_info, rel="impl-info")]
257 filters = []
258 for q in request.opt.uri_query:
259 try:
260 k, v = q.split("=", 1)
261 except ValueError:
262 continue # no =, not a relevant filter
264 if v.endswith("*"):
266 def matchexp(x, v=v):
267 return x.startswith(v[:-1])
268 else:
270 def matchexp(x, v=v):
271 return x == v
273 if k in ("rt", "if", "ct"):
274 filters.append(
275 lambda link: any(
276 matchexp(part)
277 for part in (" ".join(getattr(link, k, ()))).split(" ")
278 )
279 )
280 elif k in ("href",): # x.href is single valued
281 filters.append(lambda link: matchexp(getattr(link, k)))
282 else:
283 filters.append(
284 lambda link: any(matchexp(part) for part in getattr(link, k, ()))
285 )
287 while filters:
288 links.links = filter(filters.pop(), links.links)
289 links.links = list(links.links)
291 response = link_format_to_message(request, links)
293 if (
294 request.opt.uri_query
295 and not links.links
296 and request.remote.is_multicast_locally
297 ):
298 if request.opt.no_response is None:
299 # If the filter does not match, multicast requests should not
300 # be responded to -- that's equivalent to a "no_response on
301 # 2.xx" option.
302 response.opt.no_response = 0x02
304 return response
307class PathCapable:
308 """Class that indicates that a resource promises to parse the uri_path
309 option, and can thus be given requests for
310 :meth:`~.interfaces.Resource.render`-ing that contain a uri_path"""
313class Site(interfaces.ObservableResource, PathCapable):
314 """Typical root element that gets passed to a :class:`Context` and contains
315 all the resources that can be found when the endpoint gets accessed as a
316 server.
318 This provides easy registration of statical resources. Add resources at
319 absolute locations using the :meth:`.add_resource` method.
321 For example, the site at
323 >>> site = Site()
324 >>> site.add_resource(["hello"], Resource())
326 will have requests to </hello> rendered by the new resource.
328 You can add another Site (or another instance of :class:`PathCapable`) as
329 well, those will be nested and integrally reported in a WKCResource. The
330 path of a site should not end with an empty string (ie. a slash in the URI)
331 -- the child site's own root resource will then have the trailing slash
332 address. Subsites can not have link-header attributes on their own (eg.
333 `rt`) and will never respond to a request that does not at least contain a
334 single slash after the the given path part.
336 For example,
338 >>> batch = Site()
339 >>> batch.add_resource(["light1"], Resource())
340 >>> batch.add_resource(["light2"], Resource())
341 >>> batch.add_resource([], Resource())
342 >>> s = Site()
343 >>> s.add_resource(["batch"], batch)
345 will have the three created resources rendered at </batch/light1>,
346 </batch/light2> and </batch/>.
348 If it is necessary to respond to requests to </batch> or report its
349 attributes in .well-known/core in addition to the above, a non-PathCapable
350 resource can be added with the same path. This is usually considered an odd
351 design, not fully supported, and for example doesn't support removal of
352 resources from the site.
353 """
355 def __init__(self):
356 self._resources = {}
357 self._subsites = {}
359 async def needs_blockwise_assembly(self, request):
360 try:
361 child, subrequest = self._find_child_and_pathstripped_message(request)
362 except KeyError:
363 return True
364 else:
365 return await child.needs_blockwise_assembly(subrequest)
367 def _find_child_and_pathstripped_message(self, request):
368 """Given a request, find the child that will handle it, and strip all
369 path components from the request that are covered by the child's
370 position within the site. Returns the child and a request with a path
371 shortened by the components in the child's path, or raises a
372 KeyError.
374 While producing stripped messages, this adds a ._original_request_path
375 attribute to the messages which holds the request URI before the
376 stripping is started. That allows internal components to access the
377 original URI until there is a variation of the request API that allows
378 accessing this in a better usable way."""
380 original_request_path = getattr(
381 request,
382 "_original_request_path",
383 request.opt.uri_path,
384 )
386 if request.opt.uri_path in self._resources:
387 stripped = request.copy(uri_path=())
388 stripped._original_request_path = original_request_path
389 return self._resources[request.opt.uri_path], stripped
391 if not request.opt.uri_path:
392 raise KeyError()
394 remainder = [request.opt.uri_path[-1]]
395 path = request.opt.uri_path[:-1]
396 while path:
397 if path in self._subsites:
398 res = self._subsites[path]
399 if remainder == [""]:
400 # sub-sites should see their root resource like sites
401 remainder = []
402 stripped = request.copy(uri_path=remainder)
403 stripped._original_request_path = original_request_path
404 return res, stripped
405 remainder.insert(0, path[-1])
406 path = path[:-1]
407 raise KeyError()
409 async def render(self, request):
410 try:
411 child, subrequest = self._find_child_and_pathstripped_message(request)
412 except KeyError:
413 raise error.NotFound()
414 else:
415 return await child.render(subrequest)
417 async def add_observation(self, request, serverobservation):
418 try:
419 child, subrequest = self._find_child_and_pathstripped_message(request)
420 except KeyError:
421 return
423 try:
424 await child.add_observation(subrequest, serverobservation)
425 except AttributeError:
426 pass
428 def add_resource(self, path, resource):
429 if isinstance(path, str):
430 raise ValueError("Paths should be tuples or lists of strings")
431 if isinstance(resource, PathCapable):
432 self._subsites[tuple(path)] = resource
433 else:
434 self._resources[tuple(path)] = resource
436 def remove_resource(self, path):
437 try:
438 del self._subsites[tuple(path)]
439 except KeyError:
440 del self._resources[tuple(path)]
442 def get_resources_as_linkheader(self):
443 links = []
445 for path, resource in self._resources.items():
446 if hasattr(resource, "get_link_description"):
447 details = resource.get_link_description()
448 else:
449 details = {}
450 if details is None:
451 continue
452 lh = Link("/" + "/".join(path), **details)
454 links.append(lh)
456 for path, resource in self._subsites.items():
457 if hasattr(resource, "get_resources_as_linkheader"):
458 for link in resource.get_resources_as_linkheader().links:
459 links.append(
460 Link("/" + "/".join(path) + link.href, link.attr_pairs)
461 )
462 return LinkFormat(links)
464 async def render_to_pipe(self, request: Pipe):
465 # As soon as we use the provided render_to_pipe that fans out to
466 # needs_blockwise_assembly etc, we'll have multiple methods that all
467 # need to agree on how the path is processed, even before we go into
468 # the site dispatch -- so we better resolve this now (exercising our
469 # liberties as a library proxy) and provide a consistent view.
470 if request.request.opt.uri_path_abbrev is not None:
471 if request.request.opt.uri_path:
472 # Conflicting options
473 raise error.BadOption()
474 try:
475 request.request.opt.uri_path = uri_path_abbrev._map[
476 request.request.opt.uri_path_abbrev
477 ]
478 except KeyError:
479 # Unknown option
480 raise error.BadOption() from None
481 request.request.opt.uri_path_abbrev = None
483 try:
484 child, subrequest = self._find_child_and_pathstripped_message(
485 request.request
486 )
487 except KeyError:
488 raise error.NotFound()
489 else:
490 # FIXME consider carefully whether this switching-around is good.
491 # It probably is.
492 request.request = subrequest
493 return await child.render_to_pipe(request)