Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions openapidocs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ def normalize_dict_factory(items: list[tuple[Any, Any]]) -> dict[str, Any]:
data["$ref"] = value
continue

if key == "defs":
data["$defs"] = value
continue

for handler in TYPES_HANDLERS:
value = handler.normalize(value)

Expand Down Expand Up @@ -129,6 +133,8 @@ def _asdict_inner(obj: Any, dict_factory: Callable[[Any], Any]) -> Any:
for k, v in obj.items()
)
else:
for handler in TYPES_HANDLERS:
obj = handler.normalize(obj)
return copy.deepcopy(obj)


Expand Down
22 changes: 18 additions & 4 deletions openapidocs/mk/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,30 +33,44 @@ def is_reference(data: object) -> bool:
return "$ref" in data


def _type_matches(type_val: Any, expected: str) -> bool:
"""
Returns True if type_val equals expected (OAS 3.0 string) or contains expected
(OAS 3.1 list).
"""
if isinstance(type_val, list):
return expected in type_val
return type_val == expected


def is_object_schema(data: object) -> bool:
"""
Returns a value indicating whether the given schema dictionary represents
an object schema.

is_reference({"type": "array", "items": {...}}) -> True
Supports both OAS 3.0 (type: "object") and OAS 3.1 (type: ["object", ...]).
"""
if not isinstance(data, dict):
return False
data = cast(dict[str, object], data)
return data.get("type") == "object" and isinstance(data.get("properties"), dict)
return _type_matches(data.get("type"), "object") and isinstance(
data.get("properties"), dict
)


def is_array_schema(data: object) -> bool:
"""
Returns a value indicating whether the given schema dictionary represents
an array schema.

is_reference({"type": "array", "items": {...}}) -> True
Supports both OAS 3.0 (type: "array") and OAS 3.1 (type: ["array", ...]).
"""
if not isinstance(data, dict):
return False
data = cast(dict[str, object], data)
return data.get("type") == "array" and isinstance(data.get("items"), dict)
return _type_matches(data.get("type"), "array") and isinstance(
data.get("items"), dict
)


def get_ref_type_name(reference: dict[str, str] | str) -> str:
Expand Down
53 changes: 53 additions & 0 deletions openapidocs/mk/jinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,56 @@
from .md import normalize_link, write_table


def get_primary_type(type_val):
"""
Returns the primary (first non-null) type from a schema type value.

Handles both OAS 3.0 (string) and OAS 3.1 (list) type representations:
- "string" → "string"
- ["string", "null"] → "string"
- ["null"] → "null"
- ["string", "integer"] → "string"
"""
if not type_val:
return None
if isinstance(type_val, list):
non_null = [t for t in type_val if t != "null"]
return non_null[0] if non_null else "null"
return type_val


def is_nullable_schema(schema) -> bool:
"""
Returns True if the given schema is nullable.

Handles both OAS 3.0 (nullable: true) and OAS 3.1 (type: [..., "null"]) patterns.
"""
if not isinstance(schema, dict):
return False
if schema.get("nullable"):
return True
type_val = schema.get("type")
if isinstance(type_val, list):
return "null" in type_val
return False


def get_type_display(type_val) -> str:
"""
Returns a display string for a schema type value.

Handles both OAS 3.0 (string) and OAS 3.1 (list) type representations:
- "string" → "string"
- ["string", "null"] → "string | null"
- ["string", "integer"] → "string | integer"
"""
if not type_val:
return ""
if isinstance(type_val, list):
return " | ".join(str(t) for t in type_val)
return str(type_val)


def configure_filters(env: Environment):
env.filters.update(
{"route": highlight_params, "table": write_table, "link": normalize_link}
Expand All @@ -35,6 +85,9 @@ def configure_functions(env: Environment):
"scalar_types": {"string", "integer", "boolean", "number"},
"get_http_status_phrase": get_http_status_phrase,
"write_md_table": write_table,
"get_primary_type": get_primary_type,
"is_nullable_schema": is_nullable_schema,
"get_type_display": get_type_display,
}

env.globals.update(helpers)
Expand Down
117 changes: 116 additions & 1 deletion openapidocs/mk/v3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@
from openapidocs.mk.v3.examples import get_example_from_schema
from openapidocs.utils.source import read_from_source

_OAS31_KEYWORDS = frozenset(
{
"const",
"if",
"then",
"else",
"prefixItems",
"unevaluatedProperties",
"unevaluatedItems",
"$defs",
}
)


