From 8f5b273c4dabb6b6eee4a1847fd217faa6773879 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Fri, 9 Oct 2026 23:36:03 +0100 Subject: [PATCH 01/15] Handle integer status codes Signed-off-by: Stephen Finucane --- sphinxcontrib/openapi/_lib2to3.py | 4 ++-- tests/lib2to3/test_convert_responses.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/sphinxcontrib/openapi/_lib2to3.py b/sphinxcontrib/openapi/_lib2to3.py index 273470f..978fd73 100644 --- a/sphinxcontrib/openapi/_lib2to3.py +++ b/sphinxcontrib/openapi/_lib2to3.py @@ -19,7 +19,7 @@ def convert(spec): def _is_vendor_extension(key): """Return 'True' if a given key is a vendor extension.""" - return key.startswith("x-") + return str(key).startswith("x-") def _get_properties(node, properties, *, vendor_extensions=False): @@ -308,7 +308,7 @@ def convert_responses(self, responses): converted = _get_properties(responses, {}, vendor_extensions=True) for status_code, response in _items_wo_vendor_extensions(responses): - converted[status_code] = self.convert_response(response) + converted[str(status_code)] = self.convert_response(response) return converted diff --git a/tests/lib2to3/test_convert_responses.py b/tests/lib2to3/test_convert_responses.py index 2630ac9..f758179 100644 --- a/tests/lib2to3/test_convert_responses.py +++ b/tests/lib2to3/test_convert_responses.py @@ -43,6 +43,20 @@ def test_minimal(convert_responses, oas_fragment): """) +def test_integer_status_code(convert_responses, oas_fragment): + converted = convert_responses( + oas_fragment(""" + 200: + description: a response description + """), + produces=["application/json"], + ) + assert converted == oas_fragment(""" + '200': + description: a response description + """) + + def test_complete(convert_responses, oas_fragment): converted = convert_responses( oas_fragment(""" From 5cc7b4fc7a9f18b04b964a09c508708865d725ad Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 10:53:27 +0100 Subject: [PATCH 02/15] schema_utils: Fix example generation for arrays with oneOf/anyOf items Handle oneOf and anyOf in array items safely when elements are dicts, sets, or primitive types. This builds upon the partial fix in commit f038bf177879. Signed-off-by: Stephen Finucane --- sphinxcontrib/openapi/schema_utils.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/sphinxcontrib/openapi/schema_utils.py b/sphinxcontrib/openapi/schema_utils.py index dab2985..58c7fed 100644 --- a/sphinxcontrib/openapi/schema_utils.py +++ b/sphinxcontrib/openapi/schema_utils.py @@ -141,7 +141,32 @@ def example_from_schema(schema): example_items.extend(_DEFAULT_EXAMPLES.values()) elif isinstance(items, dict) and "oneOf" in items: # Mixed-type arrays - example_items.append(_DEFAULT_EXAMPLES[sorted(items["oneOf"])[0]]) + one_of = items["oneOf"] + if isinstance(one_of, (list, set)): + try: + first = sorted(one_of)[0] + except TypeError: + first = list(one_of)[0] + else: + first = one_of + if isinstance(first, str) and first in _DEFAULT_EXAMPLES: + example_items.append(_DEFAULT_EXAMPLES[first]) + else: + example_items.append(example_from_schema(first)) + elif isinstance(items, dict) and "anyOf" in items: + # Mixed-type arrays + any_of = items["anyOf"] + if isinstance(any_of, (list, set)): + try: + first = sorted(any_of)[0] + except TypeError: + first = list(any_of)[0] + else: + first = any_of + if isinstance(first, str) and first in _DEFAULT_EXAMPLES: + example_items.append(_DEFAULT_EXAMPLES[first]) + else: + example_items.append(example_from_schema(first)) else: example_items.append(example_from_schema(items)) From 80ba1a6f323586cd36c25d1b2037be2c71aebd2e Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 10:54:56 +0100 Subject: [PATCH 03/15] Fix programmatic option handling and renderer edge cases - Normalize programmatic option values while retaining the directive converters and empty-value validation already provided by master. - Filter operations by _HTTP_METHODS so path-level vendor extensions and other non-operation keys are ignored. - Copy schema_type list in _get_markers_from_object to prevent mutating cached specs in-place. - Safely handle operations without a 'responses' key. - Pass default=str to json.dumps() when formatting examples to handle datetime/date objects parsed from YAML. - Preserve OpenAPI response code case, including the special 'default' value, and add a regression test for selecting its example. Signed-off-by: Stephen Finucane Assisted-by: OpenAI GPT-6.1 (via pi) --- .../openapi/renderers/_httpdomain.py | 73 ++++++++++++------- .../httpdomain/test_render_parameters.py | 25 +++++++ .../renderers/httpdomain/test_render_paths.py | 21 ++++++ .../test_render_response_example.py | 25 +++++++ .../httpdomain/test_render_responses.py | 30 ++++++++ 5 files changed, 148 insertions(+), 26 deletions(-) diff --git a/sphinxcontrib/openapi/renderers/_httpdomain.py b/sphinxcontrib/openapi/renderers/_httpdomain.py index bc5d72d..85862aa 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -135,7 +135,7 @@ def _get_markers_from_object(oas_object, schema): elif schema.get("enum"): schema_type = f"{schema_type}:enum" if isinstance(schema_type, list): - markers = schema_type + markers = list(schema_type) else: markers.append(schema_type) elif schema.get("enum"): @@ -197,6 +197,17 @@ def _get_schema_type(schema): ).merge +_HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"} + + +def _to_list(val): + if val is None: + return None + if isinstance(val, str): + return val.split() + return list(val) + + class HttpdomainRenderer(abc.RestructuredTextRenderer): """Render OpenAPI v3 using `sphinxcontrib-httpdomain` extension.""" @@ -225,24 +236,33 @@ def __init__(self, state, options): self._convert_markup = self._markup_converters[ options.get("markup", "commonmark") ] - self._http_methods_order = [ - http_method.lower() for http_method in options.get("http-methods-order", []) - ] - self._response_examples_for = options.get( - "response-examples-for", self._response_examples_for - ) - self._request_parameters_order = [ - parameter_type.lower() - for parameter_type in options.get( - "request-parameters-order", self._request_parameters_order - ) - ] - self._example_preference = options.get("example-preference") - self._request_example_preference = options.get( - "request-example-preference", self._example_preference + methods_order = _to_list(options.get("http-methods-order")) or [] + self._http_methods_order = [m.lower() for m in methods_order] + + response_examples_for = _to_list(options.get("response-examples-for")) + if response_examples_for is not None: + # OpenAPI permits numeric status codes, uppercase ranges 1XX–5XX, + # and the lowercase special value "default". Preserve their case. + self._response_examples_for = {str(x) for x in response_examples_for} + else: + self._response_examples_for = {"200", "201", "202", "2XX"} + + req_params_order = _to_list(options.get("request-parameters-order")) + if req_params_order is not None: + self._request_parameters_order = [ + parameter_type.lower() for parameter_type in req_params_order + ] + else: + self._request_parameters_order = ["header", "path", "query", "cookie"] + + self._example_preference = _to_list(options.get("example-preference")) + self._request_example_preference = ( + _to_list(options.get("request-example-preference")) + or self._example_preference ) - self._response_example_preference = options.get( - "response-example-preference", self._example_preference + self._response_example_preference = ( + _to_list(options.get("response-example-preference")) + or self._example_preference ) self._generate_example_from_schema = "generate-examples-from-schemas" in options self._json_schema_description = "no-json-schema-description" not in options @@ -269,6 +289,8 @@ def render_paths(self, paths): path.pop(key, None) for method in _iterinorder(path, self._http_methods_order): + if method.lower() not in _HTTP_METHODS: + continue operation = path[method] operation.setdefault("parameters", []) operation_parameters_ids = set( @@ -309,7 +331,7 @@ def render_operation(self, endpoint, method, operation): yield from indented( self.render_request_body(operation["requestBody"], endpoint, method) ) - yield from indented(self.render_responses(operation["responses"])) + yield from indented(self.render_responses(operation.get("responses", {}))) def render_parameters(self, parameters): """Render OAS operation's parameters.""" @@ -385,7 +407,7 @@ def render_request_body_example(self, request_body, endpoint, method): example = example["value"] if not isinstance(example, str): - example = json.dumps(example, indent=2) + example = json.dumps(example, indent=2, default=str) yield ".. sourcecode:: http" yield "" @@ -480,7 +502,7 @@ def render_response_example(self, media_type, status_code): example = example["value"] if not isinstance(example, str): - example = json.dumps(example, indent=2) + example = json.dumps(example, indent=2, default=str) # According to OpenAPI v3 spec, status code may be a special value # - "default". It's not quite clear what to render in this case. @@ -493,11 +515,10 @@ def render_response_example(self, media_type, status_code): status_code = "000" status_text = "Reason-Phrase" else: - # According to OpenAPI v3 spec, status code may define a range - # of response codes. Since we're talking about rendered example - # here, we may show either code from range, but for the sake of - # simplicity let's pick the first one. - status_code = status_code.replace("XX", "00") + # OpenAPI permits numeric status codes and uppercase ranges + # 1XX–5XX here; the special value "default" is handled above. + # For a range, show its first code in the rendered example. + status_code = str(status_code).replace("XX", "00") status_text = http.client.responses.get(int(status_code), "-") yield ".. sourcecode:: http" diff --git a/tests/renderers/httpdomain/test_render_parameters.py b/tests/renderers/httpdomain/test_render_parameters.py index 79701fe..9559710 100644 --- a/tests/renderers/httpdomain/test_render_parameters.py +++ b/tests/renderers/httpdomain/test_render_parameters.py @@ -377,3 +377,28 @@ def test_render_parameters_case_insensitive(fakestate, oas_fragment): A desired Content-Type of HTTP response. :reqheadertype Accept: string """.rstrip()) + + +def test_render_parameters_custom_order_string(fakestate, oas_fragment): + """Parameters order passed as a whitespace-delimited string (docutils directive style).""" + + testrenderer = renderers.HttpdomainRenderer( + fakestate, {"request-parameters-order": "query path"} + ) + + markup = textify(testrenderer.render_parameters(oas_fragment(""" + - name: kind + in: path + schema: + type: string + - name: details + in: query + schema: + type: boolean + """))) + assert markup == textwrap.dedent("""\ + :queryparam details: + :queryparamtype details: boolean + :param kind: + :paramtype kind: string + """.rstrip()) diff --git a/tests/renderers/httpdomain/test_render_paths.py b/tests/renderers/httpdomain/test_render_paths.py index c4f727d..9f50a09 100644 --- a/tests/renderers/httpdomain/test_render_paths.py +++ b/tests/renderers/httpdomain/test_render_paths.py @@ -386,3 +386,24 @@ def test_render_paths_methods_order_insensitive(fakestate, oas_fragment): :statuscode 201: An evidence created. """) + + +def test_render_paths_vendor_extensions_ignored(testrenderer, oas_fragment): + """Vendor extensions on path items are not treated as operations.""" + + markup = textify(testrenderer.render_paths(oas_fragment(""" + /resource: + x-codegen-name: customName + x-amazon-apigateway-any-method: + some: config + get: + responses: + '200': + description: ok + """))) + assert markup == textwrap.dedent("""\ + .. http:get:: /resource + + :statuscode 200: + ok + """) diff --git a/tests/renderers/httpdomain/test_render_response_example.py b/tests/renderers/httpdomain/test_render_response_example.py index 86cad42..0533641 100644 --- a/tests/renderers/httpdomain/test_render_response_example.py +++ b/tests/renderers/httpdomain/test_render_response_example.py @@ -588,3 +588,28 @@ def test_render_response_status_code_default(testrenderer, oas_fragment): foo,baz bar,42 """.rstrip()) + + +def test_render_response_example_with_date_object(testrenderer, oas_fragment): + """Path response's example handles date objects parsed from YAML.""" + + markup = textify( + testrenderer.render_response_example( + oas_fragment(""" + application/json: + example: + date: 2024-01-15 + """), + "200", + ) + ) + assert markup == textwrap.dedent("""\ + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "date": "2024-01-15" + } + """.rstrip()) diff --git a/tests/renderers/httpdomain/test_render_responses.py b/tests/renderers/httpdomain/test_render_responses.py index 6b4460f..bf16403 100644 --- a/tests/renderers/httpdomain/test_render_responses.py +++ b/tests/renderers/httpdomain/test_render_responses.py @@ -44,6 +44,36 @@ def test_render_responses_one_item_status_code_int(testrenderer, oas_fragment): """.rstrip()) +def test_render_responses_default_example(fakestate, oas_fragment): + """The 'default' response can be selected for example rendering.""" + + testrenderer = renderers.HttpdomainRenderer( + fakestate, {"response-examples-for": "default"} + ) + + markup = textify(testrenderer.render_responses(oas_fragment(""" + default: + description: A fallback response. + content: + application/json: + example: + result: error + """))) + assert markup == textwrap.dedent("""\ + :statuscode default: + A fallback response. + + .. sourcecode:: http + + HTTP/1.1 000 Reason-Phrase + Content-Type: application/json + + { + "result": "error" + } + """.rstrip()) + + def test_render_responses_many_items(testrenderer, oas_fragment): """Many response definitions are rendered.""" From d11e1c954be0bfcef301ad66a58623b453fea09c Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 10:56:01 +0100 Subject: [PATCH 04/15] Add backwards-compat options (format, request, examples) - Support 'format' option, translating 'markdown' to 'commonmark' and 'rst'/'restructuredtext' to 'restructuredtext'. - Accept legacy 'request' and 'examples' flags in option_spec so existing directives do not fail with docutils warnings. Signed-off-by: Stephen Finucane Assisted-by: OpenAI GPT-6.1 (via pi) --- .../openapi/renderers/_httpdomain.py | 33 +++++++++++++++---- .../httpdomain/test_render_operation.py | 11 +++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/sphinxcontrib/openapi/renderers/_httpdomain.py b/sphinxcontrib/openapi/renderers/_httpdomain.py index 85862aa..4fb0e94 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -208,6 +208,17 @@ def _to_list(val): return list(val) +def _flag_or_bool(argument): + if argument is None: + return True + val = str(argument).strip().lower() + if val in ("true", "yes", "1"): + return True + if val in ("false", "no", "0"): + return False + raise ValueError(f"Invalid boolean value: {argument}") + + class HttpdomainRenderer(abc.RestructuredTextRenderer): """Render OpenAPI v3 using `sphinxcontrib-httpdomain` extension.""" @@ -220,22 +231,30 @@ class HttpdomainRenderer(abc.RestructuredTextRenderer): option_spec = { "markup": functools.partial(directives.choice, values=_markup_converters), + "format": str, "http-methods-order": _split_option, "response-examples-for": _split_option, "request-parameters-order": _split_option, "example-preference": _split_option, "request-example-preference": _split_option, "response-example-preference": _split_option, - "generate-examples-from-schemas": directives.flag, - "no-json-schema-description": directives.flag, + "generate-examples-from-schemas": _flag_or_bool, + "no-json-schema-description": _flag_or_bool, + "request": _flag_or_bool, + "examples": _flag_or_bool, } def __init__(self, state, options): super().__init__(state, options) - self._convert_markup = self._markup_converters[ - options.get("markup", "commonmark") - ] + markup = options.get("markup") + if markup is None and "format" in options: + fmt = str(options["format"]).strip().lower() + if fmt == "markdown": + markup = "commonmark" + elif fmt in ("rst", "restructuredtext"): + markup = "restructuredtext" + self._convert_markup = self._markup_converters[markup or "commonmark"] methods_order = _to_list(options.get("http-methods-order")) or [] self._http_methods_order = [m.lower() for m in methods_order] @@ -264,7 +283,9 @@ def __init__(self, state, options): _to_list(options.get("response-example-preference")) or self._example_preference ) - self._generate_example_from_schema = "generate-examples-from-schemas" in options + self._generate_example_from_schema = bool( + options.get("generate-examples-from-schemas") + ) self._json_schema_description = "no-json-schema-description" not in options def render_restructuredtext_markup(self, spec): diff --git a/tests/renderers/httpdomain/test_render_operation.py b/tests/renderers/httpdomain/test_render_operation.py index e95093f..9687aea 100644 --- a/tests/renderers/httpdomain/test_render_operation.py +++ b/tests/renderers/httpdomain/test_render_operation.py @@ -396,3 +396,14 @@ def test_render_operation_caseinsensitive_method(testrenderer, method, oas_fragm :statuscode 201: An evidence created. """.rstrip()) + + +def test_render_operation_format_compatibility(fakestate): + """'format: markdown' is translated to 'commonmark' and 'rst' to 'restructuredtext'.""" + import sphinx_mdinclude + + r_md = renderers.HttpdomainRenderer(fakestate, {"format": "markdown"}) + assert r_md._convert_markup == sphinx_mdinclude.convert + + r_rst = renderers.HttpdomainRenderer(fakestate, {"format": "rst"}) + assert r_rst._convert_markup("plain") == "plain" From 9c5b4caf96fa8934b525bd118d6cb52ac9b797f6 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 10:56:52 +0100 Subject: [PATCH 05/15] Add endpoint filtering options (paths, include, exclude, methods) - Support filtering paths using an explicit list of paths ('paths'). - Support filtering paths using include/exclude regex patterns ('include', 'exclude'). - Support filtering operations by HTTP methods ('methods'). - Copy operation definitions with deepcopy to avoid mutating cached specs. Signed-off-by: Stephen Finucane Assisted-by: OpenAI GPT-6.1 (via pi) --- .../openapi/renderers/_httpdomain.py | 53 +++++++++--- tests/renderers/httpdomain/test_options.py | 4 + .../renderers/httpdomain/test_render_paths.py | 81 +++++++++++++++++++ 3 files changed, 129 insertions(+), 9 deletions(-) diff --git a/sphinxcontrib/openapi/renderers/_httpdomain.py b/sphinxcontrib/openapi/renderers/_httpdomain.py index 4fb0e94..9e171ff 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -6,6 +6,7 @@ import functools import http.client import json +import re import deepmerge import docutils.parsers.rst.directives as directives @@ -240,6 +241,10 @@ class HttpdomainRenderer(abc.RestructuredTextRenderer): "response-example-preference": _split_option, "generate-examples-from-schemas": _flag_or_bool, "no-json-schema-description": _flag_or_bool, + "paths": _split_option, + "include": _split_option, + "exclude": _split_option, + "methods": _split_option, "request": _flag_or_bool, "examples": _flag_or_bool, } @@ -299,20 +304,50 @@ def render_restructuredtext_markup(self, spec): def render_paths(self, paths): """Render OAS paths item.""" - for endpoint, path in paths.items(): - common_parameters = path.pop("parameters", []) + if "paths" in self._options: + expected_paths = set(_to_list(self._options["paths"])) + if not expected_paths.issubset(paths): + raise ValueError( + "One or more paths are not defined in the spec: %s." + % (", ".join(sorted(expected_paths - set(paths))),) + ) + paths_to_render = [p for p in paths if p in expected_paths] + elif "include" in self._options: + paths_to_render = [] + for i in _to_list(self._options["include"]): + ir = re.compile(i) + for path in paths: + if ir.match(path) and path not in paths_to_render: + paths_to_render.append(path) + else: + paths_to_render = list(paths.keys()) + + if "exclude" in self._options: + excluded = [] + for e in _to_list(self._options["exclude"]): + er = re.compile(e) + for path in paths_to_render: + if not er.match(path): + excluded.append(path) + paths_to_render = excluded - # OpenAPI's path description may contain objects of different - # types. Since we're interested in rendering only objects of - # operation type, let's remove irrelevant one from the definition - # in order to simplify further code. - for key in {"summary", "description", "servers"}: - path.pop(key, None) + allowed_methods = None + if "methods" in self._options: + allowed_methods = {m.lower() for m in _to_list(self._options["methods"])} + + for endpoint in paths_to_render: + path = paths[endpoint] + common_parameters = path.get("parameters", []) for method in _iterinorder(path, self._http_methods_order): if method.lower() not in _HTTP_METHODS: continue - operation = path[method] + if ( + allowed_methods is not None + and method.lower() not in allowed_methods + ): + continue + operation = copy.deepcopy(path[method]) operation.setdefault("parameters", []) operation_parameters_ids = set( (parameter["name"], parameter["in"]) diff --git a/tests/renderers/httpdomain/test_options.py b/tests/renderers/httpdomain/test_options.py index 04329e5..e04b35b 100644 --- a/tests/renderers/httpdomain/test_options.py +++ b/tests/renderers/httpdomain/test_options.py @@ -55,6 +55,10 @@ _WHITESPACE_DELIMITED_OPTIONS = [ + pytest.param("paths", "/evidences", ["/evidences"]), + pytest.param("include", "/evid.* /missing", ["/evid.*", "/missing"]), + pytest.param("exclude", "/missing /other", ["/missing", "/other"]), + pytest.param("methods", "get post", ["get", "post"]), pytest.param("http-methods-order", "head get", ["head", "get"]), pytest.param( "response-examples-for", "200 201 2XX 404", ["200", "201", "2XX", "404"] diff --git a/tests/renderers/httpdomain/test_render_paths.py b/tests/renderers/httpdomain/test_render_paths.py index 9f50a09..eb1f52c 100644 --- a/tests/renderers/httpdomain/test_render_paths.py +++ b/tests/renderers/httpdomain/test_render_paths.py @@ -407,3 +407,84 @@ def test_render_paths_vendor_extensions_ignored(testrenderer, oas_fragment): :statuscode 200: ok """) + + +def test_render_paths_filtering_paths(fakestate, oas_fragment): + """Paths can be filtered using 'paths' option.""" + + testrenderer = renderers.HttpdomainRenderer(fakestate, {"paths": ["/resource_a"]}) + + markup = textify(testrenderer.render_paths(oas_fragment(""" + /resource_a: + get: + responses: + '200': + description: resource a + /resource_b: + post: + responses: + '404': + description: resource b + """))) + assert markup == textwrap.dedent("""\ + .. http:get:: /resource_a + + :statuscode 200: + resource a + """) + + +def test_render_paths_filtering_include_exclude(fakestate, oas_fragment): + """Paths can be filtered using 'include' and 'exclude' regexes.""" + + testrenderer = renderers.HttpdomainRenderer( + fakestate, {"include": [r"/api/.*"], "exclude": [r".*/private"]} + ) + + markup = textify(testrenderer.render_paths(oas_fragment(""" + /api/public: + get: + responses: + '200': + description: public api + /api/private: + get: + responses: + '200': + description: private api + /other: + get: + responses: + '200': + description: other + """))) + assert markup == textwrap.dedent("""\ + .. http:get:: /api/public + + :statuscode 200: + public api + """) + + +def test_render_paths_filtering_methods(fakestate, oas_fragment): + """Endpoints can be filtered by HTTP methods.""" + + testrenderer = renderers.HttpdomainRenderer(fakestate, {"methods": ["get"]}) + + markup = textify(testrenderer.render_paths(oas_fragment(""" + /resource: + get: + responses: + '200': + description: get response + post: + responses: + '201': + description: post response + """))) + assert markup == textwrap.dedent("""\ + .. http:get:: /resource + + :statuscode 200: + get response + """) From ad3e7c787b53f000995253e8be4114416bba633e Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 10:57:40 +0100 Subject: [PATCH 06/15] Add tag grouping support (group) - Support 'group' option to group operations under section headers by their primary tag (or 'default'). Signed-off-by: Stephen Finucane Assisted-by: OpenAI GPT-6.1 (via pi) --- .../openapi/renderers/_httpdomain.py | 107 +++++++++++++----- .../renderers/httpdomain/test_render_paths.py | 58 ++++++++++ 2 files changed, 138 insertions(+), 27 deletions(-) diff --git a/sphinxcontrib/openapi/renderers/_httpdomain.py b/sphinxcontrib/openapi/renderers/_httpdomain.py index 9e171ff..098365e 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -220,6 +220,12 @@ def _flag_or_bool(argument): raise ValueError(f"Invalid boolean value: {argument}") +def _header(title): + yield title + yield "=" * len(title) + yield "" + + class HttpdomainRenderer(abc.RestructuredTextRenderer): """Render OpenAPI v3 using `sphinxcontrib-httpdomain` extension.""" @@ -245,6 +251,7 @@ class HttpdomainRenderer(abc.RestructuredTextRenderer): "include": _split_option, "exclude": _split_option, "methods": _split_option, + "group": _flag_or_bool, "request": _flag_or_bool, "examples": _flag_or_bool, } @@ -299,9 +306,9 @@ def render_restructuredtext_markup(self, spec): if spec.get("swagger") == "2.0": spec = lib2to3.convert(spec) - yield from self.render_paths(spec.get("paths", {})) + yield from self.render_paths(spec.get("paths", {}), spec=spec) - def render_paths(self, paths): + def render_paths(self, paths, spec=None): """Render OAS paths item.""" if "paths" in self._options: @@ -335,33 +342,79 @@ def render_paths(self, paths): if "methods" in self._options: allowed_methods = {m.lower() for m in _to_list(self._options["methods"])} - for endpoint in paths_to_render: - path = paths[endpoint] - common_parameters = path.get("parameters", []) + if self._options.get("group"): + tags_order = ( + [x["name"] for x in (spec.get("tags") or []) if "name" in x] + if spec + else [] + ) + groups = collections.OrderedDict([(t, []) for t in tags_order]) - for method in _iterinorder(path, self._http_methods_order): - if method.lower() not in _HTTP_METHODS: - continue - if ( - allowed_methods is not None - and method.lower() not in allowed_methods - ): + for endpoint in paths_to_render: + path = paths[endpoint] + common_parameters = path.get("parameters", []) + + for method in _iterinorder(path, self._http_methods_order): + if method.lower() not in _HTTP_METHODS: + continue + if ( + allowed_methods is not None + and method.lower() not in allowed_methods + ): + continue + operation = copy.deepcopy(path[method]) + operation.setdefault("parameters", []) + operation_parameters_ids = set( + (parameter["name"], parameter["in"]) + for parameter in operation["parameters"] + ) + operation["parameters"] = [ + parameter + for parameter in common_parameters + if (parameter["name"], parameter["in"]) + not in operation_parameters_ids + ] + operation["parameters"] + + tag = ( + operation.get("tags", [""])[0] if operation.get("tags") else "" + ) + groups.setdefault(tag, []).append((endpoint, method, operation)) + + for tag, operations in groups.items(): + if not operations: continue - operation = copy.deepcopy(path[method]) - operation.setdefault("parameters", []) - operation_parameters_ids = set( - (parameter["name"], parameter["in"]) - for parameter in operation["parameters"] - ) - operation["parameters"] = [ - parameter - for parameter in common_parameters - if (parameter["name"], parameter["in"]) - not in operation_parameters_ids - ] + operation["parameters"] - - yield from self.render_operation(endpoint, method, operation) - yield "" + yield from _header(tag if tag else "default") + for endpoint, method, operation in operations: + yield from self.render_operation(endpoint, method, operation) + yield "" + else: + for endpoint in paths_to_render: + path = paths[endpoint] + common_parameters = path.get("parameters", []) + + for method in _iterinorder(path, self._http_methods_order): + if method.lower() not in _HTTP_METHODS: + continue + if ( + allowed_methods is not None + and method.lower() not in allowed_methods + ): + continue + operation = copy.deepcopy(path[method]) + operation.setdefault("parameters", []) + operation_parameters_ids = set( + (parameter["name"], parameter["in"]) + for parameter in operation["parameters"] + ) + operation["parameters"] = [ + parameter + for parameter in common_parameters + if (parameter["name"], parameter["in"]) + not in operation_parameters_ids + ] + operation["parameters"] + + yield from self.render_operation(endpoint, method, operation) + yield "" def render_operation(self, endpoint, method, operation): """Render OAS operation item.""" diff --git a/tests/renderers/httpdomain/test_render_paths.py b/tests/renderers/httpdomain/test_render_paths.py index eb1f52c..fbd0f26 100644 --- a/tests/renderers/httpdomain/test_render_paths.py +++ b/tests/renderers/httpdomain/test_render_paths.py @@ -488,3 +488,61 @@ def test_render_paths_filtering_methods(fakestate, oas_fragment): :statuscode 200: get response """) + + +def test_render_paths_group(fakestate, oas_fragment): + """Endpoints can be grouped by tags with headers.""" + + testrenderer = renderers.HttpdomainRenderer(fakestate, {"group": True}) + spec = oas_fragment(""" + tags: + - name: pets + - name: users + paths: + /pets: + get: + tags: + - pets + responses: + '200': + description: list pets + /users: + get: + tags: + - users + responses: + '200': + description: list users + /un-tagged: + get: + responses: + '200': + description: un-tagged + """) + + markup = textify(testrenderer.render_paths(spec["paths"], spec=spec)) + assert markup == textwrap.dedent("""\ + pets + ==== + + .. http:get:: /pets + + :statuscode 200: + list pets + + users + ===== + + .. http:get:: /users + + :statuscode 200: + list users + + default + ======= + + .. http:get:: /un-tagged + + :statuscode 200: + un-tagged + """) From 2d0ef7637111e629b915f15cc2568e7bcbafc1ca Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 12:02:03 +0100 Subject: [PATCH 07/15] Fix path metadata and parameter inheritance during normalization Only normalize HTTP operations, leaving Path Item metadata and vendor extensions untouched. Merge inherited parameters by (name, in), allowing operation-level definitions to override their path-level counterparts. Retain inheritance before Swagger conversion so shared body parameters continue to become request bodies. Add renderer entry-point regressions for OpenAPI 2.0, 3.0 and 3.1, including grouped and repeated rendering. Signed-off-by: Stephen Finucane Assisted-by: OpenAI GPT-6.1 (via pi) --- sphinxcontrib/openapi/utils.py | 23 ++- .../test_render_restructuredtext_markup.py | 137 ++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/sphinxcontrib/openapi/utils.py b/sphinxcontrib/openapi/utils.py index daeedc2..be6fd8c 100644 --- a/sphinxcontrib/openapi/utils.py +++ b/sphinxcontrib/openapi/utils.py @@ -104,9 +104,26 @@ def normalize_spec(spec, **options): # endpoints definitions. for endpoint in spec.get('paths', {}).values(): parameters = endpoint.pop('parameters', []) - for method in endpoint.values(): - method.setdefault('parameters', []) - method['parameters'].extend(parameters) + for method, operation in endpoint.items(): + # Path Items also contain metadata and vendor extensions, which + # must not be treated as Operation Objects. + if method.lower() not in { + 'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace' + }: + continue + + operation_parameters = operation.setdefault('parameters', []) + # OpenAPI identifies parameters by (name, in). An operation may + # override a path-level parameter, but cannot remove it. + operation_parameters_ids = { + (parameter['name'], parameter['in']) + for parameter in operation_parameters + } + operation_parameters.extend( + parameter for parameter in parameters + if (parameter['name'], parameter['in']) + not in operation_parameters_ids + ) def get_text_converter(options): diff --git a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py index 73ce7f3..102312e 100644 --- a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py +++ b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py @@ -1,7 +1,10 @@ """OpenAPI spec renderer: render_restructuredtext_markup.""" +import copy import textwrap +import pytest + from sphinxcontrib.openapi import renderers @@ -551,3 +554,137 @@ def test_oas3_response_example_2xx(testrenderer, oas_fragment): :statuscode 404: resource not found """) + + +def make_spec(version, path, endpoint="/resources"): + spec = { + "info": {"title": "An example API", "version": "1.0"}, + "paths": {endpoint: path}, + } + spec["swagger" if version == "2.0" else "openapi"] = version + return spec + + +@pytest.mark.parametrize("group", [False, True]) +@pytest.mark.parametrize("version", ["3.0.3", "3.1.0"]) +@pytest.mark.parametrize( + "metadata", + [ + {"summary": "A path summary"}, + {"description": "A path description"}, + {"servers": [{"url": "https://example.com"}]}, + {"x-codegen-name": "customName"}, + {"x-amazon-apigateway-any-method": {"some": "config"}}, + ], +) +def test_path_metadata(fakestate, version, group, metadata): + """Path metadata is not normalized or rendered as an operation.""" + + path = { + **metadata, + "get": {"responses": {"200": {"description": "A response."}}}, + } + original_metadata = copy.deepcopy(metadata) + renderer = renderers.HttpdomainRenderer(fakestate, {"group": group}) + markup = "\n".join( + renderer.render_restructuredtext_markup(make_spec(version, path)) + ) + assert {key: path[key] for key in metadata} == original_metadata + expected = textwrap.dedent("""\ + .. http:get:: /resources + + :statuscode 200: + A response. + """) + if group: + expected = "default\n=======\n\n" + expected + assert markup == expected + + +@pytest.mark.parametrize("group", [False, True]) +@pytest.mark.parametrize("version", ["2.0", "3.0.3", "3.1.0"]) +def test_parameter_overrides(fakestate, version, group): + """Operations override parameters by both name and location.""" + + def parameter(name, location, schema_type, description): + result = {"name": name, "in": location, "description": description} + schema = {"type": schema_type} + if version == "2.0": + result.update(schema) + else: + result["schema"] = schema + if location == "path": + result["required"] = True + return result + + path = { + "parameters": [ + parameter("id", "path", "string", "An inherited path parameter."), + parameter("q", "query", "string", "An inherited query parameter."), + parameter("q", "header", "string", "An inherited header parameter."), + ], + "get": { + "parameters": [ + parameter("q", "query", "integer", "An overridden query parameter.") + ], + "responses": {"200": {"description": "A response."}}, + }, + "post": {"responses": {"201": {"description": "Another response."}}}, + } + spec = make_spec(version, path, endpoint="/resources/{id}") + # Exercise reference resolution before parameter identity is inspected. + shared = path["parameters"][1] + if version == "2.0": + spec["parameters"] = {"query": shared} + path["parameters"][1] = {"$ref": "#/parameters/query"} + else: + spec["components"] = {"parameters": {"query": shared}} + path["parameters"][1] = {"$ref": "#/components/parameters/query"} + + renderer = renderers.HttpdomainRenderer(fakestate, {"group": group}) + markup = "\n".join(renderer.render_restructuredtext_markup(spec)) + get_markup, post_markup = markup.split(".. http:post::") + for operation_markup in (get_markup, post_markup): + assert operation_markup.count(":param id:") == 1 + assert ":paramtype id: string, required" in operation_markup + assert operation_markup.count(":reqheader q:") == 1 + assert "An inherited header parameter." in operation_markup + assert operation_markup.count(":queryparam q:") == 1 + assert ":queryparamtype q: integer" in get_markup + assert "An overridden query parameter." in get_markup + assert "An inherited query parameter." not in get_markup + assert ":queryparamtype q: string" in post_markup + assert "An inherited query parameter." in post_markup + assert "An overridden query parameter." not in post_markup + # Reusing a spec must not accumulate inherited parameters. + assert "\n".join(renderer.render_restructuredtext_markup(spec)) == markup + + +@pytest.mark.parametrize("group", [False, True]) +def test_oas2_inherited_request_body(fakestate, group): + """Swagger body parameters must be inherited before conversion to OAS 3.""" + + spec = make_spec( + "2.0", + { + "x-codegen-name": "customName", + "parameters": [ + { + "name": "body", + "in": "body", + "required": True, + "schema": { + "type": "object", + "example": {"name": "An inherited body"}, + }, + } + ], + "post": {"responses": {"201": {"description": "A response."}}}, + }, + ) + spec["consumes"] = ["application/json"] + renderer = renderers.HttpdomainRenderer(fakestate, {"group": group}) + markup = "\n".join(renderer.render_restructuredtext_markup(copy.deepcopy(spec))) + assert "POST /resources HTTP/1.1" in markup + assert '"name": "An inherited body"' in markup + assert ":statuscode 201:" in markup From fe0b23e5bc3e16cdd9c90efaa1f9d159013276f2 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 12:14:10 +0100 Subject: [PATCH 08/15] Fix filtering with multiple exclusion patterns Retain a path only when none of the exclusion patterns matches it. Checking paths once prevents excluded endpoints from reappearing and avoids duplicating retained endpoints. An empty exclusion list now leaves the selected paths unchanged. Add regressions for disjoint, overlapping, nonmatching and empty exclusions, combined with explicit path selection, inclusion patterns and tag grouping. Signed-off-by: Stephen Finucane Assisted-by: OpenAI GPT-6.1 (via pi) --- .../openapi/renderers/_httpdomain.py | 15 ++++--- .../renderers/httpdomain/test_render_paths.py | 44 +++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/sphinxcontrib/openapi/renderers/_httpdomain.py b/sphinxcontrib/openapi/renderers/_httpdomain.py index 098365e..6937585 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -330,13 +330,14 @@ def render_paths(self, paths, spec=None): paths_to_render = list(paths.keys()) if "exclude" in self._options: - excluded = [] - for e in _to_list(self._options["exclude"]): - er = re.compile(e) - for path in paths_to_render: - if not er.match(path): - excluded.append(path) - paths_to_render = excluded + excluded_patterns = [ + re.compile(pattern) for pattern in _to_list(self._options["exclude"]) + ] + paths_to_render = [ + path + for path in paths_to_render + if not any(pattern.match(path) for pattern in excluded_patterns) + ] allowed_methods = None if "methods" in self._options: diff --git a/tests/renderers/httpdomain/test_render_paths.py b/tests/renderers/httpdomain/test_render_paths.py index fbd0f26..0e628ec 100644 --- a/tests/renderers/httpdomain/test_render_paths.py +++ b/tests/renderers/httpdomain/test_render_paths.py @@ -2,6 +2,8 @@ import textwrap +import pytest + from sphinxcontrib.openapi import renderers @@ -466,6 +468,48 @@ def test_render_paths_filtering_include_exclude(fakestate, oas_fragment): """) +@pytest.mark.parametrize("group", [False, True]) +@pytest.mark.parametrize( + "selection", + [ + {}, + {"include": [r"/api/.*"]}, + {"paths": ["/api/public", "/api/private", "/api/admin"]}, + ], +) +@pytest.mark.parametrize( + "exclude, expected_paths", + [ + ([r".*/private", r".*/admin"], ["/api/public"]), + ([r"/api/.*", r"/api/private"], []), + ( + [r"/missing", r"/also-missing"], + ["/api/public", "/api/private", "/api/admin"], + ), + ([], ["/api/public", "/api/private", "/api/admin"]), + ], +) +def test_render_paths_filtering_multiple_exclusions( + fakestate, group, selection, exclude, expected_paths +): + """Exclude any matching path once, preserving the remaining path order.""" + + paths = { + endpoint: {"get": {"responses": {"200": {"description": "A response."}}}} + for endpoint in ["/api/public", "/api/private", "/api/admin", "/other"] + } + testrenderer = renderers.HttpdomainRenderer( + fakestate, {**selection, "exclude": exclude, "group": group} + ) + markup = textify(testrenderer.render_paths(paths)) + expected_paths = list(expected_paths) + if not selection: + expected_paths.append("/other") + assert [line for line in markup.splitlines() if line.startswith(".. http:")] == [ + f".. http:get:: {endpoint}" for endpoint in expected_paths + ] + + def test_render_paths_filtering_methods(fakestate, oas_fragment): """Endpoints can be filtered by HTTP methods.""" From 6221897c2c29e47af748aa9970b510b6beb0278d Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 12:33:57 +0100 Subject: [PATCH 09/15] Honor legacy request and example options in the new renderer Make the examples flag enable schema-derived examples and select all responses by default, while allowing explicit generation and response selection options to take precedence. Disabling examples suppresses both supplied and generated request and response examples. Make the request flag control request schema hints independently of response hints and examples. Honor the parsed boolean value of no-json-schema-description, including an explicit false value. Document these compatibility rules and retain the new renderer's existing defaults when legacy flags are absent. Add entry-point tests and HTML assertions for real OpenAPI 2.0 and 3.0 directives. Signed-off-by: Stephen Finucane Assisted-by: OpenAI GPT-6.1 (via pi) --- docs/index.rst | 26 +++- .../openapi/renderers/_httpdomain.py | 33 ++++- .../test_render_restructuredtext_markup.py | 117 ++++++++++++++++++ tests/test_openapi.py | 20 +++ 4 files changed, 189 insertions(+), 7 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 7f03bc1..24cf2e9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -104,6 +104,8 @@ The following options take a value. ``response-examples-for`` The response codes to render samples for. + An explicit value takes precedence over the ``examples`` flag. + :Type: A whitespace-delimited sequence of strings, with each string corresponding to an `HTTP status code `__ that response examples should be rendered for. :Default: ``["200", "201", "202", "2XX"]`` @@ -131,17 +133,35 @@ The following options take a value. :Type: A whitespace-delimited sequence of strings, with each string corresponding to a `supported media type `__. :Default: Defaults to the value of ``example-preference``, or ``None`` -The following options are boolean flags. +The following options are boolean flags. They can be passed without a value to enable them, or with an explicit ``true`` or ``false`` value. + +``examples`` + + Controls rendering of request and response examples. If enabled, examples are generated from schemas when not supplied in the spec, and examples are rendered for all response codes unless ``response-examples-for`` is set. + If disabled, neither supplied nor generated examples are rendered. + + :Type: Flag. + :Default: Supplied examples are rendered, but examples are not generated from schemas. + +``request`` + + Controls rendering of request JSON schema hints, independently of examples and response schema hints. + Unlike the old renderer, request structure is described using HTTP domain fields rather than a raw JSON schema dump. + ``no-json-schema-description`` takes precedence over this option. + + :Type: Flag. + :Default: Enabled. ``generate-examples-from-schemas`` - Whether examples should be generated from the schema if they are not provided in the spec. If unset, examples will not be generated. + Whether examples should be generated from the schema if they are not provided in the spec. If unset, examples will only be generated when ``examples`` is enabled. + An explicit value overrides the generation setting implied by ``examples``, but ``examples: false`` always suppresses rendering. :Type: Flag. ``no-json-schema-description`` - Whether to disable rendering of JSON schema hints. + Whether to disable rendering of both request and response JSON schema hints. An explicit ``false`` value leaves schema hints enabled. :Type: Flag. diff --git a/sphinxcontrib/openapi/renderers/_httpdomain.py b/sphinxcontrib/openapi/renderers/_httpdomain.py index 6937585..ffbe44f 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -275,6 +275,10 @@ def __init__(self, state, options): # OpenAPI permits numeric status codes, uppercase ranges 1XX–5XX, # and the lowercase special value "default". Preserve their case. self._response_examples_for = {str(x) for x in response_examples_for} + elif options.get("examples"): + # The legacy flag renders examples for every response. An explicit + # response-examples-for option still takes precedence. + self._response_examples_for = None else: self._response_examples_for = {"200", "201", "202", "2XX"} @@ -295,10 +299,18 @@ def __init__(self, state, options): _to_list(options.get("response-example-preference")) or self._example_preference ) + self._render_examples = bool(options.get("examples", True)) self._generate_example_from_schema = bool( - options.get("generate-examples-from-schemas") + options.get( + "generate-examples-from-schemas", options.get("examples", False) + ) + ) + self._json_schema_description = not options.get( + "no-json-schema-description", False + ) + self._request_json_schema_description = ( + self._json_schema_description and options.get("request", True) ) - self._json_schema_description = "no-json-schema-description" not in options def render_restructuredtext_markup(self, spec): """Spec render entry point.""" @@ -489,7 +501,7 @@ def render_parameter(self, parameter): def render_request_body(self, request_body, endpoint, method): """Render OAS operation's requestBody.""" - if self._json_schema_description: + if self._request_json_schema_description: for content_type, content in request_body["content"].items(): if _is_json_mimetype(content_type) and content.get("schema"): yield from self.render_json_schema_description( @@ -504,6 +516,9 @@ def render_request_body(self, request_body, endpoint, method): def render_request_body_example(self, request_body, endpoint, method): """Render OAS operation's requestBody's example.""" + if not self._render_examples: + return + content_type, example = next( _iterexamples( request_body["content"], @@ -555,7 +570,14 @@ def render_response(self, status_code, response): self._convert_markup(response["description"]).strip().splitlines() ) - if "content" in response and status_code in self._response_examples_for: + if ( + self._render_examples + and "content" in response + and ( + self._response_examples_for is None + or status_code in self._response_examples_for + ) + ): yield "" yield from indented( self.render_response_example(response["content"], status_code) @@ -592,6 +614,9 @@ def render_response(self, status_code, response): yield f":resheadertype {header_name}: {markers}" def render_response_example(self, media_type, status_code): + if not self._render_examples: + return + # OpenAPI 3.0 spec may contain more than one response media type, and # each media type may contain more than one example. Rendering all # invariants normally is not an option because the result will be hard diff --git a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py index 102312e..53d8f8b 100644 --- a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py +++ b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py @@ -556,6 +556,123 @@ def test_oas3_response_example_2xx(testrenderer, oas_fragment): """) +@pytest.mark.parametrize("version", ["3.0.3", "3.1.0"]) +@pytest.mark.parametrize("provided_example", [False, True]) +@pytest.mark.parametrize( + "options, generate, render_examples, response_codes", + [ + ({}, False, True, ["200"]), + ({"examples": True}, True, True, ["200", "400", "000"]), + ({"examples": False}, False, False, []), + ({"generate-examples-from-schemas": True}, True, True, ["200"]), + ( + {"examples": True, "generate-examples-from-schemas": False}, + False, + True, + ["200", "400", "000"], + ), + ({"examples": False, "generate-examples-from-schemas": True}, True, False, []), + ({"examples": True, "response-examples-for": "400"}, True, True, ["400"]), + ({"examples": True, "response-examples-for": []}, True, True, []), + ], +) +def test_legacy_examples( + fakestate, + version, + provided_example, + options, + generate, + render_examples, + response_codes, +): + """Legacy example flags control generation and rendering of both bodies.""" + + content = { + "application/json": { + "schema": {"type": "object", "properties": {"name": {"type": "string"}}} + } + } + if provided_example: + content["application/json"]["example"] = {"name": "A supplied example"} + spec = make_spec( + version, + { + "post": { + "requestBody": {"content": copy.deepcopy(content)}, + "responses": { + status: { + "description": "A response.", + "content": copy.deepcopy(content), + } + for status in ["200", "400", "default"] + }, + } + }, + ) + renderer = renderers.HttpdomainRenderer(fakestate, options) + markup = textify(renderer.render_restructuredtext_markup(spec)) + examples_expected = render_examples and (provided_example or generate) + assert ("POST /resources HTTP/1.1" in markup) == examples_expected + expected_codes = response_codes if examples_expected else [] + assert [ + line.split()[1] + for line in markup.splitlines() + if line.strip().startswith("HTTP/1.1") + ] == expected_codes + assert markup.count(".. sourcecode:: http") == ( + 1 + len(expected_codes) if examples_expected else 0 + ) + # Disabling examples must not suppress schema descriptions. + assert ":reqjson name:" in markup + assert ":resjson name:" in markup + if examples_expected: + expected_value = "A supplied example" if provided_example else "string" + assert f'"name": "{expected_value}"' in markup + + +@pytest.mark.parametrize( + "options, request_hints, response_hints", + [ + ({}, True, True), + ({"request": True}, True, True), + ({"request": False}, False, True), + ({"no-json-schema-description": True}, False, False), + ({"no-json-schema-description": False}, True, True), + ({"request": True, "no-json-schema-description": True}, False, False), + ({"request": False, "no-json-schema-description": False}, False, True), + ], +) +def test_legacy_request(fakestate, options, request_hints, response_hints): + """The request flag controls request hints independently of examples.""" + + content = { + "application/json": { + "schema": {"type": "object", "properties": {"name": {"type": "string"}}}, + "example": {"name": "A supplied example"}, + } + } + spec = make_spec( + "3.0.3", + { + "post": { + "requestBody": {"content": copy.deepcopy(content)}, + "responses": { + "200": { + "description": "A response.", + "content": copy.deepcopy(content), + } + }, + } + }, + ) + renderer = renderers.HttpdomainRenderer(fakestate, options) + markup = textify(renderer.render_restructuredtext_markup(spec)) + assert (":reqjson name:" in markup) == request_hints + assert (":resjson name:" in markup) == response_hints + assert "POST /resources HTTP/1.1" in markup + assert "HTTP/1.1 200 OK" in markup + + def make_spec(version, path, endpoint="/resources"): spec = { "info": {"title": "An example API", "version": "1.0"}, diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 9ba9590..c01c92b 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -2013,6 +2013,26 @@ def test_openapi3_examples(tmpdir, run_sphinx, render_examples): == render_examples +@pytest.mark.parametrize('version', ['v2.0', 'v3.0']) +@pytest.mark.parametrize('render_examples', [False, True]) +def test_openapi_examples_compatibility( + tmpdir, run_sphinx, get_testspec, version, render_examples): + """The new renderer honors legacy example flags in real directives.""" + spec = get_testspec(version, 'petstore.yaml', resolve_refs=False) + tmpdir.join('src', 'test-spec.json').write_text( + json.dumps(spec), encoding='utf-8') + run_sphinx( + 'test-spec.json', + options={'examples': render_examples}, + renderer='httpdomain', + ) + + rendered_html = tmpdir.join('out', 'index.html').read_text('utf-8') + assert ('highlight-http' in rendered_html) == render_examples + # Schema descriptions remain available when examples are disabled. + assert 'Response JSON Array' in rendered_html + + class TestConvertJsonSchema(object): schema = { 'type': 'object', From c7ab7735da480fe82cbdc058a3c1295fdafedbc6 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 12:38:26 +0100 Subject: [PATCH 10/15] Preserve examples on composed array item schemas Generate array items from their complete schema instead of bypassing item-level examples when oneOf or anyOf is present. Reuse the existing composition handling and remove special cases for sets and strings, which are not valid OpenAPI composition values. Replace the invalid fixture with an ordered array of schemas. Add coverage for explicit and falsey item examples, array-level precedence, structured items, and generated request and response bodies. Validate the new utility examples against their JSON schemas. Signed-off-by: Stephen Finucane Assisted-by: OpenAI GPT-6.1 (via pi) --- sphinxcontrib/openapi/schema_utils.py | 30 +------- .../test_render_restructuredtext_markup.py | 41 +++++++++++ tests/test_schema_utils.py | 73 ++++++++++++++++++- 3 files changed, 115 insertions(+), 29 deletions(-) diff --git a/sphinxcontrib/openapi/schema_utils.py b/sphinxcontrib/openapi/schema_utils.py index 58c7fed..2683c25 100644 --- a/sphinxcontrib/openapi/schema_utils.py +++ b/sphinxcontrib/openapi/schema_utils.py @@ -139,35 +139,9 @@ def example_from_schema(schema): if items == {}: # Any-type arrays example_items.extend(_DEFAULT_EXAMPLES.values()) - elif isinstance(items, dict) and "oneOf" in items: - # Mixed-type arrays - one_of = items["oneOf"] - if isinstance(one_of, (list, set)): - try: - first = sorted(one_of)[0] - except TypeError: - first = list(one_of)[0] - else: - first = one_of - if isinstance(first, str) and first in _DEFAULT_EXAMPLES: - example_items.append(_DEFAULT_EXAMPLES[first]) - else: - example_items.append(example_from_schema(first)) - elif isinstance(items, dict) and "anyOf" in items: - # Mixed-type arrays - any_of = items["anyOf"] - if isinstance(any_of, (list, set)): - try: - first = sorted(any_of)[0] - except TypeError: - first = list(any_of)[0] - else: - first = any_of - if isinstance(first, str) and first in _DEFAULT_EXAMPLES: - example_items.append(_DEFAULT_EXAMPLES[first]) - else: - example_items.append(example_from_schema(first)) else: + # Treat the whole item schema like any other schema, preserving + # explicit examples before considering oneOf/anyOf subschemas. example_items.append(example_from_schema(items)) # Generate array containing example_items and satisfying min_length and max_length diff --git a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py index 53d8f8b..1e1a19f 100644 --- a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py +++ b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py @@ -403,6 +403,47 @@ def test_oas3_generate_examples_from_schema(fakestate, oas_fragment): """) +@pytest.mark.parametrize("version", ["3.0.3", "3.1.0"]) +@pytest.mark.parametrize("combining_keyword", ["oneOf", "anyOf"]) +def test_oas3_generate_array_item_examples(fakestate, version, combining_keyword): + """Generated request and response arrays preserve explicit item examples.""" + + content = { + "application/json": { + "schema": { + "type": "array", + "items": { + "example": 42, + combining_keyword: [{"type": "integer"}, {"type": "string"}], + }, + } + } + } + spec = make_spec( + version, + { + "post": { + "requestBody": {"content": copy.deepcopy(content)}, + "responses": { + "200": { + "description": "A response.", + "content": copy.deepcopy(content), + } + }, + } + }, + ) + renderer = renderers.HttpdomainRenderer( + fakestate, {"generate-examples-from-schemas": True} + ) + markup = textify(renderer.render_restructuredtext_markup(spec)) + assert "POST /resources HTTP/1.1" in markup + assert "HTTP/1.1 200 OK" in markup + assert [ + line.strip() for line in markup.splitlines() if line.strip() in {"42,", "42"} + ] == ["42,", "42", "42,", "42"] + + def test_oas3_generate_examples_from_schema_with_composed_property( fakestate, oas_fragment ): diff --git a/tests/test_schema_utils.py b/tests/test_schema_utils.py index bb4a1bd..880208c 100644 --- a/tests/test_schema_utils.py +++ b/tests/test_schema_utils.py @@ -1,3 +1,4 @@ +import jsonschema import pytest from sphinxcontrib.openapi.schema_utils import example_from_schema @@ -125,7 +126,7 @@ "properties": { "mixed_type_array": { "type": "array", - "items": {"oneOf": {"string", "integer"}}, + "items": {"oneOf": [{"type": "integer"}, {"type": "string"}]}, }, "any_type_array": {"type": "array", "items": {}}, "0_to_1_length_array": { @@ -354,3 +355,73 @@ ) def test_generate_example_from_schema(schema, expected): assert example_from_schema(schema) == expected + + +@pytest.mark.parametrize("combining_keyword", ["oneOf", "anyOf"]) +@pytest.mark.parametrize( + "first_schema, expected_item", + [ + ({"type": "string"}, "string"), + ( + {"type": "object", "properties": {"name": {"type": "string"}}}, + {"name": "string"}, + ), + ({"type": "array", "items": {"type": "string"}}, ["string", "string"]), + ], +) +def test_composed_array_items(combining_keyword, first_schema, expected_item): + """Composed items use the first schema in declaration order.""" + + schema = { + "type": "array", + "items": {combining_keyword: [first_schema, {"type": "integer"}]}, + } + example = example_from_schema(schema) + assert example == [expected_item, expected_item] + jsonschema.Draft202012Validator(schema).validate(example) + + +@pytest.mark.parametrize("combining_keyword", ["oneOf", "anyOf"]) +@pytest.mark.parametrize( + "first_schema, explicit_example", + [ + ({"type": "integer", "example": 7}, 42), + ({"type": "integer", "example": 7}, 0), + ({"type": "boolean", "example": True}, False), + ({"type": "string", "example": "branch"}, ""), + ({"type": "object", "example": {"name": "branch"}}, {}), + ({"type": "array", "items": {"type": "integer"}, "example": [7]}, []), + ({"type": "null"}, None), + ], +) +def test_composed_array_item_example(combining_keyword, first_schema, explicit_example): + """An item-level example takes precedence over composed subschemas.""" + + alternative = {"type": "integer" if first_schema["type"] == "string" else "string"} + schema = { + "type": "array", + "items": { + "example": explicit_example, + combining_keyword: [first_schema, alternative], + }, + } + example = example_from_schema(schema) + assert example == [explicit_example, explicit_example] + jsonschema.Draft202012Validator(schema).validate(example) + + +@pytest.mark.parametrize("combining_keyword", ["oneOf", "anyOf"]) +def test_composed_array_example(combining_keyword): + """An array-level example takes precedence over item examples.""" + + schema = { + "type": "array", + "example": [42], + "items": { + "example": 7, + combining_keyword: [{"type": "integer"}, {"type": "string"}], + }, + } + example = example_from_schema(schema) + assert example == [42] + jsonschema.Draft202012Validator(schema).validate(example) From a27db3f4b06b08fecfa01d69dd1725ddbf3a2745 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 12:45:51 +0100 Subject: [PATCH 11/15] Handle OpenAPI 3.1 schema shapes in rendering and examples Support nullable type arrays, null values, boolean schemas and arrays without items during example generation. Prefer non-null union members, but fall back when a member cannot contribute an example. Omit forbidden optional properties and handle prefixItems separately from array tails. Signal known impossible examples with NoExampleError and let the renderer skip that media type rather than invent a value or fail the document. Handle boolean schemas and nullable structural types during description traversal, and retain individual type markers with applicable formats. Add utility validation and renderer regressions, including generation from the in-tree issue-112 reproducer. Quote its null type correctly and document the best-effort nature of schema-derived examples. Signed-off-by: Stephen Finucane Assisted-by: OpenAI GPT-6.1 (via pi) --- docs/index.rst | 6 + .../openapi/renderers/_httpdomain.py | 84 ++++++---- sphinxcontrib/openapi/schema_utils.py | 87 ++++++++-- .../test_render_json_schema_description.py | 104 ++++++++++++ .../test_render_restructuredtext_markup.py | 151 ++++++++++++++++++ tests/test_schema_utils.py | 112 ++++++++++++- tests/testspecs/v3.1/issue-112.yaml | 2 +- 7 files changed, 499 insertions(+), 47 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 24cf2e9..93ddce4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -157,6 +157,12 @@ The following options are boolean flags. They can be passed without a value to e Whether examples should be generated from the schema if they are not provided in the spec. If unset, examples will only be generated when ``examples`` is enabled. An explicit value overrides the generation setting implied by ``examples``, but ``examples: false`` always suppresses rendering. + Generation is best effort, not a full JSON Schema constraint solver. + For OpenAPI 3.1 type arrays, a non-null type is preferred when possible. + Boolean ``true`` schemas allow any value; ``false`` schemas cannot have an example. + If a schema cannot contribute an example, generation is skipped for that media type. + Arrays may omit ``items``, and ``prefixItems`` constrains initial positions independently of ``items``. + :Type: Flag. ``no-json-schema-description`` diff --git a/sphinxcontrib/openapi/renderers/_httpdomain.py b/sphinxcontrib/openapi/renderers/_httpdomain.py index ffbe44f..76d78fa 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -16,7 +16,7 @@ from sphinxcontrib.openapi import _lib2to3 as lib2to3, utils from sphinxcontrib.openapi.renderers import abc -from sphinxcontrib.openapi.schema_utils import example_from_schema +from sphinxcontrib.openapi.schema_utils import NoExampleError, example_from_schema CaseInsensitiveDict = requests.structures.CaseInsensitiveDict @@ -110,14 +110,19 @@ def _iterexamples(media_types, example_preference, examples_from_schemas): # Save example from "example" in "examples" compatible format. This # allows to treat all returned examples the same way. example = {"value": media_type["example"]} - elif media_type.get("schema", {}).get("example"): + elif isinstance(media_type.get("schema"), dict) and media_type["schema"].get( + "example" + ): # Save example from "schema" in "examples" compatible format. This # allows to treat all returned examples the same way. example = {"value": media_type["schema"]["example"]} elif "schema" in media_type and examples_from_schemas: - # Convert schema to example - example = {"value": example_from_schema(media_type["schema"])} - pass + # A valid OAS 3.1 schema can forbid all values. In that case there + # is no generated example to render; try another media type. + try: + example = {"value": example_from_schema(media_type["schema"])} + except NoExampleError: + continue else: continue @@ -128,16 +133,17 @@ def _get_markers_from_object(oas_object, schema): """Retrieve a bunch of OAS object markers.""" markers = [] + if isinstance(schema, bool): + schema = {} schema_type = _get_schema_type(schema) if schema_type: - if schema.get("format"): - schema_type = f"{schema_type}:{schema['format']}" - elif schema.get("enum"): - schema_type = f"{schema_type}:enum" - if isinstance(schema_type, list): - markers = list(schema_type) - else: + schema_types = schema_type if isinstance(schema_type, list) else [schema_type] + for schema_type in schema_types: + if schema_type != "null" and schema.get("format"): + schema_type = f"{schema_type}:{schema['format']}" + elif schema_type != "null" and schema.get("enum"): + schema_type = f"{schema_type}:enum" markers.append(schema_type) elif schema.get("enum"): markers.append("enum") @@ -174,6 +180,9 @@ def _is_2xx_status(status_code): def _get_schema_type(schema): """Retrieve schema type either by reading 'type' or guessing.""" + if isinstance(schema, bool): + return None + # There are a lot of OpenAPI specs out there that may lack 'type' property # in their schemas. I fount no explanations on what is expected behaviour # in this case neither in OpenAPI nor in JSON Schema specifications. Thus @@ -191,6 +200,15 @@ def _get_schema_type(schema): return schema_type +def _get_single_schema_type(schema): + """Choose a concrete type to traverse while retaining all types in markers.""" + + schema_type = _get_schema_type(schema) + if isinstance(schema_type, list): + return next((t for t in schema_type if t != "null"), "null") + return schema_type + + _merge_mappings = deepmerge.Merger( [(collections.abc.Mapping, deepmerge.strategy.dict.DictStrategies("merge"))], ["override"], @@ -667,20 +685,22 @@ def render_json_schema_description(self, schema, req_or_res): """Render JSON schema's description.""" def _resolve_combining_schema(schema): - if "oneOf" in schema: - # The part with merging is a vague one since I only found a - # single 'oneOf' example where such merging was assumed, and no - # explanations in the spec itself. - merged_schema = schema.copy() - merged_schema.update(merged_schema.pop("oneOf")[0]) - return merged_schema + if isinstance(schema, bool): + return {} - elif "anyOf" in schema: - # The part with merging is a vague one since I only found a - # single 'oneOf' example where such merging was assumed, and no - # explanations in the spec itself. + if "oneOf" in schema or "anyOf" in schema: merged_schema = schema.copy() - merged_schema.update(merged_schema.pop("anyOf")[0]) + keyword = "oneOf" if "oneOf" in schema else "anyOf" + # Keep the existing first-variant description policy, but skip + # False alternatives: they cannot describe any value. + first = next( + (item for item in merged_schema.pop(keyword) if item is not False), + False, + ) + if first is False: + return {} + if first is not True: + merged_schema.update(first) return merged_schema elif "allOf" in schema: @@ -691,6 +711,10 @@ def _resolve_combining_schema(schema): # object type. merged_schema = schema.copy() for item in merged_schema.pop("allOf"): + if item is False: + return {} + if item is True: + continue merged_schema = _merge_mappings(merged_schema, copy.deepcopy(item)) return merged_schema @@ -701,7 +725,9 @@ def _resolve_combining_schema(schema): return schema def _traverse_schema(schema, name, is_required=False): - schema_type = _get_schema_type(schema) + if isinstance(schema, bool): + return + schema_type = _get_single_schema_type(schema) if {"oneOf", "anyOf", "allOf"} & schema.keys(): # Since an item can represented by either or any schema from @@ -733,7 +759,7 @@ def _traverse_schema(schema, name, is_required=False): ) elif schema_type == "array": - yield from _traverse_schema(schema["items"], f"{name}[]") + yield from _traverse_schema(schema.get("items", {}), f"{name}[]") elif "enum" in schema: yield name, schema, is_required @@ -742,7 +768,7 @@ def _traverse_schema(schema, name, is_required=False): yield name, schema, is_required schema = _resolve_combining_schema(schema) - schema_type = _get_schema_type(schema) + schema_type = _get_single_schema_type(schema) # On root level, httpdomain supports only 'object' and 'array' response # types. If it's something else, let's do not even try to render it. @@ -772,11 +798,11 @@ def _traverse_schema(schema, name, is_required=False): # we're about to render is an array, there's no need to render that # array in the first place. if schema_type == "array": - schema = schema["items"] + schema = schema.get("items", {}) # Even if a root element is an array, items it contain must not be # of a primitive types. - if _get_schema_type(schema) not in {"object", "array"}: + if _get_single_schema_type(schema) not in {"object", "array"}: return for name, schema, is_required in _traverse_schema(schema, ""): diff --git a/sphinxcontrib/openapi/schema_utils.py b/sphinxcontrib/openapi/schema_utils.py index 2683c25..cfb1efd 100644 --- a/sphinxcontrib/openapi/schema_utils.py +++ b/sphinxcontrib/openapi/schema_utils.py @@ -8,6 +8,7 @@ "number": 1.0, "boolean": True, "array": [], + "null": None, } @@ -35,6 +36,10 @@ _NO_EXAMPLE = object() +class NoExampleError(ValueError): + """A schema cannot contribute a generated example.""" + + def example_from_schema(schema): """ Generates an example request/response body from the provided schema. @@ -63,15 +68,24 @@ def example_from_schema(schema): ... "tag": "string" ... } """ + # JSON Schema boolean schemas accept every value or no value at all. + if schema is True: + return _DEFAULT_EXAMPLES["integer"] + if schema is False: + raise NoExampleError("A false schema accepts no values") + # If an example was provided then we use that if "example" in schema: return schema["example"] - elif "oneOf" in schema: - return example_from_schema(schema["oneOf"][0]) - - elif "anyOf" in schema: - return example_from_schema(schema["anyOf"][0]) + elif "oneOf" in schema or "anyOf" in schema: + alternatives = schema.get("oneOf", schema.get("anyOf")) + for alternative in alternatives: + try: + return example_from_schema(alternative) + except NoExampleError: + continue + raise NoExampleError("No alternative can contribute an example") elif "allOf" in schema: # Combine schema examples @@ -87,9 +101,10 @@ def example_from_schema(schema): non_object_example_is_explicit = False for sub_schema in schema["allOf"]: - # OAS 3.1 allows a subschema to be a boolean, which carries no - # example to contribute. - if not isinstance(sub_schema, dict): + # True adds no constraints; False makes the conjunction impossible. + if sub_schema is False: + raise NoExampleError("An allOf subschema accepts no values") + if sub_schema is True: continue # A subschema that carries annotations only, a lone 'description' @@ -117,35 +132,75 @@ def example_from_schema(schema): elif "enum" in schema: return schema["enum"][0] + elif isinstance(schema.get("type"), list): + # Prefer a non-null example for nullable schemas, retaining declaration + # order for other unions. Do not mutate the caller's schema. + schema_types = [t for t in schema["type"] if t != "null"] + if "null" in schema["type"]: + schema_types.append("null") + for schema_type in schema_types: + try: + return example_from_schema({**schema, "type": schema_type}) + except NoExampleError: + continue + raise NoExampleError("No declared type can contribute an example") + elif "type" not in schema: # Any type return _DEFAULT_EXAMPLES["integer"] - elif schema["type"] == "object" or "properties" in schema: + elif schema["type"] == "object": example = {} + required = set(schema.get("required", [])) for prop, prop_schema in schema.get("properties", {}).items(): - example[prop] = example_from_schema(prop_schema) + try: + example[prop] = example_from_schema(prop_schema) + except NoExampleError: + if prop in required: + raise return example elif schema["type"] == "array": - items = schema["items"] + # In OAS 3.1, omitting items leaves array elements unconstrained. + items = schema.get("items", {}) min_length = schema.get("minItems", 0) max_length = schema.get("maxItems", max(min_length, 2)) assert min_length <= max_length # Try generate at least 2 example array items gen_length = min(2, max_length) if min_length <= 2 else min_length + # In OAS 3.1, prefixItems constrains initial positions, while items + # applies only to the remaining positions (including items: false). + example = [] + for item_schema in schema.get("prefixItems", [])[:gen_length]: + try: + example.append(example_from_schema(item_schema)) + except NoExampleError: + if len(example) < min_length: + raise + return example + if len(example) == gen_length: + return example + example_items = [] - if items == {}: + if items is True or items == {}: # Any-type arrays example_items.extend(_DEFAULT_EXAMPLES.values()) else: # Treat the whole item schema like any other schema, preserving # explicit examples before considering oneOf/anyOf subschemas. - example_items.append(example_from_schema(items)) - - # Generate array containing example_items and satisfying min_length and max_length - return [example_items[i % len(example_items)] for i in range(gen_length)] + try: + example_items.append(example_from_schema(items)) + except NoExampleError: + if len(example) < min_length: + raise + return example + + example.extend( + example_items[i % len(example_items)] + for i in range(gen_length - len(example)) + ) + return example elif schema["type"] == "string": example_string = _DEFAULT_STRING_EXAMPLES.get( diff --git a/tests/renderers/httpdomain/test_render_json_schema_description.py b/tests/renderers/httpdomain/test_render_json_schema_description.py index b9bc51c..f3f40dd 100644 --- a/tests/renderers/httpdomain/test_render_json_schema_description.py +++ b/tests/renderers/httpdomain/test_render_json_schema_description.py @@ -11,6 +11,110 @@ def textify(generator): return "\n".join(generator) +@pytest.mark.parametrize("req_or_res", ["req", "res"]) +@pytest.mark.parametrize( + "schema", + [ + True, + False, + {"type": "array"}, + {"type": "array", "items": True}, + {"type": "array", "items": False}, + {"type": ["string", "null"]}, + {"type": ["array", "null"], "items": True}, + {"allOf": [False, {"type": "object"}]}, + ], +) +def test_openapi31_schema_without_fields(testrenderer, schema, req_or_res): + """Schemas without describable object fields do not crash rendering.""" + + assert ( + textify(testrenderer.render_json_schema_description(schema, req_or_res)) == "" + ) + + +@pytest.mark.parametrize("req_or_res", ["req", "res"]) +@pytest.mark.parametrize( + "schema", + [ + {"type": ["object", "null"], "properties": {"name": {"type": "string"}}}, + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "anything": True, + "forbidden": False, + }, + }, + { + "oneOf": [ + False, + {"type": "object", "properties": {"name": {"type": "string"}}}, + ] + }, + { + "anyOf": [ + False, + {"type": "object", "properties": {"name": {"type": "string"}}}, + ] + }, + { + "allOf": [ + True, + {"type": "object", "properties": {"name": {"type": "string"}}}, + ] + }, + ], +) +def test_openapi31_object_fields(testrenderer, schema, req_or_res): + """Nullable and boolean subschemas preserve describable object fields.""" + + markup = textify(testrenderer.render_json_schema_description(schema, req_or_res)) + assert markup == f":{req_or_res}json name:\n:{req_or_res}jsonobj name: string" + + +@pytest.mark.parametrize("req_or_res", ["req", "res"]) +def test_openapi31_nullable_array_fields(testrenderer, req_or_res): + """Nullable root arrays can describe fields of nullable object items.""" + + schema = { + "type": ["null", "array"], + "items": { + "type": ["null", "object"], + "properties": {"name": {"type": "string"}}, + }, + } + markup = textify(testrenderer.render_json_schema_description(schema, req_or_res)) + assert ( + markup == f":{req_or_res}jsonarr name:\n:{req_or_res}jsonarrtype name: string" + ) + + +@pytest.mark.parametrize("req_or_res", ["req", "res"]) +def test_openapi31_nullable_field_markers(testrenderer, req_or_res): + """Type arrays are displayed as individual types, with applicable formats.""" + + schema = { + "type": "object", + "properties": { + "date": {"type": ["string", "null"], "format": "date"}, + "data": { + "type": ["object", "null"], + "properties": {"name": {"type": "string"}}, + }, + }, + } + markup = textify(testrenderer.render_json_schema_description(schema, req_or_res)) + assert markup == textwrap.dedent(f"""\ + :{req_or_res}json date: + :{req_or_res}jsonobj date: string:date, null + :{req_or_res}json data: + :{req_or_res}jsonobj data: object, null + :{req_or_res}json data.name: + :{req_or_res}jsonobj data.name: string + """.rstrip()) + + @pytest.mark.parametrize( ["req_or_res", "directive", "typedirective"], [ diff --git a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py index 1e1a19f..41ddb10 100644 --- a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py +++ b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py @@ -1,6 +1,7 @@ """OpenAPI spec renderer: render_restructuredtext_markup.""" import copy +import json import textwrap import pytest @@ -597,6 +598,156 @@ def test_oas3_response_example_2xx(testrenderer, oas_fragment): """) +def test_oas31_issue112_examples(fakestate, get_testspec): + """The in-tree nullable schemas render with schema generation enabled.""" + + renderer = renderers.HttpdomainRenderer( + fakestate, {"generate-examples-from-schemas": True} + ) + markup = textify( + renderer.render_restructuredtext_markup(get_testspec("v3.1", "issue-112.yaml")) + ) + assert markup.count(".. sourcecode:: http") == 2 + assert '"bio": "string"' in markup + assert '"deleted_at": "2020-01-01"' in markup + assert ":resjsonobj bio: string, null" in markup + + +@pytest.mark.parametrize( + "schema, expected", + [ + (True, 1), + ({"type": "null"}, None), + ({"type": ["string", "null"]}, "string"), + ( + {"type": ["object", "null"], "properties": {"name": {"type": "string"}}}, + {"name": "string"}, + ), + ({"type": ["array", "null"]}, ["string", 1]), + ({"type": "array"}, ["string", 1]), + ({"type": "array", "items": True}, ["string", 1]), + ({"type": "array", "items": False}, []), + ], +) +def test_oas31_schema_examples(fakestate, schema, expected): + """OAS 3.1 schema shapes work through example and description rendering.""" + + spec = make_spec( + "3.1.0", + { + "get": { + "responses": { + "200": { + "description": "A response.", + "content": {"application/json": {"schema": schema}}, + } + } + } + }, + ) + renderer = renderers.HttpdomainRenderer( + fakestate, {"generate-examples-from-schemas": True} + ) + markup = textify(renderer.render_restructuredtext_markup(spec)) + assert "HTTP/1.1 200 OK" in markup + body = markup.split("Content-Type: application/json\n\n")[1] + # String examples use the renderer's existing verbatim serialization. + if isinstance(expected, str): + assert body.strip() == expected + else: + assert json.loads(textwrap.dedent(body)) == expected + + +@pytest.mark.parametrize("generate_examples", [False, True]) +def test_oas31_false_schema(fakestate, generate_examples): + """A false schema is valid, but cannot have a generated example.""" + + spec = make_spec( + "3.1.0", + { + "post": { + "requestBody": {"content": {"application/json": {"schema": False}}}, + "responses": { + "200": { + "description": "A response.", + "content": {"application/json": {"schema": False}}, + } + }, + } + }, + ) + renderer = renderers.HttpdomainRenderer( + fakestate, {"generate-examples-from-schemas": generate_examples} + ) + markup = textify(renderer.render_restructuredtext_markup(spec)) + assert ":statuscode 200:" in markup + assert ".. sourcecode::" not in markup + + +def test_oas31_false_schema_media_type_fallback(fakestate): + """If one media type forbids a body, try the next for a generated example.""" + + spec = make_spec( + "3.1.0", + { + "get": { + "responses": { + "200": { + "description": "A response.", + "content": { + "application/json": {"schema": False}, + "application/problem+json": {"schema": {"type": "integer"}}, + }, + } + } + } + }, + ) + renderer = renderers.HttpdomainRenderer( + fakestate, {"generate-examples-from-schemas": True} + ) + markup = textify(renderer.render_restructuredtext_markup(spec)) + assert "Content-Type: application/problem+json" in markup + assert "Content-Type: application/json" not in markup + + +def test_oas31_boolean_parameter_schemas(fakestate): + """Boolean parameter and header schemas do not break type markers.""" + + spec = make_spec( + "3.1.0", + { + "get": { + "parameters": [ + { + "name": "anything", + "in": "query", + "required": True, + "schema": True, + }, + {"name": "forbidden", "in": "query", "schema": False}, + ], + "responses": { + "200": { + "description": "A response.", + "headers": { + "X-Anything": {"schema": True, "required": True}, + "X-Forbidden": {"schema": False}, + }, + } + }, + } + }, + ) + markup = textify( + renderers.HttpdomainRenderer(fakestate, {}).render_restructuredtext_markup(spec) + ) + assert ":queryparamtype anything: required" in markup + assert ":queryparam forbidden:" in markup + assert ":resheadertype X-Anything: required" in markup + assert ":resheader X-Forbidden:" in markup + + @pytest.mark.parametrize("version", ["3.0.3", "3.1.0"]) @pytest.mark.parametrize("provided_example", [False, True]) @pytest.mark.parametrize( diff --git a/tests/test_schema_utils.py b/tests/test_schema_utils.py index 880208c..6786a46 100644 --- a/tests/test_schema_utils.py +++ b/tests/test_schema_utils.py @@ -1,7 +1,9 @@ +import copy + import jsonschema import pytest -from sphinxcontrib.openapi.schema_utils import example_from_schema +from sphinxcontrib.openapi.schema_utils import NoExampleError, example_from_schema @pytest.mark.parametrize( @@ -410,6 +412,114 @@ def test_composed_array_item_example(combining_keyword, first_schema, explicit_e jsonschema.Draft202012Validator(schema).validate(example) +@pytest.mark.parametrize( + "schema, expected", + [ + (True, 1), + ({"oneOf": [True, False]}, 1), + ({"anyOf": [True, False]}, 1), + ({"type": "null"}, None), + ({"type": ["string", "null"]}, "string"), + ({"type": ["null", "string"], "minLength": 8}, "stringst"), + ({"type": ["null"]}, None), + ( + {"type": ["string", "null"], "properties": {"name": {"type": "integer"}}}, + "string", + ), + ( + {"type": ["object", "null"], "properties": {"name": {"type": "string"}}}, + {"name": "string"}, + ), + ({"type": ["array", "null"], "items": {"type": "integer"}}, [1, 1]), + ({"type": ["string", "null"], "example": None}, None), + ({"type": ["string", "null"], "enum": [None, "name"]}, None), + ( + {"type": "object", "properties": {"anything": True, "forbidden": False}}, + {"anything": 1}, + ), + ({"type": "array"}, ["string", 1]), + ({"type": "array", "items": True}, ["string", 1]), + ({"type": "array", "items": False}, []), + ({"type": "array", "items": False, "maxItems": 0}, []), + ( + { + "type": "array", + "prefixItems": [{"type": "integer"}], + "items": False, + "minItems": 1, + }, + [1], + ), + ( + {"type": "array", "prefixItems": [{"type": "integer"}, {"type": "string"}]}, + [1, "string"], + ), + ( + { + "type": "array", + "prefixItems": [{"type": "integer"}], + "items": {"type": "string"}, + "minItems": 3, + }, + [1, "string", "string"], + ), + ({"type": "array", "prefixItems": [False]}, []), + ({"type": ["array", "null"], "items": False, "minItems": 1}, None), + ({"oneOf": [False, {"type": "string"}]}, "string"), + ({"anyOf": [False, {"type": "string"}]}, "string"), + ], +) +def test_openapi31_example(schema, expected): + """OAS 3.1 schemas produce examples satisfying their declared constraints.""" + + original_schema = copy.deepcopy(schema) + example = example_from_schema(schema) + assert example == expected + jsonschema.Draft202012Validator(schema).validate(example) + assert schema == original_schema + + +def test_openapi31_issue112_examples(get_testspec): + """The in-tree nullable-schema reproducer supports example generation.""" + + spec = get_testspec("v3.1", "issue-112.yaml") + for path in spec["paths"].values(): + schema = path["get"]["responses"]["200"]["content"]["application/json"][ + "schema" + ] + jsonschema.Draft202012Validator.check_schema(schema) + jsonschema.Draft202012Validator(schema).validate(example_from_schema(schema)) + + +@pytest.mark.parametrize( + "schema", + [ + False, + {"allOf": [False, {"type": "string"}]}, + {"oneOf": [False]}, + {"anyOf": [False]}, + {"type": "array", "items": False, "minItems": 1}, + { + "type": "array", + "prefixItems": [{"type": "integer"}], + "items": False, + "minItems": 2, + }, + {"type": "array", "prefixItems": [False], "minItems": 1}, + { + "type": "object", + "required": ["forbidden"], + "properties": {"forbidden": False}, + }, + ], +) +def test_openapi31_impossible_example(schema): + """False schemas must not be represented by an invalid example value.""" + + with pytest.raises(NoExampleError): + example_from_schema(schema) + + @pytest.mark.parametrize("combining_keyword", ["oneOf", "anyOf"]) def test_composed_array_example(combining_keyword): """An array-level example takes precedence over item examples.""" diff --git a/tests/testspecs/v3.1/issue-112.yaml b/tests/testspecs/v3.1/issue-112.yaml index 5b53906..920f37d 100644 --- a/tests/testspecs/v3.1/issue-112.yaml +++ b/tests/testspecs/v3.1/issue-112.yaml @@ -80,4 +80,4 @@ paths: anyOf: - type: string format: date - - type: null + - type: "null" From b6a841e06580db42d0e7ea3152b02045092c12f4 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 12:52:42 +0100 Subject: [PATCH 12/15] Preserve required constraints through composed schema descriptions Accumulate required names when merging allOf schemas, including nested objects, and retain nested conjunctions instead of replacing them. Resolve nested allOf members without mutating the input schema or losing false subschemas. Carry a property's required marker through composition and array traversal. Resolve composed root array items before deciding whether their fields can be described. Add regressions for merged requirements, optional counterparts, repeated rendering and the in-tree expanded Petstore schemas. Update the two Petstore snapshots to include composed array fields and inherited required markers, and correct an allOf typo in an existing fixture. Signed-off-by: Stephen Finucane Assisted-by: OpenAI GPT-6.1 (via pi) --- .../openapi/renderers/_httpdomain.py | 36 +++- .../rendered/v2.0/petstore-expanded.yaml.rst | 10 +- .../rendered/v3.0/petstore-expanded.yaml.rst | 10 +- .../test_render_json_schema_description.py | 184 +++++++++++++++++- .../test_render_restructuredtext_markup.py | 16 ++ 5 files changed, 242 insertions(+), 14 deletions(-) diff --git a/sphinxcontrib/openapi/renderers/_httpdomain.py b/sphinxcontrib/openapi/renderers/_httpdomain.py index 76d78fa..eedc400 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -209,8 +209,21 @@ def _get_single_schema_type(schema): return schema_type +def _merge_schema_lists(merger, path, base, nxt): + # allOf is conjunctive: every required property and nested allOf constraint + # remains applicable. Other lists retain the existing override policy. + if path and path[-1] == "required": + return list(dict.fromkeys(base + nxt)) + if path and path[-1] == "allOf": + return base + nxt + return nxt + + _merge_mappings = deepmerge.Merger( - [(collections.abc.Mapping, deepmerge.strategy.dict.DictStrategies("merge"))], + [ + (collections.abc.Mapping, deepmerge.strategy.dict.DictStrategies("merge")), + (list, [_merge_schema_lists]), + ], ["override"], ["override"], ).merge @@ -686,7 +699,7 @@ def render_json_schema_description(self, schema, req_or_res): def _resolve_combining_schema(schema): if isinstance(schema, bool): - return {} + return schema if "oneOf" in schema or "anyOf" in schema: merged_schema = schema.copy() @@ -698,7 +711,7 @@ def _resolve_combining_schema(schema): False, ) if first is False: - return {} + return False if first is not True: merged_schema.update(first) return merged_schema @@ -709,13 +722,14 @@ def _resolve_combining_schema(schema): # sequentially. Please note, the only way the end result will # ever make sense is when all schemas from the array are of # object type. - merged_schema = schema.copy() + merged_schema = copy.deepcopy(schema) for item in merged_schema.pop("allOf"): + item = _resolve_combining_schema(item) if item is False: - return {} + return False if item is True: continue - merged_schema = _merge_mappings(merged_schema, copy.deepcopy(item)) + merged_schema = _merge_mappings(merged_schema, item) return merged_schema elif "not" in schema: @@ -737,7 +751,9 @@ def _traverse_schema(schema, name, is_required=False): # only a single schema variant and leave the rest out. This is # by design and it was decided so in order to keep produced # description clear and simple. - yield from _traverse_schema(_resolve_combining_schema(schema), name) + yield from _traverse_schema( + _resolve_combining_schema(schema), name, is_required=is_required + ) elif "not" in schema: yield name, {}, is_required @@ -759,7 +775,9 @@ def _traverse_schema(schema, name, is_required=False): ) elif schema_type == "array": - yield from _traverse_schema(schema.get("items", {}), f"{name}[]") + yield from _traverse_schema( + schema.get("items", {}), f"{name}[]", is_required=is_required + ) elif "enum" in schema: yield name, schema, is_required @@ -798,7 +816,7 @@ def _traverse_schema(schema, name, is_required=False): # we're about to render is an array, there's no need to render that # array in the first place. if schema_type == "array": - schema = schema.get("items", {}) + schema = _resolve_combining_schema(schema.get("items", {})) # Even if a root element is an array, items it contain must not be # of a primitive types. diff --git a/tests/renderers/httpdomain/rendered/v2.0/petstore-expanded.yaml.rst b/tests/renderers/httpdomain/rendered/v2.0/petstore-expanded.yaml.rst index e9e6384..b89da78 100644 --- a/tests/renderers/httpdomain/rendered/v2.0/petstore-expanded.yaml.rst +++ b/tests/renderers/httpdomain/rendered/v2.0/petstore-expanded.yaml.rst @@ -8,6 +8,12 @@ :queryparam limit: maximum number of results to return :queryparamtype limit: integer:int32 + :resjsonarr name: + :resjsonarrtype name: string, required + :resjsonarr tag: + :resjsonarrtype tag: string + :resjsonarr id: + :resjsonarrtype id: integer:int64, required :statuscode 200: pet response @@ -26,7 +32,7 @@ :resjson name: - :resjsonobj name: string + :resjsonobj name: string, required :resjson tag: :resjsonobj tag: string :resjson id: @@ -46,7 +52,7 @@ ID of pet to fetch :paramtype id: integer:int64, required :resjson name: - :resjsonobj name: string + :resjsonobj name: string, required :resjson tag: :resjsonobj tag: string :resjson id: diff --git a/tests/renderers/httpdomain/rendered/v3.0/petstore-expanded.yaml.rst b/tests/renderers/httpdomain/rendered/v3.0/petstore-expanded.yaml.rst index 69adbda..4b2c966 100644 --- a/tests/renderers/httpdomain/rendered/v3.0/petstore-expanded.yaml.rst +++ b/tests/renderers/httpdomain/rendered/v3.0/petstore-expanded.yaml.rst @@ -11,6 +11,12 @@ :queryparam limit: maximum number of results to return :queryparamtype limit: integer:int32 + :resjsonarr name: + :resjsonarrtype name: string, required + :resjsonarr tag: + :resjsonarrtype tag: string + :resjsonarr id: + :resjsonarrtype id: integer:int64, required :statuscode 200: pet response @@ -29,7 +35,7 @@ :resjson name: - :resjsonobj name: string + :resjsonobj name: string, required :resjson tag: :resjsonobj tag: string :resjson id: @@ -49,7 +55,7 @@ ID of pet to fetch :paramtype id: integer:int64, required :resjson name: - :resjsonobj name: string + :resjsonobj name: string, required :resjson tag: :resjsonobj tag: string :resjson id: diff --git a/tests/renderers/httpdomain/test_render_json_schema_description.py b/tests/renderers/httpdomain/test_render_json_schema_description.py index f3f40dd..d7ddfec 100644 --- a/tests/renderers/httpdomain/test_render_json_schema_description.py +++ b/tests/renderers/httpdomain/test_render_json_schema_description.py @@ -1,5 +1,6 @@ """OpenAPI spec renderer: render_json_schema_description.""" +import copy import textwrap import pytest @@ -11,6 +12,187 @@ def textify(generator): return "\n".join(generator) +@pytest.mark.parametrize("req_or_res", ["req", "res"]) +@pytest.mark.parametrize("reverse", [False, True]) +def test_all_of_required_constraints(testrenderer, oas_fragment, req_or_res, reverse): + """allOf accumulates required names at every object level without mutation.""" + + schema = oas_fragment(""" + type: object + required: [own, profile] + properties: + own: + type: string + profile: + type: object + required: [first] + properties: + first: + type: string + allOf: + - required: [name, profile] + properties: + name: + type: [string, "null"] + profile: + required: [last] + properties: + last: + type: string + - required: [id, name] + properties: + id: + type: integer + name: + type: [string, "null"] + """) + if reverse: + schema["allOf"].reverse() + original_schema = copy.deepcopy(schema) + markup = textify(testrenderer.render_json_schema_description(schema, req_or_res)) + for name, schema_type in [ + ("own", "string"), + ("profile", "object"), + ("profile.first", "string"), + ("profile.last", "string"), + ("name", "string, null"), + ("id", "integer"), + ]: + assert f":{req_or_res}jsonobj {name}: {schema_type}, required" in markup + assert markup.count(", required") == 6 + assert schema == original_schema + assert ( + textify(testrenderer.render_json_schema_description(schema, req_or_res)) + == markup + ) + + +@pytest.mark.parametrize("req_or_res", ["req", "res"]) +@pytest.mark.parametrize("as_array", [False, True]) +def test_nested_all_of_required_constraints(testrenderer, req_or_res, as_array): + """Nested conjunctions retain requirements, including within array items.""" + + schema = { + "allOf": [ + { + "allOf": [ + { + "type": "object", + "required": ["first"], + "properties": {"first": {"type": "string"}}, + } + ] + }, + { + "allOf": [ + { + "type": "object", + "required": ["last"], + "properties": {"last": {"type": "string"}}, + } + ] + }, + ] + } + if as_array: + schema = {"type": "array", "items": schema} + markup = textify(testrenderer.render_json_schema_description(schema, req_or_res)) + type_directive = "jsonarrtype" if as_array else "jsonobj" + assert f":{req_or_res}{type_directive} first: string, required" in markup + assert f":{req_or_res}{type_directive} last: string, required" in markup + + +@pytest.mark.parametrize("req_or_res", ["req", "res"]) +@pytest.mark.parametrize("combining_keyword", ["allOf", "oneOf", "anyOf"]) +@pytest.mark.parametrize( + "field_schema, name, schema_type", + [ + ({"type": "string"}, "field", "string"), + ( + {"type": "object", "properties": {"name": {"type": "string"}}}, + "field", + "object", + ), + ({"type": "array", "items": {"type": "string"}}, "field[]", "string"), + ], +) +def test_required_composed_property( + testrenderer, req_or_res, combining_keyword, field_schema, name, schema_type +): + """A property's required marker survives composition and array traversal.""" + + schema = { + "type": "object", + "required": ["field"], + "properties": { + "field": {combining_keyword: [copy.deepcopy(field_schema)]}, + "optional": {combining_keyword: [copy.deepcopy(field_schema)]}, + }, + } + markup = textify(testrenderer.render_json_schema_description(schema, req_or_res)) + assert f":{req_or_res}jsonobj {name}: {schema_type}, required" in markup + optional_name = "optional[]" if name.endswith("[]") else "optional" + assert f":{req_or_res}jsonobj {optional_name}: {schema_type}" in markup + assert markup.count(", required") == 1 + + +@pytest.mark.parametrize("req_or_res", ["req", "res"]) +def test_all_of_nested_property_constraints(testrenderer, req_or_res): + """Conjunctions on the same nested property accumulate rather than replace.""" + + schema = { + "type": "object", + "required": ["profile"], + "allOf": [ + { + "properties": { + "profile": { + "allOf": [ + { + "type": "object", + "required": ["first"], + "properties": {"first": {"type": "string"}}, + } + ] + } + } + }, + { + "properties": { + "profile": { + "allOf": [ + { + "type": "object", + "required": ["last"], + "properties": {"last": {"type": "string"}}, + } + ] + } + } + }, + ], + } + markup = textify(testrenderer.render_json_schema_description(schema, req_or_res)) + assert f":{req_or_res}jsonobj profile: object, required" in markup + assert f":{req_or_res}jsonobj profile.first: string, required" in markup + assert f":{req_or_res}jsonobj profile.last: string, required" in markup + + +@pytest.mark.parametrize("req_or_res", ["req", "res"]) +def test_nested_all_of_false_schema(testrenderer, req_or_res): + """Flattening nested allOf must retain the meaning of false subschemas.""" + + schema = { + "allOf": [ + {"allOf": [False]}, + {"type": "object", "properties": {"name": {"type": "string"}}}, + ] + } + assert ( + textify(testrenderer.render_json_schema_description(schema, req_or_res)) == "" + ) + + @pytest.mark.parametrize("req_or_res", ["req", "res"]) @pytest.mark.parametrize( "schema", @@ -1233,7 +1415,7 @@ def test_render_json_schema_description_all_of_shared_type( properties: some_key: type: string - alOf: + allOf: - minLength: 3 - maxLength: 5 """), diff --git a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py index 41ddb10..988557c 100644 --- a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py +++ b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py @@ -598,6 +598,22 @@ def test_oas3_response_example_2xx(testrenderer, oas_fragment): """) +@pytest.mark.parametrize("version", ["v2.0", "v3.0"]) +def test_expanded_petstore_required_fields(testrenderer, get_testspec, version): + """The in-tree Pet schema requires both its inherited name and added id.""" + + markup = textify( + testrenderer.render_restructuredtext_markup( + get_testspec(version, "petstore-expanded.yaml") + ) + ) + assert markup.count(":resjsonobj name: string, required") == 2 + assert markup.count(":resjsonobj id: integer:int64, required") == 2 + assert ":resjsonarrtype name: string, required" in markup + assert ":resjsonarrtype id: integer:int64, required" in markup + assert ":reqjsonobj name: string, required" in markup + + def test_oas31_issue112_examples(fakestate, get_testspec): """The in-tree nullable schemas render with schema generation enabled.""" From 18ac1d65b97c7f904493548b2651a911611ee7b5 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 13:15:04 +0100 Subject: [PATCH 13/15] Preserve falsey examples and support schema examples arrays Select supplied examples by key presence rather than truthiness, and support the first literal value in OpenAPI 3.1 schema examples arrays. Retain media-level and legacy singular-example precedence, including when examples are zero, false, null or empty. Skip annotation-only Example Objects and fall back to schema examples. Recognize plural annotations during nested generation and allOf example selection, without treating an empty examples array as a supplied value. Serialize generated and plural-schema JSON instances correctly, while preserving legacy preformatted strings and fetched payloads. Keep fetched content and rendering metadata out of the input specification. Document the selection rules and add request, response and utility regressions covering falsey values, precedence and JSON serialization. Signed-off-by: Stephen Finucane Assisted-by: OpenAI GPT-6.1 (via pi) --- docs/index.rst | 8 + .../openapi/renderers/_httpdomain.py | 84 ++++++---- sphinxcontrib/openapi/schema_utils.py | 26 +++- .../test_render_request_body_example.py | 94 ++++++++++++ .../test_render_response_example.py | 145 ++++++++++++++++++ .../test_render_restructuredtext_markup.py | 6 +- tests/test_schema_utils.py | 71 +++++++++ 7 files changed, 395 insertions(+), 39 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 93ddce4..d0a88d8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -140,6 +140,14 @@ The following options are boolean flags. They can be passed without a value to e Controls rendering of request and response examples. If enabled, examples are generated from schemas when not supplied in the spec, and examples are rendered for all response codes unless ``response-examples-for`` is set. If disabled, neither supplied nor generated examples are rendered. + Supplied media-type ``example`` or named ``examples`` values take precedence over schema examples. + OpenAPI 3.1 schema-level ``examples`` is an array of literal instance values, not a mapping of Example Objects; the first entry is selected even when it is ``null``, ``false``, zero, or empty. + If a schema contains both the legacy singular ``example`` and plural ``examples``, the singular value takes precedence for compatibility. + Supplied schema examples do not require ``generate-examples-from-schemas`` to be enabled. + + For JSON media types, generated examples and schema-level ``examples`` entries are JSON-encoded, including strings. + Legacy nonempty string examples and fetched external payloads remain verbatim. + :Type: Flag. :Default: Supplied examples are rendered, but examples are not generated from schemas. diff --git a/sphinxcontrib/openapi/renderers/_httpdomain.py b/sphinxcontrib/openapi/renderers/_httpdomain.py index eedc400..cfde925 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -72,14 +72,17 @@ def _iterexamples(media_types, example_preference, examples_from_schemas): for content_type in _iterinorder(media_types, example_preference): media_type = media_types[content_type] + example = None - # Look for a example in a bunch of possible places. According to + # Look for an example in a bunch of possible places. According to # OpenAPI v3 spec, `examples` and `example` keys are mutually # exclusive, so there's no much difference between their # inspection order, while both must take precedence over a # schema example. if media_type.get("examples", {}): for example in media_type["examples"].values(): + # Keep fetched content and rendering metadata out of the spec. + example = example.copy() if "externalValue" in example: if not example["externalValue"].startswith(("http://", "https://")): logger.warning( @@ -93,6 +96,7 @@ def _iterexamples(media_types, example_preference, examples_from_schemas): response.raise_for_status() example["value"] = response.text + example["_serialize_json"] = False example.pop("externalValue") except Exception: logger.error( @@ -100,31 +104,39 @@ def _iterexamples(media_types, example_preference, examples_from_schemas): example["externalValue"], ) continue + # Example Objects can contain annotations without a value. + # An explicitly supplied falsey value is nevertheless valid. + if "value" not in example: + continue break else: - # If the loop over examples has not been interrupted, we - # probably didn't find an example to render. In that case, - # let's try and go next media type. - continue - elif media_type.get("example"): - # Save example from "example" in "examples" compatible format. This - # allows to treat all returned examples the same way. - example = {"value": media_type["example"]} - elif isinstance(media_type.get("schema"), dict) and media_type["schema"].get( - "example" - ): - # Save example from "schema" in "examples" compatible format. This - # allows to treat all returned examples the same way. - example = {"value": media_type["schema"]["example"]} - elif "schema" in media_type and examples_from_schemas: - # A valid OAS 3.1 schema can forbid all values. In that case there - # is no generated example to render; try another media type. - try: - example = {"value": example_from_schema(media_type["schema"])} - except NoExampleError: + example = None + + if example is None: + if "example" in media_type: + example = {"value": media_type["example"]} + elif isinstance(media_type.get("schema"), dict) and ( + "example" in media_type["schema"] + or media_type["schema"].get("examples") + ): + # Schema examples are literal values, unlike media-type examples. + # Preserve singular-example precedence for legacy specifications. + schema = media_type["schema"] + example = {"value": example_from_schema(schema)} + if "example" not in schema: + example["_serialize_json"] = True + elif "schema" in media_type and examples_from_schemas: + # A valid OAS 3.1 schema can forbid all values. In that case there + # is no generated example to render; try another media type. + try: + example = { + "value": example_from_schema(media_type["schema"]), + "_serialize_json": True, + } + except NoExampleError: + continue + else: continue - else: - continue yield content_type, example @@ -163,6 +175,7 @@ def _get_markers_from_object(oas_object, schema): def _is_json_mimetype(mimetype): """Returns 'True' if a given mimetype implies JSON data.""" + mimetype = mimetype.split(";", 1)[0].strip().lower() return any( [ mimetype == "application/json", @@ -171,6 +184,21 @@ def _is_json_mimetype(mimetype): ) +def _format_example(content_type, example): + """Serialize instance values while preserving legacy preformatted payloads.""" + + value = example["value"] + # Generated and schema-level plural examples are literal values. Legacy + # nonempty strings may contain preformatted payloads, but an empty string + # cannot be preformatted JSON and must be quoted unless fetched verbatim. + serialize_json = _is_json_mimetype(content_type) and example.get( + "_serialize_json", value == "" + ) + if not isinstance(value, str) or serialize_json: + return json.dumps(value, indent=2, default=str) + return value + + def _is_2xx_status(status_code): """Returns 'True' if a given status code is one of successful.""" @@ -560,10 +588,7 @@ def render_request_body_example(self, request_body, endpoint, method): ) if content_type and example: - example = example["value"] - - if not isinstance(example, str): - example = json.dumps(example, indent=2, default=str) + example = _format_example(content_type, example) yield ".. sourcecode:: http" yield "" @@ -665,10 +690,7 @@ def render_response_example(self, media_type, status_code): ) if content_type and example: - example = example["value"] - - if not isinstance(example, str): - example = json.dumps(example, indent=2, default=str) + example = _format_example(content_type, example) # According to OpenAPI v3 spec, status code may be a special value # - "default". It's not quite clear what to render in this case. diff --git a/sphinxcontrib/openapi/schema_utils.py b/sphinxcontrib/openapi/schema_utils.py index cfb1efd..545ef74 100644 --- a/sphinxcontrib/openapi/schema_utils.py +++ b/sphinxcontrib/openapi/schema_utils.py @@ -27,7 +27,17 @@ # '$ref' is absent because references are expected to be resolved before a # schema gets here, and 'default' because it's not consulted below. _EXAMPLE_KEYWORDS = frozenset( - ["example", "oneOf", "anyOf", "allOf", "enum", "type", "properties", "items"] + [ + "example", + "examples", + "oneOf", + "anyOf", + "allOf", + "enum", + "type", + "properties", + "items", + ] ) @@ -78,6 +88,11 @@ def example_from_schema(schema): if "example" in schema: return schema["example"] + elif schema.get("examples"): + # JSON Schema examples is an array of literal values, not the named + # Example Objects used by an OpenAPI Media Type Object. + return schema["examples"][0] + elif "oneOf" in schema or "anyOf" in schema: alternatives = schema.get("oneOf", schema.get("anyOf")) for alternative in alternatives: @@ -109,13 +124,18 @@ def example_from_schema(schema): # A subschema that carries annotations only, a lone 'description' # being the common case, has no example to contribute either. - if not _EXAMPLE_KEYWORDS & sub_schema.keys(): + example_keywords = _EXAMPLE_KEYWORDS & sub_schema.keys() + if not sub_schema.get("examples"): + example_keywords -= {"examples"} + if not example_keywords: continue sub_example = example_from_schema(sub_schema) if not isinstance(sub_example, dict): - is_explicit = "example" in sub_schema + is_explicit = "example" in sub_schema or bool( + sub_schema.get("examples") + ) if non_object_example is _NO_EXAMPLE or ( is_explicit and not non_object_example_is_explicit ): diff --git a/tests/renderers/httpdomain/test_render_request_body_example.py b/tests/renderers/httpdomain/test_render_request_body_example.py index 56aa59a..a9702d6 100644 --- a/tests/renderers/httpdomain/test_render_request_body_example.py +++ b/tests/renderers/httpdomain/test_render_request_body_example.py @@ -1,5 +1,6 @@ """OpenAPI spec renderer: render_request_body_example.""" +import json import textwrap import pytest @@ -12,6 +13,99 @@ def textify(generator): return "\n".join(generator) +@pytest.mark.parametrize("value", [0, 0.0, False, None, "", [], {}]) +@pytest.mark.parametrize( + "source", ["example", "examples", "schema/example", "schema/examples"] +) +@pytest.mark.parametrize("generate", [False, True]) +def test_render_request_body_explicit_falsey_example( + fakestate, value, source, generate +): + """Explicit falsey values override schema examples and generated defaults.""" + + schema = {"example": {"fallback": True}, "examples": [{"fallback": False}]} + media_type = {"schema": schema} + if source == "example": + media_type["example"] = value + elif source == "examples": + media_type["examples"] = { + "annotation": {"summary": "No value here"}, + "first": {"value": value}, + "second": {"value": "fallback"}, + } + elif source == "schema/example": + schema["example"] = value + else: + schema.pop("example") + schema["examples"] = [value, {"fallback": True}] + renderer = renderers.HttpdomainRenderer( + fakestate, {"generate-examples-from-schemas": generate} + ) + markup = textify( + renderer.render_request_body_example( + {"content": {"application/json": media_type}}, "/resources", "POST" + ) + ) + expected_lines = [ + ".. sourcecode:: http", + "", + " POST /resources HTTP/1.1", + " Content-Type: application/json", + "", + ] + [" " + line for line in json.dumps(value, indent=2).splitlines()] + assert markup == "\n".join(expected_lines) + + +@pytest.mark.parametrize("value", ["literal", '{"value": false}', {"value": False}]) +def test_render_request_body_schema_examples_literal_values(testrenderer, value): + """Schema examples are instance values, not named Example Objects or raw JSON.""" + + markup = textify( + testrenderer.render_request_body_example( + {"content": {"application/json": {"schema": {"examples": [value]}}}}, + "/resources", + "POST", + ) + ) + payload = textwrap.dedent(markup.split("Content-Type: application/json\n\n")[1]) + assert json.loads(payload) == value + + +def test_render_request_body_empty_schema_examples(fakestate): + """An empty schema examples array supplies no example, but permits generation.""" + + request_body = { + "content": { + "application/json": { + "schema": { + "type": "string", + "examples": [], + } + } + } + } + assert ( + textify( + renderers.HttpdomainRenderer(fakestate, {}).render_request_body_example( + request_body, "/resources", "POST" + ) + ) + == "" + ) + renderer = renderers.HttpdomainRenderer( + fakestate, {"generate-examples-from-schemas": True} + ) + markup = textify( + renderer.render_request_body_example(request_body, "/resources", "POST") + ) + assert ( + json.loads( + textwrap.dedent(markup.split("Content-Type: application/json\n\n")[1]) + ) + == "string" + ) + + @pytest.mark.parametrize( ["content"], [ diff --git a/tests/renderers/httpdomain/test_render_response_example.py b/tests/renderers/httpdomain/test_render_response_example.py index 0533641..04031a1 100644 --- a/tests/renderers/httpdomain/test_render_response_example.py +++ b/tests/renderers/httpdomain/test_render_response_example.py @@ -1,5 +1,6 @@ """OpenAPI spec renderer: render_response_example.""" +import json import sys import textwrap @@ -18,6 +19,150 @@ def textify(generator): return "\n".join(generator) +@pytest.mark.parametrize("value", [0, 0.0, False, None, "", [], {}]) +@pytest.mark.parametrize( + "source", ["example", "examples", "schema/example", "schema/examples"] +) +@pytest.mark.parametrize("generate", [False, True]) +def test_render_response_explicit_falsey_example(fakestate, value, source, generate): + """Explicit falsey values override schema examples and generated defaults.""" + + schema = {"example": {"fallback": True}, "examples": [{"fallback": False}]} + media_type = {"schema": schema} + if source == "example": + media_type["example"] = value + elif source == "examples": + media_type["examples"] = { + "annotation": {"summary": "No value here"}, + "first": {"value": value}, + "second": {"value": "fallback"}, + } + elif source == "schema/example": + schema["example"] = value + else: + schema.pop("example") + schema["examples"] = [value, {"fallback": True}] + renderer = renderers.HttpdomainRenderer( + fakestate, {"generate-examples-from-schemas": generate} + ) + markup = textify( + renderer.render_response_example({"application/json": media_type}, "200") + ) + expected_lines = [ + ".. sourcecode:: http", + "", + " HTTP/1.1 200 OK", + " Content-Type: application/json", + "", + ] + [" " + line for line in json.dumps(value, indent=2).splitlines()] + assert markup == "\n".join(expected_lines) + + +@pytest.mark.parametrize("value", ["literal", '{"value": false}', {"value": False}]) +def test_render_response_schema_examples_literal_values(testrenderer, value): + """Schema examples are instance values, not named Example Objects or raw JSON.""" + + markup = textify( + testrenderer.render_response_example( + {"application/json": {"schema": {"examples": [value]}}}, "200" + ) + ) + payload = textwrap.dedent(markup.split("Content-Type: application/json\n\n")[1]) + assert json.loads(payload) == value + + +@pytest.mark.parametrize("generate", [False, True]) +def test_render_response_annotation_only_examples(fakestate, generate): + """Example Objects without values do not hide available schema examples.""" + + content = { + "application/json": { + "examples": {"annotation": {"summary": "No value here"}}, + "schema": {"examples": [None]}, + } + } + renderer = renderers.HttpdomainRenderer( + fakestate, {"generate-examples-from-schemas": generate} + ) + markup = textify(renderer.render_response_example(content, "200")) + assert markup.endswith(" null") + + +@responses.activate +def test_render_response_external_empty_payload(testrenderer): + """Fetched payloads stay verbatim and do not mutate their Example Object.""" + + url = "https://example.com/empty.json" + responses.add(responses.GET, url, body="", content_type="application/json") + content = {"application/json": {"examples": {"external": {"externalValue": url}}}} + markup = textify(testrenderer.render_response_example(content, "200")) + assert markup == "\n".join( + [ + ".. sourcecode:: http", + "", + " HTTP/1.1 200 OK", + " Content-Type: application/json", + "", + ] + ) + assert content["application/json"]["examples"]["external"] == {"externalValue": url} + + +@pytest.mark.parametrize( + "content_type", + [ + "application/problem+json", + "APPLICATION/JSON", + "application/json; charset=utf-8", + ], +) +def test_render_response_schema_examples_json_media_type(testrenderer, content_type): + """JSON suffixes, case and media-type parameters do not change serialization.""" + + markup = textify( + testrenderer.render_response_example( + {content_type: {"schema": {"examples": ["literal"]}}}, "200" + ) + ) + payload = textwrap.dedent(markup.split(f"Content-Type: {content_type}\n\n")[1]) + assert json.loads(payload) == "literal" + + +def test_render_response_empty_text_example(testrenderer): + """JSON serialization must not quote empty text/plain examples.""" + + markup = textify( + testrenderer.render_response_example( + {"text/plain": {"schema": {"examples": [""]}}}, "200" + ) + ) + assert markup.endswith(" Content-Type: text/plain\n") + + +def test_render_response_empty_schema_examples(fakestate): + """An empty schema examples array supplies no example, but permits generation.""" + + content = {"application/json": {"schema": {"type": "string", "examples": []}}} + assert ( + textify( + renderers.HttpdomainRenderer(fakestate, {}).render_response_example( + content, "200" + ) + ) + == "" + ) + renderer = renderers.HttpdomainRenderer( + fakestate, {"generate-examples-from-schemas": True} + ) + markup = textify(renderer.render_response_example(content, "200")) + assert ( + json.loads( + textwrap.dedent(markup.split("Content-Type: application/json\n\n")[1]) + ) + == "string" + ) + + @pytest.mark.parametrize( ["media_type"], [ diff --git a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py index 988557c..d836e7c 100644 --- a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py +++ b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py @@ -667,11 +667,7 @@ def test_oas31_schema_examples(fakestate, schema, expected): markup = textify(renderer.render_restructuredtext_markup(spec)) assert "HTTP/1.1 200 OK" in markup body = markup.split("Content-Type: application/json\n\n")[1] - # String examples use the renderer's existing verbatim serialization. - if isinstance(expected, str): - assert body.strip() == expected - else: - assert json.loads(textwrap.dedent(body)) == expected + assert json.loads(textwrap.dedent(body)) == expected @pytest.mark.parametrize("generate_examples", [False, True]) diff --git a/tests/test_schema_utils.py b/tests/test_schema_utils.py index 6786a46..921dce4 100644 --- a/tests/test_schema_utils.py +++ b/tests/test_schema_utils.py @@ -359,6 +359,77 @@ def test_generate_example_from_schema(schema, expected): assert example_from_schema(schema) == expected +@pytest.mark.parametrize("value", [0, 0.0, False, None, "", [], {}, {"value": False}]) +def test_schema_examples(value): + """The first schema examples entry is a literal value, even when falsey.""" + + schema = {"examples": [value, "fallback"]} + original_schema = copy.deepcopy(schema) + example = example_from_schema(schema) + assert example == value + jsonschema.Draft202012Validator(schema).validate(example) + assert schema == original_schema + + +@pytest.mark.parametrize("value", [0, False, None, "", [], {}]) +def test_schema_example_precedes_examples(value): + """Keep singular example precedence when both schema annotations are present.""" + + assert example_from_schema({"example": value, "examples": ["fallback"]}) == value + + +@pytest.mark.parametrize( + "schema, expected", + [ + ({"type": "string", "examples": []}, "string"), + ( + {"type": "object", "properties": {"name": {"examples": [False]}}}, + {"name": False}, + ), + ({"type": "array", "items": {"examples": [0]}}, [0, 0]), + ({"oneOf": [{"examples": [0]}, {"type": "string"}]}, 0), + ({"anyOf": [{"examples": [False]}, {"type": "string"}]}, False), + ({"allOf": [{"type": "boolean"}, {"examples": [False]}]}, False), + ({"allOf": [{"examples": [False]}, {"type": "boolean"}]}, False), + ({"allOf": [{"type": ["string", "null"]}, {"examples": [None]}]}, None), + ({"allOf": [{"examples": [None]}, {"type": ["string", "null"]}]}, None), + ( + { + "allOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"examples": [[]]}, + ] + }, + [], + ), + ( + { + "allOf": [ + {"examples": [[]]}, + {"type": "array", "items": {"type": "integer"}}, + ] + }, + [], + ), + ( + { + "allOf": [ + {"type": "object", "properties": {"name": {"type": "string"}}}, + {"examples": []}, + ] + }, + {"name": "string"}, + ), + ], +) +def test_schema_examples_in_generation(schema, expected): + """Nested annotations contribute explicit examples without losing empty values.""" + + example = example_from_schema(schema) + assert example == expected + jsonschema.Draft202012Validator(schema).validate(example) + + @pytest.mark.parametrize("combining_keyword", ["oneOf", "anyOf"]) @pytest.mark.parametrize( "first_schema, expected_item", From 7d8636b7e8df2322c49fa34d6c7e3155ca718f12 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 15:24:49 +0100 Subject: [PATCH 14/15] Render callback and webhook operations in the HTTP domain Keep callbacks with their selected parent operation and render webhook-only specifications using a receiver URL placeholder, not a fabricated API path. Reuse operation rendering and Path Item parameter inheritance for outbound operations, including references, payloads and response descriptions. Register dedicated outbound HTTP descriptions that retain typed fields but do not acquire URL targets or incoming-route index entries. This prevents repeated callback expressions and webhook destinations from colliding. Document filtering and grouping semantics, verify operation coverage in all in-tree example smoke tests, and add strict callback/webhook builds. Isolate docutils registrations between test builds. Signed-off-by: Stephen Finucane Assisted-by: OpenAI GPT-6.1 (via pi) --- docs/index.rst | 16 ++ sphinxcontrib/openapi/__init__.py | 4 + sphinxcontrib/openapi/directive.py | 20 ++ .../openapi/renderers/_httpdomain.py | 116 +++++----- tests/conftest.py | 33 +-- .../test_render_restructuredtext_markup.py | 141 +++++++++++++ tests/test_spec_examples.py | 198 +++++++++++++----- 7 files changed, 405 insertions(+), 123 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index d0a88d8..3aca45f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -46,6 +46,22 @@ or: .. openapi:httpdomain:old:: specs/openapi.yml +Callbacks and webhooks +====================== + +The ``httpdomain`` renderer displays OpenAPI 3 callbacks beneath their parent operation and OpenAPI 3.1 webhooks in separate, named admonitions. +These describe requests initiated by the API provider, not incoming API routes, and are not registered in the HTTP domain's route index. +Callback URL runtime expressions are displayed without evaluating them. +Webhook names are not URLs; ``{webhook_url}`` stands for the receiver's URL. +Webhook-only documents are supported, while components-only documents have no operations to render. + +``paths``, ``include``, ``exclude`` and ``methods`` select incoming operations. +Callbacks accompany their selected parent operation, including callback methods not listed in ``methods``. +Webhooks are rendered independently of these incoming-path filters. +Grouping applies to incoming operations; callbacks stay with their parent and webhooks stay in their own admonitions. +Example, request-schema and method-order options apply to outbound operations as well. + + Configuration ============= diff --git a/sphinxcontrib/openapi/__init__.py b/sphinxcontrib/openapi/__init__.py index d5e0448..a7cba87 100644 --- a/sphinxcontrib/openapi/__init__.py +++ b/sphinxcontrib/openapi/__init__.py @@ -78,6 +78,10 @@ def setup(app): ) app.setup_extension("sphinxcontrib.httpdomain") + for method in ('get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'): + app.add_directive_to_domain( + 'http', 'openapi-' + method, directive.create_outbound_directive(method), + ) app.connect("config-inited", _register_rendering_directives) return {"version": __version__, "parallel_read_safe": True} diff --git a/sphinxcontrib/openapi/directive.py b/sphinxcontrib/openapi/directive.py index d42785a..3b3507f 100644 --- a/sphinxcontrib/openapi/directive.py +++ b/sphinxcontrib/openapi/directive.py @@ -23,6 +23,26 @@ def _get_spec(abspath, encoding): return yaml.safe_load(stream) +def create_outbound_directive(http_method): + """Create an HTTP description without URL targets or route registration.""" + from sphinxcontrib.httpdomain import HTTPResource + + class _OutboundResource(HTTPResource): + method = http_method + + def run(self): + self.options['noindex'] = None + return super().run() + + def add_target_and_index(self, name_cls, sig, signode): + # A callback expression or webhook destination is not an incoming + # API route. Even noindex resources acquire anchors in some + # httpdomain versions, so explicitly suppress those as well. + pass + + return _OutboundResource + + def create_directive_from_renderer(renderer_cls): """Create rendering directive from a renderer class.""" diff --git a/sphinxcontrib/openapi/renderers/_httpdomain.py b/sphinxcontrib/openapi/renderers/_httpdomain.py index cfde925..3e087ad 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -378,6 +378,41 @@ def render_restructuredtext_markup(self, spec): if spec.get("swagger") == "2.0": spec = lib2to3.convert(spec) yield from self.render_paths(spec.get("paths", {}), spec=spec) + for name, path in spec.get("webhooks", {}).items(): + yield "" + yield f".. admonition:: Webhook: {name}" + yield "" + yield " The API provider sends this request to the webhook receiver URL." + yield " The webhook name is not an API path; ``{webhook_url}`` is a placeholder." + yield "" + for endpoint, method, operation in self._iter_path_operations( + "{webhook_url}", path + ): + yield from indented( + self.render_operation(endpoint, method, operation, outbound=True) + ) + yield "" + + def _iter_path_operations(self, endpoint, path, allowed_methods=None): + """Yield operations with their own Path Item parameter inheritance.""" + common_parameters = path.get("parameters", []) + methods = [ + method + for method in path + if isinstance(method, str) and method.lower() in _HTTP_METHODS + ] + for method in _iterinorder(methods, self._http_methods_order): + if allowed_methods is not None and method.lower() not in allowed_methods: + continue + operation = copy.deepcopy(path[method]) + operation.setdefault("parameters", []) + parameter_ids = {(p["name"], p["in"]) for p in operation["parameters"]} + operation["parameters"] = [ + p + for p in common_parameters + if (p["name"], p["in"]) not in parameter_ids + ] + operation["parameters"] + yield endpoint, method, operation def render_paths(self, paths, spec=None): """Render OAS paths item.""" @@ -423,30 +458,9 @@ def render_paths(self, paths, spec=None): groups = collections.OrderedDict([(t, []) for t in tags_order]) for endpoint in paths_to_render: - path = paths[endpoint] - common_parameters = path.get("parameters", []) - - for method in _iterinorder(path, self._http_methods_order): - if method.lower() not in _HTTP_METHODS: - continue - if ( - allowed_methods is not None - and method.lower() not in allowed_methods - ): - continue - operation = copy.deepcopy(path[method]) - operation.setdefault("parameters", []) - operation_parameters_ids = set( - (parameter["name"], parameter["in"]) - for parameter in operation["parameters"] - ) - operation["parameters"] = [ - parameter - for parameter in common_parameters - if (parameter["name"], parameter["in"]) - not in operation_parameters_ids - ] + operation["parameters"] - + for endpoint, method, operation in self._iter_path_operations( + endpoint, paths[endpoint], allowed_methods + ): tag = ( operation.get("tags", [""])[0] if operation.get("tags") else "" ) @@ -461,37 +475,17 @@ def render_paths(self, paths, spec=None): yield "" else: for endpoint in paths_to_render: - path = paths[endpoint] - common_parameters = path.get("parameters", []) - - for method in _iterinorder(path, self._http_methods_order): - if method.lower() not in _HTTP_METHODS: - continue - if ( - allowed_methods is not None - and method.lower() not in allowed_methods - ): - continue - operation = copy.deepcopy(path[method]) - operation.setdefault("parameters", []) - operation_parameters_ids = set( - (parameter["name"], parameter["in"]) - for parameter in operation["parameters"] - ) - operation["parameters"] = [ - parameter - for parameter in common_parameters - if (parameter["name"], parameter["in"]) - not in operation_parameters_ids - ] + operation["parameters"] - + for endpoint, method, operation in self._iter_path_operations( + endpoint, paths[endpoint], allowed_methods + ): yield from self.render_operation(endpoint, method, operation) yield "" - def render_operation(self, endpoint, method, operation): + def render_operation(self, endpoint, method, operation, *, outbound=False): """Render OAS operation item.""" - yield f".. http:{method}:: {endpoint}" + directive_method = "openapi-" + method if outbound else method + yield f".. http:{directive_method}:: {endpoint}" if operation.get("deprecated"): yield " :deprecated:" @@ -513,6 +507,28 @@ def render_operation(self, endpoint, method, operation): self.render_request_body(operation["requestBody"], endpoint, method) ) yield from indented(self.render_responses(operation.get("responses", {}))) + for name, callback in operation.get("callbacks", {}).items(): + yield "" + yield f" .. admonition:: Callback: {name}" + yield "" + yield " The API provider sends this request to the callback URL." + yield "" + for expression, path in callback.items(): + # Callback Objects support scalar vendor-extension metadata. + if not isinstance(expression, str) or expression.startswith("x-"): + continue + for ( + endpoint, + callback_method, + callback_operation, + ) in self._iter_path_operations(expression, path): + yield from indented( + self.render_operation( + endpoint, callback_method, callback_operation, outbound=True + ), + 6, + ) + yield "" def render_parameters(self, parameters): """Render OAS operation's parameters.""" diff --git a/tests/conftest.py b/tests/conftest.py index 4059326..e0142ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ import yaml from sphinx.application import Sphinx +from sphinx.util.docutils import docutils_namespace from sphinxcontrib.openapi import utils @@ -59,7 +60,7 @@ def run_sphinx(tmpdir): src = tmpdir.ensure('src', dir=True) out = tmpdir.ensure('out', dir=True) - def run(spec, options={}, renderer=None): + def run(spec, options={}, renderer=None, *, warningiserror=False, return_app=False): options_raw = '\n'.join([ ' %s' % _format_option_raw(key, val) for key, val in options.items()]) @@ -85,21 +86,23 @@ def run(spec, options={}, renderer=None): '.. openapi:: %s\n%s' % (spec, options_raw), encoding='utf-8') - # Warnings are captured and returned so tests can assert on them. They - # keep going to stderr as well, so that a test that doesn't care about - # them still shows them when it fails. + # Preserve the warning-text result used by the option tests. Tests + # inspecting the doctree or domain registry can explicitly request app. warning = _Tee(sys.stderr) - - Sphinx( - srcdir=src.strpath, - confdir=src.strpath, - outdir=out.strpath, - doctreedir=out.join('.doctrees').strpath, - buildername='html', - warning=warning - ).build() - - return warning.getvalue() + # Isolate docutils registrations between Sphinx builds so strict + # warning checks do not fail on prior tests' nodes and directives. + with docutils_namespace(): + app = Sphinx( + srcdir=src.strpath, + confdir=src.strpath, + outdir=out.strpath, + doctreedir=out.join('.doctrees').strpath, + buildername='html', + warning=warning, + warningiserror=warningiserror, + ) + app.build() + return app if return_app else warning.getvalue() yield run diff --git a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py index d836e7c..d62e172 100644 --- a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py +++ b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py @@ -1009,3 +1009,144 @@ def test_oas2_inherited_request_body(fakestate, group): assert "POST /resources HTTP/1.1" in markup assert '"name": "An inherited body"' in markup assert ":statuscode 201:" in markup + + +def outbound_path(): + return { + "summary": "Path metadata", + "x-notes": "Path metadata", + "parameters": [ + {"name": "token", "in": "header", "schema": {"type": "string"}}, + {"name": "trace", "in": "header", "schema": {"type": "string"}}, + ], + "post": { + "summary": "A notification", + "parameters": [ + {"name": "token", "in": "header", "schema": {"type": "integer"}}, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event", + } + } + } + }, + "responses": {"202": {"description": "Notification accepted"}}, + }, + } + + +@pytest.mark.parametrize("group", [False, True]) +@pytest.mark.parametrize("version", ["3.0.3", "3.1.0"]) +def test_referenced_callbacks(fakestate, version, group): + """Callbacks retain their own inheritance, references and payloads.""" + + expression = "{$request.query.callbackUrl}/data" + spec = make_spec( + version, + { + "get": { + "parameters": [ + {"name": "parent", "in": "query", "schema": {"type": "string"}} + ], + "responses": {"200": {"description": "Subscribed"}}, + "callbacks": {"onEvent": {"$ref": "#/components/callbacks/Event"}}, + } + }, + ) + spec["components"] = { + "callbacks": { + "Event": {expression: outbound_path(), "x-notes": "Callback metadata"} + }, + "schemas": { + "Event": { + "type": "object", + "required": ["active"], + "properties": {"active": {"type": "boolean"}}, + "example": {"active": False}, + } + }, + } + renderer = renderers.HttpdomainRenderer( + fakestate, {"group": group, "paths": ["/resources"], "methods": ["get"]} + ) + markup = textify(renderer.render_restructuredtext_markup(spec)) + assert ".. admonition:: Callback: onEvent" in markup + assert f".. http:openapi-post:: {expression}" in markup + callback_markup = markup.split(".. admonition:: Callback: onEvent")[1] + assert callback_markup.count(":reqheader token:") == 1 + assert ":reqheadertype token: integer" in callback_markup + assert ":reqheader trace:" in callback_markup + assert ":queryparam parent:" not in callback_markup + assert ":reqjsonobj active: boolean, required" in callback_markup + assert '"active": false' in callback_markup + assert ":statuscode 202:" in callback_markup + assert "Path metadata" not in markup + assert "Callback metadata" not in markup + assert textify(renderer.render_restructuredtext_markup(spec)) == markup + filtered = renderers.HttpdomainRenderer(fakestate, {"exclude": ["/resources"]}) + assert textify(filtered.render_restructuredtext_markup(spec)) == "" + + +@pytest.mark.parametrize("group", [False, True]) +@pytest.mark.parametrize("examples", [False, True]) +@pytest.mark.parametrize("render_request", [False, True]) +def test_referenced_webhook_only_spec(fakestate, group, examples, render_request): + """Webhook-only specs render independently of incoming-path filters.""" + + spec = { + "openapi": "3.1.0", + "info": {"title": "Events", "version": "1"}, + "webhooks": {"newEvent": {"$ref": "#/components/pathItems/Event"}}, + "components": { + "pathItems": {"Event": outbound_path()}, + "schemas": { + "Event": { + "type": "object", + "required": ["active"], + "properties": {"active": {"type": "boolean"}}, + "example": {"active": False}, + } + }, + }, + } + renderer = renderers.HttpdomainRenderer( + fakestate, + { + "group": group, + "examples": examples, + "request": render_request, + "exclude": [".*"], + "methods": ["get"], + }, + ) + markup = textify(renderer.render_restructuredtext_markup(spec)) + assert ".. admonition:: Webhook: newEvent" in markup + assert ".. http:openapi-post:: {webhook_url}" in markup + assert ":reqheader token:" in markup + assert ":reqheadertype token: integer" in markup + assert ":reqheader trace:" in markup + assert (":reqjsonobj active: boolean, required" in markup) == render_request + assert ('"active": false' in markup) == examples + assert ":statuscode 202:" in markup + assert ".. http:post:: newEvent" not in markup + assert "Path metadata" not in markup + + +def test_components_only_spec(testrenderer): + """A valid components-only OAS 3.1 document has no operations to render.""" + + assert ( + textify( + testrenderer.render_restructuredtext_markup( + { + "openapi": "3.1.0", + "info": {"title": "Models", "version": "1"}, + "components": {"schemas": {"Event": {"type": "object"}}}, + } + ) + ) + == "" + ) diff --git a/tests/test_spec_examples.py b/tests/test_spec_examples.py index 652abb1..0bde53c 100644 --- a/tests/test_spec_examples.py +++ b/tests/test_spec_examples.py @@ -1,68 +1,150 @@ -"""Smoke test examples from OAI/learn.openapis.org repo.""" +"""Build and verify examples from OAI/learn.openapis.org.""" -import os +import collections import glob -import itertools +import os +import pathlib +from docutils import nodes import py import pytest +from sphinx import addnodes +import yaml +from sphinxcontrib.openapi import utils -@pytest.mark.parametrize('spec', itertools.chain( - glob.glob( - os.path.join( - os.path.abspath(os.path.dirname(__file__)), - 'examples', - 'v2.0', - 'json', - '*.json')), - - glob.glob( - os.path.join( - os.path.abspath(os.path.dirname(__file__)), - 'examples', - 'v2.0', - 'yaml', - '*.yaml')), -)) + +_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"} + + +def _operation_signatures(endpoint, path): + for method, operation in path.items(): + if method not in _METHODS: + continue + yield f"{method.upper()} {endpoint}" + for callback in operation.get("callbacks", {}).values(): + for expression, callback_path in callback.items(): + if expression.startswith("x-"): + continue + yield from _operation_signatures(expression, callback_path) + + +def assert_rendered_operations(app, specfile, render_examples=None): + """A successful build must not silently omit specified operations.""" + assert app.statuscode == 0 + with open(specfile, encoding="utf-8") as stream: + spec = yaml.safe_load(stream) + spec = utils._resolve_refs(pathlib.Path(specfile).as_uri(), spec) + expected = [] + for endpoint, path in spec.get("paths", {}).items(): + if not endpoint.startswith("x-"): + expected.extend(_operation_signatures(endpoint, path)) + for path in spec.get("webhooks", {}).values(): + expected.extend(_operation_signatures("{webhook_url}", path)) + doctree = app.env.get_doctree("index") + actual = [ + node[0].astext() for node in doctree.findall(addnodes.desc) + if node.get("domain") == "http" + ] + assert collections.Counter(actual) == collections.Counter(expected) + if render_examples is False: + assert not any( + node.get("language") == "http" + for node in doctree.findall(nodes.literal_block) + ) + return doctree + + +@pytest.mark.parametrize("spec", [ + *glob.glob(os.path.join(os.path.dirname(__file__), "examples", "v2.0", "json", "*.json")), + *glob.glob(os.path.join(os.path.dirname(__file__), "examples", "v2.0", "yaml", "*.yaml")), +]) def test_openapi2_success(tmpdir, run_sphinx, spec): - py.path.local(spec).copy(tmpdir.join('src', 'test-spec.yml')) - run_sphinx('test-spec.yml') - - -@pytest.mark.parametrize('group_paths', [False, True]) -@pytest.mark.parametrize('render_examples', [False, True]) -@pytest.mark.parametrize('render_request', [False, True]) -@pytest.mark.parametrize('spec', glob.glob( - os.path.join( - os.path.abspath(os.path.dirname(__file__)), - 'examples', - 'v3.0', - '*.yaml') + py.path.local(spec).copy(tmpdir.join("src", "test-spec.yml")) + app = run_sphinx("test-spec.yml", renderer="httpdomain", return_app=True) + assert_rendered_operations(app, os.path.abspath(spec)) + + +@pytest.mark.parametrize("group_paths", [False, True]) +@pytest.mark.parametrize("render_examples", [False, True]) +@pytest.mark.parametrize("render_request", [False, True]) +@pytest.mark.parametrize("spec", glob.glob( + os.path.join(os.path.dirname(__file__), "examples", "v3.0", "*.yaml") )) -def test_openapi30_success(tmpdir, run_sphinx, spec, render_examples, - render_request, group_paths): - py.path.local(spec).copy(tmpdir.join('src', 'test-spec.yml')) - run_sphinx('test-spec.yml', options={ - 'examples': render_examples, - 'request': render_request, - 'group': group_paths, - }) - - -@pytest.mark.parametrize('render_examples', [False, True]) -@pytest.mark.parametrize('render_request', [False, True]) -@pytest.mark.parametrize('spec', glob.glob( - os.path.join( - os.path.abspath(os.path.dirname(__file__)), - 'examples', - 'v3.1', - '*.yaml') +def test_openapi30_success(tmpdir, run_sphinx, spec, render_examples, render_request, group_paths): + py.path.local(spec).copy(tmpdir.join("src", "test-spec.yml")) + app = run_sphinx("test-spec.yml", options={ + "examples": render_examples, "request": render_request, "group": group_paths, + }, renderer="httpdomain", return_app=True, + warningiserror=os.path.basename(spec) == "callback-example.yaml") + doctree = assert_rendered_operations(app, os.path.abspath(spec), render_examples) + if os.path.basename(spec) == "callback-example.yaml": + text = doctree.astext() + assert "Callback: onData" in text + assert "Your server implementation should return this HTTP status code" in text + assert ("timestamp" in text) == (render_request or render_examples) + assert ("userData" in text) == (render_request or render_examples) + + +@pytest.mark.parametrize("render_examples", [False, True]) +@pytest.mark.parametrize("render_request", [False, True]) +@pytest.mark.parametrize("spec", glob.glob( + os.path.join(os.path.dirname(__file__), "examples", "v3.1", "*.yaml") )) -def test_openapi31_success(tmpdir, run_sphinx, spec, render_examples, - render_request): - py.path.local(spec).copy(tmpdir.join('src', 'test-spec.yml')) - run_sphinx('test-spec.yml', options={ - 'examples': render_examples, - 'request': render_request, - }) +def test_openapi31_success(tmpdir, run_sphinx, spec, render_examples, render_request): + py.path.local(spec).copy(tmpdir.join("src", "test-spec.yml")) + app = run_sphinx("test-spec.yml", options={ + "examples": render_examples, "request": render_request, + }, renderer="httpdomain", return_app=True, + warningiserror=os.path.basename(spec) == "webhook-example.yaml") + doctree = assert_rendered_operations(app, os.path.abspath(spec), render_examples) + if os.path.basename(spec) == "webhook-example.yaml": + text = doctree.astext() + assert "Webhook: newPet" in text + assert "Return a 200 status to indicate that the data was received successfully" in text + assert ("Request JSON Object" in text) == render_request + assert any( + node.get("language") == "http" + for node in doctree.findall(nodes.literal_block) + ) == render_examples + + +@pytest.mark.parametrize("group", [False, True]) +def test_outbound_operations_do_not_register_routes(tmpdir, run_sphinx, group): + """Repeated callback URLs and webhook destinations must not clash with routes.""" + path = { + "post": { + "requestBody": {"content": {"application/json": { + "schema": {"type": "object", "properties": {"active": {"type": "boolean"}}}, + }}}, + "responses": {"200": {"description": "Accepted"}}, + }, + } + callback = {"callbacks": {"onEvent": {"/shared": path}}, + "responses": {"200": {"description": "Subscribed"}}} + spec = { + "openapi": "3.1.0", "info": {"title": "Events", "version": "1"}, + "paths": {"/first": {"get": callback}, "/second": {"get": callback}, "/shared": path}, + "webhooks": {"firstEvent": path, "secondEvent": path}, + } + specfile = tmpdir.join("src", "events.yaml") + specfile.write_text(yaml.safe_dump(spec), encoding="utf-8") + app = run_sphinx( + "events.yaml", options={"group": group}, + renderer="httpdomain", return_app=True, warningiserror=True, + ) + doctree = assert_rendered_operations(app, str(specfile)) + http = app.env.get_domain("http") + assert set(http.data["get"]) == {"/first", "/second"} + assert set(http.data["post"]) == {"/shared"} + outbound = [node for node in doctree.findall(addnodes.desc) + if node.get("objtype", "").startswith("openapi-")] + assert len(outbound) == 4 + for node in outbound: + assert not node[0].get("ids") + assert "Request JSON Object" in node.astext() + assert "active" in node.astext() + html = tmpdir.join("out", "index.html").read_text("utf-8") + assert "Callback: onEvent" in html + assert "Webhook: firstEvent" in html + assert "Webhook: secondEvent" in html From 6fd5c4359062ac4061f8e875213dc904b70cf5e3 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Sat, 10 Oct 2026 15:33:24 +0100 Subject: [PATCH 15/15] Make all in-tree example builds pass strict warning checks Enable warnings-as-errors for every example smoke test now that docutils registrations are isolated between builds and operation coverage is checked. Use plain response fields for OpenAPI default and uppercase response ranges rather than passing them to HTTP-domain roles that require numeric codes. Quote JSON examples when their schema explicitly declares a string, retain verbatim fetched and legacy structured payloads, and use default form encoding for object-valued application/x-www-form-urlencoded examples. Separate parameter fields from request body literal blocks so examples remain valid reStructuredText when request-schema descriptions are disabled or unavailable. Add regressions and refresh affected rendering snapshots. Signed-off-by: Stephen Finucane Assisted-by: OpenAI GPT-6.1 (via pi) --- docs/index.rst | 5 +- .../openapi/renderers/_httpdomain.py | 42 +++++++++++++++- .../rendered/v2.0/petstore-expanded.yaml.rst | 8 +-- .../rendered/v2.0/petstore.yaml.rst | 6 +-- .../httpdomain/rendered/v2.0/uber.json.rst | 10 ++-- .../httpdomain/rendered/v2.0/uber.yaml.rst | 10 ++-- .../rendered/v3.0/petstore-expanded.yaml.rst | 8 +-- .../rendered/v3.0/petstore.yaml.rst | 6 +-- .../httpdomain/rendered/v3.0/uspto.yaml.rst | 1 + .../test_render_request_body_example.py | 50 +++++++++++++++++++ .../httpdomain/test_render_response.py | 7 +-- .../test_render_response_example.py | 17 +++++++ .../httpdomain/test_render_responses.py | 2 +- tests/test_spec_examples.py | 29 +++++++++-- 14 files changed, 166 insertions(+), 35 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 3aca45f..f196b90 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -121,6 +121,7 @@ The following options take a value. The response codes to render samples for. An explicit value takes precedence over the ``examples`` flag. + Numeric responses use HTTP-domain status-code fields; OpenAPI ``default`` and ``1XX``–``5XX`` responses use plain response fields because they do not identify a single HTTP status code. :Type: A whitespace-delimited sequence of strings, with each string corresponding to an `HTTP status code `__ that response examples should be rendered for. :Default: ``["200", "201", "202", "2XX"]`` @@ -162,7 +163,9 @@ The following options are boolean flags. They can be passed without a value to e Supplied schema examples do not require ``generate-examples-from-schemas`` to be enabled. For JSON media types, generated examples and schema-level ``examples`` entries are JSON-encoded, including strings. - Legacy nonempty string examples and fetched external payloads remain verbatim. + Examples with an explicitly declared string type are JSON-encoded too. + Legacy nonempty strings for untyped or structured schemas, and fetched external payloads, remain verbatim. + Object-valued examples for ``application/x-www-form-urlencoded`` use default form encoding, rather than a JSON body. :Type: Flag. :Default: Supplied examples are rendered, but examples are not generated from schemas. diff --git a/sphinxcontrib/openapi/renderers/_httpdomain.py b/sphinxcontrib/openapi/renderers/_httpdomain.py index 3e087ad..a72450e 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -7,6 +7,7 @@ import http.client import json import re +from urllib.parse import urlencode import deepmerge import docutils.parsers.rst.directives as directives @@ -138,6 +139,16 @@ def _iterexamples(media_types, example_preference, examples_from_schemas): else: continue + schema = media_type.get("schema") + if isinstance(schema, dict): + schema_types = schema.get("type", []) + if isinstance(schema_types, str): + schema_types = [schema_types] + if "string" in schema_types: + # A declared string schema makes these JSON instance values, + # not legacy preformatted structured payloads. Fetched content + # retains its explicit verbatim marker. + example.setdefault("_serialize_json", True) yield content_type, example @@ -188,6 +199,23 @@ def _format_example(content_type, example): """Serialize instance values while preserving legacy preformatted payloads.""" value = example["value"] + mimetype = content_type.split(";", 1)[0].strip().lower() + if mimetype == "application/x-www-form-urlencoded" and isinstance(value, dict): + + def form_value(item): + if isinstance(item, str): + return item + return json.dumps(item, default=str) + + fields = { + name: ( + [form_value(item) for item in field] + if isinstance(field, list) + else form_value(field) + ) + for name, field in value.items() + } + return urlencode(fields, doseq=True) # Generated and schema-level plural examples are literal values. Legacy # nonempty strings may contain preformatted payloads, but an empty string # cannot be preformatted JSON and must be quoted unless fetched verbatim. @@ -503,6 +531,10 @@ def render_operation(self, endpoint, method, operation, *, outbound=False): yield from indented(self.render_parameters(operation.get("parameters", []))) if "requestBody" in operation: + if operation.get("parameters"): + # The body can begin with a literal block rather than more + # fields, particularly for form data or primitive schemas. + yield "" yield from indented( self.render_request_body(operation["requestBody"], endpoint, method) ) @@ -637,7 +669,15 @@ def render_responses(self, responses): def render_response(self, status_code, response): """Render OAS operation's response.""" - yield f":statuscode {status_code}:" + status_code = str(status_code) + # httpdomain's statuscode field creates numeric HTTP status roles. + # OpenAPI's fallback and range keys are not individual status codes. + field = ( + "response" + if status_code == "default" or re.fullmatch(r"[1-5]XX", status_code) + else "statuscode" + ) + yield f":{field} {status_code}:" yield from indented( self._convert_markup(response["description"]).strip().splitlines() ) diff --git a/tests/renderers/httpdomain/rendered/v2.0/petstore-expanded.yaml.rst b/tests/renderers/httpdomain/rendered/v2.0/petstore-expanded.yaml.rst index b89da78..4ce37a4 100644 --- a/tests/renderers/httpdomain/rendered/v2.0/petstore-expanded.yaml.rst +++ b/tests/renderers/httpdomain/rendered/v2.0/petstore-expanded.yaml.rst @@ -18,7 +18,7 @@ :statuscode 200: pet response - :statuscode default: + :response default: unexpected error .. http:post:: /pets @@ -41,7 +41,7 @@ :statuscode 200: pet response - :statuscode default: + :response default: unexpected error .. http:get:: /pets/{id} @@ -61,7 +61,7 @@ :statuscode 200: pet response - :statuscode default: + :response default: unexpected error .. http:delete:: /pets/{id} @@ -73,5 +73,5 @@ :paramtype id: integer:int64, required :statuscode 204: pet deleted - :statuscode default: + :response default: unexpected error diff --git a/tests/renderers/httpdomain/rendered/v2.0/petstore.yaml.rst b/tests/renderers/httpdomain/rendered/v2.0/petstore.yaml.rst index 63a2914..73240db 100644 --- a/tests/renderers/httpdomain/rendered/v2.0/petstore.yaml.rst +++ b/tests/renderers/httpdomain/rendered/v2.0/petstore.yaml.rst @@ -19,7 +19,7 @@ :resheader x-next: A link to the next page of responses :resheadertype x-next: string - :statuscode default: + :response default: unexpected error .. http:post:: /pets @@ -28,7 +28,7 @@ :statuscode 201: Null response - :statuscode default: + :response default: unexpected error .. http:get:: /pets/{petId} @@ -48,5 +48,5 @@ :statuscode 200: Expected response to a valid request - :statuscode default: + :response default: unexpected error diff --git a/tests/renderers/httpdomain/rendered/v2.0/uber.json.rst b/tests/renderers/httpdomain/rendered/v2.0/uber.json.rst index ff7367d..2fb2dc0 100644 --- a/tests/renderers/httpdomain/rendered/v2.0/uber.json.rst +++ b/tests/renderers/httpdomain/rendered/v2.0/uber.json.rst @@ -29,7 +29,7 @@ :statuscode 200: An array of products - :statuscode default: + :response default: Unexpected error .. http:get:: /estimates/price @@ -79,7 +79,7 @@ :statuscode 200: An array of price estimates by product - :statuscode default: + :response default: Unexpected error .. http:get:: /estimates/time @@ -119,7 +119,7 @@ :statuscode 200: An array of products - :statuscode default: + :response default: Unexpected error .. http:get:: /me @@ -147,7 +147,7 @@ :statuscode 200: Profile information for a user - :statuscode default: + :response default: Unexpected error .. http:get:: /history @@ -184,5 +184,5 @@ :statuscode 200: History information for the given user - :statuscode default: + :response default: Unexpected error diff --git a/tests/renderers/httpdomain/rendered/v2.0/uber.yaml.rst b/tests/renderers/httpdomain/rendered/v2.0/uber.yaml.rst index 97d838a..4b9ff4f 100644 --- a/tests/renderers/httpdomain/rendered/v2.0/uber.yaml.rst +++ b/tests/renderers/httpdomain/rendered/v2.0/uber.yaml.rst @@ -29,7 +29,7 @@ :statuscode 200: An array of products - :statuscode default: + :response default: Unexpected error .. http:get:: /estimates/price @@ -79,7 +79,7 @@ :statuscode 200: An array of price estimates by product - :statuscode default: + :response default: Unexpected error .. http:get:: /estimates/time @@ -119,7 +119,7 @@ :statuscode 200: An array of products - :statuscode default: + :response default: Unexpected error .. http:get:: /me @@ -147,7 +147,7 @@ :statuscode 200: Profile information for a user - :statuscode default: + :response default: Unexpected error .. http:get:: /history @@ -184,5 +184,5 @@ :statuscode 200: History information for the given user - :statuscode default: + :response default: Unexpected error diff --git a/tests/renderers/httpdomain/rendered/v3.0/petstore-expanded.yaml.rst b/tests/renderers/httpdomain/rendered/v3.0/petstore-expanded.yaml.rst index 4b2c966..f7ed51a 100644 --- a/tests/renderers/httpdomain/rendered/v3.0/petstore-expanded.yaml.rst +++ b/tests/renderers/httpdomain/rendered/v3.0/petstore-expanded.yaml.rst @@ -21,7 +21,7 @@ :statuscode 200: pet response - :statuscode default: + :response default: unexpected error .. http:post:: /pets @@ -44,7 +44,7 @@ :statuscode 200: pet response - :statuscode default: + :response default: unexpected error .. http:get:: /pets/{id} @@ -64,7 +64,7 @@ :statuscode 200: pet response - :statuscode default: + :response default: unexpected error .. http:delete:: /pets/{id} @@ -76,5 +76,5 @@ :paramtype id: integer:int64, required :statuscode 204: pet deleted - :statuscode default: + :response default: unexpected error diff --git a/tests/renderers/httpdomain/rendered/v3.0/petstore.yaml.rst b/tests/renderers/httpdomain/rendered/v3.0/petstore.yaml.rst index 63a2914..73240db 100644 --- a/tests/renderers/httpdomain/rendered/v3.0/petstore.yaml.rst +++ b/tests/renderers/httpdomain/rendered/v3.0/petstore.yaml.rst @@ -19,7 +19,7 @@ :resheader x-next: A link to the next page of responses :resheadertype x-next: string - :statuscode default: + :response default: unexpected error .. http:post:: /pets @@ -28,7 +28,7 @@ :statuscode 201: Null response - :statuscode default: + :response default: unexpected error .. http:get:: /pets/{petId} @@ -48,5 +48,5 @@ :statuscode 200: Expected response to a valid request - :statuscode default: + :response default: unexpected error diff --git a/tests/renderers/httpdomain/rendered/v3.0/uspto.yaml.rst b/tests/renderers/httpdomain/rendered/v3.0/uspto.yaml.rst index 6cd0c0a..d8781fe 100644 --- a/tests/renderers/httpdomain/rendered/v3.0/uspto.yaml.rst +++ b/tests/renderers/httpdomain/rendered/v3.0/uspto.yaml.rst @@ -78,6 +78,7 @@ :paramtype dataset: string, required + :statuscode 200: successful operation diff --git a/tests/renderers/httpdomain/test_render_request_body_example.py b/tests/renderers/httpdomain/test_render_request_body_example.py index a9702d6..2323a53 100644 --- a/tests/renderers/httpdomain/test_render_request_body_example.py +++ b/tests/renderers/httpdomain/test_render_request_body_example.py @@ -13,6 +13,56 @@ def textify(generator): return "\n".join(generator) +@pytest.mark.parametrize("source", ["example", "examples", "schema/example"]) +def test_render_request_body_declared_string_example(testrenderer, source): + """A string instance is quoted for JSON even with legacy example keywords.""" + media_type = {"schema": {"type": ["string", "null"]}} + if source == "examples": + media_type["examples"] = {"first": {"value": "."}} + elif source == "example": + media_type["example"] = "." + else: + media_type["schema"]["example"] = "." + markup = textify( + testrenderer.render_request_body_example( + {"content": {"application/json": media_type}}, "/resources", "POST" + ) + ) + payload = textwrap.dedent(markup.split("Content-Type: application/json\n\n")[1]) + assert json.loads(payload) == "." + + +def test_render_request_body_form_example(fakestate): + """Generated form values use form encoding, including primitive arrays.""" + renderer = renderers.HttpdomainRenderer( + fakestate, {"generate-examples-from-schemas": True} + ) + markup = textify( + renderer.render_request_body_example( + { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "term": {"type": "string", "example": "a b&c"}, + "enabled": {"type": "boolean", "example": False}, + "tags": { + "type": "array", + "items": {"type": "integer", "example": 0}, + }, + }, + } + }, + } + }, + "/resources", + "POST", + ) + ) + assert markup.endswith(" term=a+b%26c&enabled=false&tags=0&tags=0") + + @pytest.mark.parametrize("value", [0, 0.0, False, None, "", [], {}]) @pytest.mark.parametrize( "source", ["example", "examples", "schema/example", "schema/examples"] diff --git a/tests/renderers/httpdomain/test_render_response.py b/tests/renderers/httpdomain/test_render_response.py index 37d1af8..6c6face 100644 --- a/tests/renderers/httpdomain/test_render_response.py +++ b/tests/renderers/httpdomain/test_render_response.py @@ -30,10 +30,11 @@ def test_render_response_status_code(testrenderer, oas_fragment, statuscode): """), ) ) - assert markup == textwrap.dedent("""\ - :statuscode %s: + field = "statuscode" if statuscode == "200" else "response" + assert markup == textwrap.dedent(f"""\ + :{field} {statuscode}: An evidence. - """.rstrip() % statuscode) + """.rstrip()) def test_render_response_minimal(testrenderer, oas_fragment): diff --git a/tests/renderers/httpdomain/test_render_response_example.py b/tests/renderers/httpdomain/test_render_response_example.py index 04031a1..db2cd22 100644 --- a/tests/renderers/httpdomain/test_render_response_example.py +++ b/tests/renderers/httpdomain/test_render_response_example.py @@ -19,6 +19,23 @@ def textify(generator): return "\n".join(generator) +@pytest.mark.parametrize("source", ["example", "examples", "schema/example"]) +def test_render_response_declared_string_example(testrenderer, source): + """A string instance is quoted for JSON even with legacy example keywords.""" + media_type = {"schema": {"type": "string"}} + if source == "examples": + media_type["examples"] = {"first": {"value": "."}} + elif source == "example": + media_type["example"] = "." + else: + media_type["schema"]["example"] = "." + markup = textify( + testrenderer.render_response_example({"application/json": media_type}, "200") + ) + payload = textwrap.dedent(markup.split("Content-Type: application/json\n\n")[1]) + assert json.loads(payload) == "." + + @pytest.mark.parametrize("value", [0, 0.0, False, None, "", [], {}]) @pytest.mark.parametrize( "source", ["example", "examples", "schema/example", "schema/examples"] diff --git a/tests/renderers/httpdomain/test_render_responses.py b/tests/renderers/httpdomain/test_render_responses.py index bf16403..19c55b1 100644 --- a/tests/renderers/httpdomain/test_render_responses.py +++ b/tests/renderers/httpdomain/test_render_responses.py @@ -60,7 +60,7 @@ def test_render_responses_default_example(fakestate, oas_fragment): result: error """))) assert markup == textwrap.dedent("""\ - :statuscode default: + :response default: A fallback response. .. sourcecode:: http diff --git a/tests/test_spec_examples.py b/tests/test_spec_examples.py index 0bde53c..f175520 100644 --- a/tests/test_spec_examples.py +++ b/tests/test_spec_examples.py @@ -61,7 +61,9 @@ def assert_rendered_operations(app, specfile, render_examples=None): ]) def test_openapi2_success(tmpdir, run_sphinx, spec): py.path.local(spec).copy(tmpdir.join("src", "test-spec.yml")) - app = run_sphinx("test-spec.yml", renderer="httpdomain", return_app=True) + app = run_sphinx( + "test-spec.yml", renderer="httpdomain", return_app=True, warningiserror=True, + ) assert_rendered_operations(app, os.path.abspath(spec)) @@ -75,8 +77,7 @@ def test_openapi30_success(tmpdir, run_sphinx, spec, render_examples, render_req py.path.local(spec).copy(tmpdir.join("src", "test-spec.yml")) app = run_sphinx("test-spec.yml", options={ "examples": render_examples, "request": render_request, "group": group_paths, - }, renderer="httpdomain", return_app=True, - warningiserror=os.path.basename(spec) == "callback-example.yaml") + }, renderer="httpdomain", return_app=True, warningiserror=True) doctree = assert_rendered_operations(app, os.path.abspath(spec), render_examples) if os.path.basename(spec) == "callback-example.yaml": text = doctree.astext() @@ -95,8 +96,7 @@ def test_openapi31_success(tmpdir, run_sphinx, spec, render_examples, render_req py.path.local(spec).copy(tmpdir.join("src", "test-spec.yml")) app = run_sphinx("test-spec.yml", options={ "examples": render_examples, "request": render_request, - }, renderer="httpdomain", return_app=True, - warningiserror=os.path.basename(spec) == "webhook-example.yaml") + }, renderer="httpdomain", return_app=True, warningiserror=True) doctree = assert_rendered_operations(app, os.path.abspath(spec), render_examples) if os.path.basename(spec) == "webhook-example.yaml": text = doctree.astext() @@ -148,3 +148,22 @@ def test_outbound_operations_do_not_register_routes(tmpdir, run_sphinx, group): assert "Callback: onEvent" in html assert "Webhook: firstEvent" in html assert "Webhook: secondEvent" in html + + +@pytest.mark.parametrize("status", ["default", "1XX", "2XX", "3XX", "4XX", "5XX"]) +def test_response_fallback_and_range_fields(tmpdir, run_sphinx, status): + """OpenAPI response keys are preserved without invoking numeric HTTP roles.""" + spec = { + "openapi": "3.1.0", "info": {"title": "Responses", "version": "1"}, + "paths": {"/test": {"get": {"responses": { + status: {"description": "A fallback or range response"}, + }}}}, + } + specfile = tmpdir.join("src", "responses.yaml") + specfile.write_text(yaml.safe_dump(spec), encoding="utf-8") + app = run_sphinx( + "responses.yaml", renderer="httpdomain", return_app=True, warningiserror=True, + ) + doctree = assert_rendered_operations(app, str(specfile)) + assert f"Response {status}" in doctree.astext() + assert "A fallback or range response" in doctree.astext()