From 3357caa2d49861fb0aa906b6c1ca275f7093fa1f Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Sun, 8 May 2022 14:06:05 +0200 Subject: [PATCH] Fix #15 See the discussion here: https://github.com/Neoteroi/mkdocs-plugins/issues/5 --- CHANGELOG.md | 5 + openapidocs/__init__.py | 2 +- openapidocs/mk/generate.py | 4 +- openapidocs/mk/jinja.py | 2 +- openapidocs/mk/texts.py | 6 + openapidocs/mk/v3/__init__.py | 109 ++- openapidocs/mk/v3/examples.py | 6 +- openapidocs/mk/v3/views_markdown/layout.html | 8 + .../views_markdown/partial/external-docs.html | 9 + .../views_markdown/partial/schema-repr.html | 7 + .../mk/v3/views_markdown/partial/tags.html | 8 + openapidocs/mk/v3/views_mkdocs/layout.html | 8 + .../views_mkdocs/partial/external-docs.html | 11 + .../v3/views_mkdocs/partial/schema-repr.html | 7 + .../mk/v3/views_mkdocs/partial/tags.html | 8 + openapidocs/utils/source.py | 5 +- tests/common.py | 13 +- tests/res/example4-split-openapi.yaml | 35 + tests/res/example4-split-output.md | 918 ++++++++++++++++++ tests/res/spec/parameters/Cursor.yml | 8 + tests/res/spec/parameters/ExecutionId.yml | 6 + tests/res/spec/parameters/IncludeRole.yml | 6 + tests/res/spec/parameters/Limit.yml | 8 + tests/res/spec/parameters/UserIdentifier.yml | 7 + tests/res/spec/parameters/WorkflowId.yml | 6 + tests/res/spec/parameters/_index.yml | 12 + tests/res/spec/paths/UserById.yml | 37 + tests/res/spec/paths/Users.yml | 48 + tests/res/spec/responses/NotFound.yml | 5 + tests/res/spec/responses/Unauthorized.yml | 5 + tests/res/spec/responses/UserInformation.yml | 5 + tests/res/spec/responses/_index.yml | 6 + tests/res/spec/schemas/Error.yml | 13 + tests/res/spec/schemas/RoleInformation.yml | 25 + .../res/spec/schemas/UserDetailsResponse.yml | 11 + tests/res/spec/schemas/UserInformation.yml | 40 + tests/res/spec/schemas/_index.yml | 8 + tests/test_mk_v3.py | 51 +- 38 files changed, 1455 insertions(+), 23 deletions(-) create mode 100644 openapidocs/mk/v3/views_markdown/partial/external-docs.html create mode 100644 openapidocs/mk/v3/views_markdown/partial/tags.html create mode 100644 openapidocs/mk/v3/views_mkdocs/partial/external-docs.html create mode 100644 openapidocs/mk/v3/views_mkdocs/partial/tags.html create mode 100644 tests/res/example4-split-openapi.yaml create mode 100644 tests/res/example4-split-output.md create mode 100644 tests/res/spec/parameters/Cursor.yml create mode 100644 tests/res/spec/parameters/ExecutionId.yml create mode 100644 tests/res/spec/parameters/IncludeRole.yml create mode 100644 tests/res/spec/parameters/Limit.yml create mode 100644 tests/res/spec/parameters/UserIdentifier.yml create mode 100644 tests/res/spec/parameters/WorkflowId.yml create mode 100644 tests/res/spec/parameters/_index.yml create mode 100644 tests/res/spec/paths/UserById.yml create mode 100644 tests/res/spec/paths/Users.yml create mode 100644 tests/res/spec/responses/NotFound.yml create mode 100644 tests/res/spec/responses/Unauthorized.yml create mode 100644 tests/res/spec/responses/UserInformation.yml create mode 100644 tests/res/spec/responses/_index.yml create mode 100644 tests/res/spec/schemas/Error.yml create mode 100644 tests/res/spec/schemas/RoleInformation.yml create mode 100644 tests/res/spec/schemas/UserDetailsResponse.yml create mode 100644 tests/res/spec/schemas/UserInformation.yml create mode 100644 tests/res/spec/schemas/_index.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index bb12d01..20fb35b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.2] - 2022-05-08 +- Adds support for OpenAPI specification files split into multiple files + https://github.com/Neoteroi/mkdocs-plugins/issues/5 +- Adds support for `externalDocs` and `tags` root properties + ## [1.0.1] - 2022-05-05 - Adds a new output style, to provide an overview of the API endpoints with PlantUML diff --git a/openapidocs/__init__.py b/openapidocs/__init__.py index 50b5ea9..2be0457 100644 --- a/openapidocs/__init__.py +++ b/openapidocs/__init__.py @@ -1 +1 @@ -VERSION = "1.0.1" +VERSION = "1.0.2" diff --git a/openapidocs/mk/generate.py b/openapidocs/mk/generate.py index 5476893..70aae04 100644 --- a/openapidocs/mk/generate.py +++ b/openapidocs/mk/generate.py @@ -9,9 +9,9 @@ def generate_document(source: str, destination: str, style: Union[int, str]): # parameter in this function data = read_from_source(source) - handler = OpenAPIV3DocumentationHandler(data, style=style) + handler = OpenAPIV3DocumentationHandler(data, style=style, source=source) - html = handler.write(data) + html = handler.write() # TODO: support more kinds of destinations with open(destination, encoding="utf8", mode="wt") as output_file: diff --git a/openapidocs/mk/jinja.py b/openapidocs/mk/jinja.py index 018ec29..2bc31bf 100644 --- a/openapidocs/mk/jinja.py +++ b/openapidocs/mk/jinja.py @@ -19,7 +19,7 @@ def configure_functions(env: Environment): "read_dict": read_dict, "sort_dict": sort_dict, "is_reference": is_reference, - "scalar_types": {"string", "integer", "boolean"}, + "scalar_types": {"string", "integer", "boolean", "number"}, "get_http_status_phrase": get_http_status_phrase, "write_md_table": write_table, } diff --git a/openapidocs/mk/texts.py b/openapidocs/mk/texts.py index dd7dff5..b62e9d2 100644 --- a/openapidocs/mk/texts.py +++ b/openapidocs/mk/texts.py @@ -28,6 +28,7 @@ class Texts: schemas: str about_schemas: str details: str + external_docs: str required: str properties: str yes: str @@ -46,6 +47,8 @@ class Texts: common_parameters_about: str scheme: str response_headers: str + for_more_information: str + tags: str def get_yes_no(self, value: bool) -> str: return self.yes if value else self.no @@ -103,3 +106,6 @@ class EnglishTexts(Texts): security_schemes = "Security schemes" scheme = "Scheme" response_headers = "Response headers" + external_docs = "More documentation" + for_more_information = "For more information" + tags = "Tags" diff --git a/openapidocs/mk/v3/__init__.py b/openapidocs/mk/v3/__init__.py index 8537e0e..04cdb24 100644 --- a/openapidocs/mk/v3/__init__.py +++ b/openapidocs/mk/v3/__init__.py @@ -2,11 +2,14 @@ This module provides functions to generate Markdown for OpenAPI Version 3. """ import copy +import os import warnings from collections import defaultdict from dataclasses import dataclass +from pathlib import Path from typing import Any, Iterable, List, Optional, Union +from openapidocs.logs import logger from openapidocs.mk import read_dict, sort_dict from openapidocs.mk.common import ( DocumentsWriter, @@ -19,6 +22,7 @@ from openapidocs.mk.jinja import Jinja2DocumentsWriter, OutputStyle from openapidocs.mk.texts import EnglishTexts, Texts from openapidocs.mk.v3.examples import get_example_from_schema +from openapidocs.utils.source import read_from_source def _can_simplify_json(content_type) -> bool: @@ -50,6 +54,23 @@ def style_from_value(value: Union[int, str]) -> OutputStyle: raise ValueError(f"Invalid style: {value}") +class OpenAPIDocumentationHandlerError(Exception): + """Base type for exceptions raised by the handler generating documentation.""" + + +class OpenAPIFileNotFoundError(OpenAPIDocumentationHandlerError, FileNotFoundError): + """ + Exception raised when a $ref property pointing to a file (to split OAD specification + into multiple files) is not found. + """ + + def __init__(self, reference: str, attempted_path: Path) -> None: + super().__init__( + f"Cannot resolve the $ref source {reference} " + f"Tried to read from path: {attempted_path}" + ) + + class OpenAPIV3DocumentationHandler: """ Class that produces documentation from OpenAPI Documentation V3. @@ -73,21 +94,80 @@ def __init__( texts: Optional[Texts] = None, writer: Optional[DocumentsWriter] = None, style: Union[int, str] = 1, + source: str = "", ) -> None: - self.doc = self.normalize_data(copy.deepcopy(doc)) + self._source = source self.texts = texts or EnglishTexts() self._writer = writer or Jinja2DocumentsWriter( __name__, views_style=style_from_value(style) ) + self.doc = self.normalize_data(copy.deepcopy(doc)) + + @property + def source(self) -> str: + return self._source def normalize_data(self, data): """ Applies corrections to the OpenAPI specification, to simplify its handling. + + This method also resolves references to different files, if the root is split + into multiple files. + + --- + Ref. + An OpenAPI document MAY be made up of a single document or be divided into + multiple, connected parts at the discretion of the user. In the latter case, + $ref fields MUST be used in the specification to reference those parts as + follows from the JSON Schema definitions. """ if "components" not in data: data["components"] = {} - return data + return self._transform_data( + data, Path(self.source).parent if self.source else Path.cwd() + ) + + def _transform_data(self, obj, source_path): + if not isinstance(obj, dict): + return obj + + if "$ref" in obj: + return self._handle_obj_ref(obj, source_path) + + clone = {} + + for key, value in obj.items(): + if isinstance(value, list): + clone[key] = [self._transform_data(item, source_path) for item in value] + elif isinstance(value, dict): + clone[key] = self._handle_obj_ref(value, source_path) + else: + clone[key] = self._transform_data(value, source_path) + + return clone + + def _handle_obj_ref(self, obj, source_path): + """ + Handles a dictionary containing a $ref property, resolving the reference if it + is to a file. This is used to read specification files when they are split into + multiple items. + """ + assert isinstance(obj, dict) + if "$ref" in obj: + reference = obj["$ref"] + if isinstance(reference, str) and not reference.startswith("#/"): + referred_file = Path(os.path.abspath(source_path / reference)) + + if referred_file.exists(): + logger.debug("Handling $ref source: %s", reference) + else: + raise OpenAPIFileNotFoundError(reference, referred_file) + sub_fragment = read_from_source(str(referred_file)) + return self._transform_data(sub_fragment, referred_file.parent) + else: + return obj + return self._transform_data(obj, source_path) def get_operations(self): """ @@ -308,8 +388,10 @@ def get_parameters(self, operation) -> List[dict]: results = [ param for param in sorted( - parameters, key=lambda x: x["name"].lower() if "name" in x else "" + parameters, + key=lambda x: x["name"].lower() if (x and "name" in x) else "", ) + if param ] security_options = self.get_operation_security(operation) @@ -331,9 +413,12 @@ def get_parameters(self, operation) -> List[dict]: return results - def write(self, data) -> str: + def write(self) -> str: return self._writer.write( - data, operations=self.get_operations(), texts=self.texts, handler=self + self.doc, + operations=self.get_operations(), + texts=self.texts, + handler=self, ) def get_content_examples(self, data) -> Iterable[ContentExample]: @@ -463,9 +548,17 @@ def expand_references(self, schema, context: Optional[ExpandContext] = None): else: context.expanded_refs.add(ref) - clone[key] = self.expand_references( - self.resolve_reference(value), context - ) + resolved_ref = self.resolve_reference(value) + + if resolved_ref is None: # pragma: no cover + logger.warning( + "Cannot resolve the reference %s. " + "Is a fragment missing from `components` object?", + value, + ) + clone[key] = {} + else: + clone[key] = self.expand_references(resolved_ref, context) elif isinstance(value, dict): if is_array_schema(value) and is_reference(value["items"]): ref = value["items"]["$ref"] diff --git a/openapidocs/mk/v3/examples.py b/openapidocs/mk/v3/examples.py index 50954b1..e9904cd 100644 --- a/openapidocs/mk/v3/examples.py +++ b/openapidocs/mk/v3/examples.py @@ -94,7 +94,8 @@ def get_example(self, schema) -> Any: """ Returns an example value for a property with the given name and schema. """ - properties = schema["properties"] + properties = schema.get("properties") or {} + example = {} for key in properties: @@ -130,6 +131,9 @@ def get_example_from_schema(schema) -> Any: if schema is None: return None + if "example" in schema: + return schema["example"] + # does it have a type? handlers_types: List[Type[SchemaExampleHandler]] = list( get_subclasses(SchemaExampleHandler) diff --git a/openapidocs/mk/v3/views_markdown/layout.html b/openapidocs/mk/v3/views_markdown/layout.html index 9dfab1b..ad30e53 100644 --- a/openapidocs/mk/v3/views_markdown/layout.html +++ b/openapidocs/mk/v3/views_markdown/layout.html @@ -31,3 +31,11 @@ {% include "partial/components-security-schemes.html" %} {% endif -%} {% endif -%} + +{%- if tags %} +{% include "partial/tags.html" %} +{% endif -%} + +{%- if externalDocs %} +{% include "partial/external-docs.html" %} +{% endif -%} diff --git a/openapidocs/mk/v3/views_markdown/partial/external-docs.html b/openapidocs/mk/v3/views_markdown/partial/external-docs.html new file mode 100644 index 0000000..2e51aa2 --- /dev/null +++ b/openapidocs/mk/v3/views_markdown/partial/external-docs.html @@ -0,0 +1,9 @@ +## {{texts.external_docs}} + +{% if externalDocs.description -%} +{{externalDocs.description | wordwrap(80)}} + +--- +{% endif -%} + +**{{texts.for_more_information}}:** [{{externalDocs.url}}]({{externalDocs.url}}) diff --git a/openapidocs/mk/v3/views_markdown/partial/schema-repr.html b/openapidocs/mk/v3/views_markdown/partial/schema-repr.html index b94ca3b..4ba4f6b 100644 --- a/openapidocs/mk/v3/views_markdown/partial/schema-repr.html +++ b/openapidocs/mk/v3/views_markdown/partial/schema-repr.html @@ -6,6 +6,13 @@ {%- if schema.type -%} {%- with type_name = schema["type"], nullable = schema.get("nullable") -%} +{%- if type_name == "object" -%} +{%- if schema.example -%} +_{{texts.example}}: _`{{schema.example}}` +{%- elif schema.properties -%} +_{{texts.properties}}: _`{{", ".join(schema.properties.keys())}}` +{%- endif -%} +{%- endif -%} {# Scalar types #} {%- if type_name in scalar_types -%} {{type_name}} diff --git a/openapidocs/mk/v3/views_markdown/partial/tags.html b/openapidocs/mk/v3/views_markdown/partial/tags.html new file mode 100644 index 0000000..a2f29d0 --- /dev/null +++ b/openapidocs/mk/v3/views_markdown/partial/tags.html @@ -0,0 +1,8 @@ +## {{texts.tags}} + +{% with rows = [[texts.name, texts.description]] %} +{%- for tag in tags -%} +{%- set _ = rows.append([tag.name, read_dict(tag, "description", default="")]) -%} +{%- endfor -%} +{{ rows | table }} +{% endwith -%} diff --git a/openapidocs/mk/v3/views_mkdocs/layout.html b/openapidocs/mk/v3/views_mkdocs/layout.html index 64ef2eb..01188d7 100644 --- a/openapidocs/mk/v3/views_mkdocs/layout.html +++ b/openapidocs/mk/v3/views_mkdocs/layout.html @@ -31,3 +31,11 @@ {% include "partial/components-security-schemes.html" %} {% endif -%} {% endif -%} + +{%- if tags %} +{% include "partial/tags.html" %} +{% endif -%} + +{%- if externalDocs %} +{% include "partial/external-docs.html" %} +{% endif -%} diff --git a/openapidocs/mk/v3/views_mkdocs/partial/external-docs.html b/openapidocs/mk/v3/views_mkdocs/partial/external-docs.html new file mode 100644 index 0000000..e73e09c --- /dev/null +++ b/openapidocs/mk/v3/views_mkdocs/partial/external-docs.html @@ -0,0 +1,11 @@ +## {{texts.external_docs}} + +{% if externalDocs.description -%} +{{externalDocs.description | wordwrap(80)}} + +
+{%- endif -%} + +
+ {{texts.for_more_information}}: {{externalDocs.url}} +
diff --git a/openapidocs/mk/v3/views_mkdocs/partial/schema-repr.html b/openapidocs/mk/v3/views_mkdocs/partial/schema-repr.html index 4284f10..2368fbb 100644 --- a/openapidocs/mk/v3/views_mkdocs/partial/schema-repr.html +++ b/openapidocs/mk/v3/views_mkdocs/partial/schema-repr.html @@ -6,6 +6,13 @@ {%- if schema.type -%} {%- with type_name = schema["type"], nullable = schema.get("nullable") -%} +{%- if type_name == "object" -%} +{%- if schema.example -%} +{{texts.example}}: {{schema.example}} +{%- elif schema.properties -%} +{{texts.properties}}: {{", ".join(schema.properties.keys())}} +{%- endif -%} +{%- endif -%} {# Scalar types #} {%- if type_name in scalar_types -%} {{type_name}} diff --git a/openapidocs/mk/v3/views_mkdocs/partial/tags.html b/openapidocs/mk/v3/views_mkdocs/partial/tags.html new file mode 100644 index 0000000..a2f29d0 --- /dev/null +++ b/openapidocs/mk/v3/views_mkdocs/partial/tags.html @@ -0,0 +1,8 @@ +## {{texts.tags}} + +{% with rows = [[texts.name, texts.description]] %} +{%- for tag in tags -%} +{%- set _ = rows.append([tag.name, read_dict(tag, "description", default="")]) -%} +{%- endfor -%} +{{ rows | table }} +{% endwith -%} diff --git a/openapidocs/utils/source.py b/openapidocs/utils/source.py index 2c665ef..e7c95a1 100644 --- a/openapidocs/utils/source.py +++ b/openapidocs/utils/source.py @@ -65,9 +65,8 @@ def read_from_url(url: str): def read_from_source(source: str): """ - Tries to read OpenAPI Documentation from a given source. - The source can be a path to a file, or a URL. This method will try to fetch - JSON or YAML from the given source. + Tries to read a JSON or YAML file from a given source. + The source can be a path to a file, or a URL. """ source_path = Path(source) diff --git a/tests/common.py b/tests/common.py index c901826..e8c1416 100644 --- a/tests/common.py +++ b/tests/common.py @@ -3,6 +3,7 @@ from typing import Any import pkg_resources +import yaml from openapidocs.common import Format @@ -24,9 +25,15 @@ def debug_result(version: str, instance: Any, result: str, format: Format) -> No debug_file.write(result) +def get_resource_file_path(file_name: str) -> str: + return pkg_resources.resource_filename( + __name__, os.path.join(".", "res", file_name) + ) + + def get_resource_file_content(file_name: str) -> str: with open( - pkg_resources.resource_filename(__name__, os.path.join(".", "res", file_name)), + get_resource_file_path(file_name), mode="rt", encoding="utf8", ) as source: @@ -35,3 +42,7 @@ def get_resource_file_content(file_name: str) -> str: def get_file_json(file_name) -> Any: return json.loads(get_resource_file_content(file_name)) + + +def get_file_yaml(file_name) -> Any: + return yaml.safe_load(get_resource_file_content(file_name)) diff --git a/tests/res/example4-split-openapi.yaml b/tests/res/example4-split-openapi.yaml new file mode 100644 index 0000000..e2d13b3 --- /dev/null +++ b/tests/res/example4-split-openapi.yaml @@ -0,0 +1,35 @@ +--- +openapi: 3.0.0 +info: + title: Split Public API + description: split Public API + termsOfService: https://split.io/legal/terms + version: v1 +externalDocs: + description: split API documentation + url: https://www.neoteroi.xyz/ +servers: + - url: https://www.neoteroi.xyz/split/api/v1 +tags: + - name: User + description: Operations about users. +paths: + /users: + $ref: "./spec/paths/Users.yml" + /users/{identifier}: + $ref: "./spec/paths/UserById.yml" +components: + schemas: + $ref: "./spec/schemas/_index.yml" + responses: + $ref: "./spec/responses/_index.yml" + parameters: + $ref: "./spec/parameters/_index.yml" + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-KEY + +security: + - ApiKeyAuth: [] diff --git a/tests/res/example4-split-output.md b/tests/res/example4-split-output.md new file mode 100644 index 0000000..3bf7b83 --- /dev/null +++ b/tests/res/example4-split-output.md @@ -0,0 +1,918 @@ + + +# Split Public API v1 + +split Public API +
+
+ Terms of service: https://split.io/legal/terms +
+ +## Servers + + + + + + + + + + + + + + +
DescriptionURL
https://www.neoteroi.xyz/split/api/v1 + https://www.neoteroi.xyz/split/api/v1 +
+ +## User + +
+ +### POST /users +Invite a user + +??? note "Description" + Invites a user to your instance. Only available for the instance owner. + + +**Input parameters** + + + + + + + + + + + + + + + + + + + + + + +
ParameterInTypeDefaultNullableDescription
ApiKeyAuthheaderstringN/ANoAPI key
+

Request body

+ + + +=== "application/json" + + + ```json + [ + { + "id": "123e4567-e89b-12d3-a456-426614174000", + "email": "john.doe@company.com", + "firstName": "john", + "lastName": "Doe", + "isPending": true, + "createdAt": "2022-04-13T15:42:05.901Z", + "updatedAt": "2022-04-13T15:42:05.901Z", + "globalRole": { + "id": 1, + "name": "owner", + "scope": "global", + "createdAt": "2022-04-13T15:42:05.901Z", + "updatedAt": "2022-04-13T15:42:05.901Z" + } + } + ] + ``` + ⚠️ This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information. + + + + ??? hint "Schema of the request body" + ```json + { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserInformation" + } + } + ``` + + + +

+ Response 200 OK +

+ +=== "application/json" + + + ```json + [ + { + "id": "123e4567-e89b-12d3-a456-426614174000", + "email": "john.doe@company.com", + "firstName": "john", + "lastName": "Doe", + "isPending": true, + "createdAt": "2022-04-13T15:42:05.901Z", + "updatedAt": "2022-04-13T15:42:05.901Z", + "globalRole": { + "id": 1, + "name": "owner", + "scope": "global", + "createdAt": "2022-04-13T15:42:05.901Z", + "updatedAt": "2022-04-13T15:42:05.901Z" + } + } + ] + ``` + ⚠️ This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information. + + + + ??? hint "Schema of the response body" + ```json + { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserInformation" + } + } + ``` + + + +

+ Response 401 Unauthorized +

+

Refer to the common response description: Unauthorized.

+
+ +### GET /users +Retrieve all users + +??? note "Description" + Retrieve all users from your instance. Only available for the instance + owner. + + +**Input parameters** + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterInTypeDefaultNullableDescription
ApiKeyAuthheaderstringN/ANoAPI key
cursorquerystringNoPaginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request's response. Default value fetches the first "page" of the collection. See pagination for more detail.
limitquerynumber100NoThe maximum number of items to return.
+ +

+ Response 200 OK +

+ +=== "application/json" + + + ```json + { + "data": [ + { + "id": "123e4567-e89b-12d3-a456-426614174000", + "email": "john.doe@company.com", + "firstName": "john", + "lastName": "Doe", + "isPending": true, + "createdAt": "2022-04-13T15:42:05.901Z", + "updatedAt": "2022-04-13T15:42:05.901Z", + "globalRole": { + "id": 1, + "name": "owner", + "scope": "global", + "createdAt": "2022-04-13T15:42:05.901Z", + "updatedAt": "2022-04-13T15:42:05.901Z" + } + } + ], + "nextCursor": "MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA" + } + ``` + ⚠️ This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information. + + + + ??? hint "Schema of the response body" + ```json + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserInformation" + } + }, + "nextCursor": { + "type": "string", + "description": "Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first \"page\" of the collection.", + "nullable": true, + "example": "MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA" + } + } + } + ``` + + + +

+ Response 401 Unauthorized +

+

Refer to the common response description: Unauthorized.

+ +
+ +### GET /users/{identifier} +Get user by ID/Email + +??? note "Description" + Retrieve a user from your instance. Only available for the instance owner. + + +**Input parameters** + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterInTypeDefaultNullableDescription
ApiKeyAuthheaderstringN/ANoAPI key
identifierpathstringNoThe ID or email of the user.
+ +

+ Response 200 OK +

+

Refer to the common response description: UserInformation.

+ +

+ Response 401 Unauthorized +

+

Refer to the common response description: Unauthorized.

+
+ +### DELETE /users/{identifier} +Delete user by ID/Email + +??? note "Description" + Deletes a user from your instance. Only available for the instance owner. + + +**Input parameters** + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterInTypeDefaultNullableDescription
ApiKeyAuthheaderstringN/ANoAPI key
identifierpathstringNoThe ID or email of the user.
transferIdquerystringNoID of the user to transfer workflows and credentials to. Must not be equal to the to-be-deleted user.
+ +

+ Response 200 OK +

+

Refer to the common response description: UserInformation.

+ +

+ Response 401 Unauthorized +

+

Refer to the common response description: Unauthorized.

+ + + + +--- +## Schemas + + +### Error + + + + + + + + + + + + + + + + + + + + + + +
NameType
codestring
descriptionstring
messagestring
+ + + +### RoleInformation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameType
createdAtstring(date-time)
idnumber
namestring
scopestring
updatedAtstring(date-time)
+ + + +### UserDetailsResponse + + + + + + + + + + + + + + + + + + +
NameType
dataArray<UserInformation>
nextCursorstring| null
+ + + +### UserInformation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameType
createdAtstring(date-time)
emailstring()
firstNamestring
globalRoleRoleInformation
idstring
isPendingboolean
lastNamestring
updatedAtstring(date-time)
+ + + +## Common responses + +This section describes common responses that are reused across operations. + + + +### NotFound +The specified resource was not found. + +

+ +=== "application/json" + + + ```json + { + "code": "string", + "message": "string", + "description": "string" + } + ``` + ⚠️ This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information. + + + + ??? hint "Schema of the response body" + ```json + { + "required": [ + "code", + "description", + "message" + ], + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + ``` + + + + + + +### Unauthorized +Unauthorized + +

+ +=== "application/json" + + + ```json + { + "code": "string", + "message": "string", + "description": "string" + } + ``` + ⚠️ This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information. + + + + ??? hint "Schema of the response body" + ```json + { + "required": [ + "code", + "description", + "message" + ], + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + ``` + + + + + + +### UserInformation +Operation successful. + +

+ +=== "application/json" + + + ```json + { + "id": "123e4567-e89b-12d3-a456-426614174000", + "email": "john.doe@company.com", + "firstName": "john", + "lastName": "Doe", + "isPending": true, + "createdAt": "2022-04-13T15:42:05.901Z", + "updatedAt": "2022-04-13T15:42:05.901Z", + "globalRole": { + "id": 1, + "name": "owner", + "scope": "global", + "createdAt": "2022-04-13T15:42:05.901Z", + "updatedAt": "2022-04-13T15:42:05.901Z" + } + } + ``` + ⚠️ This example has been generated automatically from the schema and it is not accurate. Refer to the schema for more information. + + + + ??? hint "Schema of the response body" + ```json + { + "required": [ + "email" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "readOnly": true, + "example": "123e4567-e89b-12d3-a456-426614174000" + }, + "email": { + "type": "string", + "format": "email", + "example": "john.doe@company.com" + }, + "firstName": { + "maxLength": 32, + "type": "string", + "description": "User's first name", + "readOnly": true, + "example": "john" + }, + "lastName": { + "maxLength": 32, + "type": "string", + "description": "User's last name", + "readOnly": true, + "example": "Doe" + }, + "isPending": { + "type": "boolean", + "description": "Whether the user finished setting up their account in response to the invitation (true) or not (false).", + "readOnly": true + }, + "createdAt": { + "type": "string", + "description": "Time the user was created.", + "format": "date-time", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "description": "Last time the user was updated.", + "format": "date-time", + "readOnly": true + }, + "globalRole": { + "$ref": "#/components/schemas/RoleInformation" + } + } + } + ``` + + + + + + +## Common parameters + +This section describes common parameters that are reused across operations. + + + +### UserIdentifier + + + + + + + + + + + + + + + + + + + + + + +
NameInTypeDefaultNullableDescription
identifierpathstringNo
+ + + +### Cursor + + + + + + + + + + + + + + + + + + + + + + +
NameInTypeDefaultNullableDescription
cursorquerystringNo
+ + + +### includeRole + + + + + + + + + + + + + + + + + + + + + + +
NameInTypeDefaultNullableDescription
includeRolequerybooleanNo
+ + + +### Limit + + + + + + + + + + + + + + + + + + + + + + +
NameInTypeDefaultNullableDescription
limitquerynumber100No
+ + + +### ExecutionId + + + + + + + + + + + + + + + + + + + + + + +
NameInTypeDefaultNullableDescription
executionIdpathnumberNo
+ + + +### WorkflowId + + + + + + + + + + + + + + + + + + + + + + +
NameInTypeDefaultNullableDescription
workflowIdpathnumberNo
+ + + +## Security schemes + + + + + + + + + + + + + + + + + + + + +
NameTypeSchemeDescription
ApiKeyAuthapiKey
+ +## Tags + +| Name | Description | +| ---- | ----------------------- | +| User | Operations about users. | + + +## More documentation + +split API documentation + +
+ For more information: https://www.neoteroi.xyz/ +
diff --git a/tests/res/spec/parameters/Cursor.yml b/tests/res/spec/parameters/Cursor.yml new file mode 100644 index 0000000..6ecee3a --- /dev/null +++ b/tests/res/spec/parameters/Cursor.yml @@ -0,0 +1,8 @@ +name: cursor +in: query +description: Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request's response. Default value fetches the first "page" of the collection. See pagination for more detail. +required: false +style: form +schema: + type: string + example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/tests/res/spec/parameters/ExecutionId.yml b/tests/res/spec/parameters/ExecutionId.yml new file mode 100644 index 0000000..d7248df --- /dev/null +++ b/tests/res/spec/parameters/ExecutionId.yml @@ -0,0 +1,6 @@ +name: executionId +in: path +description: The ID of the execution. +required: true +schema: + type: number diff --git a/tests/res/spec/parameters/IncludeRole.yml b/tests/res/spec/parameters/IncludeRole.yml new file mode 100644 index 0000000..e372805 --- /dev/null +++ b/tests/res/spec/parameters/IncludeRole.yml @@ -0,0 +1,6 @@ +name: includeRole +in: query +required: false +schema: + type: boolean + example: true diff --git a/tests/res/spec/parameters/Limit.yml b/tests/res/spec/parameters/Limit.yml new file mode 100644 index 0000000..2357af4 --- /dev/null +++ b/tests/res/spec/parameters/Limit.yml @@ -0,0 +1,8 @@ +name: limit +in: query +description: The maximum number of items to return. +required: false +schema: + type: number + example: 100 + default: 100 diff --git a/tests/res/spec/parameters/UserIdentifier.yml b/tests/res/spec/parameters/UserIdentifier.yml new file mode 100644 index 0000000..de5e6e5 --- /dev/null +++ b/tests/res/spec/parameters/UserIdentifier.yml @@ -0,0 +1,7 @@ +name: identifier +in: path +description: The ID or email of the user. +required: true +schema: + type: string + format: identifier diff --git a/tests/res/spec/parameters/WorkflowId.yml b/tests/res/spec/parameters/WorkflowId.yml new file mode 100644 index 0000000..ef87691 --- /dev/null +++ b/tests/res/spec/parameters/WorkflowId.yml @@ -0,0 +1,6 @@ +name: workflowId +in: path +description: The ID of the workflow. +required: true +schema: + type: number diff --git a/tests/res/spec/parameters/_index.yml b/tests/res/spec/parameters/_index.yml new file mode 100644 index 0000000..3fd9929 --- /dev/null +++ b/tests/res/spec/parameters/_index.yml @@ -0,0 +1,12 @@ +UserIdentifier: + $ref: "./UserIdentifier.yml" +Cursor: + $ref: "./Cursor.yml" +includeRole: + $ref: "./IncludeRole.yml" +Limit: + $ref: "./Limit.yml" +ExecutionId: + $ref: "./ExecutionId.yml" +WorkflowId: + $ref: "./WorkflowId.yml" diff --git a/tests/res/spec/paths/UserById.yml b/tests/res/spec/paths/UserById.yml new file mode 100644 index 0000000..03c658c --- /dev/null +++ b/tests/res/spec/paths/UserById.yml @@ -0,0 +1,37 @@ +get: + x-eov-operation-id: getUser + x-eov-operation-handler: v1/handlers/users + tags: + - User + summary: Get user by ID/Email + description: Retrieve a user from your instance. Only available for the instance owner. + parameters: + - $ref: "#/components/parameters/UserIdentifier" + - $ref: "#/components/parameters/IncludeRole" + responses: + "200": + $ref: "#/components/responses/UserInformation" + "401": + $ref: "#/components/responses/Unauthorized" +delete: + x-eov-operation-id: deleteUser + x-eov-operation-handler: v1/handlers/users + tags: + - User + summary: Delete user by ID/Email + description: Deletes a user from your instance. Only available for the instance owner. + operationId: deleteUser + parameters: + - $ref: "#/components/parameters/UserIdentifier" + - $ref: "#/components/parameters/IncludeRole" + - name: transferId + in: query + description: ID of the user to transfer workflows and credentials to. Must not be equal to the to-be-deleted user. + required: false + schema: + type: string + responses: + "200": + $ref: "#/components/responses/UserInformation" + "401": + $ref: "#/components/responses/Unauthorized" diff --git a/tests/res/spec/paths/Users.yml b/tests/res/spec/paths/Users.yml new file mode 100644 index 0000000..bc0a2cd --- /dev/null +++ b/tests/res/spec/paths/Users.yml @@ -0,0 +1,48 @@ +post: + x-eov-operation-id: createUsers + x-eov-operation-handler: v1/handlers/users + tags: + - User + summary: Invite a user + description: Invites a user to your instance. Only available for the instance owner. + operationId: createUser + requestBody: + description: Created user object. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UserInformation" + required: true + responses: + "200": + description: A User object + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UserInformation" + "401": + $ref: "#/components/responses/Unauthorized" +get: + x-eov-operation-id: getUsers + x-eov-operation-handler: v1/handlers/users + tags: + - User + summary: Retrieve all users + description: Retrieve all users from your instance. Only available for the instance owner. + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/IncludeRole" + responses: + "200": + description: Operation successful. + content: + application/json: + schema: + $ref: "#/components/schemas/UserDetailsResponse" + "401": + $ref: "#/components/responses/Unauthorized" diff --git a/tests/res/spec/responses/NotFound.yml b/tests/res/spec/responses/NotFound.yml new file mode 100644 index 0000000..823b44f --- /dev/null +++ b/tests/res/spec/responses/NotFound.yml @@ -0,0 +1,5 @@ +description: The specified resource was not found. +content: + application/json: + schema: + $ref: "#/components/schemas/Error" diff --git a/tests/res/spec/responses/Unauthorized.yml b/tests/res/spec/responses/Unauthorized.yml new file mode 100644 index 0000000..83326b4 --- /dev/null +++ b/tests/res/spec/responses/Unauthorized.yml @@ -0,0 +1,5 @@ +description: Unauthorized +content: + application/json: + schema: + $ref: "#/components/schemas/Error" diff --git a/tests/res/spec/responses/UserInformation.yml b/tests/res/spec/responses/UserInformation.yml new file mode 100644 index 0000000..1d8aecc --- /dev/null +++ b/tests/res/spec/responses/UserInformation.yml @@ -0,0 +1,5 @@ +description: Operation successful. +content: + application/json: + schema: + $ref: '#/components/schemas/UserInformation' diff --git a/tests/res/spec/responses/_index.yml b/tests/res/spec/responses/_index.yml new file mode 100644 index 0000000..eaee166 --- /dev/null +++ b/tests/res/spec/responses/_index.yml @@ -0,0 +1,6 @@ +NotFound: + $ref: './NotFound.yml' +Unauthorized: + $ref: './Unauthorized.yml' +UserInformation: + $ref: './UserInformation.yml' diff --git a/tests/res/spec/schemas/Error.yml b/tests/res/spec/schemas/Error.yml new file mode 100644 index 0000000..111f80c --- /dev/null +++ b/tests/res/spec/schemas/Error.yml @@ -0,0 +1,13 @@ +#error.yml +required: + - code + - description + - message +type: object +properties: + code: + type: string + message: + type: string + description: + type: string diff --git a/tests/res/spec/schemas/RoleInformation.yml b/tests/res/spec/schemas/RoleInformation.yml new file mode 100644 index 0000000..7043fb4 --- /dev/null +++ b/tests/res/spec/schemas/RoleInformation.yml @@ -0,0 +1,25 @@ +readOnly: true +type: object +properties: + id: + type: number + readOnly: true + example: 1 + name: + type: string + example: owner + readOnly: true + scope: + type: string + readOnly: true + example: global + createdAt: + type: string + description: Time the role was created. + format: date-time + readOnly: true + updatedAt: + type: string + description: Last time the role was updaded. + format: date-time + readOnly: true diff --git a/tests/res/spec/schemas/UserDetailsResponse.yml b/tests/res/spec/schemas/UserDetailsResponse.yml new file mode 100644 index 0000000..2b09196 --- /dev/null +++ b/tests/res/spec/schemas/UserDetailsResponse.yml @@ -0,0 +1,11 @@ +type: object +properties: + data: + type: array + items: + $ref: "#/components/schemas/UserInformation" + nextCursor: + type: string + description: Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. + nullable: true + example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/tests/res/spec/schemas/UserInformation.yml b/tests/res/spec/schemas/UserInformation.yml new file mode 100644 index 0000000..a3fd1d4 --- /dev/null +++ b/tests/res/spec/schemas/UserInformation.yml @@ -0,0 +1,40 @@ +required: + - email +type: object +properties: + id: + type: string + readOnly: true + example: 123e4567-e89b-12d3-a456-426614174000 + email: + type: string + format: email + example: john.doe@company.com + firstName: + maxLength: 32 + type: string + description: User's first name + readOnly: true + example: john + lastName: + maxLength: 32 + type: string + description: User's last name + readOnly: true + example: Doe + isPending: + type: boolean + description: Whether the user finished setting up their account in response to the invitation (true) or not (false). + readOnly: true + createdAt: + type: string + description: Time the user was created. + format: date-time + readOnly: true + updatedAt: + type: string + description: Last time the user was updated. + format: date-time + readOnly: true + globalRole: + $ref: "#/components/schemas/RoleInformation" diff --git a/tests/res/spec/schemas/_index.yml b/tests/res/spec/schemas/_index.yml new file mode 100644 index 0000000..db79cbc --- /dev/null +++ b/tests/res/spec/schemas/_index.yml @@ -0,0 +1,8 @@ +Error: + $ref: "./Error.yml" +UserInformation: + $ref: "./UserInformation.yml" +UserDetailsResponse: + $ref: "./UserDetailsResponse.yml" +RoleInformation: + $ref: "./RoleInformation.yml" diff --git a/tests/test_mk_v3.py b/tests/test_mk_v3.py index 191ea24..5043b54 100644 --- a/tests/test_mk_v3.py +++ b/tests/test_mk_v3.py @@ -1,7 +1,17 @@ import pytest -from openapidocs.mk.v3 import OpenAPIV3DocumentationHandler, style_from_value -from tests.common import get_file_json, get_resource_file_content +from openapidocs.mk.v3 import ( + OpenAPIFileNotFoundError, + OpenAPIV3DocumentationHandler, + style_from_value, +) +from openapidocs.mk.v3.examples import ObjectExampleHandler +from tests.common import ( + get_file_json, + get_file_yaml, + get_resource_file_content, + get_resource_file_path, +) @pytest.mark.parametrize("example_file", ["example1", "example2", "example3"]) @@ -11,11 +21,36 @@ def test_v3_markdown_gen(example_file): handler = OpenAPIV3DocumentationHandler(data) - html = handler.write(data) + html = handler.write() + assert html == expected_result + + +def test_v3_markdown_gen_split_file(): + example_file = "example4-split" + example_file_name = f"{example_file}-openapi.yaml" + data = get_file_yaml(example_file_name) + expected_result = get_resource_file_content(f"{example_file}-output.md") + + handler = OpenAPIV3DocumentationHandler( + data, source=get_resource_file_path(example_file_name) + ) + + html = handler.write() assert html == expected_result +def test_file_ref_raises_for_missing_file(): + with pytest.raises(OpenAPIFileNotFoundError): + OpenAPIV3DocumentationHandler( + { + "openapi": "3.0.0", + "info": {"title": "Split Public API"}, + "components": {"schemas": {"$ref": "./not-existing.yml"}}, + } + ) + + @pytest.mark.parametrize( "input,expected_result", [ @@ -246,7 +281,11 @@ def test_v3_markdown_gen_handles_missing_components(): } handler = OpenAPIV3DocumentationHandler(data) - html = handler.write(data) - with open("___a.html", encoding="utf8", mode="wt") as f: - f.write(html) + html = handler.write() assert html is not None + + +def test_object_example_handler_handles_missing_pros(): + handler = ObjectExampleHandler() + + assert handler.get_example({}) == {}