Skip to content

Commit 02864e4

Browse files
Add OAS 3.1 support, cross-version warnings, and fix nullable spacing (#63)
* Add OAS 3.1 support, cross-version warnings, and fix nullable spacing Handle OAS 3.1 breaking changes: type can now be a list (e.g. `["string", "null"]`), `paths` is optional, and new keywords (`const`, `if/then/else`, `prefixItems`, `$defs`, etc.) are supported throughout templates and the `Schema` dataclass. Emit a `UserWarning` when a 3.0.x document uses 3.1-specific features (`list` types, `$defs`, `webhooks`, numeric exclusive bounds, etc.), or when a 3.1 document uses 3.0-specific patterns (`nullable: true`, `boolean` exclusive bounds) that are invalid in 3.1. Fix the missing space before the pipe character in nullable schema output (e.g. `string| null` → `string | null`), resolving issue #46 (I was touching these files anyway). Fixes: #62, #46 * Apply black and isort formatting --------- Co-authored-by: Roberto Prevato <roberto.prevato@gmail.com>
1 parent 1a8047e commit 02864e4

File tree

20 files changed

+1194
-103
lines changed

20 files changed

+1194
-103
lines changed

openapidocs/common.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ def normalize_dict_factory(items: list[tuple[Any, Any]]) -> dict[str, Any]:
8282
data["$ref"] = value
8383
continue
8484

85+
if key == "defs":
86+
data["$defs"] = value
87+
continue
88+
8589
for handler in TYPES_HANDLERS:
8690
value = handler.normalize(value)
8791

@@ -129,6 +133,8 @@ def _asdict_inner(obj: Any, dict_factory: Callable[[Any], Any]) -> Any:
129133
for k, v in obj.items()
130134
)
131135
else:
136+
for handler in TYPES_HANDLERS:
137+
obj = handler.normalize(obj)
132138
return copy.deepcopy(obj)
133139

134140

openapidocs/mk/common.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,30 +33,44 @@ def is_reference(data: object) -> bool:
3333
return "$ref" in data
3434

3535

36+
def _type_matches(type_val: Any, expected: str) -> bool:
37+
"""
38+
Returns True if type_val equals expected (OAS 3.0 string) or contains expected
39+
(OAS 3.1 list).
40+
"""
41+
if isinstance(type_val, list):
42+
return expected in type_val
43+
return type_val == expected
44+
45+
3646
def is_object_schema(data: object) -> bool:
3747
"""
3848
Returns a value indicating whether the given schema dictionary represents
3949
an object schema.
4050
41-
is_reference({"type": "array", "items": {...}}) -> True
51+
Supports both OAS 3.0 (type: "object") and OAS 3.1 (type: ["object", ...]).
4252
"""
4353
if not isinstance(data, dict):
4454
return False
4555
data = cast(dict[str, object], data)
46-
return data.get("type") == "object" and isinstance(data.get("properties"), dict)
56+
return _type_matches(data.get("type"), "object") and isinstance(
57+
data.get("properties"), dict
58+
)
4759

4860

4961
def is_array_schema(data: object) -> bool:
5062
"""
5163
Returns a value indicating whether the given schema dictionary represents
5264
an array schema.
5365
54-
is_reference({"type": "array", "items": {...}}) -> True
66+
Supports both OAS 3.0 (type: "array") and OAS 3.1 (type: ["array", ...]).
5567
"""
5668
if not isinstance(data, dict):
5769
return False
5870
data = cast(dict[str, object], data)
59-
return data.get("type") == "array" and isinstance(data.get("items"), dict)
71+
return _type_matches(data.get("type"), "array") and isinstance(
72+
data.get("items"), dict
73+
)
6074

6175

6276
def get_ref_type_name(reference: dict[str, str] | str) -> str:

openapidocs/mk/jinja.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,56 @@
2121
from .md import normalize_link, write_table
2222

2323

24+
def get_primary_type(type_val):
25+
"""
26+
Returns the primary (first non-null) type from a schema type value.
27+
28+
Handles both OAS 3.0 (string) and OAS 3.1 (list) type representations:
29+
- "string" → "string"
30+
- ["string", "null"] → "string"
31+
- ["null"] → "null"
32+
- ["string", "integer"] → "string"
33+
"""
34+
if not type_val:
35+
return None
36+
if isinstance(type_val, list):
37+
non_null = [t for t in type_val if t != "null"]
38+
return non_null[0] if non_null else "null"
39+
return type_val
40+
41+
42+
def is_nullable_schema(schema) -> bool:
43+
"""
44+
Returns True if the given schema is nullable.
45+
46+
Handles both OAS 3.0 (nullable: true) and OAS 3.1 (type: [..., "null"]) patterns.
47+
"""
48+
if not isinstance(schema, dict):
49+
return False
50+
if schema.get("nullable"):
51+
return True
52+
type_val = schema.get("type")
53+
if isinstance(type_val, list):
54+
return "null" in type_val
55+
return False
56+
57+
58+
def get_type_display(type_val) -> str:
59+
"""
60+
Returns a display string for a schema type value.
61+
62+
Handles both OAS 3.0 (string) and OAS 3.1 (list) type representations:
63+
- "string" → "string"
64+
- ["string", "null"] → "string | null"
65+
- ["string", "integer"] → "string | integer"
66+
"""
67+
if not type_val:
68+
return ""
69+
if isinstance(type_val, list):
70+
return " | ".join(str(t) for t in type_val)
71+
return str(type_val)
72+
73+
2474
def configure_filters(env: Environment):
2575
env.filters.update(
2676
{"route": highlight_params, "table": write_table, "link": normalize_link}
@@ -35,6 +85,9 @@ def configure_functions(env: Environment):
3585
"scalar_types": {"string", "integer", "boolean", "number"},
3686
"get_http_status_phrase": get_http_status_phrase,
3787
"write_md_table": write_table,
88+
"get_primary_type": get_primary_type,
89+
"is_nullable_schema": is_nullable_schema,
90+
"get_type_display": get_type_display,
3891
}
3992

