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
@@ -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+
110183class 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