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