Skip to content

Commit 4f78db9

Browse files
authored
Merge pull request #101 from sphinx-contrib/json-schema-description
Render JSON schema description
2 parents 9dbae9c + 5b014b4 commit 4f78db9

14 files changed

+2153
-61
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: 233 additions & 28 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
@@ -107,6 +109,77 @@ def _iterexamples(media_types, example_preference, examples_from_schemas):
107109
yield content_type, example
108110

109111

112+
def _get_markers_from_object(oas_object, schema):
113+
"""Retrieve a bunch of OAS object markers."""
114+
115+
markers = []
116+
117+
schema_type = _get_schema_type(schema)
118+
if schema_type:
119+
if schema.get("format"):
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")
126+
127+
if oas_object.get("required"):
128+
markers.append("required")
129+
130+
if oas_object.get("deprecated"):
131+
markers.append("deprecated")
132+
133+
if schema.get("deprecated"):
134+
markers.append("deprecated")
135+
136+
return markers
137+
138+
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+
110183
class HttpdomainRenderer(abc.RestructuredTextRenderer):
111184
"""Render OpenAPI v3 using `sphinxcontrib-httpdomain` extension."""
112185

@@ -123,6 +196,7 @@ class HttpdomainRenderer(abc.RestructuredTextRenderer):
123196
"request-example-preference": None,
124197
"response-example-preference": None,
125198
"generate-examples-from-schemas": directives.flag,
199+
"no-json-schema-description": directives.flag,
126200
}
127201

128202
def __init__(self, state, options):
@@ -151,6 +225,7 @@ def __init__(self, state, options):
151225
"response-example-preference", self._example_preference
152226
)
153227
self._generate_example_from_schema = "generate-examples-from-schemas" in options
228+
self._json_schema_description = "no-json-schema-description" not in options
154229

155230
def render_restructuredtext_markup(self, spec):
156231
"""Spec render entry point."""
@@ -229,7 +304,6 @@ def render_parameter(self, parameter):
229304
kinds = CaseInsensitiveDict(
230305
{"path": "param", "query": "queryparam", "header": "reqheader"}
231306
)
232-
markers = []
233307
schema = parameter.get("schema", {})
234308

235309
if "content" in parameter:
@@ -247,32 +321,30 @@ def render_parameter(self, parameter):
247321
)
248322
return
249323

250-
if schema.get("type"):
251-
type_ = schema["type"]
252-
if schema.get("format"):
253-
type_ = f"{type_}:{schema['format']}"
254-
markers.append(type_)
255-
256-
if parameter.get("required"):
257-
markers.append("required")
258-
259-
if parameter.get("deprecated"):
260-
markers.append("deprecated")
261-
262324
yield f":{kinds[parameter['in']]} {parameter['name']}:"
263325

264326
if parameter.get("description"):
265327
yield from indented(
266328
self._convert_markup(parameter["description"]).strip().splitlines()
267329
)
268330

331+
markers = _get_markers_from_object(parameter, schema)
269332
if markers:
270333
markers = ", ".join(markers)
271334
yield f":{kinds[parameter['in']]}type {parameter['name']}: {markers}"
272335

273336
def render_request_body(self, request_body, endpoint, method):
274337
"""Render OAS operation's requestBody."""
275338

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+
276348
yield from self.render_request_body_example(request_body, endpoint, method)
277349
yield ""
278350

@@ -304,6 +376,18 @@ def render_request_body_example(self, request_body, endpoint, method):
304376
def render_responses(self, responses):
305377
"""Render OAS operation's responses."""
306378

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+
307391
for status_code, response in responses.items():
308392
# Due to the way how YAML spec is parsed, status code may be
309393
# infered as integer. In order to spare some cycles on type
@@ -321,7 +405,7 @@ def render_response(self, status_code, response):
321405
if "content" in response and status_code in self._response_examples_for:
322406
yield ""
323407
yield from indented(
324-
self.render_response_content(response["content"], status_code)
408+
self.render_response_example(response["content"], status_code)
325409
)
326410

327411
if "headers" in response:
@@ -342,31 +426,19 @@ def render_response(self, status_code, response):
342426
.splitlines()
343427
)
344428

345-
markers = []
346429
schema = header_value.get("schema", {})
347430
if "content" in header_value:
348431
# According to OpenAPI v3 spec, 'content' in this case may
349432
# have one and only one entry. Hence casting its values to
350433
# list is not expensive and should be acceptable.
351434
schema = list(header_value["content"].values())[0].get("schema", {})
352435

353-
if schema.get("type"):
354-
type_ = schema["type"]
355-
if schema.get("format"):
356-
type_ = f"{type_}:{schema['format']}"
357-
markers.append(type_)
358-
359-
if header_value.get("required"):
360-
markers.append("required")
361-
362-
if header_value.get("deprecated"):
363-
markers.append("deprecated")
364-
436+
markers = _get_markers_from_object(header_value, schema)
365437
if markers:
366438
markers = ", ".join(markers)
367439
yield f":resheadertype {header_name}: {markers}"
368440

369-
def render_response_content(self, media_type, status_code):
441+
def render_response_example(self, media_type, status_code):
370442
# OpenAPI 3.0 spec may contain more than one response media type, and
371443
# each media type may contain more than one example. Rendering all
372444
# invariants normally is not an option because the result will be hard
@@ -413,3 +485,136 @@ def render_response_content(self, media_type, status_code):
413485
yield f" Content-Type: {content_type}"
414486
yield f""
415487
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}"

0 commit comments

Comments
 (0)