Skip to content

Commit 3357caa

Browse files
Fix #15
See the discussion here: Neoteroi/mkdocs-plugins#5
1 parent 7f5e1a9 commit 3357caa

38 files changed

Lines changed: 1455 additions & 23 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.0.2] - 2022-05-08
9+
- Adds support for OpenAPI specification files split into multiple files
10+
https://github.com/Neoteroi/mkdocs-plugins/issues/5
11+
- Adds support for `externalDocs` and `tags` root properties
12+
813
## [1.0.1] - 2022-05-05
914
- Adds a new output style, to provide an overview of the API endpoints with
1015
PlantUML

openapidocs/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
VERSION = "1.0.1"
1+
VERSION = "1.0.2"

openapidocs/mk/generate.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ def generate_document(source: str, destination: str, style: Union[int, str]):
99
# parameter in this function
1010

1111
data = read_from_source(source)
12-
handler = OpenAPIV3DocumentationHandler(data, style=style)
12+
handler = OpenAPIV3DocumentationHandler(data, style=style, source=source)
1313

14-
html = handler.write(data)
14+
html = handler.write()
1515

1616
# TODO: support more kinds of destinations
1717
with open(destination, encoding="utf8", mode="wt") as output_file:

openapidocs/mk/jinja.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def configure_functions(env: Environment):
1919
"read_dict": read_dict,
2020
"sort_dict": sort_dict,
2121
"is_reference": is_reference,
22-
"scalar_types": {"string", "integer", "boolean"},
22+
"scalar_types": {"string", "integer", "boolean", "number"},
2323
"get_http_status_phrase": get_http_status_phrase,
2424
"write_md_table": write_table,
2525
}

openapidocs/mk/texts.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class Texts:
2828
schemas: str
2929
about_schemas: str
3030
details: str
31+
external_docs: str
3132
required: str
3233
properties: str
3334
yes: str
@@ -46,6 +47,8 @@ class Texts:
4647
common_parameters_about: str
4748
scheme: str
4849
response_headers: str
50+
for_more_information: str
51+
tags: str
4952

5053
def get_yes_no(self, value: bool) -> str:
5154
return self.yes if value else self.no
@@ -103,3 +106,6 @@ class EnglishTexts(Texts):
103106
security_schemes = "Security schemes"
104107
scheme = "Scheme"
105108
response_headers = "Response headers"
109+
external_docs = "More documentation"
110+
for_more_information = "For more information"
111+
tags = "Tags"

openapidocs/mk/v3/__init__.py

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
This module provides functions to generate Markdown for OpenAPI Version 3.
33
"""
44
import copy
5+
import os
56
import warnings
67
from collections import defaultdict
78
from dataclasses import dataclass
9+
from pathlib import Path
810
from typing import Any, Iterable, List, Optional, Union
911

12+
from openapidocs.logs import logger
1013
from openapidocs.mk import read_dict, sort_dict
1114
from openapidocs.mk.common import (
1215
DocumentsWriter,
@@ -19,6 +22,7 @@
1922
from openapidocs.mk.jinja import Jinja2DocumentsWriter, OutputStyle
2023
from openapidocs.mk.texts import EnglishTexts, Texts
2124
from openapidocs.mk.v3.examples import get_example_from_schema
25+
from openapidocs.utils.source import read_from_source
2226

2327

2428
def _can_simplify_json(content_type) -> bool:
@@ -50,6 +54,23 @@ def style_from_value(value: Union[int, str]) -> OutputStyle:
5054
raise ValueError(f"Invalid style: {value}")
5155

5256

57+
class OpenAPIDocumentationHandlerError(Exception):
58+
"""Base type for exceptions raised by the handler generating documentation."""
59+
60+
61+
class OpenAPIFileNotFoundError(OpenAPIDocumentationHandlerError, FileNotFoundError):
62+
"""
63+
Exception raised when a $ref property pointing to a file (to split OAD specification
64+
into multiple files) is not found.
65+
"""
66+
67+
def __init__(self, reference: str, attempted_path: Path) -> None:
68+
super().__init__(
69+
f"Cannot resolve the $ref source {reference} "
70+
f"Tried to read from path: {attempted_path}"
71+
)
72+
73+
5374
class OpenAPIV3DocumentationHandler:
5475
"""
5576
Class that produces documentation from OpenAPI Documentation V3.
@@ -73,21 +94,80 @@ def __init__(
7394
texts: Optional[Texts] = None,
7495
writer: Optional[DocumentsWriter] = None,
7596
style: Union[int, str] = 1,
97+
source: str = "",
7698
) -> None:
77-
self.doc = self.normalize_data(copy.deepcopy(doc))
99+
self._source = source
78100
self.texts = texts or EnglishTexts()
79101
self._writer = writer or Jinja2DocumentsWriter(
80102
__name__, views_style=style_from_value(style)
81103
)
104+
self.doc = self.normalize_data(copy.deepcopy(doc))
105+
106+
@property
107+
def source(self) -> str:
108+
return self._source
82109

83110
def normalize_data(self, data):
84111
"""
85112
Applies corrections to the OpenAPI specification, to simplify its handling.
113+
114+
This method also resolves references to different files, if the root is split
115+
into multiple files.
116+
117+
---
118+
Ref.
119+
An OpenAPI document MAY be made up of a single document or be divided into
120+
multiple, connected parts at the discretion of the user. In the latter case,
121+
$ref fields MUST be used in the specification to reference those parts as
122+
follows from the JSON Schema definitions.
86123
"""
87124
if "components" not in data:
88125
data["components"] = {}
89126

