diff --git a/sphinxcontrib/openapi/schema_utils.py b/sphinxcontrib/openapi/schema_utils.py index eefb5b8..dab2985 100644 --- a/sphinxcontrib/openapi/schema_utils.py +++ b/sphinxcontrib/openapi/schema_utils.py @@ -21,6 +21,20 @@ } +# Keywords an example can possibly be derived from. A schema that has none of +# them carries annotations only, and thus contributes no example. Please note, +# '$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"] +) + + +# Tells "no example has been found yet" apart from an example that is 'None', +# which is what an explicit 'example: null' produces. Never returned. +_NO_EXAMPLE = object() + + def example_from_schema(schema): """ Generates an example request/response body from the provided schema. @@ -62,8 +76,42 @@ def example_from_schema(schema): elif "allOf" in schema: # Combine schema examples example = {} + + # Merging examples only makes sense for objects. If a subschema is of + # any other type, an instance of the composed schema is a value of that + # type, so the composed example is that value and there's nothing to + # merge it into. Every subschema is still visited, so that the outcome + # doesn't depend on their order, and an explicitly provided example wins + # over one derived from a type. + non_object_example = _NO_EXAMPLE + non_object_example_is_explicit = False + for sub_schema in schema["allOf"]: - example.update(example_from_schema(sub_schema)) + # OAS 3.1 allows a subschema to be a boolean, which carries no + # example to contribute. + if not isinstance(sub_schema, dict): + 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(): + continue + + sub_example = example_from_schema(sub_schema) + + if not isinstance(sub_example, dict): + is_explicit = "example" in sub_schema + if non_object_example is _NO_EXAMPLE or ( + is_explicit and not non_object_example_is_explicit + ): + non_object_example = sub_example + non_object_example_is_explicit = is_explicit + continue + + example.update(sub_example) + + if non_object_example is not _NO_EXAMPLE: + return non_object_example return example elif "enum" in schema: diff --git a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py index a984ccf..73ce7f3 100644 --- a/tests/renderers/httpdomain/test_render_restructuredtext_markup.py +++ b/tests/renderers/httpdomain/test_render_restructuredtext_markup.py @@ -400,6 +400,62 @@ def test_oas3_generate_examples_from_schema(fakestate, oas_fragment): """) +def test_oas3_generate_examples_from_schema_with_composed_property( + fakestate, oas_fragment +): + """Schema of a property composed with 'allOf' can be used to generate an example.""" + + testrenderer = renderers.HttpdomainRenderer( + fakestate, {"generate-examples-from-schemas": True} + ) + markup = textify(testrenderer.render_restructuredtext_markup(oas_fragment(""" + openapi: 3.0.3 + info: + title: An example spec + version: 1.0 + paths: + /test: + get: + description: an operation description + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + description: a property description + allOf: + - type: string + enum: + - PENDING + - RUNNING + description: a response description + """))) + assert markup == textwrap.dedent("""\ + .. http:get:: /test + + an operation description + + :resjson status: + a property description + :resjsonobj status: string:enum + + :statuscode 200: + a response description + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "PENDING" + } + """) + + def test_oas3_request_body(testrenderer, oas_fragment): """Request body example is rendered.""" diff --git a/tests/test_schema_utils.py b/tests/test_schema_utils.py index 7102e9e..bb4a1bd 100644 --- a/tests/test_schema_utils.py +++ b/tests/test_schema_utils.py @@ -199,6 +199,109 @@ }, id="oneOf_anyOf_allOf", ), + pytest.param( + { + "type": "object", + "properties": { + "timestamp": { + "allOf": [{"type": "string", "example": "2020-01-01T01:01:01Z"}] + }, + "status": { + "description": "The status of the job.", + "allOf": [{"type": "string", "enum": ["PENDING", "RUNNING"]}], + }, + "count": {"allOf": [{"type": "integer"}]}, + "tags": {"allOf": [{"type": "array", "items": {"type": "string"}}]}, + }, + }, + { + "timestamp": "2020-01-01T01:01:01Z", + "status": "PENDING", + "count": 1, + "tags": ["string", "string"], + }, + id="allOf_of_non_object", + ), + pytest.param( + { + "type": "object", + "properties": { + "annotated": { + "allOf": [ + { + "type": "object", + "properties": {"one": {"type": "string"}}, + }, + {"description": "this only annotates the schema above"}, + ] + }, + }, + }, + {"annotated": {"one": "string"}}, + id="allOf_with_annotation_only_subschema", + ), + pytest.param( + { + "type": "object", + "properties": { + "explicit_last": { + "allOf": [ + {"type": "string"}, + {"type": "string", "example": "PENDING"}, + ] + }, + "explicit_first": { + "allOf": [ + {"type": "string", "example": "PENDING"}, + {"type": "string"}, + ] + }, + }, + }, + {"explicit_last": "PENDING", "explicit_first": "PENDING"}, + id="allOf_of_non_object_prefers_explicit_example", + ), + pytest.param( + { + "type": "object", + "properties": { + "boolean_subschema": { + "allOf": [ + True, + { + "type": "object", + "properties": {"one": {"type": "string"}}, + }, + ] + }, + "only_boolean_subschema": {"allOf": [True]}, + }, + }, + {"boolean_subschema": {"one": "string"}, "only_boolean_subschema": {}}, + id="allOf_with_boolean_subschema", + ), + pytest.param( + # A composition of an object and a non-object cannot be satisfied, + # so there's no sensible example to generate. Rendering the + # non-object one at least keeps the type of the last word on what an + # instance looks like. + { + "type": "object", + "properties": { + "unsatisfiable": { + "allOf": [ + { + "type": "object", + "properties": {"one": {"type": "string"}}, + }, + {"type": "string", "example": "PENDING"}, + ] + }, + }, + }, + {"unsatisfiable": "PENDING"}, + id="allOf_of_object_and_non_object", + ), pytest.param( { "type": "object",