1# This file is part of the Python aiocoap library project.
2#
3# Copyright (c) 2012-2014 Maciej Wasilak <http://sixpinetrees.blogspot.com/>,
4#               2013-2014 Christian Amsüss <c.amsuess@energyharvesting.at>
5#
6# aiocoap is free software, this file is published under the MIT license as
7# described in the accompanying LICENSE file.
8
9"""Basic resource implementations
10
11A resource in URL / CoAP / REST terminology is the thing identified by a URI.
12
13Here, a :class:`.Resource` is the place where server functionality is
14implemented. In many cases, there exists one persistent Resource object for a
15given resource (eg. a ``TimeResource()`` is responsible for serving the
16``/time`` location). On the other hand, an aiocoap server context accepts only
17one thing as its serversite, and that is a Resource too (typically of the
18:class:`Site` class).
19
20Resources are most easily implemented by deriving from :class:`.Resource` and
21implementing ``render_get``, ``render_post`` and similar coroutine methods.
22Those take a single request message object and must return a
23:class:`aiocoap.Message` object or raise an
24:class:`.error.RenderableError` (eg. ``raise UnsupportedMediaType()``).
25
26To serve more than one resource on a site, use the :class:`Site` class to
27dispatch requests based on the Uri-Path header.
28"""
29
30import hashlib
31import warnings
32
33from . import message
34from . import meta
35from . import error
36from . import interfaces
37from . import numbers
38
39def hashing_etag(request, response):
40    """Helper function for render_get handlers that allows them to use ETags based
41    on the payload's hash value
42
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.
48
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.
53
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    """
64
65    if response.code != numbers.codes.CONTENT and response.code is not None:
66        return
67
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 = numbers.codes.VALID
71        response.payload = b''
72
73class _ExposesWellknownAttributes:
74    def get_link_description(self):
75        ## FIXME which formats are acceptable, and how much escaping and
76        # list-to-separated-string conversion needs to happen here
77        ret = {}
78        if hasattr(self, 'ct'):
79            ret['ct'] = str(self.ct)
80        if hasattr(self, 'rt'):
81            ret['rt'] = self.rt
82        if hasattr(self, 'if_'):
83            ret['if'] = self.if_
84        return ret
85
86class Resource(_ExposesWellknownAttributes, interfaces.Resource):
87    """Simple base implementation of the :class:`interfaces.Resource`
88    interface
89
90    The render method delegates content creation to ``render_$method`` methods
91    (``render_get``, ``render_put`` etc), and responds appropriately to
92    unsupported methods. Those messages may return messages without a response
93    code, the default render method will set an appropriate successful code
94    ("Content" for GET/FETCH, "Deleted" for DELETE, "Changed" for anything
95    else). The render method will also fill in the request's no_response code
96    into the response (see :meth:`.interfaces.Resource.render`) if none was
97    set.
98
99    Moreover, this class provides a ``get_link_description`` method as used by
100    .well-known/core to expose a resource's ``.ct``, ``.rt`` and ``.if_``
101    (alternative name for ``if`` as that's a Python keyword) attributes.
102    Details can be added by overriding the method to return a more
103    comprehensive dictionary, and resources can be hidden completely by
104    returning None.
105    """
106
107    async def needs_blockwise_assembly(self, request):
108        return True
109
110    async def render(self, request):
111        if not request.code.is_request():
112            raise error.UnsupportedMethod()
113        m = getattr(self, 'render_%s' % str(request.code).lower(), None)
114        if not m:
115            raise error.UnallowedMethod()
116
117        response = await m(request)
118
119        if response is message.NoResponse:
120            warnings.warn("Returning NoResponse is deprecated, please return a"
121                          " regular response with a no_response option set.",
122                          DeprecationWarning)
123            response = message.Message(no_response=26)
124
125        if response.code is None:
126            if request.code in (numbers.codes.GET, numbers.codes.FETCH):
127                response_default = numbers.codes.CONTENT
128            elif request.code == numbers.codes.DELETE:
129                response_default = numbers.codes.DELETED
130            else:
131                response_default = numbers.codes.CHANGED
132            response.code = response_default
133
134        if response.opt.no_response is None:
135            response.opt.no_response = request.opt.no_response
136
137        return response
138
139class ObservableResource(Resource, interfaces.ObservableResource):
140    def __init__(self):
141        super(ObservableResource, self).__init__()
142        self._observations = set()
143
144    async def add_observation(self, request, serverobservation):
145        self._observations.add(serverobservation)
146        def _cancel(self=self, obs=serverobservation):
147            self._observations.remove(serverobservation)
148            self.update_observation_count(len(self._observations))
149        serverobservation.accept(_cancel)
150        self.update_observation_count(len(self._observations))
151
152    def update_observation_count(self, newcount):
153        """Hook into this method to be notified when the number of observations
154        on the resource changes."""
155
156    def updated_state(self, response=None):
157        """Call this whenever the resource was updated, and a notification
158        should be sent to observers."""
159
160        for o in self._observations:
161            o.trigger(response)
162
163    def get_link_description(self):
164        link = super(ObservableResource, self).get_link_description()
165        link['obs'] = None
166        return link
167
168def link_format_to_message(request, linkformat,
169        default_ct=numbers.media_types_rev['application/link-format']):
170    """Given a LinkFormat object, render it to a response message, picking a
171    suitable conent format from a given request.
172
173    It returns a Not Acceptable response if something unsupported was queried.
174
175    It makes no attempt to modify the URI reference literals encoded in the
176    LinkFormat object; they have to be suitably prepared by the caller."""
177
178    ct = request.opt.accept
179    if ct is None:
180        ct = default_ct
181
182    if ct == numbers.media_types_rev['application/link-format']:
183        payload = str(linkformat).encode('utf8')
184    elif ct == numbers.media_types_rev['application/link-format+cbor']:
185        payload = linkformat.as_cbor_bytes()
186    elif ct == numbers.media_types_rev['application/link-format+json']:
187        payload = linkformat.as_json_string().encode('utf8')
188    else:
189        return message.Message(code=numbers.NOT_ACCEPTABLE)
190
191    return message.Message(payload=payload, content_format=ct)
192
193# Convenience attribute to set as ct on resources that use
194# link_format_to_message as their final step in the request handler
195link_format_to_message.supported_ct = " ".join(str(x) for x in (
196        numbers.media_types_rev['application/link-format'],
197        numbers.media_types_rev['application/link-format+cbor'],
198        numbers.media_types_rev['application/link-format+json'],
199        ))
200
201class WKCResource(Resource):
202    """Read-only dynamic resource list, suitable as .well-known/core.
203
204    This resource renders a link_header.LinkHeader object (which describes a
205    collection of resources) as application/link-format (RFC 6690).
206
207    The list to be rendered is obtained from a function passed into the
208    constructor; typically, that function would be a bound
209    Site.get_resources_as_linkheader() method.
210
211    This resource also provides server `implementation information link`_;
212    server authors are invited to override this by passing an own URI as the
213    `impl_info` parameter, and can disable it by passing None.
214
215    .. _`implementation information link`: https://tools.ietf.org/html/draft-bormann-t2trg-rel-impl-00"""
216
217    ct = link_format_to_message.supported_ct
218
219    def __init__(self, listgenerator, impl_info=meta.library_uri):
220        self.listgenerator = listgenerator
221        self.impl_info = impl_info
222
223    async def render_get(self, request):
224        links = self.listgenerator()
225
226        if self.impl_info is not None:
227            from .util.linkformat import Link
228            links.links = links.links + [Link(href=self.impl_info, rel="impl-info")]
229
230        filters = []
231        for q in request.opt.uri_query:
232            try:
233                k, v = q.split('=', 1)
234            except ValueError:
235                continue # no =, not a relevant filter
236
237            if v.endswith('*'):
238                matchexp = lambda x: x.startswith(v[:-1])
239            else:
240                matchexp = lambda x: x == v
241
242            if k in ('rt', 'if', 'ct'):
243                filters.append(lambda link: any(matchexp(part) for part in (" ".join(getattr(link, k, ()))).split(" ")))
244            elif k in ('href',): # x.href is single valued
245                filters.append(lambda link: matchexp(getattr(link, k)))
246            else:
247                filters.append(lambda link: any(matchexp(part) for part in getattr(link, k, ())))
248
249        while filters:
250            links.links = filter(filters.pop(), links.links)
251        links.links = list(links.links)
252
253        response = link_format_to_message(request, links)
254
255        if request.opt.uri_query and not links.links and \
256                request.remote.is_multicast_locally:
257            if request.opt.no_response is None:
258                # If the filter does not match, multicast requests should not
259                # be responded to -- that's equivalent to a "no_response on
260                # 2.xx" option.
261                response.opt.no_response = 0x02
262
263        return response
264
265class PathCapable:
266    """Class that indicates that a resource promises to parse the uri_path
267    option, and can thus be given requests for :meth:`.render`\ ing that
268    contain a uri_path"""
269
270class Site(interfaces.ObservableResource, PathCapable):
271    """Typical root element that gets passed to a :class:`Context` and contains
272    all the resources that can be found when the endpoint gets accessed as a
273    server.
274
275    This provides easy registration of statical resources. Add resources at
276    absolute locations using the :meth:`.add_resource` method.
277
278    For example, the site at
279
280    >>> site = Site()
281    >>> site.add_resource(["hello"], Resource())
282
283    will have requests to </hello> rendered by the new resource.
284
285    You can add another Site (or another instance of :class:`PathCapable`) as
286    well, those will be nested and integrally reported in a WKCResource. The
287    path of a site should not end with an empty string (ie. a slash in the URI)
288    -- the child site's own root resource will then have the trailing slash
289    address.  Subsites can not have link-header attributes on their own (eg.
290    `rt`) and will never respond to a request that does not at least contain a
291    single slash after the the given path part.
292
293    For example,
294
295    >>> batch = Site()
296    >>> batch.add_resource(["light1"], Resource())
297    >>> batch.add_resource(["light2"], Resource())
298    >>> batch.add_resource([], Resource())
299    >>> s = Site()
300    >>> s.add_resource(["batch"], batch)
301
302    will have the three created resources rendered at </batch/light1>,
303    </batch/light2> and </batch/>.
304
305    If it is necessary to respond to requests to </batch> or report its
306    attributes in .well-known/core in addition to the above, a non-PathCapable
307    resource can be added with the same path. This is usually considered an odd
308    design, not fully supported, and for example doesn't support removal of
309    resources from the site.
310    """
311
312    def __init__(self):
313        self._resources = {}
314        self._subsites = {}
315
316    async def needs_blockwise_assembly(self, request):
317        try:
318            child, subrequest = self._find_child_and_pathstripped_message(request)
319        except KeyError:
320            return True
321        else:
322            return await child.needs_blockwise_assembly(subrequest)
323
324    def _find_child_and_pathstripped_message(self, request):
325        """Given a request, find the child that will handle it, and strip all
326        path components from the request that are covered by the child's
327        position within the site. Returns the child and a request with a path
328        shortened by the components in the child's path, or raises a
329        KeyError.
330
331        While producing stripped messages, this adds a ._original_request_uri
332        attribute to the messages which holds the request URI before the
333        stripping is started. That allows internal components to access the
334        original URI until there is a variation of the request API that allows
335        accessing this in a better usable way."""
336
337        original_request_uri = getattr(request, '_original_request_uri',
338                request.get_request_uri(local_is_server=True))
339
340        if request.opt.uri_path in self._resources:
341            stripped = request.copy(uri_path=())
342            stripped._original_request_uri = original_request_uri
343            return self._resources[request.opt.uri_path], stripped
344
345        if not request.opt.uri_path:
346            raise KeyError()
347
348        remainder = [request.opt.uri_path[-1]]
349        path = request.opt.uri_path[:-1]
350        while path:
351            if path in self._subsites:
352                res = self._subsites[path]
353                if remainder == [""]:
354                    # sub-sites should see their root resource like sites
355                    remainder = []
356                stripped = request.copy(uri_path=remainder)
357                stripped._original_request_uri = original_request_uri
358                return res, stripped
359            remainder.insert(0, path[-1])
360            path = path[:-1]
361        raise KeyError()
362
363    async def render(self, request):
364        try:
365            child, subrequest = self._find_child_and_pathstripped_message(request)
366        except KeyError:
367            raise error.NotFound()
368        else:
369            return await child.render(subrequest)
370
371    async def add_observation(self, request, serverobservation):
372        try:
373            child, subrequest = self._find_child_and_pathstripped_message(request)
374        except KeyError:
375            return
376
377        try:
378            await child.add_observation(subrequest, serverobservation)
379        except AttributeError:
380            pass
381
382    def add_resource(self, path, resource):
383        if isinstance(path, str):
384            raise ValueError("Paths should be tuples or lists of strings")
385        if isinstance(resource, PathCapable):
386            self._subsites[tuple(path)] = resource
387        else:
388            self._resources[tuple(path)] = resource
389
390    def remove_resource(self, path):
391        try:
392            del self._subsites[tuple(path)]
393        except KeyError:
394            del self._resources[tuple(path)]
395
396    def get_resources_as_linkheader(self):
397        from .util.linkformat import Link, LinkFormat
398
399        links = []
400
401        for path, resource in self._resources.items():
402            if hasattr(resource, "get_link_description"):
403                details = resource.get_link_description()
404            else:
405                details = {}
406            if details is None:
407                continue
408            lh = Link('/' + '/'.join(path), **details)
409
410            links.append(lh)
411
412        for path, resource in self._subsites.items():
413            if hasattr(resource, "get_resources_as_linkheader"):
414                for l in resource.get_resources_as_linkheader().links:
415                    links.append(Link('/' + '/'.join(path) + l.href, l.attr_pairs))
416        return LinkFormat(links)
417