Quellcode für modules.admin.rest

#!/usr/bin/env python3
# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab
#########################################################################
#  Copyright 2018-      Martin Sinn                         m.sinn@gmx.de
#########################################################################
#  Based on code by Anders Pearson
#########################################################################
#  This file is part of SmartHomeNG.
#
#  SmartHomeNG is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  SmartHomeNG is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with SmartHomeNG.  If not, see <http://www.gnu.org/licenses/>.
#########################################################################


import json
import cherrypy
import logging
import jwt


# """
# REST Resource
#
# cherrypy controller mixin to make it easy to build REST applications.
#
# handles nested resources and method-based dispatching.
#
# here's a rough sample of what a controller would look like using this:
#
# cherrypy.root = MainController()
# cherrypy.root.user = UserController()
#
# class PostController(RESTResource):
#     def index(self,post):
#         return post.as_html()
#     index.expose_resource = True
#
#     def delete(self,post):
#         post.destroySelf()
#         return "ok"
#     delete.expose_resource = True
#
#     def update(self,post,title="",body=""):
#         post.title = title
#         post.body = body
#         return "ok"
#     update.expose_resource = True
#
#     def add(self, post, title="", body="")
#         post.title = title
#         post.body = body
#         return "ok"
#     update.expose_resource = True
#
#     def REST_instantiate(self, slug):
#         try:
#             return Post.select(Post.q.slug == slug, Post.q.userID = self.parent.id)[0]
#         except:
#             return None
#
#     def REST_create(self, slug):
#         return Post(slug=slug,user=self.parent)
#
# class UserController(RESTResource):
#     REST_children = {'posts' : PostController()}
#
#     def index(self,user):
#         return user.as_html()
#     index.expose_resource = True
#
#     def delete(self,user):
#         user.destroySelf()
#         return "ok"
#     delete.expose_resource = True
#
#     def update(self,user,fullname="",email=""):
#         user.fullname = fullname
#         user.email = email
#         return "ok"
#     update.expose_resource = True
#
#     def add(self, user, fullname="", email=""):
#         user.fullname = fullname
#         user.email = email
#         return "ok"
#     add.expose_resource = True
#
#     def extra_action(self,user):
#         # do something else
#     extra_action.expose_resource = True
#
#     def REST_instantiate(self, username):
#         try:
#             return User.byUsername(username)
#         except:
#             return None
#
#     def REST_create(self, username):
#         return User(username=username)
#
# then, the site would have urls like:
#
#     /user/bob
#     /user/bob/posts/my-first-post
#     /user/bob/posts/my-second-post
#
# which represent REST resources. calling 'GET /usr/bob' would call the index() method on UserController
# for the user bob. 'PUT /usr/joe' would create a new user with username 'joe'. 'DELETE /usr/joe'
# would delete that user. 'GET /usr/bob/posts/my-first-post' would call index() on the Post Controller
# with the post with the slug 'my-first-post' that is owned by bob.
#
#
# """