90-
return data
127+
return self._transform_data(
128+
data, Path(self.source).parent if self.source else Path.cwd()
129+
)
130+
131+
def _transform_data(self, obj, source_path):
132+
if not isinstance(obj, dict):
133+
return obj
134+
135+
if "$ref" in obj:
136+
return self._handle_obj_ref(obj, source_path)
137+
138+
clone = {}
139+
140+
for key, value in obj.items():
141+
if isinstance(value, list):
142+
clone[key] = [self._transform_data(item, source_path) for item in value]
143+
elif isinstance(value, dict):
144+
clone[key] = self._handle_obj_ref(value, source_path)
145+
else:
146+
clone[key] = self._transform_data(value, source_path)
147+
148+
return clone
149+
150+
def _handle_obj_ref(self, obj, source_path):
151+
"""
152+
Handles a dictionary containing a $ref property, resolving the reference if it
153+
is to a file. This is used to read specification files when they are split into
154+
multiple items.
155+
"""
156+
assert isinstance(obj, dict)
157+
if "$ref" in obj:
158+
reference = obj["$ref"]
159+
if isinstance(reference, str) and not reference.startswith("#/"):
160+
referred_file = Path(os.path.abspath(source_path / reference))
161+
162+
if referred_file.exists():
163+
logger.debug("Handling $ref source: %s", reference)
164+
else:
165+
raise OpenAPIFileNotFoundError(reference, referred_file)
166+
sub_fragment = read_from_source(str(referred_file))
167+
return self._transform_data(sub_fragment, referred_file.parent)
168+
else:
169+
return obj
170+
return self._transform_data(obj, source_path)
91171

92172
def get_operations(self):
93173
"""
@@ -308,8 +388,10 @@ def get_parameters(self, operation) -> List[dict]:
308388
results = [
309389
param
310390
for param in sorted(
311-
parameters, key=lambda x: x["name"].lower() if "name" in x else ""
391+
parameters,
392+
key=lambda x: x["name"].lower() if (x and "name" in x) else "",
312393
)
394+
if param
313395
]
314396

315397
security_options = self.get_operation_security(operation)
@@ -331,9 +413,12 @@ def get_parameters(self, operation) -> List[dict]:
331413

332414
return results
333415

334-
def write(self, data) -> str:
416+
def write(self) -> str:
335417
return self._writer.write(
336-
data, operations=self.get_operations(), texts=self.texts, handler=self
418+
self.doc,
419+
operations=self.get_operations(),
420+
texts=self.texts,
421+
handler=self,
337422
)
338423

339424
def get_content_examples(self, data) -> Iterable[ContentExample]:
@@ -463,9 +548,17 @@ def expand_references(self, schema, context: Optional[ExpandContext] = None):
463548
else:
464549
context.expanded_refs.add(ref)
465550

466-
clone[key] = self.expand_references(
467-
self.resolve_reference(value), context
468-
)
551+
resolved_ref = self.resolve_reference(value)
552+
553+
if resolved_ref is None: # pragma: no cover
554+
logger.warning(
555+
"Cannot resolve the reference %s. "
556+
"Is a fragment missing from `components` object?",
557+
value,
558+
)
559+
clone[key] = {}
560+
else:
561+
clone[key] = self.expand_references(resolved_ref, context)
469562
elif isinstance(value, dict):
470563
if is_array_schema(value) and is_reference(value["items"]):
471564
ref = value["items"]["$ref"]

openapidocs/mk/v3/examples.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ def get_example(self, schema) -> Any:
9494
"""
9595
Returns an example value for a property with the given name and schema.
9696
"""
97-
properties = schema["properties"]
97+
properties = schema.get("properties") or {}
98+
9899
example = {}
99100

100101
for key in properties:
@@ -130,6 +131,9 @@ def get_example_from_schema(schema) -> Any:
130131
if schema is None:
131132
return None
132133

134+
if "example" in schema:
135+
return schema["example"]
136+
133137
# does it have a type?
134138
handlers_types: List[Type[SchemaExampleHandler]] = list(
135139
get_subclasses(SchemaExampleHandler)

openapidocs/mk/v3/views_markdown/layout.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,11 @@
3131
{% include "partial/components-security-schemes.html" %}
3232
{% endif -%}
3333
{% endif -%}
34+
35+
{%- if tags %}
36+
{% include "partial/tags.html" %}
37+
{% endif -%}
38+
39+
{%- if externalDocs %}
40+
{% include "partial/external-docs.html" %}
41+
{% endif -%}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
## {{texts.external_docs}}
2+
3+
{% if externalDocs.description -%}
4+
{{externalDocs.description | wordwrap(80)}}
5+
6+
---
7+
{% endif -%}
8+
9+
**{{texts.for_more_information}}:** [{{externalDocs.url}}]({{externalDocs.url}})

openapidocs/mk/v3/views_markdown/partial/schema-repr.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66

77
{%- if schema.type -%}
88
{%- with type_name = schema["type"], nullable = schema.get("nullable") -%}
9+
{%- if type_name == "object" -%}
10+
{%- if schema.example -%}
11+
_{{texts.example}}: _`{{schema.example}}`
12+
{%- elif schema.properties -%}
13+
_{{texts.properties}}: _`{{", ".join(schema.properties.keys())}}`
14+
{%- endif -%}
15+
{%- endif -%}
916
{# Scalar types #}
1017
{%- if type_name in scalar_types -%}
1118
{{type_name}}

0 commit comments

Comments
 (0)