From 9faa05aec18a30c50a9d1c536be91c00330d0862 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 13 Jan 2020 16:24:32 +0000 Subject: [PATCH] Added missing API documentation --- .../api/v2/views/captures.py | 61 +++++++++++-------- openflexure_microscope/api/v2/views/state.py | 13 +++- .../api/v2/views/streams.py | 1 + .../common/flask_labthings/labthing.py | 25 +++++--- .../{spec.py => spec/__init__.py} | 19 ++++-- .../common/flask_labthings/spec/paths.py | 50 +++++++++++++++ .../flask_labthings/views/docs/__init__.py | 23 ++++--- .../common/flask_labthings/views/tasks.py | 20 ++++-- 8 files changed, 160 insertions(+), 52 deletions(-) rename openflexure_microscope/common/flask_labthings/{spec.py => spec/__init__.py} (89%) create mode 100644 openflexure_microscope/common/flask_labthings/spec/paths.py diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index 1ef852ab..74bf97e6 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -9,7 +9,7 @@ from openflexure_microscope.common.flask_labthings.resource import Resource from openflexure_microscope.common.flask_labthings.utilities import ( description_from_view, ) -from openflexure_microscope.common.flask_labthings.decorators import marshal_with +from openflexure_microscope.common.flask_labthings.decorators import marshal_with, doc_response, Tag, ThingProperty from openflexure_microscope.common.flask_labthings.find import find_component @@ -64,25 +64,26 @@ capture_schema = CaptureSchema() capture_list_schema = CaptureSchema(many=True) +@ThingProperty +@Tag("captures") class CaptureList(Resource): - """ - List all image captures - """ - @marshal_with(CaptureSchema(many=True)) def get(self): + """ + List all image captures + """ microscope = find_component("org.openflexure.microscope") image_list = microscope.camera.images return image_list +@Tag("captures") class CaptureResource(Resource): - """ - Description of a single image capture - """ - @marshal_with(CaptureSchema()) def get(self, id): + """ + Description of a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) @@ -92,6 +93,9 @@ class CaptureResource(Resource): return capture_obj def delete(self, id): + """ + Delete a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) @@ -103,13 +107,13 @@ class CaptureResource(Resource): return "", 204 +@Tag("captures") class CaptureDownload(Resource): - """ - Image data for a single image capture - """ - + @doc_response(200, mimetype="image/jpeg") def get(self, id, filename): - + """ + Image data for a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) @@ -139,13 +143,12 @@ class CaptureDownload(Resource): return send_file(img, mimetype="image/jpeg") +@Tag("captures") class CaptureTags(Resource): - """ - Tags associated with a single image capture - """ - def get(self, id): - + """ + Get tags associated with a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) @@ -155,13 +158,16 @@ class CaptureTags(Resource): return jsonify(capture_obj.tags) def put(self, id): - + """ + Add tags to a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) if not capture_obj: return abort(404) # 404 Not Found + # TODO: Replace with normal Flask request JSON thing data_dict = JsonResponse(request).json if type(data_dict) != list: @@ -172,7 +178,9 @@ class CaptureTags(Resource): return jsonify(capture_obj.tags) def delete(self, capture_id): - + """ + Delete tags from a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) @@ -190,12 +198,12 @@ class CaptureTags(Resource): return jsonify(capture_obj.tags) +@Tag("captures") class CaptureMetadata(Resource): - """ - All metadata associated with a single image capture - """ - def get(self, id): + """ + Get metadata associated with a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) @@ -205,6 +213,9 @@ class CaptureMetadata(Resource): return jsonify(capture_obj.metadata) def put(self, id): + """ + Update metadata for a single image capture + """ microscope = find_component("org.openflexure.microscope") capture_obj = microscope.camera.image_from_id(id) diff --git a/openflexure_microscope/api/v2/views/state.py b/openflexure_microscope/api/v2/views/state.py index c3df403d..378608da 100644 --- a/openflexure_microscope/api/v2/views/state.py +++ b/openflexure_microscope/api/v2/views/state.py @@ -9,7 +9,7 @@ from openflexure_microscope.common.labthings_core.utilities import ( from openflexure_microscope.common.flask_labthings.find import find_component from openflexure_microscope.common.flask_labthings.resource import Resource -from openflexure_microscope.common.flask_labthings.decorators import ThingProperty, marshal_with +from openflexure_microscope.common.flask_labthings.decorators import ThingProperty, Tag from flask import jsonify, request, abort import logging @@ -40,8 +40,12 @@ class SettingsProperty(Resource): return self.get() +@Tag("properties") class NestedSettingsProperty(Resource): def get(self, route): + """ + Show a nested section of the current microscope settings + """ microscope = find_component("org.openflexure.microscope") keys = route.split("/") @@ -53,6 +57,9 @@ class NestedSettingsProperty(Resource): return jsonify(value) def put(self, route): + """ + Update a nested section of the current microscope settings + """ microscope = find_component("org.openflexure.microscope") keys = route.split("/") payload = JsonResponse(request) @@ -76,8 +83,12 @@ class StatusProperty(Resource): return jsonify(microscope.status) +@Tag("properties") class NestedStatusProperty(Resource): def get(self, route): + """ + Show a nested section of the current microscope state + """ microscope = find_component("org.openflexure.microscope") keys = route.split("/") diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index 24bfef96..967c6da7 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -12,6 +12,7 @@ from openflexure_microscope.common.flask_labthings.decorators import doc_respons from flask import Response + @ThingProperty class MjpegStream(Resource): """ diff --git a/openflexure_microscope/common/flask_labthings/labthing.py b/openflexure_microscope/common/flask_labthings/labthing.py index df8888a4..c49d7e25 100644 --- a/openflexure_microscope/common/flask_labthings/labthing.py +++ b/openflexure_microscope/common/flask_labthings/labthing.py @@ -6,7 +6,7 @@ from . import EXTENSION_NAME # TODO: Move into .names from .names import TASK_ENDPOINT, TASK_LIST_ENDPOINT, EXTENSION_LIST_ENDPOINT from .extensions import BaseExtension from .utilities import description_from_view -from .spec import view2path, get_spec +from .spec import rule2path, get_spec from .views.extensions import ExtensionList from .views.tasks import TaskList, TaskResource @@ -188,8 +188,8 @@ class LabThing(object): return decorator - def _register_view(self, app, resource, *urls, endpoint=None, **kwargs): - endpoint = endpoint or resource.__name__.lower() + def _register_view(self, app, view, *urls, endpoint=None, **kwargs): + endpoint = endpoint or view.__name__.lower() self.endpoints.add(endpoint) resource_class_args = kwargs.pop("resource_class_args", ()) resource_class_kwargs = kwargs.pop("resource_class_kwargs", {}) @@ -199,14 +199,14 @@ class LabThing(object): previous_view_class = app.view_functions[endpoint].__dict__["view_class"] # if you override the endpoint with a different class, avoid the collision by raising an exception - if previous_view_class != resource: + if previous_view_class != view: raise ValueError( "This endpoint (%s) is already set to the class %s." % (endpoint, previous_view_class.__name__) ) - resource.endpoint = endpoint - resource_func = resource.as_view( + view.endpoint = endpoint + resource_func = view.as_view( endpoint, *resource_class_args, **resource_class_kwargs ) @@ -216,15 +216,20 @@ class LabThing(object): # 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)) + #self.spec.path(**view2path(rule, view, self.spec)) + + # TEST: Getting Flask rule objects + flask_rules = app.url_map._rules_by_endpoint.get(endpoint) + for flask_rule in flask_rules: + self.spec.path(**rule2path(flask_rule, view, self.spec)) # Handle resource groups listed in API spec - view_spec = get_spec(resource) + view_spec = get_spec(view) view_groups = view_spec.get("_groups", {}) if "actions" in view_groups: - self.actions[resource.endpoint] = resource + self.actions[view.endpoint] = view if "properties" in view_groups: - self.properties[resource.endpoint] = resource + self.properties[view.endpoint] = view ### Utilities diff --git a/openflexure_microscope/common/flask_labthings/spec.py b/openflexure_microscope/common/flask_labthings/spec/__init__.py similarity index 89% rename from openflexure_microscope/common/flask_labthings/spec.py rename to openflexure_microscope/common/flask_labthings/spec/__init__.py index 13d9fcb3..59a1bae7 100644 --- a/openflexure_microscope/common/flask_labthings/spec.py +++ b/openflexure_microscope/common/flask_labthings/spec/__init__.py @@ -1,4 +1,4 @@ -from .resource import Resource +from ..resource import Resource from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin @@ -8,9 +8,12 @@ from openflexure_microscope.common.labthings_core.utilities import ( rupdate, ) -from .fields import Field +from ..fields import Field from marshmallow import Schema as BaseSchema +from .paths import rule_to_path, rule_to_params + +from werkzeug.routing import Rule from collections import Mapping from http import HTTPStatus @@ -26,14 +29,22 @@ def get_spec(obj): return obj.__apispec__ -def view2path(rule: str, view: Resource, spec: APISpec): +def rule2path(rule: Rule, view: Resource, spec: APISpec): params = { - "path": rule, # TODO: Validate this slightly (leading / etc) + "path": rule_to_path(rule), "operations": view2operations(view, spec), "description": get_docstring(view), "summary": get_summary(view), } + # Add URL arguments + if rule.arguments: + for op in params.get("operations").keys(): + params["operations"][op].update({ + "parameters": rule_to_params(rule) + }) + + # Add extra parameters if hasattr(view, "__apispec__"): # Recursively update params rupdate(params, view.__apispec__) diff --git a/openflexure_microscope/common/flask_labthings/spec/paths.py b/openflexure_microscope/common/flask_labthings/spec/paths.py new file mode 100644 index 00000000..6c70c2c4 --- /dev/null +++ b/openflexure_microscope/common/flask_labthings/spec/paths.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- + +import re + +import werkzeug.routing + +PATH_RE = re.compile(r'<(?:[^:<>]+:)?([^<>]+)>') + + +def rule_to_path(rule): + return PATH_RE.sub(r'{\1}', rule.rule) + + +# Conversion map of werkzeug rule converters to Javascript schema types +CONVERTER_MAPPING = { + werkzeug.routing.UnicodeConverter: ('string', None), + werkzeug.routing.IntegerConverter: ('integer', 'int32'), + werkzeug.routing.FloatConverter: ('number', 'float'), +} + +DEFAULT_TYPE = ('string', None) + + +def rule_to_params(rule, overrides=None): + overrides = (overrides or {}) + result = [ + argument_to_param(argument, rule, overrides.get(argument, {})) + for argument in rule.arguments + ] + for key in overrides.keys(): + if overrides[key].get('in') in ('header', 'query'): + overrides[key]['name'] = overrides[key].get('name', key) + result.append(overrides[key]) + return result + + +def argument_to_param(argument, rule, override=None): + param = { + 'in': 'path', + 'name': argument, + 'required': True, + } + type_, format_ = CONVERTER_MAPPING.get(type(rule._converters[argument]), DEFAULT_TYPE) + param['type'] = type_ + if format_ is not None: + param['format'] = format_ + if rule.defaults and argument in rule.defaults: + param['default'] = rule.defaults[argument] + param.update(override or {}) + return param diff --git a/openflexure_microscope/common/flask_labthings/views/docs/__init__.py b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py index 3331f4eb..0768926c 100644 --- a/openflexure_microscope/common/flask_labthings/views/docs/__init__.py +++ b/openflexure_microscope/common/flask_labthings/views/docs/__init__.py @@ -1,9 +1,10 @@ -from flask import abort, url_for, jsonify, render_template, Blueprint +from flask import abort, url_for, jsonify, render_template, Blueprint, current_app, request from openflexure_microscope.common.labthings_core.utilities import get_docstring from ...resource import Resource from ...find import current_labthing +from ...spec import rule_to_path import os @@ -35,8 +36,13 @@ class W3CThingDescriptionResource(Resource): """ def get(self): + base_url = request.host_url.rstrip('/') + props = {} for key, prop in current_labthing().properties.items(): + prop_rules = current_app.url_map._rules_by_endpoint.get(prop.endpoint) + prop_urls = [rule_to_path(rule) for rule in prop_rules] + props[key] = {} props[key]["title"] = prop.__name__ # TODO: Get description from __apispec__ preferentially @@ -48,19 +54,22 @@ class W3CThingDescriptionResource(Resource): ) props[key]["writeOnly"] = not hasattr(prop, "get") props[key]["links"] = [ - {"href": current_labthing().url_for(prop, _external=True)} + {"href": f"{base_url}{url}"} for url in prop_urls ] actions = {} - for key, prop in current_labthing().actions.items(): + for key, action in current_labthing().actions.items(): + action_rules = current_app.url_map._rules_by_endpoint.get(action.endpoint) + action_urls = [rule_to_path(rule) for rule in action_rules] + actions[key] = {} - actions[key]["title"] = prop.__name__ + actions[key]["title"] = action.__name__ # TODO: Get description from __apispec__ preferentially - actions[key]["description"] = get_docstring(prop) or ( - get_docstring(prop.post) if hasattr(prop, "post") else "" + actions[key]["description"] = get_docstring(action) or ( + get_docstring(action.post) if hasattr(action, "post") else "" ) actions[key]["links"] = [ - {"href": current_labthing().url_for(prop, _external=True)} + {"href": f"{base_url}{url}"} for url in action_urls ] td = { diff --git a/openflexure_microscope/common/flask_labthings/views/tasks.py b/openflexure_microscope/common/flask_labthings/views/tasks.py index 3e446277..d9475f65 100644 --- a/openflexure_microscope/common/flask_labthings/views/tasks.py +++ b/openflexure_microscope/common/flask_labthings/views/tasks.py @@ -1,6 +1,6 @@ from flask import abort, url_for -from ..decorators import marshal_with, ThingProperty +from ..decorators import marshal_with, ThingProperty, Tag from ..resource import Resource from ..schema import TaskSchema @@ -9,18 +9,23 @@ from openflexure_microscope.common.labthings_core import tasks @ThingProperty class TaskList(Resource): - """ - List and basic documentation for all session tasks - """ - @marshal_with(TaskSchema(many=True)) def get(self): + """ + List of all session tasks + """ return tasks.tasks() +@Tag("properties") class TaskResource(Resource): @marshal_with(TaskSchema()) def get(self, id): + """ + Show status of a session task + + Includes progress and intermediate data. + """ try: task = tasks.dict()[id] except KeyError: @@ -30,6 +35,11 @@ class TaskResource(Resource): @marshal_with(TaskSchema()) def delete(self, id): + """ + Terminate a running task. + + If the task is finished, deletes its entry. + """ try: task = tasks.dict()[id] except KeyError: