-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/tests.xml b/.idea/runConfigurations/tests__local_infrastructure_.xml
similarity index 78%
rename from .idea/runConfigurations/tests.xml
rename to .idea/runConfigurations/tests__local_infrastructure_.xml
index cd0ad61..e149f59 100644
--- a/.idea/runConfigurations/tests.xml
+++ b/.idea/runConfigurations/tests__local_infrastructure_.xml
@@ -1,12 +1,12 @@
-
+
diff --git a/README.md b/README.md
index e397e59..d42b070 100644
--- a/README.md
+++ b/README.md
@@ -175,8 +175,8 @@ $ docker run --rm mrmat-python-api-flask:0.0.1
## Configuration
-You can provide configuration in a JSON file pointed to by the FLASK_CONFIG environment variable. The file is expected
-to be in the following format. The configuration file can contain anything the app picks up (see
+You can provide configuration in a JSON file pointed to by the APP_CONFIG environment variable, which defaults to
+`~/etc/mrmat-python-api-flask.json`. The configuration file can contain anything the app picks up (see
`mrmat_python_api_flask/__init__.py`) but should typically contain the following three items:
```json
@@ -274,7 +274,7 @@ No authentication/authorisation is enforced by default. Token-based authenticati
enforced by configuring connectivity to such extra central infrastructure. For this to happen, you must register the
app in your OIDC IdP and create an OIDC secrets configuration file (json) of the following structure, which you
subsequently point to using the `--oidc-secrets` option of the CLI or the `OIDC_CLIENT_SECRETS` key of the configuration
-file pointed to by the `FLASK_CONFIG` environment variable.
+file pointed to by the `APP_CONFIG` environment variable.
```json
{
@@ -356,3 +356,6 @@ following form. The DISCOVERY_URL must point to the URL where the IdP publishes
```
>The client requires configuration with OIDC secrets and currently implements the Device code flow
+
+### Logging
+
diff --git a/ci/__init__.py b/ci/__init__.py
index 798ea25..28b6707 100644
--- a/ci/__init__.py
+++ b/ci/__init__.py
@@ -21,6 +21,11 @@
# SOFTWARE.
#
+"""
+Build-time only module to determine the version of this project from CI and if
+not provided a reasonable development-time default.
+"""
+
import os
version = os.environ.get('MRMAT_VERSION', '0.0.0.dev0')
diff --git a/migrations/versions/1bea80b612cc_initial.py b/migrations/versions/2c4268119c7e_initial.py
similarity index 91%
rename from migrations/versions/1bea80b612cc_initial.py
rename to migrations/versions/2c4268119c7e_initial.py
index be6ec5f..96546d3 100644
--- a/migrations/versions/1bea80b612cc_initial.py
+++ b/migrations/versions/2c4268119c7e_initial.py
@@ -1,8 +1,8 @@
-"""initial
+"""newest
-Revision ID: 1bea80b612cc
+Revision ID: 2c4268119c7e
Revises:
-Create Date: 2021-12-25 13:31:36.003378
+Create Date: 2021-12-31 09:46:33.167275
"""
from alembic import op
@@ -10,7 +10,7 @@
# revision identifiers, used by Alembic.
-revision = '1bea80b612cc'
+revision = '2c4268119c7e'
down_revision = None
branch_labels = None
depends_on = None
diff --git a/mrmat_python_api_flask/__init__.py b/mrmat_python_api_flask/__init__.py
index 76c9b1b..863e83c 100644
--- a/mrmat_python_api_flask/__init__.py
+++ b/mrmat_python_api_flask/__init__.py
@@ -25,25 +25,76 @@
import sys
import os
-import logging
+import logging.config
import secrets
import importlib.metadata
-from rich.console import Console
-from rich.logging import RichHandler
-
-from flask import Flask
+import flask
+from flask import Flask, has_request_context, request
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_marshmallow import Marshmallow
from flask_oidc import OpenIDConnect
+from flask_smorest import Api
+
+
+#
+# Establish consistent logging
+# The logger we obtain here is an operational logger, not the one logging requests. The former uses the matching
+# logger '__name__', the latter uses 'werkzeug'
+
-logging.basicConfig(level='INFO',
- handlers=[RichHandler(rich_tracebacks=True,
- show_path=False,
- omit_repeated_times=False)])
+class RequestFormatter(logging.Formatter):
+ """
+ Formatter for requests
+ """
+ def format(self, record):
+ if has_request_context():
+ record.blueprint = request.blueprint
+ record.url = request.full_path
+ record.remote_addr = request.remote_addr
+ record.user_agent = request.user_agent
+ else:
+ record.url = None
+ record.remote_addr = None
+ return super().format(record)
+
+
+logging.config.dictConfig({
+ 'version': 1,
+ 'formatters': {
+ 'default': {
+ 'class': 'logging.Formatter',
+ 'format': '%(asctime)s %(name)-22s %(levelname)-8s %(message)s'
+ }
+ },
+ 'handlers': {
+ 'console': {
+ 'class': 'logging.StreamHandler',
+ 'level': 'INFO',
+ 'formatter': 'default'
+ }
+ },
+ 'loggers': {
+ __name__: {
+ 'level': 'INFO',
+ 'handlers': ['console'],
+ 'propagate': False
+ }
+ },
+ 'root': {
+ 'level': 'INFO',
+ 'formatter': 'default',
+ 'handlers': ['console']
+ }
+})
log = logging.getLogger(__name__)
-console = Console()
+
+#
+# Establish consistent logging
+
+#
+# Determine the version we're at and a version header we add to each response
try:
__version__ = importlib.metadata.version('mrmat-python-api-flask')
@@ -51,10 +102,14 @@
# You have not actually installed the wheel yet. We may be within CI so pick that version or fall back
__version__ = os.environ.get('MRMAT_VERSION', '0.0.0.dev0')
+#
+# Initialise supporting services
+
db = SQLAlchemy()
ma = Marshmallow()
migrate = Migrate()
oidc = OpenIDConnect()
+api = Api()
def create_app(config_override=None, instance_path=None):
@@ -81,11 +136,23 @@ def create_app(config_override=None, instance_path=None):
app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False)
app.config.setdefault('OIDC_USER_INFO_ENABLED', True)
app.config.setdefault('OIDC_RESOURCE_SERVER_ONLY', True)
- if 'FLASK_CONFIG' in os.environ and os.path.exists(os.path.expanduser(os.environ['FLASK_CONFIG'])):
- app.config.from_json(os.path.expanduser(os.environ['FLASK_CONFIG']))
+ app.config.setdefault('API_TITLE', 'MrMat :: Python :: API :: Flask')
+ app.config.setdefault('API_VERSION', __version__)
+ app.config.setdefault('OPENAPI_VERSION', '3.0.2')
+ app.config.setdefault('OPENAPI_URL_PREFIX', '/doc')
+ app.config.setdefault('OPENAPI_JSON_PATH', '/openapi.json')
+ app.config.setdefault('OPENAPI_SWAGGER_UI_PATH', '/swagger-ui')
+ app.config.setdefault('OPENAPI_SWAGGER_UI_URL', 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@4.5.0/')
+ app_config_file = os.path.expanduser(os.environ.get('APP_CONFIG', '~/etc/mrmat-python-api-flask.json'))
+ if os.path.exists(app_config_file):
+ log.info('Applying configuration from %s', app_config_file)
+ app.config.from_json(app_config_file)
if config_override is not None:
+ for override in config_override:
+ log.info('Overriding configuration for %s from the command line', override)
app.config.from_mapping(config_override)
if app.config['SECRET_KEY'] is None:
+ log.warning('Generating new secret key')
app.config['SECRET_KEY'] = secrets.token_urlsafe(16)
#
@@ -93,12 +160,12 @@ def create_app(config_override=None, instance_path=None):
try:
if not os.path.exists(app.instance_path):
- app.logger.info(f'Creating new instance path at {app.instance_path}')
+ log.info('Creating new instance path at %s', app.instance_path)
os.makedirs(app.instance_path)
else:
- app.logger.info(f'Using existing instance path at {app.instance_path}')
+ log.info('Using existing instance path at %s', app.instance_path)
except OSError:
- app.logger.error(f'Failed to create new instance path at {app.instance_path}')
+ log.error('Failed to create new instance path at %s', app.instance_path)
sys.exit(1)
# When using Flask-SQLAlchemy, there is no need to explicitly import DAO classes because they themselves
@@ -107,23 +174,44 @@ def create_app(config_override=None, instance_path=None):
db.init_app(app)
migrate.init_app(app, db)
ma.init_app(app)
+ api.init_app(app)
if 'OIDC_CLIENT_SECRETS' in app.config.keys():
oidc.init_app(app)
else:
- app.logger.warning('Running without any authentication/authorisation')
+ log.warning('Running without any authentication/authorisation')
+
+ #
+ # Security Schemes
+
+ api.spec.components.security_scheme('openId', dict(
+ type='openIdConnect',
+ description='MrMat OIDC',
+ openIdConnectUrl='http://localhost:8080/auth/realms/master'
+ '/.well-known/openid-configuration'
+ ))
#
# Import and register our APIs here
- from mrmat_python_api_flask.apis.healthz import api_healthz # pylint: disable=import-outside-toplevel
+ from mrmat_python_api_flask.apis.healthz import api_healthz # pylint: disable=import-outside-toplevel
from mrmat_python_api_flask.apis.greeting.v1 import api_greeting_v1 # pylint: disable=import-outside-toplevel
from mrmat_python_api_flask.apis.greeting.v2 import api_greeting_v2 # pylint: disable=import-outside-toplevel
from mrmat_python_api_flask.apis.greeting.v3 import api_greeting_v3 # pylint: disable=import-outside-toplevel
from mrmat_python_api_flask.apis.resource.v1 import api_resource_v1 # pylint: disable=import-outside-toplevel
- app.register_blueprint(api_healthz, url_prefix='/healthz')
- app.register_blueprint(api_greeting_v1, url_prefix='/api/greeting/v1')
- app.register_blueprint(api_greeting_v2, url_prefix='/api/greeting/v2')
- app.register_blueprint(api_greeting_v3, url_prefix='/api/greeting/v3')
- app.register_blueprint(api_resource_v1, url_prefix='/api/resource/v1')
+ api.register_blueprint(api_healthz, url_prefix='/healthz')
+ api.register_blueprint(api_greeting_v1, url_prefix='/api/greeting/v1')
+ api.register_blueprint(api_greeting_v2, url_prefix='/api/greeting/v2')
+ api.register_blueprint(api_greeting_v3, url_prefix='/api/greeting/v3')
+ api.register_blueprint(api_resource_v1, url_prefix='/api/resource/v1')
+
+ #
+ # Postprocess the request
+ # Log its result and add our version header to it
+
+ @app.after_request
+ def after_request(response: flask.Response) -> flask.Response:
+ log.info('[%s]', response.status_code)
+ response.headers.add('X-MrMat-Python-API-Flask-Version', __version__)
+ return response
return app
diff --git a/mrmat_python_api_flask/apis/__init__.py b/mrmat_python_api_flask/apis/__init__.py
index c386ce8..d5a36d1 100644
--- a/mrmat_python_api_flask/apis/__init__.py
+++ b/mrmat_python_api_flask/apis/__init__.py
@@ -19,3 +19,56 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
+
+"""
+Code that can be re-used by all APIs
+"""
+
+from typing import Optional
+from marshmallow import fields
+
+from mrmat_python_api_flask import ma
+
+
+class StatusOutputSchema(ma.Schema):
+ """
+ A schema for a generic status message returned via HTTP
+ """
+ class Meta:
+ fields = ('code', 'message')
+
+ code = fields.Int(
+ default=200,
+ metadata={
+ 'description': 'An integer status code which will typically match the HTTP status code'
+ }
+ )
+ message = fields.Str(
+ required=True,
+ dump_only=True,
+ metadata={
+ 'description': 'A human-readable message'
+ }
+ )
+
+ def __init__(self, code: Optional[int] = 200, message: Optional[str] = 'OK'):
+ super().__init__()
+ self.code = code
+ self.message = message
+
+
+status_output = StatusOutputSchema()
+
+
+def status(code: Optional[int] = 200, message: Optional[str] = 'OK') -> dict:
+ """
+ A utility to return a standardised HTTP status message
+ Args:
+ code: Status code, typically matches the HTTP status code
+ message: Human-readable message
+
+ Returns:
+ A dict to be rendered into JSON
+ """
+ status_message = StatusOutputSchema(code=code, message=message)
+ return status_output.dump(status_message)
diff --git a/mrmat_python_api_flask/apis/greeting/v1/api.py b/mrmat_python_api_flask/apis/greeting/v1/api.py
index d2cd2a2..aad319d 100644
--- a/mrmat_python_api_flask/apis/greeting/v1/api.py
+++ b/mrmat_python_api_flask/apis/greeting/v1/api.py
@@ -20,22 +20,28 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
-"""Blueprint for the Greeting API in V1
"""
-from flask.views import MethodView
-from flask_smorest import Blueprint
-from .model import greeting_v1_output, GreetingV1Output
+Blueprint for the Greeting API in V1
+"""
-bp = Blueprint('greeting_v1',
- __name__,
- description='Greeting V1 API')
+import logging
+
+from flask_smorest import Blueprint
+from mrmat_python_api_flask.apis.greeting.v1.model import greeting_v1_output, GreetingV1Output
+bp = Blueprint('greeting_v1', __name__, description='Greeting V1 API')
+log = logging.getLogger('api')
-@bp.route('/')
-class GreetingV1(MethodView):
- @bp.response(200, GreetingV1Output)
- def get(self):
- """Get a Hello World message
- """
- return greeting_v1_output.dump({'message': 'Hello World'}), 200
+@bp.route('/', methods=['GET'])
+@bp.response(200, GreetingV1Output)
+@bp.doc(summary='Get an anonymous greeting',
+ description='This version of the greeting API does not have a means to determine who you are')
+def get_greeting():
+ """
+ Receive a Hello World message
+ Returns:
+ A plain-text hello world message
+ """
+ log.info('v1/helloworld')
+ return greeting_v1_output.dump({'message': 'Hello World'}), 200
diff --git a/mrmat_python_api_flask/apis/greeting/v1/model.py b/mrmat_python_api_flask/apis/greeting/v1/model.py
index a20ddd0..cd77038 100644
--- a/mrmat_python_api_flask/apis/greeting/v1/model.py
+++ b/mrmat_python_api_flask/apis/greeting/v1/model.py
@@ -35,7 +35,7 @@ class Meta:
required=True,
dump_only=True,
metadata={
- 'description': 'The message returned'
+ 'description': 'A greeting message'
}
)
diff --git a/mrmat_python_api_flask/apis/greeting/v2/api.py b/mrmat_python_api_flask/apis/greeting/v2/api.py
index 4b63c5d..d3337ba 100644
--- a/mrmat_python_api_flask/apis/greeting/v2/api.py
+++ b/mrmat_python_api_flask/apis/greeting/v2/api.py
@@ -20,34 +20,32 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
-"""Blueprint for the Greeting API in V2
+"""
+Blueprint for the Greeting API in V2
"""
-from flask.views import MethodView
from flask_smorest import Blueprint
from .model import greeting_v2_output, GreetingV2Output, GreetingV2Input
-bp = Blueprint('greeting_v2',
- __name__,
- description='Greeting V2 API')
+bp = Blueprint('greeting_v2', __name__, description='Greeting V2 API')
-@bp.route('/')
-class GreetingV2(MethodView):
- """GreetingV2 API Implementation
+@bp.route('/', methods=['GET'])
+@bp.arguments(GreetingV2Input,
+ description='The name to greet',
+ location='query',
+ required=False)
+@bp.response(200, GreetingV2Output)
+@bp.doc(summary='Get a greeting for a given name',
+ description='This version of the greeting API allows you to specify who to greet')
+def get(greeting_input):
"""
-
- @bp.arguments(GreetingV2Input,
- description='The name to greet',
- location='query',
- required=False,
- as_kwargs=True)
- @bp.response(200, GreetingV2Output)
- def get(self, **kwargs):
- """Get a named greeting
- ---
- It is possible to place logic here like we do for safe_name, but if we parse
- the GreetingV2Input via MarshMallow then we can also set a 'default' or 'missing' there.
- """
- safe_name: str = kwargs['name'] or 'World'
- return greeting_v2_output.dump({'message': f'Hello {safe_name}'}), 200
+ Get a named greeting
+ Returns:
+ A named greeting in JSON
+ ---
+ It is possible to place logic here like we do for safe_name, but if we parse
+ the GreetingV2Input via MarshMallow then we can also set a 'default' or 'missing' there.
+ """
+ safe_name: str = greeting_input['name'] or 'World'
+ return greeting_v2_output.dump({'message': f'Hello {safe_name}'}), 200
diff --git a/mrmat_python_api_flask/apis/greeting/v2/model.py b/mrmat_python_api_flask/apis/greeting/v2/model.py
index 146908f..a8f8365 100644
--- a/mrmat_python_api_flask/apis/greeting/v2/model.py
+++ b/mrmat_python_api_flask/apis/greeting/v2/model.py
@@ -49,7 +49,7 @@ class Meta:
required=True,
dump_only=True,
metadata={
- 'description': 'The message returned'
+ 'description': 'A greeting message'
}
)
diff --git a/mrmat_python_api_flask/apis/greeting/v3/api.py b/mrmat_python_api_flask/apis/greeting/v3/api.py
index 913372d..2821ed8 100644
--- a/mrmat_python_api_flask/apis/greeting/v3/api.py
+++ b/mrmat_python_api_flask/apis/greeting/v3/api.py
@@ -20,17 +20,29 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
-"""Blueprint for the Greeting API in V3
+"""
+Blueprint for the Greeting API in V3
"""
-from flask import Blueprint, g
+from flask import g
+from flask_smorest import Blueprint
from mrmat_python_api_flask import oidc
+from .model import greeting_v3_output, GreetingV3Output
-bp = Blueprint('greeting_v3', __name__)
+bp = Blueprint('greeting_v3', __name__, description='Greeting V3 API')
@bp.route('/', methods=['GET'])
+@bp.response(200, schema=GreetingV3Output)
+@bp.doc(summary='Get a greeting for the authenticated name',
+ description='This version of the greeting API knows who you are',
+ security=[{'openId': ['profile']}])
@oidc.accept_token(require_token=True)
def get():
- return {'message': f'Hello {g.oidc_token_info["username"]}'}, 200
+ """
+ Get a named greeting for the authenticated user
+ Returns:
+ A named greeting in JSON
+ """
+ return greeting_v3_output.dump(dict(message=f'Hello {g.oidc_token_info["username"]}')), 200
diff --git a/mrmat_python_api_flask/apis/greeting/v3/model.py b/mrmat_python_api_flask/apis/greeting/v3/model.py
index 6a1f372..a543ea7 100644
--- a/mrmat_python_api_flask/apis/greeting/v3/model.py
+++ b/mrmat_python_api_flask/apis/greeting/v3/model.py
@@ -27,20 +27,6 @@
from mrmat_python_api_flask import ma
-class GreetingV3Input(ma.Schema):
- class Meta:
- fields: ('name',)
-
- name = fields.Str(
- required=False,
- load_only=True,
- missing='Stranger',
- metadata={
- 'description': 'The name to greet'
- }
- )
-
-
class GreetingV3Output(ma.Schema):
class Meta:
fields = ('message',)
diff --git a/mrmat_python_api_flask/apis/healthz/__init__.py b/mrmat_python_api_flask/apis/healthz/__init__.py
index 9fd9dbd..60b7b26 100644
--- a/mrmat_python_api_flask/apis/healthz/__init__.py
+++ b/mrmat_python_api_flask/apis/healthz/__init__.py
@@ -23,5 +23,4 @@
"""Pluggable blueprint of the Health API
"""
-from .model import HealthzOutput # noqa: F401
from .api import bp as api_healthz # noqa: F401
diff --git a/mrmat_python_api_flask/apis/healthz/api.py b/mrmat_python_api_flask/apis/healthz/api.py
index f65781f..5390c42 100644
--- a/mrmat_python_api_flask/apis/healthz/api.py
+++ b/mrmat_python_api_flask/apis/healthz/api.py
@@ -20,21 +20,25 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
-"""Blueprint for the Healthz API
+"""
+Blueprint for the Healthz API
"""
-from flask.views import MethodView
from flask_smorest import Blueprint
-from .model import HealthzOutput, healthz_output
-bp = Blueprint('healthz',
- __name__,
- description='Health API')
+from mrmat_python_api_flask.apis import status, StatusOutputSchema
+bp = Blueprint('healthz', __name__, description='Health API')
-@bp.route('/')
-class Healthz(MethodView):
- @bp.response(200, HealthzOutput)
- def get(self):
- return healthz_output.dump({'status': 'OK'}), 200
+@bp.route('/', methods=['GET'])
+@bp.response(200, StatusOutputSchema)
+@bp.doc(summary='Get an indication of application health',
+ description='Assess application health')
+def get():
+ """
+ Respond with the app health status
+ Returns:
+ A status response
+ """
+ return status(code=200, message='OK'), 200
diff --git a/mrmat_python_api_flask/apis/healthz/model.py b/mrmat_python_api_flask/apis/healthz/model.py
deleted file mode 100644
index d3c305b..0000000
--- a/mrmat_python_api_flask/apis/healthz/model.py
+++ /dev/null
@@ -1,42 +0,0 @@
-# MIT License
-#
-# Copyright (c) 2021 MrMat
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-"""Healthz API Model"""
-
-from marshmallow import fields
-
-from mrmat_python_api_flask import ma
-
-
-class HealthzOutput(ma.Schema):
- class Meta:
- fields = ('status',)
-
- status = fields.Str(
- required=True,
- dump_only=True,
- metadata={
- 'description': 'An indication of application health'
- })
-
-
-healthz_output = HealthzOutput()
diff --git a/mrmat_python_api_flask/apis/resource/v1/__init__.py b/mrmat_python_api_flask/apis/resource/v1/__init__.py
index d2ca02f..52bde28 100644
--- a/mrmat_python_api_flask/apis/resource/v1/__init__.py
+++ b/mrmat_python_api_flask/apis/resource/v1/__init__.py
@@ -23,5 +23,5 @@
"""Pluggable blueprint of the Resource API v1
"""
-from .api import bp as api_resource_v1 # noqa: F401
-from .model import Owner, Resource, OwnerSchema, ResourceSchema # noqa: F401
+from .api import bp as api_resource_v1 # noqa: F401
+from .model import Owner, Resource, OwnerSchema, ResourceSchema # noqa: F401
diff --git a/mrmat_python_api_flask/apis/resource/v1/api.py b/mrmat_python_api_flask/apis/resource/v1/api.py
index 7fb3247..5ef6ef7 100644
--- a/mrmat_python_api_flask/apis/resource/v1/api.py
+++ b/mrmat_python_api_flask/apis/resource/v1/api.py
@@ -26,13 +26,15 @@
from typing import Tuple
from werkzeug.local import LocalProxy
-from flask import Blueprint, request, g, current_app
+from flask import request, g, current_app
+from flask_smorest import Blueprint
from marshmallow import ValidationError
from mrmat_python_api_flask import db, oidc
-from .model import Owner, Resource, resource_schema, resources_schema
+from mrmat_python_api_flask.apis import status
+from .model import Owner, Resource, ResourceSchema, resource_schema, resources_schema
-bp = Blueprint('resource_v1', __name__)
+bp = Blueprint('resource_v1', __name__, description='Resource V1 API')
logger = LocalProxy(lambda: current_app.logger)
@@ -42,6 +44,10 @@ def _extract_identity() -> Tuple:
@bp.route('/', methods=['GET'])
+@bp.doc(summary='Get all known resources',
+ description='Returns all currently known resources and their metadata',
+ security=[{'openId': ['mpaf-read']}])
+@bp.response(200, schema=ResourceSchema(many=True))
@oidc.accept_token(require_token=True, scopes_required=['mpaf-read'])
def get_all():
(client_id, name) = _extract_identity()
@@ -51,17 +57,29 @@ def get_all():
@bp.route('/', methods=['GET'])
+@bp.doc(summary='Get a single resource',
+ description='Return a single resource identified by its resource id.',
+ security=[{'openId': ['mpaf-read']}])
+@bp.response(200, schema=ResourceSchema)
@oidc.accept_token(require_token=True, scopes_required=['mpaf-read'])
def get_one(i: int):
(client_id, name) = _extract_identity()
logger.info(f'Called by {client_id} for {name}')
resource = Resource.query.filter(Resource.id == i).one_or_none()
if resource is None:
- return {'status': 404, 'message': f'Unable to find entry with identifier {i} in database'}, 404
+ return status(code=404, message='Unable to find a resource with this id'), 404
return resource_schema.dump(resource), 200
@bp.route('/', methods=['POST'])
+@bp.doc(summary='Create a resource',
+ description='Create a resource owned by the authenticated user',
+ security=[{'openId': ['mpaf-write']}])
+@bp.arguments(ResourceSchema,
+ location='json',
+ required=True,
+ description='The resource to create')
+@bp.response(200, schema=ResourceSchema)
@oidc.accept_token(require_token=True, scopes_required=['mpaf-write'])
def create():
(client_id, name) = _extract_identity()
@@ -69,7 +87,7 @@ def create():
try:
json_body = request.get_json()
if not json_body:
- return {'message': 'No input data provided'}, 400
+ return status(code=400, message='Missing required input data'), 400
body = resource_schema.load(request.get_json())
except ValidationError as ve:
return ve.messages, 422
@@ -81,8 +99,8 @@ def create():
.filter(Resource.name == body['name'] and Resource.owner.client_id == client_id)\
.one_or_none()
if resource is not None:
- return {'status': 409,
- 'message': f'A resource with the same name and owner already exists with id {resource.id}'}, 409
+ # TODO: Allow turning this off because it can be used as an enumeration attack
+ return status(code=409, message='This resource already exists'), 409
#
# Look up the owner and create one if necessary
@@ -107,9 +125,9 @@ def modify(i: int):
resource = Resource.query.filter(Resource.id == i).one_or_none()
if resource is None:
- return {'status': 404, 'message': 'Unable to find requested resource'}, 404
+ return status(code=404, message='Unable to find a resources with this id'), 404
if resource.owner.client_id != client_id:
- return {'status': 401, 'message': 'You do not own this resource'}, 401
+ return status(code=401, message='You are not authorised to modify this resource'), 401
resource.name = body['name']
db.session.add(resource)
@@ -125,9 +143,10 @@ def remove(i: int):
resource = Resource.query.filter(Resource.id == i).one_or_none()
if resource is None:
- return {'status': 410, 'message': 'The requested resource is permanently deleted'}, 410
+ # TODO: Allow turning this off because it can be used as an enumeration attack
+ return status(code=410, message='This resource is gone'), 410
if resource.owner.client_id != client_id:
- return {'status': 401, 'message': 'You do not own this resource'}, 401
+ return status(code=401, message='You are not authorised to remove this resource'), 401
db.session.delete(resource)
db.session.commit()
diff --git a/mrmat_python_api_flask/apis/resource/v1/model.py b/mrmat_python_api_flask/apis/resource/v1/model.py
index d3daaf0..2dec9e5 100644
--- a/mrmat_python_api_flask/apis/resource/v1/model.py
+++ b/mrmat_python_api_flask/apis/resource/v1/model.py
@@ -25,7 +25,6 @@
from sqlalchemy import ForeignKey, Column, Integer, String, UniqueConstraint, BigInteger
from sqlalchemy.orm import relationship
-from marshmallow import fields
from mrmat_python_api_flask import db, ma
@@ -48,24 +47,15 @@ class Resource(db.Model):
UniqueConstraint('owner_id', 'name', name='no_duplicate_names_per_owner')
-class OwnerSchema(ma.Schema):
+class OwnerSchema(ma.SQLAlchemyAutoSchema):
class Meta:
fields = ('id', 'client_id', 'name')
- id = fields.Int(dump_only=True)
- client_id = fields.Str(dump_only=True)
- name = fields.Str()
-
-class ResourceSchema(ma.Schema):
+class ResourceSchema(ma.SQLAlchemyAutoSchema):
class Meta:
fields = ('id', 'owner', 'name')
- id = fields.Int()
- owner_id = fields.Int(load_only=True)
- owner = fields.Nested(lambda: OwnerSchema(), dump_only=True) # pylint: disable=W0108
- name = fields.Str()
-
owner_schema = OwnerSchema()
owners_schema = OwnerSchema(many=True)
diff --git a/mrmat_python_api_flask/cui.py b/mrmat_python_api_flask/cui.py
index 726da28..f1354ef 100644
--- a/mrmat_python_api_flask/cui.py
+++ b/mrmat_python_api_flask/cui.py
@@ -23,6 +23,7 @@
"""Main entry point when executing this application from the CLI
"""
+import os
import sys
import argparse
@@ -67,7 +68,11 @@ def main() -> int:
overrides = {'DEBUG': args.debug}
if args.oidc_secrets is not None:
- overrides['OIDC_CLIENT_SECRETS'] = args.oidc_secrets
+ oidc_secrets_config = os.path.expanduser(args.oidc_secrets)
+ if not os.path.exists(oidc_secrets_config):
+ print(f'ERROR: {oidc_secrets_config} does not exist')
+ return 1
+ overrides['OIDC_CLIENT_SECRETS'] = oidc_secrets_config
if args.dsn is not None:
overrides['SQLALCHEMY_DATABASE_URI'] = args.dsn
diff --git a/requirements.txt b/requirements.txt
index fb3bc89..d37b27b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,25 +3,26 @@
# quickly install both build- and test-requirements from within your development environment. DO MAKE
# SURE to update setup.cfg whenever you make changes here.
+# Build/Test requirements
+
+build~=0.7.0 # MIT
+wheel~=0.37.1 # MIT
+pylint~=2.12.2 # GPL-2.0-or-later
+pytest~=7.0.1 # MIT
+pytest-cov~=3.0.0 # MIT
+pyjwt~=2.3.0 # MIT
+python-keycloak~=0.27.0 # MIT
+
# Runtime requirements
-rich~=10.14.0 # MIT
-flask~=2.0.2 # BSD 3-Clause
+rich~=11.2.0 # MIT
+Flask~=2.0.3 # BSD 3-Clause
Flask-SQLAlchemy~=2.5.1 # BSD 3-Clause
Flask-Migrate~=3.1.0 # MIT
-flask-smorest~=0.35.0 # MIT
+flask-smorest~=0.37.0 # MIT
Flask-Marshmallow~=0.14.0 # MIT
-marshmallow-sqlalchemy~=0.26.0 # MIT
-psycopg2-binary~=2.9.1 # LGPL with exceptions
+marshmallow-sqlalchemy~=0.27.0 # MIT
+psycopg2-binary~=2.9.3 # LGPL with exceptions
Flask-OIDC~=1.4.0 # MIT
-requests_oauthlib~=1.3.0 # ISC
-
-# Build/Test requirements
-
-build~=0.7.0 # MIT
-wheel~=0.36.2 # MIT
-pylint~=2.12.2 # GPL-2.0-or-later
-pytest~=6.2.5 # MIT
-pytest-cov~=3.0.0 # MIT
-pyjwt==2.3.0 # MIT
-python-keycloak~=0.26.1 # MIT
+requests_oauthlib~=1.3.1 # ISC
+itsdangerous<=2.0.1 # BSD 3-Clause Must affix so JSONWebSignatureSerializer is known
diff --git a/setup.cfg b/setup.cfg
index f5c0ea6..7185e4a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -16,16 +16,17 @@ classifiers =
[options]
packages = find:
install_requires =
- flask~=2.0.2
+ rich~=11.2.0
+ Flask~=2.0.3
Flask-SQLAlchemy~=2.5.1
Flask-Migrate~=3.1.0
+ flask-smorest~=0.37.0
Flask-Marshmallow~=0.14.0
- marshmallow-sqlalchemy~=0.26.0
- psycopg2-binary~=2.9.1
+ marshmallow-sqlalchemy~=0.27.0
+ psycopg2-binary~=2.9.3
Flask-OIDC~=1.4.0
- requests_oauthlib~=1.3.0
- rich~=10.14.0
- flask-smorest~=0.35.0
+ requests_oauthlib~=1.3.1
+ itsdangerous<=2.0.1
[options.entry_points]
console_scripts =
diff --git a/tests/conftest.py b/tests/conftest.py
index 6429974..b4f2874 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -20,6 +20,10 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
+"""
+Code available to the entire testsuite
+"""
+
import os
import logging
import json
@@ -45,15 +49,22 @@
class TIException(Exception):
+ """
+ An dedicated exception raised when issues occur establishing the test infrastructure
+ """
skip: bool = False
msg: str = 'An unexpected exception occurred'
def __init__(self, msg: str, skip: Optional[bool] = False):
+ super().__init__()
self.skip = skip
self.msg = msg
class AbstractTestInfrastructure(abc.ABC):
+ """
+ An abstract class dedicated to any local test infrastructure implementation
+ """
_app = None
@@ -67,6 +78,10 @@ def app_client(self):
class NoTestInfrastructure(AbstractTestInfrastructure):
+ """
+ An implementation of test infrastructure that does not rely on anything (e.g. there is nothing
+ available except what we have right here)
+ """
@contextlib.contextmanager
def app(self):
@@ -87,14 +102,14 @@ def app_client(self):
class LocalTestInfrastructure(object):
"""
- A class for administration of the available test infrastructure
+ A class for administration of the available local test infrastructure
"""
_ti_config_path: pathlib.Path = None
_ti_config: Dict = {}
_pg_admin = None
- _keycloak_admin: KeycloakAdmin
+ _keycloak_admin: KeycloakAdmin = None
_auth_info: Dict = {}
_app = None
@@ -110,11 +125,13 @@ def __init__(self, ti_config_path: pathlib.Path):
self._keycloak_admin = KeycloakAdmin(server_url=self._ti_config['keycloak'].get('admin_url'),
username=self._ti_config['keycloak'].get('admin_user'),
password=self._ti_config['keycloak'].get('admin_password'),
- realm_name='master')
- except psycopg2.OperationalError:
- raise TIException(skip=True, msg='Failed to obtain an administrative connection to PG')
- except KeycloakOperationError:
- raise TIException(skip=True, msg='Failed to obtain an administrative connection to KeyCloak')
+ realm_name=self._ti_config['keycloak'].get('admin_realm'))
+ except psycopg2.OperationalError as oe:
+ raise TIException(skip=True, msg='Failed to obtain an administrative connection to PG') from oe
+ except KeycloakOperationError as koe:
+ raise TIException(skip=True, msg='Failed to obtain an administrative connection to KeyCloak') from koe
+ except Exception as e:
+ raise TIException(skip=True, msg='Unknown exception') from e
@contextlib.contextmanager
def app_dsn(self,
@@ -124,7 +141,7 @@ def app_dsn(self,
drop_finally: bool = False):
try:
cur = self._pg_admin.cursor()
- cur.execute("SELECT COUNT(rolname) FROM pg_roles WHERE rolname = %(role_name)s;", {'role_name': role})
+ cur.execute('SELECT COUNT(rolname) FROM pg_roles WHERE rolname = %(role_name)s;', {'role_name': role})
role_count = cur.fetchone()
if role_count[0] == 0:
cur.execute(
@@ -145,11 +162,11 @@ def app_dsn(self,
app_dsn = f"postgresql://{role}:{password}@{dsn_info['host']}:{dsn_info['port']}/{dsn_info['dbname']}"
yield app_dsn
- except psycopg2.Error:
- raise TIException(msg=f'Failed to create role {role} on schema {schema}')
+ except psycopg2.Error as e:
+ raise TIException(msg=f'Failed to create role {role} on schema {schema}') from e
finally:
if drop_finally:
- LOGGER.info(f'Dropping schema {schema} and associated role {role}')
+ LOGGER.info('Dropping schema %s and associated role %s', schema, role)
cur = self._pg_admin.cursor()
cur.execute(
psycopg2.sql.SQL('DROP SCHEMA {} CASCADE').format(psycopg2.sql.Identifier(schema)))
@@ -164,20 +181,19 @@ def app_auth(self,
client_id: str = 'test-client',
ti_id: str = 'ti-client',
scopes: List[str] = None,
- scope: str = 'test-scope',
redirect_uris: List = None,
drop_finally: bool = False):
try:
if scopes is None:
scopes = []
- for scope in scopes:
- if not self._keycloak_admin.get_client_scope(scope):
- self._keycloak_admin.create_client_scope({
- 'id': scope,
- 'name': scope,
- 'description': f'Test {scope}',
- 'protocol': 'openid-connect'
- })
+ # existing_scopes = self._keycloak_admin.get_client_scopes()
+ # existing_scopes = [e.name for e in existing_scopes]
+ for desired_scope in scopes:
+ self._keycloak_admin.create_client_scope(skip_exists=True, payload={
+ 'id': desired_scope,
+ 'name': desired_scope,
+ 'description': f'Test {desired_scope}',
+ 'protocol': 'openid-connect'})
if not self._keycloak_admin.get_client_id(client_id):
self._keycloak_admin.create_client({
'id': client_id,
@@ -203,7 +219,7 @@ def app_auth(self,
realm_name='master',
verify=True)
discovery = keycloak.well_know()
- with open(f'{tmpdir}/client_secrets.json', 'w') as cs:
+ with open(f'{tmpdir}/client_secrets.json', 'w', encoding='UTF-8') as cs:
json.dump({
'web': {
'client_id': client_id,
@@ -229,17 +245,16 @@ def app_auth(self,
LOGGER.exception(koe)
finally:
if drop_finally:
- LOGGER.info(f'Deleting client_id {client_id}')
+ LOGGER.info('Deleting client_id %s', client_id)
self._keycloak_admin.delete_client(client_id)
- LOGGER.info(f'Deleting client_id {ti_id}')
+ LOGGER.info('Deleting client_id %s', ti_id)
self._keycloak_admin.delete_client(ti_id)
- LOGGER.info(f'Deleting scope {scope}')
@contextlib.contextmanager
def app(self,
tmpdir,
- pg_role: str = 'mpaf',
- pg_password: str = 'mpaf',
+ pg_role: str = 'mpaf-test',
+ pg_password: str = 'mpaf-test',
pg_schema: str = 'mpaf-test',
drop_finally: bool = False):
with self.app_dsn(role=pg_role, password=pg_password, schema=pg_schema, drop_finally=drop_finally) as dsn, \
@@ -262,8 +277,8 @@ def app_client(self, app_dir):
@contextlib.contextmanager
def user_token(self,
- user_id: str = 'test-user',
- user_password: str = 'test',
+ user_id: str = 'mpaf-test-user',
+ user_password: str = 'mpaf-test-user',
scopes: List[str] = None,
drop_finally: bool = False):
try:
@@ -289,7 +304,7 @@ def user_token(self,
yield token
finally:
if drop_finally:
- LOGGER.info(f'Deleting user {user_id}')
+ LOGGER.info('Deleting user %s', user_id)
self._keycloak_admin.delete_user(user_id)
diff --git a/tests/resource_api_client.py b/tests/resource_api_client.py
index bca0e9f..12c99cf 100644
--- a/tests/resource_api_client.py
+++ b/tests/resource_api_client.py
@@ -20,12 +20,19 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
+"""
+Resource API test utilities
+"""
+
from flask import Response
from flask.testing import FlaskClient
from typing import Tuple, Dict, Optional
class ResourceAPIClient:
+ """
+ A client class for the resource API
+ """
client: FlaskClient
token: Dict
diff --git a/tests/test_greeting_v1.py b/tests/test_greeting_v1.py
index 0116642..b4fbecb 100644
--- a/tests/test_greeting_v1.py
+++ b/tests/test_greeting_v1.py
@@ -20,6 +20,10 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
+"""
+Tests for the greeting v1 API
+"""
+
import pytest
from flask import Response
diff --git a/tests/test_greeting_v2.py b/tests/test_greeting_v2.py
index 2d3dc90..2b46f43 100644
--- a/tests/test_greeting_v2.py
+++ b/tests/test_greeting_v2.py
@@ -20,6 +20,10 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
+"""
+Tests for the Greeting V2 API
+"""
+
import pytest
from flask import Response
diff --git a/tests/test_greeting_v3.py b/tests/test_greeting_v3.py
index 5ee1936..5695cad 100644
--- a/tests/test_greeting_v3.py
+++ b/tests/test_greeting_v3.py
@@ -20,12 +20,19 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
+"""
+Tests for the Greeting V3 API
+"""
+
import pytest
from flask import Response
@pytest.mark.usefixtures('local_test_infrastructure')
class TestWithLocalInfrastructure:
+ """
+ Tests for the Greeting V3 API using locally available infrastructure
+ """
def test_greeting_v3(self, tmpdir, local_test_infrastructure):
with local_test_infrastructure.app_client(tmpdir) as client:
diff --git a/tests/test_healthz.py b/tests/test_healthz.py
index 96de54d..1fc3cb6 100644
--- a/tests/test_healthz.py
+++ b/tests/test_healthz.py
@@ -20,6 +20,10 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
+"""
+Tests for the Healthz API
+"""
+
import pytest
from flask import Response
@@ -32,5 +36,6 @@ def test_healthz(self, no_test_infrastructure):
with no_test_infrastructure.app_client() as client:
rv: Response = client.get('/healthz/')
json_body = rv.get_json()
- assert 'status' in json_body
- assert json_body['status'] == 'OK'
+ assert rv.status_code == 200
+ assert json_body['code'] == 200
+ assert json_body['message'] == 'OK'
diff --git a/tests/test_resource_v1.py b/tests/test_resource_v1.py
index 64028a6..b8e3d53 100644
--- a/tests/test_resource_v1.py
+++ b/tests/test_resource_v1.py
@@ -20,6 +20,10 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
+"""
+Tests for the Resource API
+"""
+
import pytest
from datetime import datetime
@@ -28,6 +32,9 @@
@pytest.mark.usefixtures('local_test_infrastructure')
class TestWithLocalInfrastructure:
+ """
+ Tests for the Resource API using locally available infrastructure
+ """
def test_resource_lifecycle(self, tmpdir, local_test_infrastructure):
with local_test_infrastructure.app_client(tmpdir) as client:
@@ -63,8 +70,8 @@ def test_resource_lifecycle(self, tmpdir, local_test_infrastructure):
(resp, resp_body) = rac.remove(resource_id)
assert resp.status_code == 410
+ assert resp_body['code'] == 410
assert resp_body['message'] == 'The requested resource is permanently deleted'
- assert resp_body['status'] == 410
def test_insufficient_scope(self, tmpdir, local_test_infrastructure):
with local_test_infrastructure.app_client(tmpdir) as client:
@@ -73,7 +80,7 @@ def test_insufficient_scope(self, tmpdir, local_test_infrastructure):
(resp, resp_body) = rac.create(name='Unauthorised')
assert resp.status_code == 401
- assert resp_body is None
+ assert resp_body == {}
(resp, resp_body) = rac.modify(1, name='Unauthorised')
assert resp.status_code == 401
@@ -81,3 +88,46 @@ def test_insufficient_scope(self, tmpdir, local_test_infrastructure):
(resp, resp_body) = rac.remove(1)
assert resp.status_code == 401
assert resp_body is None
+
+ def test_duplicate_creation_fails(self, tmpdir, local_test_infrastructure):
+ with local_test_infrastructure.app_client(tmpdir) as client:
+ with local_test_infrastructure.user_token(scopes=['mpaf-read', 'mpaf-write']) as user_token:
+ rac = ResourceAPIClient(client, token=user_token['access_token'])
+
+ resource_name = f'Test Resource {datetime.utcnow()}'
+ (resp, resp_body) = rac.create(name=resource_name)
+ assert resp.status_code == 201
+ assert resp_body['id'] is not None
+ assert resp_body['name'] == resource_name
+
+ (resp, resp_body) = rac.create(name=resource_name)
+ assert resp.status_code == 409
+ assert resp_body['code'] == 409
+ assert resp_body['message'] == 'This resource already exists'
+
+ def test_ownership_is_maintained(self, tmpdir, local_test_infrastructure):
+ with local_test_infrastructure.app_client(tmpdir) as client, \
+ local_test_infrastructure.user_token(user_id='test-user1', scopes=['mpaf-read', 'mpaf-write']) as token1, \
+ local_test_infrastructure.user_token(user_id='test-user2', scopes=['mpaf-read', 'mpaf-write']) as token2:
+
+ rac1 = ResourceAPIClient(client, token=token1['access_token'])
+ rac2 = ResourceAPIClient(client, token=token2['access_token'])
+
+ resource_name = f'Test Resource {datetime.utcnow()}'
+ (resp, resp_body) = rac1.create(name=resource_name)
+ assert resp.status_code == 201
+
+ (resp, resp_body) = rac2.create(name=resource_name)
+ assert resp.status_code == 201
+ user2_resource = resp_body['id']
+
+ (resp, resp_body) = rac1.modify(user2_resource, name='Test')
+ assert resp.status_code == 401
+ assert resp_body['code'] == 401
+ assert resp_body['message'] == 'You are not authorised to modify this resource'
+
+ (resp, resp_body) = rac1.remove(user2_resource)
+ assert resp.status_code == 401
+ assert resp_body['code'] == 401
+ assert resp_body['message'] == 'You are not authorised to remove this resource'
+
diff --git a/var/docker/Dockerfile b/var/docker/Dockerfile
index e483850..34c7ac0 100644
--- a/var/docker/Dockerfile
+++ b/var/docker/Dockerfile
@@ -25,4 +25,4 @@ RUN \
EXPOSE 8080
USER 1000:1000
-ENTRYPOINT /app/venv/bin/gunicorn -w 4 -b 0.0.0.0:8080 'mrmat_python_api_flask:create_app()'
+ENTRYPOINT /app/venv/bin/gunicorn -w 4 -b 0.0.0.0:8080 --error-logfile /app/instance/error.log --access-logfile /app/instance/access.log 'mrmat_python_api_flask:create_app()'