4093
env.globals.update(helpers)

openapidocs/mk/v3/__init__.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@
2525
from openapidocs.mk.v3.examples import get_example_from_schema
2626
from openapidocs.utils.source import read_from_source
2727

28+
_OAS31_KEYWORDS = frozenset(
29+
{
30+
"const",
31+
"if",
32+
"then",
33+
"else",
34+
"prefixItems",
35+
"unevaluatedProperties",
36+
"unevaluatedItems",
37+
"$defs",
38+
}
39+
)
40+
2841

2942
def _can_simplify_json(content_type) -> bool:
3043
return "json" in content_type or content_type == "text/plain"
@@ -106,11 +119,110 @@ def __init__(
106119
custom_templates_path=templates_path,
107120
)
108121
self.doc = self.normalize_data(copy.deepcopy(doc))
122+
self._warn_31_features_in_30_doc()
123+
self._warn_30_features_in_31_doc()
109124

110125
@property
111126
def source(self) -> str:
112127
return self._source
113128

129+
def _collect_31_features(self, obj: object, found: set) -> None:
130+
"""Recursively scans obj for OAS 3.1-specific features, collecting them in found."""
131+
if not isinstance(obj, dict):
132+
return
133+
134+
type_val = obj.get("type")
135+
if isinstance(type_val, list):
136+
found.add('type as list (e.g. ["string", "null"])')
137+
138+
for kw in _OAS31_KEYWORDS:
139+
if kw in obj:
140+
found.add(kw)
141+
142+
for kw in ("exclusiveMinimum", "exclusiveMaximum"):
143+
val = obj.get(kw)
144+
if (
145+
val is not None
146+
and isinstance(val, (int, float))
147+
and not isinstance(val, bool)
148+
):
149+
found.add(f"{kw} as number")
150+
151+
for value in obj.values():
152+
if isinstance(value, dict):
153+
self._collect_31_features(value, found)
154+
elif isinstance(value, list):
155+
for item in value:
156+
self._collect_31_features(item, found)
157+
158+
def _warn_31_features_in_30_doc(self) -> None:
159+
"""
160+
Emits a warning if OAS 3.1-specific features are detected in a document
161+
that declares an OAS 3.0.x version.
162+
"""
163+
version = self.doc.get("openapi", "")
164+
if not (isinstance(version, str) and version.startswith("3.0")):
165+
return
166+
167+
found: set = set()
168+
169+
if "webhooks" in self.doc:
170+
found.add("webhooks")
171+
172+
self._collect_31_features(self.doc, found)
173+
174+
if found:
175+
feature_list = ", ".join(sorted(found))
176+
warnings.warn(
177+
f"OpenAPI document declares version {version!r} but uses "
178+
f"OAS 3.1-specific features: {feature_list}. "
179+
"Consider updating the `openapi` field to '3.1.0'.",
180+
stacklevel=3,
181+
)
182+
183+
def _collect_30_features(self, obj: object, found: set) -> None:
184+
"""Recursively scans obj for OAS 3.0-specific features, collecting them in found."""
185+
if not isinstance(obj, dict):
186+
return
187+
188+
# nullable: true is OAS 3.0 only — replaced by type: [..., "null"] in 3.1
189+
if obj.get("nullable") is True:
190+
found.add("nullable: true")
191+
192+
# boolean exclusiveMinimum/exclusiveMaximum are 3.0 semantics;
193+
# in 3.1 they are numeric bounds
194+
for kw in ("exclusiveMinimum", "exclusiveMaximum"):
195+
if isinstance(obj.get(kw), bool):
196+
found.add(f"{kw}: true/false (boolean)")
197+
198+
for value in obj.values():
199+
if isinstance(value, dict):
200+
self._collect_30_features(value, found)
201+
elif isinstance(value, list):
202+
for item in value:
203+
self._collect_30_features(item, found)
204+
205+
def _warn_30_features_in_31_doc(self) -> None:
206+
"""
207+
Emits a warning if OAS 3.0-specific features are detected in a document
208+
that declares an OAS 3.1.x version.
209+
"""
210+
version = self.doc.get("openapi", "")
211+
if not (isinstance(version, str) and version.startswith("3.1")):
212+
return
213+
214+
found: set = set()
215+
self._collect_30_features(self.doc, found)
216+
217+
if found:
218+
feature_list = ", ".join(sorted(found))
219+
warnings.warn(
220+
f"OpenAPI document declares version {version!r} but uses "
221+
f"OAS 3.0-specific features: {feature_list}. "
222+
"These features are not valid in OAS 3.1 and may be ignored by tooling.",
223+
stacklevel=3,
224+
)
225+
114226
def normalize_data(self, data):
115227
"""
116228
Applies corrections to the OpenAPI specification, to simplify its handling.
@@ -179,7 +291,10 @@ def get_operations(self):
179291
"""
180292
data = self.doc
181293
groups = defaultdict(list)
182-
paths = data["paths"]
294+
paths = data.get("paths") # paths is optional in OAS 3.1
295+
296+
if not paths:
297+
return groups
183298

184299
for path, path_item in paths.items():
185300
if not isinstance(path_item, dict):

openapidocs/mk/v3/examples.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@ def get_example_from_schema(schema) -> Any:
145145

146146
schema_type = schema.get("type")
147147

148+
# OAS 3.1: type can be a list (e.g. ["string", "null"]). Use the first non-null type.
149+
if isinstance(schema_type, list):
150+
non_null = [t for t in schema_type if t != "null"]
151+
schema_type = non_null[0] if non_null else None
152+
148153
if schema_type:
149154
handler_type = next(
150155
(_type for _type in handlers_types if _type.type_name == schema_type), None

openapidocs/mk/v3/views_markdown/partial/request-parameters.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
{% with rows = [[texts.parameter, texts.parameter_location, texts.type, texts.default, texts.nullable, texts.description]] %}
55
{%- for param in parameters -%}
6-
{%- 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="")]) -%}
6+
{%- 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="")]) -%}
77
{%- endfor -%}
88
{{ rows | table }}
99
{%- endwith -%}

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
{%- endif -%}
66

77
{%- if schema.type -%}
8-
{%- with type_name = schema["type"], nullable = schema.get("nullable") -%}
8+
{%- with type_name = get_primary_type(schema["type"]), nullable = is_nullable_schema(schema) -%}
99
{%- if type_name == "object" -%}
1010
{%- if schema.example -%}
1111
_{{texts.example}}: _`{{schema.example}}`
@@ -19,9 +19,7 @@
1919
{%- if schema.format -%}
2020
({{schema.format}})
2121
{%- endif -%}
22-
{%- if nullable -%}
23-
&#124; null
24-
{%- endif -%}
22+
{%- if nullable %} &#124; null{%- endif -%}
2523
{%- endif -%}
2624
{%- if type_name == "array" -%}
2725
{%- with schema = schema["items"] -%}

openapidocs/mk/v3/views_markdown/partial/type.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{% if definition.type == "object" %}
1+
{% if get_primary_type(definition.type) == "object" %}
22
{%- with props = handler.get_properties(definition) -%}
33
{% if props %}
44
| {{texts.name}} | {{texts.type}} |

openapidocs/mk/v3/views_mkdocs/partial/request-parameters.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
<tr>
1818
<td class="parameter-name"><code>{{param.name}}</code></td>
1919
<td>{{param.in}}</td>
20-
<td>{{read_dict(param, "schema", "type")}}</td>
20+
<td>{{get_type_display(read_dict(param, "schema", "type"))}}</td>
2121
<td>{{read_dict(param, "schema", "default", default="")}}</td>
22-
<td>{{texts.get_yes_no(read_dict(param, "schema", "nullable", default=False))}}</td>
22+
<td>{{texts.get_yes_no(is_nullable_schema(read_dict(param, "schema") or {}))}}</td>
2323
<td>{{read_dict(param, "description", default="")}}</td>
2424
</tr>
2525
{%- endfor %}

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
{%- endif -%}
66

77
{%- if schema.type -%}
8-
{%- with type_name = schema["type"], nullable = schema.get("nullable") -%}
8+
{%- with type_name = get_primary_type(schema["type"]), nullable = is_nullable_schema(schema) -%}
99
{%- if type_name == "object" -%}
1010
{%- if schema.example -%}
1111
<em>{{texts.example}}: </em><code>{{schema.example}}</code>
@@ -19,9 +19,7 @@
1919
{%- if schema.format -%}
2020
(<span class="{{schema.format}}-format format">{{schema.format}}</span>)
2121
{%- endif -%}
22-
{%- if nullable -%}
23-
&#124; <span class="null-type">null</span>
24-
{%- endif -%}
22+
{%- if nullable %} &#124; <span class="null-type">null</span>{%- endif -%}
2523
{%- endif -%}
2624
{%- if type_name == "array" -%}
2725
{%- with schema = schema["items"] -%}

0 commit comments

Comments
 (0)