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.
Files changed (52) hide show
  1. README.md +581 -8
  2. otterapi/__init__.py +73 -0
  3. otterapi/cli.py +327 -29
  4. otterapi/codegen/__init__.py +115 -0
  5. otterapi/codegen/ast_utils.py +134 -5
  6. otterapi/codegen/client.py +1271 -0
  7. otterapi/codegen/codegen.py +1736 -0
  8. otterapi/codegen/dataframes.py +392 -0
  9. otterapi/codegen/emitter.py +473 -0
  10. otterapi/codegen/endpoints.py +2597 -343
  11. otterapi/codegen/pagination.py +1026 -0
  12. otterapi/codegen/schema.py +593 -0
  13. otterapi/codegen/splitting.py +1397 -0
  14. otterapi/codegen/types.py +1345 -0
  15. otterapi/codegen/utils.py +180 -1
  16. otterapi/config.py +1017 -24
  17. otterapi/exceptions.py +231 -0
  18. otterapi/openapi/__init__.py +46 -0
  19. otterapi/openapi/v2/__init__.py +86 -0
  20. otterapi/openapi/v2/spec.json +1607 -0
  21. otterapi/openapi/v2/v2.py +1776 -0
  22. otterapi/openapi/v3/__init__.py +131 -0
  23. otterapi/openapi/v3/spec.json +1651 -0
  24. otterapi/openapi/v3/v3.py +1557 -0
  25. otterapi/openapi/v3_1/__init__.py +133 -0
  26. otterapi/openapi/v3_1/spec.json +1411 -0
  27. otterapi/openapi/v3_1/v3_1.py +798 -0
  28. otterapi/openapi/v3_2/__init__.py +133 -0
  29. otterapi/openapi/v3_2/spec.json +1666 -0
  30. otterapi/openapi/v3_2/v3_2.py +777 -0
  31. otterapi/tests/__init__.py +3 -0
  32. otterapi/tests/fixtures/__init__.py +455 -0
  33. otterapi/tests/test_ast_utils.py +680 -0
  34. otterapi/tests/test_codegen.py +610 -0
  35. otterapi/tests/test_dataframe.py +1038 -0
  36. otterapi/tests/test_exceptions.py +493 -0
  37. otterapi/tests/test_openapi_support.py +616 -0
  38. otterapi/tests/test_openapi_upgrade.py +215 -0
  39. otterapi/tests/test_pagination.py +1101 -0
  40. otterapi/tests/test_splitting_config.py +319 -0
  41. otterapi/tests/test_splitting_integration.py +427 -0
  42. otterapi/tests/test_splitting_resolver.py +512 -0
  43. otterapi/tests/test_splitting_tree.py +525 -0
  44. otterapi-0.0.6.dist-info/METADATA +627 -0
  45. otterapi-0.0.6.dist-info/RECORD +48 -0
  46. {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/WHEEL +1 -1
  47. otterapi/codegen/generator.py +0 -358
  48. otterapi/codegen/openapi_processor.py +0 -27
  49. otterapi/codegen/type_generator.py +0 -559
  50. otterapi-0.0.5.dist-info/METADATA +0 -54
  51. otterapi-0.0.5.dist-info/RECORD +0 -16
  52. {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()