Coverage for aiocoap/resource.py: 80%
198 statements
« prev ^ index » next coverage.py v7.6.8, created at 2024-11-28 12:34 +0000
« prev ^ index » next coverage.py v7.6.8, created at 2024-11-28 12:34 +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 links = self.listgenerator()
248 if self.impl_info is not None:
249 links.links = links.links + [Link(href=self.impl_info, rel="impl-info")]
251 filters = []
252 for q in request.opt.uri_query:
253 try:
254 k, v = q.split("=", 1)
255 except ValueError:
256 continue # no =, not a relevant filter
258 if v.endswith("*"):
260 def matchexp(x, v=v):
261 return x.startswith(v[:-1])
262 else:
264 def matchexp(x, v=v):
265 return x == v
267 if k in ("rt", "if", "ct"):
268 filters.append(
269 lambda link: any(
270 matchexp(part)
271 for part in (" ".join(getattr(link, k, ()))).split(" ")
272 )
273 )
274 elif k in ("href",): # x.href is single valued
275 filters.append(lambda link: matchexp(getattr(link, k)))
276 else:
277 filters.append(
278 lambda link: any(matchexp(part) for part in getattr(link, k, ()))
279 )
281 while filters:
282 links.links = filter(filters.pop(), links.links)
283 links.links = list(links.links)
285 response = link_format_to_message(request, links)
287 if (
288 request.opt.uri_query
289 and not links.links
290 and request.remote.is_multicast_locally
291 ):
292 if request.opt.no_response is None:
293 # If the filter does not match, multicast requests should not
294 # be responded to -- that's equivalent to a "no_response on
295 # 2.xx" option.
296 response.opt.no_response = 0x02
298 return response
301class PathCapable:
302 """Class that indicates that a resource promises to parse the uri_path
303 option, and can thus be given requests for
304 :meth:`~.interfaces.Resource.render`-ing that contain a uri_path"""
307class Site(interfaces.ObservableResource, PathCapable):
308 """Typical root element that gets passed to a :class:`Context` and contains
309 all the resources that can be found when the endpoint gets accessed as a
310 server.
312 This provides easy registration of statical resources. Add resources at
313 absolute locations using the :meth:`.add_resource` method.
315 For example, the site at
317 >>> site = Site()
318 >>> site.add_resource(["hello"], Resource())
320 will have requests to </hello> rendered by the new resource.
322 You can add another Site (or another instance of :class:`PathCapable`) as
323 well, those will be nested and integrally reported in a WKCResource. The
324 path of a site should not end with an empty string (ie. a slash in the URI)
325 -- the child site's own root resource will then have the trailing slash
326 address. Subsites can not have link-header attributes on their own (eg.
327 `rt`) and will never respond to a request that does not at least contain a
328 single slash after the the given path part.
330 For example,
332 >>> batch = Site()
333 >>> batch.add_resource(["light1"], Resource())
334 >>> batch.add_resource(["light2"], Resource())
335 >>> batch.add_resource([], Resource())
336 >>> s = Site()
337 >>> s.add_resource(["batch"], batch)
339 will have the three created resources rendered at </batch/light1>,
340 </batch/light2> and </batch/>.
342 If it is necessary to respond to requests to </batch> or report its
343 attributes in .well-known/core in addition to the above, a non-PathCapable
344 resource can be added with the same path. This is usually considered an odd
345 design, not fully supported, and for example doesn't support removal of
346 resources from the site.
347 """
349 def __init__(self):
350 self._resources = {}
351 self._subsites = {}
353 async def needs_blockwise_assembly(self, request):
354 try:
355 child, subrequest = self._find_child_and_pathstripped_message(request)
356 except KeyError:
357 return True
358 else:
359 return await child.needs_blockwise_assembly(subrequest)
361 def _find_child_and_pathstripped_message(self, request):
362 """Given a request, find the child that will handle it, and strip all
363 path components from the request that are covered by the child's
364 position within the site. Returns the child and a request with a path
365 shortened by the components in the child's path, or raises a
366 KeyError.
368 While producing stripped messages, this adds a ._original_request_uri
369 attribute to the messages which holds the request URI before the
370 stripping is started. That allows internal components to access the
371 original URI until there is a variation of the request API that allows
372 accessing this in a better usable way."""
374 original_request_uri = getattr(
375 request,
376 "_original_request_uri",
377 request.get_request_uri(local_is_server=True),
378 )
380 if request.opt.uri_path in self._resources:
381 stripped = request.copy(uri_path=())
382 stripped._original_request_uri = original_request_uri
383 return self._resources[request.opt.uri_path], stripped
385 if not request.opt.uri_path:
386 raise KeyError()
388 remainder = [request.opt.uri_path[-1]]
389 path = request.opt.uri_path[:-1]
390 while path:
391 if path in self._subsites:
392 res = self._subsites[path]
393 if remainder == [""]:
394 # sub-sites should see their root resource like sites
395 remainder = []
396 stripped = request.copy(uri_path=remainder)
397 stripped._original_request_uri = original_request_uri
398 return res, stripped
399 remainder.insert(0, path[-1])
400 path = path[:-1]
401 raise KeyError()
403 async def render(self, request):
404 try:
405 child, subrequest = self._find_child_and_pathstripped_message(request)
406 except KeyError:
407 raise error.NotFound()
408 else:
409 return await child.render(subrequest)
411 async def add_observation(self, request, serverobservation):
412 try:
413 child, subrequest = self._find_child_and_pathstripped_message(request)
414 except KeyError:
415 return
417 try:
418 await child.add_observation(subrequest, serverobservation)
419 except AttributeError:
420 pass
422 def add_resource(self, path, resource):
423 if isinstance(path, str):
424 raise ValueError("Paths should be tuples or lists of strings")
425 if isinstance(resource, PathCapable):
426 self._subsites[tuple(path)] = resource
427 else:
428 self._resources[tuple(path)] = resource
430 def remove_resource(self, path):
431 try:
432 del self._subsites[tuple(path)]
433 except KeyError:
434 del self._resources[tuple(path)]
436 def get_resources_as_linkheader(self):
437 links = []
439 for path, resource in self._resources.items():
440 if hasattr(resource, "get_link_description"):
441 details = resource.get_link_description()
442 else:
443 details = {}
444 if details is None:
445 continue
446 lh = Link("/" + "/".join(path), **details)
448 links.append(lh)
450 for path, resource in self._subsites.items():
451 if hasattr(resource, "get_resources_as_linkheader"):
452 for link in resource.get_resources_as_linkheader().links:
453 links.append(
454 Link("/" + "/".join(path) + link.href, link.attr_pairs)
455 )
456 return LinkFormat(links)
458 async def render_to_pipe(self, request: Pipe):
459 try:
460 child, subrequest = self._find_child_and_pathstripped_message(
461 request.request
462 )
463 except KeyError:
464 raise error.NotFound()
465 else:
466 # FIXME consider carefully whether this switching-around is good.
467 # It probably is.
468 request.request = subrequest
469 return await child.render_to_pipe(request)