Skip to content

Commit

Permalink
#123 - document using apispec
Browse files Browse the repository at this point in the history
- initialise spec
- adds models
- filters documented
- base entity endpoint fully documented
- sessions endpoint fully documented
  • Loading branch information
louise-davies committed Apr 15, 2020
1 parent 1d84c39 commit dfebf55
Show file tree
Hide file tree
Showing 10 changed files with 6,732 additions and 21 deletions.
15 changes: 9 additions & 6 deletions common/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ def requires_session_id(method):
:returns a 403, "Forbidden" if a valid session_id is not provided with the request
"""


@wraps(method)
def wrapper_requires_session(*args, **kwargs):
log.info(" Authenticating consumer")
Expand All @@ -45,7 +44,6 @@ def wrapper_requires_session(*args, **kwargs):
except AuthenticationError:
return "Forbidden", 403


return wrapper_requires_session


Expand All @@ -54,6 +52,7 @@ def queries_records(method):
Decorator for endpoint resources that search for a record in a table
:param method: The method for the endpoint
:return: Will return a 404, "No such record" if a MissingRecordError is caught
:return: Will return a 400, "Error message" if other expected errors are caught
"""

@wraps(method)
Expand Down Expand Up @@ -93,11 +92,14 @@ def get_session_id_from_auth_header():
parser = reqparse.RequestParser()
parser.add_argument("Authorization", location="headers")
args = parser.parse_args()
auth_header = args["Authorization"].split(" ") if args["Authorization"] is not None else ""
auth_header = args["Authorization"].split(
" ") if args["Authorization"] is not None else ""
if auth_header == "":
raise MissingCredentialsError(f"No credentials provided in auth header")
raise MissingCredentialsError(
f"No credentials provided in auth header")
if len(auth_header) != 2 or auth_header[0] != "Bearer":
raise AuthenticationError(f" Could not authenticate consumer with auth header {auth_header}")
raise AuthenticationError(
f" Could not authenticate consumer with auth header {auth_header}")
return auth_header[1]


Expand Down Expand Up @@ -125,5 +127,6 @@ def get_filters_from_query_string():
filters = []
for arg in request.args:
for value in request.args.getlist(arg):
filters.append(QueryFilterFactory.get_query_filter({arg: json.loads(value)}))
filters.append(QueryFilterFactory.get_query_filter(
{arg: json.loads(value)}))
return filters
2 changes: 2 additions & 0 deletions requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ flask_restful == 0.3.7
sqlalchemy == 1.3.8
pymysql == 0.9.3
flask-cors == 3.0.8
apispec == 3.3.0
flask-swagger-ui == 3.25.0
7 changes: 5 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile '.\requirements.in'
# pip-compile requirements.in
#
aniso8601==8.0.0 # via flask-restful
apispec-flask-restful==0.1
apispec==3.3.0
click==7.0 # via flask
flask-cors==3.0.8
flask==1.1.1 # via flask-cors, flask-restful
flask-swagger-ui==3.25.0
flask==1.1.1 # via flask-cors, flask-restful, flask-swagger-ui
flask_restful==0.3.7
itsdangerous==1.1.0 # via flask
jinja2==2.10.1 # via flask
Expand Down
77 changes: 67 additions & 10 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from flask import Flask
from flask_cors import CORS
from flask_restful import Api
from flask_swagger_ui import get_swaggerui_blueprint

from common.config import config
from common.logger_setup import setup_logger
Expand All @@ -11,35 +12,91 @@
from src.resources.table_endpoints.table_endpoints import UsersInvestigations, UsersInvestigationsCount, \
InstrumentsFacilityCycles, InstrumentsFacilityCyclesCount, InstrumentsFacilityCyclesInvestigations, \
InstrumentsFacilityCyclesInvestigationsCount
from src.swagger.swagger_generator import swagger_gen

swagger_gen.write_swagger_spec()
from apispec import APISpec
from pathlib import Path
import json
from src.swagger.apispec_flask_restful import RestfulPlugin
from src.swagger.initialise_spec import initialise_spec


spec = APISpec(title="DataGateway API", version="1.0", openapi_version="3.0.3",
plugins=[RestfulPlugin()], security=[{"session_id": []}])

app = Flask(__name__)
cors = CORS(app)
app.url_map.strict_slashes = False
api = Api(app)

swaggerui_blueprint = get_swaggerui_blueprint(
"",
"/openapi.json",
config={
'app_name': "DataGateway API OpenAPI Spec"
},
)

app.register_blueprint(swaggerui_blueprint, url_prefix="/")

setup_logger()

initialise_spec(spec)

for entity_name in endpoints:
api.add_resource(get_endpoint(entity_name, endpoints[entity_name]), f"/{entity_name.lower()}")
api.add_resource(get_id_endpoint(entity_name, endpoints[entity_name]), f"/{entity_name.lower()}/<int:id>")
api.add_resource(get_count_endpoint(entity_name, endpoints[entity_name]), f"/{entity_name.lower()}/count")
api.add_resource(get_find_one_endpoint(entity_name, endpoints[entity_name]), f"/{entity_name.lower()}/findone")
get_endpoint_resource = get_endpoint(entity_name, endpoints[entity_name])
api.add_resource(get_endpoint_resource, f"/{entity_name.lower()}")
spec.path(resource=get_endpoint_resource, api=api)

