Skip to content

Commit 5b014b4

Browse files
committed
Render JSON schema description
Render JSON request/response JSON schema descriptions using httpdomain's '<json' and '>json' field lists. There are couple of nuances though. First, only JSON object and JSON arrays are supported on top level. That said, if your endpoint returns a bare number of string, the description won't be rendered. Second, the description is rendered only for 2XX status code. It won't be rendered for anything else.
1 parent 4e08f1a commit 5b014b4

13 files changed

+2102
-4
lines changed

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"jsonschema >= 2.5.1",
3333
"m2r >= 0.2",
3434
"picobox >= 2.2",
35+
"deepmerge >= 0.1",
3536
],
3637
project_urls={
3738
"Documentation": "https://sphinxcontrib-openapi.readthedocs.io/",

sphinxcontrib/openapi/renderers/_httpdomain.py

Lines changed: 213 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""OpenAPI spec renderer."""
22

33
import collections
4+
import copy
45
import functools
56
import http.client
67
import json
78

9+
import deepmerge
810
import docutils.parsers.rst.directives as directives
911
import m2r
1012
import requests
@@ -112,21 +114,72 @@ def _get_markers_from_object(oas_object, schema):
112114

113115
markers = []
114116

115-
if schema.get("type"):
116-
type_ = schema["type"]
117+
schema_type = _get_schema_type(schema)
118+
if schema_type:
117119
if schema.get("format"):
118-
type_ = f"{type_}:{schema['format']}"
119-
markers.append(type_)
120+
schema_type = f"{schema_type}:{schema['format']}"
121+
elif schema.get("enum"):
122+
schema_type = f"{schema_type}:enum"
123+
markers.append(schema_type)
124+
elif schema.get("enum"):
125+
markers.append("enum")
120126

121127
if oas_object.get("required"):
122128
markers.append("required")
123129

124130
if oas_object.get("deprecated"):
125131
markers.append("deprecated")
126132

133+
if schema.get("deprecated"):
134+
markers.append("deprecated")
135+
127136
return markers
128137

129138

139+
def _is_json_mimetype(mimetype):
140+
"""Returns 'True' if a given mimetype implies JSON data."""
141+
142+
return any(
143+
[
144+
mimetype == "application/json",
145+
mimetype.startswith("application/") and mimetype.endswith("+json"),
146+
]
147+
)
148+
149+
150+
def _is_2xx_status(status_code):
151+
"""Returns 'True' if a given status code is one of successful."""
152+
153+
return str(status_code).startswith("2")
154+
155+
156+
def _get_schema_type(schema):
157+
"""Retrieve schema type either by reading 'type' or guessing."""
158+
159+
# There are a lot of OpenAPI specs out there that may lack 'type' property
160+
# in their schemas. I fount no explanations on what is expected behaviour
161+
# in this case neither in OpenAPI nor in JSON Schema specifications. Thus
162+
# let's assume what everyone assumes, and try to guess schema type at least
163+
# for two most popular types: 'object' and 'array'.
164+
if "type" not in schema:
165+
if "properties" in schema:
166+
schema_type = "object"
167+
elif "items" in schema:
168+
schema_type = "array"
169+
else:
170+
schema_type = None
171+
else:
172+
schema_type = schema["type"]
173+
return schema_type
174+
175+
176+
_merge_mappings = deepmerge.Merger(
177+
[(collections.Mapping, deepmerge.strategy.dict.DictStrategies("merge"))],
178+
["override"],
179+
["override"],
180+
).merge
181+
182+
130183
class HttpdomainRenderer(abc.RestructuredTextRenderer):
131184
"""Render OpenAPI v3 using `sphinxcontrib-httpdomain` extension."""
132185

@@ -143,6 +196,7 @@ class HttpdomainRenderer(abc.RestructuredTextRenderer):
143196
"request-example-preference": None,
144197
"response-example-preference": None,
145198
"generate-examples-from-schemas": directives.flag,
199+
"no-json-schema-description": directives.flag,
146200
}
147201

148202
def __init__(self, state, options):
@@ -171,6 +225,7 @@ def __init__(self, state, options):
171225
"response-example-preference", self._example_preference
172226
)
173227
self._generate_example_from_schema = "generate-examples-from-schemas" in options
228+
self._json_schema_description = "no-json-schema-description" not in options
174229

175230
def render_restructuredtext_markup(self, spec):
176231
"""Spec render entry point."""
@@ -281,6 +336,15 @@ def render_parameter(self, parameter):
281336
def render_request_body(self, request_body, endpoint, method):
282337
"""Render OAS operation's requestBody."""
283338

339+
if self._json_schema_description:
340+
for content_type, content in request_body["content"].items():
341+
if _is_json_mimetype(content_type) and content.get("schema"):
342+
yield from self.render_json_schema_description(
343+
content["schema"], "req"
344+
)
345+
yield ""
346+
break
347+
284348
yield from self.render_request_body_example(request_body, endpoint, method)
285349
yield ""
286350

@@ -312,6 +376,18 @@ def render_request_body_example(self, request_body, endpoint, method):
312376
def render_responses(self, responses):
313377
"""Render OAS operation's responses."""
314378

379+
if self._json_schema_description:
380+
for status_code, response in responses.items():
381+
if _is_2xx_status(status_code):
382+
for content_type, content in response.get("content", {}).items():
383+
if _is_json_mimetype(content_type) and content.get("schema"):
384+
yield from self.render_json_schema_description(
385+
content["schema"], "res"
386+
)
387+
yield ""
388+
break
389+
break
390+
315391
for status_code, response in responses.items():
316392
# Due to the way how YAML spec is parsed, status code may be
317393
# infered as integer. In order to spare some cycles on type
@@ -409,3 +485,136 @@ def render_response_example(self, media_type, status_code):
409485
yield f" Content-Type: {content_type}"
410486
yield f""
411487
yield from indented(example.splitlines())
488+
489+
def render_json_schema_description(self, schema, req_or_res):
490+
"""Render JSON schema's description."""
491+
492+
def _resolve_combining_schema(schema):
493+
if "oneOf" in schema:
494+
# The part with merging is a vague one since I only found a
495+
# single 'oneOf' example where such merging was assumed, and no
496+
# explanations in the spec itself.
497+
merged_schema = schema.copy()
498+
merged_schema.update(merged_schema.pop("oneOf")[0])
499+
return merged_schema
500+
501+
elif "anyOf" in schema:
502+
# The part with merging is a vague one since I only found a
503+
# single 'oneOf' example where such merging was assumed, and no
504+
# explanations in the spec itself.
505+
merged_schema = schema.copy()
506+
merged_schema.update(merged_schema.pop("anyOf")[0])
507+
return merged_schema
508+
509+
elif "allOf" in schema:
510+
# Since the item is represented by all schemas from the array,
511+
# the best we can do is to render them all at once
512+
# sequentially. Please note, the only way the end result will
513+
# ever make sense is when all schemas from the array are of
514+
# object type.
515+
merged_schema = schema.copy()
516+
for item in merged_schema.pop("allOf"):
517+
merged_schema = _merge_mappings(merged_schema, copy.deepcopy(item))
518+
return merged_schema
519+
520+
elif "not" in schema:
521+
# Eh.. do nothing because I have no idea what can we do.
522+
return {}
523+
524+
return schema
525+
526+
def _traverse_schema(schema, name, is_required=False):
527+
schema_type = _get_schema_type(schema)
528+
529+
if {"oneOf", "anyOf", "allOf"} & schema.keys():
530+
# Since an item can represented by either or any schema from
531+
# the array of schema in case of `oneOf` and `anyOf`
532+
# respectively, the best we can do for them is to render the
533+
# first found variant. In other words, we are going to traverse
534+
# only a single schema variant and leave the rest out. This is
535+
# by design and it was decided so in order to keep produced
536+
# description clear and simple.
537+
yield from _traverse_schema(_resolve_combining_schema(schema), name)
538+
539+
elif "not" in schema:
540+
yield name, {}, is_required
541+
542+
elif schema_type == "object":
543+
if name:
544+
yield name, schema, is_required
545+
546+
required = set(schema.get("required", []))
547+
548+
for key, value in schema.get("properties", {}).items():
549+
# In case of the first recursion call, when 'name' is an
550+
# empty string, we should go with 'key' only in order to
551+
# avoid leading dot at the beginning.
552+
yield from _traverse_schema(
553+
value,
554+
f"{name}.{key}" if name else key,
555+
is_required=key in required,
556+
)
557+
558+
elif schema_type == "array":
559+
yield from _traverse_schema(schema["items"], f"{name}[]")
560+
561+
elif "enum" in schema:
562+
yield name, schema, is_required
563+
564+
elif schema_type is not None:
565+
yield name, schema, is_required
566+
567+
schema = _resolve_combining_schema(schema)
568+
schema_type = _get_schema_type(schema)
569+
570+
# On root level, httpdomain supports only 'object' and 'array' response
571+
# types. If it's something else, let's do not even try to render it.
572+
if schema_type not in {"object", "array"}:
573+
return
574+
575+
# According to httpdomain's documentation, 'reqjsonobj' is an alias for
576+
# 'reqjson'. However, since the same name is passed as a type directive
577+
# internally, it actually can be used to specify its type. The same
578+
# goes for 'resjsonobj'.
579+
directives_map = {
580+
"req": {
581+
"object": ("reqjson", "reqjsonobj"),
582+
"array": ("reqjsonarr", "reqjsonarrtype"),
583+
},
584+
"res": {
585+
"object": ("resjson", "resjsonobj"),
586+
"array": ("resjsonarr", "resjsonarrtype"),
587+
},
588+
}
589+
590+
# These httpdomain's fields always expect either JSON Object or JSON
591+
# Array. No primitive types are allowed as input.
592+
directive, typedirective = directives_map[req_or_res][schema_type]
593+
594+
# Since we use JSON array specific httpdomain directives if a schema
595+
# we're about to render is an array, there's no need to render that
596+
# array in the first place.
597+
if schema_type == "array":
598+
schema = schema["items"]
599+
600+
# Even if a root element is an array, items it contain must not be
601+
# of a primitive types.
602+
if _get_schema_type(schema) not in {"object", "array"}:
603+
return
604+
605+
for name, schema, is_required in _traverse_schema(schema, ""):
606+
yield f":{directive} {name}:"
607+
608+
if schema.get("description"):
609+
yield from indented(
610+
self._convert_markup(schema["description"]).strip().splitlines()
611+
)
612+
613+
markers = _get_markers_from_object({}, schema)
614+
615+
if is_required:
616+
markers.append("required")
617+
618+
if markers:
619+
markers = ", ".join(markers)
620+
yield f":{typedirective} {name}: {markers}"

tests/renderers/httpdomain/rendered/v2.0/petstore-expanded.yaml.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
:queryparam limit:
99
maximum number of results to return
1010
:queryparamtype limit: integer:int32
11+
1112
:statuscode 200:
1213
pet response
1314

@@ -18,6 +19,18 @@
1819
1920
Creates a new pet in the store. Duplicates are allowed
2021

22+
:reqjson name:
23+
:reqjsonobj name: string, required
24+
:reqjson tag:
25+
:reqjsonobj tag: string
26+
27+
28+
:resjson name:
29+
:resjsonobj name: string
30+
:resjson tag:
31+
:resjsonobj tag: string
32+
:resjson id:
33+
:resjsonobj id: integer:int64, required
2134

2235
:statuscode 200:
2336
pet response
@@ -32,6 +45,13 @@
3245
:param id:
3346
ID of pet to fetch
3447
:paramtype id: integer:int64, required
48+
:resjson name:
49+
:resjsonobj name: string
50+
:resjson tag:
51+
:resjsonobj tag: string
52+
:resjson id:
53+
:resjsonobj id: integer:int64, required
54+
3555
:statuscode 200:
3656
pet response
3757

tests/renderers/httpdomain/rendered/v2.0/petstore.yaml.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
:queryparam limit:
66
How many items to return at one time (max 100)
77
:queryparamtype limit: integer:int32
8+
:resjsonarr id:
9+
:resjsonarrtype id: integer:int64, required
10+
:resjsonarr name:
11+
:resjsonarrtype name: string, required
12+
:resjsonarr tag:
13+
:resjsonarrtype tag: string
14+
815
:statuscode 200:
916
A paged array of pets
1017

@@ -31,6 +38,13 @@
3138
:param petId:
3239
The id of the pet to retrieve
3340
:paramtype petId: string, required
41+
:resjsonarr id:
42+
:resjsonarrtype id: integer:int64, required
43+
:resjsonarr name:
44+
:resjsonarrtype name: string, required
45+
:resjsonarr tag:
46+
:resjsonarrtype tag: string
47+
3448
:statuscode 200:
3549
Expected response to a valid request
3650

0 commit comments

Comments
 (0)