diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 937c610e..4b028d8b 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -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()) \ No newline at end of file diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index 948be81b..b6a7ef7f 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -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. diff --git a/openflexure_microscope/common/flask_labthings/decorators.py b/openflexure_microscope/common/flask_labthings/decorators.py index ed0cb8b2..41bdd368 100644 --- a/openflexure_microscope/common/flask_labthings/decorators.py +++ b/openflexure_microscope/common/flask_labthings/decorators.py @@ -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 \ No newline at end of file diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index a9085fab..208e0313 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -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 diff --git a/openflexure_microscope/common/flask_labthings/resource.py b/openflexure_microscope/common/flask_labthings/resource.py index cb18092f..5b675654 100644 --- a/openflexure_microscope/common/flask_labthings/resource.py +++ b/openflexure_microscope/common/flask_labthings/resource.py @@ -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 diff --git a/openflexure_microscope/common/flask_labthings/schema.py b/openflexure_microscope/common/flask_labthings/schema.py index 3652e208..83294a0e 100644 --- a/openflexure_microscope/common/flask_labthings/schema.py +++ b/openflexure_microscope/common/flask_labthings/schema.py @@ -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 diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/flask_labthings/spec.py new file mode 100644 index 00000000..4b01f139 --- /dev/null +++ b/openflexure_microscope/common/flask_labthings/spec.py @@ -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} \ No newline at end of file diff --git a/openflexure_microscope/common/flask_labthings/utilities.py b/openflexure_microscope/common/flask_labthings/utilities.py index 681471c9..7c6232a2 100644 --- a/openflexure_microscope/common/flask_labthings/utilities.py +++ b/openflexure_microscope/common/flask_labthings/utilities.py @@ -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 \ No newline at end of file