diff --git a/Makefile b/Makefile index dc2d422..2f0b104 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,12 @@ PANTOS_SERVICE_NODE_VERSION := $(shell command -v poetry >/dev/null 2>&1 && poetry version -s || echo "0.0.0") PANTOS_SERVICE_NODE_SSH_HOST ?= bdev-service-node -PYTHON_FILES_WITHOUT_TESTS := pantos/servicenode linux/scripts/start-web.py +PYTHON_FILES_WITHOUT_TESTS := pantos/servicenode linux/scripts/start-web.py openapi.py PYTHON_FILES := $(PYTHON_FILES_WITHOUT_TESTS) tests STACK_BASE_NAME=stack-service-node INSTANCE_COUNT ?= 1 DEV_MODE ?= false SHELL := $(shell which bash) +OPENAPI_FILE_LOCATION ?= ./docs/openapi.json .PHONY: check-version check-version: @@ -83,6 +84,10 @@ coverage-all: .PHONY: tar tar: dist/pantos_service_node-$(PANTOS_SERVICE_NODE_VERSION).tar.gz +.PHONY: openapi-docs +openapi-docs: + poetry run python3 -m openapi $(OPENAPI_FILE_LOCATION) + dist/pantos_service_node-$(PANTOS_SERVICE_NODE_VERSION).tar.gz: pantos/ service-node-config.yml service-node-config.env bids.yml alembic.ini pantos-service-node.sh pantos-service-node-worker.sh cp service-node-config.yml pantos/service-node-config.yml cp service-node-config.env pantos/service-node-config.env diff --git a/README.md b/README.md index 37c8068..2c94429 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,21 @@ Run the following command from the repository's root directory: make code ``` -### 3.2 Local development environment +### 3.2 OpenAPI + +If you want to generate the OpenAPI documentation, you can run the following command: + +```bash +make openapi-docs +``` +which will generate a `openapi.json` file in the `docs` directory. +If you want to specify a different path for the output file, you can do so by running: + +```bash +make openapi-docs OUTPUT_FILE=/ +``` + +### 3.3 Local development environment #### PostgreSQL diff --git a/openapi.py b/openapi.py new file mode 100644 index 0000000..d5578ca --- /dev/null +++ b/openapi.py @@ -0,0 +1,42 @@ +import json +import os +import sys +from pathlib import Path + +from apispec.ext.marshmallow import MarshmallowPlugin +from apispec_webframeworks.flask import FlaskPlugin +from flasgger import APISpec # type: ignore +from flasgger import Swagger + +from pantos.servicenode.restapi import _BidSchema +from pantos.servicenode.restapi import _BidsSchema +from pantos.servicenode.restapi import _TransferSchema +from pantos.servicenode.restapi import _TransferStatusSchema +from pantos.servicenode.restapi import flask_app + +DOCS_PATH = "docs/openapi.json" + +if len(sys.argv) > 1: + DOCS_PATH = sys.argv[1] + +plugins = [FlaskPlugin(), MarshmallowPlugin()] +spec = APISpec("Pantos Service Node APISpec", '1.0', "3.0.2", plugins=plugins) + +template = spec.to_flasgger( + flask_app, definitions=[ + _BidSchema, _BidsSchema, _TransferSchema, _TransferStatusSchema + ]) + +swagger = Swagger(flask_app, template=template, parse=True) + +with flask_app.test_request_context(): + data = swagger.get_apispecs() + data.pop('definitions') + data.pop('swagger') + data['servers'] = [{'url': 'https://sn1.testnet.pantos.io'}] + + if not (Path.cwd() / DOCS_PATH).exists(): + os.makedirs(os.path.dirname(DOCS_PATH), exist_ok=True) + + with open(DOCS_PATH, "w") as f: + f.write(json.dumps(data, indent=4)) diff --git a/pantos/servicenode/restapi.py b/pantos/servicenode/restapi.py index 54d46f9..59b6510 100644 --- a/pantos/servicenode/restapi.py +++ b/pantos/servicenode/restapi.py @@ -215,6 +215,45 @@ class _Transfer(flask_restful.Resource): """ def post(self) -> flask.Response: + """ + Endpoint for submitting a token transfer request. + --- + requestBody: + description: Transfer request + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/_Transfer" + responses: + 200: + description: Transfer request accepted + content: + application/json: + schema: + type: object + example: + task_id: '123e4567-e89b-12d3-a456-426614174000' + 406: + description: Transfer request no accepted + content: + application/json: + schema: + type: array + items: + type: string + example: "[bid has been rejected by service node: \ + 'bid not accepted']" + 409: + description: Sender nonce from transfer request is not unique + content: + application/json: + schema: + type: string + example: sender nonce 1337 is not unique + 500: + description: Internal server error + """ try: time_received = time.time() arguments = flask_restful.request.json @@ -244,6 +283,53 @@ class _TransferStatus(flask_restful.Resource): """ def get(self, task_id: str) -> flask.Response: + """ + Endpoint that returns the status of a transfer. + --- + parameters: + - in: path + name: task_id + schema: + $ref: '#/components/schemas/_TransferStatus' + required: true + description: Id of a transfer submitted to the service node + responses: + 200: + description: Object containing the status of a transfer with + the given task ID + content: + application/json: + schema: + type: object + example: + task_id: '123e4567-e89b-12d3-a456-426614174000' + source_blockchain_id: 1 + destination_blockchain_id: 2 + sender_address: \ + '0x1234567890123456789012345678901234567890' + recipient_address: \ + '0x1234567890123456789012345678901234567890' + source_token_address: \ + '0x1234567890123456789012345678901234567890' + destination_token_address: \ + '0x1234567890123456789012345678901234567890' + amount: 100 + fee: 1 + status: 'pending' + transfer_id: \ + '0x1234567890123456789012345678901234567890' + transaction_id: \ + '0x1234567890123456789012345678901234567890' + 404: + description: 'not found' + content: + application/json: + schema: + type: string + example: {"message": "task ID 123 is unknown"} + 500: + description: 'internal server error' + """ try: task_id_uuid = _TransferStatusSchema().load({'task_id': task_id}) _logger.info(f'new transfer status request: {task_id}') @@ -289,6 +375,45 @@ class _Bids(flask_restful.Resource): """ def get(self) -> flask.Response: + """ + Endpoint that returns a list of bids for a given source and \ + destination blockchain. + --- + parameters: + - in: query + name: source_blockchain + schema: + $ref: '#/components/schemas/_Bids/properties/source_blockchain' + required: true + description: Numeric ID of the supported Blockchain ID + - in: query + name: destination_blockchain + schema: + $ref: \ + '#/components/schemas/_Bids/properties/destination_blockchain' + required: true + description: Numeric ID of the supported Blockchain ID + responses: + 200: + description: List of bids for a given source and \ + destination blockchain + content: + application/json: + schema: + $ref: '#/components/schemas/_Bid' + 400: + description: 'bad request' + content: + application/json: + schema: + type: string + example: {"message": {"source_blockchain": \ + ["Missing data for required field."], \ + "destination_blockchain": \ + ["Missing data for required field."]}} + 500: + description: 'internal server error' + """ try: query_arguments = flask_restful.request.args bids_parameter = _BidsSchema().load(query_arguments) diff --git a/poetry.lock b/poetry.lock index 6ac8e3c..5ad412b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -195,6 +195,46 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "apispec" +version = "6.7.1" +description = "A pluggable API specification generator. Currently supports the OpenAPI Specification (f.k.a. the Swagger specification)." +optional = false +python-versions = ">=3.9" +files = [ + {file = "apispec-6.7.1-py3-none-any.whl", hash = "sha256:d99e7a564f3871327c17b3e43726cc1e6ade2c97aa05706644a48818fc37999e"}, + {file = "apispec-6.7.1.tar.gz", hash = "sha256:c01b8b6ff40ffedf55b79a67f9dd920e9b2fc3909aae116facf6c8372a08b933"}, +] + +[package.dependencies] +packaging = ">=21.3" +PyYAML = {version = ">=3.10", optional = true, markers = "extra == \"yaml\""} + +[package.extras] +dev = ["apispec[tests]", "pre-commit (>=3.5,<5.0)", "tox"] +docs = ["apispec[marshmallow]", "pyyaml (==6.0.2)", "sphinx (==8.1.3)", "sphinx-issues (==5.0.0)", "sphinx-rtd-theme (==3.0.1)"] +marshmallow = ["marshmallow (>=3.18.0)"] +tests = ["apispec[marshmallow,yaml]", "openapi-spec-validator (==0.7.1)", "pytest"] +yaml = ["PyYAML (>=3.10)"] + +[[package]] +name = "apispec-webframeworks" +version = "1.2.0" +description = "Web framework plugins for apispec." +optional = false +python-versions = ">=3.8" +files = [ + {file = "apispec_webframeworks-1.2.0-py3-none-any.whl", hash = "sha256:68aea0d1eeb3caeeacc7d6772a48c59c8b60b1a88d0bd51529d94597ccf33116"}, + {file = "apispec_webframeworks-1.2.0.tar.gz", hash = "sha256:5689288c266a2713c2f516eacc14ea2fec9b21f193edc8f659c770342b97fd81"}, +] + +[package.dependencies] +apispec = {version = ">=6.0.0", extras = ["yaml"]} + +[package.extras] +dev = ["apispec-webframeworks[tests]", "pre-commit (>=3.5,<4.0)", "tox"] +tests = ["Flask (>=2.3.3)", "aiohttp (>=3.9.3)", "bottle (>=0.12.25)", "pytest", "tornado (>=6)"] + [[package]] name = "async-timeout" version = "4.0.3" @@ -1275,6 +1315,24 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.12.0,<2.13.0" pyflakes = ">=3.2.0,<3.3.0" +[[package]] +name = "flasgger" +version = "0.9.7.1" +description = "Extract swagger specs from your flask project" +optional = false +python-versions = "*" +files = [ + {file = "flasgger-0.9.7.1.tar.gz", hash = "sha256:ca098e10bfbb12f047acc6299cc70a33851943a746e550d86e65e60d4df245fb"}, +] + +[package.dependencies] +Flask = ">=0.10" +jsonschema = ">=3.0.1" +mistune = "*" +packaging = "*" +PyYAML = ">=3.0" +six = ">=1.10.0" + [[package]] name = "flask" version = "3.0.3" @@ -2016,6 +2074,17 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "mistune" +version = "3.0.2" +description = "A sane and fast Markdown parser with useful plugins and renderers" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mistune-3.0.2-py3-none-any.whl", hash = "sha256:71481854c30fdbc938963d3605b72501f5c10a9320ecd412c121c163a1c7d205"}, + {file = "mistune-3.0.2.tar.gz", hash = "sha256:fc7f93ded930c92394ef2cb6f04a8aabab4117a91449e72dcc8dfa646a508be8"}, +] + [[package]] name = "multidict" version = "6.1.0" @@ -3081,7 +3150,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -4047,4 +4115,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "e3fe7cb3719e7c8db451acfc735075c7a2e5a4cebb4d020f0f559e1f065acf8c" +content-hash = "02e1a334f82d850cfa694ba2ad93342110486213157813d96d7dc8f4c8a755b8" diff --git a/pyproject.toml b/pyproject.toml index 835d60a..940ac6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,12 @@ yapf = "0.40.2" [tool.poetry.group.dev.dependencies] pre-commit = "4.0.1" + +[tool.poetry.group.docs.dependencies] +apispec = "^6.7.1" +apispec-webframeworks = "^1.2.0" +flasgger = "^0.9.7.1" + [tool.poetry.dependencies] python = "^3.10" pantos-common = "4.0.1"