11"""OpenAPI spec renderer."""
22
33import collections
4+ import copy
45import functools
56import http .client
67import json
78
9+ import deepmerge
810import docutils .parsers .rst .directives as directives
911import m2r
1012import 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+
130183class 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 } "
0 commit comments