First draft of auto-Swagger

This commit is contained in:
jtc42 2020-01-02 21:33:50 +00:00
parent 99d0d0055e
commit 6d7921e8b4
8 changed files with 205 additions and 21 deletions

View file

@ -168,4 +168,8 @@ def cleanup():
atexit.register(cleanup)
if __name__ == "__main__":
app.run(host="0.0.0.0", port="5000", threaded=True, debug=True, use_reloader=False)
# app.run(host="0.0.0.0", port="5000", threaded=True, debug=True, use_reloader=False)
#from pprint import pprint
#pprint(labthing.spec.to_dict())
with open('spec.yaml', 'w') as f:
f.write(labthing.spec.to_yaml())

View file

@ -1,7 +1,7 @@
from openflexure_microscope.api.utilities import get_bool, JsonResponse
from openflexure_microscope.common.flask_labthings.resource import Resource
from openflexure_microscope.common.flask_labthings.find import find_device
from openflexure_microscope.common.flask_labthings.decorators import use_args, marshal_with
from openflexure_microscope.common.flask_labthings.decorators import use_args, marshal_with, doc
from openflexure_microscope.common.flask_labthings import fields
from openflexure_microscope.utilities import filter_dict
@ -10,7 +10,7 @@ from openflexure_microscope.api.v2.views.captures import capture_schema
import logging
from flask import jsonify, request, abort, url_for, redirect, send_file
@doc(tags=["actions"])
class CaptureAPI(Resource):
"""
Create a new image capture.

View file

@ -32,6 +32,10 @@ class marshal_with(object):
self.schema = schema
def __call__(self, f):
# Pass params to call function attribute for external access
f.__apispec__ = f.__dict__.get('__apispec__', {})
f.__apispec__["_schema"] = self.schema
# Wrapper function
@wraps(f)
def wrapper(*args, **kwargs):
resp = f(*args, **kwargs)
@ -46,27 +50,37 @@ class marshal_with(object):
class use_args(object):
def __init__(self, argmap, **kwargs):
def __init__(self, schema, **kwargs):
"""
Equivalent to webargs.flask_parser.use_args
"""
self.argmap = argmap
self.wrapper = flaskparser.use_args(argmap, **kwargs)
self.schema = schema
self.wrapper = flaskparser.use_args(schema, **kwargs)
def __call__(self, f):
# Pass params to call function attribute for external access
f.__apispec__ = f.__dict__.get('__apispec__', {})
f.__apispec__["_params"] = self.schema
# Wrapper function
update_wrapper(self.wrapper, f)
return self.wrapper(f)
class use_kwargs(object):
def __init__(self, argmap, **kwargs):
class use_kwargs(use_args):
def __init__(self, schema, **kwargs):
"""
Equivalent to webargs.flask_parser.use_kwargs
"""
kwargs["as_kwargs"] = True
self.argmap = argmap
self.wrapper = flaskparser.use_args(argmap, **kwargs)
use_args.__init__(self, schema, **kwargs)
class doc(object):
def __init__(self, **kwargs):
self.kwargs = kwargs
def __call__(self, f):
update_wrapper(self.wrapper, f)
return self.wrapper(f)
# Pass params to call function attribute for external access
f.__apispec__ = f.__dict__.get('__apispec__', {})
f.__apispec__.update(self.kwargs)
return f

View file

@ -1,14 +1,19 @@
from flask import url_for, jsonify
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from .plugins import BasePlugin
from .views.plugins import PluginListResource
from .views.tasks import TaskList, TaskResource
from .spec import view2path
from openflexure_microscope.common.labthings_core.utilities import get_docstring
from .exceptions import JSONExceptionHandler
from . import EXTENSION_NAME
import logging
class LabThing(object):
def __init__(
@ -17,6 +22,7 @@ class LabThing(object):
prefix: str = "",
title: str = "",
description: str = "",
version: str = "0.0.0",
handle_errors: bool = True,
):
self.app = app
@ -32,17 +38,53 @@ class LabThing(object):
self.endpoints = set()
self.url_prefix = prefix
self.description = description
self.title = title
self._description = description
self._title = title
self._version = version
if handle_errors:
self.error_handler = JSONExceptionHandler()
else:
self.error_handler = None
self.spec = APISpec(
title=self.title,
version=self.version,
openapi_version="3.0.2",
plugins=[MarshmallowPlugin()],
)
if app is not None:
self.init_app(app)
@property
def description(self, ):
return self._description
@description.setter
def description(self, description: str):
self._description = description
self.spec.description = description
@property
def title(self, ):
return self._title
@title.setter
def title(self, title: str):
self._title = title
self.spec.title = title
@property
def version(self, ):
return str(self._version)
@version.setter
def version(self, version: str):
self._version = version
self.spec.version = version
### Flask stuff
def init_app(self, app):
@ -56,14 +98,14 @@ class LabThing(object):
if self.error_handler:
self.error_handler.init_app(self.app)
# Create base routes
self._create_base_routes()
# Add resources, if registered before tying to a Flask app
if len(self.resources) > 0:
for resource, urls, endpoint, kwargs in self.resources:
self._register_view(app, resource, *urls, endpoint=endpoint, **kwargs)
# Create base routes
self._create_base_routes()
def teardown(self, exception):
pass
@ -158,10 +200,13 @@ class LabThing(object):
api.add_resource(FooSpecial, '/special/foo', endpoint="foo")
"""
endpoint = endpoint or resource.__name__.lower()
logging.debug(f"{endpoint}: {type(resource)}")
if self.app is not None:
self._register_view(self.app, resource, *urls, endpoint=endpoint, **kwargs)
else:
self.resources.append((resource, urls, endpoint, kwargs))
self.resources.append((resource, urls, endpoint, kwargs))
def resource(self, *urls, **kwargs):
"""Wraps a :class:`~flask_restful.Resource` class, adding it to the
@ -208,6 +253,10 @@ class LabThing(object):
rule = self._complete_url(url, "")
# Add the url to the application or blueprint
app.add_url_rule(rule, view_func=resource_func, **kwargs)
# Add the resource to our API spec
self.spec.path(
**view2path(rule, resource, self.spec)
)
### Utilities

View file

@ -5,6 +5,19 @@ class Resource(MethodView):
"""Currently identical to MethodView
"""
endpoint = None
methods = ["get", "post", "put", "delete"]
def __init__(self, *args, **kwargs):
MethodView.__init__(self, *args, **kwargs)
def doc(self):
docs = {"operations": {}}
if hasattr(self, "__apispec__"):
docs.update(self.__apispec__)
for meth in Resource.methods:
if hasattr(self, meth) and hasattr(getattr(self, meth), "__apispec__"):
docs["operations"][meth] = {}
docs["operations"][meth] = getattr(self, meth).__apispec__
return docs

View file

@ -2,7 +2,7 @@
import flask
import marshmallow
_MARSHMALLOW_VERSION_INFO = tuple(
MARSHMALLOW_VERSION_INFO = tuple(
[int(part) for part in marshmallow.__version__.split(".") if part.isdigit()]
)
@ -32,7 +32,7 @@ class Schema(marshmallow.Schema):
"""
if many is sentinel:
many = self.many
if _MARSHMALLOW_VERSION_INFO[0] >= 3:
if MARSHMALLOW_VERSION_INFO[0] >= 3:
data = self.dump(obj, many=many)
else:
data = self.dump(obj, many=many).data

