diff --git a/src/labthings/server/spec/apispec.py b/src/labthings/server/spec/apispec.py index 015f6ede..c66b937a 100644 --- a/src/labthings/server/spec/apispec.py +++ b/src/labthings/server/spec/apispec.py @@ -4,6 +4,8 @@ from ..schema import Schema +from labthings.core.utilities import get_docstring + from werkzeug.routing import Rule from http import HTTPStatus @@ -42,7 +44,13 @@ def view_to_apispec_operations(view, apispec: APISpec): for op in ("get", "post", "put", "delete"): if hasattr(view, op): - ops[op] = {} + ops[op] = { + "description": getattr(view, "description", None) + or get_docstring(view), + "summary": (getattr(view, "description", None) or get_docstring(view)) + .partition("\n")[0] + .strip(), + } # Add arguments schema if (op in (("post", "put", "delete"))) and hasattr(view, "get_args"): @@ -56,7 +64,6 @@ def view_to_apispec_operations(view, apispec: APISpec): if hasattr(view, "get_responses"): ops[op]["responses"] = {} - print(view.get_responses()) for code, schema in view.get_responses().items(): ops[op]["responses"][code] = { "description": HTTPStatus(code).phrase, diff --git a/src/labthings/server/spec/td.py b/src/labthings/server/spec/td.py index 2a49dc8f..cad906d2 100644 --- a/src/labthings/server/spec/td.py +++ b/src/labthings/server/spec/td.py @@ -175,7 +175,7 @@ def view_to_thing_property(self, rules: list, view: View): # Basic description prop_description = { "title": getattr(view, "title", None) or view.__name__, - "description": get_docstring(view), + "description": getattr(view, "description", None) or get_docstring(view), "readOnly": not ( hasattr(view, "post") or hasattr(view, "put") or hasattr(view, "delete") ), @@ -225,7 +225,7 @@ def view_to_thing_action(self, rules: list, view: View): # Basic description action_description = { "title": getattr(view, "title", None) or view.__name__, - "description": get_docstring(view), + "description": getattr(view, "description", None) or get_docstring(view), "links": [{"href": f"{url}"} for url in action_urls], "safe": getattr(view, "safe", False), "idempotent": getattr(view, "idempotent", False), @@ -248,7 +248,6 @@ def view_to_thing_action(self, rules: list, view: View): if semtype: action_description["@type"] = semtype - # Look for a _schema in the Action classes API Spec action_output_schema = getattr(view, "schema", None) if action_output_schema: diff --git a/src/labthings/server/view/__init__.py b/src/labthings/server/view/__init__.py index b165f36d..91c64608 100644 --- a/src/labthings/server/view/__init__.py +++ b/src/labthings/server/view/__init__.py @@ -37,13 +37,14 @@ class View(MethodView): """ endpoint = None - __apispec__ = {} schema: Schema = None args: dict = None + semtype: str = None tags: list = [] title: None - semtype: str = None + + # Content type of response, usually application/json content_type: str = "application/json" responses: dict = {} @@ -120,8 +121,6 @@ def represent_response(self, response): class ActionView(View): - __apispec__ = {"tags": {"actions"}} - tags: list = ["actions"] safe: bool = False idempotent: bool = False @@ -172,8 +171,6 @@ def dispatch_request(self, *args, **kwargs): class PropertyView(View): - __apispec__ = {"tags": {"properties"}} - tags: list = ["properties"] @classmethod diff --git a/tests/test_server_decorators.py b/tests/test_server_decorators.py deleted file mode 100644 index 8daf965e..00000000 --- a/tests/test_server_decorators.py +++ /dev/null @@ -1,335 +0,0 @@ -import pytest - -from marshmallow import Schema as _Schema -from flask import make_response -from labthings.server import fields -from labthings.server.view import View -from labthings.core.tasks.thread import TaskThread - -from labthings.server import decorators - - -@pytest.fixture -def empty_cls(): - class Index: - pass - - return Index - - -def common_task_test(marshaled_task: dict): - assert isinstance(marshaled_task, dict) - assert isinstance(marshaled_task.get("id"), str) - assert marshaled_task.get("function") == "None(args=(), kwargs={})" - assert marshaled_task.get("status") == "pending" - - -def test_marshal_with_ma_schema(): - def func(): - obj = type("obj", (object,), {"integer": 1}) - return obj - - schema = _Schema.from_dict({"integer": fields.Int()})() - wrapped_func = decorators.marshal_with(schema)(func) - - assert wrapped_func() == {"integer": 1} - - -def test_marshal_with_dict_schema(): - def func(): - obj = type("obj", (object,), {"integer": 1}) - return obj - - schema = {"integer": fields.Int()} - wrapped_func = decorators.marshal_with(schema)(func) - - assert wrapped_func() == {"integer": 1} - - -def test_marshal_with_field_schema(): - def func(): - return 1 - - schema = fields.String() - wrapped_func = decorators.marshal_with(schema)(func) - - assert wrapped_func() == "1" - - -def test_marshal_with_response_tuple_field_schema(app_ctx): - def func(): - return ("response", 200, {}) - - schema = fields.String() - wrapped_func = decorators.marshal_with(schema)(func) - - with app_ctx.test_request_context(): - assert wrapped_func() == ("response", 200, {}) - - -def test_marshal_with_response_field_schema(app_ctx): - def func(): - return make_response("response", 200) - - schema = fields.String() - wrapped_func = decorators.marshal_with(schema)(func) - - with app_ctx.test_request_context(): - assert wrapped_func().data == b"response" - - -def test_marshal_with_invalid_schema(): - def func(): - return 1 - - schema = object() - with pytest.raises(TypeError): - decorators.marshal_with(schema)(func) - - -def test_marshal_task(app_ctx): - def func(): - return TaskThread(None) - - wrapped_func = decorators.marshal_task(func) - - with app_ctx.test_request_context(): - out = wrapped_func() - common_task_test(out) - - -def test_marshal_task_response_invalid(app_ctx): - def func(): - return object() - - wrapped_func = decorators.marshal_task(func) - - with app_ctx.test_request_context(), pytest.raises(TypeError): - wrapped_func() - - -def test_thing_action(empty_cls): - wrapped_cls = decorators.thing_action(empty_cls) - assert wrapped_cls.__apispec__["tags"] == {"actions"} - - -def test_safe(empty_cls): - wrapped_cls = decorators.safe(empty_cls) - assert wrapped_cls.__apispec__["_safe"] == True - - -def test_idempotent(empty_cls): - wrapped_cls = decorators.idempotent(empty_cls) - assert wrapped_cls.__apispec__["_idempotent"] == True - - -def test_thing_property(view_cls): - wrapped_cls = decorators.thing_property(view_cls) - assert wrapped_cls.__apispec__["tags"] == {"properties"} - - -def test_thing_property_empty_class(empty_cls, app_ctx): - wrapped_cls = decorators.thing_property(empty_cls) - assert wrapped_cls.__apispec__["tags"] == {"properties"} - - -def test_thing_property_property_notify(view_cls, app_ctx): - wrapped_cls = decorators.thing_property(view_cls) - - with app_ctx.test_request_context(): - wrapped_cls().post() - - -def test_property_schema(app, client): - class Index(View): - def get(self): - obj = type("obj", (object,), {"integer": 1}) - return obj - - def post(self, args): - i = args.get("integer") - obj = type("obj", (object,), {"integer": i}) - return obj - - def put(self, args): - i = args.get("integer") - obj = type("obj", (object,), {"integer": i}) - return obj - - schema = _Schema.from_dict({"integer": fields.Int()})() - WrappedCls = decorators.PropertySchema(schema)(Index) - - assert WrappedCls.__apispec__.get("_propertySchema") == schema - - app.add_url_rule("/", view_func=WrappedCls.as_view("index")) - - with client as c: - assert c.get("/").json == {"integer": 1} - assert c.post("/", json={"integer": 5}).json == {"integer": 5} - assert c.put("/", json={"integer": 5}).json == {"integer": 5} - - -def test_property_schema_empty_class(empty_cls): - schema = _Schema.from_dict({"integer": fields.Int()})() - WrappedCls = decorators.PropertySchema(schema)(empty_cls) - - assert WrappedCls.__apispec__.get("_propertySchema") == schema - - -def test_use_body(app, client): - class Index(View): - def post(self, data): - return str(data) - - schema = fields.Int() - Index.post = decorators.use_body(schema)(Index.post) - - assert Index.post.__apispec__.get("_params") == schema - - app.add_url_rule("/", view_func=Index.as_view("index")) - - with client as c: - assert c.post("/", data=b"5\n").data == b'"5"\n' - - -def test_use_body_required_no_data(app, client): - class Index(View): - def post(self, data): - return {} - - schema = fields.Int(required=True) - Index.post = decorators.use_body(schema)(Index.post) - - assert Index.post.__apispec__.get("_params") == schema - - app.add_url_rule("/", view_func=Index.as_view("index")) - - with client as c: - assert c.post("/").status_code == 400 - - -def test_use_body_no_data(app, client): - class Index(View): - def post(self, data): - assert data is None - return {} - - schema = fields.Int() - Index.post = decorators.use_body(schema)(Index.post) - - assert Index.post.__apispec__.get("_params") == schema - - app.add_url_rule("/", view_func=Index.as_view("index")) - - with client as c: - assert c.post("/").status_code == 200 - - -def test_use_body_no_data_missing_given(app, client): - class Index(View): - def post(self, data): - return str(data) - - schema = fields.Int(missing=5) - Index.post = decorators.use_body(schema)(Index.post) - - assert Index.post.__apispec__.get("_params") == schema - - app.add_url_rule("/", view_func=Index.as_view("index")) - - with client as c: - assert c.post("/").data == b'"5"\n' - - -def test_use_body_malformed(app, client): - class Index(View): - def post(self, data): - return {} - - schema = fields.Int(required=True) - Index.post = decorators.use_body(schema)(Index.post) - - assert Index.post.__apispec__.get("_params") == schema - - app.add_url_rule("/", view_func=Index.as_view("index")) - - with client as c: - assert c.post("/", data=b"{}").status_code == 400 - - -def test_use_args(app, client): - class Index(View): - def post(self, data): - return data - - schema = _Schema.from_dict({"integer": fields.Int()})() - Index.post = decorators.use_args(schema)(Index.post) - - assert Index.post.__apispec__.get("_params") == schema - - app.add_url_rule("/", view_func=Index.as_view("index")) - - with client as c: - assert c.post("/", json={"integer": 5}).json == {"integer": 5} - - -def test_use_args_field(app, client): - class Index(View): - def post(self, data): - return str(data) - - schema = fields.Int(missing=5) - Index.post = decorators.use_args(schema)(Index.post) - - assert Index.post.__apispec__.get("_params") == schema - - app.add_url_rule("/", view_func=Index.as_view("index")) - - with client as c: - assert c.post("/").data == b'"5"\n' - - -def test_doc(empty_cls): - wrapped_cls = decorators.doc(key="value")(empty_cls) - assert wrapped_cls.__apispec__["key"] == "value" - - -def test_semtype(empty_cls): - wrapped_cls = decorators.semtype("OnOffProperty")(empty_cls) - assert wrapped_cls.__apispec__["@type"] == "OnOffProperty" - - -def test_tag(empty_cls): - wrapped_cls = decorators.tag(["tag", "tag2"])(empty_cls) - assert wrapped_cls.__apispec__["tags"] == {"tag", "tag2"} - - -def test_tag_single(empty_cls): - wrapped_cls = decorators.tag("tag")(empty_cls) - assert wrapped_cls.__apispec__["tags"] == {"tag"} - - -def test_tag_invalid(empty_cls): - with pytest.raises(TypeError): - decorators.tag(object())(empty_cls) - - -def test_doc_response(empty_cls): - wrapped_cls = decorators.doc_response( - 200, description="description", mimetype="text/plain", key="value" - )(empty_cls) - assert wrapped_cls.__apispec__ == { - "responses": { - 200: { - "description": "description", - "key": "value", - "content": {"text/plain": {}}, - } - }, - "_content_type": "text/plain", - } - - -def test_doc_response_no_mimetype(empty_cls): - wrapped_cls = decorators.doc_response(200)(empty_cls) - assert wrapped_cls.__apispec__ == {"responses": {200: {"description": "OK"}}} diff --git a/tests/test_server_labthing.py b/tests/test_server_labthing.py index 5400359c..31b6bebd 100644 --- a/tests/test_server_labthing.py +++ b/tests/test_server_labthing.py @@ -61,13 +61,13 @@ def get(self): def test_add_view_action(thing, view_cls, client): - view_cls.__apispec__ = {"tags": {"actions",}} + view_cls.tags = ["actions"] thing.add_view(view_cls, "/index", endpoint="index") assert view_cls in thing._action_views.values() def test_add_view_property(thing, view_cls, client): - view_cls.__apispec__ = {"tags": {"properties",}} + view_cls.tags = ["properties"] thing.add_view(view_cls, "/index", endpoint="index") assert view_cls in thing._property_views.values() diff --git a/tests/test_server_spec_utilities.py b/tests/test_server_spec_utilities.py index 8c654dd3..72eca205 100644 --- a/tests/test_server_spec_utilities.py +++ b/tests/test_server_spec_utilities.py @@ -3,64 +3,6 @@ import pytest -def test_initial_update_spec(view_cls): - initial_spec = {"key": "value"} - utilities.update_spec(view_cls, initial_spec) - - assert view_cls.__apispec__ == initial_spec - - -def test_update_spec(view_cls): - initial_spec = {"key": {"subkey": "value"}} - utilities.update_spec(view_cls, initial_spec) - - new_spec = {"key": {"new_subkey": "new_value"}} - utilities.update_spec(view_cls, new_spec) - - assert view_cls.__apispec__ == { - "key": {"subkey": "value", "new_subkey": "new_value"} - } - - -def test_tag_spec(view_cls): - utilities.tag_spec(view_cls, {"tag1"}) - assert view_cls.__apispec__.get("tags") == {"tag1"} - utilities.tag_spec(view_cls, {"tag2"}) - assert view_cls.__apispec__.get("tags") == {"tag1", "tag2"} - - -def test_tag_spec_string(view_cls): - utilities.tag_spec(view_cls, "tag1") - assert view_cls.__apispec__.get("tags") == {"tag1"} - - -def test_tag_spec_invalid(view_cls): - with pytest.raises(TypeError): - utilities.tag_spec(view_cls, {object(), "tag"}) - - -def test_get_spec(view_cls): - assert utilities.get_spec(None) == {} - assert utilities.get_spec(view_cls) == {} - - initial_spec = {"key": {"subkey": "value"}} - view_cls.__apispec__ = initial_spec - - assert utilities.get_spec(view_cls) == initial_spec - - -def test_get_topmost_spec_attr(view_cls): - assert not utilities.get_topmost_spec_attr(view_cls, "key") - - # Root value missing, fall back to GET - view_cls.get.__apispec__ = {"key": "get_value"} - assert utilities.get_topmost_spec_attr(view_cls, "key") == "get_value" - - # Root value present, return root value - view_cls.__apispec__ = {"key": "class_value"} - assert utilities.get_topmost_spec_attr(view_cls, "key") == "class_value" - - def test_convert_schema_none(spec): assert not utilities.convert_to_schema_or_json(None, spec)