Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

EPICSYSTEM-178-Swagger integrated #5

Merged
17 changes: 17 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Submit API",
"type": "python",
"cwd": "${workspaceFolder}/submit-api/",
"request": "launch",
"program": "${workspaceFolder}/submit-api/wsgi.py",
"console": "integratedTerminal",
"justMyCode": true
}
]
}
2 changes: 1 addition & 1 deletion submit-api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ pylint: ## Linting with pylint
. venv/bin/activate && pylint --rcfile=setup.cfg src/$(PROJECT_NAME)

flake8: ## Linting with flake8
. venv/bin/activate && flake8 src/$(PROJECT_NAME) tests
. venv/bin/activate && flake8 --extend-ignore=Q000,D400,D401,I005,W503 src/$(PROJECT_NAME) tests

lintfix: ## Linting fix
. venv/bin/activate && autopep8 -i -a src/**/*.py tests/**/*.py
Expand Down
1 change: 1 addition & 0 deletions submit-api/requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pytest-mock
requests
flake8==4.0.1
flake8-blind-except
Flask-JWT-Extended=4.6.0
flake8-debugger
flake8-docstrings
flake8-isort
Expand Down
12 changes: 11 additions & 1 deletion submit-api/src/submit_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""

import os
from http import HTTPStatus

import secure
from flask import Flask, current_app, g, request
Expand Down Expand Up @@ -49,7 +50,7 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'development')):
# All configuration are in config file
app.config.from_object(get_named_config(run_mode))

CORS(app, origins=allowedorigins(), supports_credentials=True)
CORS(app, resources={r"/*": {"origins": allowedorigins()}}, supports_credentials=True)

# Register blueprints
app.register_blueprint(API_BLUEPRINT)
Expand Down Expand Up @@ -82,6 +83,14 @@ def set_secure_headers(response):
response.headers['Cross-Origin-Embedder-Policy'] = 'unsafe-none'
return response

@app.errorhandler(Exception)
def handle_error(err):
if run_mode != "production":
# To get stacktrace in local development for internal server errors
raise err
current_app.logger.error(str(err))
return "Internal server error", HTTPStatus.INTERNAL_SERVER_ERROR

# Return App for run in run.py file
return app

Expand All @@ -90,6 +99,7 @@ def build_cache(app):
"""Build cache."""
cache.init_app(app)


def setup_jwt_manager(app_context, jwt_manager):
"""Use flask app to configure the JWTManager to work for a particular Realm."""

Expand Down
70 changes: 69 additions & 1 deletion submit-api/src/submit_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
import sys

from dotenv import find_dotenv, load_dotenv
from flask import g

# this will load all the envars from a .env file located in the project root (api)
load_dotenv(find_dotenv())
Expand Down Expand Up @@ -103,6 +102,75 @@ class TestConfig(_Config): # pylint: disable=too-few-public-methods
DEBUG = True
TESTING = True

# POSTGRESQL
DB_USER = os.getenv('DATABASE_TEST_USERNAME', 'postgres')
DB_PASSWORD = os.getenv('DATABASE_TEST_PASSWORD', 'postgres')
DB_NAME = os.getenv('DATABASE_TEST_NAME', 'testdb')
DB_HOST = os.getenv('DATABASE_TEST_HOST', 'localhost')
DB_PORT = os.getenv('DATABASE_TEST_PORT', '5432')
SQLALCHEMY_DATABASE_URI = f'postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{int(DB_PORT)}/{DB_NAME}'

JWT_OIDC_TEST_MODE = True
# JWT_OIDC_ISSUER = _get_config('JWT_OIDC_TEST_ISSUER')
JWT_OIDC_TEST_AUDIENCE = os.getenv('JWT_OIDC_TEST_AUDIENCE')
JWT_OIDC_TEST_CLIENT_SECRET = os.getenv('JWT_OIDC_TEST_CLIENT_SECRET')
JWT_OIDC_TEST_ISSUER = os.getenv('JWT_OIDC_TEST_ISSUER')
JWT_OIDC_WELL_KNOWN_CONFIG = os.getenv('JWT_OIDC_WELL_KNOWN_CONFIG')
JWT_OIDC_TEST_ALGORITHMS = os.getenv('JWT_OIDC_TEST_ALGORITHMS')
JWT_OIDC_TEST_JWKS_URI = os.getenv('JWT_OIDC_TEST_JWKS_URI', default=None)
JWT_OIDC_TEST_MODE = True

JWT_OIDC_TEST_KEYS = {
'keys': [
{
'kid': 'epictrack',
'kty': 'RSA',
'alg': 'RS256',
'use': 'sig',
'n': 'AN-fWcpCyE5KPzHDjigLaSUVZI0uYrcGcc40InVtl-rQRDmAh-C2W8H4_Hxhr5VLc6crsJ2LiJTV_E72S03pzpOOaaYV6-'
'TzAjCou2GYJIXev7f6Hh512PuG5wyxda_TlBSsI-gvphRTPsKCnPutrbiukCYrnPuWxX5_cES9eStR',
'e': 'AQAB'
}
]
}

JWT_OIDC_TEST_PRIVATE_KEY_JWKS = {
'keys': [
{
'kid': 'forms-flow-ai',
'kty': 'RSA',
'alg': 'RS256',
'use': 'sig',
'n': 'AN-fWcpCyE5KPzHDjigLaSUVZI0uYrcGcc40InVtl-rQRDmAh-C2W8H4_Hxhr5VLc6crsJ2LiJTV_E72S03pzpOOaaYV6-'
'TzAjCou2GYJIXev7f6Hh512PuG5wyxda_TlBSsI-gvphRTPsKCnPutrbiukCYrnPuWxX5_cES9eStR',
'e': 'AQAB',
'd': 'C0G3QGI6OQ6tvbCNYGCqq043YI_8MiBl7C5dqbGZmx1ewdJBhMNJPStuckhskURaDwk4-'
'8VBW9SlvcfSJJrnZhgFMjOYSSsBtPGBIMIdM5eSKbenCCjO8Tg0BUh_'
'xa3CHST1W4RQ5rFXadZ9AeNtaGcWj2acmXNO3DVETXAX3x0',
'p': 'APXcusFMQNHjh6KVD_hOUIw87lvK13WkDEeeuqAydai9Ig9JKEAAfV94W6Aftka7tGgE7ulg1vo3eJoLWJ1zvKM',
'q': 'AOjX3OnPJnk0ZFUQBwhduCweRi37I6DAdLTnhDvcPTrrNWuKPg9uGwHjzFCJgKd8KBaDQ0X1rZTZLTqi3peT43s',
'dp': 'AN9kBoA5o6_Rl9zeqdsIdWFmv4DB5lEqlEnC7HlAP-3oo3jWFO9KQqArQL1V8w2D4aCd0uJULiC9pCP7aTHvBhc',
'dq': 'ANtbSY6njfpPploQsF9sU26U0s7MsuLljM1E8uml8bVJE1mNsiu9MgpUvg39jEu9BtM2tDD7Y51AAIEmIQex1nM',
'qi': 'XLE5O360x-MhsdFXx8Vwz4304-MJg-oGSJXCK_ZWYOB_FGXFRTfebxCsSYi0YwJo-oNu96bvZCuMplzRI1liZw'
}
]
}

JWT_OIDC_TEST_PRIVATE_KEY_PEM = """-----BEGIN RSA PRIVATE KEY-----
MIICXQIBAAKBgQDfn1nKQshOSj8xw44oC2klFWSNLmK3BnHONCJ1bZfq0EQ5gIfg
tlvB+Px8Ya+VS3OnK7Cdi4iU1fxO9ktN6c6TjmmmFevk8wIwqLthmCSF3r+3+h4e
ddj7hucMsXWv05QUrCPoL6YUUz7Cgpz7ra24rpAmK5z7lsV+f3BEvXkrUQIDAQAB
AoGAC0G3QGI6OQ6tvbCNYGCqq043YI/8MiBl7C5dqbGZmx1ewdJBhMNJPStuckhs
kURaDwk4+8VBW9SlvcfSJJrnZhgFMjOYSSsBtPGBIMIdM5eSKbenCCjO8Tg0BUh/
xa3CHST1W4RQ5rFXadZ9AeNtaGcWj2acmXNO3DVETXAX3x0CQQD13LrBTEDR44ei
lQ/4TlCMPO5bytd1pAxHnrqgMnWovSIPSShAAH1feFugH7ZGu7RoBO7pYNb6N3ia
C1idc7yjAkEA6Nfc6c8meTRkVRAHCF24LB5GLfsjoMB0tOeEO9w9Ous1a4o+D24b
AePMUImAp3woFoNDRfWtlNktOqLel5PjewJBAN9kBoA5o6/Rl9zeqdsIdWFmv4DB
5lEqlEnC7HlAP+3oo3jWFO9KQqArQL1V8w2D4aCd0uJULiC9pCP7aTHvBhcCQQDb
W0mOp436T6ZaELBfbFNulNLOzLLi5YzNRPLppfG1SRNZjbIrvTIKVL4N/YxLvQbT
NrQw+2OdQACBJiEHsdZzAkBcsTk7frTH4yGx0VfHxXDPjfTj4wmD6gZIlcIr9lZg
4H8UZcVFN95vEKxJiLRjAmj6g273pu9kK4ymXNEjWWJn
-----END RSA PRIVATE KEY-----"""

