This repository has been archived on 2024-05-31. You can view files and clone it, but cannot push or open issues or pull requests.
devicehub-teal/ereuse_devicehub/teal/teal.py
2023-03-21 17:31:43 +01:00

309 lines
11 KiB
Python

import inspect
from typing import Dict, Type
import click_spinner
import ereuse_devicehub.ereuse_utils
import flask_cors
from anytree import Node
from apispec import APISpec
from click import option
from ereuse_devicehub.ereuse_utils import ensure_utf8
from flask import Flask, jsonify
from flask.globals import _app_ctx_stack
from flask_sqlalchemy import SQLAlchemy
from marshmallow import ValidationError
from werkzeug.exceptions import HTTPException, UnprocessableEntity
from ereuse_devicehub.teal.auth import Auth
from ereuse_devicehub.teal.cli import TealCliRunner
from ereuse_devicehub.teal.client import Client
from ereuse_devicehub.teal.config import Config as ConfigClass
from ereuse_devicehub.teal.db import SchemaSQLAlchemy
from ereuse_devicehub.teal.json_util import TealJSONEncoder
from ereuse_devicehub.teal.request import Request
from ereuse_devicehub.teal.resource import Converters, LowerStrConverter, Resource
class Teal(Flask):
"""
An opinionated REST and JSON first server built on Flask using
MongoDB and Marshmallow.
"""
test_client_class = Client
request_class = Request
json_encoder = TealJSONEncoder
cli_context_settings = {'help_option_names': ('-h', '--help')}
test_cli_runner_class = TealCliRunner
def __init__(
self,
config: ConfigClass,
db: SQLAlchemy,
schema: str = None,
import_name=__name__.split('.')[0],
static_url_path=None,
static_folder='static',
static_host=None,
host_matching=False,
subdomain_matching=False,
template_folder='templates',
instance_path=None,
instance_relative_config=False,
root_path=None,
use_init_db=True,
Auth: Type[Auth] = Auth,
):
"""
:param config:
:param db:
:param schema: A string describing the main PostgreSQL's schema.
``None`` disables this functionality.
If you use a factory of apps (for example by using
:func:`teal.teal.prefixed_database_factory`) and then set this
value differently per each app (as each app has a separate config)
you effectively create a `multi-tenant app <https://
news.ycombinator.com/item?id=4268792>`_.
Your models by default will be created in this ``SCHEMA``,
unless you set something like::
class User(db.Model):
__table_args__ = {'schema': 'users'}
In which case this will be created in the ``users`` schema.
Schemas are interesting over having multiple databases (i.e. using
flask-sqlalchemy's data binding) because you can have relationships
between them.
Note that this only works with PostgreSQL.
:param import_name:
:param static_url_path:
:param static_folder:
:param static_host:
:param host_matching:
:param subdomain_matching:
:param template_folder:
:param instance_path:
:param instance_relative_config:
:param root_path:
:param Auth:
"""
self.schema = schema
ensure_utf8(self.__class__.__name__)
super().__init__(
import_name,
static_url_path,
static_folder,
static_host,
host_matching,
subdomain_matching,
template_folder,
instance_path,
instance_relative_config,
root_path,
)
self.config.from_object(config)
flask_cors.CORS(self)
# Load databases
self.auth = Auth()
self.url_map.converters[Converters.lower.name] = LowerStrConverter
self.load_resources()
self.register_error_handler(HTTPException, self._handle_standard_error)
self.register_error_handler(ValidationError, self._handle_validation_error)
self.db = db
db.init_app(self)
if use_init_db:
self.cli.command('init-db', context_settings=self.cli_context_settings)(
self.init_db
)
self.spec = None # type: APISpec
self.apidocs()
# noinspection PyAttributeOutsideInit
def load_resources(self):
self.resources = {} # type: Dict[str, Resource]
"""
The resources definitions loaded on this App, referenced by their
type name.
"""
self.tree = {} # type: Dict[str, Node]
"""
A tree representing the hierarchy of the instances of
ResourceDefinitions. ResourceDefinitions use these nodes to
traverse their hierarchy.
Do not use the normal python class hierarchy as it is global,
thus unreliable if you run different apps with different
schemas (for example, an extension that is only added on the
third app adds a new type of user).
"""
for ResourceDef in self.config['RESOURCE_DEFINITIONS']:
resource_def = ResourceDef(self) # type: Resource
self.register_blueprint(resource_def)
if resource_def.cli_commands:
@self.cli.group(
resource_def.cli_name,
context_settings=self.cli_context_settings,
short_help='{} management.'.format(resource_def.type),
)
def dummy_group():
pass
for (
cli_command,
*args,
) in resource_def.cli_commands: # Register CLI commands
# todo cli commands with multiple arguments end-up reversed
# when teal has been executed multiple times (ex. testing)
# see _param_memo func in click package
dummy_group.command(*args)(cli_command)
# todo should we use resource_def.name instead of type?
# are we going to have collisions? (2 resource_def -> 1 schema)
self.resources[resource_def.type] = resource_def
self.tree[resource_def.type] = Node(resource_def.type)
# Link tree nodes between them
for _type, node in self.tree.items():
resource_def = self.resources[_type]
_, Parent, *superclasses = inspect.getmro(resource_def.__class__)
if Parent is not Resource:
node.parent = self.tree[Parent.type]
@staticmethod
def _handle_standard_error(e: HTTPException):
"""
Handles HTTPExceptions by transforming them to JSON.
"""
try:
response = jsonify(e)
response.status_code = e.code
except (AttributeError, TypeError) as e:
code = getattr(e, 'code', 500)
response = jsonify(
{'message': str(e), 'code': code, 'type': e.__class__.__name__}
)
response.status_code = code
return response
@staticmethod
def _handle_validation_error(e: ValidationError):
data = {
'message': e.messages,
'code': UnprocessableEntity.code,
'type': e.__class__.__name__,
}
response = jsonify(data)
response.status_code = UnprocessableEntity.code
return response
@option(
'--erase/--no-erase',
default=False,
help='Delete all contents from the database (including common schemas)?',
)
@option(
'--exclude-schema',
default=None,
help='Schema to exclude creation (and deletion if --erase is set). '
'Required the SchemaSQLAlchemy.',
)
def init_db(self, erase: bool = False, exclude_schema=None):
"""
Initializes a database from scratch,
creating tables and needed resources.
Note that this does not create the database per se.
If executing this directly, remember to use an app_context.
Resources can hook functions that will be called when this
method executes, by subclassing :meth:`teal.resource.
Resource.load_resource`.
"""
assert _app_ctx_stack.top, 'Use an app context.'
print('Initializing database...'.ljust(30), end='')
with click_spinner.spinner():
if erase:
if exclude_schema: # Using then a schema teal sqlalchemy
assert isinstance(self.db, SchemaSQLAlchemy)
self.db.drop_schema()
else: # using regular flask sqlalchemy
self.db.drop_all()
self._init_db(exclude_schema)
self._init_resources()
self.db.session.commit()
print('done.')
def _init_db(self, exclude_schema=None) -> bool:
"""Where the database is initialized. You can override this.
:return: A flag stating if the database has been created (can
be False in case check is True and the schema already
exists).
"""
if exclude_schema: # Using then a schema teal sqlalchemy
assert isinstance(self.db, SchemaSQLAlchemy)
self.db.create_all(exclude_schema=exclude_schema)
else: # using regular flask sqlalchemy
self.db.create_all()
return True
def _init_resources(self, **kw):
for resource in self.resources.values():
resource.init_db(self.db, **kw)
def apidocs(self):
"""Apidocs configuration and generation."""
self.spec = APISpec(
plugins=(
'apispec.ext.flask',
'apispec.ext.marshmallow',
),
**self.config.get_namespace('API_DOC_CONFIG_'),
)
for name, resource in self.resources.items():
if resource.SCHEMA:
self.spec.definition(
name,
schema=resource.SCHEMA,
extra_fields=self.config.get_namespace('API_DOC_CLASS_'),
)
self.add_url_rule('/apidocs', view_func=self.apidocs_endpoint)
def apidocs_endpoint(self):
"""An endpoint that prints a JSON OpenApi 2.0 specification."""
if not getattr(self, '_apidocs', None):
# We are forced to to this under a request context
for path, view_func in self.view_functions.items():
if path != 'static':
self.spec.add_path(view=view_func)
self._apidocs = self.spec.to_dict()
return jsonify(self._apidocs)
class DumpeableHTTPException(ereuse_devicehub.ereuse_utils.Dumpeable):
"""Exceptions that inherit this class will be able to dump
to dicts and JSONs.
"""
def dump(self):
# todo this is heavily ad-hoc and should be more generic
value = super().dump()
value['type'] = self.__class__.__name__
value['code'] = self.code
value.pop('exc', None)
value.pop('response', None)
if 'data' in value:
value['fields'] = value['data']['messages']
del value['data']
if 'message' not in value:
value['message'] = value.pop('description', str(self))
return value
# Add dump capacity to Werkzeug's HTTPExceptions
HTTPException.__bases__ = HTTPException.__bases__ + (DumpeableHTTPException,)