From 6f2556a0ee2970790ff5bea0f9ea2d75b5381668 Mon Sep 17 00:00:00 2001 From: Derek Ditch Date: Thu, 26 Feb 2026 16:05:51 +0000 Subject: [PATCH 1/2] Add OAS 3.1 support, cross-version warnings, and fix nullable spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- openapidocs/common.py | 6 + openapidocs/mk/common.py | 22 +- openapidocs/mk/jinja.py | 53 ++ openapidocs/mk/v3/__init__.py | 116 ++- openapidocs/mk/v3/examples.py | 5 + .../partial/request-parameters.html | 2 +- .../views_markdown/partial/schema-repr.html | 6 +- .../mk/v3/views_markdown/partial/type.html | 2 +- .../partial/request-parameters.html | 4 +- .../v3/views_mkdocs/partial/schema-repr.html | 6 +- .../mk/v3/views_mkdocs/partial/type.html | 2 +- .../partial/schema-repr.html | 2 +- .../partial/schema-repr.html | 2 +- openapidocs/v3.py | 39 +- tests/res/example1-output-plain.md | 76 +- tests/res/example1-output.md | 76 +- tests/res/example4-split-output.md | 2 +- tests/res/example5-output.md | 6 +- tests/res/oas31-openapi.yaml | 113 +++ tests/test_oas31.py | 772 ++++++++++++++++++ 20 files changed, 1209 insertions(+), 103 deletions(-) create mode 100644 tests/res/oas31-openapi.yaml create mode 100644 tests/test_oas31.py diff --git a/openapidocs/common.py b/openapidocs/common.py index 6477b44..d7e6a95 100644 --- a/openapidocs/common.py +++ b/openapidocs/common.py @@ -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) @@ -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) diff --git a/openapidocs/mk/common.py b/openapidocs/mk/common.py index 8dbc18b..eca9560 100644 --- a/openapidocs/mk/common.py +++ b/openapidocs/mk/common.py @@ -33,17 +33,29 @@ 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: @@ -51,12 +63,14 @@ 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: diff --git a/openapidocs/mk/jinja.py b/openapidocs/mk/jinja.py index f31bfd8..91d0389 100644 --- a/openapidocs/mk/jinja.py +++ b/openapidocs/mk/jinja.py @@ -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} @@ -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) diff --git a/openapidocs/mk/v3/__init__.py b/openapidocs/mk/v3/__init__.py index a5250d0..d22edde 100644 --- a/openapidocs/mk/v3/__init__.py +++ b/openapidocs/mk/v3/__init__.py @@ -26,6 +26,20 @@ 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" @@ -106,11 +120,108 @@ 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. @@ -179,7 +290,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): diff --git a/openapidocs/mk/v3/examples.py b/openapidocs/mk/v3/examples.py index a5d8cf3..d3e0123 100644 --- a/openapidocs/mk/v3/examples.py +++ b/openapidocs/mk/v3/examples.py @@ -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 diff --git a/openapidocs/mk/v3/views_markdown/partial/request-parameters.html b/openapidocs/mk/v3/views_markdown/partial/request-parameters.html index 355340e..159ed5e 100644 --- a/openapidocs/mk/v3/views_markdown/partial/request-parameters.html +++ b/openapidocs/mk/v3/views_markdown/partial/request-parameters.html @@ -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 -%} diff --git a/openapidocs/mk/v3/views_markdown/partial/schema-repr.html b/openapidocs/mk/v3/views_markdown/partial/schema-repr.html index 4ba4f6b..a944584 100644 --- a/openapidocs/mk/v3/views_markdown/partial/schema-repr.html +++ b/openapidocs/mk/v3/views_markdown/partial/schema-repr.html @@ -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}}` @@ -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"] -%} diff --git a/openapidocs/mk/v3/views_markdown/partial/type.html b/openapidocs/mk/v3/views_markdown/partial/type.html index 9fd68e5..01b5432 100644 --- a/openapidocs/mk/v3/views_markdown/partial/type.html +++ b/openapidocs/mk/v3/views_markdown/partial/type.html @@ -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}} | diff --git a/openapidocs/mk/v3/views_mkdocs/partial/request-parameters.html b/openapidocs/mk/v3/views_mkdocs/partial/request-parameters.html index e8540dd..451beb6 100644 --- a/openapidocs/mk/v3/views_mkdocs/partial/request-parameters.html +++ b/openapidocs/mk/v3/views_mkdocs/partial/request-parameters.html @@ -17,9 +17,9 @@ {{param.name}} {{param.in}} - {{read_dict(param, "schema", "type")}} + {{get_type_display(read_dict(param, "schema", "type"))}} {{read_dict(param, "schema", "default", default="")}} - {{texts.get_yes_no(read_dict(param, "schema", "nullable", default=False))}} + {{texts.get_yes_no(is_nullable_schema(read_dict(param, "schema") or {}))}} {{read_dict(param, "description", default="")}} {%- endfor %} diff --git a/openapidocs/mk/v3/views_mkdocs/partial/schema-repr.html b/openapidocs/mk/v3/views_mkdocs/partial/schema-repr.html index a132eb5..d725545 100644 --- a/openapidocs/mk/v3/views_mkdocs/partial/schema-repr.html +++ b/openapidocs/mk/v3/views_mkdocs/partial/schema-repr.html @@ -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}} @@ -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"] -%} diff --git a/openapidocs/mk/v3/views_mkdocs/partial/type.html b/openapidocs/mk/v3/views_mkdocs/partial/type.html index b6ec3ac..3de5d60 100644 --- a/openapidocs/mk/v3/views_mkdocs/partial/type.html +++ b/openapidocs/mk/v3/views_mkdocs/partial/type.html @@ -1,4 +1,4 @@ -{% if definition.type == "object" %} +{% if get_primary_type(definition.type) == "object" %} {%- with props = handler.get_properties(definition) -%} {% if props %} diff --git a/openapidocs/mk/v3/views_plantuml_api/partial/schema-repr.html b/openapidocs/mk/v3/views_plantuml_api/partial/schema-repr.html index e87e8b2..5155422 100644 --- a/openapidocs/mk/v3/views_plantuml_api/partial/schema-repr.html +++ b/openapidocs/mk/v3/views_plantuml_api/partial/schema-repr.html @@ -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}} diff --git a/openapidocs/mk/v3/views_plantuml_schemas/partial/schema-repr.html b/openapidocs/mk/v3/views_plantuml_schemas/partial/schema-repr.html index e87e8b2..5155422 100644 --- a/openapidocs/mk/v3/views_plantuml_schemas/partial/schema-repr.html +++ b/openapidocs/mk/v3/views_plantuml_schemas/partial/schema-repr.html @@ -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}} diff --git a/openapidocs/v3.py b/openapidocs/v3.py index 673f898..df11746 100644 --- a/openapidocs/v3.py +++ b/openapidocs/v3.py @@ -350,8 +350,30 @@ class Schema(OpenAPIElement): A list of schemas where at least one must apply. one_of (list["Schema" | "Reference"] | None): A list of schemas where exactly one must apply. - not_ (list["Schema" | "Reference"] | None): + not_ (None | "Schema" | "Reference"): A schema that must not apply. + if_ (None | "Schema" | "Reference"): + If-then-else conditional schema (JSON Schema / OAS 3.1). + then_ (None | "Schema" | "Reference"): + Schema applied when if_ validates successfully. + else_ (None | "Schema" | "Reference"): + Schema applied when if_ fails to validate. + exclusive_maximum (float | None): + OAS 3.1 / JSON Schema: the exclusive upper bound for numeric values. + exclusive_minimum (float | None): + OAS 3.1 / JSON Schema: the exclusive lower bound for numeric values. + multiple_of (float | None): + The value must be a multiple of this number. + const (Any | None): + OAS 3.1 / JSON Schema: the value must be exactly this value. + prefix_items (list["Schema" | "Reference"] | None): + OAS 3.1 / JSON Schema: schemas for tuple validation of array items. + unevaluated_properties (None | bool | "Schema" | "Reference"): + OAS 3.1 / JSON Schema: controls handling of unevaluated properties. + unevaluated_items (None | bool | "Schema" | "Reference"): + OAS 3.1 / JSON Schema: controls handling of unevaluated array items. + defs (dict[str, "Schema" | "Reference"] | None): + OAS 3.1 / JSON Schema: locally-scoped schema definitions ($defs). """ type: None | str | ValueType | list[None | str | ValueType] = None @@ -376,15 +398,26 @@ class Schema(OpenAPIElement): unique_items: bool | None = None maximum: float | None = None minimum: float | None = None + exclusive_maximum: float | None = None + exclusive_minimum: float | None = None + multiple_of: float | None = None nullable: bool | None = None xml: XML | None = None items: "None | Schema | Reference" = None - enum: list[str] | None = None + prefix_items: list["Schema | Reference"] | None = None + enum: list[Any] | None = None + const: Any | None = None discriminator: Discriminator | None = None all_of: list["Schema | Reference"] | None = None any_of: list["Schema | Reference"] | None = None one_of: list["Schema | Reference"] | None = None - not_: list["Schema | Reference"] | None = None + not_: "None | Schema | Reference" = None + if_: "None | Schema | Reference" = None + then_: "None | Schema | Reference" = None + else_: "None | Schema | Reference" = None + unevaluated_properties: "None | bool | Schema | Reference" = None + unevaluated_items: "None | bool | Schema | Reference" = None + defs: dict[str, "Schema | Reference"] | None = None @dataclass diff --git a/tests/res/example1-output-plain.md b/tests/res/example1-output-plain.md index b239b18..ea2985a 100644 --- a/tests/res/example1-output-plain.md +++ b/tests/res/example1-output-plain.md @@ -2956,9 +2956,9 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | | creationTime | string(date-time) | -| description | string| null | +| description | string | null | | eTag | string | -| id | string| null | +| id | string | null | | name | string | | releases | Array<[Release](#release)> | | updateTime | string(date-time) | @@ -2969,8 +2969,8 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | -| countryCode | string| null | -| id | string| null | +| countryCode | string | null | +| id | string | null | | name | string | @@ -2989,8 +2989,8 @@ _Other possible types: text/json, text/plain_ | -- | -- | | categoryId | string | | countries | Array<string> | -| description | string| null | -| name | string| null | +| description | string | null | +| name | string | null | @@ -3017,12 +3017,12 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | -| categoryId | string| null | -| countryCode | string| null | -| countryName | string| null | +| categoryId | string | null | +| countryCode | string | null | +| countryName | string | null | | publishTime | string(date-time) | | releaseId | string(uuid) | -| releaseName | string| null | +| releaseName | string | null | @@ -3046,7 +3046,7 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | -| url | string| null | +| url | string | null | @@ -3064,7 +3064,7 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | | alive | boolean | -| regionName | string| null | +| regionName | string | null | | timestamp | string(date-time) | @@ -3073,9 +3073,9 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | -| fileName | string| null | +| fileName | string | null | | fileSize | integer(int32) | -| fileType | string| null | +| fileType | string | null | | releaseId | string(uuid) | @@ -3084,10 +3084,10 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | -| baseURL | string| null | -| fileId | string| null | -| fileName | string| null | -| token | string| null | +| baseURL | string | null | +| fileId | string | null | +| fileName | string | null | +| token | string | null | @@ -3095,9 +3095,9 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | -| buildNumber | string| null | -| contactEmail | string| null | -| version | string| null | +| buildNumber | string | null | +| contactEmail | string | null | +| version | string | null | @@ -3122,7 +3122,7 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | | id | string(uuid) | -| name | string| null | +| name | string | null | @@ -3131,7 +3131,7 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | | membership | Array<[ProfessionalMembership](#professionalmembership)> | -| signature | string| null | +| signature | string | null | @@ -3140,16 +3140,16 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | | brandNames | Array<string> | -| categoryId | string| null | +| categoryId | string | null | | id | string(uuid) | | marketCodes | Array<string> | | organizationBrands | Array<string(uuid)> | | organizationId | string(uuid) | | organizationMarkets | Array<string(uuid)> | -| organizationName | string| null | -| organizationNumber | string| null | -| role | string| null | -| scope | string| null | +| organizationName | string | null | +| organizationNumber | string | null | +| role | string | null | +| scope | string | null | @@ -3157,7 +3157,7 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | -| eTag | string| null | +| eTag | string | null | | sendEmailNotifications | boolean | @@ -3167,10 +3167,10 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | | category | [Category](#category) | -| categoryId | string| null | +| categoryId | string | null | | countries | Array<[ReleaseCountry](#releasecountry)> | | creationTime | string(date-time) | -| description | string| null | +| description | string | null | | draft | boolean | | eTag | string | | history | Array<[ReleaseHistory](#releasehistory)> | @@ -3188,7 +3188,7 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | | country | [Country](#country) | -| countryId | string| null | +| countryId | string | null | | release | [Release](#release) | | releaseId | string(uuid) | @@ -3198,7 +3198,7 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | -| data | string| null | +| data | string | null | | description | string | | id | string(uuid) | | release | [Release](#release) | @@ -3224,7 +3224,7 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | -| accessGrantedByOrganizationId | string(uuid)| null | +| accessGrantedByOrganizationId | string(uuid) | null | | id | string(uuid) | | node | [NodeInfo](#nodeinfo) | | nodeId | string(uuid) | @@ -3249,7 +3249,7 @@ _Other possible types: text/json, text/plain_ | Name | Type | | -- | -- | -| displayName | string| null | +| displayName | string | null | | organizationId | string(uuid) | | release | [Release](#release) | | releaseId | string(uuid) | @@ -3271,10 +3271,10 @@ _Other possible types: text/json, text/plain_ | -- | -- | | categoryId | string | | countries | Array<string> | -| description | string| null | -| eTag | string| null | +| description | string | null | +| eTag | string | null | | id | string(uuid) | -| name | string| null | +| name | string | null | diff --git a/tests/res/example1-output.md b/tests/res/example1-output.md index d012de0..e207e9b 100644 --- a/tests/res/example1-output.md +++ b/tests/res/example1-output.md @@ -3743,7 +3743,7 @@ Deletes a release by id. - + @@ -3751,7 +3751,7 @@ Deletes a release by id. - + @@ -3782,11 +3782,11 @@ Deletes a release by id. - + - + @@ -3836,11 +3836,11 @@ Deletes a release by id. - + - +
descriptionstring| nullstring | null
eTag
idstring| nullstring | null
name
countryCodestring| nullstring | null
idstring| nullstring | null
name
descriptionstring| nullstring | null
namestring| nullstring | null
@@ -3909,15 +3909,15 @@ Deletes a release by id. categoryId - string| null + string | null countryCode - string| null + string | null countryName - string| null + string | null publishTime @@ -3929,7 +3929,7 @@ Deletes a release by id. releaseName - string| null + string | null @@ -3986,7 +3986,7 @@ Deletes a release by id. url - string| null + string | null @@ -4032,7 +4032,7 @@ Deletes a release by id. regionName - string| null + string | null timestamp @@ -4055,7 +4055,7 @@ Deletes a release by id. fileName - string| null + string | null fileSize @@ -4063,7 +4063,7 @@ Deletes a release by id. fileType - string| null + string | null releaseId @@ -4086,19 +4086,19 @@ Deletes a release by id. baseURL - string| null + string | null fileId - string| null + string | null fileName - string| null + string | null token - string| null + string | null @@ -4117,15 +4117,15 @@ Deletes a release by id. buildNumber - string| null + string | null contactEmail - string| null + string | null version - string| null + string | null @@ -4199,7 +4199,7 @@ Deletes a release by id. name - string| null + string | null @@ -4222,7 +4222,7 @@ Deletes a release by id. signature - string| null + string | null @@ -4245,7 +4245,7 @@ Deletes a release by id. categoryId - string| null + string | null id @@ -4269,19 +4269,19 @@ Deletes a release by id. organizationName - string| null + string | null organizationNumber - string| null + string | null role - string| null + string | null scope - string| null + string | null @@ -4300,7 +4300,7 @@ Deletes a release by id. eTag - string| null + string | null sendEmailNotifications @@ -4327,7 +4327,7 @@ Deletes a release by id. categoryId - string| null + string | null countries @@ -4339,7 +4339,7 @@ Deletes a release by id. description - string| null + string | null draft @@ -4398,7 +4398,7 @@ Deletes a release by id. countryId - string| null + string | null release @@ -4425,7 +4425,7 @@ Deletes a release by id. data - string| null + string | null description @@ -4503,7 +4503,7 @@ Deletes a release by id. accessGrantedByOrganizationId - string(uuid)| null + string(uuid) | null id @@ -4577,7 +4577,7 @@ Deletes a release by id. displayName - string| null + string | null organizationId @@ -4639,11 +4639,11 @@ Deletes a release by id. description - string| null + string | null eTag - string| null + string | null id @@ -4651,7 +4651,7 @@ Deletes a release by id. name - string| null + string | null diff --git a/tests/res/example4-split-output.md b/tests/res/example4-split-output.md index 58ca19c..5ac657e 100644 --- a/tests/res/example4-split-output.md +++ b/tests/res/example4-split-output.md @@ -466,7 +466,7 @@ Delete user by ID/Email nextCursor - string| null + string | null diff --git a/tests/res/example5-output.md b/tests/res/example5-output.md index 183147e..f8b4eb9 100644 --- a/tests/res/example5-output.md +++ b/tests/res/example5-output.md @@ -141,7 +141,7 @@ Initializes a file upload operation. regionName - string| null + string | null timestamp @@ -164,7 +164,7 @@ Initializes a file upload operation. fileName - string| null + string | null fileSize @@ -172,7 +172,7 @@ Initializes a file upload operation. fileType - string| null + string | null releaseId diff --git a/tests/res/oas31-openapi.yaml b/tests/res/oas31-openapi.yaml new file mode 100644 index 0000000..b923a14 --- /dev/null +++ b/tests/res/oas31-openapi.yaml @@ -0,0 +1,113 @@ +openapi: 3.1.0 +info: + version: 1.0.0 + title: OAS 3.1 Test API + summary: API to exercise OpenAPI 3.1 features +servers: + - url: https://api.example.com/v1 + +paths: + /catalog: + get: + summary: List catalog items + operationId: listCatalog + tags: + - catalog + parameters: + - name: q + in: query + description: Filter query (nullable in OAS 3.1 style) + schema: + type: [string, "null"] + - name: limit + in: query + description: Max number of results + schema: + type: integer + format: int32 + default: 20 + - name: kind + in: query + description: Item kind (OAS 3.1 multi-type) + schema: + type: [string, integer, "null"] + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CatalogPage" + "400": + description: Bad request + + /catalog/{id}: + get: + summary: Get a catalog item + operationId: getCatalogItem + tags: + - catalog + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Item" + "404": + description: Not found + +components: + schemas: + Item: + type: [object, "null"] + properties: + id: + type: string + format: uuid + name: + type: [string, "null"] + score: + type: [number, "null"] + format: float + count: + type: [integer, "null"] + format: int32 + active: + type: [boolean, "null"] + tags: + type: [array, "null"] + items: + type: string + metadata: + type: object + properties: + created: + type: string + format: date-time + source: + type: [string, "null"] + required: + - id + - name + + CatalogPage: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/Item" + total: + type: integer + next_cursor: + type: [string, "null"] + required: + - items + - total diff --git a/tests/test_oas31.py b/tests/test_oas31.py new file mode 100644 index 0000000..caf0c79 --- /dev/null +++ b/tests/test_oas31.py @@ -0,0 +1,772 @@ +""" +Tests for OpenAPI 3.1.x compatibility. + +OAS 3.1 introduces several breaking changes compared to 3.0: + - `type` can be a list (e.g. ["string", "null"]) instead of only a string + - `nullable: true` is replaced by including "null" in the type list + - `paths` is optional (valid to have webhooks-only specs) + - New keywords: const, if/then/else, prefixItems, unevaluatedProperties, $defs + - exclusiveMinimum / exclusiveMaximum are now numeric values (not booleans) + - `not` takes a single schema (not a list) +""" + +import pytest + +from openapidocs.mk.common import is_array_schema, is_object_schema +from openapidocs.mk.jinja import get_primary_type, get_type_display, is_nullable_schema +from openapidocs.mk.v3 import OpenAPIV3DocumentationHandler +from openapidocs.mk.v3.examples import get_example_from_schema +from openapidocs.v3 import ( + Components, + Info, + OpenAPI, + Schema, + ValueType, +) +from tests.common import get_file_yaml, get_resource_file_path + + +# --------------------------------------------------------------------------- +# Helper function unit tests +# --------------------------------------------------------------------------- + + +class TestGetPrimaryType: + def test_string(self): + assert get_primary_type("string") == "string" + + def test_list_single_type(self): + assert get_primary_type(["string"]) == "string" + + def test_list_nullable(self): + assert get_primary_type(["string", "null"]) == "string" + + def test_list_nullable_null_first(self): + assert get_primary_type(["null", "integer"]) == "integer" + + def test_list_only_null(self): + assert get_primary_type(["null"]) == "null" + + def test_list_multi_type(self): + # returns the first non-null type + assert get_primary_type(["string", "integer"]) == "string" + + def test_none_returns_none(self): + assert get_primary_type(None) is None + + def test_empty_list_returns_none(self): + assert get_primary_type([]) is None + + +class TestIsNullableSchema: + def test_oas30_nullable_true(self): + assert is_nullable_schema({"type": "string", "nullable": True}) is True + + def test_oas31_null_in_list(self): + assert is_nullable_schema({"type": ["string", "null"]}) is True + + def test_not_nullable_string(self): + assert is_nullable_schema({"type": "string"}) is False + + def test_not_nullable_list(self): + assert is_nullable_schema({"type": ["string", "integer"]}) is False + + def test_non_dict_returns_false(self): + assert is_nullable_schema(None) is False + assert is_nullable_schema("string") is False + + +class TestGetTypeDisplay: + def test_string(self): + assert get_type_display("string") == "string" + + def test_list_nullable(self): + assert get_type_display(["string", "null"]) == "string | null" + + def test_list_multi_type(self): + assert get_type_display(["string", "integer"]) == "string | integer" + + def test_list_three_types(self): + assert get_type_display(["string", "integer", "null"]) == "string | integer | null" + + def test_none_returns_empty(self): + assert get_type_display(None) == "" + + def test_empty_list_returns_empty(self): + assert get_type_display([]) == "" + + +# --------------------------------------------------------------------------- +# is_object_schema / is_array_schema with OAS 3.1 list types +# --------------------------------------------------------------------------- + + +class TestIsObjectSchema: + def test_oas30_string_type(self): + assert is_object_schema({"type": "object", "properties": {"x": {}}}) is True + + def test_oas31_list_type(self): + assert is_object_schema( + {"type": ["object", "null"], "properties": {"x": {}}} + ) is True + + def test_no_properties(self): + assert is_object_schema({"type": ["object", "null"]}) is False + + def test_different_type(self): + assert is_object_schema({"type": ["string", "null"]}) is False + + +class TestIsArraySchema: + def test_oas30_string_type(self): + assert is_array_schema({"type": "array", "items": {"type": "string"}}) is True + + def test_oas31_list_type(self): + assert is_array_schema( + {"type": ["array", "null"], "items": {"type": "string"}} + ) is True + + def test_no_items(self): + assert is_array_schema({"type": ["array", "null"]}) is False + + def test_different_type(self): + assert is_array_schema({"type": ["string", "null"]}) is False + + +# --------------------------------------------------------------------------- +# get_example_from_schema with OAS 3.1 list types +# --------------------------------------------------------------------------- + + +class TestGetExampleFromSchemaOas31: + @pytest.mark.parametrize( + "schema, expected", + [ + ({"type": ["string", "null"]}, "string"), + ({"type": ["integer", "null"]}, 0), + ({"type": ["boolean", "null"]}, True), + ({"type": ["number", "null"]}, 10.12), + ( + {"type": ["array", "null"], "items": {"type": "string"}}, + ["string"], + ), + ( + { + "type": ["object", "null"], + "properties": {"x": {"type": "string"}}, + }, + {"x": "string"}, + ), + ], + ) + def test_list_type(self, schema, expected): + assert get_example_from_schema(schema) == expected + + def test_null_only_type(self): + assert get_example_from_schema({"type": ["null"]}) is None + + +# --------------------------------------------------------------------------- +# OpenAPIV3DocumentationHandler — OAS 3.1 rendering +# --------------------------------------------------------------------------- + + +class TestOas31DocumentationHandler: + def test_renders_without_crash_for_list_types(self): + """Handler must not crash when type is a list.""" + data = { + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": { + "/items": { + "get": { + "parameters": [ + { + "name": "filter", + "in": "query", + "schema": {"type": ["string", "null"]}, + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + } + }, + } + } + }, + "components": { + "schemas": { + "Item": { + "type": ["object", "null"], + "properties": { + "name": {"type": ["string", "null"]}, + "tags": { + "type": ["array", "null"], + "items": {"type": "string"}, + }, + }, + } + } + }, + } + handler = OpenAPIV3DocumentationHandler(data) + result = handler.write() + assert result is not None + + def test_nullable_parameter_shown_correctly(self): + """Type list with null → type column shows display type, nullable shows Yes.""" + data = { + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": { + "/search": { + "get": { + "parameters": [ + { + "name": "q", + "in": "query", + "schema": {"type": ["string", "null"]}, + } + ], + "responses": {"200": {"description": "OK"}}, + } + } + }, + } + handler = OpenAPIV3DocumentationHandler(data) + result = handler.write() + assert "string | null" in result + assert "Yes" in result # Nullable column + + def test_non_nullable_parameter_shown_correctly(self): + """Non-nullable type list → nullable shows No.""" + data = { + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": { + "/search": { + "get": { + "parameters": [ + { + "name": "q", + "in": "query", + "schema": {"type": "string"}, + } + ], + "responses": {"200": {"description": "OK"}}, + } + } + }, + } + handler = OpenAPIV3DocumentationHandler(data) + result = handler.write() + assert "No" in result # Nullable column + + def test_missing_paths_does_not_crash(self): + """paths is optional in OAS 3.1 — webhook-only specs must not crash.""" + data = { + "openapi": "3.1.0", + "info": {"title": "Webhooks Only", "version": "1.0.0"}, + # No "paths" key at all + } + handler = OpenAPIV3DocumentationHandler(data) + result = handler.write() + assert result is not None + + def test_webhooks_only_spec(self): + """A spec with webhooks but no paths must render without error.""" + data = { + "openapi": "3.1.0", + "info": {"title": "Webhook API", "version": "1.0.0"}, + "webhooks": { + "newOrder": { + "post": { + "responses": { + "200": { + "description": "Webhook received successfully" + } + } + } + } + }, + } + handler = OpenAPIV3DocumentationHandler(data) + result = handler.write() + assert result is not None + + def test_object_schema_with_list_type_renders_properties(self): + """type: ["object", "null"] schema must render its properties table.""" + data = { + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": {}, + "components": { + "schemas": { + "Pet": { + "type": ["object", "null"], + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + } + } + }, + } + handler = OpenAPIV3DocumentationHandler(data) + result = handler.write() + # Properties table must be rendered for an object schema + assert "id" in result + assert "name" in result + + def test_array_schema_with_list_type_generates_example(self): + """type: ["array", "null"] schema must produce a valid auto-generated example.""" + data = { + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": { + "/things": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": ["array", "null"], + "items": {"type": "string"}, + } + } + }, + } + } + } + } + }, + } + handler = OpenAPIV3DocumentationHandler(data) + example = handler.generate_example_from_schema( + {"type": ["array", "null"], "items": {"type": "string"}} + ) + assert example == ["string"] + + def test_full_oas31_yaml_file(self): + """Full OAS 3.1 YAML file with list types must render without error.""" + example_file_name = "oas31-openapi.yaml" + data = get_file_yaml(example_file_name) + handler = OpenAPIV3DocumentationHandler( + data, source=get_resource_file_path(example_file_name) + ) + result = handler.write() + assert result is not None + assert len(result) > 0 + + def test_full_oas31_yaml_nullable_params(self): + """Parameters with list types render display type and nullable correctly.""" + example_file_name = "oas31-openapi.yaml" + data = get_file_yaml(example_file_name) + handler = OpenAPIV3DocumentationHandler( + data, source=get_resource_file_path(example_file_name) + ) + result = handler.write() + # The `q` parameter has type: [string, "null"] + assert "string | null" in result + + def test_multi_type_parameter(self): + """type: [string, integer, null] renders all types joined by ' | '.""" + data = { + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": { + "/search": { + "get": { + "parameters": [ + { + "name": "id", + "in": "query", + "schema": {"type": ["string", "integer", "null"]}, + } + ], + "responses": {"200": {"description": "OK"}}, + } + } + }, + } + handler = OpenAPIV3DocumentationHandler(data) + result = handler.write() + assert "string | integer | null" in result + + +# --------------------------------------------------------------------------- +# Schema dataclass — OAS 3.1 fields serialisation +# --------------------------------------------------------------------------- + + +class TestSchemaOas31Fields: + def test_const_field(self): + from openapidocs.common import Serializer + + s = Serializer() + schema = Schema(const="fixed-value") + obj = s.to_obj(schema) + assert obj["const"] == "fixed-value" + + def test_exclusive_maximum_numeric(self): + from openapidocs.common import Serializer + + s = Serializer() + schema = Schema(type=ValueType.INTEGER, exclusive_maximum=100.0) + obj = s.to_obj(schema) + assert obj["exclusiveMaximum"] == 100.0 + + def test_exclusive_minimum_numeric(self): + from openapidocs.common import Serializer + + s = Serializer() + schema = Schema(type=ValueType.INTEGER, exclusive_minimum=0.0) + obj = s.to_obj(schema) + assert obj["exclusiveMinimum"] == 0.0 + + def test_defs_serialised_as_dollar_defs(self): + from openapidocs.common import Serializer + + s = Serializer() + schema = Schema( + defs={"inner": Schema(type=ValueType.STRING)} + ) + obj = s.to_obj(schema) + assert "$defs" in obj + assert "inner" in obj["$defs"] + + def test_if_then_else(self): + from openapidocs.common import Serializer + + s = Serializer() + schema = Schema( + if_=Schema(type=ValueType.STRING), + then_=Schema(type=ValueType.STRING, min_length=1), + else_=Schema(type=ValueType.INTEGER), + ) + obj = s.to_obj(schema) + assert "if" in obj + assert "then" in obj + assert "else" in obj + + def test_prefix_items(self): + from openapidocs.common import Serializer + + s = Serializer() + schema = Schema( + type=ValueType.ARRAY, + prefix_items=[Schema(type=ValueType.STRING), Schema(type=ValueType.INTEGER)], + ) + obj = s.to_obj(schema) + assert "prefixItems" in obj + assert len(obj["prefixItems"]) == 2 + + def test_not_is_single_schema(self): + from openapidocs.common import Serializer + + s = Serializer() + schema = Schema(not_=Schema(type=ValueType.STRING)) + obj = s.to_obj(schema) + assert "not" in obj + assert obj["not"]["type"] == "string" + + def test_type_list_serialised_as_list(self): + from openapidocs.common import Serializer + + s = Serializer() + schema = Schema(type=[ValueType.STRING, ValueType.NULL]) + obj = s.to_obj(schema) + assert obj["type"] == ["string", "null"] + + def test_openapi_root_with_oas31_schema(self): + from openapidocs.common import Serializer + + s = Serializer() + doc = OpenAPI( + info=Info(title="Test", version="1.0.0"), + components=Components( + schemas={ + "NullableString": Schema(type=[ValueType.STRING, ValueType.NULL]), + "ConstSchema": Schema(const=42), + } + ), + ) + obj = s.to_obj(doc) + schemas = obj["components"]["schemas"] + assert schemas["NullableString"]["type"] == ["string", "null"] + assert schemas["ConstSchema"]["const"] == 42 + + +# --------------------------------------------------------------------------- +# Version mismatch warnings — OAS 3.1 features in a 3.0.x document +# --------------------------------------------------------------------------- + + +def _base_doc(version="3.0.3"): + return { + "openapi": version, + "info": {"title": "Test", "version": "1.0"}, + "paths": {}, + } + + +class TestVersionMismatchWarning: + def test_no_warning_for_clean_30_doc(self): + """A 3.0 document with no 3.1 features must not emit a warning.""" + import warnings + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + OpenAPIV3DocumentationHandler(_base_doc("3.0.3")) + version_warnings = [ + w for w in caught if "OAS 3.1-specific" in str(w.message) + ] + assert version_warnings == [] + + def test_no_warning_for_31_doc_with_31_features(self): + """A 3.1 document using 3.1 features must not emit a warning.""" + doc = _base_doc("3.1.0") + doc["components"] = {"schemas": {"Foo": {"type": ["string", "null"]}}} + import warnings + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + OpenAPIV3DocumentationHandler(doc) + version_warnings = [ + w for w in caught if "OAS 3.1-specific" in str(w.message) + ] + assert version_warnings == [] + + def test_warning_for_list_type_in_30_doc(self): + """type as list in a 3.0 doc must trigger a warning.""" + doc = _base_doc("3.0.3") + doc["components"] = {"schemas": {"Foo": {"type": ["string", "null"]}}} + with pytest.warns(UserWarning, match="3.0.3"): + OpenAPIV3DocumentationHandler(doc) + + def test_warning_mentions_detected_feature(self): + """Warning message must name the detected 3.1 feature.""" + doc = _base_doc("3.0.3") + doc["components"] = {"schemas": {"Foo": {"type": ["string", "null"]}}} + with pytest.warns(UserWarning, match='type as list'): + OpenAPIV3DocumentationHandler(doc) + + def test_warning_for_const_in_30_doc(self): + doc = _base_doc("3.0.3") + doc["components"] = {"schemas": {"Foo": {"type": "string", "const": "fixed"}}} + with pytest.warns(UserWarning, match="const"): + OpenAPIV3DocumentationHandler(doc) + + def test_warning_for_defs_in_30_doc(self): + doc = _base_doc("3.0.3") + doc["components"] = { + "schemas": {"Foo": {"$defs": {"Bar": {"type": "string"}}}} + } + with pytest.warns(UserWarning, match=r"\$defs"): + OpenAPIV3DocumentationHandler(doc) + + def test_warning_for_webhooks_in_30_doc(self): + doc = _base_doc("3.0.3") + doc["webhooks"] = { + "newOrder": {"post": {"responses": {"200": {"description": "OK"}}}} + } + with pytest.warns(UserWarning, match="webhooks"): + OpenAPIV3DocumentationHandler(doc) + + def test_warning_for_if_then_else_in_30_doc(self): + doc = _base_doc("3.0.3") + doc["components"] = { + "schemas": { + "Conditional": { + "if": {"type": "string"}, + "then": {"minLength": 1}, + "else": {"type": "integer"}, + } + } + } + with pytest.warns(UserWarning, match="if"): + OpenAPIV3DocumentationHandler(doc) + + def test_warning_for_prefix_items_in_30_doc(self): + doc = _base_doc("3.0.3") + doc["components"] = { + "schemas": { + "Tuple": { + "type": "array", + "prefixItems": [{"type": "string"}, {"type": "integer"}], + } + } + } + with pytest.warns(UserWarning, match="prefixItems"): + OpenAPIV3DocumentationHandler(doc) + + def test_warning_for_unevaluated_properties_in_30_doc(self): + doc = _base_doc("3.0.3") + doc["components"] = { + "schemas": { + "Strict": { + "type": "object", + "properties": {"x": {"type": "string"}}, + "unevaluatedProperties": False, + } + } + } + with pytest.warns(UserWarning, match="unevaluatedProperties"): + OpenAPIV3DocumentationHandler(doc) + + def test_warning_for_exclusive_maximum_as_number_in_30_doc(self): + doc = _base_doc("3.0.3") + doc["components"] = { + "schemas": {"Score": {"type": "integer", "exclusiveMaximum": 100}} + } + with pytest.warns(UserWarning, match="exclusiveMaximum as number"): + OpenAPIV3DocumentationHandler(doc) + + def test_no_warning_for_exclusive_maximum_as_bool_in_30_doc(self): + """exclusiveMaximum: true is valid 3.0 syntax and must not trigger a warning.""" + doc = _base_doc("3.0.3") + doc["components"] = { + "schemas": {"Score": {"type": "integer", "exclusiveMaximum": True}} + } + import warnings + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + OpenAPIV3DocumentationHandler(doc) + version_warnings = [ + w for w in caught if "OAS 3.1-specific" in str(w.message) + ] + assert version_warnings == [] + + def test_warning_mentions_openapi_version(self): + """Warning message must include the declared version string.""" + doc = _base_doc("3.0.1") + doc["components"] = {"schemas": {"Foo": {"type": ["string", "null"]}}} + with pytest.warns(UserWarning, match="3.0.1"): + OpenAPIV3DocumentationHandler(doc) + + def test_warning_suggests_upgrade(self): + """Warning message must suggest upgrading to 3.1.0.""" + doc = _base_doc("3.0.3") + doc["components"] = {"schemas": {"Foo": {"type": ["string", "null"]}}} + with pytest.warns(UserWarning, match="3.1.0"): + OpenAPIV3DocumentationHandler(doc) + + +# --------------------------------------------------------------------------- +# OAS 3.0 features in a 3.1 document +# --------------------------------------------------------------------------- + + +class TestOas30FeaturesIn31Doc: + def test_no_warning_for_clean_31_doc(self): + """A 3.1 document with no 3.0 features must not emit an OAS-3.0 warning.""" + import warnings + + doc = _base_doc("3.1.0") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + OpenAPIV3DocumentationHandler(doc) + version_warnings = [ + w for w in caught if "OAS 3.0-specific" in str(w.message) + ] + assert version_warnings == [] + + def test_no_warning_for_30_doc(self): + """A 3.0 document must not trigger the OAS-3.0-in-3.1 warning.""" + import warnings + + doc = _base_doc("3.0.3") + doc["components"] = {"schemas": {"Foo": {"type": "string", "nullable": True}}} + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + OpenAPIV3DocumentationHandler(doc) + oas30_in_31 = [ + w for w in caught if "OAS 3.0-specific" in str(w.message) + ] + assert oas30_in_31 == [] + + def test_warning_for_nullable_true_in_31_doc(self): + """`nullable: true` is a 3.0 pattern and must warn in a 3.1 doc.""" + doc = _base_doc("3.1.0") + doc["components"] = {"schemas": {"Foo": {"type": "string", "nullable": True}}} + with pytest.warns(UserWarning, match="nullable: true"): + OpenAPIV3DocumentationHandler(doc) + + def test_warning_mentions_31_version(self): + """Warning message must include the declared 3.1.x version.""" + doc = _base_doc("3.1.0") + doc["components"] = {"schemas": {"Foo": {"type": "string", "nullable": True}}} + with pytest.warns(UserWarning, match="3.1.0"): + OpenAPIV3DocumentationHandler(doc) + + def test_warning_for_boolean_exclusive_maximum_in_31_doc(self): + """`exclusiveMaximum: true` (boolean) is a 3.0 pattern and must warn in 3.1.""" + doc = _base_doc("3.1.0") + doc["components"] = { + "schemas": {"Score": {"type": "integer", "maximum": 100, "exclusiveMaximum": True}} + } + with pytest.warns(UserWarning, match="exclusiveMaximum: true/false"): + OpenAPIV3DocumentationHandler(doc) + + def test_warning_for_boolean_exclusive_minimum_in_31_doc(self): + """`exclusiveMinimum: true` (boolean) is a 3.0 pattern and must warn in 3.1.""" + doc = _base_doc("3.1.0") + doc["components"] = { + "schemas": {"Score": {"type": "integer", "minimum": 0, "exclusiveMinimum": True}} + } + with pytest.warns(UserWarning, match="exclusiveMinimum: true/false"): + OpenAPIV3DocumentationHandler(doc) + + def test_no_warning_for_numeric_exclusive_maximum_in_31_doc(self): + """`exclusiveMaximum: 100` (numeric) is valid 3.1 and must not warn.""" + import warnings + + doc = _base_doc("3.1.0") + doc["components"] = { + "schemas": {"Score": {"type": "integer", "exclusiveMaximum": 100}} + } + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + OpenAPIV3DocumentationHandler(doc) + oas30_in_31 = [ + w for w in caught if "OAS 3.0-specific" in str(w.message) + ] + assert oas30_in_31 == [] + + def test_warning_detects_nullable_nested_in_path(self): + """`nullable: true` nested inside a path operation schema must warn.""" + doc = _base_doc("3.1.0") + doc["paths"] = { + "/items": { + "get": { + "parameters": [ + { + "name": "q", + "in": "query", + "schema": {"type": "string", "nullable": True}, + } + ], + "responses": {"200": {"description": "OK"}}, + } + } + } + with pytest.warns(UserWarning, match="nullable: true"): + OpenAPIV3DocumentationHandler(doc) + + def test_warning_mentions_not_valid_in_31(self): + """Warning message must state the feature is not valid in OAS 3.1.""" + doc = _base_doc("3.1.0") + doc["components"] = {"schemas": {"Foo": {"type": "string", "nullable": True}}} + with pytest.warns(UserWarning, match="not valid in OAS 3.1"): + OpenAPIV3DocumentationHandler(doc) From 60bab98644734bbb86a474b33b8279af6b88e618 Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Sun, 1 Mar 2026 09:46:33 +0100 Subject: [PATCH 2/2] Apply black and isort formatting --- openapidocs/mk/v3/__init__.py | 7 +-- tests/test_oas31.py | 82 ++++++++++++++--------------------- 2 files changed, 37 insertions(+), 52 deletions(-) diff --git a/openapidocs/mk/v3/__init__.py b/openapidocs/mk/v3/__init__.py index d22edde..8a02c5c 100644 --- a/openapidocs/mk/v3/__init__.py +++ b/openapidocs/mk/v3/__init__.py @@ -25,7 +25,6 @@ from openapidocs.mk.v3.examples import get_example_from_schema from openapidocs.utils.source import read_from_source - _OAS31_KEYWORDS = frozenset( { "const", @@ -142,8 +141,10 @@ def _collect_31_features(self, obj: object, found: set) -> None: for kw in ("exclusiveMinimum", "exclusiveMaximum"): val = obj.get(kw) - if val is not None and isinstance(val, (int, float)) and not isinstance( - val, bool + if ( + val is not None + and isinstance(val, (int, float)) + and not isinstance(val, bool) ): found.add(f"{kw} as number") diff --git a/tests/test_oas31.py b/tests/test_oas31.py index caf0c79..965353d 100644 --- a/tests/test_oas31.py +++ b/tests/test_oas31.py @@ -16,16 +16,9 @@ from openapidocs.mk.jinja import get_primary_type, get_type_display, is_nullable_schema from openapidocs.mk.v3 import OpenAPIV3DocumentationHandler from openapidocs.mk.v3.examples import get_example_from_schema -from openapidocs.v3 import ( - Components, - Info, - OpenAPI, - Schema, - ValueType, -) +from openapidocs.v3 import Components, Info, OpenAPI, Schema, ValueType from tests.common import get_file_yaml, get_resource_file_path - # --------------------------------------------------------------------------- # Helper function unit tests # --------------------------------------------------------------------------- @@ -87,7 +80,9 @@ def test_list_multi_type(self): assert get_type_display(["string", "integer"]) == "string | integer" def test_list_three_types(self): - assert get_type_display(["string", "integer", "null"]) == "string | integer | null" + assert ( + get_type_display(["string", "integer", "null"]) == "string | integer | null" + ) def test_none_returns_empty(self): assert get_type_display(None) == "" @@ -106,9 +101,10 @@ def test_oas30_string_type(self): assert is_object_schema({"type": "object", "properties": {"x": {}}}) is True def test_oas31_list_type(self): - assert is_object_schema( - {"type": ["object", "null"], "properties": {"x": {}}} - ) is True + assert ( + is_object_schema({"type": ["object", "null"], "properties": {"x": {}}}) + is True + ) def test_no_properties(self): assert is_object_schema({"type": ["object", "null"]}) is False @@ -122,9 +118,10 @@ def test_oas30_string_type(self): assert is_array_schema({"type": "array", "items": {"type": "string"}}) is True def test_oas31_list_type(self): - assert is_array_schema( - {"type": ["array", "null"], "items": {"type": "string"}} - ) is True + assert ( + is_array_schema({"type": ["array", "null"], "items": {"type": "string"}}) + is True + ) def test_no_items(self): assert is_array_schema({"type": ["array", "null"]}) is False @@ -192,9 +189,7 @@ def test_renders_without_crash_for_list_types(self): "description": "OK", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/Item" - } + "schema": {"$ref": "#/components/schemas/Item"} } }, } @@ -290,9 +285,7 @@ def test_webhooks_only_spec(self): "newOrder": { "post": { "responses": { - "200": { - "description": "Webhook received successfully" - } + "200": {"description": "Webhook received successfully"} } } } @@ -438,9 +431,7 @@ def test_defs_serialised_as_dollar_defs(self): from openapidocs.common import Serializer s = Serializer() - schema = Schema( - defs={"inner": Schema(type=ValueType.STRING)} - ) + schema = Schema(defs={"inner": Schema(type=ValueType.STRING)}) obj = s.to_obj(schema) assert "$defs" in obj assert "inner" in obj["$defs"] @@ -465,7 +456,10 @@ def test_prefix_items(self): s = Serializer() schema = Schema( type=ValueType.ARRAY, - prefix_items=[Schema(type=ValueType.STRING), Schema(type=ValueType.INTEGER)], + prefix_items=[ + Schema(type=ValueType.STRING), + Schema(type=ValueType.INTEGER), + ], ) obj = s.to_obj(schema) assert "prefixItems" in obj @@ -528,9 +522,7 @@ def test_no_warning_for_clean_30_doc(self): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") OpenAPIV3DocumentationHandler(_base_doc("3.0.3")) - version_warnings = [ - w for w in caught if "OAS 3.1-specific" in str(w.message) - ] + version_warnings = [w for w in caught if "OAS 3.1-specific" in str(w.message)] assert version_warnings == [] def test_no_warning_for_31_doc_with_31_features(self): @@ -542,9 +534,7 @@ def test_no_warning_for_31_doc_with_31_features(self): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") OpenAPIV3DocumentationHandler(doc) - version_warnings = [ - w for w in caught if "OAS 3.1-specific" in str(w.message) - ] + version_warnings = [w for w in caught if "OAS 3.1-specific" in str(w.message)] assert version_warnings == [] def test_warning_for_list_type_in_30_doc(self): @@ -558,7 +548,7 @@ def test_warning_mentions_detected_feature(self): """Warning message must name the detected 3.1 feature.""" doc = _base_doc("3.0.3") doc["components"] = {"schemas": {"Foo": {"type": ["string", "null"]}}} - with pytest.warns(UserWarning, match='type as list'): + with pytest.warns(UserWarning, match="type as list"): OpenAPIV3DocumentationHandler(doc) def test_warning_for_const_in_30_doc(self): @@ -569,9 +559,7 @@ def test_warning_for_const_in_30_doc(self): def test_warning_for_defs_in_30_doc(self): doc = _base_doc("3.0.3") - doc["components"] = { - "schemas": {"Foo": {"$defs": {"Bar": {"type": "string"}}}} - } + doc["components"] = {"schemas": {"Foo": {"$defs": {"Bar": {"type": "string"}}}}} with pytest.warns(UserWarning, match=r"\$defs"): OpenAPIV3DocumentationHandler(doc) @@ -643,9 +631,7 @@ def test_no_warning_for_exclusive_maximum_as_bool_in_30_doc(self): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") OpenAPIV3DocumentationHandler(doc) - version_warnings = [ - w for w in caught if "OAS 3.1-specific" in str(w.message) - ] + version_warnings = [w for w in caught if "OAS 3.1-specific" in str(w.message)] assert version_warnings == [] def test_warning_mentions_openapi_version(self): @@ -677,9 +663,7 @@ def test_no_warning_for_clean_31_doc(self): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") OpenAPIV3DocumentationHandler(doc) - version_warnings = [ - w for w in caught if "OAS 3.0-specific" in str(w.message) - ] + version_warnings = [w for w in caught if "OAS 3.0-specific" in str(w.message)] assert version_warnings == [] def test_no_warning_for_30_doc(self): @@ -691,9 +675,7 @@ def test_no_warning_for_30_doc(self): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") OpenAPIV3DocumentationHandler(doc) - oas30_in_31 = [ - w for w in caught if "OAS 3.0-specific" in str(w.message) - ] + oas30_in_31 = [w for w in caught if "OAS 3.0-specific" in str(w.message)] assert oas30_in_31 == [] def test_warning_for_nullable_true_in_31_doc(self): @@ -714,7 +696,9 @@ def test_warning_for_boolean_exclusive_maximum_in_31_doc(self): """`exclusiveMaximum: true` (boolean) is a 3.0 pattern and must warn in 3.1.""" doc = _base_doc("3.1.0") doc["components"] = { - "schemas": {"Score": {"type": "integer", "maximum": 100, "exclusiveMaximum": True}} + "schemas": { + "Score": {"type": "integer", "maximum": 100, "exclusiveMaximum": True} + } } with pytest.warns(UserWarning, match="exclusiveMaximum: true/false"): OpenAPIV3DocumentationHandler(doc) @@ -723,7 +707,9 @@ def test_warning_for_boolean_exclusive_minimum_in_31_doc(self): """`exclusiveMinimum: true` (boolean) is a 3.0 pattern and must warn in 3.1.""" doc = _base_doc("3.1.0") doc["components"] = { - "schemas": {"Score": {"type": "integer", "minimum": 0, "exclusiveMinimum": True}} + "schemas": { + "Score": {"type": "integer", "minimum": 0, "exclusiveMinimum": True} + } } with pytest.warns(UserWarning, match="exclusiveMinimum: true/false"): OpenAPIV3DocumentationHandler(doc) @@ -739,9 +725,7 @@ def test_no_warning_for_numeric_exclusive_maximum_in_31_doc(self): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") OpenAPIV3DocumentationHandler(doc) - oas30_in_31 = [ - w for w in caught if "OAS 3.0-specific" in str(w.message) - ] + oas30_in_31 = [w for w in caught if "OAS 3.0-specific" in str(w.message)] assert oas30_in_31 == [] def test_warning_detects_nullable_nested_in_path(self):