otterapi 0.0.5__py3-none-any.whl → 0.0.6__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.
- README.md +581 -8
- otterapi/__init__.py +73 -0
- otterapi/cli.py +327 -29
- otterapi/codegen/__init__.py +115 -0
- otterapi/codegen/ast_utils.py +134 -5
- otterapi/codegen/client.py +1271 -0
- otterapi/codegen/codegen.py +1736 -0
- otterapi/codegen/dataframes.py +392 -0
- otterapi/codegen/emitter.py +473 -0
- otterapi/codegen/endpoints.py +2597 -343
- otterapi/codegen/pagination.py +1026 -0
- otterapi/codegen/schema.py +593 -0
- otterapi/codegen/splitting.py +1397 -0
- otterapi/codegen/types.py +1345 -0
- otterapi/codegen/utils.py +180 -1
- otterapi/config.py +1017 -24
- otterapi/exceptions.py +231 -0
- otterapi/openapi/__init__.py +46 -0
- otterapi/openapi/v2/__init__.py +86 -0
- otterapi/openapi/v2/spec.json +1607 -0
- otterapi/openapi/v2/v2.py +1776 -0
- otterapi/openapi/v3/__init__.py +131 -0
- otterapi/openapi/v3/spec.json +1651 -0
- otterapi/openapi/v3/v3.py +1557 -0
- otterapi/openapi/v3_1/__init__.py +133 -0
- otterapi/openapi/v3_1/spec.json +1411 -0
- otterapi/openapi/v3_1/v3_1.py +798 -0
- otterapi/openapi/v3_2/__init__.py +133 -0
- otterapi/openapi/v3_2/spec.json +1666 -0
- otterapi/openapi/v3_2/v3_2.py +777 -0
- otterapi/tests/__init__.py +3 -0
- otterapi/tests/fixtures/__init__.py +455 -0
- otterapi/tests/test_ast_utils.py +680 -0
- otterapi/tests/test_codegen.py +610 -0
- otterapi/tests/test_dataframe.py +1038 -0
- otterapi/tests/test_exceptions.py +493 -0
- otterapi/tests/test_openapi_support.py +616 -0
- otterapi/tests/test_openapi_upgrade.py +215 -0
- otterapi/tests/test_pagination.py +1101 -0
- otterapi/tests/test_splitting_config.py +319 -0
- otterapi/tests/test_splitting_integration.py +427 -0
- otterapi/tests/test_splitting_resolver.py +512 -0
- otterapi/tests/test_splitting_tree.py +525 -0
- otterapi-0.0.6.dist-info/METADATA +627 -0
- otterapi-0.0.6.dist-info/RECORD +48 -0
- {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/WHEEL +1 -1
- otterapi/codegen/generator.py +0 -358
- otterapi/codegen/openapi_processor.py +0 -27
- otterapi/codegen/type_generator.py +0 -559
- otterapi-0.0.5.dist-info/METADATA +0 -54
- otterapi-0.0.5.dist-info/RECORD +0 -16
- {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,1776 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic V2 models for Swagger/OpenAPI 2.0 specification.
|
|
3
|
+
|
|
4
|
+
Based on the JSON Schema at: http://swagger.io/v2/schema.json
|
|
5
|
+
|
|
6
|
+
Usage Example:
|
|
7
|
+
-------------
|
|
8
|
+
|
|
9
|
+
from otterapi.openapi.v2 import Swagger
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
# Load a Swagger 2.0 document
|
|
13
|
+
with open('swagger.json') as f:
|
|
14
|
+
swagger_dict = json.load(f)
|
|
15
|
+
|
|
16
|
+
# Parse and validate
|
|
17
|
+
spec = Swagger(**swagger_dict)
|
|
18
|
+
|
|
19
|
+
# Access the parsed data
|
|
20
|
+
print(f"API: {spec.info.title} v{spec.info.version}")
|
|
21
|
+
print(f"Host: {spec.host}")
|
|
22
|
+
|
|
23
|
+
# Iterate over paths
|
|
24
|
+
for path_name in spec.paths.__pydantic_extra__:
|
|
25
|
+
path_item = spec.paths.__pydantic_extra__[path_name]
|
|
26
|
+
if path_item.get:
|
|
27
|
+
print(f"GET {path_name}: {path_item.get.summary}")
|
|
28
|
+
|
|
29
|
+
# Export back to dict
|
|
30
|
+
output = spec.model_dump(by_alias=True, exclude_none=True)
|
|
31
|
+
|
|
32
|
+
# Create a new spec from scratch
|
|
33
|
+
new_spec = Swagger(
|
|
34
|
+
swagger="2.0",
|
|
35
|
+
info={"title": "My API", "version": "1.0.0"},
|
|
36
|
+
paths={
|
|
37
|
+
"/users": {
|
|
38
|
+
"get": {
|
|
39
|
+
"summary": "List users",
|
|
40
|
+
"responses": {
|
|
41
|
+
"200": {"description": "Success"}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
Features:
|
|
49
|
+
---------
|
|
50
|
+
- Full Swagger 2.0 specification support
|
|
51
|
+
- Pydantic V2 validation
|
|
52
|
+
- Type hints for IDE support
|
|
53
|
+
- Vendor extensions (x-*) support
|
|
54
|
+
- Proper validation of path parameters (must be required)
|
|
55
|
+
- JSON reference ($ref) support
|
|
56
|
+
- All OAuth2 flows supported
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from enum import Enum
|
|
60
|
+
from typing import Any, Literal, Optional, Union
|
|
61
|
+
|
|
62
|
+
from pydantic import (
|
|
63
|
+
BaseModel,
|
|
64
|
+
ConfigDict,
|
|
65
|
+
Field,
|
|
66
|
+
HttpUrl,
|
|
67
|
+
model_validator,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Import OpenAPI 3.0 models for upgrade functionality
|
|
71
|
+
from otterapi.openapi.v3 import OpenAPI, v3 as openapi_v3
|
|
72
|
+
|
|
73
|
+
# ============================================================================
|
|
74
|
+
# Enums
|
|
75
|
+
# ============================================================================
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SchemeType(str, Enum):
|
|
79
|
+
"""Transfer protocol schemes."""
|
|
80
|
+
|
|
81
|
+
HTTP = 'http'
|
|
82
|
+
HTTPS = 'https'
|
|
83
|
+
WS = 'ws'
|
|
84
|
+
WSS = 'wss'
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ParameterLocation(str, Enum):
|
|
88
|
+
"""Parameter location types."""
|
|
89
|
+
|
|
90
|
+
QUERY = 'query'
|
|
91
|
+
HEADER = 'header'
|
|
92
|
+
PATH = 'path'
|
|
93
|
+
FORM_DATA = 'formData'
|
|
94
|
+
BODY = 'body'
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PrimitiveType(str, Enum):
|
|
98
|
+
"""Primitive types for non-body parameters."""
|
|
99
|
+
|
|
100
|
+
STRING = 'string'
|
|
101
|
+
NUMBER = 'number'
|
|
102
|
+
INTEGER = 'integer'
|
|
103
|
+
BOOLEAN = 'boolean'
|
|
104
|
+
ARRAY = 'array'
|
|
105
|
+
FILE = 'file'
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class CollectionFormat(str, Enum):
|
|
109
|
+
"""Collection format for array parameters."""
|
|
110
|
+
|
|
111
|
+
CSV = 'csv'
|
|
112
|
+
SSV = 'ssv'
|
|
113
|
+
TSV = 'tsv'
|
|
114
|
+
PIPES = 'pipes'
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class CollectionFormatWithMulti(str, Enum):
|
|
118
|
+
"""Collection format including multi for query/formData parameters."""
|
|
119
|
+
|
|
120
|
+
CSV = 'csv'
|
|
121
|
+
SSV = 'ssv'
|
|
122
|
+
TSV = 'tsv'
|
|
123
|
+
PIPES = 'pipes'
|
|
124
|
+
MULTI = 'multi'
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SecuritySchemeType(str, Enum):
|
|
128
|
+
"""Security scheme types."""
|
|
129
|
+
|
|
130
|
+
BASIC = 'basic'
|
|
131
|
+
API_KEY = 'apiKey'
|
|
132
|
+
OAUTH2 = 'oauth2'
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class OAuth2Flow(str, Enum):
|
|
136
|
+
"""OAuth2 flow types."""
|
|
137
|
+
|
|
138
|
+
IMPLICIT = 'implicit'
|
|
139
|
+
PASSWORD = 'password'
|
|
140
|
+
APPLICATION = 'application'
|
|
141
|
+
ACCESS_CODE = 'accessCode'
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ApiKeyLocation(str, Enum):
|
|
145
|
+
"""API key location."""
|
|
146
|
+
|
|
147
|
+
HEADER = 'header'
|
|
148
|
+
QUERY = 'query'
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ============================================================================
|
|
152
|
+
# Helper Classes
|
|
153
|
+
# ============================================================================
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class WarningCollector:
|
|
157
|
+
"""Helper class to collect and deduplicate warnings during upgrade.
|
|
158
|
+
|
|
159
|
+
Instead of generating per-occurrence warnings that can flood output for large APIs,
|
|
160
|
+
this class tracks warning counts and generates summary messages.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self) -> None:
|
|
164
|
+
self._counts: dict[str, int] = {}
|
|
165
|
+
self._unique_warnings: list[str] = []
|
|
166
|
+
|
|
167
|
+
def add(self, warning_key: str, count: int = 1) -> None:
|
|
168
|
+
"""Add a warning that should be counted and summarized."""
|
|
169
|
+
self._counts[warning_key] = self._counts.get(warning_key, 0) + count
|
|
170
|
+
|
|
171
|
+
def add_unique(self, warning: str) -> None:
|
|
172
|
+
"""Add a warning that should appear as-is (not deduplicated)."""
|
|
173
|
+
self._unique_warnings.append(warning)
|
|
174
|
+
|
|
175
|
+
def get_warnings(self) -> list[str]:
|
|
176
|
+
"""Get the final list of deduplicated/summarized warnings."""
|
|
177
|
+
result: list[str] = []
|
|
178
|
+
|
|
179
|
+
# Add unique warnings first
|
|
180
|
+
result.extend(self._unique_warnings)
|
|
181
|
+
|
|
182
|
+
# Add summarized warnings
|
|
183
|
+
warning_templates = {
|
|
184
|
+
'oauth2_implicit': (
|
|
185
|
+
'OAuth2 implicit flow restructured for OpenAPI 3.0',
|
|
186
|
+
'OAuth2 implicit flows',
|
|
187
|
+
),
|
|
188
|
+
'oauth2_password': (
|
|
189
|
+
'OAuth2 password flow restructured for OpenAPI 3.0',
|
|
190
|
+
'OAuth2 password flows',
|
|
191
|
+
),
|
|
192
|
+
'oauth2_client_credentials': (
|
|
193
|
+
'OAuth2 application flow converted to clientCredentials for OpenAPI 3.0',
|
|
194
|
+
'OAuth2 application flows',
|
|
195
|
+
),
|
|
196
|
+
'oauth2_authorization_code': (
|
|
197
|
+
'OAuth2 accessCode flow converted to authorizationCode for OpenAPI 3.0',
|
|
198
|
+
'OAuth2 accessCode flows',
|
|
199
|
+
),
|
|
200
|
+
'collection_format_multi': (
|
|
201
|
+
"collectionFormat 'multi' converted to style=form with explode=true",
|
|
202
|
+
'multi collectionFormat fields',
|
|
203
|
+
),
|
|
204
|
+
'collection_format_tsv': (
|
|
205
|
+
"collectionFormat 'tsv' has no direct equivalent in OpenAPI 3.0, using pipeDelimited",
|
|
206
|
+
'tsv collectionFormat fields',
|
|
207
|
+
),
|
|
208
|
+
'file_upload_consumes': (
|
|
209
|
+
'File upload parameter requires multipart/form-data content type',
|
|
210
|
+
'file upload warnings',
|
|
211
|
+
),
|
|
212
|
+
'inferred_response': (
|
|
213
|
+
'Inferred 200 response schema from request body',
|
|
214
|
+
'inferred response schemas',
|
|
215
|
+
),
|
|
216
|
+
'body_param_at_path': (
|
|
217
|
+
'Body parameter found at path level, converting to inline requestBody',
|
|
218
|
+
'body parameters at path level',
|
|
219
|
+
),
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for key, count in self._counts.items():
|
|
223
|
+
if key in warning_templates:
|
|
224
|
+
singular, plural = warning_templates[key]
|
|
225
|
+
if count == 1:
|
|
226
|
+
result.append(singular)
|
|
227
|
+
else:
|
|
228
|
+
result.append(f'{singular} ({count} occurrences)')
|
|
229
|
+
else:
|
|
230
|
+
# Generic key not in templates
|
|
231
|
+
if count == 1:
|
|
232
|
+
result.append(key)
|
|
233
|
+
else:
|
|
234
|
+
result.append(f'{key} ({count} occurrences)')
|
|
235
|
+
|
|
236
|
+
return result
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ============================================================================
|
|
240
|
+
# Base Models
|
|
241
|
+
# ============================================================================
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class BaseModelWithVendorExtensions(BaseModel):
|
|
245
|
+
"""Base model that allows vendor extensions (x- fields)."""
|
|
246
|
+
|
|
247
|
+
model_config = ConfigDict(extra='allow', populate_by_name=True)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class JsonReference(BaseModel):
|
|
251
|
+
"""JSON Reference object."""
|
|
252
|
+
|
|
253
|
+
ref: str = Field(..., alias='$ref')
|
|
254
|
+
|
|
255
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ============================================================================
|
|
259
|
+
# Info Models
|
|
260
|
+
# ============================================================================
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class Contact(BaseModelWithVendorExtensions):
|
|
264
|
+
"""Contact information for the API."""
|
|
265
|
+
|
|
266
|
+
name: str | None = None
|
|
267
|
+
url: HttpUrl | None = None
|
|
268
|
+
email: str | None = None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class License(BaseModelWithVendorExtensions):
|
|
272
|
+
"""License information for the API."""
|
|
273
|
+
|
|
274
|
+
name: str
|
|
275
|
+
url: HttpUrl | None = None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class Info(BaseModelWithVendorExtensions):
|
|
279
|
+
"""General information about the API."""
|
|
280
|
+
|
|
281
|
+
title: str
|
|
282
|
+
version: str
|
|
283
|
+
description: str | None = None
|
|
284
|
+
terms_of_service: str | None = Field(None, alias='termsOfService')
|
|
285
|
+
contact: Contact | None = None
|
|
286
|
+
license: License | None = None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class ExternalDocs(BaseModelWithVendorExtensions):
|
|
290
|
+
"""External documentation reference."""
|
|
291
|
+
|
|
292
|
+
url: HttpUrl
|
|
293
|
+
description: str | None = None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class Tag(BaseModelWithVendorExtensions):
|
|
297
|
+
"""API tag for grouping operations."""
|
|
298
|
+
|
|
299
|
+
name: str
|
|
300
|
+
description: str | None = None
|
|
301
|
+
external_docs: ExternalDocs | None = Field(None, alias='externalDocs')
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ============================================================================
|
|
305
|
+
# XML Model
|
|
306
|
+
# ============================================================================
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class XML(BaseModelWithVendorExtensions):
|
|
310
|
+
"""XML representation metadata."""
|
|
311
|
+
|
|
312
|
+
name: str | None = None
|
|
313
|
+
namespace: str | None = None
|
|
314
|
+
prefix: str | None = None
|
|
315
|
+
attribute: bool = False
|
|
316
|
+
wrapped: bool = False
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ============================================================================
|
|
320
|
+
# Schema Models
|
|
321
|
+
# ============================================================================
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class Schema(BaseModelWithVendorExtensions):
|
|
325
|
+
"""
|
|
326
|
+
JSON Schema object for Swagger 2.0.
|
|
327
|
+
|
|
328
|
+
Note: This is a simplified version. Full schema validation is complex
|
|
329
|
+
and may require recursive type definitions.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
ref: str | None = Field(None, alias='$ref')
|
|
333
|
+
format: str | None = None
|
|
334
|
+
title: str | None = None
|
|
335
|
+
description: str | None = None
|
|
336
|
+
default: Any | None = None
|
|
337
|
+
multiple_of: float | None = Field(None, alias='multipleOf', gt=0)
|
|
338
|
+
maximum: float | None = None
|
|
339
|
+
exclusive_maximum: bool | None = Field(None, alias='exclusiveMaximum')
|
|
340
|
+
minimum: float | None = None
|
|
341
|
+
exclusive_minimum: bool | None = Field(None, alias='exclusiveMinimum')
|
|
342
|
+
max_length: int | None = Field(None, alias='maxLength', ge=0)
|
|
343
|
+
min_length: int | None = Field(None, alias='minLength', ge=0)
|
|
344
|
+
pattern: str | None = None
|
|
345
|
+
max_items: int | None = Field(None, alias='maxItems', ge=0)
|
|
346
|
+
min_items: int | None = Field(None, alias='minItems', ge=0)
|
|
347
|
+
unique_items: bool | None = Field(None, alias='uniqueItems')
|
|
348
|
+
max_properties: int | None = Field(None, alias='maxProperties', ge=0)
|
|
349
|
+
min_properties: int | None = Field(None, alias='minProperties', ge=0)
|
|
350
|
+
required: list[str] | None = None
|
|
351
|
+
enum: list[Any] | None = None
|
|
352
|
+
type: str | list[str] | None = None
|
|
353
|
+
items: Union['Schema', list['Schema']] | None = None
|
|
354
|
+
all_of: list['Schema'] | None = Field(None, alias='allOf')
|
|
355
|
+
properties: dict[str, 'Schema'] | None = None
|
|
356
|
+
additional_properties: Union['Schema', bool] | None = Field(
|
|
357
|
+
None, alias='additionalProperties'
|
|
358
|
+
)
|
|
359
|
+
discriminator: str | None = None
|
|
360
|
+
read_only: bool = Field(False, alias='readOnly')
|
|
361
|
+
xml: XML | None = None
|
|
362
|
+
external_docs: ExternalDocs | None = Field(None, alias='externalDocs')
|
|
363
|
+
example: Any | None = None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class FileSchema(BaseModelWithVendorExtensions):
|
|
367
|
+
"""Schema for file uploads."""
|
|
368
|
+
|
|
369
|
+
type: Literal['file']
|
|
370
|
+
format: str | None = None
|
|
371
|
+
title: str | None = None
|
|
372
|
+
description: str | None = None
|
|
373
|
+
default: Any | None = None
|
|
374
|
+
required: list[str] | None = None
|
|
375
|
+
read_only: bool = Field(False, alias='readOnly')
|
|
376
|
+
external_docs: ExternalDocs | None = Field(None, alias='externalDocs')
|
|
377
|
+
example: Any | None = None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ============================================================================
|
|
381
|
+
# Parameter Models
|
|
382
|
+
# ============================================================================
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class PrimitivesItems(BaseModelWithVendorExtensions):
|
|
386
|
+
"""Items object for primitive array parameters."""
|
|
387
|
+
|
|
388
|
+
type: PrimitiveType | None = None
|
|
389
|
+
format: str | None = None
|
|
390
|
+
items: Optional['PrimitivesItems'] = None
|
|
391
|
+
collection_format: CollectionFormat | None = Field(
|
|
392
|
+
CollectionFormat.CSV, alias='collectionFormat'
|
|
393
|
+
)
|
|
394
|
+
default: Any | None = None
|
|
395
|
+
maximum: float | None = None
|
|
396
|
+
exclusive_maximum: bool | None = Field(None, alias='exclusiveMaximum')
|
|
397
|
+
minimum: float | None = None
|
|
398
|
+
exclusive_minimum: bool | None = Field(None, alias='exclusiveMinimum')
|
|
399
|
+
max_length: int | None = Field(None, alias='maxLength', ge=0)
|
|
400
|
+
min_length: int | None = Field(None, alias='minLength', ge=0)
|
|
401
|
+
pattern: str | None = None
|
|
402
|
+
max_items: int | None = Field(None, alias='maxItems', ge=0)
|
|
403
|
+
min_items: int | None = Field(None, alias='minItems', ge=0)
|
|
404
|
+
unique_items: bool | None = Field(None, alias='uniqueItems')
|
|
405
|
+
enum: list[Any] | None = None
|
|
406
|
+
multiple_of: float | None = Field(None, alias='multipleOf', gt=0)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class BaseParameterFields(BaseModelWithVendorExtensions):
|
|
410
|
+
"""Common fields for all parameter types."""
|
|
411
|
+
|
|
412
|
+
name: str
|
|
413
|
+
in_: ParameterLocation = Field(..., alias='in')
|
|
414
|
+
description: str | None = None
|
|
415
|
+
required: bool = False
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class BodyParameter(BaseParameterFields):
|
|
419
|
+
"""Body parameter definition."""
|
|
420
|
+
|
|
421
|
+
in_: Literal[ParameterLocation.BODY] = Field(ParameterLocation.BODY, alias='in')
|
|
422
|
+
schema_: Schema = Field(..., alias='schema')
|
|
423
|
+
required: bool = False
|
|
424
|
+
|
|
425
|
+
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class NonBodyParameter(BaseParameterFields):
|
|
429
|
+
"""Non-body parameter (query, header, path, formData)."""
|
|
430
|
+
|
|
431
|
+
in_: (
|
|
432
|
+
Literal[ParameterLocation.QUERY]
|
|
433
|
+
| Literal[ParameterLocation.HEADER]
|
|
434
|
+
| Literal[ParameterLocation.PATH]
|
|
435
|
+
| Literal[ParameterLocation.FORM_DATA]
|
|
436
|
+
) = Field(..., alias='in')
|
|
437
|
+
type: PrimitiveType
|
|
438
|
+
format: str | None = None
|
|
439
|
+
allow_empty_value: bool | None = Field(None, alias='allowEmptyValue')
|
|
440
|
+
items: PrimitivesItems | None = None
|
|
441
|
+
collection_format: CollectionFormat | CollectionFormatWithMulti | None = Field(
|
|
442
|
+
CollectionFormat.CSV, alias='collectionFormat'
|
|
443
|
+
)
|
|
444
|
+
default: Any | None = None
|
|
445
|
+
maximum: float | None = None
|
|
446
|
+
exclusive_maximum: bool | None = Field(None, alias='exclusiveMaximum')
|
|
447
|
+
minimum: float | None = None
|
|
448
|
+
exclusive_minimum: bool | None = Field(None, alias='exclusiveMinimum')
|
|
449
|
+
max_length: int | None = Field(None, alias='maxLength', ge=0)
|
|
450
|
+
min_length: int | None = Field(None, alias='minLength', ge=0)
|
|
451
|
+
pattern: str | None = None
|
|
452
|
+
max_items: int | None = Field(None, alias='maxItems', ge=0)
|
|
453
|
+
min_items: int | None = Field(None, alias='minItems', ge=0)
|
|
454
|
+
unique_items: bool | None = Field(None, alias='uniqueItems')
|
|
455
|
+
enum: list[Any] | None = None
|
|
456
|
+
multiple_of: float | None = Field(None, alias='multipleOf', gt=0)
|
|
457
|
+
|
|
458
|
+
@model_validator(mode='after')
|
|
459
|
+
def validate_path_required(self) -> 'NonBodyParameter':
|
|
460
|
+
"""Path parameters must be required."""
|
|
461
|
+
if self.in_ == ParameterLocation.PATH and not self.required:
|
|
462
|
+
raise ValueError('Path parameters must have required=True')
|
|
463
|
+
return self
|
|
464
|
+
|
|
465
|
+
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
Parameter = BodyParameter | NonBodyParameter | JsonReference
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ============================================================================
|
|
472
|
+
# Response Models
|
|
473
|
+
# ============================================================================
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class Header(BaseModelWithVendorExtensions):
|
|
477
|
+
"""Response header definition."""
|
|
478
|
+
|
|
479
|
+
type: PrimitiveType
|
|
480
|
+
format: str | None = None
|
|
481
|
+
items: PrimitivesItems | None = None
|
|
482
|
+
collection_format: CollectionFormat | None = Field(
|
|
483
|
+
CollectionFormat.CSV, alias='collectionFormat'
|
|
484
|
+
)
|
|
485
|
+
default: Any | None = None
|
|
486
|
+
maximum: float | None = None
|
|
487
|
+
exclusive_maximum: bool | None = Field(None, alias='exclusiveMaximum')
|
|
488
|
+
minimum: float | None = None
|
|
489
|
+
exclusive_minimum: bool | None = Field(None, alias='exclusiveMinimum')
|
|
490
|
+
max_length: int | None = Field(None, alias='maxLength', ge=0)
|
|
491
|
+
min_length: int | None = Field(None, alias='minLength', ge=0)
|
|
492
|
+
pattern: str | None = None
|
|
493
|
+
max_items: int | None = Field(None, alias='maxItems', ge=0)
|
|
494
|
+
min_items: int | None = Field(None, alias='minItems', ge=0)
|
|
495
|
+
unique_items: bool | None = Field(None, alias='uniqueItems')
|
|
496
|
+
enum: list[Any] | None = None
|
|
497
|
+
multiple_of: float | None = Field(None, alias='multipleOf', gt=0)
|
|
498
|
+
description: str | None = None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class Response(BaseModelWithVendorExtensions):
|
|
502
|
+
"""Response object."""
|
|
503
|
+
|
|
504
|
+
description: str
|
|
505
|
+
schema_: Schema | FileSchema | None = Field(None, alias='schema')
|
|
506
|
+
headers: dict[str, Header] | None = None
|
|
507
|
+
examples: dict[str, Any] | None = None
|
|
508
|
+
|
|
509
|
+
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
ResponseValue = Response | JsonReference
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class Responses(BaseModelWithVendorExtensions):
|
|
516
|
+
"""
|
|
517
|
+
Response definitions for an operation.
|
|
518
|
+
|
|
519
|
+
Keys can be HTTP status codes (as strings) or "default".
|
|
520
|
+
"""
|
|
521
|
+
|
|
522
|
+
model_config = ConfigDict(extra='allow', populate_by_name=True)
|
|
523
|
+
|
|
524
|
+
def __getitem__(self, key: str) -> ResponseValue:
|
|
525
|
+
"""Allow dict-like access to response codes."""
|
|
526
|
+
return self.__pydantic_extra__.get(key) or getattr(self, key, None)
|
|
527
|
+
|
|
528
|
+
@model_validator(mode='before')
|
|
529
|
+
@classmethod
|
|
530
|
+
def validate_and_convert_responses(cls, data: Any) -> Any:
|
|
531
|
+
"""Validate response keys and convert to Response objects."""
|
|
532
|
+
if not isinstance(data, dict):
|
|
533
|
+
return data
|
|
534
|
+
|
|
535
|
+
result = {}
|
|
536
|
+
for key, value in data.items():
|
|
537
|
+
# Validate response keys (except vendor extensions)
|
|
538
|
+
if not key.startswith('x-'):
|
|
539
|
+
if key != 'default' and not (key.isdigit() and len(key) == 3):
|
|
540
|
+
raise ValueError(
|
|
541
|
+
f"Response key must be a 3-digit status code or 'default', got: {key}"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# Convert dict values to Response objects (or keep JsonReference)
|
|
545
|
+
if isinstance(value, dict):
|
|
546
|
+
if '$ref' in value:
|
|
547
|
+
result[key] = JsonReference(**value)
|
|
548
|
+
else:
|
|
549
|
+
result[key] = Response(**value)
|
|
550
|
+
else:
|
|
551
|
+
result[key] = value
|
|
552
|
+
|
|
553
|
+
return result
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# ============================================================================
|
|
557
|
+
# Operation Models
|
|
558
|
+
# ============================================================================
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class Operation(BaseModelWithVendorExtensions):
|
|
562
|
+
"""Operation (HTTP method) on a path."""
|
|
563
|
+
|
|
564
|
+
tags: list[str] | None = None
|
|
565
|
+
summary: str | None = None
|
|
566
|
+
description: str | None = None
|
|
567
|
+
external_docs: ExternalDocs | None = Field(None, alias='externalDocs')
|
|
568
|
+
operation_id: str | None = Field(None, alias='operationId')
|
|
569
|
+
consumes: list[str] | None = None
|
|
570
|
+
produces: list[str] | None = None
|
|
571
|
+
parameters: list[Parameter] | None = None
|
|
572
|
+
responses: Responses
|
|
573
|
+
schemes: list[SchemeType] | None = None
|
|
574
|
+
deprecated: bool = False
|
|
575
|
+
security: list[dict[str, list[str]]] | None = None
|
|
576
|
+
|
|
577
|
+
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
class PathItem(BaseModelWithVendorExtensions):
|
|
581
|
+
"""Path item with operations."""
|
|
582
|
+
|
|
583
|
+
ref: str | None = Field(None, alias='$ref')
|
|
584
|
+
get: Operation | None = None
|
|
585
|
+
put: Operation | None = None
|
|
586
|
+
post: Operation | None = None
|
|
587
|
+
delete: Operation | None = None
|
|
588
|
+
options: Operation | None = None
|
|
589
|
+
head: Operation | None = None
|
|
590
|
+
patch: Operation | None = None
|
|
591
|
+
parameters: list[Parameter] | None = None
|
|
592
|
+
|
|
593
|
+
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
class Paths(BaseModelWithVendorExtensions):
|
|
597
|
+
"""
|
|
598
|
+
Paths object containing all API paths.
|
|
599
|
+
|
|
600
|
+
Keys must start with "/" (except vendor extensions starting with "x-").
|
|
601
|
+
"""
|
|
602
|
+
|
|
603
|
+
model_config = ConfigDict(extra='allow', populate_by_name=True)
|
|
604
|
+
|
|
605
|
+
@model_validator(mode='before')
|
|
606
|
+
@classmethod
|
|
607
|
+
def validate_and_convert_paths(cls, data: Any) -> Any:
|
|
608
|
+
"""Validate path keys and convert path items to PathItem objects."""
|
|
609
|
+
if not isinstance(data, dict):
|
|
610
|
+
return data
|
|
611
|
+
|
|
612
|
+
result = {}
|
|
613
|
+
for key, value in data.items():
|
|
614
|
+
# Validate path keys
|
|
615
|
+
if not key.startswith('x-') and not key.startswith('/'):
|
|
616
|
+
raise ValueError(f"Path must start with '/', got: {key}")
|
|
617
|
+
|
|
618
|
+
# Convert dict values to PathItem objects for paths
|
|
619
|
+
if key.startswith('/') and isinstance(value, dict):
|
|
620
|
+
result[key] = PathItem(**value)
|
|
621
|
+
else:
|
|
622
|
+
result[key] = value
|
|
623
|
+
|
|
624
|
+
return result
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# ============================================================================
|
|
628
|
+
# Security Models
|
|
629
|
+
# ============================================================================
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
class BasicAuthenticationSecurity(BaseModelWithVendorExtensions):
|
|
633
|
+
"""Basic authentication security scheme."""
|
|
634
|
+
|
|
635
|
+
type: Literal[SecuritySchemeType.BASIC]
|
|
636
|
+
description: str | None = None
|
|
637
|
+
|
|
638
|
+
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class ApiKeySecurity(BaseModelWithVendorExtensions):
|
|
642
|
+
"""API key security scheme."""
|
|
643
|
+
|
|
644
|
+
type: Literal[SecuritySchemeType.API_KEY]
|
|
645
|
+
name: str
|
|
646
|
+
in_: ApiKeyLocation = Field(..., alias='in')
|
|
647
|
+
description: str | None = None
|
|
648
|
+
|
|
649
|
+
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
class OAuth2ImplicitSecurity(BaseModelWithVendorExtensions):
|
|
653
|
+
"""OAuth2 implicit flow security scheme."""
|
|
654
|
+
|
|
655
|
+
type: Literal[SecuritySchemeType.OAUTH2]
|
|
656
|
+
flow: Literal[OAuth2Flow.IMPLICIT]
|
|
657
|
+
authorization_url: HttpUrl = Field(..., alias='authorizationUrl')
|
|
658
|
+
scopes: dict[str, str] = {}
|
|
659
|
+
description: str | None = None
|
|
660
|
+
|
|
661
|
+
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
class OAuth2PasswordSecurity(BaseModelWithVendorExtensions):
|
|
665
|
+
"""OAuth2 password flow security scheme."""
|
|
666
|
+
|
|
667
|
+
type: Literal[SecuritySchemeType.OAUTH2]
|
|
668
|
+
flow: Literal[OAuth2Flow.PASSWORD]
|
|
669
|
+
token_url: HttpUrl = Field(..., alias='tokenUrl')
|
|
670
|
+
scopes: dict[str, str] = {}
|
|
671
|
+
description: str | None = None
|
|
672
|
+
|
|
673
|
+
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
class OAuth2ApplicationSecurity(BaseModelWithVendorExtensions):
|
|
677
|
+
"""OAuth2 application flow security scheme."""
|
|
678
|
+
|
|
679
|
+
type: Literal[SecuritySchemeType.OAUTH2]
|
|
680
|
+
flow: Literal[OAuth2Flow.APPLICATION]
|
|
681
|
+
token_url: HttpUrl = Field(..., alias='tokenUrl')
|
|
682
|
+
scopes: dict[str, str] = {}
|
|
683
|
+
description: str | None = None
|
|
684
|
+
|
|
685
|
+
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
class OAuth2AccessCodeSecurity(BaseModelWithVendorExtensions):
|
|
689
|
+
"""OAuth2 access code flow security scheme."""
|
|
690
|
+
|
|
691
|
+
type: Literal[SecuritySchemeType.OAUTH2]
|
|
692
|
+
flow: Literal[OAuth2Flow.ACCESS_CODE]
|
|
693
|
+
authorization_url: HttpUrl = Field(..., alias='authorizationUrl')
|
|
694
|
+
token_url: HttpUrl = Field(..., alias='tokenUrl')
|
|
695
|
+
scopes: dict[str, str] = {}
|
|
696
|
+
description: str | None = None
|
|
697
|
+
|
|
698
|
+
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
SecurityScheme = (
|
|
702
|
+
BasicAuthenticationSecurity
|
|
703
|
+
| ApiKeySecurity
|
|
704
|
+
| OAuth2ImplicitSecurity
|
|
705
|
+
| OAuth2PasswordSecurity
|
|
706
|
+
| OAuth2ApplicationSecurity
|
|
707
|
+
| OAuth2AccessCodeSecurity
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# ============================================================================
|
|
712
|
+
# Main Swagger Model
|
|
713
|
+
# ============================================================================
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
class Swagger(BaseModelWithVendorExtensions):
|
|
717
|
+
"""
|
|
718
|
+
Root Swagger 2.0 specification object.
|
|
719
|
+
|
|
720
|
+
This is the main model representing a complete Swagger/OpenAPI 2.0 document.
|
|
721
|
+
"""
|
|
722
|
+
|
|
723
|
+
swagger: Literal['2.0']
|
|
724
|
+
info: Info
|
|
725
|
+
host: str | None = Field(None, pattern=r'^[^{}/ :\\]+(?::\d+)?$')
|
|
726
|
+
base_path: str | None = Field(None, alias='basePath', pattern=r'^/')
|
|
727
|
+
schemes: list[SchemeType] | None = None
|
|
728
|
+
consumes: list[str] | None = None
|
|
729
|
+
produces: list[str] | None = None
|
|
730
|
+
paths: Paths
|
|
731
|
+
definitions: dict[str, Schema] | None = None
|
|
732
|
+
parameters: dict[str, Parameter] | None = None
|
|
733
|
+
responses: dict[str, Response] | None = None
|
|
734
|
+
security_definitions: dict[str, SecurityScheme] | None = Field(
|
|
735
|
+
None, alias='securityDefinitions'
|
|
736
|
+
)
|
|
737
|
+
security: list[dict[str, list[str]]] | None = None
|
|
738
|
+
tags: list[Tag] | None = None
|
|
739
|
+
external_docs: ExternalDocs | None = Field(None, alias='externalDocs')
|
|
740
|
+
|
|
741
|
+
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
|
742
|
+
|
|
743
|
+
def upgrade(self) -> tuple[OpenAPI, list[str]]:
|
|
744
|
+
"""
|
|
745
|
+
Upgrade this Swagger 2.0 specification to OpenAPI 3.0.
|
|
746
|
+
|
|
747
|
+
Returns:
|
|
748
|
+
A tuple of (OpenAPI 3.0 dict, list of warnings)
|
|
749
|
+
|
|
750
|
+
Note: Returns a dict rather than openapi_v3.OpenAPI object because the v3
|
|
751
|
+
models have limitations (e.g., Responses has extra='forbid' but needs to
|
|
752
|
+
accept arbitrary status codes). The dict can be validated with
|
|
753
|
+
openapi_v3.OpenAPI.model_validate() if needed, or used directly.
|
|
754
|
+
|
|
755
|
+
Warnings are generated for:
|
|
756
|
+
- Lossy conversions
|
|
757
|
+
- Structural changes
|
|
758
|
+
- Missing data that requires defaults
|
|
759
|
+
- OAuth2 flow restructuring
|
|
760
|
+
- Collection format conversions
|
|
761
|
+
|
|
762
|
+
Warnings are deduplicated and summarized to avoid overwhelming output
|
|
763
|
+
for large APIs.
|
|
764
|
+
"""
|
|
765
|
+
warning_collector = WarningCollector()
|
|
766
|
+
|
|
767
|
+
# Convert basic metadata
|
|
768
|
+
info = self._convert_info()
|
|
769
|
+
|
|
770
|
+
# Convert servers from host/basePath/schemes
|
|
771
|
+
servers = self._convert_servers(warning_collector)
|
|
772
|
+
|
|
773
|
+
# Convert components
|
|
774
|
+
components = self._convert_components(warning_collector)
|
|
775
|
+
|
|
776
|
+
# Convert paths - returns dict for Paths RootModel
|
|
777
|
+
paths_dict = self._convert_paths(warning_collector)
|
|
778
|
+
|
|
779
|
+
# Build OpenAPI dict
|
|
780
|
+
openapi_dict: dict[str, Any] = {
|
|
781
|
+
'openapi': '3.0.3',
|
|
782
|
+
'info': info.model_dump(by_alias=True, exclude_none=True, mode='json'),
|
|
783
|
+
'paths': paths_dict,
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if servers:
|
|
787
|
+
openapi_dict['servers'] = [
|
|
788
|
+
s.model_dump(by_alias=True, exclude_none=True, mode='json')
|
|
789
|
+
for s in servers
|
|
790
|
+
]
|
|
791
|
+
|
|
792
|
+
if components:
|
|
793
|
+
openapi_dict['components'] = components.model_dump(
|
|
794
|
+
by_alias=True, exclude_none=True, mode='json'
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
if self.security:
|
|
798
|
+
openapi_dict['security'] = self.security
|
|
799
|
+
|
|
800
|
+
if self.tags:
|
|
801
|
+
openapi_dict['tags'] = [
|
|
802
|
+
openapi_v3.Tag(
|
|
803
|
+
name=tag.name,
|
|
804
|
+
description=tag.description,
|
|
805
|
+
externalDocs=openapi_v3.ExternalDocumentation(
|
|
806
|
+
url=str(tag.external_docs.url),
|
|
807
|
+
description=tag.external_docs.description,
|
|
808
|
+
)
|
|
809
|
+
if tag.external_docs
|
|
810
|
+
else None,
|
|
811
|
+
).model_dump(by_alias=True, exclude_none=True, mode='json')
|
|
812
|
+
for tag in self.tags
|
|
813
|
+
]
|
|
814
|
+
|
|
815
|
+
if self.external_docs:
|
|
816
|
+
openapi_dict['externalDocs'] = openapi_v3.ExternalDocumentation(
|
|
817
|
+
url=str(self.external_docs.url),
|
|
818
|
+
description=self.external_docs.description,
|
|
819
|
+
).model_dump(by_alias=True, exclude_none=True, mode='json')
|
|
820
|
+
|
|
821
|
+
return OpenAPI.model_validate(openapi_dict), warning_collector.get_warnings()
|
|
822
|
+
|
|
823
|
+
def _convert_info(self) -> openapi_v3.Info:
|
|
824
|
+
"""Convert Info object from Swagger 2.0 to OpenAPI 3.0."""
|
|
825
|
+
contact = None
|
|
826
|
+
if self.info.contact:
|
|
827
|
+
contact = openapi_v3.Contact(
|
|
828
|
+
name=self.info.contact.name,
|
|
829
|
+
url=str(self.info.contact.url) if self.info.contact.url else None,
|
|
830
|
+
email=self.info.contact.email,
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
license_obj = None
|
|
834
|
+
if self.info.license:
|
|
835
|
+
license_obj = openapi_v3.License(
|
|
836
|
+
name=self.info.license.name,
|
|
837
|
+
url=str(self.info.license.url) if self.info.license.url else None,
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
return openapi_v3.Info(
|
|
841
|
+
title=self.info.title,
|
|
842
|
+
version=self.info.version,
|
|
843
|
+
description=self.info.description,
|
|
844
|
+
termsOfService=self.info.terms_of_service,
|
|
845
|
+
contact=contact,
|
|
846
|
+
license=license_obj,
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
def _convert_servers(self, warnings: WarningCollector) -> list[openapi_v3.Server]:
|
|
850
|
+
"""Convert host, basePath, and schemes to servers array."""
|
|
851
|
+
if not self.host and not self.base_path:
|
|
852
|
+
warnings.add_unique(
|
|
853
|
+
"No host or basePath specified, defaulting to server URL '/'"
|
|
854
|
+
)
|
|
855
|
+
return [openapi_v3.Server(url='/')]
|
|
856
|
+
|
|
857
|
+
servers = []
|
|
858
|
+
schemes = self.schemes or [SchemeType.HTTP]
|
|
859
|
+
host = self.host or ''
|
|
860
|
+
base_path = self.base_path or ''
|
|
861
|
+
|
|
862
|
+
for scheme in schemes:
|
|
863
|
+
url = f'{scheme.value}://{host}{base_path}' if host else base_path
|
|
864
|
+
servers.append(openapi_v3.Server(url=url))
|
|
865
|
+
|
|
866
|
+
return servers
|
|
867
|
+
|
|
868
|
+
def _convert_components(
|
|
869
|
+
self, warnings: WarningCollector
|
|
870
|
+
) -> openapi_v3.Components | None:
|
|
871
|
+
"""Convert definitions, parameters, responses, and security to components."""
|
|
872
|
+
schemas = None
|
|
873
|
+
if self.definitions:
|
|
874
|
+
schemas = {
|
|
875
|
+
name: self._convert_schema_to_dict(schema)
|
|
876
|
+
for name, schema in self.definitions.items()
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
parameters = None
|
|
880
|
+
if self.parameters:
|
|
881
|
+
parameters = {
|
|
882
|
+
name: self._convert_component_parameter_to_dict(param, warnings)
|
|
883
|
+
for name, param in self.parameters.items()
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
responses = None
|
|
887
|
+
if self.responses:
|
|
888
|
+
responses = {
|
|
889
|
+
name: self._convert_response_to_dict(
|
|
890
|
+
response, self.produces or ['application/json'], warnings
|
|
891
|
+
)
|
|
892
|
+
for name, response in self.responses.items()
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
security_schemes = None
|
|
896
|
+
if self.security_definitions:
|
|
897
|
+
security_schemes = {
|
|
898
|
+
name: self._convert_security_scheme_to_dict(scheme, warnings)
|
|
899
|
+
for name, scheme in self.security_definitions.items()
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if not any([schemas, parameters, responses, security_schemes]):
|
|
903
|
+
return None
|
|
904
|
+
|
|
905
|
+
# Use model_validate to create Components from dict
|
|
906
|
+
components_dict = {}
|
|
907
|
+
if schemas:
|
|
908
|
+
components_dict['schemas'] = schemas
|
|
909
|
+
if parameters:
|
|
910
|
+
components_dict['parameters'] = parameters
|
|
911
|
+
if responses:
|
|
912
|
+
components_dict['responses'] = responses
|
|
913
|
+
if security_schemes:
|
|
914
|
+
components_dict['securitySchemes'] = security_schemes
|
|
915
|
+
|
|
916
|
+
return openapi_v3.Components.model_validate(components_dict)
|
|
917
|
+
|
|
918
|
+
def _convert_paths(self, warnings: WarningCollector) -> dict[str, Any]:
|
|
919
|
+
"""Convert paths object."""
|
|
920
|
+
result = {}
|
|
921
|
+
|
|
922
|
+
# Get paths from __pydantic_extra__
|
|
923
|
+
if hasattr(self.paths, '__pydantic_extra__') and self.paths.__pydantic_extra__:
|
|
924
|
+
for path, path_item in self.paths.__pydantic_extra__.items():
|
|
925
|
+
if path.startswith('x-'):
|
|
926
|
+
# Vendor extension
|
|
927
|
+
result[path] = path_item
|
|
928
|
+
elif isinstance(path_item, PathItem):
|
|
929
|
+
result[path] = self._convert_path_item(path_item, warnings)
|
|
930
|
+
|
|
931
|
+
return result
|
|
932
|
+
|
|
933
|
+
def _convert_path_item(
|
|
934
|
+
self, path_item: PathItem, warnings: WarningCollector
|
|
935
|
+
) -> dict[str, Any]:
|
|
936
|
+
"""Convert a single PathItem."""
|
|
937
|
+
result: dict[str, Any] = {}
|
|
938
|
+
|
|
939
|
+
if path_item.ref:
|
|
940
|
+
result['$ref'] = self._update_ref(path_item.ref)
|
|
941
|
+
|
|
942
|
+
# Convert each operation
|
|
943
|
+
for method in ['get', 'put', 'post', 'delete', 'options', 'head', 'patch']:
|
|
944
|
+
operation = getattr(path_item, method, None)
|
|
945
|
+
if operation:
|
|
946
|
+
result[method] = self._convert_operation(
|
|
947
|
+
operation, warnings, method=method
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
# Convert path-level parameters
|
|
951
|
+
if path_item.parameters:
|
|
952
|
+
result['parameters'] = [
|
|
953
|
+
self._convert_parameter_item_to_dict(param, warnings)
|
|
954
|
+
for param in path_item.parameters
|
|
955
|
+
]
|
|
956
|
+
|
|
957
|
+
result.update(self._extract_vendor_extensions(path_item))
|
|
958
|
+
|
|
959
|
+
return result
|
|
960
|
+
|
|
961
|
+
def _convert_operation(
|
|
962
|
+
self, operation: Operation, warnings: list[str], method: str = None
|
|
963
|
+
) -> dict[str, Any]:
|
|
964
|
+
"""Convert an Operation object."""
|
|
965
|
+
result: dict[str, Any] = {}
|
|
966
|
+
|
|
967
|
+
if operation.tags:
|
|
968
|
+
result['tags'] = operation.tags
|
|
969
|
+
|
|
970
|
+
if operation.summary:
|
|
971
|
+
result['summary'] = operation.summary
|
|
972
|
+
|
|
973
|
+
if operation.description:
|
|
974
|
+
result['description'] = operation.description
|
|
975
|
+
|
|
976
|
+
if operation.external_docs:
|
|
977
|
+
result['externalDocs'] = {
|
|
978
|
+
'url': str(operation.external_docs.url),
|
|
979
|
+
'description': operation.external_docs.description,
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if operation.operation_id:
|
|
983
|
+
result['operationId'] = operation.operation_id
|
|
984
|
+
|
|
985
|
+
# Convert parameters and extract body/formData
|
|
986
|
+
body_schema = None
|
|
987
|
+
if operation.parameters:
|
|
988
|
+
converted = self._convert_parameters(
|
|
989
|
+
operation.parameters,
|
|
990
|
+
operation.consumes or self.consumes,
|
|
991
|
+
warnings,
|
|
992
|
+
)
|
|
993
|
+
if converted['parameters']:
|
|
994
|
+
result['parameters'] = converted['parameters']
|
|
995
|
+
if converted['requestBody']:
|
|
996
|
+
result['requestBody'] = converted['requestBody']
|
|
997
|
+
# Extract body schema for response inference
|
|
998
|
+
body_schema = converted.get('body_schema')
|
|
999
|
+
|
|
1000
|
+
# Convert responses
|
|
1001
|
+
result['responses'] = self._convert_responses(
|
|
1002
|
+
operation.responses,
|
|
1003
|
+
operation.produces or self.produces or ['application/json'],
|
|
1004
|
+
warnings,
|
|
1005
|
+
method=method,
|
|
1006
|
+
body_schema=body_schema,
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
if operation.deprecated:
|
|
1010
|
+
result['deprecated'] = True
|
|
1011
|
+
|
|
1012
|
+
if operation.security:
|
|
1013
|
+
result['security'] = operation.security
|
|
1014
|
+
|
|
1015
|
+
if operation.schemes:
|
|
1016
|
+
# Convert schemes to servers at operation level
|
|
1017
|
+
servers = []
|
|
1018
|
+
for scheme in operation.schemes:
|
|
1019
|
+
host = self.host or ''
|
|
1020
|
+
base_path = self.base_path or ''
|
|
1021
|
+
url = f'{scheme.value}://{host}{base_path}' if host else base_path
|
|
1022
|
+
servers.append({'url': url})
|
|
1023
|
+
result['servers'] = servers
|
|
1024
|
+
|
|
1025
|
+
result.update(self._extract_vendor_extensions(operation))
|
|
1026
|
+
|
|
1027
|
+
return result
|
|
1028
|
+
|
|
1029
|
+
def _convert_parameters(
|
|
1030
|
+
self,
|
|
1031
|
+
parameters: list[Parameter],
|
|
1032
|
+
consumes: list[str] | None,
|
|
1033
|
+
warnings: list[str],
|
|
1034
|
+
) -> dict[str, Any]:
|
|
1035
|
+
"""
|
|
1036
|
+
Convert parameters list, separating body/formData into requestBody.
|
|
1037
|
+
|
|
1038
|
+
Returns dict with 'parameters', 'requestBody', and 'body_schema' keys.
|
|
1039
|
+
The 'body_schema' is used for inferring response schemas when not specified.
|
|
1040
|
+
"""
|
|
1041
|
+
result_params = []
|
|
1042
|
+
body_param = None
|
|
1043
|
+
form_params = []
|
|
1044
|
+
|
|
1045
|
+
for param in parameters:
|
|
1046
|
+
if isinstance(param, JsonReference):
|
|
1047
|
+
# Handle reference
|
|
1048
|
+
result_params.append({'$ref': self._update_ref(param.ref)})
|
|
1049
|
+
elif isinstance(param, BodyParameter):
|
|
1050
|
+
body_param = param
|
|
1051
|
+
elif isinstance(param, NonBodyParameter):
|
|
1052
|
+
if param.in_ == ParameterLocation.FORM_DATA:
|
|
1053
|
+
form_params.append(param)
|
|
1054
|
+
else:
|
|
1055
|
+
result_params.append(
|
|
1056
|
+
self._convert_non_body_parameter_to_dict(param, warnings)
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
request_body = None
|
|
1060
|
+
body_schema = None
|
|
1061
|
+
if body_param:
|
|
1062
|
+
request_body = self._convert_body_parameter_to_dict(
|
|
1063
|
+
body_param, consumes, warnings
|
|
1064
|
+
)
|
|
1065
|
+
# Extract the body schema for response inference
|
|
1066
|
+
body_schema = self._convert_schema_to_dict(body_param.schema_)
|
|
1067
|
+
elif form_params:
|
|
1068
|
+
request_body = self._convert_formdata_parameters(
|
|
1069
|
+
form_params, consumes, warnings
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
return {
|
|
1073
|
+
'parameters': result_params,
|
|
1074
|
+
'requestBody': request_body,
|
|
1075
|
+
'body_schema': body_schema,
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
def _convert_body_parameter_to_dict(
|
|
1079
|
+
self,
|
|
1080
|
+
param: BodyParameter,
|
|
1081
|
+
consumes: list[str] | None,
|
|
1082
|
+
warnings: list[str],
|
|
1083
|
+
) -> dict[str, Any]:
|
|
1084
|
+
"""Convert body parameter to requestBody."""
|
|
1085
|
+
media_types = consumes or ['application/json']
|
|
1086
|
+
|
|
1087
|
+
content = {}
|
|
1088
|
+
for media_type in media_types:
|
|
1089
|
+
content[media_type] = {
|
|
1090
|
+
'schema': self._convert_schema_to_dict(param.schema_)
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
result: dict[str, Any] = {'content': content}
|
|
1094
|
+
|
|
1095
|
+
if param.description:
|
|
1096
|
+
result['description'] = param.description
|
|
1097
|
+
|
|
1098
|
+
if param.required:
|
|
1099
|
+
result['required'] = True
|
|
1100
|
+
|
|
1101
|
+
return result
|
|
1102
|
+
|
|
1103
|
+
def _convert_formdata_parameters(
|
|
1104
|
+
self,
|
|
1105
|
+
params: list[NonBodyParameter],
|
|
1106
|
+
consumes: list[str] | None,
|
|
1107
|
+
warnings: list[str],
|
|
1108
|
+
) -> dict[str, Any]:
|
|
1109
|
+
"""Convert formData parameters to requestBody."""
|
|
1110
|
+
# Determine media type
|
|
1111
|
+
has_file = any(p.type == PrimitiveType.FILE for p in params)
|
|
1112
|
+
media_type = (
|
|
1113
|
+
'multipart/form-data' if has_file else 'application/x-www-form-urlencoded'
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
# Check if consumes specifies something different
|
|
1117
|
+
if consumes and media_type not in consumes:
|
|
1118
|
+
if has_file and 'multipart/form-data' not in consumes:
|
|
1119
|
+
warnings.add('file_upload_consumes')
|
|
1120
|
+
|
|
1121
|
+
# Build schema with properties
|
|
1122
|
+
properties = {}
|
|
1123
|
+
required = []
|
|
1124
|
+
|
|
1125
|
+
for param in params:
|
|
1126
|
+
prop_schema = self._convert_parameter_to_schema(param, warnings)
|
|
1127
|
+
properties[param.name] = prop_schema
|
|
1128
|
+
if param.required:
|
|
1129
|
+
required.append(param.name)
|
|
1130
|
+
|
|
1131
|
+
schema: dict[str, Any] = {
|
|
1132
|
+
'type': 'object',
|
|
1133
|
+
'properties': properties,
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if required:
|
|
1137
|
+
schema['required'] = required
|
|
1138
|
+
|
|
1139
|
+
result: dict[str, Any] = {'content': {media_type: {'schema': schema}}}
|
|
1140
|
+
|
|
1141
|
+
# Use description from first parameter if any
|
|
1142
|
+
if params and params[0].description:
|
|
1143
|
+
result['description'] = params[0].description
|
|
1144
|
+
|
|
1145
|
+
return result
|
|
1146
|
+
|
|
1147
|
+
def _convert_non_body_parameter_to_dict(
|
|
1148
|
+
self, param: NonBodyParameter, warnings: WarningCollector
|
|
1149
|
+
) -> dict[str, Any]:
|
|
1150
|
+
"""Convert query/header/path parameter to OpenAPI 3.0 format."""
|
|
1151
|
+
result: dict[str, Any] = {
|
|
1152
|
+
'name': param.name,
|
|
1153
|
+
'in': param.in_.value,
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
if param.description:
|
|
1157
|
+
result['description'] = param.description
|
|
1158
|
+
|
|
1159
|
+
if param.required:
|
|
1160
|
+
result['required'] = True
|
|
1161
|
+
|
|
1162
|
+
if param.allow_empty_value:
|
|
1163
|
+
result['allowEmptyValue'] = True
|
|
1164
|
+
|
|
1165
|
+
# Convert to schema
|
|
1166
|
+
result['schema'] = self._convert_parameter_to_schema(param, warnings)
|
|
1167
|
+
|
|
1168
|
+
# Convert collectionFormat to style and explode
|
|
1169
|
+
if param.type == PrimitiveType.ARRAY and param.collection_format:
|
|
1170
|
+
style, explode = self._convert_collection_format(
|
|
1171
|
+
param.collection_format,
|
|
1172
|
+
param.in_,
|
|
1173
|
+
warnings,
|
|
1174
|
+
)
|
|
1175
|
+
if style:
|
|
1176
|
+
result['style'] = style
|
|
1177
|
+
if explode is not None:
|
|
1178
|
+
result['explode'] = explode
|
|
1179
|
+
|
|
1180
|
+
result.update(self._extract_vendor_extensions(param))
|
|
1181
|
+
|
|
1182
|
+
return result
|
|
1183
|
+
|
|
1184
|
+
def _convert_parameter_to_schema(
|
|
1185
|
+
self, param: NonBodyParameter, warnings: WarningCollector
|
|
1186
|
+
) -> dict[str, Any]:
|
|
1187
|
+
"""Convert parameter properties to a schema object."""
|
|
1188
|
+
schema: dict[str, Any] = {}
|
|
1189
|
+
|
|
1190
|
+
if param.type == PrimitiveType.FILE:
|
|
1191
|
+
schema['type'] = 'string'
|
|
1192
|
+
schema['format'] = 'binary'
|
|
1193
|
+
else:
|
|
1194
|
+
schema['type'] = param.type.value
|
|
1195
|
+
|
|
1196
|
+
if param.format:
|
|
1197
|
+
schema['format'] = param.format
|
|
1198
|
+
|
|
1199
|
+
if param.items:
|
|
1200
|
+
schema['items'] = self._convert_primitives_items(param.items)
|
|
1201
|
+
|
|
1202
|
+
if param.default is not None:
|
|
1203
|
+
schema['default'] = param.default
|
|
1204
|
+
|
|
1205
|
+
if param.maximum is not None:
|
|
1206
|
+
schema['maximum'] = param.maximum
|
|
1207
|
+
|
|
1208
|
+
if param.exclusive_maximum is not None:
|
|
1209
|
+
schema['exclusiveMaximum'] = param.exclusive_maximum
|
|
1210
|
+
|
|
1211
|
+
if param.minimum is not None:
|
|
1212
|
+
schema['minimum'] = param.minimum
|
|
1213
|
+
|
|
1214
|
+
if param.exclusive_minimum is not None:
|
|
1215
|
+
schema['exclusiveMinimum'] = param.exclusive_minimum
|
|
1216
|
+
|
|
1217
|
+
if param.max_length is not None:
|
|
1218
|
+
schema['maxLength'] = param.max_length
|
|
1219
|
+
|
|
1220
|
+
if param.min_length is not None:
|
|
1221
|
+
schema['minLength'] = param.min_length
|
|
1222
|
+
|
|
1223
|
+
if param.pattern:
|
|
1224
|
+
schema['pattern'] = param.pattern
|
|
1225
|
+
|
|
1226
|
+
if param.max_items is not None:
|
|
1227
|
+
schema['maxItems'] = param.max_items
|
|
1228
|
+
|
|
1229
|
+
if param.min_items is not None:
|
|
1230
|
+
schema['minItems'] = param.min_items
|
|
1231
|
+
|
|
1232
|
+
if param.unique_items is not None:
|
|
1233
|
+
schema['uniqueItems'] = param.unique_items
|
|
1234
|
+
|
|
1235
|
+
if param.enum:
|
|
1236
|
+
schema['enum'] = param.enum
|
|
1237
|
+
|
|
1238
|
+
if param.multiple_of is not None:
|
|
1239
|
+
schema['multipleOf'] = param.multiple_of
|
|
1240
|
+
|
|
1241
|
+
return schema
|
|
1242
|
+
|
|
1243
|
+
def _convert_primitives_items(self, items: PrimitivesItems) -> dict[str, Any]:
|
|
1244
|
+
"""Convert PrimitivesItems to schema."""
|
|
1245
|
+
schema: dict[str, Any] = {}
|
|
1246
|
+
|
|
1247
|
+
if items.type:
|
|
1248
|
+
schema['type'] = items.type.value
|
|
1249
|
+
|
|
1250
|
+
if items.format:
|
|
1251
|
+
schema['format'] = items.format
|
|
1252
|
+
|
|
1253
|
+
if items.items:
|
|
1254
|
+
schema['items'] = self._convert_primitives_items(items.items)
|
|
1255
|
+
|
|
1256
|
+
if items.default is not None:
|
|
1257
|
+
schema['default'] = items.default
|
|
1258
|
+
|
|
1259
|
+
if items.maximum is not None:
|
|
1260
|
+
schema['maximum'] = items.maximum
|
|
1261
|
+
|
|
1262
|
+
if items.minimum is not None:
|
|
1263
|
+
schema['minimum'] = items.minimum
|
|
1264
|
+
|
|
1265
|
+
if items.max_length is not None:
|
|
1266
|
+
schema['maxLength'] = items.max_length
|
|
1267
|
+
|
|
1268
|
+
if items.min_length is not None:
|
|
1269
|
+
schema['minLength'] = items.min_length
|
|
1270
|
+
|
|
1271
|
+
if items.pattern:
|
|
1272
|
+
schema['pattern'] = items.pattern
|
|
1273
|
+
|
|
1274
|
+
if items.max_items is not None:
|
|
1275
|
+
schema['maxItems'] = items.max_items
|
|
1276
|
+
|
|
1277
|
+
if items.min_items is not None:
|
|
1278
|
+
schema['minItems'] = items.min_items
|
|
1279
|
+
|
|
1280
|
+
if items.unique_items is not None:
|
|
1281
|
+
schema['uniqueItems'] = items.unique_items
|
|
1282
|
+
|
|
1283
|
+
if items.enum:
|
|
1284
|
+
schema['enum'] = items.enum
|
|
1285
|
+
|
|
1286
|
+
if items.multiple_of is not None:
|
|
1287
|
+
schema['multipleOf'] = items.multiple_of
|
|
1288
|
+
|
|
1289
|
+
return schema
|
|
1290
|
+
|
|
1291
|
+
def _convert_collection_format(
|
|
1292
|
+
self,
|
|
1293
|
+
collection_format: CollectionFormat | CollectionFormatWithMulti | None,
|
|
1294
|
+
in_: ParameterLocation,
|
|
1295
|
+
warnings: WarningCollector,
|
|
1296
|
+
) -> tuple[str | None, bool | None]:
|
|
1297
|
+
"""
|
|
1298
|
+
Convert collectionFormat to style and explode.
|
|
1299
|
+
|
|
1300
|
+
Returns (style, explode) tuple.
|
|
1301
|
+
"""
|
|
1302
|
+
# Default styles by location
|
|
1303
|
+
default_styles = {
|
|
1304
|
+
ParameterLocation.QUERY: 'form',
|
|
1305
|
+
ParameterLocation.PATH: 'simple',
|
|
1306
|
+
ParameterLocation.HEADER: 'simple',
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
if isinstance(collection_format, CollectionFormatWithMulti):
|
|
1310
|
+
if collection_format == CollectionFormatWithMulti.MULTI:
|
|
1311
|
+
# multi -> explode=true with form style
|
|
1312
|
+
warnings.add('collection_format_multi')
|
|
1313
|
+
return 'form', True
|
|
1314
|
+
collection_format = CollectionFormat(collection_format.value)
|
|
1315
|
+
|
|
1316
|
+
# Mapping for other formats
|
|
1317
|
+
format_map = {
|
|
1318
|
+
CollectionFormat.CSV: (default_styles.get(in_, 'simple'), False),
|
|
1319
|
+
CollectionFormat.SSV: ('spaceDelimited', False),
|
|
1320
|
+
CollectionFormat.TSV: ('pipeDelimited', False),
|
|
1321
|
+
CollectionFormat.PIPES: ('pipeDelimited', False),
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
if collection_format == CollectionFormat.TSV:
|
|
1325
|
+
warnings.add('collection_format_tsv')
|
|
1326
|
+
|
|
1327
|
+
return format_map.get(collection_format, (None, None))
|
|
1328
|
+
|
|
1329
|
+
def _convert_responses(
|
|
1330
|
+
self,
|
|
1331
|
+
responses: Responses,
|
|
1332
|
+
produces: list[str],
|
|
1333
|
+
warnings: WarningCollector,
|
|
1334
|
+
body_schema: dict[str, Any] | None = None,
|
|
1335
|
+
method: str = 'get',
|
|
1336
|
+
) -> dict[str, Any]:
|
|
1337
|
+
"""Convert Responses object.
|
|
1338
|
+
|
|
1339
|
+
Args:
|
|
1340
|
+
responses: The Swagger 2.0 Responses object
|
|
1341
|
+
produces: List of media types the operation produces
|
|
1342
|
+
warnings: List to append warnings to
|
|
1343
|
+
method: HTTP method (post, put, etc.) for response inference
|
|
1344
|
+
body_schema: Body parameter schema for inferring response when not specified
|
|
1345
|
+
"""
|
|
1346
|
+
result = {}
|
|
1347
|
+
|
|
1348
|
+
if hasattr(responses, '__pydantic_extra__') and responses.__pydantic_extra__:
|
|
1349
|
+
for status_code, response in responses.__pydantic_extra__.items():
|
|
1350
|
+
if status_code.startswith('x-'):
|
|
1351
|
+
result[status_code] = response
|
|
1352
|
+
elif isinstance(response, JsonReference):
|
|
1353
|
+
result[status_code] = {'$ref': self._update_ref(response.ref)}
|
|
1354
|
+
elif isinstance(response, Response):
|
|
1355
|
+
result[status_code] = self._convert_response_to_dict(
|
|
1356
|
+
response, produces, warnings
|
|
1357
|
+
)
|
|
1358
|
+
|
|
1359
|
+
# Infer success response schema if not present
|
|
1360
|
+
# For POST/PUT operations with a body parameter that references a model,
|
|
1361
|
+
# it's common for the response to return the same model
|
|
1362
|
+
if body_schema and method in ('post', 'put'):
|
|
1363
|
+
# Check if there's already a 2xx response with a schema
|
|
1364
|
+
has_success_schema = False
|
|
1365
|
+
for status_code in result:
|
|
1366
|
+
if status_code.startswith('2') and not status_code.startswith('x-'):
|
|
1367
|
+
response_obj = result[status_code]
|
|
1368
|
+
if isinstance(response_obj, dict) and 'content' in response_obj:
|
|
1369
|
+
# Check if any content type has a schema
|
|
1370
|
+
for media_type_obj in response_obj['content'].values():
|
|
1371
|
+
if (
|
|
1372
|
+
isinstance(media_type_obj, dict)
|
|
1373
|
+
and 'schema' in media_type_obj
|
|
1374
|
+
):
|
|
1375
|
+
has_success_schema = True
|
|
1376
|
+
break
|
|
1377
|
+
if has_success_schema:
|
|
1378
|
+
break
|
|
1379
|
+
|
|
1380
|
+
# Only infer if the body schema is a $ref (model reference)
|
|
1381
|
+
if not has_success_schema and body_schema.get('$ref'):
|
|
1382
|
+
warnings.add('inferred_response')
|
|
1383
|
+
# Build the inferred response
|
|
1384
|
+
content = {}
|
|
1385
|
+
for media_type in produces:
|
|
1386
|
+
content[media_type] = {'schema': body_schema}
|
|
1387
|
+
|
|
1388
|
+
result['200'] = {
|
|
1389
|
+
'description': 'Successful operation',
|
|
1390
|
+
'content': content,
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
return result
|
|
1394
|
+
|
|
1395
|
+
def _convert_header_to_dict(self, header: Header) -> dict[str, Any]:
|
|
1396
|
+
"""Convert Header to OpenAPI 3.0 format."""
|
|
1397
|
+
result: dict[str, Any] = {}
|
|
1398
|
+
|
|
1399
|
+
if header.description:
|
|
1400
|
+
result['description'] = header.description
|
|
1401
|
+
|
|
1402
|
+
# Convert to schema
|
|
1403
|
+
schema: dict[str, Any] = {'type': header.type.value}
|
|
1404
|
+
|
|
1405
|
+
if header.format:
|
|
1406
|
+
schema['format'] = header.format
|
|
1407
|
+
|
|
1408
|
+
if header.items:
|
|
1409
|
+
schema['items'] = self._convert_primitives_items(header.items)
|
|
1410
|
+
|
|
1411
|
+
if header.default is not None:
|
|
1412
|
+
schema['default'] = header.default
|
|
1413
|
+
|
|
1414
|
+
if header.maximum is not None:
|
|
1415
|
+
schema['maximum'] = header.maximum
|
|
1416
|
+
|
|
1417
|
+
if header.minimum is not None:
|
|
1418
|
+
schema['minimum'] = header.minimum
|
|
1419
|
+
|
|
1420
|
+
if header.max_length is not None:
|
|
1421
|
+
schema['maxLength'] = header.max_length
|
|
1422
|
+
|
|
1423
|
+
if header.min_length is not None:
|
|
1424
|
+
schema['minLength'] = header.min_length
|
|
1425
|
+
|
|
1426
|
+
if header.pattern:
|
|
1427
|
+
schema['pattern'] = header.pattern
|
|
1428
|
+
|
|
1429
|
+
if header.max_items is not None:
|
|
1430
|
+
schema['maxItems'] = header.max_items
|
|
1431
|
+
|
|
1432
|
+
if header.min_items is not None:
|
|
1433
|
+
schema['minItems'] = header.min_items
|
|
1434
|
+
|
|
1435
|
+
if header.unique_items is not None:
|
|
1436
|
+
schema['uniqueItems'] = header.unique_items
|
|
1437
|
+
|
|
1438
|
+
if header.enum:
|
|
1439
|
+
schema['enum'] = header.enum
|
|
1440
|
+
|
|
1441
|
+
if header.multiple_of is not None:
|
|
1442
|
+
schema['multipleOf'] = header.multiple_of
|
|
1443
|
+
|
|
1444
|
+
result['schema'] = schema
|
|
1445
|
+
|
|
1446
|
+
result.update(self._extract_vendor_extensions(header))
|
|
1447
|
+
|
|
1448
|
+
return result
|
|
1449
|
+
|
|
1450
|
+
def _convert_schema_to_dict(self, schema: Schema | FileSchema) -> dict[str, Any]:
|
|
1451
|
+
"""Convert Schema object to OpenAPI 3.0 format."""
|
|
1452
|
+
if isinstance(schema, FileSchema):
|
|
1453
|
+
return {
|
|
1454
|
+
'type': 'string',
|
|
1455
|
+
'format': 'binary',
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
result: dict[str, Any] = {}
|
|
1459
|
+
|
|
1460
|
+
if schema.ref:
|
|
1461
|
+
result['$ref'] = self._update_ref(schema.ref)
|
|
1462
|
+
return result
|
|
1463
|
+
|
|
1464
|
+
if schema.type:
|
|
1465
|
+
result['type'] = schema.type
|
|
1466
|
+
|
|
1467
|
+
if schema.format:
|
|
1468
|
+
result['format'] = schema.format
|
|
1469
|
+
|
|
1470
|
+
if schema.title:
|
|
1471
|
+
result['title'] = schema.title
|
|
1472
|
+
|
|
1473
|
+
if schema.description:
|
|
1474
|
+
result['description'] = schema.description
|
|
1475
|
+
|
|
1476
|
+
if schema.default is not None:
|
|
1477
|
+
result['default'] = schema.default
|
|
1478
|
+
|
|
1479
|
+
if schema.multiple_of is not None:
|
|
1480
|
+
result['multipleOf'] = schema.multiple_of
|
|
1481
|
+
|
|
1482
|
+
if schema.maximum is not None:
|
|
1483
|
+
result['maximum'] = schema.maximum
|
|
1484
|
+
|
|
1485
|
+
if schema.exclusive_maximum is not None:
|
|
1486
|
+
result['exclusiveMaximum'] = schema.exclusive_maximum
|
|
1487
|
+
|
|
1488
|
+
if schema.minimum is not None:
|
|
1489
|
+
result['minimum'] = schema.minimum
|
|
1490
|
+
|
|
1491
|
+
if schema.exclusive_minimum is not None:
|
|
1492
|
+
result['exclusiveMinimum'] = schema.exclusive_minimum
|
|
1493
|
+
|
|
1494
|
+
if schema.max_length is not None:
|
|
1495
|
+
result['maxLength'] = schema.max_length
|
|
1496
|
+
|
|
1497
|
+
if schema.min_length is not None:
|
|
1498
|
+
result['minLength'] = schema.min_length
|
|
1499
|
+
|
|
1500
|
+
if schema.pattern:
|
|
1501
|
+
result['pattern'] = schema.pattern
|
|
1502
|
+
|
|
1503
|
+
if schema.max_items is not None:
|
|
1504
|
+
result['maxItems'] = schema.max_items
|
|
1505
|
+
|
|
1506
|
+
if schema.min_items is not None:
|
|
1507
|
+
result['minItems'] = schema.min_items
|
|
1508
|
+
|
|
1509
|
+
if schema.unique_items is not None:
|
|
1510
|
+
result['uniqueItems'] = schema.unique_items
|
|
1511
|
+
|
|
1512
|
+
if schema.max_properties is not None:
|
|
1513
|
+
result['maxProperties'] = schema.max_properties
|
|
1514
|
+
|
|
1515
|
+
if schema.min_properties is not None:
|
|
1516
|
+
result['minProperties'] = schema.min_properties
|
|
1517
|
+
|
|
1518
|
+
if schema.required:
|
|
1519
|
+
result['required'] = schema.required
|
|
1520
|
+
|
|
1521
|
+
if schema.enum:
|
|
1522
|
+
result['enum'] = schema.enum
|
|
1523
|
+
|
|
1524
|
+
if schema.items:
|
|
1525
|
+
if isinstance(schema.items, list):
|
|
1526
|
+
result['items'] = [
|
|
1527
|
+
self._convert_schema_to_dict(item) for item in schema.items
|
|
1528
|
+
]
|
|
1529
|
+
else:
|
|
1530
|
+
result['items'] = self._convert_schema_to_dict(schema.items)
|
|
1531
|
+
|
|
1532
|
+
if schema.all_of:
|
|
1533
|
+
result['allOf'] = [self._convert_schema_to_dict(s) for s in schema.all_of]
|
|
1534
|
+
|
|
1535
|
+
if schema.properties:
|
|
1536
|
+
result['properties'] = {
|
|
1537
|
+
name: self._convert_schema_to_dict(prop)
|
|
1538
|
+
for name, prop in schema.properties.items()
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if schema.additional_properties is not None:
|
|
1542
|
+
if isinstance(schema.additional_properties, bool):
|
|
1543
|
+
result['additionalProperties'] = schema.additional_properties
|
|
1544
|
+
else:
|
|
1545
|
+
result['additionalProperties'] = self._convert_schema_to_dict(
|
|
1546
|
+
schema.additional_properties
|
|
1547
|
+
)
|
|
1548
|
+
|
|
1549
|
+
# Convert discriminator from string to object
|
|
1550
|
+
if schema.discriminator:
|
|
1551
|
+
result['discriminator'] = {'propertyName': schema.discriminator}
|
|
1552
|
+
|
|
1553
|
+
if schema.read_only:
|
|
1554
|
+
result['readOnly'] = True
|
|
1555
|
+
|
|
1556
|
+
if schema.xml:
|
|
1557
|
+
xml_dict: dict[str, Any] = {}
|
|
1558
|
+
if schema.xml.name:
|
|
1559
|
+
xml_dict['name'] = schema.xml.name
|
|
1560
|
+
if schema.xml.namespace:
|
|
1561
|
+
xml_dict['namespace'] = schema.xml.namespace
|
|
1562
|
+
if schema.xml.prefix:
|
|
1563
|
+
xml_dict['prefix'] = schema.xml.prefix
|
|
1564
|
+
if schema.xml.attribute:
|
|
1565
|
+
xml_dict['attribute'] = True
|
|
1566
|
+
if schema.xml.wrapped:
|
|
1567
|
+
xml_dict['wrapped'] = True
|
|
1568
|
+
if xml_dict:
|
|
1569
|
+
result['xml'] = xml_dict
|
|
1570
|
+
|
|
1571
|
+
if schema.external_docs:
|
|
1572
|
+
result['externalDocs'] = {
|
|
1573
|
+
'url': str(schema.external_docs.url),
|
|
1574
|
+
'description': schema.external_docs.description,
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
if schema.example is not None:
|
|
1578
|
+
result['example'] = schema.example
|
|
1579
|
+
|
|
1580
|
+
result.update(self._extract_vendor_extensions(schema))
|
|
1581
|
+
|
|
1582
|
+
return result
|
|
1583
|
+
|
|
1584
|
+
def _convert_component_parameter_to_dict(
|
|
1585
|
+
self, param: Parameter, warnings: WarningCollector
|
|
1586
|
+
) -> dict[str, Any]:
|
|
1587
|
+
"""Convert a component-level parameter."""
|
|
1588
|
+
if isinstance(param, JsonReference):
|
|
1589
|
+
return {'$ref': self._update_ref(param.ref)}
|
|
1590
|
+
elif isinstance(param, BodyParameter):
|
|
1591
|
+
# Body parameters in components need special handling
|
|
1592
|
+
return self._convert_body_parameter_to_dict(
|
|
1593
|
+
param, ['application/json'], warnings
|
|
1594
|
+
)
|
|
1595
|
+
elif isinstance(param, NonBodyParameter):
|
|
1596
|
+
return self._convert_non_body_parameter_to_dict(param, warnings)
|
|
1597
|
+
return {}
|
|
1598
|
+
|
|
1599
|
+
def _convert_response_to_dict(
|
|
1600
|
+
self, response: Response, produces: list[str], warnings: WarningCollector
|
|
1601
|
+
) -> dict[str, Any]:
|
|
1602
|
+
"""Convert a single Response object to dict."""
|
|
1603
|
+
result: dict[str, Any] = {'description': response.description}
|
|
1604
|
+
|
|
1605
|
+
# Convert schema to content
|
|
1606
|
+
if response.schema_:
|
|
1607
|
+
content = {}
|
|
1608
|
+
for media_type in produces:
|
|
1609
|
+
content[media_type] = {
|
|
1610
|
+
'schema': self._convert_schema_to_dict(response.schema_)
|
|
1611
|
+
}
|
|
1612
|
+
result['content'] = content
|
|
1613
|
+
|
|
1614
|
+
# Convert headers
|
|
1615
|
+
if response.headers:
|
|
1616
|
+
result['headers'] = {
|
|
1617
|
+
name: self._convert_header_to_dict(header)
|
|
1618
|
+
for name, header in response.headers.items()
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
# Handle examples
|
|
1622
|
+
if response.examples:
|
|
1623
|
+
# In OpenAPI 3.0, examples are per media type
|
|
1624
|
+
if 'content' in result:
|
|
1625
|
+
for media_type in result['content']:
|
|
1626
|
+
if media_type in response.examples:
|
|
1627
|
+
result['content'][media_type]['example'] = response.examples[
|
|
1628
|
+
media_type
|
|
1629
|
+
]
|
|
1630
|
+
|
|
1631
|
+
result.update(self._extract_vendor_extensions(response))
|
|
1632
|
+
|
|
1633
|
+
return result
|
|
1634
|
+
|
|
1635
|
+
def _convert_security_scheme_to_dict(
|
|
1636
|
+
self, scheme: SecurityScheme, warnings: WarningCollector
|
|
1637
|
+
) -> dict[str, Any]:
|
|
1638
|
+
"""Convert a single security scheme."""
|
|
1639
|
+
if isinstance(scheme, BasicAuthenticationSecurity):
|
|
1640
|
+
result: dict[str, Any] = {
|
|
1641
|
+
'type': 'http',
|
|
1642
|
+
'scheme': 'basic',
|
|
1643
|
+
}
|
|
1644
|
+
if scheme.description:
|
|
1645
|
+
result['description'] = scheme.description
|
|
1646
|
+
result.update(self._extract_vendor_extensions(scheme))
|
|
1647
|
+
return result
|
|
1648
|
+
|
|
1649
|
+
elif isinstance(scheme, ApiKeySecurity):
|
|
1650
|
+
result = {
|
|
1651
|
+
'type': 'apiKey',
|
|
1652
|
+
'name': scheme.name,
|
|
1653
|
+
'in': scheme.in_.value,
|
|
1654
|
+
}
|
|
1655
|
+
if scheme.description:
|
|
1656
|
+
result['description'] = scheme.description
|
|
1657
|
+
result.update(self._extract_vendor_extensions(scheme))
|
|
1658
|
+
return result
|
|
1659
|
+
|
|
1660
|
+
elif isinstance(scheme, OAuth2ImplicitSecurity):
|
|
1661
|
+
flows: dict[str, Any] = {
|
|
1662
|
+
'implicit': {
|
|
1663
|
+
'authorizationUrl': str(scheme.authorization_url),
|
|
1664
|
+
'scopes': scheme.scopes,
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
result = {
|
|
1668
|
+
'type': 'oauth2',
|
|
1669
|
+
'flows': flows,
|
|
1670
|
+
}
|
|
1671
|
+
if scheme.description:
|
|
1672
|
+
result['description'] = scheme.description
|
|
1673
|
+
result.update(self._extract_vendor_extensions(scheme))
|
|
1674
|
+
warnings.add('oauth2_implicit')
|
|
1675
|
+
return result
|
|
1676
|
+
|
|
1677
|
+
elif isinstance(scheme, OAuth2PasswordSecurity):
|
|
1678
|
+
flows = {
|
|
1679
|
+
'password': {
|
|
1680
|
+
'tokenUrl': str(scheme.token_url),
|
|
1681
|
+
'scopes': scheme.scopes,
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
result = {
|
|
1685
|
+
'type': 'oauth2',
|
|
1686
|
+
'flows': flows,
|
|
1687
|
+
}
|
|
1688
|
+
if scheme.description:
|
|
1689
|
+
result['description'] = scheme.description
|
|
1690
|
+
result.update(self._extract_vendor_extensions(scheme))
|
|
1691
|
+
warnings.add('oauth2_password')
|
|
1692
|
+
return result
|
|
1693
|
+
|
|
1694
|
+
elif isinstance(scheme, OAuth2ApplicationSecurity):
|
|
1695
|
+
flows = {
|
|
1696
|
+
'clientCredentials': {
|
|
1697
|
+
'tokenUrl': str(scheme.token_url),
|
|
1698
|
+
'scopes': scheme.scopes,
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
result = {
|
|
1702
|
+
'type': 'oauth2',
|
|
1703
|
+
'flows': flows,
|
|
1704
|
+
}
|
|
1705
|
+
if scheme.description:
|
|
1706
|
+
result['description'] = scheme.description
|
|
1707
|
+
result.update(self._extract_vendor_extensions(scheme))
|
|
1708
|
+
warnings.add('oauth2_client_credentials')
|
|
1709
|
+
return result
|
|
1710
|
+
|
|
1711
|
+
elif isinstance(scheme, OAuth2AccessCodeSecurity):
|
|
1712
|
+
flows = {
|
|
1713
|
+
'authorizationCode': {
|
|
1714
|
+
'authorizationUrl': str(scheme.authorization_url),
|
|
1715
|
+
'tokenUrl': str(scheme.token_url),
|
|
1716
|
+
'scopes': scheme.scopes,
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
result = {
|
|
1720
|
+
'type': 'oauth2',
|
|
1721
|
+
'flows': flows,
|
|
1722
|
+
}
|
|
1723
|
+
if scheme.description:
|
|
1724
|
+
result['description'] = scheme.description
|
|
1725
|
+
result.update(self._extract_vendor_extensions(scheme))
|
|
1726
|
+
warnings.add('oauth2_authorization_code')
|
|
1727
|
+
return result
|
|
1728
|
+
|
|
1729
|
+
return {}
|
|
1730
|
+
|
|
1731
|
+
def _update_ref(self, ref: str) -> str:
|
|
1732
|
+
"""Update $ref paths from Swagger 2.0 to OpenAPI 3.0 format."""
|
|
1733
|
+
# Update definitions references
|
|
1734
|
+
if ref.startswith('#/definitions/'):
|
|
1735
|
+
return ref.replace('#/definitions/', '#/components/schemas/')
|
|
1736
|
+
|
|
1737
|
+
# Update parameters references
|
|
1738
|
+
if ref.startswith('#/parameters/'):
|
|
1739
|
+
return ref.replace('#/parameters/', '#/components/parameters/')
|
|
1740
|
+
|
|
1741
|
+
# Update responses references
|
|
1742
|
+
if ref.startswith('#/responses/'):
|
|
1743
|
+
return ref.replace('#/responses/', '#/components/responses/')
|
|
1744
|
+
|
|
1745
|
+
return ref
|
|
1746
|
+
|
|
1747
|
+
def _extract_vendor_extensions(
|
|
1748
|
+
self, obj: BaseModelWithVendorExtensions
|
|
1749
|
+
) -> dict[str, Any]:
|
|
1750
|
+
"""Extract vendor extensions (x-*) from an object."""
|
|
1751
|
+
if hasattr(obj, '__pydantic_extra__') and obj.__pydantic_extra__:
|
|
1752
|
+
return {
|
|
1753
|
+
k: v for k, v in obj.__pydantic_extra__.items() if k.startswith('x-')
|
|
1754
|
+
}
|
|
1755
|
+
return {}
|
|
1756
|
+
|
|
1757
|
+
def _convert_parameter_item_to_dict(
|
|
1758
|
+
self, param: Parameter, warnings: WarningCollector
|
|
1759
|
+
) -> dict[str, Any]:
|
|
1760
|
+
"""Convert a single parameter (handles both body and non-body)."""
|
|
1761
|
+
if isinstance(param, JsonReference):
|
|
1762
|
+
return {'$ref': self._update_ref(param.ref)}
|
|
1763
|
+
elif isinstance(param, BodyParameter):
|
|
1764
|
+
# This shouldn't happen at path level in Swagger 2.0, but handle it
|
|
1765
|
+
warnings.add('body_param_at_path')
|
|
1766
|
+
return self._convert_body_parameter_to_dict(
|
|
1767
|
+
param, ['application/json'], warnings
|
|
1768
|
+
)
|
|
1769
|
+
elif isinstance(param, NonBodyParameter):
|
|
1770
|
+
return self._convert_non_body_parameter_to_dict(param, warnings)
|
|
1771
|
+
return {}
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
# Update forward references for recursive models
|
|
1775
|
+
Schema.model_rebuild()
|
|
1776
|
+
PrimitivesItems.model_rebuild()
|