Coverage for src/aiocoap/resource.py: 0%
198 statements
« prev ^ index » next coverage.py v7.7.0, created at 2025-03-20 17:26 +0000
« prev ^ index » next coverage.py v7.7.0, created at 2025-03-20 17:26 +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 .pipe import Pipe
36from .util.linkformat import Link, LinkFormat
39def hashing_etag(request: message.Message, response: message.Message):
40 """Helper function for render_get handlers that allows them to use ETags based
41 on the payload's hash value
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.
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.
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 """
65 if response.code != Code.CONTENT and response.code is not None:
66 return
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 = Code.VALID
71 response.payload = b""
74class _ExposesWellknownAttributes:
75 def get_link_description(self):
76 # FIXME which formats are acceptable, and how much escaping and
77 # list-to-separated-string conversion needs to happen here
78 ret = {}
79 if hasattr(self, "ct"):
80 ret["ct"] = str(self.ct)
81 if hasattr(self, "rt"):
82 ret["rt"] = self.rt
83 if hasattr(self, "if_"):
84 ret["if"] = self.if_
85 return ret
88class Resource(_ExposesWellknownAttributes, interfaces.Resource):
89 """Simple base implementation of the :class:`interfaces.Resource`
90 interface
92 The render method delegates content creation to ``render_$method`` methods
93 (``render_get``, ``render_put`` etc), and responds appropriately to
94 unsupported methods. Those messages may return messages without a response
95 code, the default render method will set an appropriate successful code
96 ("Content" for GET/FETCH, "Deleted" for DELETE, "Changed" for anything
97 else). The render method will also fill in the request's no_response code
98 into the response (see :meth:`.interfaces.Resource.render`) if none was
99 set.
101 Moreover, this class provides a ``get_link_description`` method as used by
102 .well-known/core to expose a resource's ``.ct``, ``.rt`` and ``.if_``
103 (alternative name for ``if`` as that's a Python keyword) attributes.
104 Details can be added by overriding the method to return a more
105 comprehensive dictionary, and resources can be hidden completely by
106 returning None.
107 """
109 async def needs_blockwise_assembly(self, request):
110 return True
112 async def render(self, request):
113 if not request.code.is_request():
114 raise error.UnsupportedMethod()
115 m = getattr(self, "render_%s" % str(request.code).lower(), None)
116 if not m:
117 raise error.UnallowedMethod()
119 response = await m(request)
121 if response is message.NoResponse:
122 warnings.warn(
123 "Returning NoResponse is deprecated, please return a"
124 " regular response with a no_response option set.",
125 DeprecationWarning,
126 )
127 response = message.Message(no_response=26)
129 if response.code is None:
130 if request.code in (Code.GET, Code.FETCH):
131 response_default = Code.CONTENT
132 elif request.code == Code.DELETE:
133 response_default = Code.DELETED
134 else:
135 response_default = Code.CHANGED
136 response.code = response_default
138 if response.opt.no_response is None:
139 response.opt.no_response = request.opt.no_response
141 return response
143 async def render_to_pipe(self, pipe: Pipe):
144 # Silence the deprecation warning
145 if isinstance(self, interfaces.ObservableResource):
146 # See interfaces.Resource.render_to_pipe
147 return await interfaces.ObservableResource._render_to_pipe(self, pipe)
148 return await interfaces.Resource._render_to_pipe(self, pipe)
151class ObservableResource(Resource, interfaces.ObservableResource):
152 def __init__(self):
153 super(ObservableResource, self).__init__()
154 self._observations = set()
156 async def add_observation(self, request, serverobservation):
157 self._observations.add(serverobservation)
159 def _cancel(self=self, obs=serverobservation):
160 self._observations.remove(serverobservation)
161 self.update_observation_count(len(self._observations))
163 serverobservation.accept(_cancel)
164 self.update_observation_count(len(self._observations))
166 def update_observation_count(self, newcount):
167 """Hook into this method to be notified when the number of observations
168 on the resource changes."""
170 def updated_state(self, response=None):
171 """Call this whenever the resource was updated, and a notification
172 should be sent to observers."""
174 for o in self._observations:
175 o.trigger(response)
177 def get_link_description(self):
178 link = super(ObservableResource, self).get_link_description()
179 link["obs"] = None
180 return link
182 async def render_to_pipe(self, request: Pipe):
183 # Silence the deprecation warning
184 return await interfaces.ObservableResource._render_to_pipe(self, request)
187def link_format_to_message(
188 request: message.Message,
189 linkformat: LinkFormat,
190 default_ct=ContentFormat.LINKFORMAT,
191) -> message.Message:
192 """Given a LinkFormat object, render it to a response message, picking a
193 suitable conent format from a given request.
195 It returns a Not Acceptable response if something unsupported was queried.
197 It makes no attempt to modify the URI reference literals encoded in the
198 LinkFormat object; they have to be suitably prepared by the caller."""
200 ct = request.opt.accept
201 if ct is None:
202 ct = default_ct
204 if ct == ContentFormat.LINKFORMAT:
205 payload = str(linkformat).encode("utf8")
206 else:
207 return message.Message(code=Code.NOT_ACCEPTABLE)
209 return message.Message(payload=payload, content_format=ct)
212# Convenience attribute to set as ct on resources that use
213# link_format_to_message as their final step in the request handler
214#
215# mypy doesn't like attributes on functions, requiring some `type: ignore` for
216# this, but the alternatives (a __call__able class) have worse docs.
217link_format_to_message.supported_ct = " ".join( # type: ignore
218 str(int(x)) for x in (ContentFormat.LINKFORMAT,)
219)
222class WKCResource(Resource):
223 """Read-only dynamic resource list, suitable as .well-known/core.
225 This resource renders a link_header.LinkHeader object (which describes a
226 collection of resources) as application/link-format (RFC 6690).
228 The list to be rendered is obtained from a function passed into the
229 constructor; typically, that function would be a bound
230 Site.get_resources_as_linkheader() method.
232 This resource also provides server `implementation information link`_;
233 server authors are invited to override this by passing an own URI as the
234 `impl_info` parameter, and can disable it by passing None.
236 .. _`implementation information link`: https://tools.ietf.org/html/draft-bormann-t2trg-rel-impl-00"""
238 ct = link_format_to_message.supported_ct # type: ignore
240 def __init__(self, listgenerator, impl_info=meta.library_uri, **kwargs):
241 super().__init__(**kwargs)
242 self.listgenerator = listgenerator
243 self.impl_info = impl_info
245 async def render_get(self, request):
246 # If this is ever filtered to the request's authenticated claims,
247 # adjustments may be due in oscore_sitewrapper's
248 # get_resources_as_linkheader
249 links = self.listgenerator()
251 if self.impl_info is not None:
252 links.links = links.links + [Link(href=self.impl_info, rel="impl-info")]
254 filters = []
255 for q in request.opt.uri_query:
256 try:
257 k, v = q.split("=", 1)
258 except ValueError:
259 continue # no =, not a relevant filter
261 if v.endswith("*"):
263 def matchexp(x, v=v):
264 return x.startswith(v[:-1])
265 else:
267 def matchexp(x, v=v):
268 return x == v
270 if k in ("rt", "if", "ct"):
271 filters.append(
272 lambda link: any(
273 matchexp(part)
274 for part in (" ".join(getattr(link, k, ()))).split(" ")
275 )
276 )
277 elif k in ("href",): # x.href is single valued
278 filters.append(lambda link: matchexp(getattr(link, k)))
279 else:
280 filters.append(
281 lambda link: any(matchexp(part) for part in getattr(link, k, ()))
282 )
284 while filters:
285 links.links = filter(filters.pop(), links.links)
286 links.links = list(links.links)
288 response = link_format_to_message(request, links)
290 if (
291 request.opt.uri_query
292 and not links.links
293 and request.remote.is_multicast_locally
294 ):
295 if request.opt.no_response is None:
296 # If the filter does not match, multicast requests should not
297 # be responded to -- that's equivalent to a "no_response on
298 # 2.xx" option.
299 response.opt.no_response = 0x02
301 return response
304class PathCapable:
305 """Class that indicates that a resource promises to parse the uri_path
306 option, and can thus be given requests for
307 :meth:`~.interfaces.Resource.render`-ing that contain a uri_path"""
310class Site(interfaces.ObservableResource, PathCapable):
311 """Typical root element that gets passed to a :class:`Context` and contains
312 all the resources that can be found when the endpoint gets accessed as a
313 server.
315 This provides easy registration of statical resources. Add resources at
316 absolute locations using the :meth:`.add_resource` method.
318 For example, the site at
320 >>> site = Site()
321 >>> site.add_resource(["hello"], Resource())
323 will have requests to </hello> rendered by the new resource.
325 You can add another Site (or another instance of :class:`PathCapable`) as
326 well, those will be nested and integrally reported in a WKCResource. The
327 path of a site should not end with an empty string (ie. a slash in the URI)
328 -- the child site's own root resource will then have the trailing slash
329 address. Subsites can not have link-header attributes on their own (eg.
330 `rt`) and will never respond to a request that does not at least contain a
331 single slash after the the given path part.
333 For example,
335 >>> batch = Site()
336 >>> batch.add_resource(["light1"], Resource())
337 >>> batch.add_resource(["light2"], Resource())
338 >>> batch.add_resource([], Resource())
339 >>> s = Site()
340 >>> s.add_resource(["batch"], batch)
342 will have the three created resources rendered at </batch/light1>,
343 </batch/light2> and </batch/>.
345 If it is necessary to respond to requests to </batch> or report its
346 attributes in .well-known/core in addition to the above, a non-PathCapable
347 resource can be added with the same path. This is usually considered an odd
348 design, not fully supported, and for example doesn't support removal of
349 resources from the site.
350 """
352 def __init__(self):
353 self._resources = {}
354 self._subsites = {}
356 async def needs_blockwise_assembly(self, request):
357 try:
358 child, subrequest = self._find_child_and_pathstripped_message(request)
359 except KeyError:
360 return True
361 else:
362 return await child.needs_blockwise_assembly(subrequest)
364 def _find_child_and_pathstripped_message(self, request):
365 """Given a request, find the child that will handle it, and strip all
366 path components from the request that are covered by the child's
367 position within the site. Returns the child and a request with a path
368 shortened by the components in the child's path, or raises a
369 KeyError.
371 While producing stripped messages, this adds a ._original_request_uri
372 attribute to the messages which holds the request URI before the
373 stripping is started. That allows internal components to access the
374 original URI until there is a variation of the request API that allows
375 accessing this in a better usable way."""
377 original_request_uri = getattr(
378 request,
379 "_original_request_uri",
380 request.get_request_uri(local_is_server=True),
381 )
383 if request.opt.uri_path in self._resources:
384 stripped = request.copy(uri_path=())
385 stripped._original_request_uri = original_request_uri
386 return self._resources[request.opt.uri_path], stripped
388 if not request.opt.uri_path:
389 raise KeyError()
391 remainder = [request.opt.uri_path[-1]]
392 path = request.opt.uri_path[:-1]
393 while path:
394 if path in self._subsites:
395 res = self._subsites[path]
396 if remainder == [""]:
397 # sub-sites should see their root resource like sites
398 remainder = []
399 stripped = request.copy(uri_path=remainder)
400 stripped._original_request_uri = original_request_uri
401 return res, stripped
402 remainder.insert(0, path[-1])
403 path = path[:-1]
404 raise KeyError()
406 async def render(self, request):
407 try:
408 child, subrequest = self._find_child_and_pathstripped_message(request)
409 except KeyError:
410 raise error.NotFound()
411 else:
412 return await child.render(subrequest)
414 async def add_observation(self, request, serverobservation):
415 try:
416 child, subrequest = self._find_child_and_pathstripped_message(request)
417 except KeyError:
418 return
420 try:
421 await child.add_observation(subrequest, serverobservation)
422 except AttributeError:
423 pass
425 def add_resource(self, path, resource):
426 if isinstance(path, str):
427 raise ValueError("Paths should be tuples or lists of strings")
428 if isinstance(resource, PathCapable):
429 self._subsites[tuple(path)] = resource
430 else:
431 self._resources[tuple(path)] = resource
433 def remove_resource(self, path):
434 try:
435 del self._subsites[tuple(path)]
436 except KeyError:
437 del self._resources[tuple(path)]
439 def get_resources_as_linkheader(self):
440 links = []
442 for path, resource in self._resources.items():
443 if hasattr(resource, "get_link_description"):
444 details = resource.get_link_description()
445 else:
446 details = {}
447 if details is None:
448 continue
449 lh = Link("/" + "/".join(path), **details)
451 links.append(lh)
453 for path, resource in self._subsites.items():
454 if hasattr(resource, "get_resources_as_linkheader"):
455 for link in resource.get_resources_as_linkheader().links:
456 links.append(
457 Link("/" + "/".join(path) + link.href, link.attr_pairs)
458 )
459 return LinkFormat(links)
461 async def render_to_pipe(self, request: Pipe):
462 try:
463 child, subrequest = self._find_child_and_pathstripped_message(
464 request.request
465 )
466 except KeyError:
467 raise error.NotFound()
468 else:
469 # FIXME consider carefully whether this switching-around is good.
470 # It probably is.
471 request.request = subrequest
472 return await child.render_to_pipe(request)