diff --git a/README.md b/README.md index 49341f02..42d5b397 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ db: > `mySecondCollection` and `myThirdCollection`, respectively). FOCA will > automatically register and initialize these databases and collections for you > and add convenient clients to the app instance (accessible as children of -> `current_app.config.foca` in an [application +> `connexion.request.state.config` in an [application > context][res-flask-app-context]). The collections would be indexed by keys > `id`, `other_id` and `third_id`, respectively. Out of these, only `id` > will be required to be unique. @@ -443,12 +443,13 @@ my_custom_param_section: Once the application is created using `foca()`, one can easily access any configuration parameters from within the [application -context][res-flask-app-context] through `current_app.config.foca like so: +context][res-flask-app-context] through `connexion.request.state.config` like +so: ```python -from flask import current_app +from connexion import request -app_config = current_app.config.foca +app_config = request.state.config db = app_config.db api = app_config.api @@ -457,7 +458,6 @@ exceptions = app_config.exceptions security = app_config.security jobs = app_config.jobs log = app_config.log -app_specific_param = current_app.config['app_specific_param'] ``` _Outside of the application context_, configuration parameters are available diff --git a/TESTS/proTES/config.yaml b/TESTS/proTES/config.yaml new file mode 100644 index 00000000..538514ac --- /dev/null +++ b/TESTS/proTES/config.yaml @@ -0,0 +1,147 @@ +# FOCA configuration +# Available in app context as attributes of `connexion.request.state.config` +# Automatically validated via FOCA +# Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html + +# Server configuration +# Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.ServerConfig +server: + host: "0.0.0.0" + port: 8080 + debug: True + environment: development + testing: False + use_reloader: False + +# Security configuration +# Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.SecurityConfig +security: + auth: + add_key_to_claims: True + algorithms: + - RS256 + allow_expired: False + audience: null + validation_methods: + - userinfo + - public_key + validation_checks: any + +# Database configuration +# Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.DBConfig +db: + host: mongodb + port: 27017 + dbs: + taskStore: + collections: + tasks: + indexes: + - keys: + task_id: 1 + worker_id: 1 + options: + "unique": True + "sparse": True + service_info: + indexes: + - keys: + id: 1 + +# API configuration +# Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.APIConfig +api: + specs: + - path: + - petstore.yaml + add_operation_fields: + x-openapi-router-controller: controllers + security: + - bearerAuth: [] + add_security_fields: + x-bearerInfoFunc: foca.security.auth.validate_token + disable_auth: True + connexion: + strict_validation: True + # current specs have inconsistency, therefore disabling response validation + # see: https://github.com/ga4gh/task-execution-schemas/issues/136 + validate_responses: False + options: + swagger_ui: True + serve_spec: True + +# Logging configuration +# Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.LogConfig +log: + version: 1 + disable_existing_loggers: False + formatters: + standard: + class: logging.Formatter + style: "{" + format: "[{asctime}: {levelname:<8}] {message} [{name}]" + handlers: + console: + class: logging.StreamHandler + level: 10 + formatter: standard + stream: ext://sys.stderr + root: + level: 10 + handlers: [console] + +jobs: + host: rabbitmq + port: 5672 + backend: "rpc://" + include: + - pro_tes.tasks.track_task_progress + +# Exception configuration +# Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.ExceptionConfig +exceptions: + required_members: [["message"], ["code"]] + status_member: ["code"] + exceptions: exceptions.exceptions + +controllers: + post_task: + db: + insert_attempts: 10 + task_id: + charset: string.ascii_uppercase + string.digits + length: 6 + timeout: + post: null + poll: 2 + job: null + polling: + wait: 3 + attempts: 100 + list_tasks: + default_page_size: 5 + celery: + monitor: + timeout: 0.1 + message_maxsize: 16777216 + +serviceInfo: + doc: Proxy TES for distributing tasks across a list of service TES instances + name: proTES + storage: + - file:///path/to/local/storage + +tes: + service_list: + - "https://csc-tesk-noauth.rahtiapp.fi" + - "https://funnel.cloud.e-infra.cz/" + - "https://tesk-eu.hypatia-comp.athenarc.gr" + - "https://tesk-na.cloud.e-infra.cz" + - "https://vm4816.kaj.pouta.csc.fi/" + +storeLogs: + execution_trace: True + +middlewares: + - - "pro_tes.plugins.middlewares.task_distribution.distance.TaskDistributionDistance" + - "pro_tes.plugins.middlewares.task_distribution.random.TaskDistributionRandom" diff --git a/TESTS/proTES/docker-compose.yaml b/TESTS/proTES/docker-compose.yaml new file mode 100644 index 00000000..edc3a6bb --- /dev/null +++ b/TESTS/proTES/docker-compose.yaml @@ -0,0 +1,10 @@ +version: '3.6' +services: + + mongodb: + image: mongo:7.0 + restart: unless-stopped + volumes: + - ./data/petstore/db:/data/db + ports: + - "27017:27017" diff --git a/TESTS/proTES/exceptions.py b/TESTS/proTES/exceptions.py new file mode 100644 index 00000000..c50a8682 --- /dev/null +++ b/TESTS/proTES/exceptions.py @@ -0,0 +1,97 @@ +"""proTES exceptions.""" + +from connexion.exceptions import ( # type: ignore + BadRequestProblem, + ExtraParameterProblem, + Forbidden, + Unauthorized, +) +from pydantic import ValidationError +from pymongo.errors import PyMongoError # type: ignore +from werkzeug.exceptions import ( + BadRequest, + InternalServerError, + NotFound, +) + +# pylint: disable="too-few-public-methods" + + +class TaskNotFound(NotFound): + """Raised when task with given task identifier was not found.""" + + +class IdsUnavailableProblem(PyMongoError): + """Raised when task identifier is unavailable.""" + + +class NoTesInstancesAvailable(ValueError): + """Raised when no TES instances are available.""" + + +class MiddlewareException(ValueError): + """Raised when a middleware could not be applied.""" + + +class InvalidMiddleware(MiddlewareException): + """Raised when a middleware is invalid.""" + + +exceptions = { + Exception: { + "message": "An unexpected error occurred.", + "code": "500", + }, + BadRequest: { + "message": "The request is malformed.", + "code": "400", + }, + BadRequestProblem: { + "message": "The request is malformed.", + "code": "400", + }, + ExtraParameterProblem: { + "message": "The request is malformed.", + "code": "400", + }, + ValidationError: { + "message": "The request is malformed.", + "code": "400", + }, + Unauthorized: { + "message": " The request is unauthorized.", + "code": "401", + }, + Forbidden: { + "message": "The requester is not authorized to perform this action.", + "code": "403", + }, + NotFound: { + "message": "The requested resource wasn't found.", + "code": "404", + }, + TaskNotFound: { + "message": "The requested task wasn't found.", + "code": "404", + }, + InternalServerError: { + "message": "An unexpected error occurred.", + "code": "500", + }, + IdsUnavailableProblem: { + "message": "No/few unique task identifiers available.", + "code": "500", + }, + NoTesInstancesAvailable: { + "message": "No valid TES instances available.", + "code": "500", + }, + MiddlewareException: { + "message": "Middleware could not be applied.", + "code": "500", + }, + InvalidMiddleware: { + "message": "Middleware is invalid.", + "code": "500", + }, +} diff --git a/TESTS/proTES/petstore.yaml b/TESTS/proTES/petstore.yaml new file mode 100644 index 00000000..2109d653 --- /dev/null +++ b/TESTS/proTES/petstore.yaml @@ -0,0 +1,152 @@ +openapi: 3.0.2 +info: + version: 1.0.0 + title: Swagger Petstore + description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification + termsOfService: http://swagger.io/terms/ + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html +servers: + - url: http://localhost/ +paths: + /pets: + get: + description: | + Returns all pets from the system that the user has access to. + operationId: findPets + parameters: + - name: tags + in: query + description: tags to filter by + required: false + style: form + schema: + type: array + items: + type: string + - name: limit + in: query + description: maximum number of results to return + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: pet response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + description: Creates a new pet in the store. Duplicates are allowed + operationId: addPet + requestBody: + description: Pet to add to the store + required: true + content: + application/json: + schema: + x-body-name: pet + $ref: '#/components/schemas/NewPet' + responses: + '200': + description: pet response + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /pets/{id}: + get: + description: Returns a user based on a single ID, if the user does not have access to the pet + operationId: findPetById + parameters: + - name: id + in: path + description: ID of pet to fetch + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: pet response + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + description: deletes a single pet based on the ID supplied + operationId: deletePet + parameters: + - name: id + in: path + description: ID of pet to delete + required: true + schema: + type: integer + format: int64 + responses: + '204': + description: pet deleted + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Pet: + allOf: + - $ref: '#/components/schemas/NewPet' + - type: object + required: + - id + properties: + id: + type: integer + format: int64 + + NewPet: + type: object + required: + - name + properties: + name: + type: string + tag: + type: string + + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/TESTS/proTES/run.py b/TESTS/proTES/run.py new file mode 100644 index 00000000..1354e396 --- /dev/null +++ b/TESTS/proTES/run.py @@ -0,0 +1,5 @@ +from foca import Foca + +foca = Foca(config_file="config.yaml") +app = foca.create_app() +celery_app = foca.create_celery_app() diff --git a/examples/petstore-access-control/app.py b/examples/petstore-access-control/app.py index 9d250aad..585b732d 100644 --- a/examples/petstore-access-control/app.py +++ b/examples/petstore-access-control/app.py @@ -2,9 +2,11 @@ from foca import Foca +from pathlib import Path + if __name__ == '__main__': foca = Foca( - config_file="config.yaml" + config_file=Path("config.yaml") ) app = foca.create_app() - app.run() + app.run(host="0.0.0.0", port=8080, lifespan="on") diff --git a/examples/petstore-access-control/controllers.py b/examples/petstore-access-control/controllers.py index cfc6a3f7..36f19e2a 100644 --- a/examples/petstore-access-control/controllers.py +++ b/examples/petstore-access-control/controllers.py @@ -2,27 +2,33 @@ import logging -from flask import (current_app, make_response) -from pymongo.collection import Collection - -from exceptions import NotFound +from connexion import request +from flask import make_response +from foca.models.config import Config +from foca.utils.db import get_client +from foca.utils.logging import log_traffic from foca.security.access_control.register_access_control import ( check_permissions ) +from exceptions import NotFound + logger = logging.getLogger(__name__) @check_permissions +@log_traffic def findPets(limit=None, tags=None): - db_collection: Collection = ( - current_app.config.foca.db.dbs['petstore-access-control'] - .collections['pets'].client + config: Config = request.state.config + client = get_client( + config=config, + db='petstore-access-control', + collection='pets', ) filter_dict = {} if tags is None else {'tag': {'$in': tags}} if not limit: limit = 0 - records = db_collection.find( + records = client.find( filter_dict, {'_id': False} ).sort([('$natural', -1)]).limit(limit) @@ -30,32 +36,38 @@ def findPets(limit=None, tags=None): @check_permissions +@log_traffic def addPet(pet): - db_collection: Collection = ( - current_app.config.foca.db.dbs['petstore-access-control'] - .collections['pets'].client + config: Config = request.state.config + client = get_client( + config=config, + db='petstore-access-control', + collection='pets', ) counter = 0 - ctr = db_collection.find({}).sort([('$natural', -1)]) - if not db_collection.count_documents({}) == 0: + ctr = client.find({}).sort([('$natural', -1)]) + if not client.count_documents({}) == 0: counter = ctr[0].get('id') + 1 record = { "id": counter, "name": pet['name'], "tag": pet['tag'] } - db_collection.insert_one(record) + client.insert_one(record) del record['_id'] return record @check_permissions +@log_traffic def findPetById(id): - db_collection: Collection = ( - current_app.config.foca.db.dbs['petstore-access-control'] - .collections['pets'].client + config: Config = request.state.config + client = get_client( + config=config, + db='petstore-access-control', + collection='pets', ) - record = db_collection.find_one( + record = client.find_one( {"id": id}, {'_id': False}, ) @@ -65,18 +77,21 @@ def findPetById(id): @check_permissions +@log_traffic def deletePet(id): - db_collection: Collection = ( - current_app.config.foca.db.dbs['petstore-access-control'] - .collections['pets'].client + config: Config = request.state.config + client = get_client( + config=config, + db='petstore-access-control', + collection='pets', ) - record = db_collection.find_one( + record = client.find_one( {"id": id}, {'_id': False}, ) if record is None: raise NotFound - db_collection.delete_one( + client.delete_one( {"id": id}, ) response = make_response('', 204) diff --git a/examples/petstore/app.py b/examples/petstore/app.py index 9d250aad..5aff2b0b 100644 --- a/examples/petstore/app.py +++ b/examples/petstore/app.py @@ -1,10 +1,12 @@ """Entry point for petstore example app.""" +from pathlib import Path + from foca import Foca if __name__ == '__main__': foca = Foca( - config_file="config.yaml" + config_file=Path("config.yaml") ) app = foca.create_app() - app.run() + app.run(host="0.0.0.0", port=8080, lifespan="on") diff --git a/examples/petstore/controllers.py b/examples/petstore/controllers.py index b8fad832..b61def37 100644 --- a/examples/petstore/controllers.py +++ b/examples/petstore/controllers.py @@ -2,54 +2,68 @@ import logging -from flask import (current_app, make_response) -from pymongo.collection import Collection +from connexion import request +from flask import make_response +from foca.utils.db import get_client +from foca.utils.logging import log_traffic +from foca.models.config import Config -from exceptions import NotFound +from exceptions import ( + NotFound, + CustomException, + CustomExceptionStarlette, + CustomExceptionWerkzeug, + CustomExceptionConnexion, + WrappedException, + WrappedExceptionConnexion, + WrappedExceptionStarlette, + WrappedExceptionWerkzeug, +) +import connexion.exceptions +import starlette.exceptions +import werkzeug.exceptions logger = logging.getLogger(__name__) +@log_traffic def findPets(limit=None, tags=None): - db_collection: Collection = ( - current_app.config.foca.db.dbs['petstore'] - .collections['pets'].client - ) + config: Config = request.state.config + logger.warning(f"Config: {config}") + client = get_client(config=config, db='petstore', collection='pets') filter_dict = {} if tags is None else {'tag': {'$in': tags}} if not limit: limit = 0 - records = db_collection.find( + records = client.find( filter_dict, {'_id': False} ).sort([('$natural', -1)]).limit(limit) return list(records) +@log_traffic def addPet(pet): - db_collection: Collection = ( - current_app.config.foca.db.dbs['petstore'] - .collections['pets'].client - ) + config: Config = request.state.config + client = get_client(config=config, db='petstore', collection='pets') counter = 0 - ctr = db_collection.find({}).sort([('$natural', -1)]) - if not db_collection.count_documents({}) == 0: + ctr = client.find({}).sort([('$natural', -1)]) + if not client.count_documents({}) == 0: counter = ctr[0].get('id') + 1 record = { "id": counter, "name": pet['name'], "tag": pet['tag'] } - db_collection.insert_one(record) + client.insert_one(record) del record['_id'] return record +@log_traffic def findPetById(id): - db_collection: Collection = ( - current_app.config.foca.db.dbs['petstore'] - .collections['pets'].client - ) - record = db_collection.find_one( + config: Config = request.state.config + client = get_client(config=config, db='petstore', collection='pets') + record = client.find_one( {"id": id}, {'_id': False}, ) @@ -58,19 +72,84 @@ def findPetById(id): return record +@log_traffic def deletePet(id): - db_collection: Collection = ( - current_app.config.foca.db.dbs['petstore'] - .collections['pets'].client - ) - record = db_collection.find_one( + config: Config = request.state.config + client = get_client(config=config, db='petstore', collection='pets') + record = client.find_one( {"id": id}, {'_id': False}, ) if record is None: raise NotFound - db_collection.delete_one( + client.delete_one( {"id": id}, ) response = make_response('', 204) return response + + +@log_traffic +def exceptionWrapped(): + raise WrappedException + + +@log_traffic +def exceptionWrappedConnexion(): + raise WrappedExceptionConnexion + + +@log_traffic +def exceptionWrappedStarlette(): + raise WrappedExceptionStarlette( + status_code=404, + detail="Wrapper exception Starlette." + ) + + +@log_traffic +def exceptionWrappedWerkzeug(): + raise WrappedExceptionWerkzeug + + +@log_traffic +def exceptionCustom(): + raise CustomException + + +@log_traffic +def exceptionCustomConnexion(): + raise CustomExceptionConnexion + + +@log_traffic +def exceptionCustomStarlette(): + raise CustomExceptionStarlette + + +@log_traffic +def exceptionCustomWerkzeug(): + raise CustomExceptionWerkzeug + + +@log_traffic +def exceptionBuiltin(): + raise TypeError + + +@log_traffic +def exceptionBuiltinConnexion(): + raise connexion.exceptions.Unauthorized + + +@log_traffic +def exceptionBuiltinStarlette(): + raise starlette.exceptions.HTTPException( + status_code=404, + detail="Builtin exception Starlette." + ) + + +@log_traffic +def exceptionBuiltinWerkzeug(): + raise werkzeug.exceptions.Forbidden diff --git a/examples/petstore/exceptions.py b/examples/petstore/exceptions.py index 83a2d93a..86f5b001 100644 --- a/examples/petstore/exceptions.py +++ b/examples/petstore/exceptions.py @@ -1,10 +1,69 @@ """Petstore exceptions.""" -from connexion.exceptions import BadRequestProblem +from connexion.exceptions import BadRequestProblem, ProblemException +from starlette.exceptions import HTTPException from werkzeug.exceptions import ( InternalServerError, NotFound, ) +import connexion.exceptions +import starlette.exceptions +import werkzeug.exceptions + + +class WrappedException(KeyError): + """Raised when task with given task identifier was not found.""" + + +class WrappedExceptionConnexion(connexion.exceptions.BadRequestProblem): + """Raised when task with given task identifier was not found.""" + + +class WrappedExceptionStarlette(starlette.exceptions.HTTPException): + """Raised when task with given task identifier was not found.""" + + +class WrappedExceptionWerkzeug(werkzeug.exceptions.NotFound): + """Raised when task with given task identifier was not found.""" + + +class CustomException(Exception): + def __init__( + self, + message: str = "Custom exception", + ): + super().__init__(message) + + +class CustomExceptionConnexion(ProblemException): + def __init__( + self, + status: int = 403, + title: str = "CustomExceptionConnexion", + detail: str = "Custom exception Connexion", + ): + super().__init__(status=status, title=title, detail=detail) + + +class CustomExceptionStarlette(HTTPException): + def __init__( + self, + status_code: int = 403, + detail: str = "Custom exception Starlette", + ): + super().__init__(status_code, detail) + + +class CustomExceptionWerkzeug(werkzeug.exceptions.HTTPException): + def __init__( + self, + description: str = "Custom exception Werkzeug", + response=None, + ): + super().__init__(description=description, response=response) + + code = 400 + exceptions = { Exception: { diff --git a/examples/petstore/petstore.yaml b/examples/petstore/petstore.yaml index 2109d653..138ec360 100644 --- a/examples/petstore/petstore.yaml +++ b/examples/petstore/petstore.yaml @@ -116,6 +116,138 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /exceptions/wrapped: + get: + description: Raise wrapped Python exception. + operationId: exceptionWrapped + responses: + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /exceptions/wrapped/connexion: + get: + description: Raise wrapped Connexion exception. + operationId: exceptionWrappedConnexion + responses: + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /exceptions/wrapped/starlette: + get: + description: Raise wrapped Starlette exception. + operationId: exceptionWrappedStarlette + responses: + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /exceptions/wrapped/werkzeug: + get: + description: Raise wrapped Werkzeug exception. + operationId: exceptionWrappedWerkzeug + responses: + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /exceptions/custom: + get: + description: Raise custom Python exception. + operationId: exceptionCustom + responses: + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /exceptions/custom/connexion: + get: + description: Raise custom Connexion exception. + operationId: exceptionCustomConnexion + responses: + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /exceptions/custom/starlette: + get: + description: Raise custom Starlette exception. + operationId: exceptionCustomStarlette + responses: + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /exceptions/custom/werkzeug: + get: + description: Raise custom Werkzeug exception. + operationId: exceptionCustomWerkzeug + responses: + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /exceptions/builtin: + get: + description: Raise builtin Python exception. + operationId: exceptionBuiltin + responses: + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /exceptions/builtin/connexion: + get: + description: Raise builtin Connexion exception. + operationId: exceptionBuiltinConnexion + responses: + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /exceptions/builtin/starlette: + get: + description: Raise builtin Starlette exception. + operationId: exceptionBuiltinStarlette + responses: + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /exceptions/builtin/werkzeug: + get: + description: Raise builtin Werkzeug exception. + operationId: exceptionBuiltinWerkzeug + responses: + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' components: schemas: Pet: diff --git a/examples/petstore/petstore_correct.yaml b/examples/petstore/petstore_correct.yaml new file mode 100644 index 00000000..7797575c --- /dev/null +++ b/examples/petstore/petstore_correct.yaml @@ -0,0 +1,152 @@ +openapi: 3.0.2 +info: + version: 1.0.0 + title: Swagger Petstore + description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification + termsOfService: http://swagger.io/terms/ + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html +servers: + - url: http://localhost/ +paths: + /pets: + get: + description: | + Returns all pets from the system that the user has access to. + operationId: findPets + parameters: + - name: tags + in: query + description: tags to filter by + required: false + style: form + schema: + type: array + items: + type: string + - name: limit + in: query + description: maximum number of results to return + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: pet response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + description: Creates a new pet in the store. Duplicates are allowed + operationId: addPet + requestBody: + x-body-name: pet + description: Pet to add to the store + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewPet' + responses: + '200': + description: pet response + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /pets/{id}: + get: + description: Returns a user based on a single ID, if the user does not have access to the pet + operationId: findPetById + parameters: + - name: id + in: path + description: ID of pet to fetch + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: pet response + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + description: deletes a single pet based on the ID supplied + operationId: deletePet + parameters: + - name: id + in: path + description: ID of pet to delete + required: true + schema: + type: integer + format: int64 + responses: + '204': + description: pet deleted + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Pet: + allOf: + - $ref: '#/components/schemas/NewPet' + - type: object + required: + - id + properties: + id: + type: integer + format: int64 + + NewPet: + type: object + required: + - name + properties: + name: + type: string + tag: + type: string + + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/examples/petstore/test.yaml b/examples/petstore/test.yaml new file mode 100644 index 00000000..bfafb01f --- /dev/null +++ b/examples/petstore/test.yaml @@ -0,0 +1,32 @@ +responses: + default: + "*": + msg: Interal Server Error + status_code: 500 + 400: + "('operationId1', 'operationId2')": + msg: Bad Request + status_code: 400 + operationId3: + msg: Extra Parameter + status_code: 400 + 401: + "*": + msg: Unauthorized + status_code: 401 + operationId4: + msg: Very Unauthorized + status_code: 401 + 403: + "*": + msg: Forbidden + status_code: 403 + 500: + "*": + msg: Internal Server Error + status_code: 500 + + 5XX: + "*": + msg: Internal Server Error + status_code: 500 diff --git a/foca/api/register_openapi.py b/foca/api/register_openapi.py index 88810054..6ee86347 100644 --- a/foca/api/register_openapi.py +++ b/foca/api/register_openapi.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Dict, List -from connexion import App +from connexion import FlaskApp from foca.models.config import SpecConfig from foca.config.config_parser import ConfigParser @@ -14,9 +14,9 @@ def register_openapi( - app: App, + app: FlaskApp, specs: List[SpecConfig], -) -> App: +) -> FlaskApp: """ Register OpenAPI specifications with Connexion application instance. @@ -87,10 +87,10 @@ def register_openapi( # Attach specs to connexion App logger.debug(f"Modified specs: {spec_parsed}") - spec.connexion = {} if spec.connexion is None else spec.connexion + spec.connexion = {} if spec.connexion is None else spec.connexion # mod app.add_api( specification=spec_parsed, - **spec.model_dump().get('connexion', {}), + **spec.connexion, ) logger.info(f"API endpoints added from spec: {spec.path_out}") diff --git a/foca/errors/exceptions.py b/foca/errors/exceptions.py index 0a62223e..78e39cc4 100644 --- a/foca/errors/exceptions.py +++ b/foca/errors/exceptions.py @@ -5,14 +5,15 @@ from traceback import format_exception from typing import (Dict, List) -from connexion import App +import connexion +from connexion import FlaskApp +from connexion.lifecycle import ConnexionRequest, ConnexionResponse from connexion.exceptions import ( ExtraParameterProblem, Forbidden, OAuthProblem, Unauthorized, ) -from flask import (current_app, Response) from json import dumps from werkzeug.exceptions import ( BadRequest, @@ -22,8 +23,11 @@ NotFound, ServiceUnavailable, ) +import connexion.exceptions +import werkzeug.exceptions +import starlette.exceptions -from foca.models.config import _get_by_path +from foca.models.config import _get_by_path, ExceptionConfig # Get logger instance logger = logging.getLogger(__name__) @@ -76,8 +80,39 @@ } } +exceptions_http = { + 400: { + "title": "Bad Request", + "status": 400, + }, + 401: { + "title": "Unauthorized", + "status": 401, + }, + 403: { + "title": "Forbidden", + "status": 403, + }, + 404: { + "title": "Not Found", + "status": 404, + }, + 500: { + "title": "Internal Server Error", + "status": 500, + }, + 502: { + "title": "Bad Gateway", + "status": 502, + }, + 504: { + "title": "Gateway Timeout", + "status": 504, + } +} -def register_exception_handler(app: App) -> App: + +def register_exception_handler(app: FlaskApp) -> FlaskApp: """Register generic JSON problem handler with Connexion application instance. @@ -88,8 +123,19 @@ def register_exception_handler(app: App) -> App: Connexion application instance with registered generic JSON problem handler. """ - app.add_error_handler(Exception, _problem_handler_json) - logger.debug("Registered generic JSON problem handler with Connexion app.") + app.add_error_handler( + Exception, + exception_handler_builtin, + ) + app.add_error_handler( + starlette.exceptions.HTTPException, + exception_handler_starlette, + ) + app.add_error_handler( + connexion.exceptions.ProblemException, + exception_handler_connexion, + ) + logger.debug("Registered FOCA problem handler with Connexion app.") return app @@ -201,7 +247,10 @@ def _exclude_key_nested_dict( return obj -def _problem_handler_json(exception: Exception) -> Response: +def _problem_handler_json( + request: ConnexionRequest, + exception: Exception, +) -> ConnexionResponse: """Generic JSON problem handler. Args: @@ -210,9 +259,23 @@ def _problem_handler_json(exception: Exception) -> Response: Returns: JSON-formatted error response. """ - # Look up exception & get status code - conf = current_app.config.foca.exceptions # type: ignore[attr-defined] + logger.warning(f"Config: {connexion.request.state.config}") + logger.warning(f"ExceptionConfig: {connexion.request.state.config.exceptions}") + conf: ExceptionConfig = connexion.request.state.config.exceptions + assert conf.mapping is not None exc = type(exception) + logger.warning(f"Exception type: {exc}") + logger.warning(f"Exception type dir: {dir(exc)}") + logger.warning(f"Exception: {exception}") + logger.warning(f"Exception dir: {dir(exception)}") + logger.warning(f"Exception args: {exception.args}") + logger.warning(f"Exception args dir: {dir(exception.args)}") + logger.warning(f"Exception detail: {exception.detail}") + logger.warning(f"Exception detail dir: {dir(exception.detail)}") + logger.warning(f"Exception headers: {exception.headers}") + logger.warning(f"Exception headers dir: {dir(exception.headers)}") + logger.warning(f"Exception status code: {exception.status_code}") + logger.warning(f"Exception status code dir: {dir(exception.status_code)}") if exc not in conf.mapping: exc = Exception try: @@ -226,8 +289,8 @@ def _problem_handler_json(exception: Exception) -> Response: exc=exception, format=conf.logging.value ) - return Response( - status=500, + return ConnexionResponse( + status_code=500, mimetype="application/problem+json", ) # Log exception JSON & traceback @@ -253,8 +316,69 @@ def _problem_handler_json(exception: Exception) -> Response: key_sequence=member, )) # Return response - return Response( - response=dumps(keep), - status=status, + return ConnexionResponse( + status_code=status, + mimetype="application/problem+json", + body=dumps(keep), + ) + + +def exception_handler_builtin( + request: ConnexionRequest, + exception: Exception, +) -> ConnexionResponse: + """Return JSON-formatted error response for built-in exceptions. + + Return the ``500`` error response defined in the OpenAPI specification for + the operation during which the exception was raised. If not defined, return + the corresponding default response for that operation instead. If also not + defined, or if the exception is not associated with a specific operation, + return the defined global default response. If none of these are defined, + return a generic RFC 9457-compliant error response. + + Args: + request: Connexion request object. + exception: Raised exception. + + Returns: + JSON-formatted error response. + """ + logger.warning( + f'Exception "{exception}" of type "{type(exception)}" raised by ' + '"builtins.Exception" handler.' + ) + return ConnexionResponse( + status_code=500, + mimetype="application/problem+json", + body=dumps({"title": "Internal Server Error", "status": 500}), + ) + + +def exception_handler_connexion( + request: ConnexionRequest, + exception: Exception, +) -> ConnexionResponse: + logger.warning( + f'Exception "{exception}" of type "{type(exception)}" raised by ' + '"connexion.exceptions.ProblemException" handler.' + ) + return ConnexionResponse( + status_code=500, + mimetype="application/problem+json", + body=dumps({"title": "Internal Server Error", "status": 500}), + ) + + +def exception_handler_starlette( + request: ConnexionRequest, + exception: Exception, +) -> ConnexionResponse: + logger.warning( + f'Exception "{exception}" of type "{type(exception)}" raised by ' + '"starlette.exceptions.HTTPException" handler.' + ) + return ConnexionResponse( + status_code=500, mimetype="application/problem+json", + body=dumps({"title": "Internal Server Error", "status": 500}), ) diff --git a/foca/factories/connexion_app.py b/foca/factories/connexion_app.py index b39b7f6c..81935bc3 100644 --- a/foca/factories/connexion_app.py +++ b/foca/factories/connexion_app.py @@ -1,10 +1,11 @@ """Factory for creating and configuring Connexion application instances.""" +import contextlib from inspect import stack import logging -from typing import Optional +from typing import AsyncIterator, Optional -from connexion import App +from connexion import FlaskApp, ConnexionMiddleware from foca.models.config import Config @@ -12,7 +13,7 @@ logger = logging.getLogger(__name__) -def create_connexion_app(config: Optional[Config] = None) -> App: +def create_connexion_app(config: Optional[Config] = Config()) -> FlaskApp: """Create and configure Connexion application instance. Args: @@ -21,57 +22,18 @@ def create_connexion_app(config: Optional[Config] = None) -> App: Returns: Connexion application instance. """ + + @contextlib.asynccontextmanager + async def config_handler(app: ConnexionMiddleware) -> AsyncIterator: + yield {"config": config} + # Instantiate Connexion app - app = App( + app = FlaskApp( __name__, - skip_error_handlers=True, + lifespan=config_handler, ) calling_module = ':'.join([stack()[1].filename, stack()[1].function]) logger.debug(f"Connexion app created from '{calling_module}'.") - # Configure Connexion app - if config is not None: - app = __add_config_to_connexion_app( - app=app, - config=config, - ) - - return app - - -def __add_config_to_connexion_app( - app: App, - config: Config, -) -> App: - """Replace default Flask and Connexion settings with FOCA configuration - parameters. - - Args: - app: Connexion application instance. - config: Application configuration. - - Returns: - Connexion application instance with updated configuration. - """ - conf = config.server - - # replace Connexion app settings - app.host = conf.host - app.port = conf.port - app.debug = conf.debug - - # replace Flask app settings - app.app.config['DEBUG'] = conf.debug - app.app.config['ENV'] = conf.environment - app.app.config['TESTING'] = conf.testing - - logger.debug('Flask app settings:') - for (key, value) in app.app.config.items(): - logger.debug('* {}: {}'.format(key, value)) - - # Add user configuration to Flask app config - setattr(app.app.config, 'foca', config) - - logger.debug('Connexion app configured.') return app diff --git a/foca/foca.py b/foca/foca.py index a249c896..50a20ebd 100644 --- a/foca/foca.py +++ b/foca/foca.py @@ -5,22 +5,22 @@ from typing import Optional from celery import Celery -from connexion import App +from connexion import FlaskApp -from foca.security.access_control.register_access_control import ( - register_access_control, -) -from foca.security.access_control.constants import ( - DEFAULT_SPEC_CONTROLLER, - DEFAULT_ACCESS_CONTROL_DB_NAME, - DEFAULT_ACESS_CONTROL_COLLECTION_NAME, -) from foca.api.register_openapi import register_openapi from foca.config.config_parser import ConfigParser from foca.database.register_mongodb import register_mongodb from foca.errors.exceptions import register_exception_handler -from foca.factories.connexion_app import create_connexion_app from foca.factories.celery_app import create_celery_app +from foca.factories.connexion_app import create_connexion_app +from foca.security.access_control.constants import ( + DEFAULT_SPEC_CONTROLLER, + DEFAULT_ACCESS_CONTROL_DB_NAME, + DEFAULT_ACESS_CONTROL_COLLECTION_NAME, +) +from foca.security.access_control.register_access_control import ( + register_access_control, +) from foca.security.cors import enable_cors # Get logger instance @@ -83,32 +83,31 @@ def __init__( logger.info(f"Configuration file '{self.config_file}' parsed.") else: logger.info("Default app configuration used.") + logger.info(f"App configuration: {self.conf}.") - def create_app(self) -> App: + def create_app(self) -> FlaskApp: """Set up and initialize FOCA-based Connexion app. Returns: Connexion application instance. """ # Create Connexion app - cnx_app = create_connexion_app(self.conf) + connexion_app = create_connexion_app(self.conf) logger.info("Connexion app created.") - # Register error handlers - cnx_app = register_exception_handler(cnx_app) - logger.info("Error handler registered.") - # Enable cross-origin resource sharing if self.conf.security.cors.enabled is True: - enable_cors(cnx_app.app) + connexion_app = enable_cors(connexion_app) logger.info("CORS enabled.") - else: - logger.info("CORS not enabled.") + + # Register error handler + connexion_app = register_exception_handler(connexion_app) + logger.info("Error handler registered.") # Register OpenAPI specs if self.conf.api.specs: - cnx_app = register_openapi( - app=cnx_app, + connexion_app = register_openapi( + app=connexion_app, specs=self.conf.api.specs, ) else: @@ -116,49 +115,49 @@ def create_app(self) -> App: # Register MongoDB if self.conf.db: - cnx_app.app.config.foca.db = register_mongodb( - app=cnx_app.app, + self.conf.db = register_mongodb( + app=connexion_app.app, conf=self.conf.db, ) logger.info("Database registered.") else: logger.info("No database support configured.") - # Register permission management and casbin enforcer - if self.conf.security.auth.required: - if ( - self.conf.security.access_control.api_specs is None - or self.conf.security.access_control.api_controllers is None - ): - self.conf.security.access_control.api_controllers = ( - DEFAULT_SPEC_CONTROLLER - ) - - if self.conf.security.access_control.db_name is None: - self.conf.security.access_control.db_name = ( - DEFAULT_ACCESS_CONTROL_DB_NAME - ) - - if self.conf.security.access_control.collection_name is None: - self.conf.security.access_control.collection_name = ( - DEFAULT_ACESS_CONTROL_COLLECTION_NAME - ) - - cnx_app = register_access_control( - cnx_app=cnx_app, - mongo_config=self.conf.db, - access_control_config=self.conf.security.access_control, - ) - else: - if ( - self.conf.security.access_control.api_specs - or self.conf.security.access_control.api_controllers - ): - logger.error( - "Please enable security config to register access control." - ) - - return cnx_app + # # Register permission management and Casbin enforcer + # if self.conf.security.auth.required: + # if ( + # self.conf.security.access_control.api_specs is None + # or self.conf.security.access_control.api_controllers is None + # ): + # self.conf.security.access_control.api_controllers = ( + # DEFAULT_SPEC_CONTROLLER + # ) + + # if self.conf.security.access_control.db_name is None: + # self.conf.security.access_control.db_name = ( + # DEFAULT_ACCESS_CONTROL_DB_NAME + # ) + + # if self.conf.security.access_control.collection_name is None: + # self.conf.security.access_control.collection_name = ( + # DEFAULT_ACESS_CONTROL_COLLECTION_NAME + # ) + + # connexion_app, self.conf.db = register_access_control( + # cnx_app=connexion_app, + # mongo_config=self.conf.db, + # access_control_config=self.conf.security.access_control, + # ) + # else: + # if ( + # self.conf.security.access_control.api_specs + # or self.conf.security.access_control.api_controllers + # ): + # logger.error( + # "Please enable security config to register access control." + # ) + + return connexion_app def create_celery_app(self) -> Celery: """Set up and initialize FOCA-based Celery app. @@ -166,32 +165,32 @@ def create_celery_app(self) -> Celery: Returns: Celery application instance. """ - # Create Connexion app - cnx_app = create_connexion_app(self.conf) - logger.info("Connexion app created.") - - # Register error handlers - cnx_app = register_exception_handler(cnx_app) - logger.info("Error handler registered.") - - # Register MongoDB - if self.conf.db: - cnx_app.app.config.foca.db = register_mongodb( - app=cnx_app.app, - conf=self.conf.db, - ) - logger.info("Database registered.") - else: - logger.info("No database support configured.") - - # Create Celery app - if self.conf.jobs: - celery_app = create_celery_app(cnx_app.app) - logger.info("Support for background tasks set up.") - else: - raise ValueError( - "No support for background tasks configured. Please use the " - "'jobs' keyword section in your configuration file." - ) - - return celery_app + # # Create Connexion app + # connexion_app = create_connexion_app(self.conf) + # logger.info("Connexion app created.") + + # # Register error handlers + # connexion_app = register_exception_handler(connexion_app) + # logger.info("Error handler registered.") + + # # Register MongoDB + # if self.conf.db: + # self.conf.db = register_mongodb( + # app=connexion_app.app, + # conf=self.conf.db, + # ) + # logger.info("Database registered.") + # else: + # logger.info("No database support configured.") + + # # Create Celery app + # if self.conf.jobs: + # celery_app = create_celery_app(connexion_app.app) + # logger.info("Support for background tasks set up.") + # else: + # raise ValueError( + # "No support for background tasks configured. Please use the " + # "'jobs' keyword section in your configuration file." + # ) + + # return celery_app diff --git a/foca/security/access_control/access_control_server.py b/foca/security/access_control/access_control_server.py index e55474a6..98013975 100644 --- a/foca/security/access_control/access_control_server.py +++ b/foca/security/access_control/access_control_server.py @@ -4,12 +4,14 @@ from typing import (Dict, List) +import connexion from flask import (request, current_app) -from pymongo.collection import Collection from werkzeug.exceptions import (InternalServerError, NotFound) -from foca.utils.logging import log_traffic +from foca.models.config import Config from foca.errors.exceptions import BadRequest +from foca.utils.db import get_client +from foca.utils.logging import log_traffic logger = logging.getLogger(__name__) @@ -62,22 +64,20 @@ def putPermission( """ request_json = request.json if isinstance(request_json, dict): - app_config = current_app.config + app_config: Config = connexion.request.state.config try: - security_conf = \ - app_config.foca.security # type: ignore[attr-defined] - access_control_config = \ - security_conf.access_control # type: ignore[attr-defined] - db_coll_permission: Collection = ( - app_config.foca.db.dbs[ # type: ignore[attr-defined] - access_control_config.db_name] - .collections[access_control_config.collection_name].client + access_control_config = app_config.security.access_control + assert access_control_config.db_name is not None + assert access_control_config.collection_name is not None + client = get_client( + config=app_config, + db=access_control_config.db_name, + collection=access_control_config.collection_name ) - permission_data = request_json.get("rule", {}) permission_data["id"] = id permission_data["ptype"] = request_json.get("policy_type", None) - db_coll_permission.replace_one( + client.replace_one( filter={"id": id}, replacement=permission_data, upsert=True @@ -102,19 +102,19 @@ def getAllPermissions(limit=None) -> List[Dict]: Returns: List of permission dicts. """ - app_config = current_app.config - access_control_config = \ - app_config.foca.security.access_control # type: ignore[attr-defined] - db_coll_permission: Collection = ( - app_config.foca.db.dbs[ # type: ignore[attr-defined] - access_control_config.db_name - ].collections[access_control_config.collection_name].client + app_config: Config = connexion.request.state.config + access_control_config = app_config.security.access_control + assert access_control_config.db_name is not None + assert access_control_config.collection_name is not None + client = get_client( + config=app_config, + db=access_control_config.db_name, + collection=access_control_config.collection_name ) - if not limit: limit = 0 permissions = list( - db_coll_permission.find( + client.find( filter={}, projection={'_id': False} ).sort([('$natural', -1)]).limit(limit) @@ -146,16 +146,16 @@ def getPermission( Returns: Permission data for the given id. """ - app_config = current_app.config - access_control_config = \ - app_config.foca.security.access_control # type: ignore[attr-defined] - db_coll_permission: Collection = ( - app_config.foca.db.dbs[ # type: ignore[attr-defined] - access_control_config.db_name - ].collections[access_control_config.collection_name].client + app_config: Config = connexion.request.state.config + access_control_config = app_config.security.access_control + assert access_control_config.db_name is not None + assert access_control_config.collection_name is not None + client = get_client( + config=app_config, + db=access_control_config.db_name, + collection=access_control_config.collection_name ) - - permission = db_coll_permission.find_one(filter={"id": id}) + permission = client.find_one(filter={"id": id}) if permission is None: raise NotFound del permission["_id"] @@ -182,17 +182,16 @@ def deletePermission( Returns: Delete permission identifier. """ - app_config = current_app.config - access_control_config = \ - app_config.foca.security.access_control # type: ignore[attr-defined] - db_coll_permission: Collection = ( - app_config.foca.db.dbs[ # type: ignore[attr-defined] - access_control_config.db_name - ].collections[access_control_config.collection_name].client + app_config: Config = connexion.request.state.config + access_control_config = app_config.security.access_control + assert access_control_config.db_name is not None + assert access_control_config.collection_name is not None + client = get_client( + config=app_config, + db=access_control_config.db_name, + collection=access_control_config.collection_name ) - - del_obj_permission = db_coll_permission.delete_one({'id': id}) - + del_obj_permission = client.delete_one({'id': id}) if del_obj_permission.deleted_count: return id else: diff --git a/foca/security/access_control/constants.py b/foca/security/access_control/constants.py index eda4d1ed..aa6aed0d 100644 --- a/foca/security/access_control/constants.py +++ b/foca/security/access_control/constants.py @@ -1,5 +1,4 @@ -"""File to store permission based constants. -""" +"""File to store permission based constants.""" DEFAULT_ACCESS_CONTROL_DB_NAME = "access_control_db" DEFAULT_ACESS_CONTROL_COLLECTION_NAME = "policy_rules" diff --git a/foca/security/access_control/register_access_control.py b/foca/security/access_control/register_access_control.py index ffd1894a..41287928 100644 --- a/foca/security/access_control/register_access_control.py +++ b/foca/security/access_control/register_access_control.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import (Callable, Optional, Tuple) -from connexion import App +from connexion import FlaskApp from connexion.exceptions import Forbidden from flask import current_app from flask.wrappers import Response @@ -30,10 +30,10 @@ def register_access_control( - cnx_app: App, + cnx_app: FlaskApp, mongo_config: Optional[MongoConfig], access_control_config: AccessControlConfig -) -> App: +) -> Tuple[FlaskApp, MongoConfig]: """Register access control configuration with flask app. Args: @@ -66,8 +66,6 @@ def register_access_control( else: mongo_config.dbs[access_control_db] = access_db_conf - cnx_app.app.config.foca.db = mongo_config - # Register new database for access control. add_new_database( app=cnx_app.app, @@ -88,11 +86,11 @@ def register_access_control( access_control_config=access_control_config ) - return cnx_app + return cnx_app, mongo_config def register_permission_specs( - app: App, + app: FlaskApp, access_control_config: AccessControlConfig ): """Register open api specs for permission management. @@ -140,10 +138,10 @@ def register_permission_specs( def register_casbin_enforcer( - app: App, + app: FlaskApp, access_control_config: AccessControlConfig, mongo_config: MongoConfig -) -> App: +) -> FlaskApp: """Method to add casbin permission enforcer. Args: diff --git a/foca/security/auth.py b/foca/security/auth.py index d0f3468c..7d5caccf 100644 --- a/foca/security/auth.py +++ b/foca/security/auth.py @@ -4,9 +4,10 @@ import logging from typing import (Dict, Iterable, List, Optional) +import connexion from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey -from flask import current_app, request +from flask import request import jwt from jwt.exceptions import InvalidKeyError import requests @@ -36,7 +37,7 @@ def validate_token(token: str) -> Dict: oidc_config_claim_public_keys: str = 'jwks_uri' # Fetch security parameters - conf = current_app.config.foca.security.auth # type: ignore[attr-defined] + conf = connexion.request.state.config.security.auth add_key_to_claims: bool = conf.add_key_to_claims allow_expired: bool = conf.allow_expired audience: Optional[Iterable[str]] = conf.audience @@ -125,8 +126,7 @@ def validate_token(token: str) -> Dict: for key, val in claims.items(): req_headers[key] = val req_headers['user_id'] = claims[claim_identity] - request.headers = \ - ImmutableMultiDict(req_headers) # type: ignore[assignment] + request.headers = ImmutableMultiDict(req_headers) # type: ignore # Return token info return { diff --git a/foca/security/cors.py b/foca/security/cors.py index bf110178..8c9bcf34 100644 --- a/foca/security/cors.py +++ b/foca/security/cors.py @@ -1,20 +1,28 @@ """Resources for cross-origin resource sharing (CORS).""" import logging -from flask import Flask -from flask_cors import CORS +from connexion import FlaskApp +from connexion.middleware import MiddlewarePosition +from starlette.middleware.cors import CORSMiddleware # Get logger instance logger = logging.getLogger(__name__) -def enable_cors(app: Flask) -> None: - """Enables cross-origin resource sharing (CORS) for Flask application - instance. +def enable_cors(app: FlaskApp) -> FlaskApp: + """Enables cross-origin resource sharing (CORS). Args: - app: Flask application instance. + app: Connexion application instance. """ - CORS(app) - logger.debug('Enabled CORS for Flask app.') + app.add_middleware( + CORSMiddleware, # type: ignore + position=MiddlewarePosition.BEFORE_EXCEPTION, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + logger.debug('Enabled CORS for Connexion app.') + return app diff --git a/foca/utils/db.py b/foca/utils/db.py index e44604c5..494bd061 100644 --- a/foca/utils/db.py +++ b/foca/utils/db.py @@ -5,6 +5,8 @@ from bson.objectid import ObjectId from pymongo.collection import Collection +from foca.models.config import Config + def find_one_latest(collection: Collection) -> Optional[Mapping[Any, Any]]: """Return newest document, stripped of `ObjectId`. @@ -39,3 +41,31 @@ def find_id_latest(collection: Collection) -> Optional[ObjectId]: return collection.find().sort([('_id', -1)]).limit(1).next()['_id'] except StopIteration: return None + + +def get_client(config: Config, db: str, collection: str) -> Collection: + """Get client for a given database collection. + + Args: + config: Application configuration. + + Raises: + AssertionError: If the database configuration is invalid or incomplete, + or the specified database or collection is missing. + """ + assert config.db is not None, "Database configuration is missing." + assert config.db.dbs is not None, "Database connections are missing." + + my_db = config.db.dbs.get(db) + assert my_db is not None, f"Database '{db}' is missing." + assert my_db.collections is not None, f"Database '{db}' has no collections." + + my_collection = my_db.collections.get(collection) + assert my_collection is not None, ( + f"Database collection '{collection}' is missing." + ) + assert my_collection.client is not None, ( + f"Database collection '{collection}' is missing." + ) + + return my_collection.client diff --git a/foca/utils/logging.py b/foca/utils/logging.py index 2a47aaa9..f0d650f2 100644 --- a/foca/utils/logging.py +++ b/foca/utils/logging.py @@ -1,7 +1,7 @@ """Utility functions for logging.""" import logging -from connexion import request +from flask import request from functools import wraps from typing import (Callable, Optional) diff --git a/requirements.txt b/requirements.txt index d5d98faf..ef0b661c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,13 @@ -addict~=2.2 -celery~=5.2 -connexion~=2.11 -cryptography~=42.0 -Flask~=2.2 -flask-authz~=2.5 -Flask-Cors~=4.0 +addict~=2.4 +celery~=5.4 +connexion[swagger-ui,flask,uvicorn]~=3.0 +#cryptography~=42.0 +flask-authz~=2.6 Flask-PyMongo~=2.3 pydantic~=2.7 -PyJWT~=2.4 +#PyJWT~=2.4 pymongo~=4.7 -PyYAML~=6.0 -requests~=2.31 -swagger-ui-bundle~=0.0 -toml~=0.10 -typing~=3.7 -Werkzeug~=2.2 +#PyYAML~=6.0 +#requests~=2.31 +#toml~=0.10 +#typing~=3.7 diff --git a/requirements_dev.txt b/requirements_dev.txt index 7e66e879..7961f93b 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -5,6 +5,7 @@ mypy>=0.991 mypy-extensions>=0.4.4 pylint>=3.2 pytest>=7.4 +pytest-mock>=3.14 python-semantic-release>=9.7 types-PyYAML types-requests diff --git a/setup.py b/setup.py index c29774bf..fc4e9a37 100644 --- a/setup.py +++ b/setup.py @@ -12,9 +12,9 @@ long_description = _file.read() install_requires = [] -req = root_dir / 'requirements.txt' -with open(req, "r") as _file: - install_requires = _file.read().splitlines() +# req = root_dir / 'requirements.txt' +# with open(req, "r") as _file: +# install_requires = _file.read().splitlines() docs_require = [] req = root_dir / 'requirements_docs.txt' @@ -34,7 +34,7 @@ setup( name="foca", - version=__version__, # noqa: F821 + version=__version__, # noqa: F821 # type: ignore description=( "Archetype for OpenAPI microservices based on Flask and Connexion" ), diff --git a/templates/config.yaml b/templates/config.yaml index 68e29506..b27c9b58 100644 --- a/templates/config.yaml +++ b/templates/config.yaml @@ -1,13 +1,13 @@ # FOCA CONFIGURATION -# Available in app context as attributes of `current_app.config.foca` +# Available in app context as attributes of `connexion.request.state.config` # Automatically validated via FOCA # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html # SERVER CONFIGURATION # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.ServerConfig server: - host: '0.0.0.0' + host: "0.0.0.0" port: 8080 debug: True environment: development @@ -17,8 +17,8 @@ server: # EXCEPTION CONFIGURATION # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.ExceptionConfig exceptions: - required_members: [['msg'], ['status']] - status_member: ['status'] + required_members: [["msg"], ["status"]] + status_member: ["status"] exceptions: my_app.exceptions.exceptions # SECURITY CONFIGURATION @@ -66,14 +66,14 @@ db: - keys: id: 1 options: - 'unique': True + "unique": True # WORKER CONFIGURATION # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.JobsConfig jobs: host: rabbitmq port: 5672 - backend: 'rpc://' + backend: "rpc://" include: - my_app.tasks.my_task_1 - my_app.tasks.my_task_2 @@ -98,16 +98,15 @@ log: level: 10 handlers: [console] - # CUSTOM APP CONFIGURATION -# Available in app context as attributes of `current_app.config.foca` +# Available in app context as attributes of `connexion.request.state.config` # Can be validated by FOCA by passing a Pydantic model class to the # `custom_config_model` parameter in the `foca.Foca()` constructor custom: - my_param: 'some_value' + my_param: "some_value" # Any other sections/parameters are *not* validated by FOCA; if desired, # validate parameters in app custom_params_not_validated: - my_other_param: 'some_other_value' + my_other_param: "some_other_value" diff --git a/tests/errors/test_errors.py b/tests/errors/test_exceptions.py similarity index 55% rename from tests/errors/test_errors.py rename to tests/errors/test_exceptions.py index 93eb91dd..a1cbfb84 100644 --- a/tests/errors/test_errors.py +++ b/tests/errors/test_exceptions.py @@ -5,10 +5,11 @@ from copy import deepcopy import json -from flask import (Flask, Response) -from connexion import App +from connexion import FlaskApp +from connexion.lifecycle import ConnexionResponse import pytest + from foca.errors.exceptions import ( _exc_to_str, _exclude_key_nested_dict, @@ -50,11 +51,30 @@ class UnknownException(Exception): pass +@pytest.fixture +def foca_app(): + """Create a Connexion app.""" + app = FlaskApp(__name__) + setattr(app.app.config, 'foca', Config()) + return app + + +@pytest.fixture +def mock_connexion_request(mocker): + request = mocker.MagicMock() + request.headers = {} + request.args = {} + request.json = {} + request.method = "GET" + request.path = "/test_endpoint" + return request + + def test_register_exception_handler(): """Test exception handler registration with Connexion app.""" - app = App(__name__) + app = FlaskApp(__name__) ret = register_exception_handler(app) - assert isinstance(ret, App) + assert isinstance(ret, FlaskApp) def test__exc_to_str(): @@ -100,53 +120,60 @@ def test__exclude_key_nested_dict(): assert res == EXPECTED_EXCLUDE_RESULT -def test__problem_handler_json(): +def test__problem_handler_json(foca_app, mock_connexion_request): """Test problem handler with instance of custom, unlisted error.""" - app = Flask(__name__) - setattr(app.config, 'foca', Config()) - EXPECTED_RESPONSE = app.config.foca.exceptions.mapping[Exception] - with app.app_context(): - res = _problem_handler_json(UnknownException()) - assert isinstance(res, Response) - assert res.status == '500 INTERNAL SERVER ERROR' + EXPECTED_RESPONSE = ( + foca_app.app.config.foca.exceptions.mapping[Exception] # type: ignore + ) + with foca_app.app.app_context(): + res = _problem_handler_json(mock_connexion_request, UnknownException()) + assert isinstance(res, ConnexionResponse) + assert res.status_code == 500 assert res.mimetype == "application/problem+json" - response = json.loads(res.data.decode('utf-8')) + response = json.loads(res.body) # type: ignore assert response == EXPECTED_RESPONSE -def test__problem_handler_json_no_fallback_exception(): +def test__problem_handler_json_no_fallback_exception( + foca_app, + mock_connexion_request +): """Test problem handler; unlisted error without fallback.""" - app = Flask(__name__) - setattr(app.config, 'foca', Config()) - del app.config.foca.exceptions.mapping[Exception] - with app.app_context(): - res = _problem_handler_json(UnknownException()) - assert isinstance(res, Response) - assert res.status == '500 INTERNAL SERVER ERROR' + del foca_app.app.config.foca.exceptions.mapping[Exception] # type: ignore + with foca_app.app.app_context(): + res = _problem_handler_json(mock_connexion_request, UnknownException()) + assert isinstance(res, ConnexionResponse) + assert res.status_code == 500 assert res.mimetype == "application/problem+json" - response = res.data.decode("utf-8") - assert response == "" + response = res.body + assert response is None -def test__problem_handler_json_with_public_members(): +def test__problem_handler_json_with_public_members( + foca_app, + mock_connexion_request +): """Test problem handler with public members.""" - app = Flask(__name__) - setattr(app.config, 'foca', Config()) - app.config.foca.exceptions.public_members = PUBLIC_MEMBERS - with app.app_context(): - res = _problem_handler_json(UnknownException()) - assert isinstance(res, Response) - assert res.status == '500 INTERNAL SERVER ERROR' + foca_app.app.config.foca.exceptions.public_members = ( # type: ignore + PUBLIC_MEMBERS + ) + with foca_app.app.app_context(): + res = _problem_handler_json(mock_connexion_request, UnknownException()) + assert isinstance(res, ConnexionResponse) + assert res.status_code == 500 assert res.mimetype == "application/problem+json" -def test__problem_handler_json_with_private_members(): +def test__problem_handler_json_with_private_members( + foca_app, + mock_connexion_request +): """Test problem handler with private members.""" - app = Flask(__name__) - setattr(app.config, 'foca', Config()) - app.config.foca.exceptions.private_members = PRIVATE_MEMBERS - with app.app_context(): - res = _problem_handler_json(UnknownException()) - assert isinstance(res, Response) - assert res.status == '500 INTERNAL SERVER ERROR' + foca_app.app.config.foca.exceptions.private_members = ( # type: ignore + PRIVATE_MEMBERS + ) + with foca_app.app.app_context(): + res = _problem_handler_json(mock_connexion_request, UnknownException()) + assert isinstance(res, ConnexionResponse) + assert res.status_code == 500 assert res.mimetype == "application/problem+json" diff --git a/tests/security/test_cors.py b/tests/security/test_cors.py index 376b120f..ed375cdf 100644 --- a/tests/security/test_cors.py +++ b/tests/security/test_cors.py @@ -1,15 +1,33 @@ """Unit test for security.cors.py""" -from unittest.mock import patch +import functools -from flask import Flask +from connexion import FlaskApp +from starlette.middleware.cors import CORSMiddleware from foca.security.cors import enable_cors def test_enable_cors(): """Test that CORS is called with app as a parameter.""" - app = Flask(__name__) - with patch('foca.security.cors.CORS') as mock_cors: - enable_cors(app) - mock_cors.assert_called_once_with(app) + app = FlaskApp(__name__) + expected_middleware = functools.partial( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + assert not any( + isinstance(item, functools.partial) + and item.func == expected_middleware.func + and item.args == expected_middleware.args + for item in app.middleware.middlewares + ) + enable_cors(app) + assert any( + isinstance(item, functools.partial) + and item.func == expected_middleware.func + and item.args == expected_middleware.args + for item in app.middleware.middlewares + )