[Doku]class RESTResource: """ REST Resource cherrypy controller mixin to make it easy to build REST applications. handles nested resources and method-based dispatching. here's a rough sample of what a controller would look like using this: cherrypy.root = MainController() cherrypy.root.user = UserController() class PostController(RESTResource): def index(self,post): return post.as_html() index.expose_resource = True def delete(self,post): post.destroySelf() return "ok" delete.expose_resource = True def update(self,post,title="",body=""): post.title = title post.body = body return "ok" update.expose_resource = True def add(self, post, title="", body="") post.title = title post.body = body return "ok" update.expose_resource = True def REST_instantiate(self, slug): try: return Post.select(Post.q.slug == slug, Post.q.userID = self.parent.id)[0] except: return None def REST_create(self, slug): return Post(slug=slug,user=self.parent) class UserController(RESTResource): REST_children = {'posts' : PostController()} def index(self,user): return user.as_html() index.expose_resource = True def delete(self,user): user.destroySelf() return "ok" delete.expose_resource = True def update(self,user,fullname="",email=""): user.fullname = fullname user.email = email return "ok" update.expose_resource = True def add(self, user, fullname="", email=""): user.fullname = fullname user.email = email return "ok" add.expose_resource = True def extra_action(self,user): # do something else extra_action.expose_resource = True def REST_instantiate(self, username): try: return User.byUsername(username) except: return None def REST_create(self, username): return User(username=username) then, the site would have urls like: /user/bob /user/bob/posts/my-first-post /user/bob/posts/my-second-post which represent REST resources. calling 'GET /usr/bob' would call the index() method on UserController for the user bob. 'PUT /usr/joe' would create a new user with username 'joe'. 'DELETE /usr/joe' would delete that user. 'GET /usr/bob/posts/my-first-post' would call index() on the Post Controller with the post with the slug 'my-first-post' that is owned by bob. """ REST_dispatch_execute_warnlevel = 'WARNING' # default method mapping. ie, if a GET request is made for # the resource's url, it will try to call an index() method (if it exists); # if a PUT request is made, it will try to call an update() method. # if you prefer other method names, just override these values in your # controller with REST_map REST_defaults = {'DELETE' : 'delete', 'GET' : 'read', 'POST' : 'add', 'PUT' : 'update', 'OPTIONS': 'options'} REST_map = {} # if the resource has children resources, list them here. format is # a dictionary of name -> resource mappings. ie, # # REST_children = {'posts' : PostController()} REST_children = {} logger = logging.getLogger('REST') jwt_secret = 'SmartHomeNG$0815' def set_response_headers(self, vpath=''): """ Set http response headers for CORS support """ # if vpath != 'status': # self.logger.notice(f"set_response_headers ({vpath=}): request headers: {cherrypy.request.headers}") cherrypy.response.headers['Access-Control-Allow-Headers'] = '*' #cherrypy.response.headers['Access-Control-Allow-Origin'] = '*' origin = cherrypy.request.headers.get('Origin', '*') cherrypy.response.headers['Access-Control-Allow-Origin'] = origin cherrypy.response.headers['Access-Control-Allow-Credentials'] = 'true' # if vpath != 'status': # self.logger.notice(f"set_response_headers: response headers for: {cherrypy.response.headers}") @cherrypy.expose def index(self, *vpath, **params): self.logger.info(f"RESTResource.index for class {self.__class__.__name__} - *vpath={vpath}, **params={params}") self.set_response_headers() return self.default(*vpath, **params) def index2(self): self.logger.info("RESTResource index2 (nicht überschrieben){}".format(self.__class__.__name__)) # Methode muss überschrieben werden return def REST_get_jwt_token(self): """ get the (decoded) token that was used to make the request :return: tuple """ # self.logger.debug("REST_get_jwt_token(): cherrypy.request.headers = {}".format(cherrypy.request.headers)) token = cherrypy.request.headers.get('Authorization', '') decoded = {} # self.logger.debug("REST_test_jwt_token(): raw token = {}".format(token)) if token != '': if token.startswith('Bearer '): token = token[len('Bearer '):] # self.logger.debug("REST_test_jwt_token(): jwt token = {}".format(token)) if self.jwt_secret and (len(token) > 0): try: decoded = jwt.decode(token, self.jwt_secret, verify=True, algorithms='HS256') except Exception as e: self.logger.debug("REST_test_jwt_token(): Exception = {}".format(e)) se = format(e) if se.endswith('expired'): error_text = format(e) token = '' decoded = {} self.logger.debug("REST_test_jwt_token(): decoded jwt token = {}".format(decoded)) if len(token) == 0: decoded = {} return decoded def REST_test_jwt_token(self): """ test existance of jwt :return: tuple """ error_text = 'Unauthorized' # self.logger.debug("REST_test_jwt_token(): cherrypy.request.headers = {}".format(cherrypy.request.headers)) # token = cherrypy.request.headers.get('Authorization', '') # decoded = {} # # self.logger.debug("REST_test_jwt_token(): raw token = {}".format(token)) # if token != '': # if token.startswith('Bearer '): # token = token[len('Bearer '):] # # # self.logger.debug("REST_test_jwt_token(): jwt token = {}".format(token)) # if self.jwt_secret and (len(token) > 0): # try: # decoded = jwt.decode(token, self.jwt_secret, verify=True, algorithms='HS256') # except Exception as e: # self.logger.debug("REST_test_jwt_token(): Exception = {}".format(e)) # se = format(e) # if se.endswith('expired'): # error_text = format(e) # token = '' # decoded = {} # self.logger.debug("REST_test_jwt_token(): decoded jwt token = {}".format(decoded)) decoded = self.REST_get_jwt_token() if decoded == {}: return (False, error_text) return (True, '') def REST_dispatch_execute(self, m, method, root, resource, **params): if m and getattr(m, "expose_resource", False): public_root = False if root: public_root = getattr(m, "public_root", False) self.logger.info(f"REST_dispatch_execute(): public_root = '{public_root}'") if not public_root: auth_needed = getattr(m, "authentication_needed", False) self.logger.info(f"REST_dispatch_execute(): {('' if auth_needed else 'No ')}Authentication needed for {method} ({str(m).split()[2]})") if auth_needed: # self.logger.info("REST_dispatch: Authentication needed for {} ({})".format(method, str(m).split()[2])) token_valid, error_text = self.REST_test_jwt_token() if not token_valid: self.logger.info("REST_dispatch_execute(): Authentication failed for {method} ({str(m).split()[2]})") response = {'result': 'error', 'description': error_text} return json.dumps(response) try: return m(resource, **params) except Exception as e: if self.module.rest_dispatch_force_exception: self.logger.notice("The following exception is thrown, due to the configuration in etc/module.yaml:") self.logger.exception(f"REST_dispatch_execute: {resource}: {e.__class__.__name__} {e}") else: self.logger.warning(f"REST_dispatch_execute: {resource}: {e.__class__.__name__} {e}") response = {'result': 'error', 'description': f"{e.__class__.__name__} {e}"} return json.dumps(response) return None def REST_dispatch(self, root, resource, **params): # if this gets called, we assume that default has already # traversed down the tree to the right location and this is # being called for a raw resource method = cherrypy.request.method if method in self.REST_map: try: m = getattr(self,self.REST_map[method]) except: self.logger.info("REST_dispatch *1: Unsupported method = {} for resource '{}'".format(method, resource)) raise cherrypy.HTTPError(status=404) result = self.REST_dispatch_execute(m, method, root, resource, **params) if result != None: return result else: raise cherrypy.NotFound else: if method in self.REST_defaults: try: m = getattr(self,self.REST_defaults[method]) except: self.logger.info("REST_dispatch: Unsupported method = {} for resource '{}'".format(method, resource)) raise cherrypy.HTTPError(status=404) result = self.REST_dispatch_execute(m, method, root, resource, **params) if result != None: return result else: raise cherrypy.NotFound raise cherrypy.NotFound @cherrypy.expose def default(self, *vpath, **params): self.logger.info(f"RESTResource.default: *vpath={vpath}, **params={params} {type(vpath)=}") try: self.set_response_headers(*vpath) except Exception as ex: self.logger.error(f"reas.py default: Exception {ex} - parameters: {vpath}") if not vpath: resource = None # self.logger.info("RESTResource.default: vpath = '{}', params = '{}'".format(list(vpath), dict(**params))) return self.REST_dispatch(True, resource, **params) # return list(**params) self.logger.info(f"RESTResource.default: vpath = '{list(vpath)}', params = '{dict(**params)}'") # Make a copy of vpath in a list vpath = list(vpath) atom = vpath.pop(0) # Coerce the ID to the correct db type resource = self.REST_instantiate(atom) if resource is None: if cherrypy.request.method == "PUT": # PUT is special since it can be used to create # a resource resource = self.REST_create(atom) else: raise cherrypy.NotFound # There may be further virtual path components. # Try to map them to methods in children or this class. if vpath: a = vpath.pop(0) if a in self.REST_children: c = self.REST_children[a] c.parent = resource return c.default(*vpath, **params) method = getattr(self, a, None) #self.logger.notice(f"dir(method): {dir(method)}") if method and getattr(method, "expose_resource", False): return method(resource, *vpath, **params) else: # path component was specified but doesn't # map to anything exposed and callable raise cherrypy.NotFound # No further known vpath components. Call a default handler # based on the method return self.REST_dispatch(False, resource,**params) def REST_instantiate(self,id): """ instantiate a REST resource based on the id this method MUST be overridden in your class. it will be passed the id (from the url fragment) and should return a model object corresponding to the resource. if the object doesn't exist, it should return None rather than throwing an error. if this method returns None and it is a PUT request, REST_create() will be called so you can actually create the resource. """ return id # raise cherrypy.NotFound def REST_create(self,id): """ create a REST resource with the specified id this method should be overridden in your class. this method will be called when a PUT request is made for a resource that doesn't already exist. you should create the resource in this method and return it. """ return id # raise cherrypy.NotFound @cherrypy.expose def options(self, id=None): """ Handle OPTIONS requests """ self.logger.notice("RESTResource.options for class {self.__class__.__name__}") return json.dumps(False) options.expose_resource = True options.authentication_needed = True