get_id_endpoint_resource = get_id_endpoint(
entity_name, endpoints[entity_name])
api.add_resource(get_id_endpoint_resource,
f"/{entity_name.lower()}/<int:id>")
spec.path(resource=get_id_endpoint_resource, api=api)

get_count_endpoint_resource = get_count_endpoint(
entity_name, endpoints[entity_name])
api.add_resource(get_count_endpoint_resource,
f"/{entity_name.lower()}/count")
spec.path(resource=get_count_endpoint_resource, api=api)

get_find_one_endpoint_resource = get_find_one_endpoint(
entity_name, endpoints[entity_name])
api.add_resource(get_find_one_endpoint_resource,
f"/{entity_name.lower()}/findone")
spec.path(resource=get_find_one_endpoint_resource, api=api)


# Session endpoint
api.add_resource(Sessions, "/sessions")
spec.path(resource=Sessions, api=api)

# TODO: move this to a script that we run separately?
# with app.test_request_context():
openapi_spec_path = Path(__file__).parent / "swagger/openapi-new.yaml"
with open(openapi_spec_path, "w") as f:
f.write(spec.to_yaml())


@app.route("/openapi.json")
def specs():
resp = app.make_response(json.dumps(spec.to_dict(), indent=2))
resp.mimetype = "application/json"
return resp


# Table specific endpoints
api.add_resource(UsersInvestigations, "/users/<int:id>/investigations")
api.add_resource(UsersInvestigationsCount, "/users/<int:id>/investigations/count")
api.add_resource(InstrumentsFacilityCycles, "/instruments/<int:id>/facilitycycles")
api.add_resource(InstrumentsFacilityCyclesCount, "/instruments/<int:id>/facilitycycles/count")
api.add_resource(UsersInvestigationsCount,
"/users/<int:id>/investigations/count")
api.add_resource(InstrumentsFacilityCycles,
"/instruments/<int:id>/facilitycycles")
api.add_resource(InstrumentsFacilityCyclesCount,
"/instruments/<int:id>/facilitycycles/count")
api.add_resource(InstrumentsFacilityCyclesInvestigations,
"/instruments/<int:instrument_id>/facilitycycles/<int:cycle_id>/investigations")
api.add_resource(InstrumentsFacilityCyclesInvestigationsCount,
"/instruments/<int:instrument_id>/facilitycycles/<int:cycle_id>/investigations/count")

if __name__ == "__main__":
app.run(host=config.get_host(), port=config.get_port(), debug=config.is_debug_mode())
app.run(host=config.get_host(), port=config.get_port(),
debug=config.is_debug_mode())
108 changes: 108 additions & 0 deletions src/resources/entities/entity_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,124 @@ class Endpoint(Resource):
def get(self):
return get_rows_by_filter(table, get_filters_from_query_string()), 200

get.__doc__ = f"""
---
summary: Get {name}
description: Retrieves a list of {table.__name__} objects
tags:
- {name}
parameters:
- WHERE_FILTER
- ORDER_FILTER
- LIMIT_FILTER
- SKIP_FILTER
- DISTINCT_FILTER
- INCLUDE_FILTER
responses:
200:
description: Success - a user's session details
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/{table.__name__.strip("_")}'
400:
description: Bad request - something was wrong with the request
401:
description: Unauthorized - No session ID was found in the HTTP Authorization header
403:
description: Forbidden - The session ID provided is invalid
404:
description: No such record - Unable to find a record in the database
"""

@requires_session_id
@queries_records
def post(self):
return create_rows_from_json(table, request.json), 200

post.__doc__ = f"""
---
summary: Create new {name}
description: Creates new {table.__name__} object(s) with details provided in the request body
tags:
- {name}
requestBody:
description: The values to use to create the new object(s) with
required: true
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/{table.__name__.strip("_")}'
- type: array
items:
$ref: '#/components/schemas/{table.__name__.strip("_")}'
responses:
200:
description: Success - returns the created object
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/{table.__name__.strip("_")}'
- type: array
items:
$ref: '#/components/schemas/{table.__name__.strip("_")}'
400:
description: Bad request - something was wrong with the request
401:
description: Unauthorized - No session ID was found in the HTTP Authorization header
403:
description: Forbidden - The session ID provided is invalid
404:
description: No such record - Unable to find a record in the database
"""

@requires_session_id
@queries_records
def patch(self):
return list(map(lambda x: x.to_dict(), patch_entities(table, request.json))), 200

