22This module provides functions to generate Markdown for OpenAPI Version 3.
33"""
44import copy
5+ import os
56import warnings
67from collections import defaultdict
78from dataclasses import dataclass
9+ from pathlib import Path
810from typing import Any , Iterable , List , Optional , Union
911
12+ from openapidocs .logs import logger
1013from openapidocs .mk import read_dict , sort_dict
1114from openapidocs .mk .common import (
1215 DocumentsWriter ,
1922from openapidocs .mk .jinja import Jinja2DocumentsWriter , OutputStyle
2023from openapidocs .mk .texts import EnglishTexts , Texts
2124from openapidocs .mk .v3 .examples import get_example_from_schema
25+ from openapidocs .utils .source import read_from_source
2226
2327
2428def _can_simplify_json (content_type ) -> bool :
@@ -50,6 +54,23 @@ def style_from_value(value: Union[int, str]) -> OutputStyle:
5054 raise ValueError (f"Invalid style: { value } " )
5155
5256
57+ class OpenAPIDocumentationHandlerError (Exception ):
58+ """Base type for exceptions raised by the handler generating documentation."""
59+
60+
61+ class OpenAPIFileNotFoundError (OpenAPIDocumentationHandlerError , FileNotFoundError ):
62+ """
63+ Exception raised when a $ref property pointing to a file (to split OAD specification
64+ into multiple files) is not found.
65+ """
66+
67+ def __init__ (self , reference : str , attempted_path : Path ) -> None :
68+ super ().__init__ (
69+ f"Cannot resolve the $ref source { reference } "
70+ f"Tried to read from path: { attempted_path } "
71+ )
72+
73+
5374class OpenAPIV3DocumentationHandler :
5475 """
5576 Class that produces documentation from OpenAPI Documentation V3.
@@ -73,21 +94,80 @@ def __init__(
7394 texts : Optional [Texts ] = None ,
7495 writer : Optional [DocumentsWriter ] = None ,
7596 style : Union [int , str ] = 1 ,
97+ source : str = "" ,
7698 ) -> None :
77- self .doc = self . normalize_data ( copy . deepcopy ( doc ))
99+ self ._source = source
78100 self .texts = texts or EnglishTexts ()
79101 self ._writer = writer or Jinja2DocumentsWriter (
80102 __name__ , views_style = style_from_value (style )
81103 )
104+ self .doc = self .normalize_data (copy .deepcopy (doc ))
105+
106+ @property
107+ def source (self ) -> str :
108+ return self ._source
82109
83110 def normalize_data (self , data ):
84111 """
85112 Applies corrections to the OpenAPI specification, to simplify its handling.
113+
114+ This method also resolves references to different files, if the root is split
115+ into multiple files.
116+
117+ ---
118+ Ref.
119+ An OpenAPI document MAY be made up of a single document or be divided into
120+ multiple, connected parts at the discretion of the user. In the latter case,
121+ $ref fields MUST be used in the specification to reference those parts as
122+ follows from the JSON Schema definitions.
86123 """
87124 if "components" not in data :
88125 data ["components" ] = {}
89126
90- return data
127+ return self ._transform_data (
128+ data , Path (self .source ).parent if self .source else Path .cwd ()
129+ )
130+
131+ def _transform_data (self , obj , source_path ):
132+ if not isinstance (obj , dict ):
133+ return obj
134+
135+ if "$ref" in obj :
136+ return self ._handle_obj_ref (obj , source_path )
137+
138+ clone = {}
139+
140+ for key , value in obj .items ():
141+ if isinstance (value , list ):
142+ clone [key ] = [self ._transform_data (item , source_path ) for item in value ]
143+ elif isinstance (value , dict ):
144+ clone [key ] = self ._handle_obj_ref (value , source_path )
145+ else :
146+ clone [key ] = self ._transform_data (value , source_path )
147+
148+ return clone
149+
150+ def _handle_obj_ref (self , obj , source_path ):
151+ """
152+ Handles a dictionary containing a $ref property, resolving the reference if it
153+ is to a file. This is used to read specification files when they are split into
154+ multiple items.
155+ """
156+ assert isinstance (obj , dict )
157+ if "$ref" in obj :
158+ reference = obj ["$ref" ]
159+ if isinstance (reference , str ) and not reference .startswith ("#/" ):
160+ referred_file = Path (os .path .abspath (source_path / reference ))
161+
162+ if referred_file .exists ():
163+ logger .debug ("Handling $ref source: %s" , reference )
164+ else :
165+ raise OpenAPIFileNotFoundError (reference , referred_file )
166+ sub_fragment = read_from_source (str (referred_file ))
167+ return self ._transform_data (sub_fragment , referred_file .parent )
168+ else :
169+ return obj
170+ return self ._transform_data (obj , source_path )
91171
92172 def get_operations (self ):
93173 """
@@ -308,8 +388,10 @@ def get_parameters(self, operation) -> List[dict]:
308388 results = [
309389 param
310390 for param in sorted (
311- parameters , key = lambda x : x ["name" ].lower () if "name" in x else ""
391+ parameters ,
392+ key = lambda x : x ["name" ].lower () if (x and "name" in x ) else "" ,
312393 )
394+ if param
313395 ]
314396
315397 security_options = self .get_operation_security (operation )
@@ -331,9 +413,12 @@ def get_parameters(self, operation) -> List[dict]:
331413
332414 return results
333415
334- def write (self , data ) -> str :
416+ def write (self ) -> str :
335417 return self ._writer .write (
336- data , operations = self .get_operations (), texts = self .texts , handler = self
418+ self .doc ,
419+ operations = self .get_operations (),
420+ texts = self .texts ,
421+ handler = self ,
337422 )
338423
339424 def get_content_examples (self , data ) -> Iterable [ContentExample ]:
@@ -463,9 +548,17 @@ def expand_references(self, schema, context: Optional[ExpandContext] = None):
463548 else :
464549 context .expanded_refs .add (ref )
465550
466- clone [key ] = self .expand_references (
467- self .resolve_reference (value ), context
468- )
551+ resolved_ref = self .resolve_reference (value )
552+
553+ if resolved_ref is None : # pragma: no cover
554+ logger .warning (
555+ "Cannot resolve the reference %s. "
556+ "Is a fragment missing from `components` object?" ,
557+ value ,
558+ )
559+ clone [key ] = {}
560+ else :
561+ clone [key ] = self .expand_references (resolved_ref , context )
469562 elif isinstance (value , dict ):
470563 if is_array_schema (value ) and is_reference (value ["items" ]):
471564 ref = value ["items" ]["$ref" ]
0 commit comments