diff --git a/docs/index.rst b/docs/index.rst index 7f03bc10..f196b90e 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 ============= @@ -104,6 +120,9 @@ 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. + 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"]`` @@ -131,17 +150,51 @@ 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. + + 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. + 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. + +``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. + + 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`` - 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/__init__.py b/sphinxcontrib/openapi/__init__.py index d5e04481..a7cba87d 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/_lib2to3.py b/sphinxcontrib/openapi/_lib2to3.py index 273470fc..978fd734 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/sphinxcontrib/openapi/directive.py b/sphinxcontrib/openapi/directive.py index d42785ab..3b3507f6 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 bc5d72de..a72450e1 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -6,6 +6,8 @@ import functools import http.client import json +import re +from urllib.parse import urlencode import deepmerge import docutils.parsers.rst.directives as directives @@ -15,7 +17,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 @@ -71,14 +73,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( @@ -92,6 +97,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( @@ -99,27 +105,50 @@ 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. + 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 - 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 media_type.get("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 - 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 @@ -127,16 +156,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 = 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") @@ -156,6 +186,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", @@ -164,6 +195,38 @@ def _is_json_mimetype(mimetype): ) +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. + 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.""" @@ -173,6 +236,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 @@ -190,13 +256,63 @@ 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 + + +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 +_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) + + +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}") + + +def _header(title): + yield title + yield "=" * len(title) + yield "" + + class HttpdomainRenderer(abc.RestructuredTextRenderer): """Render OpenAPI v3 using `sphinxcontrib-httpdomain` extension.""" @@ -209,43 +325,79 @@ 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, + "paths": _split_option, + "include": _split_option, + "exclude": _split_option, + "methods": _split_option, + "group": _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") - ] - 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 + 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] + + 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} + 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"} + + 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 = ( + _to_list(options.get("response-example-preference")) + or self._example_preference ) - self._request_parameters_order = [ - parameter_type.lower() - for parameter_type in options.get( - "request-parameters-order", self._request_parameters_order + self._render_examples = bool(options.get("examples", True)) + self._generate_example_from_schema = bool( + options.get( + "generate-examples-from-schemas", options.get("examples", False) ) - ] - self._example_preference = options.get("example-preference") - self._request_example_preference = options.get( - "request-example-preference", self._example_preference ) - self._response_example_preference = options.get( - "response-example-preference", self._example_preference + 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._generate_example_from_schema = "generate-examples-from-schemas" in options - self._json_schema_description = "no-json-schema-description" not in options def render_restructuredtext_markup(self, spec): """Spec render entry point.""" @@ -253,42 +405,115 @@ 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) + 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 render_paths(self, paths): + 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.""" - for endpoint, path in paths.items(): - common_parameters = path.pop("parameters", []) - - # 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) - - for method in _iterinorder(path, self._http_methods_order): - operation = path[method] - operation.setdefault("parameters", []) - operation_parameters_ids = set( - (parameter["name"], parameter["in"]) - for parameter in operation["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))),) ) - 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 "" + 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_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: + allowed_methods = {m.lower() for m in _to_list(self._options["methods"])} + + 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 endpoint in paths_to_render: + for endpoint, method, operation in self._iter_path_operations( + endpoint, paths[endpoint], allowed_methods + ): + 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 + 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: + 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:" @@ -306,10 +531,36 @@ def render_operation(self, endpoint, method, operation): 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) ) - yield from indented(self.render_responses(operation["responses"])) + 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.""" @@ -357,7 +608,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( @@ -372,6 +623,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"], @@ -382,10 +636,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) + example = _format_example(content_type, example) yield ".. sourcecode:: http" yield "" @@ -418,12 +669,27 @@ 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() ) - 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) @@ -460,6 +726,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 @@ -477,10 +746,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) + 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. @@ -493,11 +759,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" @@ -511,20 +776,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 schema - 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 False + if first is not True: + merged_schema.update(first) return merged_schema elif "allOf" in schema: @@ -533,9 +800,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"): - merged_schema = _merge_mappings(merged_schema, copy.deepcopy(item)) + item = _resolve_combining_schema(item) + if item is False: + return False + if item is True: + continue + merged_schema = _merge_mappings(merged_schema, item) return merged_schema elif "not" in schema: @@ -545,7 +817,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 @@ -555,7 +829,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 @@ -577,7 +853,9 @@ 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}[]", is_required=is_required + ) elif "enum" in schema: yield name, schema, is_required @@ -586,7 +864,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. @@ -616,11 +894,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 = _resolve_combining_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 dab2985b..545ef748 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, } @@ -26,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", + ] ) @@ -35,6 +46,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 +78,29 @@ 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 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 "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,20 +116,26 @@ 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' # 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 ): @@ -117,36 +152,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()) - elif isinstance(items, dict) and "oneOf" in items: - # Mixed-type arrays - example_items.append(_DEFAULT_EXAMPLES[sorted(items["oneOf"])[0]]) else: - example_items.append(example_from_schema(items)) + # Treat the whole item schema like any other schema, preserving + # explicit examples before considering oneOf/anyOf subschemas. + try: + example_items.append(example_from_schema(items)) + except NoExampleError: + if len(example) < min_length: + raise + return example - # 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)] + 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/sphinxcontrib/openapi/utils.py b/sphinxcontrib/openapi/utils.py index daeedc2f..be6fd8cb 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/conftest.py b/tests/conftest.py index 4059326d..e0142ff3 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/lib2to3/test_convert_responses.py b/tests/lib2to3/test_convert_responses.py index 2630ac90..f758179b 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(""" 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 e9e63847..4ce37a4f 100644 --- a/tests/renderers/httpdomain/rendered/v2.0/petstore-expanded.yaml.rst +++ b/tests/renderers/httpdomain/rendered/v2.0/petstore-expanded.yaml.rst @@ -8,11 +8,17 @@ :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 - :statuscode default: + :response default: unexpected error .. http:post:: /pets @@ -26,7 +32,7 @@ :resjson name: - :resjsonobj name: string + :resjsonobj name: string, required :resjson tag: :resjsonobj tag: string :resjson id: @@ -35,7 +41,7 @@ :statuscode 200: pet response - :statuscode default: + :response default: unexpected error .. http:get:: /pets/{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: @@ -55,7 +61,7 @@ :statuscode 200: pet response - :statuscode default: + :response default: unexpected error .. http:delete:: /pets/{id} @@ -67,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 63a29142..73240db7 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 ff7367d2..2fb2dc04 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 97d838ab..4b9ff4fc 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 69adbda6..f7ed51ab 100644 --- a/tests/renderers/httpdomain/rendered/v3.0/petstore-expanded.yaml.rst +++ b/tests/renderers/httpdomain/rendered/v3.0/petstore-expanded.yaml.rst @@ -11,11 +11,17 @@ :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 - :statuscode default: + :response default: unexpected error .. http:post:: /pets @@ -29,7 +35,7 @@ :resjson name: - :resjsonobj name: string + :resjsonobj name: string, required :resjson tag: :resjsonobj tag: string :resjson id: @@ -38,7 +44,7 @@ :statuscode 200: pet response - :statuscode default: + :response default: unexpected error .. http:get:: /pets/{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: @@ -58,7 +64,7 @@ :statuscode 200: pet response - :statuscode default: + :response default: unexpected error .. http:delete:: /pets/{id} @@ -70,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 63a29142..73240db7 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 6cd0c0a1..d8781fe1 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_options.py b/tests/renderers/httpdomain/test_options.py index 04329e5c..e04b35b8 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_json_schema_description.py b/tests/renderers/httpdomain/test_render_json_schema_description.py index b9bc51c6..d7ddfec0 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,291 @@ 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", + [ + 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"], [ @@ -1129,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_operation.py b/tests/renderers/httpdomain/test_render_operation.py index e95093f3..9687aeac 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" diff --git a/tests/renderers/httpdomain/test_render_parameters.py b/tests/renderers/httpdomain/test_render_parameters.py index 79701fee..95597106 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 c4f727d2..0e628ecb 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 @@ -386,3 +388,205 @@ 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 + """) + + +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 + """) + + +@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.""" + + 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 + """) + + +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 + """) diff --git a/tests/renderers/httpdomain/test_render_request_body_example.py b/tests/renderers/httpdomain/test_render_request_body_example.py index 56aa59a7..2323a530 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,149 @@ 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"] +) +@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.py b/tests/renderers/httpdomain/test_render_response.py index 37d1af84..6c6facec 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 86cad426..db2cd22d 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,167 @@ 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"] +) +@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"], [ @@ -588,3 +750,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 6b4460fc..19c55b1f 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("""\ + :response 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.""" diff --git a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py index 73ce7f3f..d62e1724 100644 --- a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py +++ b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py @@ -1,7 +1,11 @@ """OpenAPI spec renderer: render_restructuredtext_markup.""" +import copy +import json import textwrap +import pytest + from sphinxcontrib.openapi import renderers @@ -400,6 +404,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 ): @@ -551,3 +596,557 @@ def test_oas3_response_example_2xx(testrenderer, oas_fragment): :statuscode 404: resource not found """) + + +@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.""" + + 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] + 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( + "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"}, + "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 + + +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_openapi.py b/tests/test_openapi.py index 9ba9590e..c01c92b0 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', diff --git a/tests/test_schema_utils.py b/tests/test_schema_utils.py index bb4a1bd5..921dce40 100644 --- a/tests/test_schema_utils.py +++ b/tests/test_schema_utils.py @@ -1,6 +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( @@ -125,7 +128,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 +357,252 @@ ) 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", + [ + ({"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( + "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.""" + + 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) diff --git a/tests/test_spec_examples.py b/tests/test_spec_examples.py index 652abb1c..f175520b 100644 --- a/tests/test_spec_examples.py +++ b/tests/test_spec_examples.py @@ -1,68 +1,169 @@ -"""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, warningiserror=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=True) + 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=True) + 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 + + +@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() diff --git a/tests/testspecs/v3.1/issue-112.yaml b/tests/testspecs/v3.1/issue-112.yaml index 5b53906e..920f37d3 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"