amati 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1031 @@
1
+ """
2
+ Validates the OpenAPI Specification version 3.1.1
3
+
4
+ Note that per https://spec.openapis.org/oas/v3.0.4.html#relative-references-in-api-description-uris # pylint: disable=line-too-long
5
+
6
+ > URIs used as references within an OpenAPI Description, or to external documentation
7
+ > or other supplementary information such as a license, are resolved as identifiers,
8
+ > and described by this specification as URIs.
9
+
10
+ > Note that some URI fields are named url for historical reasons, but the descriptive
11
+ > text for those fields uses the correct “URI” terminology.
12
+
13
+ """
14
+
15
+ import re
16
+ from typing import Any, ClassVar, Optional
17
+ from typing_extensions import Self
18
+
19
+ from jsonschema.exceptions import ValidationError as JSONVSchemeValidationError
20
+ from jsonschema.protocols import Validator as JSONSchemaValidator
21
+ from jsonschema.validators import validator_for # type: ignore
22
+ from pydantic import (
23
+ ConfigDict,
24
+ Field,
25
+ RootModel,
26
+ ValidationError,
27
+ field_validator,
28
+ model_validator,
29
+ )
30
+
31
+ from amati import AmatiValueError, Reference
32
+ from amati import model_validators as mv
33
+ from amati.fields import (
34
+ URI,
35
+ Email,
36
+ HTTPAuthenticationScheme,
37
+ HTTPStatusCode,
38
+ MediaType,
39
+ URIType,
40
+ URIWithVariables,
41
+ )
42
+ from amati.fields.commonmark import CommonMark
43
+ from amati.fields.json import JSON
44
+ from amati.fields.oas import OpenAPI, RuntimeExpression
45
+ from amati.logging import Log, LogMixin
46
+ from amati.validators.generic import GenericObject, allow_extra_fields
47
+
48
+ type JSONPrimitive = str | int | float | bool | None
49
+ type JSONArray = list["JSONValue"]
50
+ type JSONObject = dict[str, "JSONValue"]
51
+ type JSONValue = JSONPrimitive | JSONArray | JSONObject
52
+
53
+
54
+ TITLE = "OpenAPI Specification v3.0.4"
55
+
56
+ # Convenience naming to ensure that it's clear what's happening.
57
+ # https://spec.openapis.org/oas/v3.0.4.html#specification-extensions
58
+ specification_extensions = allow_extra_fields
59
+
60
+
61
+ @specification_extensions("x-")
62
+ class ContactObject(GenericObject):
63
+ """
64
+ Validates the OpenAPI Specification contact object - §4.8.3
65
+ """
66
+
67
+ name: Optional[str] = None
68
+ url: Optional[URI] = None
69
+ email: Optional[Email] = None
70
+ _reference: ClassVar[Reference] = Reference(
71
+ title=TITLE,
72
+ url="https://spec.openapis.org/oas/3.0.4.html#contact-object",
73
+ section="Contact Object",
74
+ )
75
+
76
+
77
+ @specification_extensions("x-")
78
+ class LicenceObject(GenericObject):
79
+ """
80
+ A model representing the OpenAPI Specification licence object §4.8.4
81
+ """
82
+
83
+ name: str = Field(min_length=1)
84
+ url: Optional[URI] = None
85
+ _reference: ClassVar[Reference] = Reference(
86
+ title=TITLE,
87
+ url="https://spec.openapis.org/oas/v3.0.4.html#license-object",
88
+ section="License Object",
89
+ )
90
+
91
+
92
+ class ReferenceObject(GenericObject):
93
+ """
94
+ Validates the OpenAPI Specification reference object - §4.8.23
95
+
96
+ Note, "URIs" can be prefixed with a hash; this is because if the
97
+ representation of the referenced document is JSON or YAML, then
98
+ the fragment identifier SHOULD be interpreted as a JSON-Pointer
99
+ as per RFC6901.
100
+ """
101
+
102
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
103
+
104
+ ref: URI = Field(alias="$ref")
105
+ _reference: ClassVar[Reference] = Reference(
106
+ title=TITLE,
107
+ url="https://spec.openapis.org/oas/v3.0.4.html#reference-object",
108
+ section="Reference Object",
109
+ )
110
+
111
+
112
+ @specification_extensions("x-")
113
+ class InfoObject(GenericObject):
114
+ """
115
+ Validates the OpenAPI Specification info object - §4.8.2:
116
+ """
117
+
118
+ title: str
119
+ description: Optional[str | CommonMark] = None
120
+ termsOfService: Optional[str] = None # pylint: disable=invalid-name
121
+ contact: Optional[ContactObject] = None
122
+ license: Optional[LicenceObject] = None
123
+ version: str
124
+ _reference: ClassVar[Reference] = Reference(
125
+ title=TITLE,
126
+ url="https://spec.openapis.org/oas/3.0.4.html#info-object",
127
+ section="Info Object",
128
+ )
129
+
130
+
131
+ class DiscriminatorObject(GenericObject):
132
+ """
133
+ Validates the OpenAPI Specification object - §4.8.25
134
+ """
135
+
136
+ # FIXME: Need post processing to determine whether the property actually exists
137
+ # FIXME: The component and schema objects need to check that this is being used
138
+ # properly.
139
+ propertyName: str
140
+ mapping: Optional[dict[str, str | URI]] = None
141
+ _reference: ClassVar[Reference] = Reference(
142
+ title=TITLE,
143
+ url="https://spec.openapis.org/oas/v3.0.4.html#discriminator-object",
144
+ section="Security Scheme Object",
145
+ )
146
+
147
+
148
+ @specification_extensions("x-")
149
+ class ExampleObject(GenericObject):
150
+ """
151
+ Validates the OpenAPI Specification example object - §4.8.19
152
+ """
153
+
154
+ summary: Optional[str] = None
155
+ description: Optional[str | CommonMark] = None
156
+ value: Optional[JSONValue] = None
157
+ externalValue: Optional[URI] = None
158
+ _reference: ClassVar[Reference] = Reference(
159
+ title=TITLE,
160
+ url="https://spec.openapis.org/oas/v3.0.4.html#example-object",
161
+ section="Example Object",
162
+ )
163
+
164
+ _not_value_and_external_value = mv.only_one_of(["value", "externalValue"])
165
+
166
+
167
+ @specification_extensions("x-")
168
+ class ServerVariableObject(GenericObject):
169
+ """
170
+ Validates the OpenAPI Specification server variable object - §4.8.6
171
+ """
172
+
173
+ enum: Optional[list[str]] = Field(None, min_length=1)
174
+ default: str = Field(min_length=1)
175
+ description: Optional[str | CommonMark] = None
176
+ _reference: ClassVar[Reference] = Reference(
177
+ title=TITLE,
178
+ url="https://spec.openapis.org/oas/v3.0.4.html#server-variable-object",
179
+ section="Server Variable Object",
180
+ )
181
+
182
+ @model_validator(mode="after")
183
+ def check_enum_default(self: Self) -> Self:
184
+ """
185
+ Validate that the default value is in the enum list.
186
+
187
+ Returns:
188
+ The validated server variable object
189
+ """
190
+ if self.enum is None:
191
+ return self
192
+
193
+ if self.default not in self.enum:
194
+ LogMixin.log(
195
+ Log(
196
+ message=f"The default value {self.default} is not in the enum list {self.enum}", # pylint: disable=line-too-long
197
+ type=Warning,
198
+ reference=self._reference,
199
+ )
200
+ )
201
+
202
+ return self
203
+
204
+
205
+ @specification_extensions("x-")
206
+ class ServerObject(GenericObject):
207
+ """
208
+ Validates the OpenAPI Specification server object - §4.8.5
209
+ """
210
+
211
+ url: URIWithVariables | URI
212
+ description: Optional[str | CommonMark] = None
213
+ variables: Optional[dict[str, ServerVariableObject]] = None
214
+ _reference: ClassVar[Reference] = Reference(
215
+ title=TITLE,
216
+ url="https://spec.openapis.org/oas/v3.0.4.html#server-object",
217
+ section="Server Object",
218
+ )
219
+
220
+
221
+ @specification_extensions("x-")
222
+ class ExternalDocumentationObject(GenericObject):
223
+ """
224
+ Validates the OpenAPI Specification external documentation object - §4.8.22
225
+ """
226
+
227
+ description: Optional[str | CommonMark] = None
228
+ url: URI
229
+ _reference: ClassVar[Reference] = Reference(
230
+ title=TITLE,
231
+ url="https://spec.openapis.org/oas/v3.0.4.html#external-documentation-object",
232
+ section="External Documentation Object",
233
+ )
234
+
235
+
236
+ # FIXME: Specification extensions should be "^x-", but the implementation
237
+ # doesn't play well with ConfigDict(extra="allow"). This is the only case
238
+ # so less important to change as the eventual logic is still correct.
239
+ @specification_extensions(".*")
240
+ class PathsObject(GenericObject):
241
+ """Validates the OpenAPI Specification paths object - §4.8.8"""
242
+
243
+ model_config = ConfigDict(extra="allow")
244
+
245
+ @model_validator(mode="before")
246
+ @classmethod
247
+ def paths_are_uris(cls, data: Any) -> Any:
248
+ """
249
+ Validates that paths are valid URIs, it's allowed that they
250
+ have variables, e.g. /pets or /pets/{petID}
251
+
252
+ Special-case specification extensions, which are also allowed.
253
+ """
254
+
255
+ for field in data.keys():
256
+
257
+ # Specification extensions
258
+ if field.startswith("x-"):
259
+ continue
260
+
261
+ URIWithVariables(field)
262
+
263
+ return data
264
+
265
+
266
+ @specification_extensions("x-")
267
+ class OperationObject(GenericObject):
268
+ """Validates the OpenAPI Specification operation object - §4.8.10"""
269
+
270
+ tags: Optional[list[str]] = None
271
+ summary: Optional[str] = None
272
+ description: Optional[str | CommonMark] = None
273
+ externalDocs: Optional[ExternalDocumentationObject] = None
274
+ operationId: Optional[str] = None
275
+ parameters: Optional[list["ParameterObject | ReferenceObject"]] = None
276
+ requestBody: Optional["RequestBodyObject | ReferenceObject"] = None
277
+ responses: "ResponsesObject"
278
+ callbacks: Optional[dict[str, "CallbackObject | ReferenceObject"]] = None
279
+ deprecated: Optional[bool] = False
280
+ security: Optional[list["SecurityRequirementObject"]] = None
281
+ servers: Optional[list[ServerObject]] = None
282
+
283
+ _reference: ClassVar[Reference] = Reference(
284
+ title=TITLE,
285
+ url="https://spec.openapis.org/oas/v3.0.4.html#operation-object",
286
+ section="Operation Object",
287
+ )
288
+
289
+
290
+ PARAMETER_STYLES: set[str] = {
291
+ "matrix",
292
+ "label",
293
+ "simple",
294
+ "form",
295
+ "spaceDelimited",
296
+ "pipeDelimited",
297
+ "deepObject",
298
+ }
299
+
300
+
301
+ class ParameterObject(GenericObject):
302
+ """Validates the OpenAPI Specification parameter object - §4.8.11"""
303
+
304
+ name: str
305
+ in_: str = Field(alias="in")
306
+ description: Optional[str | CommonMark] = None
307
+ required: Optional[bool] = None
308
+ deprecated: Optional[bool] = None
309
+ allowEmptyValue: Optional[bool] = None
310
+ style: Optional[str] = None
311
+ explode: Optional[bool] = None
312
+ allowReserved: Optional[bool] = None
313
+ schema_: Optional["SchemaObject | ReferenceObject"] = Field(alias="schema")
314
+ example: Optional[Any] = None
315
+ examples: Optional[dict[str, "ExampleObject | ReferenceObject"]] = None
316
+ content: Optional[dict[str, "MediaTypeObject"]] = None
317
+
318
+ _in_valid = mv.if_then(
319
+ conditions={"in_": mv.UNKNOWN},
320
+ consequences={"in_": ["query", "header", "path", "cookie"]},
321
+ )
322
+ _path_location_is_required = mv.if_then(
323
+ conditions={"in_": "path"}, consequences={"required": True}
324
+ )
325
+ _empty_value_only_with_query = mv.if_then(
326
+ conditions={"allowEmptyValue": mv.UNKNOWN},
327
+ consequences={"in_": PARAMETER_STYLES ^ {"query"}},
328
+ )
329
+ _style_is_valid = mv.if_then(
330
+ conditions={"style": mv.UNKNOWN}, consequences={"style": list(PARAMETER_STYLES)}
331
+ )
332
+ _reserved_only_with_query = mv.if_then(
333
+ conditions={"allowReserved": mv.UNKNOWN},
334
+ consequences={"in_": PARAMETER_STYLES ^ {"query"}},
335
+ )
336
+ _disallowed_if_schema = mv.if_then(
337
+ conditions={"schema_": mv.UNKNOWN}, consequences={"content": None}
338
+ )
339
+ _disallowed_if_content = mv.if_then(
340
+ conditions={"content": mv.UNKNOWN},
341
+ consequences={
342
+ "content": None,
343
+ "style": None,
344
+ "explode": None,
345
+ "allowReserved": None,
346
+ "schema_": None,
347
+ },
348
+ )
349
+
350
+
351
+ @specification_extensions("x-")
352
+ class RequestBodyObject(GenericObject):
353
+ """
354
+ Validates the OpenAPI Specification request body object - §4.8.13
355
+ """
356
+
357
+ description: Optional[CommonMark | str] = None
358
+ content: dict[str, "MediaTypeObject"]
359
+ required: Optional[bool] = False
360
+ _reference: ClassVar[Reference] = Reference(
361
+ title=TITLE,
362
+ url="https://spec.openapis.org/oas/v3.0.4.html#request-body-object",
363
+ section="Request Body Object",
364
+ )
365
+
366
+
367
+ @specification_extensions("x-")
368
+ class MediaTypeObject(GenericObject):
369
+ """
370
+ Validates the OpenAPI Specification media type object - §4.8.14
371
+ """
372
+
373
+ schema_: Optional["SchemaObject | ReferenceObject"] = Field(
374
+ alias="schema", default=None
375
+ )
376
+ # FIXME: Define example
377
+ example: Optional[Any] = None
378
+ examples: Optional[dict[str, ExampleObject | ReferenceObject]] = None
379
+ encoding: Optional["EncodingObject"] = None
380
+ _reference: ClassVar[Reference] = Reference(
381
+ title=TITLE,
382
+ url="https://spec.openapis.org/oas/v3.0.4.html#media-type-object",
383
+ section="Tag Object",
384
+ )
385
+
386
+
387
+ @specification_extensions("x-")
388
+ class EncodingObject(GenericObject):
389
+ """
390
+ Validates the OpenAPI Specification media type object - §4.8.15
391
+ """
392
+
393
+ contentType: Optional[str] = None
394
+ headers: Optional[dict[str, "HeaderObject | ReferenceObject"]] = None
395
+ _reference: ClassVar[Reference] = Reference(
396
+ title=TITLE,
397
+ url="https://spec.openapis.org/oas/v3.0.4.html#encoding object-object",
398
+ section="Encoding Object",
399
+ )
400
+
401
+ @field_validator("contentType", mode="after")
402
+ @classmethod
403
+ def check_content_type(cls, value: str) -> str:
404
+ """
405
+ contentType is a comma-separated list of media types.
406
+ Check that they are all valid
407
+
408
+ raises: ValueError
409
+ """
410
+
411
+ for media_type in value.split(","):
412
+ MediaType(media_type.strip())
413
+
414
+ return value
415
+
416
+
417
+ type _ResponsesObjectReturnType = dict[str, "ReferenceObject | ResponseObject"]
418
+
419
+
420
+ @specification_extensions("x-")
421
+ class ResponsesObject(GenericObject):
422
+ """
423
+ Validates the OpenAPI Specification responses object - §4.8.16
424
+ """
425
+
426
+ model_config = ConfigDict(
427
+ extra="allow",
428
+ )
429
+
430
+ default: Optional["ResponseObject | ReferenceObject"] = None
431
+ _reference: ClassVar[Reference] = Reference(
432
+ title=TITLE,
433
+ url="https://spec.openapis.org/oas/v3.0.4.html#responses-object",
434
+ section="Responses Object",
435
+ )
436
+
437
+ @classmethod
438
+ def _choose_model(
439
+ cls, value: Any, field_name: str
440
+ ) -> "ReferenceObject | ResponseObject":
441
+ """
442
+ Choose the model to use for validation based on the type of value.
443
+
444
+ Args:
445
+ value: The value to validate.
446
+
447
+ Returns:
448
+ The model class to use for validation.
449
+ """
450
+
451
+ message = f"{field_name} must be a ResponseObject or ReferenceObject, got {type(value)}" # pylint: disable=line-too-long
452
+
453
+ try:
454
+ return ResponseObject.model_validate(value)
455
+ except ValidationError:
456
+ try:
457
+ return ReferenceObject.model_validate(value)
458
+ except ValidationError as e:
459
+ raise ValueError(message, ResponsesObject._reference) from e
460
+
461
+ @model_validator(mode="before")
462
+ @classmethod
463
+ def validate_all_fields(cls, data: dict[str, Any]) -> _ResponsesObjectReturnType:
464
+ """
465
+ Validates the responses object.
466
+ """
467
+
468
+ validated_data: _ResponsesObjectReturnType = {}
469
+
470
+ for field_name, value in data.items():
471
+
472
+ # If the value is a specification extension, allow it
473
+ if field_name.startswith("x-"):
474
+ validated_data[field_name] = value
475
+ continue
476
+
477
+ # If the value is the fixed field, "default", allow it
478
+ if field_name == "default":
479
+ if isinstance(value, dict):
480
+ validated_data[field_name] = ResponsesObject._choose_model(
481
+ value, field_name
482
+ )
483
+ continue
484
+
485
+ # Otherwise, if the field appears like a valid HTTP status code or a range
486
+ if re.match(r"^[1-5]([0-9]{2}|XX)+$", str(field_name)):
487
+
488
+ # Double check and raise a value error if not
489
+ HTTPStatusCode(field_name)
490
+
491
+ # and validate as a ResponseObject or ReferenceObject
492
+ validated_data[field_name] = ResponsesObject._choose_model(
493
+ value, field_name
494
+ )
495
+
496
+ continue
497
+
498
+ # If the field is not a valid HTTP status code or "default"
499
+ raise ValueError(f"Invalid type for numeric field '{field_name}'")
500
+
501
+ return validated_data
502
+
503
+
504
+ @specification_extensions("x-")
505
+ class ResponseObject(GenericObject):
506
+ """
507
+ Validates the OpenAPI Specification response object - §4.8.17
508
+ """
509
+
510
+ description: str | CommonMark
511
+ headers: Optional[dict[str, "HeaderObject | ReferenceObject"]] = None
512
+ content: Optional[dict[str, MediaTypeObject]] = None
513
+ links: Optional[dict[str, "LinkObject | ReferenceObject"]] = None
514
+ _reference: ClassVar[Reference] = Reference(
515
+ title=TITLE,
516
+ url="https://spec.openapis.org/oas/v3.0.4.html#response-object",
517
+ section="Response Object",
518
+ )
519
+
520
+
521
+ @specification_extensions("x-")
522
+ class CallbackObject(GenericObject):
523
+ """
524
+ Validates the OpenAPI Specification callback object - §4.8.18
525
+ """
526
+
527
+ model_config = ConfigDict(extra="allow")
528
+
529
+ # The keys are runtime expressions that resolve to a URL
530
+ # The values are Response Objects or Reference Objects
531
+ _reference: ClassVar[Reference] = Reference(
532
+ title=TITLE,
533
+ url="https://spec.openapis.org/oas/v3.0.4.html#callback-object",
534
+ section="Callback Object",
535
+ )
536
+
537
+ @model_validator(mode="before")
538
+ @classmethod
539
+ def validate_all_fields(cls, data: dict[str, Any]) -> dict[str, "PathItemObject"]:
540
+ """
541
+ Validates the callback object.
542
+ """
543
+
544
+ validated_data: dict[str, PathItemObject] = {}
545
+
546
+ # Everything after a { but before a } should be runtime expression
547
+ pattern: str = r"\{([^}]+)\}"
548
+
549
+ for field_name, value in data.items():
550
+
551
+ # If the value is a specification extension, allow it
552
+ if field_name.startswith("x-"):
553
+ validated_data[field_name] = PathItemObject.model_validate(value)
554
+ continue
555
+
556
+ # Either the field name is a runtime expression, so test this:
557
+ try:
558
+ RuntimeExpression(field_name)
559
+ validated_data[field_name] = PathItemObject.model_validate(value)
560
+ continue
561
+ except AmatiValueError:
562
+ pass
563
+
564
+ # Or, the field name is a runtime expression embedded in a string
565
+ # value per https://spec.openapis.org/oas/latest.html#examples-0
566
+ matches = re.findall(pattern, field_name)
567
+
568
+ for match in matches:
569
+ try:
570
+ RuntimeExpression(match)
571
+ except AmatiValueError as e:
572
+ raise AmatiValueError(
573
+ f"Invalid runtime expression '{match}' in field '{field_name}'",
574
+ CallbackObject._reference,
575
+ ) from e
576
+
577
+ if matches:
578
+ validated_data[field_name] = PathItemObject.model_validate(value)
579
+ else:
580
+ # If the field does not contain a valid runtime expression
581
+ raise ValueError(f"Invalid type for numeric field '{field_name}'")
582
+
583
+ return validated_data
584
+
585
+
586
+ @specification_extensions("x-")
587
+ class TagObject(GenericObject):
588
+ """
589
+ Validates the OpenAPI Specification tag object - §4.8.22
590
+ """
591
+
592
+ name: str
593
+ description: Optional[str | CommonMark] = None
594
+ externalDocs: Optional[ExternalDocumentationObject] = None
595
+ _reference: ClassVar[Reference] = Reference(
596
+ title=TITLE,
597
+ url="https://spec.openapis.org/oas/v3.0.4.html#tag-object",
598
+ section="Tag Object",
599
+ )
600
+
601
+
602
+ @specification_extensions("x-")
603
+ class LinkObject(GenericObject):
604
+ """
605
+ Validates the OpenAPI Specification link object - §4.8.20
606
+ """
607
+
608
+ operationRef: Optional[URI] = None
609
+ operationId: Optional[str] = None
610
+ parameters: Optional[dict[str, RuntimeExpression | JSONValue]] = None
611
+ requestBody: Optional[JSONValue | RuntimeExpression] = None
612
+ description: Optional[str | CommonMark] = None
613
+ server: Optional[ServerObject] = None
614
+ _reference: ClassVar[Reference] = Reference(
615
+ title=TITLE,
616
+ url="https://spec.openapis.org/oas/v3.0.4.html#link-object",
617
+ section="Link Object",
618
+ )
619
+
620
+ _not_operationref_and_operationid = mv.only_one_of(
621
+ fields=["operationRef", "operationId"]
622
+ )
623
+
624
+
625
+ @specification_extensions("x-")
626
+ class HeaderObject(GenericObject):
627
+ """
628
+ Validates the OpenAPI Specification link object - §4.8.20
629
+ """
630
+
631
+ # Common schema/content fields
632
+ description: Optional[str | CommonMark] = None
633
+ required: Optional[bool] = Field(default=False)
634
+ deprecated: Optional[bool] = Field(default=False)
635
+
636
+ # Schema fields
637
+ style: Optional[str] = Field(default="simple")
638
+ explode: Optional[bool] = Field(default=False)
639
+ schema_: Optional["SchemaObject | ReferenceObject"] = Field(
640
+ alias="schema", default=None
641
+ )
642
+ example: Optional[JSONValue] = None
643
+ examples: Optional[dict[str, ExampleObject | ReferenceObject]] = None
644
+
645
+ # Content fields
646
+ content: Optional[dict[str, MediaTypeObject]] = None
647
+
648
+ _reference: ClassVar[Reference] = Reference(
649
+ title=TITLE,
650
+ url="https://spec.openapis.org/oas/v3.0.4.html#link-object",
651
+ section="Link Object",
652
+ )
653
+
654
+ _not_schema_and_content = mv.only_one_of(["schema_", "content"])
655
+
656
+
657
+ @specification_extensions("x-")
658
+ class XMLObject(GenericObject):
659
+ """
660
+ Validates the OpenAPI Specification object - §4.8.26
661
+ """
662
+
663
+ name: Optional[str] = None
664
+ namespace: Optional[URI] = None
665
+ prefix: Optional[str] = None
666
+ attribute: Optional[bool] = Field(default=False)
667
+ wrapped: Optional[bool] = None
668
+ _reference: ClassVar[Reference] = Reference(
669
+ title=TITLE,
670
+ url="https://spec.openapis.org/oas/v3.0.4.html#xml-object",
671
+ section="Security Scheme Object",
672
+ )
673
+
674
+ @field_validator("namespace", mode="after")
675
+ @classmethod
676
+ def _validate_namespace(cls, value: URI) -> URI:
677
+ if value.type == URIType.RELATIVE:
678
+ message = "XML namespace {value} cannot be a relative URI"
679
+ LogMixin.log(
680
+ Log(message=message, type=ValueError, reference=cls._reference)
681
+ )
682
+
683
+ return value
684
+
685
+
686
+ class SchemaObject(GenericObject):
687
+ """
688
+ Schema Object as per OAS 3.1.1 specification (section 4.8.24)
689
+
690
+ This model defines only the OpenAPI-specific fields explicitly.
691
+ Standard JSON Schema fields are allowed via the 'extra' config
692
+ and validated through jsonschema.
693
+ """
694
+
695
+ model_config = ConfigDict(
696
+ populate_by_name=True,
697
+ extra="allow", # Allow all standard JSON Schema fields
698
+ )
699
+
700
+ # OpenAPI-specific fields not in standard JSON Schema
701
+ nullable: Optional[bool] = None # OAS 3.0 style nullable flag
702
+ discriminator: Optional[DiscriminatorObject] = None # Polymorphism support
703
+ readOnly: Optional[bool] = None # Declares property as read-only for requests
704
+ writeOnly: Optional[bool] = None # Declares property as write-only for responses
705
+ xml: Optional[XMLObject] = None # XML metadata
706
+ externalDocs: Optional[ExternalDocumentationObject] = None # External documentation
707
+ example: Optional[Any] = None # Example of schema
708
+ examples: Optional[list[Any]] = None # Examples of schema (OAS 3.1)
709
+ deprecated: Optional[bool] = None # Specifies schema is deprecated
710
+
711
+ # JSON Schema fields that need special handling in OAS context
712
+ ref: Optional[str] = Field(
713
+ default=None, alias="$ref"
714
+ ) # Reference to another schema
715
+
716
+ _reference: ClassVar[Reference] = Reference(
717
+ title=TITLE,
718
+ url="https://spec.openapis.org/oas/v3.0.4.html#schema-object",
719
+ section="Link Object",
720
+ )
721
+
722
+ @model_validator(mode="after")
723
+ def validate_schema(self):
724
+ """
725
+ Use jsonschema to validate the model as a valid JSON Schema
726
+ """
727
+ schema_dict = self.model_dump(exclude_none=True, by_alias=True)
728
+
729
+ # Handle OAS 3.1 specific validations
730
+
731
+ # 1. Convert nullable to type array with null if needed
732
+ if schema_dict.get("nullable") is True and "type" in schema_dict:
733
+ type_val = schema_dict["type"]
734
+ if isinstance(type_val, str) and type_val != "null":
735
+ schema_dict["type"] = [type_val, "null"]
736
+ elif isinstance(type_val, list) and "null" not in type_val:
737
+ schema_dict["type"] = type_val + ["null"]
738
+
739
+ # 2. Validate the schema structure using jsonschema's meta-schema
740
+ # Get the right validator based on the declared $schema or default
741
+ # to Draft 2020-12
742
+ schema_version = schema_dict.get(
743
+ "$schema", "https://json-schema.org/draft/2020-12/schema"
744
+ )
745
+ try:
746
+ validator_cls: JSONSchemaValidator = validator_for( # type: ignore
747
+ {"$schema": schema_version}
748
+ )
749
+ meta_schema: JSON = validator_cls.META_SCHEMA # type: ignore
750
+
751
+ # This will validate the structure conforms to JSON Schema
752
+ validator_cls(meta_schema).validate(schema_dict) # type: ignore
753
+ except JSONVSchemeValidationError as e:
754
+ LogMixin.log(
755
+ Log(
756
+ message=f"Invalid JSON Schema: {e.message}",
757
+ type=ValueError,
758
+ reference=self._reference,
759
+ )
760
+ )
761
+
762
+ return self
763
+
764
+
765
+ OAUTH_FLOW_TYPES: set[str] = {
766
+ "implicit",
767
+ "authorizationCode",
768
+ "clientCredentials",
769
+ "password",
770
+ }
771
+
772
+
773
+ @specification_extensions("x-")
774
+ class OAuthFlowObject(GenericObject):
775
+ """
776
+ Validates the OpenAPI OAuth Flow object - §4.8.29
777
+ """
778
+
779
+ type: Optional[str] = None
780
+ authorizationUrl: Optional[URI] = None
781
+ tokenUrl: Optional[URI] = None
782
+ refreshUrl: Optional[URI] = None
783
+ scopes: dict[str, str] = {}
784
+ _reference: ClassVar[Reference] = Reference(
785
+ title=TITLE,
786
+ url="https://spec.openapis.org/oas/v3.0.4.html#oauth-flow-object",
787
+ section="OAuth Flow Object",
788
+ )
789
+
790
+ _implicit_has_authorization_url = mv.if_then(
791
+ conditions={"type": "implicit"},
792
+ consequences={"authorizationUrl": mv.UNKNOWN},
793
+ )
794
+
795
+ _token_url_not_implicit = mv.if_then(
796
+ conditions={"tokenUrl": mv.UNKNOWN},
797
+ consequences={"type": OAUTH_FLOW_TYPES ^ {"implicit"}},
798
+ )
799
+
800
+ _authorization_code_has_urls = mv.if_then(
801
+ conditions={"type": "authorizationCode"},
802
+ consequences={"authorizationUrl": mv.UNKNOWN, "tokenUrl": mv.UNKNOWN},
803
+ )
804
+
805
+ _authorization_url_not_credentials_password = mv.if_then(
806
+ conditions={"authorizationUrl": mv.UNKNOWN},
807
+ consequences={"type": OAUTH_FLOW_TYPES ^ {"clientCredentials", "password"}},
808
+ )
809
+
810
+ _client_credentials_has_token = mv.if_then(
811
+ conditions={"type": "clientCredentials"},
812
+ consequences={"tokenUrl": mv.UNKNOWN},
813
+ )
814
+ _password_has_token = mv.if_then(
815
+ conditions={"type": "password"}, consequences={"tokenUrl": mv.UNKNOWN}
816
+ )
817
+
818
+
819
+ @specification_extensions("-x")
820
+ class OAuthFlowsObject(GenericObject):
821
+ """
822
+ Validates the OpenAPI OAuth Flows object - §4.8.28
823
+
824
+ SPECFIX: Not all of these should be optional as an OAuth2 workflow
825
+ without any credentials will not do anything.
826
+ """
827
+
828
+ implicit: Optional[OAuthFlowObject] = None
829
+ password: Optional[OAuthFlowObject] = None
830
+ clientCredentials: Optional[OAuthFlowObject] = None
831
+ authorizationCode: Optional[OAuthFlowObject] = None
832
+ _reference: ClassVar[Reference] = Reference(
833
+ title=TITLE,
834
+ url="https://spec.openapis.org/oas/v3.0.4.html#oauth-flow-object",
835
+ section="OAuth Flows Object",
836
+ )
837
+
838
+ @model_validator(mode="before")
839
+ @classmethod
840
+ def _push_down_type(cls, data: Any) -> Any:
841
+ """
842
+ Adds the type of OAuth2 flow, e.g. implicit, password to the child
843
+ OAuthFlowObject so that additional validation can be done on this object.
844
+ """
845
+
846
+ for k, v in data.items():
847
+
848
+ if isinstance(v, OAuthFlowObject):
849
+ raise NotImplementedError("Must pass a dict")
850
+
851
+ if v:
852
+ data[k]["type"] = k
853
+
854
+ return data
855
+
856
+
857
+ class SecuritySchemeObject(GenericObject):
858
+ """
859
+ Validates the OpenAPI Security Scheme object - §4.8.27
860
+ """
861
+
862
+ type: str
863
+ description: Optional[str | CommonMark] = None
864
+ name: Optional[str] = None
865
+ in_: Optional[str] = Field(default=None, alias="in")
866
+ scheme: Optional[HTTPAuthenticationScheme] = None
867
+ bearerFormat: Optional[str] = None
868
+ flows: Optional[OAuthFlowsObject] = None
869
+ openIdConnectUrl: Optional[URI] = None
870
+
871
+ _SECURITY_SCHEME_TYPES: ClassVar[set[str]] = {
872
+ "apiKey",
873
+ "http",
874
+ "oauth2",
875
+ "openIdConnect",
876
+ }
877
+
878
+ _reference: ClassVar[Reference] = Reference(
879
+ title=TITLE,
880
+ url="https://spec.openapis.org/oas/v3.0.4.html#security-scheme-object-0",
881
+ section="Security Scheme Object",
882
+ )
883
+
884
+ _type_in_enum = mv.if_then(
885
+ conditions={"type": mv.UNKNOWN}, consequences={"type": _SECURITY_SCHEME_TYPES}
886
+ )
887
+
888
+ _apikey_has_name_and_in = mv.if_then(
889
+ conditions={"type": "apiKey"},
890
+ consequences={"name": mv.UNKNOWN, "in_": ("query", "header", "cookie")},
891
+ )
892
+
893
+ _http_has_scheme = mv.if_then(
894
+ conditions={"type": "http"}, consequences={"scheme": mv.UNKNOWN}
895
+ )
896
+
897
+ _oauth2_has_flows = mv.if_then(
898
+ conditions={"type": "oauth2"}, consequences={"flows": mv.UNKNOWN}
899
+ )
900
+
901
+ _open_id_connect_has_url = mv.if_then(
902
+ conditions={"type": "openIdConnect"},
903
+ consequences={"openIdConnectUrl": mv.UNKNOWN},
904
+ )
905
+
906
+ _flows_not_oauth2 = mv.if_then(
907
+ conditions={"flows": None},
908
+ consequences={"type": _SECURITY_SCHEME_TYPES ^ {"oauth2"}},
909
+ )
910
+
911
+
912
+ type _Requirement = dict[str, list[str]]
913
+
914
+
915
+ # NB This is implemented as a RootModel as there are no pre-defined field names.
916
+ class SecurityRequirementObject(RootModel[list[_Requirement] | _Requirement]):
917
+ """
918
+ Validates the OpenAPI Specification security requirement object - §4.8.30:
919
+ """
920
+
921
+ # FIXME: The name must be a valid Security Scheme - need to use post-processing
922
+ # FIXME If the security scheme is of type "oauth2" or "openIdConnect", then the
923
+ # value must be a list For other security scheme types, the array MAY contain a
924
+ # list of role names which are required for the execution
925
+ _reference: ClassVar[Reference] = Reference(
926
+ title=TITLE,
927
+ url="https://spec.openapis.org/oas/3.0.4.html#security-requirement-object",
928
+ section="Security Requirement Object",
929
+ )
930
+
931
+
932
+ @specification_extensions("x-")
933
+ class PathItemObject(GenericObject):
934
+ """Validates the OpenAPI Specification path item object - §4.8.9"""
935
+
936
+ ref_: Optional[URI] = Field(alias="$ref", default=None)
937
+ summary: Optional[str] = None
938
+ description: Optional[str | CommonMark] = None
939
+ get: Optional[OperationObject] = None
940
+ put: Optional[OperationObject] = None
941
+ post: Optional[OperationObject] = None
942
+ delete: Optional[OperationObject] = None
943
+ options: Optional[OperationObject] = None
944
+ head: Optional[OperationObject] = None
945
+ patch: Optional[OperationObject] = None
946
+ trace: Optional[OperationObject] = None
947
+ servers: Optional[list[ServerObject]] = None
948
+ parameters: Optional[list[ParameterObject | ReferenceObject]] = None
949
+ _reference: ClassVar[Reference] = Reference(
950
+ title=TITLE,
951
+ url="https://spec.openapis.org/oas/v3.0.4.html#path-item-object",
952
+ section="Path Item Object",
953
+ )
954
+
955
+
956
+ @specification_extensions("x-")
957
+ class ComponentsObject(GenericObject):
958
+ """
959
+ Validates the OpenAPI Specification components object - §4.8.7
960
+ """
961
+
962
+ schemas: Optional[dict[str, SchemaObject]] = None
963
+ responses: Optional[dict[str, ResponseObject | ReferenceObject]] = None
964
+ paremeters: Optional[dict[str, ParameterObject | ReferenceObject]] = None
965
+ examples: Optional[dict[str, ExampleObject | ReferenceObject]] = None
966
+ requestBodies: Optional[dict[str, RequestBodyObject | ReferenceObject]] = None
967
+ headers: Optional[dict[str, HeaderObject | ReferenceObject]] = None
968
+ securitySchemes: Optional[dict[str, SecuritySchemeObject | ReferenceObject]] = None
969
+ links: Optional[dict[str, LinkObject | ReferenceObject]] = None
970
+ callbacks: Optional[dict[str, CallbackObject | ReferenceObject]] = None
971
+ _reference: ClassVar[Reference] = Reference(
972
+ title=TITLE,
973
+ url="https://spec.openapis.org/oas/v3.0.4.html#components-object",
974
+ section="Components Object",
975
+ )
976
+
977
+ @model_validator(mode="before")
978
+ @classmethod
979
+ def validate_all_fields(
980
+ cls, data: dict[str, dict[str, Any]]
981
+ ) -> dict[str, dict[str, Any]]:
982
+ """
983
+ Validates the components object.
984
+
985
+ Args:
986
+ data: The data to validate.
987
+
988
+ Returns:
989
+ The validated components object.
990
+ """
991
+
992
+ pattern: str = r"^[a-zA-Z0-9\.\-_]+$"
993
+
994
+ # Validate each field in the components object
995
+ for field_name, value in data.items():
996
+ if field_name.startswith("x-"):
997
+ continue
998
+
999
+ if not isinstance(value, dict): # type: ignore
1000
+ raise ValueError(
1001
+ f"Invalid type for '{field_name}': expected dict, got {type(value)}"
1002
+ )
1003
+
1004
+ for key in value.keys():
1005
+ if not re.match(pattern, key):
1006
+ raise ValueError(
1007
+ f"Invalid key '{key}' in '{field_name}': must match pattern {pattern}" # pylint: disable=line-too-long
1008
+ )
1009
+
1010
+ return data
1011
+
1012
+
1013
+ @specification_extensions("x-")
1014
+ class OpenAPIObject(GenericObject):
1015
+ """
1016
+ Validates the OpenAPI Specification object - §4.1
1017
+ """
1018
+
1019
+ openapi: OpenAPI
1020
+ info: InfoObject
1021
+ servers: Optional[list[ServerObject]] = Field(default=[ServerObject(url=URI("/"))])
1022
+ paths: PathsObject
1023
+ components: Optional[ComponentsObject] = None
1024
+ security: Optional[list[SecurityRequirementObject]] = None
1025
+ tags: Optional[list[TagObject]] = None
1026
+ externalDocs: Optional[ExternalDocumentationObject] = None
1027
+ _reference: ClassVar[Reference] = Reference(
1028
+ title=TITLE,
1029
+ url="https://spec.openapis.org/oas/3.0.4.html#openapi-object",
1030
+ section="OpenAPI Object",
1031
+ )