class DockerConfig(_Config): # pylint: disable=too-few-public-methods
"""In support of testing only.used by the py.test suite."""
Expand Down
4 changes: 2 additions & 2 deletions submit-api/src/submit_api/models/base_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ class BaseModel(db.Model):
updated_date = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=True)

@declared_attr
def created_by(cls): # pylint:disable=no-self-argument, # noqa: N805
def created_by(cls): # pylint:disable=no-self-argument, no-self-use, # noqa: N805
"""Return foreign key for created by."""
return Column(db.String(50))

@declared_attr
def updated_by(cls): # pylint:disable=no-self-argument, # noqa: N805
def updated_by(cls): # pylint:disable=no-self-argument, no-self-use, # noqa: N805
"""Return foreign key for modified by."""
return Column(db.String(50))

Expand Down
57 changes: 53 additions & 4 deletions submit-api/src/submit_api/resources/apihelper.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@

to support swagger on http
"""
from functools import wraps
from flask import url_for
from flask_restx import Api as BaseApi
from flask_restx import Api as BaseApi, fields
from flask_restx.apidoc import apidoc
from marshmallow import fields as ma_fields


class Api(BaseApi):
Expand All @@ -26,10 +28,57 @@ class Api(BaseApi):
@property
def specs_url(self):
"""Return URL for endpoint."""
scheme = 'http' if '5000' in self.base_url else 'https'
return url_for(self.endpoint('specs'), _external=True, _scheme=scheme)
scheme = "http" if "3200" in self.base_url else "https"
return url_for(self.endpoint("specs"), _external=True, _scheme=scheme)

@classmethod
def swagger_decorators(cls, api, endpoint_description):
"""Common decorators for the resources"""

def decorator(func):
@wraps(func)
@api.doc(description=endpoint_description)
@api.response(401, "Unauthorized")
@api.response(500, "Internal Server Error")
def wrapper(*args, **kwargs):
return func(*args, **kwargs)

return wrapper

return decorator

@classmethod
def convert_ma_schema_to_restx_model(cls, api, schema, name):
"""
Converts a Marshmallow schema to a Flask-RESTX model.

