1# This requires Python 3.6 as it makes use of __init_subclass__
2
3from collections import namedtuple, defaultdict
4import functools
5import inspect
6import abc
7
8import json
9import cbor2 as cbor
10
11from aiocoap import resource, numbers, interfaces
12from aiocoap import GET, PUT, POST, Message, CONTENT, CHANGED
13from aiocoap.error import BadRequest, UnsupportedContentFormat, UnallowedMethod
14
15
16_ContenttypeRenderedHandler = namedtuple("_ContenttypeRenderedHandler", ("method", "accept", "contentformat", "implementation", "responseformat"))
17
18# this could become an alternative to the resource.Resource currently implemented in aiocoap.resource
19
20class ContenttypeRendered(resource._ExposesWellknownAttributes, interfaces.Resource, metaclass=abc.ABCMeta):
21    def __init_subclass__(cls):
22        # __new__ code moved in here to use __ properties
23        cls.__handlers = defaultdict(lambda: {})
24        for member in vars(cls).values():
25            if isinstance(member, _ContenttypeRenderedHandler):
26                for accept in member.accept:
27                    for contentformat in member.contentformat:
28                        cls.__handlers[member.method][(accept, contentformat)] = (member.implementation, member.responseformat)
29
30    @staticmethod
31    def get_handler(accept, *, default=False):
32        """Decorate a method with this to make it the GET handler for a given
33        method and Accept value (or additionally the empty one if default=True).
34
35        FIXME move to ContenttypeRendered
36
37        Methods that accept a payload will get the payload passed as an
38        argument, and be decided based on the Content-Format header (with the
39        Accept header being ignored; possibly, that's a reasons to split this
40        decorator up per-method).
41
42        The method will not be usable by its name any more. It is recommended
43        to use a double-underscore name for thusly decorated methods (eg.
44        __get_plain).
45
46        The method has some freedom in the types it may return (None is treated
47        as an empty payload, strings are encoded in UTF-8). It is unclear yet
48        whether more complex conversions (eg. JSON, CBOR) will be supported by
49        this or need additonal decorators."""
50        def wrapper(func):
51            cf = numbers.media_types_rev[accept]
52            return _ContenttypeRenderedHandler(GET, (cf, None) if default else (cf,), (None,), func, cf)
53        return wrapper
54
55    @staticmethod
56    def put_handler(contentformat, *, default=False):
57        def wrapper(func):
58            cf = numbers.media_types_rev[contentformat]
59            return _ContenttypeRenderedHandler(PUT, (None,), (cf, None) if default else (cf,), func, None)
60        return wrapper
61
62    @staticmethod
63    def empty_post_handler():
64        # i suppose this'll be replaced with something more generic when i add something that needs request or response payloads
65        def wrapper(func):
66            return _ContenttypeRenderedHandler(POST, (None,), (None,), func, None)
67        return wrapper
68
69    async def needs_blockwise_assembly(self, request):
70        return True
71
72    async def render(self, request):
73        cf = request.opt.content_format
74        acc = request.opt.accept
75        raise_class = UnallowedMethod
76        method_would_have_worked = False
77
78        # FIXME: manually walking the MRO is not a nice way to go about this;
79        # is there no other way to query the registered handlers according to
80        # the regular inheritance patterns?
81        for cls in type(self).mro():
82            if not issubclass(cls, ContenttypeRendered) or cls is ContenttypeRendered:
83                continue
84            for_method = cls.__handlers.get(request.code, None)
85            if for_method is None:
86                continue
87            raise_class = UnsupportedContentFormat
88            handler, responseformat = for_method.get((acc, cf), (None, None))
89            if handler is not None:
90                break
91        else:
92            raise raise_class()
93
94        sig = inspect.signature(handler)
95        parameters = set(sig.parameters.keys())
96        parameters.remove("self")
97        kwargs = {}
98        if request.payload and "payload" not in parameters:
99            raise BadRequest("Unexpected payload")
100        if request.opt.uri_query and "query" not in parameters:
101            raise BadRequest("Unexepcted query arguments")
102
103        for p in parameters:
104            if p == "payload":
105                kwargs['payload'] = request.payload
106            elif p == "request_uri":
107                # BIG FIXME: This does not give the expected results due to the
108                # URI path stripping in Site, and because Message gets the
109                # requested authority wrong on the server side.
110                kwargs["request_uri"] = request.get_request_uri()
111            else:
112                raise RuntimeError("Unexpected argument requested: %s" % p)
113        payload = handler(self, **kwargs)
114
115        if payload is None:
116            payload = b""
117        elif isinstance(payload, str):
118            payload = payload.encode('utf8')
119
120        return Message(
121                code={GET: CONTENT, PUT: CHANGED}[request.code],
122                payload=payload,
123                content_format=responseformat,
124                no_response=request.opt.no_response,
125                )
126
127class ObservableContenttypeRendered(ContenttypeRendered, interfaces.ObservableResource):
128    def __init__(self):
129        super().__init__()
130
131        self._callbacks = set()
132
133    async def add_observation(self, request, serverobservation):
134        """Implementation of interfaces.ObservableResource"""
135        callback = serverobservation.trigger
136        self._callbacks.add(callback)
137        remover = functools.partial(self._callbacks.remove, callback)
138        serverobservation.accept(remover)
139
140    def add_valuechange_callback(self, cb):
141        """Call this when you want a callback outside of aiocoap called
142        whenever value_change is called, typically because the callback
143        recipient would extract the state of the resource in a non-CoAP way."""
144        self._callbacks.add(cb)
145
146    def value_changed(self):
147        """Call this whenever the object was modified in such a way that any
148        rendition might change."""
149        for c in self._callbacks:
150            c()
151
152
153class SenmlResource(ObservableContenttypeRendered):
154    """A resource that has its state in .value; this class implements SenML
155    getters and setters as well as plain text.
156
157    Implementors need to provide a .value instance property as well as
158    .jsonsenml_key / .cborsenml_key class properties for picking the right
159    value key in the respective SenML serialization, and a .valuetype type that
160    is used both for converting any text/plain'ly PUT string as well as for
161    filtering (typically copy-constructing) data from SenML."""
162
163    @ContenttypeRendered.get_handler('application/senml+json')
164    def __jsonsenml_get(self, request_uri):
165        return json.dumps([{"n": request_uri, self.jsonsenml_key: self.value}])
166
167    @ContenttypeRendered.get_handler('application/senml+cbor')
168    def __cborsenml_get(self, request_uri):
169        return cbor.dumps([{0: request_uri, self.cborsenml_key: self.value}])
170
171    @ContenttypeRendered.get_handler('text/plain;charset=utf-8', default=True)
172    def __textplain_get(self):
173        return str(self.value)
174
175    @ContenttypeRendered.put_handler('application/senml+json')
176    def __jsonsenml_set(self, payload, request_uri):
177        try:
178            new = json.loads(payload.decode('utf8'))
179            if len(new) != 1 or new[0].get("bn", "") + new[0].get("n", "") != request_uri:
180                raise BadRequest("Not a single record pertaining to this resource")
181            self.value = self.valuetype(new[0][self.jsonsenml_key])
182        except (KeyError, ValueError):
183            raise BadRequest()
184
185    @ContenttypeRendered.put_handler('application/senml+cbor')
186    def __cborsenml_set(self, payload, request_uri):
187        try:
188            new = cbor.loads(payload)
189            if len(new) != 1 or new[0].get(-2, "") + new[0].get(0, "") != request_uri:
190                raise BadRequest("Not a single record pertaining to this resource")
191            self.value = self.valuetype(new[self.cborsenml_key])
192        except (KeyError, ValueError):
193            raise BadRequest()
194
195    @ContenttypeRendered.put_handler('text/plain;charset=utf-8', default=True)
196    def __textplain_set(self, payload):
197        try:
198            self.value = self.valuetype(payload.decode('utf8').strip())
199        except ValueError:
200            raise BadRequest()
201
202class BooleanResource(SenmlResource):
203    jsonsenml_key = "vb"
204    cborsenml_key = 4
205    valuetype = bool
206
207    @ContenttypeRendered.get_handler('text/plain;charset=utf-8', default=True)
208    def __textplain_get(self):
209        return "01"[self.value]
210
211    @ContenttypeRendered.put_handler('text/plain;charset=utf-8', default=True)
212    def __textplain_set(self, payload):
213        try:
214            self.value = {"0": False, "1": True}[payload.decode('utf8').strip()]
215        except (KeyError, ValueError):
216            raise BadRequest()
217
218class FloatResource(SenmlResource):
219    jsonsenml_key = "v"
220    cborsenml_key = 2
221    valuetype = float
222
223class StringResource(SenmlResource):
224    jsonsenml_key = "vs"
225    cborsenml_key = 3
226    valuetype = str
227
228class SubsiteBatch(ObservableContenttypeRendered):
229    """An implementation of a CoRE interfaces batch that is the root resource
230    of a subsite
231
232    This currently depends on being added to the site after all other
233    resources; it could enumerate them later, but it installs its own
234    value_changed callbacks of other members at initialization time."""
235
236    if_ = 'core.b'
237
238    def __init__(self, site):
239        self.site = site
240        super().__init__()
241
242        # FIXME this ties in directly into resource.Site's privates, AND it
243        # should actually react to changes in the site, AND overriding the
244        # callback to install an own hook is not compatible with any other
245        # ObservableResource implementations
246        for subres in self.site._resources.values():
247            if isinstance(subres, ObservableContenttypeRendered):
248                subres.add_valuechange_callback(self.value_changed)
249        for subsite in self.site._subsites.values():
250            if not isinstance(subsite, resource.Site):
251                continue # can't access privates
252            if () not in subsite._resources:
253                continue # no root, better not try
254            rootres = subsite._resources[()]
255            if not isinstance(rootres, SubsiteBatch):
256                continue
257            rootres.add_valuechange_callback(self.value_changed)
258
259    def __get_records(self, request_uri):
260        records = []
261        # FIXME this ties in directly into resource.Site's privates
262        for path, subres in self.site._resources.items():
263            if isinstance(subres, SenmlResource): # this conveniently filters out self as well
264                records.append({'n': '/'.join(path), subres.jsonsenml_key: subres.value})
265        print(self.site, vars(self.site))
266        for path, subsite in self.site._subsites.items():
267            if not isinstance(subsite, resource.Site):
268                continue # can't access privates
269            if () not in subsite._resources:
270                continue # no root, better not try
271            rootres = subsite._resources[()]
272            if not isinstance(rootres, SubsiteBatch):
273                continue
274            for r in rootres.__get_records(request_uri):
275                r = dict(**r)
276                r.pop('bn', None)
277                r['n'] = "/".join(path) + "/" + r['n']
278                records.append(r)
279        records[0]['bn'] = request_uri
280        return records
281
282    @ContenttypeRendered.get_handler('application/senml+json', default=True)
283    def __regular_get(self, request_uri):
284        return json.dumps(self.__get_records(request_uri))
285
286
287class PythonBacked(SenmlResource):
288    """Provides a .value stored in regular Python, but pulls the
289    .value_changed() trigger on every change"""
290
291    def _set_value(self, value):
292        changed = not hasattr(self, '_value') or self._value != value
293        self._value = value
294        if changed:
295            self.value_changed()
296
297    value = property(lambda self: self._value, _set_value)
298