Skip to content

Commit

Permalink
Fix #15
Browse files Browse the repository at this point in the history
See the discussion here: Neoteroi/mkdocs-plugins#5
  • Loading branch information
RobertoPrevato authored May 8, 2022
1 parent 7f5e1a9 commit 3357caa
Show file tree
Hide file tree
Showing 38 changed files with 1,455 additions and 23 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion openapidocs/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "1.0.1"
VERSION = "1.0.2"
4 changes: 2 additions & 2 deletions openapidocs/mk/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion openapidocs/mk/jinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
6 changes: 6 additions & 0 deletions openapidocs/mk/texts.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class Texts:
schemas: str
about_schemas: str
details: str
external_docs: str
required: str
properties: str
yes: str
Expand All @@ -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
Expand Down Expand Up @@ -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"
109 changes: 101 additions & 8 deletions openapidocs/mk/v3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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)
Expand All @@ -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]:
Expand Down Expand Up @@ -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"]
Expand Down
6 changes: 5 additions & 1 deletion openapidocs/mk/v3/examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions openapidocs/mk/v3/views_markdown/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 -%}
9 changes: 9 additions & 0 deletions openapidocs/mk/v3/views_markdown/partial/external-docs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## {{texts.external_docs}}

{% if externalDocs.description -%}
{{externalDocs.description | wordwrap(80)}}

---
{% endif -%}

**{{texts.for_more_information}}:** [{{externalDocs.url}}]({{externalDocs.url}})
7 changes: 7 additions & 0 deletions openapidocs/mk/v3/views_markdown/partial/schema-repr.html
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down
8 changes: 8 additions & 0 deletions openapidocs/mk/v3/views_markdown/partial/tags.html
Original file line number Diff line number Diff line change
@@ -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 -%}
8 changes: 8 additions & 0 deletions openapidocs/mk/v3/views_mkdocs/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 -%}
11 changes: 11 additions & 0 deletions openapidocs/mk/v3/views_mkdocs/partial/external-docs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## {{texts.external_docs}}

{% if externalDocs.description -%}
{{externalDocs.description | wordwrap(80)}}

<hr />
{%- endif -%}

<div class="external-docs info-data">
<strong>{{texts.for_more_information}}:</strong> <a href="{{externalDocs.url}}" target="_blank" rel="noopener noreferrer">{{externalDocs.url}}</a>
</div>
7 changes: 7 additions & 0 deletions openapidocs/mk/v3/views_mkdocs/partial/schema-repr.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@

{%- if schema.type -%}
{%- with type_name = schema["type"], nullable = schema.get("nullable") -%}
{%- if type_name == "object" -%}
{%- if schema.example -%}
<em>{{texts.example}}: </em><code>{{schema.example}}</code>
{%- elif schema.properties -%}
<em>{{texts.properties}}: </em><code>{{", ".join(schema.properties.keys())}}</code>
{%- endif -%}
{%- endif -%}
{# Scalar types #}
{%- if type_name in scalar_types -%}
<span class="{{type_name}}-type">{{type_name}}</span>
Expand Down
8 changes: 8 additions & 0 deletions openapidocs/mk/v3/views_mkdocs/partial/tags.html
Original file line number Diff line number Diff line change
@@ -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 -%}
5 changes: 2 additions & 3 deletions openapidocs/utils/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
13 changes: 12 additions & 1 deletion tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Any

import pkg_resources
import yaml

from openapidocs.common import Format

Expand All @@ -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:
Expand All @@ -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))
Loading

0 comments on commit 3357caa

Please sign in to comment.