:param api: The Flask-RESTX API instance
:param schema: The Marshmallow schema instance
:param name: The name of the Flask-RESTX model
:return: A Flask-RESTX model
"""
type_mapping = {
ma_fields.Integer: fields.Integer,
ma_fields.String: fields.String,
ma_fields.Float: fields.Float,
ma_fields.Boolean: fields.Boolean,
ma_fields.DateTime: fields.DateTime,
# Add more field types as needed
}
model_fields = {}
for field_name, field in schema.fields.items():
field_type = type(field)
restx_field = type_mapping.get(field_type)
if restx_field:
model_fields[field_name] = restx_field(
required=field.required,
description=field.metadata.get("description", ""),
)
# Add more field types as needed

return api.model(name, model_fields)


# Make a global change setting the URL prefix for the swaggerui at the module level
# This solves the issue where the swaggerui does not pick up the url prefix
apidoc.url_prefix = '/api/'
apidoc.url_prefix = "/api/"
2 changes: 1 addition & 1 deletion submit-api/src/submit_api/resources/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from flask_restx import Namespace, Resource
from sqlalchemy import exc, text

from api.models import db
from submit_api.models import db


API = Namespace('OPS', description='Service - OPS checks')
Expand Down
82 changes: 51 additions & 31 deletions submit-api/src/submit_api/resources/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,69 +15,89 @@

from http import HTTPStatus

from flask import jsonify, request

from submit_api.auth import auth
from submit_api.services.user_service import UserService
from flask_cors import cross_origin
from flask_restx import Namespace, Resource
from submit_api.services.user_service import UserService
from submit_api.utils.util import cors_preflight
from submit_api.schemas.user import UserSchema, UserRequestSchema
from submit_api.exceptions import ResourceNotFoundError
from .apihelper import Api as ApiHelper

from submit_api.utils.util import allowedorigins, cors_preflight

API = Namespace('user', description='Endpoints for User Management')
API = Namespace("users", description="Endpoints for User Management")
"""Custom exception messages
"""

user_request_model = ApiHelper.convert_ma_schema_to_restx_model(
API, UserRequestSchema(), "User"
)
user_list_model = ApiHelper.convert_ma_schema_to_restx_model(
API, UserSchema(), "UserListItem"
)


@cors_preflight('GET, OPTIONS, POST')
@API.route('', methods=["POST", "GET", "OPTIONS"])
@cors_preflight("GET, OPTIONS, POST")
@API.route("", methods=["POST", "GET", "OPTIONS"])
class Users(Resource):
"""Resource for managing users."""

@staticmethod
@cross_origin(origins=allowedorigins())
@auth.require
@API.response(code=200, description="Success", model=[user_list_model])
@ApiHelper.swagger_decorators(API, endpoint_description="Fetch all users")
def get():
"""Fetch all users."""
users = UserService.get_all_users()
return jsonify(users), HTTPStatus.OK
user_list_schema = UserSchema(many=True)
return user_list_schema.dump(users), HTTPStatus.OK

@staticmethod
@cross_origin(origins=allowedorigins())
@auth.require
@ApiHelper.swagger_decorators(API, endpoint_description="Create a user")
@API.expect(user_request_model)
@API.response(code=201, model=user_request_model, description="UserCreated")
@API.response(400, "Bad Request")
def post():
"""Create a user."""
user_data = request.get_json()
user_data = UserRequestSchema().load(API.payload)
created_user = UserService.create_user(user_data)
return created_user, HTTPStatus.CREATED
return UserSchema().dump(created_user), HTTPStatus.CREATED


@cors_preflight('GET, OPTIONS, PUT, DELETE')
@API.route('/<user_id>', methods=["PUT", "GET", "OPTIONS", "DELETE"])
@cors_preflight("GET, OPTIONS, PATCH, DELETE")
@API.route("/<user_id>", methods=["PATCH", "GET", "OPTIONS", "DELETE"])
@API.doc(params={"user_id": "The user identifier"})
class User(Resource):
"""Resource for managing a single user"""

@staticmethod
@cross_origin(origins=allowedorigins())
@auth.require
@ApiHelper.swagger_decorators(API, endpoint_description="Fetch a user by id")
@API.response(code=200, model=user_list_model, description="Success")
@API.response(404, "Not Found")
def get(user_id):
"""Fetch a user by id."""
user = UserService.get_user_by_id(user_id)
return user, HTTPStatus.OK
if not user:
raise ResourceNotFoundError(f"User with {user_id} not found")
return UserSchema().dump(user), HTTPStatus.OK

@staticmethod
@cross_origin(origins=allowedorigins())
@auth.require
def put(user_id):
@ApiHelper.swagger_decorators(API, endpoint_description="Update a user by id")
@API.expect(user_request_model)
@API.response(code=200, model=user_list_model, description="Success")
@API.response(400, "Bad Request")
@API.response(404, "Not Found")
def patch(user_id):
"""Update a user by id."""
user_data = request.get_json()
user_data = UserRequestSchema().load(API.payload)
updated_user = UserService.update_user(user_id, user_data)
return updated_user, HTTPStatus.OK
if not updated_user:
raise ResourceNotFoundError(f"User with {user_id} not found")
return UserSchema().dump(updated_user), HTTPStatus.OK

@staticmethod
@cross_origin(origins=allowedorigins())
@auth.require
@ApiHelper.swagger_decorators(API, endpoint_description="Delete a user by id")
@API.response(code=200, model=user_list_model, description="Deleted")
@API.response(404, "Not Found")
def delete(user_id):
"""Delete a user by id."""
deleted_user_id = UserService.delete_user(user_id)
return deleted_user_id, HTTPStatus.OK
deleted_user = UserService.delete_user(user_id)
if not deleted_user:
raise ResourceNotFoundError(f"User with {user_id} not found")
return UserSchema().dump(deleted_user), HTTPStatus.OK
Loading
Loading