patch.__doc__ = f"""
---
summary: Update {name}
description: Updates {table.__name__} object(s) with details provided in the request body
tags:
- {name}
requestBody:
description: The values to use to update the object(s) with
required: true
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/{table.__name__.strip("_")}'
- type: array
items:
$ref: '#/components/schemas/{table.__name__.strip("_")}'
responses:
200:
description: Success - returns the updated objects
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/{table.__name__.strip("_")}'
- type: array
items:
$ref: '#/components/schemas/{table.__name__.strip("_")}'
400:
description: Bad request - something was wrong with the request
401:
description: Unauthorized - No session ID was found in the HTTP Authorization header
403:
description: Forbidden - The session ID provided is invalid
404:
description: No such record - Unable to find a record in the database
"""

Endpoint.__name__ = name
return Endpoint

Expand Down
71 changes: 68 additions & 3 deletions src/resources/entities/entity_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
INVESTIGATIONGROUP, INVESTIGATIONINSTRUMENT, INVESTIGATIONTYPE, INVESTIGATION, JOB, KEYWORD, PARAMETERTYPE, \
INVESTIGATIONPARAMETER, INVESTIGATIONUSER, PUBLICSTEP, RULE, SAMPLE, USERGROUP, STUDYINVESTIGATION, SAMPLETYPE, \
RELATEDDATAFILE, SAMPLEPARAMETER, PUBLICATION, STUDY, USER, SHIFT, PERMISSIBLESTRINGVALUE, FACILITY, \
DATAFILEPARAMETER, DATASET
DATAFILEPARAMETER, DATASET, DATASETPARAMETER

import datetime
from sqlalchemy.inspection import inspect

endpoints = {'Applications': APPLICATION, 'DataCollectionDatafiles': DATACOLLECTIONDATAFILE,
'DataCollectionDatasets': DATACOLLECTIONDATASET, 'DataCollectionParameters': DATACOLLECTIONPARAMETER,
'DataCollections': DATACOLLECTION, 'DatafileFormats': DATAFILEFORMAT,
'DatafileParameters': DATAFILEPARAMETER, 'Datafiles': DATAFILE, 'DatasetTypes': DATASETTYPE,
'DatafileParameters': DATAFILEPARAMETER, 'Datafiles': DATAFILE, 'DatasetParameters': DATASETPARAMETER, 'DatasetTypes': DATASETTYPE,
'Datasets': DATASET, 'Facilities': FACILITY, 'FacilityCycles': FACILITYCYCLE, 'Groupings': GROUPING,
'InstrumentScientists': INSTRUMENTSCIENTIST, 'Instruments': INSTRUMENT,
'InvestigationGroups': INVESTIGATIONGROUP,
Expand All @@ -18,4 +21,66 @@
'PermissibleStringValues': PERMISSIBLESTRINGVALUE, 'PublicSteps': PUBLICSTEP,
'Publications': PUBLICATION, 'RelatedDatafiles': RELATEDDATAFILE, 'Rules': RULE,
'SampleParameters': SAMPLEPARAMETER, 'SampleTypes': SAMPLETYPE, 'Samples': SAMPLE, 'Shifts': SHIFT,
'Studies': STUDY, 'StudyInvestigations': STUDYINVESTIGATION, 'UserGroups': USERGROUP, 'Users': USER}
'Studies': STUDY, 'StudyInvestigations': STUDYINVESTIGATION, 'UserGroups': USERGROUP, 'Users': USER}


def type_conversion(python_type):
"""
Converts python type to openapi param type
:param python_type: type that is to be converted to flask type
:return: OpenAPI param spec dict
"""
if python_type is int:
return {"type": "integer"}
if python_type is float:
return {"type": 'number', "format": "float"}
if python_type is bool:
return {"type": 'boolean'}
if python_type is datetime.datetime:
return {"type": 'string', "format": "datetime"}
if python_type is datetime.date:
return {"type": 'string', "format": "date"}
return {"type": "string"}


def create_entity_models():
"""
Creates a schema dict for each endpoint
:return: dict of endpoint names to model
"""
endpoint_models = {}

for endpoint in endpoints:
params = {}
required = []
endpoint_inspection = inspect(endpoints[endpoint])
for column in endpoint_inspection.columns:
python_type = column.type.impl.python_type if hasattr(
column.type, 'impl') else column.type.python_type

param = type_conversion(python_type)
if column.name == "ID":
param["readOnly"] = True
if column.doc:
param["description"] = column.doc
if not column.nullable:
required.append(column.name)
params[column.name] = param

for (relationship_name, relationship_class) in endpoint_inspection.relationships.items():
if relationship_class.direction.name == "MANYTOONE" or relationship_class.direction.name == "ONETOONE":
params[relationship_name] = {
"$ref": f"#/components/schemas/{relationship_name.strip('_')}"}
if relationship_class.direction.name == "MANYTOMANY" or relationship_class.direction.name == "ONETOMANY":
params[relationship_name] = {
"type": "array",
"items": {
"$ref": f"#/components/schemas/{relationship_name.strip('_')}"
}
}
endpoint_models[endpoints[endpoint].__name__] = {
"properties": params, "required": required}

return endpoint_models
Loading

0 comments on commit dfebf55

Please sign in to comment.