View file

@ -0,0 +1,89 @@
from .resource import Resource
from .utilities import rupdate
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from .fields import Field
from marshmallow import Schema as BaseSchema
from collections import Mapping
def view2path(rule: str, view: Resource, spec: APISpec):
params = {
"path": rule, # TODO: Validate this slightly (leading / etc)
"operations": view2operations(view, spec),
}
if hasattr(view, "__apispec__"):
# Recursively update params
rupdate(params, view.__apispec__)
return params
def view2operations(view: Resource, spec: APISpec):
ops = {}
for method in Resource.methods:
if hasattr(view, method):
ops[method] = {}
if hasattr(getattr(view, method), "__apispec__"):
ops[method] = doc2operation(getattr(view, method).__apispec__, spec)
return ops
def doc2operation(apispec: dict, spec: APISpec):
op = {}
if "_params" in apispec:
op["requestBody"] = {
"content": {
"application/json": {
"schema": convert_schema(apispec.get("_params"), spec)
}
},
}
if "_schema" in apispec:
op["responses"] = {
200: {
"description": "success",
"content": {
"application/json": {
"schema": convert_schema(apispec.get("_schema"), spec)
}
},
}
}
for key, val in apispec.items():
if not key in ["_params", "_schema"]:
op[key] = val
return op
def convert_schema(schema, spec: APISpec):
if isinstance(schema, BaseSchema):
return schema
elif isinstance(schema, Mapping):
return map2properties(schema, spec)
else:
raise TypeError("Unsupported schema type. Ensure schema is a Schema class, or dictionary of Field objects")
def map2properties(schema, spec: APISpec):
marshmallow_plugin = next(
plugin for plugin in spec.plugins
if isinstance(plugin, MarshmallowPlugin)
)
converter = marshmallow_plugin.converter
d = {}
for k, v in schema.items():
if isinstance(v, Field):
d[k] = converter.field2property(v)
elif isinstance(v, Mapping):
d[k] = map2properties(v, spec)
else:
d[k] = v
return {"properties": d}

View file

@ -1,4 +1,10 @@
from openflexure_microscope.common.labthings_core.utilities import get_docstring
from .schema import Schema, marshmallow, MARSHMALLOW_VERSION_INFO
import collections.abc
from webargs import dict2schema as wa_dict2schema
import logging
def description_from_view(view_class):
@ -11,3 +17,12 @@ def description_from_view(view_class):
d = {"methods": methods, "description": brief_description}
return d
def rupdate(d, u):
for k, v in u.items():
if isinstance(v, collections.abc.Mapping):
d[k] = rupdate(d.get(k, {}), v)
else:
d[k] = v
return d