def _can_simplify_json(content_type) -> bool:
return "json" in content_type or content_type == "text/plain"
Expand Down Expand Up @@ -106,11 +119,110 @@ def __init__(
custom_templates_path=templates_path,
)
self.doc = self.normalize_data(copy.deepcopy(doc))
self._warn_31_features_in_30_doc()
self._warn_30_features_in_31_doc()

@property
def source(self) -> str:
return self._source

def _collect_31_features(self, obj: object, found: set) -> None:
"""Recursively scans obj for OAS 3.1-specific features, collecting them in found."""
if not isinstance(obj, dict):
return

type_val = obj.get("type")
if isinstance(type_val, list):
found.add('type as list (e.g. ["string", "null"])')

for kw in _OAS31_KEYWORDS:
if kw in obj:
found.add(kw)

for kw in ("exclusiveMinimum", "exclusiveMaximum"):
val = obj.get(kw)
if (
val is not None
and isinstance(val, (int, float))
and not isinstance(val, bool)
):
found.add(f"{kw} as number")

for value in obj.values():
if isinstance(value, dict):
self._collect_31_features(value, found)
elif isinstance(value, list):
for item in value:
self._collect_31_features(item, found)

def _warn_31_features_in_30_doc(self) -> None:
"""
Emits a warning if OAS 3.1-specific features are detected in a document
that declares an OAS 3.0.x version.
"""
version = self.doc.get("openapi", "")
if not (isinstance(version, str) and version.startswith("3.0")):
return

found: set = set()

if "webhooks" in self.doc:
found.add("webhooks")

self._collect_31_features(self.doc, found)

if found:
feature_list = ", ".join(sorted(found))
warnings.warn(
f"OpenAPI document declares version {version!r} but uses "
f"OAS 3.1-specific features: {feature_list}. "
"Consider updating the `openapi` field to '3.1.0'.",
stacklevel=3,
)

def _collect_30_features(self, obj: object, found: set) -> None:
"""Recursively scans obj for OAS 3.0-specific features, collecting them in found."""
if not isinstance(obj, dict):
return

# nullable: true is OAS 3.0 only — replaced by type: [..., "null"] in 3.1
if obj.get("nullable") is True:
found.add("nullable: true")

# boolean exclusiveMinimum/exclusiveMaximum are 3.0 semantics;
# in 3.1 they are numeric bounds
for kw in ("exclusiveMinimum", "exclusiveMaximum"):
if isinstance(obj.get(kw), bool):
found.add(f"{kw}: true/false (boolean)")

for value in obj.values():
if isinstance(value, dict):
self._collect_30_features(value, found)
elif isinstance(value, list):
for item in value:
self._collect_30_features(item, found)

def _warn_30_features_in_31_doc(self) -> None:
"""
Emits a warning if OAS 3.0-specific features are detected in a document
that declares an OAS 3.1.x version.
"""
version = self.doc.get("openapi", "")
if not (isinstance(version, str) and version.startswith("3.1")):
return

found: set = set()
self._collect_30_features(self.doc, found)

if found:
feature_list = ", ".join(sorted(found))
warnings.warn(
f"OpenAPI document declares version {version!r} but uses "
f"OAS 3.0-specific features: {feature_list}. "
"These features are not valid in OAS 3.1 and may be ignored by tooling.",
stacklevel=3,
)

def normalize_data(self, data):
"""
Applies corrections to the OpenAPI specification, to simplify its handling.
Expand Down Expand Up @@ -179,7 +291,10 @@ def get_operations(self):
"""
data = self.doc
groups = defaultdict(list)
paths = data["paths"]
paths = data.get("paths") # paths is optional in OAS 3.1

if not paths:
return groups

for path, path_item in paths.items():
if not isinstance(path_item, dict):
Expand Down
5 changes: 5 additions & 0 deletions openapidocs/mk/v3/examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ def get_example_from_schema(schema) -> Any:

schema_type = schema.get("type")

# OAS 3.1: type can be a list (e.g. ["string", "null"]). Use the first non-null type.
if isinstance(schema_type, list):
non_null = [t for t in schema_type if t != "null"]
schema_type = non_null[0] if non_null else None

