309 lines
11 KiB
Python
309 lines
11 KiB
Python
import inspect
|
|
from typing import Dict, Type
|
|
|
|
import click_spinner
|
|
import ereuse_utils
|
|
import flask_cors
|
|
from anytree import Node
|
|
from apispec import APISpec
|
|
from click import option
|
|
from 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_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,)
|