Coverage for aiocoap/resource.py: 80%
199 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-16 16:09 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-16 16:09 +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 . import numbers
34from .pipe import Pipe
36def hashing_etag(request, response):
37 """Helper function for render_get handlers that allows them to use ETags based
38 on the payload's hash value
40 Run this on your request and response before returning from render_get; it is
41 safe to use this function with all kinds of responses, it will only act on
42 2.05 Content messages (and those with no code set, which defaults to that
43 for GET requests). The hash used are the first 8 bytes of the sha1 sum of
44 the payload.
46 Note that this method is not ideal from a server performance point of view
47 (a file server, for example, might want to hash only the stat() result of a
48 file instead of reading it in full), but it saves bandwith for the simple
49 cases.
51 >>> from aiocoap import *
52 >>> req = Message(code=GET)
53 >>> hash_of_hello = b'\\xaa\\xf4\\xc6\\x1d\\xdc\\xc5\\xe8\\xa2'
54 >>> req.opt.etags = [hash_of_hello]
55 >>> resp = Message(code=CONTENT)
56 >>> resp.payload = b'hello'
57 >>> hashing_etag(req, resp)
58 >>> resp # doctest: +ELLIPSIS
59 <aiocoap.Message at ... 2.03 Valid ... 1 option(s)>
60 """
62 if response.code != numbers.codes.CONTENT and response.code is not None:
63 return
65 response.opt.etag = hashlib.sha1(response.payload).digest()[:8]
66 if request.opt.etags is not None and response.opt.etag in request.opt.etags:
67 response.code = numbers.codes.VALID
68 response.payload = b''
70class _ExposesWellknownAttributes:
71 def get_link_description(self):
72 # FIXME which formats are acceptable, and how much escaping and
73 # list-to-separated-string conversion needs to happen here
74 ret = {}
75 if hasattr(self, 'ct'):
76 ret['ct'] = str(self.ct)
77 if hasattr(self, 'rt'):
78 ret['rt'] = self.rt
79 if hasattr(self, 'if_'):
80 ret['if'] = self.if_
81 return ret
83class Resource(_ExposesWellknownAttributes, interfaces.Resource):
84 """Simple base implementation of the :class:`interfaces.Resource`
85 interface
87 The render method delegates content creation to ``render_$method`` methods
88 (``render_get``, ``render_put`` etc), and responds appropriately to
89 unsupported methods. Those messages may return messages without a response
90 code, the default render method will set an appropriate successful code
91 ("Content" for GET/FETCH, "Deleted" for DELETE, "Changed" for anything
92 else). The render method will also fill in the request's no_response code
93 into the response (see :meth:`.interfaces.Resource.render`) if none was
94 set.
96 Moreover, this class provides a ``get_link_description`` method as used by
97 .well-known/core to expose a resource's ``.ct``, ``.rt`` and ``.if_``
98 (alternative name for ``if`` as that's a Python keyword) attributes.
99 Details can be added by overriding the method to return a more
100 comprehensive dictionary, and resources can be hidden completely by
101 returning None.
102 """
104 async def needs_blockwise_assembly(self, request):
105 return True
107 async def render(self, request):
108 if not request.code.is_request():
109 raise error.UnsupportedMethod()
110 m = getattr(self, 'render_%s' % str(request.code).lower(), None)
111 if not m:
112 raise error.UnallowedMethod()
114 response = await m(request)
116 if response is message.NoResponse:
117 warnings.warn("Returning NoResponse is deprecated, please return a"
118 " regular response with a no_response option set.",
119 DeprecationWarning)
120 response = message.Message(no_response=26)
122 if response.code is None:
123 if request.code in (numbers.codes.GET, numbers.codes.FETCH):
124 response_default = numbers.codes.CONTENT
125 elif request.code == numbers.codes.DELETE:
126 response_default = numbers.codes.DELETED
127 else:
128 response_default = numbers.codes.CHANGED
129 response.code = response_default
131 if response.opt.no_response is None:
132 response.opt.no_response = request.opt.no_response
134 return response
136 async def render_to_pipe(self, request: Pipe):
137 # Silence the deprecation warning
138 if isinstance(self, interfaces.ObservableResource):
139 # See interfaces.Resource.render_to_pipe
140 return await interfaces.ObservableResource._render_to_pipe(self, request)
141 return await interfaces.Resource._render_to_pipe(self, request)
143class ObservableResource(Resource, interfaces.ObservableResource):
144 def __init__(self):
145 super(ObservableResource, self).__init__()
146 self._observations = set()
148 async def add_observation(self, request, serverobservation):
149 self._observations.add(serverobservation)
150 def _cancel(self=self, obs=serverobservation):
151 self._observations.remove(serverobservation)
152 self.update_observation_count(len(self._observations))
153 serverobservation.accept(_cancel)
154 self.update_observation_count(len(self._observations))
156 def update_observation_count(self, newcount):
157 """Hook into this method to be notified when the number of observations
158 on the resource changes."""
160 def updated_state(self, response=None):
161 """Call this whenever the resource was updated, and a notification
162 should be sent to observers."""
164 for o in self._observations:
165 o.trigger(response)
167 def get_link_description(self):
168 link = super(ObservableResource, self).get_link_description()
169 link['obs'] = None
170 return link
172 async def render_to_pipe(self, request: Pipe):
173 # Silence the deprecation warning
174 return await interfaces.ObservableResource._render_to_pipe(self, request)
176def link_format_to_message(request, linkformat,
177 default_ct=numbers.ContentFormat.LINKFORMAT):
178 """Given a LinkFormat object, render it to a response message, picking a
179 suitable conent format from a given request.
181 It returns a Not Acceptable response if something unsupported was queried.
183 It makes no attempt to modify the URI reference literals encoded in the
184 LinkFormat object; they have to be suitably prepared by the caller."""
186 ct = request.opt.accept
187 if ct is None:
188 ct = default_ct
190 if ct == numbers.ContentFormat.LINKFORMAT:
191 payload = str(linkformat).encode('utf8')
192 else:
193 return message.Message(code=numbers.NOT_ACCEPTABLE)
195 return message.Message(payload=payload, content_format=ct)
197# Convenience attribute to set as ct on resources that use
198# link_format_to_message as their final step in the request handler
199link_format_to_message.supported_ct = " ".join(str(int(x)) for x in (
200 numbers.ContentFormat.LINKFORMAT,
201 ))
203class WKCResource(Resource):
204 """Read-only dynamic resource list, suitable as .well-known/core.
206 This resource renders a link_header.LinkHeader object (which describes a
207 collection of resources) as application/link-format (RFC 6690).
209 The list to be rendered is obtained from a function passed into the
210 constructor; typically, that function would be a bound
211 Site.get_resources_as_linkheader() method.
213 This resource also provides server `implementation information link`_;
214 server authors are invited to override this by passing an own URI as the
215 `impl_info` parameter, and can disable it by passing None.
217 .. _`implementation information link`: https://tools.ietf.org/html/draft-bormann-t2trg-rel-impl-00"""
219 ct = link_format_to_message.supported_ct
221 def __init__(self, listgenerator, impl_info=meta.library_uri, **kwargs):
222 super().__init__(**kwargs)
223 self.listgenerator = listgenerator
224 self.impl_info = impl_info
226 async def render_get(self, request):
227 links = self.listgenerator()
229 if self.impl_info is not None:
230 from .util.linkformat import Link
231 links.links = links.links + [Link(href=self.impl_info, rel="impl-info")]
233 filters = []
234 for q in request.opt.uri_query:
235 try:
236 k, v = q.split('=', 1)
237 except ValueError:
238 continue # no =, not a relevant filter
240 if v.endswith('*'):
241 def matchexp(x, v=v):
242 return x.startswith(v[:-1])
243 else:
244 def matchexp(x, v=v):
245 return x == v
247 if k in ('rt', 'if', 'ct'):
248 filters.append(lambda link: any(matchexp(part) for part in (" ".join(getattr(link, k, ()))).split(" ")))
249 elif k in ('href',): # x.href is single valued
250 filters.append(lambda link: matchexp(getattr(link, k)))
251 else:
252 filters.append(lambda link: any(matchexp(part) for part in getattr(link, k, ())))
254 while filters:
255 links.links = filter(filters.pop(), links.links)
256 links.links = list(links.links)
258 response = link_format_to_message(request, links)
260 if request.opt.uri_query and not links.links and \
261 request.remote.is_multicast_locally:
262 if request.opt.no_response is None:
263 # If the filter does not match, multicast requests should not
264 # be responded to -- that's equivalent to a "no_response on
265 # 2.xx" option.
266 response.opt.no_response = 0x02
268 return response
270class PathCapable:
271 """Class that indicates that a resource promises to parse the uri_path
272 option, and can thus be given requests for :meth:`.render`-ing that
273 contain a uri_path"""
275class Site(interfaces.ObservableResource, PathCapable):
276 """Typical root element that gets passed to a :class:`Context` and contains
277 all the resources that can be found when the endpoint gets accessed as a
278 server.
280 This provides easy registration of statical resources. Add resources at
281 absolute locations using the :meth:`.add_resource` method.
283 For example, the site at
285 >>> site = Site()
286 >>> site.add_resource(["hello"], Resource())
288 will have requests to </hello> rendered by the new resource.
290 You can add another Site (or another instance of :class:`PathCapable`) as
291 well, those will be nested and integrally reported in a WKCResource. The
292 path of a site should not end with an empty string (ie. a slash in the URI)
293 -- the child site's own root resource will then have the trailing slash
294 address. Subsites can not have link-header attributes on their own (eg.
295 `rt`) and will never respond to a request that does not at least contain a
296 single slash after the the given path part.
298 For example,
300 >>> batch = Site()
301 >>> batch.add_resource(["light1"], Resource())
302 >>> batch.add_resource(["light2"], Resource())
303 >>> batch.add_resource([], Resource())
304 >>> s = Site()
305 >>> s.add_resource(["batch"], batch)
307 will have the three created resources rendered at </batch/light1>,
308 </batch/light2> and </batch/>.
310 If it is necessary to respond to requests to </batch> or report its
311 attributes in .well-known/core in addition to the above, a non-PathCapable
312 resource can be added with the same path. This is usually considered an odd
313 design, not fully supported, and for example doesn't support removal of
314 resources from the site.
315 """
317 def __init__(self):
318 self._resources = {}
319 self._subsites = {}
321 async def needs_blockwise_assembly(self, request):
322 try:
323 child, subrequest = self._find_child_and_pathstripped_message(request)
324 except KeyError:
325 return True
326 else:
327 return await child.needs_blockwise_assembly(subrequest)
329 def _find_child_and_pathstripped_message(self, request):
330 """Given a request, find the child that will handle it, and strip all
331 path components from the request that are covered by the child's
332 position within the site. Returns the child and a request with a path
333 shortened by the components in the child's path, or raises a
334 KeyError.
336 While producing stripped messages, this adds a ._original_request_uri
337 attribute to the messages which holds the request URI before the
338 stripping is started. That allows internal components to access the
339 original URI until there is a variation of the request API that allows
340 accessing this in a better usable way."""
342 original_request_uri = getattr(request, '_original_request_uri',
343 request.get_request_uri(local_is_server=True))
345 if request.opt.uri_path in self._resources:
346 stripped = request.copy(uri_path=())
347 stripped._original_request_uri = original_request_uri
348 return self._resources[request.opt.uri_path], stripped
350 if not request.opt.uri_path:
351 raise KeyError()
353 remainder = [request.opt.uri_path[-1]]
354 path = request.opt.uri_path[:-1]
355 while path:
356 if path in self._subsites:
357 res = self._subsites[path]
358 if remainder == [""]:
359 # sub-sites should see their root resource like sites
360 remainder = []
361 stripped = request.copy(uri_path=remainder)
362 stripped._original_request_uri = original_request_uri
363 return res, stripped
364 remainder.insert(0, path[-1])
365 path = path[:-1]
366 raise KeyError()
368 async def render(self, request):
369 try:
370 child, subrequest = self._find_child_and_pathstripped_message(request)
371 except KeyError:
372 raise error.NotFound()
373 else:
374 return await child.render(subrequest)
376 async def add_observation(self, request, serverobservation):
377 try:
378 child, subrequest = self._find_child_and_pathstripped_message(request)
379 except KeyError:
380 return
382 try:
383 await child.add_observation(subrequest, serverobservation)
384 except AttributeError:
385 pass
387 def add_resource(self, path, resource):
388 if isinstance(path, str):
389 raise ValueError("Paths should be tuples or lists of strings")
390 if isinstance(resource, PathCapable):
391 self._subsites[tuple(path)] = resource
392 else:
393 self._resources[tuple(path)] = resource
395 def remove_resource(self, path):
396 try:
397 del self._subsites[tuple(path)]
398 except KeyError:
399 del self._resources[tuple(path)]
401 def get_resources_as_linkheader(self):
402 from .util.linkformat import Link, LinkFormat
404 links = []
406 for path, resource in self._resources.items():
407 if hasattr(resource, "get_link_description"):
408 details = resource.get_link_description()
409 else:
410 details = {}
411 if details is None:
412 continue
413 lh = Link('/' + '/'.join(path), **details)
415 links.append(lh)
417 for path, resource in self._subsites.items():
418 if hasattr(resource, "get_resources_as_linkheader"):
419 for l in resource.get_resources_as_linkheader().links:
420 links.append(Link('/' + '/'.join(path) + l.href, l.attr_pairs))
421 return LinkFormat(links)
423 async def render_to_pipe(self, request: Pipe):
424 try:
425 child, subrequest = self._find_child_and_pathstripped_message(request.request)
426 except KeyError:
427 raise error.NotFound()
428 else:
429 # FIXME consider carefully whether this switching-around is good.
430 # It probably is.
431 request.request = subrequest
432 return await child.render_to_pipe(request)