if schema_type:
handler_type = next(
(_type for _type in handlers_types if _type.type_name == schema_type), None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

{% with rows = [[texts.parameter, texts.parameter_location, texts.type, texts.default, texts.nullable, texts.description]] %}
{%- for param in parameters -%}
{%- set _ = rows.append([param.name, param.in, read_dict(param, "schema", "type"), read_dict(param, "schema", "default", default=""), texts.get_yes_no(read_dict(param, "schema", "nullable", default=False)), read_dict(param, "description", default="")]) -%}
{%- set _ = rows.append([param.name, param.in, get_type_display(read_dict(param, "schema", "type")), read_dict(param, "schema", "default", default=""), texts.get_yes_no(is_nullable_schema(read_dict(param, "schema") or {})), read_dict(param, "description", default="")]) -%}
{%- endfor -%}
{{ rows | table }}
{%- endwith -%}
6 changes: 2 additions & 4 deletions openapidocs/mk/v3/views_markdown/partial/schema-repr.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
{%- endif -%}

{%- if schema.type -%}
{%- with type_name = schema["type"], nullable = schema.get("nullable") -%}
{%- with type_name = get_primary_type(schema["type"]), nullable = is_nullable_schema(schema) -%}
{%- if type_name == "object" -%}
{%- if schema.example -%}
_{{texts.example}}: _`{{schema.example}}`
Expand All @@ -19,9 +19,7 @@
{%- if schema.format -%}
({{schema.format}})
{%- endif -%}
{%- if nullable -%}
| null
{%- endif -%}
{%- if nullable %} | null{%- endif -%}
{%- endif -%}
{%- if type_name == "array" -%}
{%- with schema = schema["items"] -%}
Expand Down
2 changes: 1 addition & 1 deletion openapidocs/mk/v3/views_markdown/partial/type.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% if definition.type == "object" %}
{% if get_primary_type(definition.type) == "object" %}
{%- with props = handler.get_properties(definition) -%}
{% if props %}
| {{texts.name}} | {{texts.type}} |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
<tr>
<td class="parameter-name"><code>{{param.name}}</code></td>
<td>{{param.in}}</td>
<td>{{read_dict(param, "schema", "type")}}</td>
<td>{{get_type_display(read_dict(param, "schema", "type"))}}</td>
<td>{{read_dict(param, "schema", "default", default="")}}</td>
<td>{{texts.get_yes_no(read_dict(param, "schema", "nullable", default=False))}}</td>
<td>{{texts.get_yes_no(is_nullable_schema(read_dict(param, "schema") or {}))}}</td>
<td>{{read_dict(param, "description", default="")}}</td>
</tr>
{%- endfor %}
Expand Down
6 changes: 2 additions & 4 deletions openapidocs/mk/v3/views_mkdocs/partial/schema-repr.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
{%- endif -%}

{%- if schema.type -%}
{%- with type_name = schema["type"], nullable = schema.get("nullable") -%}
{%- with type_name = get_primary_type(schema["type"]), nullable = is_nullable_schema(schema) -%}
{%- if type_name == "object" -%}
{%- if schema.example -%}
<em>{{texts.example}}: </em><code>{{schema.example}}</code>
Expand All @@ -19,9 +19,7 @@
{%- if schema.format -%}
(<span class="{{schema.format}}-format format">{{schema.format}}</span>)
{%- endif -%}
{%- if nullable -%}
&#124; <span class="null-type">null</span>
{%- endif -%}
{%- if nullable %} &#124; <span class="null-type">null</span>{%- endif -%}
{%- endif -%}
{%- if type_name == "array" -%}
{%- with schema = schema["items"] -%}
Expand Down
2 changes: 1 addition & 1 deletion openapidocs/mk/v3/views_mkdocs/partial/type.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% if definition.type == "object" %}
{% if get_primary_type(definition.type) == "object" %}
{%- with props = handler.get_properties(definition) -%}
{% if props %}
<table>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
{%- endif -%}

{%- if schema.type -%}
{%- with type_name = schema["type"], nullable = schema.get("nullable") -%}
{%- with type_name = get_primary_type(schema["type"]), nullable = is_nullable_schema(schema) -%}
{# Scalar types #}
{%- if type_name in scalar_types -%}
{{type_name}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
{%- endif -%}

{%- if schema.type -%}
{%- with type_name = schema["type"], nullable = schema.get("nullable") -%}
{%- with type_name = get_primary_type(schema["type"]), nullable = is_nullable_schema(schema) -%}
{# Scalar types #}
{%- if type_name in scalar_types -%}
{{type_name}}
Expand Down
Loading