1
1
"""OpenAPI spec renderer."""
2
2
3
3
import collections
4
+ import copy
4
5
import functools
5
6
import http .client
6
7
import json
7
8
9
+ import deepmerge
8
10
import docutils .parsers .rst .directives as directives
9
11
import m2r
10
12
import requests
@@ -107,6 +109,77 @@ def _iterexamples(media_types, example_preference, examples_from_schemas):
107
109
yield content_type , example
108
110
109
111
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
+
110
183
class HttpdomainRenderer (abc .RestructuredTextRenderer ):
111
184
"""Render OpenAPI v3 using `sphinxcontrib-httpdomain` extension."""
112
185
@@ -123,6 +196,7 @@ class HttpdomainRenderer(abc.RestructuredTextRenderer):
123
196
"request-example-preference" : None ,
124
197
"response-example-preference" : None ,
125
198
"generate-examples-from-schemas" : directives .flag ,
199
+ "no-json-schema-description" : directives .flag ,
126
200
}
127
201
128
202
def __init__ (self , state , options ):
@@ -151,6 +225,7 @@ def __init__(self, state, options):
151
225
"response-example-preference" , self ._example_preference
152
226
)
153
227
self ._generate_example_from_schema = "generate-examples-from-schemas" in options
228
+ self ._json_schema_description = "no-json-schema-description" not in options
154
229
155
230
def render_restructuredtext_markup (self , spec ):
156
231
"""Spec render entry point."""
@@ -229,7 +304,6 @@ def render_parameter(self, parameter):
229
304
kinds = CaseInsensitiveDict (
230
305
{"path" : "param" , "query" : "queryparam" , "header" : "reqheader" }
231
306
)
232
- markers = []
233
307
schema = parameter .get ("schema" , {})
234
308
235
309
if "content" in parameter :
@@ -247,32 +321,30 @@ def render_parameter(self, parameter):
247
321
)
248
322
return
249
323
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
-
262
324
yield f":{ kinds [parameter ['in' ]]} { parameter ['name' ]} :"
263
325
264
326
if parameter .get ("description" ):
265
327
yield from indented (
266
328
self ._convert_markup (parameter ["description" ]).strip ().splitlines ()
267
329
)
268
330
331
+ markers = _get_markers_from_object (parameter , schema )
269
332
if markers :
270
333
markers = ", " .join (markers )
271
334
yield f":{ kinds [parameter ['in' ]]} type { parameter ['name' ]} : { markers } "
272
335
273
336
def render_request_body (self , request_body , endpoint , method ):
274
337
"""Render OAS operation's requestBody."""
275
338
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
+
276
348
yield from self .render_request_body_example (request_body , endpoint , method )
277
349
yield ""
278
350
@@ -304,6 +376,18 @@ def render_request_body_example(self, request_body, endpoint, method):
304
376
def render_responses (self , responses ):
305
377
"""Render OAS operation's responses."""
306
378
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
+
307
391
for status_code , response in responses .items ():
308
392
# Due to the way how YAML spec is parsed, status code may be
309
393
# infered as integer. In order to spare some cycles on type
@@ -321,7 +405,7 @@ def render_response(self, status_code, response):
321
405
if "content" in response and status_code in self ._response_examples_for :
322
406
yield ""
323
407
yield from indented (
324
- self .render_response_content (response ["content" ], status_code )
408
+ self .render_response_example (response ["content" ], status_code )
325
409
)
326
410
327
411
if "headers" in response :
@@ -342,31 +426,19 @@ def render_response(self, status_code, response):
342
426
.splitlines ()
343
427
)
344
428
345
- markers = []
346
429
schema = header_value .get ("schema" , {})
347
430
if "content" in header_value :
348
431
# According to OpenAPI v3 spec, 'content' in this case may
349
432
# have one and only one entry. Hence casting its values to
350
433
# list is not expensive and should be acceptable.
351
434
schema = list (header_value ["content" ].values ())[0 ].get ("schema" , {})
352
435
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 )
365
437
if markers :
366
438
markers = ", " .join (markers )
367
439
yield f":resheadertype { header_name } : { markers } "
368
440
369
- def render_response_content (self , media_type , status_code ):
441
+ def render_response_example (self , media_type , status_code ):
370
442
# OpenAPI 3.0 spec may contain more than one response media type, and
371
443
# each media type may contain more than one example. Rendering all
372
444
# 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):
413
485
yield f" Content-Type: { content_type } "
414
486
yield f""
415
487
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