sc-oa 0.7.0.17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ """Here lies OpenAPI renderers."""
2
+
3
+ from . import abc
4
+ from ._httpdomain_old import HttpdomainOldRenderer
5
+ from ._httpdomain import HttpdomainRenderer
6
+ from ._model import ModelRenderer
7
+ from ._toc import TocRenderer
8
+ from ._description import DescriptionRenderer
9
+
10
+
11
+ __all__ = [
12
+ "abc",
13
+ "HttpdomainOldRenderer",
14
+ "HttpdomainRenderer",
15
+ "ModelRenderer",
16
+ "TocRenderer",
17
+ "DescriptionRenderer",
18
+ ]
@@ -0,0 +1,26 @@
1
+
2
+ from . import abc
3
+ from .. import utils
4
+
5
+
6
+ class DescriptionRenderer(abc.RestructuredTextRenderer):
7
+
8
+ option_spec = {
9
+ # Markup format to render OpenAPI descriptions.
10
+ "format": str,
11
+ }
12
+
13
+ def __init__(self, state, options):
14
+ self._state = state
15
+ self._options = options
16
+
17
+ def render_restructuredtext_markup(self, spec):
18
+
19
+ utils.normalize_spec(spec, **self._options)
20
+
21
+ convert = utils.get_text_converter(self._options)
22
+ if 'info' in spec:
23
+ if 'description' in spec['info']:
24
+ for line in convert(spec['info']['description']).splitlines():
25
+ yield '{line}'.format(**locals())
26
+ yield ''
@@ -0,0 +1,620 @@
1
+ """OpenAPI spec renderer."""
2
+
3
+ import collections.abc
4
+ import copy
5
+ import functools
6
+ import http.client
7
+ import json
8
+
9
+ import deepmerge
10
+ import docutils.parsers.rst.directives as directives
11
+ import m2r2
12
+ import requests
13
+ import sphinx.util.logging as logging
14
+
15
+ from sphinxcontrib.openapi import _lib2to3 as lib2to3
16
+ from sphinxcontrib.openapi.renderers import abc
17
+ from sphinxcontrib.openapi.schema_utils import example_from_schema
18
+
19
+
20
+ CaseInsensitiveDict = requests.structures.CaseInsensitiveDict
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def indented(generator, indent=3):
27
+ for item in generator:
28
+ if item:
29
+ item = " " * indent + item
30
+ yield item
31
+
32
+
33
+ def _iterinorder(iterable, order_by, key=lambda x: x, case_sensitive=False):
34
+ """Iterate over iterable in a given order."""
35
+
36
+ order_by = collections.defaultdict(
37
+ # Assume default priority is `Infinity` which means the lowest one.
38
+ # This value is effectively used if there's no corresponding value in a
39
+ # given 'order_by' array.
40
+ lambda: float("Inf"),
41
+ # Passed 'order_by' may be 'None' which means *do not reorder, use
42
+ # natural order*. In order to avoid special cases in the code, we're
43
+ # simply falling back to an empty 'order_by' array since it effectively
44
+ # means *assume every item in 'iterable' has equal priority*.
45
+ ((value, i) for i, value in enumerate(order_by or [])),
46
+ )
47
+ yield from sorted(
48
+ iterable,
49
+ key=lambda value: order_by[
50
+ key(value) if case_sensitive else key(value).lower()
51
+ ],
52
+ )
53
+
54
+
55
+ def _iterexamples(media_types, example_preference, examples_from_schemas):
56
+ """Iterate over examples and return them according to the caller preference."""
57
+
58
+ for content_type in _iterinorder(media_types, example_preference):
59
+ media_type = media_types[content_type]
60
+
61
+ # Look for a example in a bunch of possible places. According to
62
+ # OpenAPI v3 spec, `examples` and `example` keys are mutually
63
+ # exclusive, so there's no much difference between their
64
+ # inspection order, while both must take precedence over a
65
+ # schema example.
66
+ if media_type.get("examples", {}):
67
+ for example in media_type["examples"].values():
68
+ if "externalValue" in example:
69
+ if not example["externalValue"].startswith(("http://", "https://")):
70
+ logger.warning(
71
+ "Not supported protocol in 'externalValue': %s",
72
+ example["externalValue"],
73
+ )
74
+ continue
75
+
76
+ try:
77
+ response = requests.get(example["externalValue"])
78
+ response.raise_for_status()
79
+
80
+ example["value"] = response.text
81
+ example.pop("externalValue")
82
+ except Exception:
83
+ logger.error(
84
+ "Cannot retrieve example from: '%s'",
85
+ example["externalValue"],
86
+ )
87
+ continue
88
+ break
89
+ else:
90
+ # If the loop over examples has not been interrupted, we
91
+ # probably didn't find an example to render. In that case,
92
+ # let's try and go next media type.
93
+ continue
94
+ elif media_type.get("example"):
95
+ # Save example from "example" in "examples" compatible format. This
96
+ # allows to treat all returned examples the same way.
97
+ example = {"value": media_type["example"]}
98
+ elif media_type.get("schema", {}).get("example"):
99
+ # Save example from "schema" in "examples" compatible format. This
100
+ # allows to treat all returned examples the same way.
101
+ example = {"value": media_type["schema"]["example"]}
102
+ elif "schema" in media_type and examples_from_schemas:
103
+ # Convert schema to example
104
+ example = {"value": example_from_schema(media_type["schema"])}
105
+ pass
106
+ else:
107
+ continue
108
+
109
+ yield content_type, example
110
+
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.abc.Mapping, deepmerge.strategy.dict.DictStrategies("merge"))],
178
+ ["override"],
179
+ ["override"],
180
+ ).merge
181
+
182
+
183
+ class HttpdomainRenderer(abc.RestructuredTextRenderer):
184
+ """Render OpenAPI v3 using `sphinxcontrib-httpdomain` extension."""
185
+
186
+ _markup_converters = {"commonmark": m2r2.convert, "restructuredtext": lambda x: x}
187
+ _response_examples_for = {"200", "201", "202", "2XX"}
188
+ _request_parameters_order = ["header", "path", "query", "cookie"]
189
+
190
+ option_spec = {
191
+ "markup": functools.partial(directives.choice, values=_markup_converters),
192
+ "http-methods-order": lambda s: s.split(),
193
+ "response-examples-for": None,
194
+ "request-parameters-order": None,
195
+ "example-preference": None,
196
+ "request-example-preference": None,
197
+ "response-example-preference": None,
198
+ "generate-examples-from-schemas": directives.flag,
199
+ "no-json-schema-description": directives.flag,
200
+ }
201
+
202
+ def __init__(self, state, options):
203
+ super().__init__(state, options)
204
+
205
+ self._convert_markup = self._markup_converters[
206
+ options.get("markup", "commonmark")
207
+ ]
208
+ self._http_methods_order = [
209
+ http_method.lower() for http_method in options.get("http-methods-order", [])
210
+ ]
211
+ self._response_examples_for = options.get(
212
+ "response-examples-for", self._response_examples_for
213
+ )
214
+ self._request_parameters_order = [
215
+ parameter_type.lower()
216
+ for parameter_type in options.get(
217
+ "request-parameters-order", self._request_parameters_order
218
+ )
219
+ ]
220
+ self._example_preference = options.get("example-preference")
221
+ self._request_example_preference = options.get(
222
+ "request-example-preference", self._example_preference
223
+ )
224
+ self._response_example_preference = options.get(
225
+ "response-example-preference", self._example_preference
226
+ )
227
+ self._generate_example_from_schema = "generate-examples-from-schemas" in options
228
+ self._json_schema_description = "no-json-schema-description" not in options
229
+
230
+ def render_restructuredtext_markup(self, spec):
231
+ """Spec render entry point."""
232
+
233
+ if spec.get("swagger") == "2.0":
234
+ spec = lib2to3.convert(spec)
235
+ yield from self.render_paths(spec.get("paths", {}))
236
+
237
+ def render_paths(self, paths):
238
+ """Render OAS paths item."""
239
+
240
+ for endpoint, path in paths.items():
241
+ common_parameters = path.pop("parameters", [])
242
+
243
+ # OpenAPI's path description may contain objects of different
244
+ # types. Since we're interested in rendering only objects of
245
+ # operation type, let's remove irrelevant one from the definition
246
+ # in order to simplify further code.
247
+ for key in {"summary", "description", "servers"}:
248
+ path.pop(key, None)
249
+
250
+ for method in _iterinorder(path, self._http_methods_order):
251
+ operation = path[method]
252
+ operation.setdefault("parameters", [])
253
+ operation_parameters_ids = set(
254
+ (parameter["name"], parameter["in"])
255
+ for parameter in operation["parameters"]
256
+ )
257
+ operation["parameters"] = [
258
+ parameter
259
+ for parameter in common_parameters
260
+ if (parameter["name"], parameter["in"])
261
+ not in operation_parameters_ids
262
+ ] + operation["parameters"]
263
+
264
+ yield from self.render_operation(endpoint, method, operation)
265
+ yield ""
266
+
267
+ def render_operation(self, endpoint, method, operation):
268
+ """Render OAS operation item."""
269
+
270
+ yield f".. http:{method}:: {endpoint}"
271
+
272
+ if operation.get("deprecated"):
273
+ yield f" :deprecated:"
274
+ yield f""
275
+
276
+ if operation.get("summary"):
277
+ yield f" **{operation['summary']}**"
278
+ yield f""
279
+
280
+ if operation.get("description"):
281
+ yield from indented(
282
+ self._convert_markup(operation["description"]).strip().splitlines()
283
+ )
284
+ yield f""
285
+
286
+ yield from indented(self.render_parameters(operation.get("parameters", [])))
287
+ if "requestBody" in operation:
288
+ yield from indented(
289
+ self.render_request_body(operation["requestBody"], endpoint, method)
290
+ )
291
+ yield from indented(self.render_responses(operation["responses"]))
292
+
293
+ def render_parameters(self, parameters):
294
+ """Render OAS operation's parameters."""
295
+
296
+ for parameter in _iterinorder(
297
+ parameters, self._request_parameters_order, key=lambda value: value["in"]
298
+ ):
299
+ yield from self.render_parameter(parameter)
300
+
301
+ def render_parameter(self, parameter):
302
+ """Render OAS operation's parameter."""
303
+
304
+ kinds = CaseInsensitiveDict(
305
+ {"path": "param", "query": "queryparam", "header": "reqheader"}
306
+ )
307
+ schema = parameter.get("schema", {})
308
+
309
+ if "content" in parameter:
310
+ # According to OpenAPI v3 spec, 'content' in this case may
311
+ # have one and only one entry. Hence casting its values to
312
+ # list is not expensive and should be acceptable.
313
+ schema = list(parameter["content"].values())[0].get("schema", {})
314
+
315
+ if parameter["in"] not in kinds:
316
+ logger.warning(
317
+ "OpenAPI spec contains parameter '%s' (in: '%s') that cannot "
318
+ "be rendererd.",
319
+ parameter["name"],
320
+ parameter["in"],
321
+ )
322
+ return
323
+
324
+ yield f":{kinds[parameter['in']]} {parameter['name']}:"
325
+
326
+ if parameter.get("description"):
327
+ yield from indented(
328
+ self._convert_markup(parameter["description"]).strip().splitlines()
329
+ )
330
+
331
+ markers = _get_markers_from_object(parameter, schema)
332
+ if markers:
333
+ markers = ", ".join(markers)
334
+ yield f":{kinds[parameter['in']]}type {parameter['name']}: {markers}"
335
+
336
+ def render_request_body(self, request_body, endpoint, method):
337
+ """Render OAS operation's requestBody."""
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
+
348
+ yield from self.render_request_body_example(request_body, endpoint, method)
349
+ yield ""
350
+
351
+ def render_request_body_example(self, request_body, endpoint, method):
352
+ """Render OAS operation's requestBody's example."""
353
+
354
+ content_type, example = next(
355
+ _iterexamples(
356
+ request_body["content"],
357
+ self._request_example_preference,
358
+ self._generate_example_from_schema,
359
+ ),
360
+ (None, None),
361
+ )
362
+
363
+ if content_type and example:
364
+ example = example["value"]
365
+
366
+ if not isinstance(example, str):
367
+ example = json.dumps(example, indent=2)
368
+
369
+ yield f".. sourcecode:: http"
370
+ yield f""
371
+ yield f" {method.upper()} {endpoint} HTTP/1.1"
372
+ yield f" Content-Type: {content_type}"
373
+ yield f""
374
+ yield from indented(example.splitlines())
375
+
376
+ def render_responses(self, responses):
377
+ """Render OAS operation's responses."""
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
+
391
+ for status_code, response in responses.items():
392
+ # Due to the way how YAML spec is parsed, status code may be
393
+ # infered as integer. In order to spare some cycles on type
394
+ # guessing going on, let's ensure it's always string at this point.
395
+ yield from self.render_response(str(status_code), response)
396
+
397
+ def render_response(self, status_code, response):
398
+ """Render OAS operation's response."""
399
+
400
+ yield f":statuscode {status_code}:"
401
+ yield from indented(
402
+ self._convert_markup(response["description"]).strip().splitlines()
403
+ )
404
+
405
+ if "content" in response and status_code in self._response_examples_for:
406
+ yield ""
407
+ yield from indented(
408
+ self.render_response_example(response["content"], status_code)
409
+ )
410
+
411
+ if "headers" in response:
412
+ yield ""
413
+
414
+ for header_name, header_value in response["headers"].items():
415
+ # According to OpenAPI v3 specification, if a response header
416
+ # is defined with the name 'Content-Type', it shall be ignored.
417
+ if header_name.lower() == "content-type":
418
+ continue
419
+
420
+ yield f":resheader {header_name}:"
421
+
422
+ if header_value.get("description"):
423
+ yield from indented(
424
+ self._convert_markup(header_value["description"])
425
+ .strip()
426
+ .splitlines()
427
+ )
428
+
429
+ schema = header_value.get("schema", {})
430
+ if "content" in header_value:
431
+ # According to OpenAPI v3 spec, 'content' in this case may
432
+ # have one and only one entry. Hence casting its values to
433
+ # list is not expensive and should be acceptable.
434
+ schema = list(header_value["content"].values())[0].get("schema", {})
435
+
436
+ markers = _get_markers_from_object(header_value, schema)
437
+ if markers:
438
+ markers = ", ".join(markers)
439
+ yield f":resheadertype {header_name}: {markers}"
440
+
441
+ def render_response_example(self, media_type, status_code):
442
+ # OpenAPI 3.0 spec may contain more than one response media type, and
443
+ # each media type may contain more than one example. Rendering all
444
+ # invariants normally is not an option because the result will be hard
445
+ # to read and follow. The best option we can go with at this moment is
446
+ # to render first found example of either response media type. Users
447
+ # should control what to render by putting recommended example first in
448
+ # the list.
449
+ content_type, example = next(
450
+ _iterexamples(
451
+ media_type,
452
+ self._response_example_preference,
453
+ self._generate_example_from_schema,
454
+ ),
455
+ (None, None),
456
+ )
457
+
458
+ if content_type and example:
459
+ example = example["value"]
460
+
461
+ if not isinstance(example, str):
462
+ example = json.dumps(example, indent=2)
463
+
464
+ # According to OpenAPI v3 spec, status code may be a special value
465
+ # - "default". It's not quite clear what to render in this case.
466
+ # One possible option is to avoid rendering status code at all.
467
+ # This option, however, suffers from broken code highlighting
468
+ # because Pygments relies on the snippet to start with HTTP
469
+ # protocol line. That said, probably the best we can do at the
470
+ # moment is to render some generic status.
471
+ if status_code == "default":
472
+ status_code = "000"
473
+ status_text = "Reason-Phrase"
474
+ else:
475
+ # According to OpenAPI v3 spec, status code may define a range
476
+ # of response codes. Since we're talking about rendered example
477
+ # here, we may show either code from range, but for the sake of
478
+ # simplicity let's pick the first one.
479
+ status_code = status_code.replace("XX", "00")
480
+ status_text = http.client.responses.get(int(status_code), "-")
481
+
482
+ yield f".. sourcecode:: http"
483
+ yield f""
484
+ yield f" HTTP/1.1 {status_code} {status_text}"
485
+ yield f" Content-Type: {content_type}"
486
+ yield f""
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}"