|
25 | 25 | from openapidocs.mk.v3.examples import get_example_from_schema |
26 | 26 | from openapidocs.utils.source import read_from_source |
27 | 27 |
|
| 28 | +_OAS31_KEYWORDS = frozenset( |
| 29 | + { |
| 30 | + "const", |
| 31 | + "if", |
| 32 | + "then", |
| 33 | + "else", |
| 34 | + "prefixItems", |
| 35 | + "unevaluatedProperties", |
| 36 | + "unevaluatedItems", |
| 37 | + "$defs", |
| 38 | + } |
| 39 | +) |
| 40 | + |
28 | 41 |
|
29 | 42 | def _can_simplify_json(content_type) -> bool: |
30 | 43 | return "json" in content_type or content_type == "text/plain" |
@@ -106,11 +119,110 @@ def __init__( |
106 | 119 | custom_templates_path=templates_path, |
107 | 120 | ) |
108 | 121 | self.doc = self.normalize_data(copy.deepcopy(doc)) |
| 122 | + self._warn_31_features_in_30_doc() |
| 123 | + self._warn_30_features_in_31_doc() |
109 | 124 |
|
110 | 125 | @property |
111 | 126 | def source(self) -> str: |
112 | 127 | return self._source |
113 | 128 |
|
| 129 | + def _collect_31_features(self, obj: object, found: set) -> None: |
| 130 | + """Recursively scans obj for OAS 3.1-specific features, collecting them in found.""" |
| 131 | + if not isinstance(obj, dict): |
| 132 | + return |
| 133 | + |
| 134 | + type_val = obj.get("type") |
| 135 | + if isinstance(type_val, list): |
| 136 | + found.add('type as list (e.g. ["string", "null"])') |
| 137 | + |
| 138 | + for kw in _OAS31_KEYWORDS: |
| 139 | + if kw in obj: |
| 140 | + found.add(kw) |
| 141 | + |
| 142 | + for kw in ("exclusiveMinimum", "exclusiveMaximum"): |
| 143 | + val = obj.get(kw) |
| 144 | + if ( |
| 145 | + val is not None |
| 146 | + and isinstance(val, (int, float)) |
| 147 | + and not isinstance(val, bool) |
| 148 | + ): |
| 149 | + found.add(f"{kw} as number") |
| 150 | + |
| 151 | + for value in obj.values(): |
| 152 | + if isinstance(value, dict): |
| 153 | + self._collect_31_features(value, found) |
| 154 | + elif isinstance(value, list): |
| 155 | + for item in value: |
| 156 | + self._collect_31_features(item, found) |
| 157 | + |
| 158 | + def _warn_31_features_in_30_doc(self) -> None: |
| 159 | + """ |
| 160 | + Emits a warning if OAS 3.1-specific features are detected in a document |
| 161 | + that declares an OAS 3.0.x version. |
| 162 | + """ |
| 163 | + version = self.doc.get("openapi", "") |
| 164 | + if not (isinstance(version, str) and version.startswith("3.0")): |
| 165 | + return |
| 166 | + |
| 167 | + found: set = set() |
| 168 | + |
| 169 | + if "webhooks" in self.doc: |
| 170 | + found.add("webhooks") |
| 171 | + |
| 172 | + self._collect_31_features(self.doc, found) |
| 173 | + |
| 174 | + if found: |
| 175 | + feature_list = ", ".join(sorted(found)) |
| 176 | + warnings.warn( |
| 177 | + f"OpenAPI document declares version {version!r} but uses " |
| 178 | + f"OAS 3.1-specific features: {feature_list}. " |
| 179 | + "Consider updating the `openapi` field to '3.1.0'.", |
| 180 | + stacklevel=3, |
| 181 | + ) |
| 182 | + |
| 183 | + def _collect_30_features(self, obj: object, found: set) -> None: |
| 184 | + """Recursively scans obj for OAS 3.0-specific features, collecting them in found.""" |
| 185 | + if not isinstance(obj, dict): |
| 186 | + return |
| 187 | + |
| 188 | + # nullable: true is OAS 3.0 only — replaced by type: [..., "null"] in 3.1 |
| 189 | + if obj.get("nullable") is True: |
| 190 | + found.add("nullable: true") |
| 191 | + |
| 192 | + # boolean exclusiveMinimum/exclusiveMaximum are 3.0 semantics; |
| 193 | + # in 3.1 they are numeric bounds |
| 194 | + for kw in ("exclusiveMinimum", "exclusiveMaximum"): |
| 195 | + if isinstance(obj.get(kw), bool): |
| 196 | + found.add(f"{kw}: true/false (boolean)") |
| 197 | + |
| 198 | + for value in obj.values(): |
| 199 | + if isinstance(value, dict): |
| 200 | + self._collect_30_features(value, found) |
| 201 | + elif isinstance(value, list): |
| 202 | + for item in value: |
| 203 | + self._collect_30_features(item, found) |
| 204 | + |
| 205 | + def _warn_30_features_in_31_doc(self) -> None: |
| 206 | + """ |
| 207 | + Emits a warning if OAS 3.0-specific features are detected in a document |
| 208 | + that declares an OAS 3.1.x version. |
| 209 | + """ |
| 210 | + version = self.doc.get("openapi", "") |
| 211 | + if not (isinstance(version, str) and version.startswith("3.1")): |
| 212 | + return |
| 213 | + |
| 214 | + found: set = set() |
| 215 | + self._collect_30_features(self.doc, found) |
| 216 | + |
| 217 | + if found: |
| 218 | + feature_list = ", ".join(sorted(found)) |
| 219 | + warnings.warn( |
| 220 | + f"OpenAPI document declares version {version!r} but uses " |
| 221 | + f"OAS 3.0-specific features: {feature_list}. " |
| 222 | + "These features are not valid in OAS 3.1 and may be ignored by tooling.", |
| 223 | + stacklevel=3, |
| 224 | + ) |
| 225 | + |
114 | 226 | def normalize_data(self, data): |
115 | 227 | """ |
116 | 228 | Applies corrections to the OpenAPI specification, to simplify its handling. |
@@ -179,7 +291,10 @@ def get_operations(self): |
179 | 291 | """ |
180 | 292 | data = self.doc |
181 | 293 | groups = defaultdict(list) |
182 | | - paths = data["paths"] |
| 294 | + paths = data.get("paths") # paths is optional in OAS 3.1 |
| 295 | + |
| 296 | + if not paths: |
| 297 | + return groups |
183 | 298 |
|
184 | 299 | for path, path_item in paths.items(): |
185 | 300 | if not isinstance(path_item, dict): |
|
0 commit comments