From 6230cc255201d8a9ca33ffe67914f2a89c9b3115 Mon Sep 17 00:00:00 2001 From: Rose Yemelyanova <90774497+RAYemelyanova@users.noreply.github.com> Date: Fri, 12 May 2023 10:20:00 +0100 Subject: [PATCH] add catalog-info for backstage (#137) * rebased * added a catalog info and openapi schema * renamed rest -> service as files have changed location * removed outdated test_rest_api.py file * modified catalog-info to include asyncapi, and made openapi.py output openapi.json into the same location as asyncapi.yaml * removed catalog-info.yaml from pre commit checks to pass linting * added CI job to automatically run openapi.py script to generate openapi schema * changed name of CI job for openapi * added test for openapi.py * added openapi schema in docs * using sphinxcontrib.openapi to autogenerate open api documentation * added dependency to pyproject.toml --- .github/workflows/openapi.yml | 30 +++ .pre-commit-config.yaml | 2 +- catalog-info.yaml | 43 ++++ docs/conf.py | 2 + docs/user/index.rst | 1 + docs/user/reference/openapi.json | 328 +++++++++++++++++++++++++++++++ docs/user/reference/openapi.rst | 6 + pyproject.toml | 1 + src/blueapi/service/openapi.py | 32 +++ tests/service/test_openapi.py | 48 +++++ 10 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/openapi.yml create mode 100644 catalog-info.yaml create mode 100644 docs/user/reference/openapi.json create mode 100644 docs/user/reference/openapi.rst create mode 100644 src/blueapi/service/openapi.py create mode 100644 tests/service/test_openapi.py diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml new file mode 100644 index 000000000..20476441c --- /dev/null +++ b/.github/workflows/openapi.yml @@ -0,0 +1,30 @@ +name: Openapi + +on: + push: + pull_request: + schedule: + # Run weekly to check latest versions of dependencies + - cron: "0 8 * * WED" +env: + # The target python version, which must match the Dockerfile version + CONTAINER_PYTHON: "3.11" + +jobs: + publish: + # pull requests are a duplicate of a branch push if within the same repo. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install python packages + uses: ./.github/actions/install_requirements + with: + requirements_file: requirements-dev-3.x.txt + install_options: -e .[dev] + + - name: generate openapi schema + run: python src/blueapi/service/openapi.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4bdce7ee1..3667d7f3a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: hooks: - id: check-added-large-files - id: check-yaml - exclude: ^helm\/.*\/templates\/.* + exclude: ^helm\/.*\/templates\/.*|catalog-info.yaml - id: check-merge-conflict - repo: local diff --git a/catalog-info.yaml b/catalog-info.yaml new file mode 100644 index 000000000..44b9b86c0 --- /dev/null +++ b/catalog-info.yaml @@ -0,0 +1,43 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: bluesky-worker + title: bluesky-worker + description: Lightweight wrapper around bluesky services + annotations: + github.com/project-slug: DiamondLightSouce/blueapi +spec: + type: service + lifecycle: production + owner: user:vid18871 # TODO: owner: DAQ-Core + # system: Athena # TODO: Define Athena system: presumably same location as DAQ-Core/DAQ? + providesApis: + - message-topics + - restapi + - blueskydocument-to-scanmessage +--- +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: restapi + title: restapi + description: REST API for getting plans/devices from the worker (and running tasks) +spec: + type: openapi + lifecycle: production + owner: user:vid18871 + definition: + $text: ./docs/user/reference/openapi.json +--- +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: message-topics + title: message-topics + description: Message topics which can be listened to over an activeMQ message bus +spec: + type: asyncapi + lifecycle: production + owner: user:vid18871 + definition: + $text: ./docs/user/reference/asyncapi.yaml diff --git a/docs/conf.py b/docs/conf.py index 7b1a737e8..c4b9364d2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,6 +42,8 @@ "sphinx_copybutton", # For the card element "sphinx_design", + # OpenAPI directive + "sphinxcontrib.openapi", ] # If true, Sphinx will warn about all references where the target cannot diff --git a/docs/user/index.rst b/docs/user/index.rst index e3ba1aa4d..a36bc9f42 100644 --- a/docs/user/index.rst +++ b/docs/user/index.rst @@ -52,6 +52,7 @@ side-bar. reference/api reference/asyncapi + reference/openapi ../genindex +++ diff --git a/docs/user/reference/openapi.json b/docs/user/reference/openapi.json new file mode 100644 index 000000000..e3059182b --- /dev/null +++ b/docs/user/reference/openapi.json @@ -0,0 +1,328 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "FastAPI", + "version": "0.1.0" + }, + "paths": { + "/plans": { + "get": { + "summary": "Get Plans", + "description": "Retrieve information about all available plans.", + "operationId": "get_plans_plans_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanResponse" + } + } + } + } + } + } + }, + "/plan/{name}": { + "get": { + "summary": "Get Plan By Name", + "description": "Retrieve information about a plan by its (unique) name.", + "operationId": "get_plan_by_name_plan__name__get", + "parameters": [ + { + "required": true, + "schema": { + "title": "Name", + "type": "string" + }, + "name": "name", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/devices": { + "get": { + "summary": "Get Devices", + "description": "Retrieve information about all available devices.", + "operationId": "get_devices_devices_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceResponse" + } + } + } + } + } + } + }, + "/device/{name}": { + "get": { + "summary": "Get Device By Name", + "description": "Retrieve information about a devices by its (unique) name.", + "operationId": "get_device_by_name_device__name__get", + "parameters": [ + { + "required": true, + "schema": { + "title": "Name", + "type": "string" + }, + "name": "name", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/task/{name}": { + "put": { + "summary": "Submit Task", + "description": "Submit a task onto the worker queue.", + "operationId": "submit_task_task__name__put", + "parameters": [ + { + "required": true, + "schema": { + "title": "Name", + "type": "string" + }, + "name": "name", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "Task", + "type": "object" + }, + "example": { + "detectors": [ + "x" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "DeviceModel": { + "title": "DeviceModel", + "required": [ + "name", + "protocols" + ], + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "string", + "description": "Name of the device" + }, + "protocols": { + "title": "Protocols", + "type": "array", + "items": { + "type": "string" + }, + "description": "Protocols that a device conforms to, indicating its capabilities" + } + }, + "additionalProperties": false, + "description": "Representation of a device" + }, + "DeviceResponse": { + "title": "DeviceResponse", + "required": [ + "devices" + ], + "type": "object", + "properties": { + "devices": { + "title": "Devices", + "type": "array", + "items": { + "$ref": "#/components/schemas/DeviceModel" + }, + "description": "Devices available to use in plans" + } + }, + "additionalProperties": false, + "description": "Response to a query for devices" + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "PlanModel": { + "title": "PlanModel", + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "string", + "description": "Name of the plan" + } + }, + "additionalProperties": false, + "description": "Representation of a plan" + }, + "PlanResponse": { + "title": "PlanResponse", + "required": [ + "plans" + ], + "type": "object", + "properties": { + "plans": { + "title": "Plans", + "type": "array", + "items": { + "$ref": "#/components/schemas/PlanModel" + }, + "description": "Plans available to use by a worker" + } + }, + "additionalProperties": false, + "description": "Response to a query for plans" + }, + "TaskResponse": { + "title": "TaskResponse", + "required": [ + "taskName" + ], + "type": "object", + "properties": { + "taskName": { + "title": "Taskname", + "type": "string", + "description": "Unique identifier for the task" + } + }, + "additionalProperties": false, + "description": "Acknowledgement that a task has started, includes its ID" + }, + "ValidationError": { + "title": "ValidationError", + "required": [ + "loc", + "msg", + "type" + ], + "type": "object", + "properties": { + "loc": { + "title": "Location", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + } + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/docs/user/reference/openapi.rst b/docs/user/reference/openapi.rst new file mode 100644 index 000000000..b059ed2c5 --- /dev/null +++ b/docs/user/reference/openapi.rst @@ -0,0 +1,6 @@ +REST API +======== + +The endpoints of the REST service are documented below. + +.. openapi:: ./openapi.json diff --git a/pyproject.toml b/pyproject.toml index df6d73fc7..c0c98a345 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dev = [ "sphinx-autobuild", "sphinx-copybutton", "sphinx-design", + "sphinxcontrib-openapi", "tox-direct", "types-mock", "types-PyYAML", diff --git a/src/blueapi/service/openapi.py b/src/blueapi/service/openapi.py new file mode 100644 index 000000000..e279bc19d --- /dev/null +++ b/src/blueapi/service/openapi.py @@ -0,0 +1,32 @@ +"""Generate openapi.json.""" + +import json +from pathlib import Path + +from fastapi.openapi.utils import get_openapi + +from blueapi.service.main import app + + +def write_openapi_file(location: Path): + with open(location, "w") as f: + json.dump( + get_openapi( + title=app.title, + version=app.version, + openapi_version=app.openapi_version, + description=app.description, + routes=app.routes, + ), + f, + indent=4, + ) + + +def init(location: Path): + if __name__ == "__main__": + write_openapi_file(location) + + +location = Path(__file__).parents[3] / "docs" / "user" / "reference" / "openapi.json" +init(location) diff --git a/tests/service/test_openapi.py b/tests/service/test_openapi.py new file mode 100644 index 000000000..ae247be02 --- /dev/null +++ b/tests/service/test_openapi.py @@ -0,0 +1,48 @@ +# this should test if we change app, what openapi is generated. + +import json + +# i.e.checking that the openapi generation actually works. +from pathlib import Path + +import mock +from mock import Mock, PropertyMock + + +@mock.patch("blueapi.service.openapi.app") +def test_init(mock_app: Mock): + from blueapi.service.main import app + + title = PropertyMock(return_value="title") + version = PropertyMock(return_value=app.version) + openapi_version = PropertyMock(return_value=app.openapi_version) + description = PropertyMock(return_value="description") + routes = PropertyMock(return_value=[app.routes[0]]) + + type(mock_app).title = title + type(mock_app).version = version + type(mock_app).openapi_version = openapi_version + type(mock_app).description = description + type(mock_app).routes = routes + + from blueapi.service import openapi + + with mock.patch.object(openapi, "__name__", "__main__"): + location = Path(__file__).parent / "test_file.json" + openapi.init(location) + print("ah") + + with open(location, "r") as f: + result = json.load(f) + + assert result == { + "openapi": openapi_version(), + "info": { + "title": title(), + "description": description(), + "version": version(), + }, + "paths": {}, + } + + location.unlink()