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.
- amati/__init__.py +14 -0
- amati/_resolve_forward_references.py +185 -0
- amati/amati.py +143 -0
- amati/data/http-status-codes.json +1 -0
- amati/data/iso9110.json +1 -0
- amati/data/media-types.json +1 -0
- amati/data/schemes.json +1 -0
- amati/data/spdx-licences.json +1 -0
- amati/data/tlds.json +1 -0
- amati/exceptions.py +26 -0
- amati/fields/__init__.py +15 -0
- amati/fields/_custom_types.py +71 -0
- amati/fields/commonmark.py +9 -0
- amati/fields/email.py +27 -0
- amati/fields/http_status_codes.py +95 -0
- amati/fields/iso9110.py +61 -0
- amati/fields/json.py +13 -0
- amati/fields/media.py +100 -0
- amati/fields/oas.py +79 -0
- amati/fields/spdx_licences.py +92 -0
- amati/fields/uri.py +342 -0
- amati/file_handler.py +155 -0
- amati/grammars/oas.py +45 -0
- amati/grammars/rfc6901.py +26 -0
- amati/grammars/rfc7159.py +65 -0
- amati/logging.py +57 -0
- amati/model_validators.py +438 -0
- amati/references.py +33 -0
- amati/validators/__init__.py +0 -0
- amati/validators/generic.py +133 -0
- amati/validators/oas304.py +1031 -0
- amati/validators/oas311.py +615 -0
- amati-0.1.0.dist-info/METADATA +89 -0
- amati-0.1.0.dist-info/RECORD +37 -0
- amati-0.1.0.dist-info/WHEEL +4 -0
- amati-0.1.0.dist-info/entry_points.txt +2 -0
- amati-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,615 @@
|
|
1
|
+
"""
|
2
|
+
Validates the OpenAPI Specification version 3.1.1
|
3
|
+
|
4
|
+
Note that per https://spec.openapis.org/oas/v3.1.1.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
|
+
model_validator,
|
28
|
+
)
|
29
|
+
|
30
|
+
from amati import AmatiValueError, Reference
|
31
|
+
from amati import model_validators as mv
|
32
|
+
from amati.fields import (
|
33
|
+
SPDXURL,
|
34
|
+
URI,
|
35
|
+
HTTPStatusCode,
|
36
|
+
SPDXIdentifier,
|
37
|
+
)
|
38
|
+
from amati.fields.commonmark import CommonMark
|
39
|
+
from amati.fields.json import JSON
|
40
|
+
from amati.fields.oas import OpenAPI
|
41
|
+
from amati.fields.spdx_licences import VALID_LICENCES
|
42
|
+
from amati.logging import Log, LogMixin
|
43
|
+
from amati.validators.generic import GenericObject, allow_extra_fields
|
44
|
+
from amati.validators.oas304 import (
|
45
|
+
CallbackObject,
|
46
|
+
ContactObject,
|
47
|
+
EncodingObject,
|
48
|
+
ExampleObject,
|
49
|
+
ExternalDocumentationObject,
|
50
|
+
HeaderObject,
|
51
|
+
LinkObject,
|
52
|
+
PathItemObject,
|
53
|
+
PathsObject,
|
54
|
+
RequestBodyObject,
|
55
|
+
ResponseObject,
|
56
|
+
)
|
57
|
+
from amati.validators.oas304 import SecuritySchemeObject as OAS30SecuritySchemeObject
|
58
|
+
from amati.validators.oas304 import (
|
59
|
+
ServerObject,
|
60
|
+
TagObject,
|
61
|
+
XMLObject,
|
62
|
+
)
|
63
|
+
|
64
|
+
TITLE = "OpenAPI Specification v3.1.1"
|
65
|
+
|
66
|
+
# Convenience naming to ensure that it's clear what's happening.
|
67
|
+
# https://spec.openapis.org/oas/v3.1.1.html#specification-extensions
|
68
|
+
specification_extensions = allow_extra_fields
|
69
|
+
|
70
|
+
|
71
|
+
@specification_extensions("x-")
|
72
|
+
class LicenceObject(GenericObject):
|
73
|
+
"""
|
74
|
+
A model representing the OpenAPI Specification licence object §4.8.4
|
75
|
+
|
76
|
+
OAS uses the SPDX licence list.
|
77
|
+
|
78
|
+
# SPECFIX: The URI is mutually exclusive of the identifier. I don't see
|
79
|
+
the purpose of this; if the identifier is a SPDX Identifier where's the
|
80
|
+
harm in also including the URI
|
81
|
+
"""
|
82
|
+
|
83
|
+
name: str = Field(min_length=1)
|
84
|
+
# What difference does Optional make here?
|
85
|
+
identifier: Optional[SPDXIdentifier] = None
|
86
|
+
url: Optional[URI] = None
|
87
|
+
_reference: ClassVar[Reference] = Reference(
|
88
|
+
title=TITLE,
|
89
|
+
url="https://spec.openapis.org/oas/v3.1.1.html#license-object",
|
90
|
+
section="License Object",
|
91
|
+
)
|
92
|
+
|
93
|
+
_not_url_and_identifier = mv.only_one_of(["url", "identifier"])
|
94
|
+
|
95
|
+
@model_validator(mode="after")
|
96
|
+
def check_uri_associated_with_identifier(self: Self) -> Self:
|
97
|
+
"""
|
98
|
+
Validate that the URL matches the provided licence identifier.
|
99
|
+
|
100
|
+
This validator checks if the URL is listed among the known URLs for the
|
101
|
+
specified licence identifier.
|
102
|
+
|
103
|
+
Returns:
|
104
|
+
The validated licence object
|
105
|
+
"""
|
106
|
+
# URI only - should warn if not SPDX
|
107
|
+
if self.url:
|
108
|
+
try:
|
109
|
+
SPDXURL(self.url)
|
110
|
+
except AmatiValueError:
|
111
|
+
LogMixin.log(
|
112
|
+
Log(
|
113
|
+
message=f"{self.url} is not a valid SPDX URL",
|
114
|
+
type=Warning,
|
115
|
+
reference=self._reference,
|
116
|
+
)
|
117
|
+
)
|
118
|
+
|
119
|
+
# Both Identifier and URI, technically invalid, but should check if
|
120
|
+
# consistent
|
121
|
+
if (
|
122
|
+
self.url
|
123
|
+
and self.identifier
|
124
|
+
and str(self.url) not in VALID_LICENCES[self.identifier]
|
125
|
+
):
|
126
|
+
LogMixin.log(
|
127
|
+
Log(
|
128
|
+
message=f"{self.url} is not associated with the identifier {self.identifier}", # pylint: disable=line-too-long
|
129
|
+
type=Warning,
|
130
|
+
reference=self._reference,
|
131
|
+
)
|
132
|
+
)
|
133
|
+
|
134
|
+
return self
|
135
|
+
|
136
|
+
|
137
|
+
class ReferenceObject(GenericObject):
|
138
|
+
"""
|
139
|
+
Validates the OpenAPI Specification reference object - §4.8.23
|
140
|
+
|
141
|
+
Note, "URIs" can be prefixed with a hash; this is because if the
|
142
|
+
representation of the referenced document is JSON or YAML, then
|
143
|
+
the fragment identifier SHOULD be interpreted as a JSON-Pointer
|
144
|
+
as per RFC6901.
|
145
|
+
"""
|
146
|
+
|
147
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
148
|
+
|
149
|
+
ref: URI = Field(alias="$ref")
|
150
|
+
summary: Optional[str]
|
151
|
+
description: Optional[CommonMark]
|
152
|
+
_reference: ClassVar[Reference] = Reference(
|
153
|
+
title=TITLE,
|
154
|
+
url="https://spec.openapis.org/oas/v3.1.1.html#reference-object",
|
155
|
+
section="Reference Object",
|
156
|
+
)
|
157
|
+
|
158
|
+
|
159
|
+
@specification_extensions("x-")
|
160
|
+
class InfoObject(GenericObject):
|
161
|
+
"""
|
162
|
+
Validates the OpenAPI Specification info object - §4.8.2:
|
163
|
+
"""
|
164
|
+
|
165
|
+
title: str
|
166
|
+
summary: Optional[str] = None
|
167
|
+
description: Optional[str | CommonMark] = None
|
168
|
+
termsOfService: Optional[str] = None # pylint: disable=invalid-name
|
169
|
+
contact: Optional[ContactObject] = None
|
170
|
+
license: Optional[LicenceObject] = None
|
171
|
+
version: str
|
172
|
+
_reference: ClassVar[Reference] = Reference(
|
173
|
+
title=TITLE,
|
174
|
+
url="https://spec.openapis.org/oas/3.1.1.html#info-object",
|
175
|
+
section="Info Object",
|
176
|
+
)
|
177
|
+
|
178
|
+
|
179
|
+
@specification_extensions("x-")
|
180
|
+
class DiscriminatorObject(GenericObject):
|
181
|
+
"""
|
182
|
+
Validates the OpenAPI Specification object - §4.8.25
|
183
|
+
"""
|
184
|
+
|
185
|
+
# FIXME: Need post processing to determine whether the property actually exists
|
186
|
+
# FIXME: The component and schema objects need to check that this is being used
|
187
|
+
# properly.
|
188
|
+
propertyName: str
|
189
|
+
mapping: Optional[dict[str, str | URI]] = None
|
190
|
+
_reference: ClassVar[Reference] = Reference(
|
191
|
+
title=TITLE,
|
192
|
+
url="https://spec.openapis.org/oas/v3.1.1.html#discriminator-object",
|
193
|
+
section="Discriminator Object",
|
194
|
+
)
|
195
|
+
|
196
|
+
|
197
|
+
@specification_extensions("x-")
|
198
|
+
class ServerVariableObject(GenericObject):
|
199
|
+
"""
|
200
|
+
Validates the OpenAPI Specification server variable object - §4.8.6
|
201
|
+
"""
|
202
|
+
|
203
|
+
enum: Optional[list[str]] = Field(None, min_length=1)
|
204
|
+
default: str = Field(min_length=1)
|
205
|
+
description: Optional[str | CommonMark] = None
|
206
|
+
_reference: ClassVar[Reference] = Reference(
|
207
|
+
title=TITLE,
|
208
|
+
url="https://spec.openapis.org/oas/v3.1.1.html#server-variable-object",
|
209
|
+
section="Server Variable Object",
|
210
|
+
)
|
211
|
+
|
212
|
+
@model_validator(mode="after")
|
213
|
+
def check_enum_default(self: Self) -> Self:
|
214
|
+
"""
|
215
|
+
Validate that the default value is in the enum list.
|
216
|
+
|
217
|
+
Returns:
|
218
|
+
The validated server variable object
|
219
|
+
"""
|
220
|
+
if self.enum is None:
|
221
|
+
return self
|
222
|
+
|
223
|
+
if self.default not in self.enum:
|
224
|
+
LogMixin.log(
|
225
|
+
Log(
|
226
|
+
message=f"The default value {self.default} is not in the enum list {self.enum}", # pylint: disable=line-too-long
|
227
|
+
type=ValueError,
|
228
|
+
reference=self._reference,
|
229
|
+
)
|
230
|
+
)
|
231
|
+
|
232
|
+
return self
|
233
|
+
|
234
|
+
|
235
|
+
@specification_extensions("x-")
|
236
|
+
class OperationObject(GenericObject):
|
237
|
+
"""Validates the OpenAPI Specification operation object - §4.8.10"""
|
238
|
+
|
239
|
+
tags: Optional[list[str]] = None
|
240
|
+
summary: Optional[str] = None
|
241
|
+
description: Optional[str | CommonMark] = None
|
242
|
+
externalDocs: Optional[ExternalDocumentationObject] = None
|
243
|
+
operationId: Optional[str] = None
|
244
|
+
parameters: Optional[list["ParameterObject | ReferenceObject"]] = None
|
245
|
+
requestBody: Optional["RequestBodyObject | ReferenceObject"] = None
|
246
|
+
responses: Optional["ResponsesObject"] = None
|
247
|
+
callbacks: Optional[dict[str, "CallbackObject | ReferenceObject"]] = None
|
248
|
+
deprecated: Optional[bool] = False
|
249
|
+
security: Optional[list["SecurityRequirementObject"]] = None
|
250
|
+
servers: Optional[list[ServerObject]] = None
|
251
|
+
|
252
|
+
_reference: ClassVar[Reference] = Reference(
|
253
|
+
title=TITLE,
|
254
|
+
url="https://spec.openapis.org/oas/v3.1.1.html#operation-object",
|
255
|
+
section="Operation Object",
|
256
|
+
)
|
257
|
+
|
258
|
+
|
259
|
+
PARAMETER_STYLES: set[str] = {
|
260
|
+
"matrix",
|
261
|
+
"label",
|
262
|
+
"simple",
|
263
|
+
"form",
|
264
|
+
"spaceDelimited",
|
265
|
+
"pipeDelimited",
|
266
|
+
"deepObject",
|
267
|
+
}
|
268
|
+
|
269
|
+
|
270
|
+
class ParameterObject(GenericObject):
|
271
|
+
"""Validates the OpenAPI Specification parameter object - §4.8.11"""
|
272
|
+
|
273
|
+
name: str
|
274
|
+
in_: str = Field(alias="in")
|
275
|
+
description: Optional[str | CommonMark] = None
|
276
|
+
required: Optional[bool] = None
|
277
|
+
deprecated: Optional[bool] = None
|
278
|
+
allowEmptyValue: Optional[bool] = None
|
279
|
+
style: Optional[str] = None
|
280
|
+
explode: Optional[bool] = None
|
281
|
+
allowReserved: Optional[bool] = None
|
282
|
+
schema_: Optional["SchemaObject"] = Field(alias="schema")
|
283
|
+
example: Optional[Any] = None
|
284
|
+
examples: Optional[dict[str, "ExampleObject | ReferenceObject"]] = None
|
285
|
+
content: Optional[dict[str, "MediaTypeObject"]] = None
|
286
|
+
|
287
|
+
_in_valid = mv.if_then(
|
288
|
+
conditions={"in_": mv.UNKNOWN},
|
289
|
+
consequences={"in_": ["query", "header", "path", "cookie"]},
|
290
|
+
)
|
291
|
+
_path_location_is_required = mv.if_then(
|
292
|
+
conditions={"in_": "path"}, consequences={"required": True}
|
293
|
+
)
|
294
|
+
_empty_value_only_with_query = mv.if_then(
|
295
|
+
conditions={"allowEmptyValue": mv.UNKNOWN},
|
296
|
+
consequences={"in_": PARAMETER_STYLES ^ {"query"}},
|
297
|
+
)
|
298
|
+
_style_is_valid = mv.if_then(
|
299
|
+
conditions={"style": mv.UNKNOWN}, consequences={"style": list(PARAMETER_STYLES)}
|
300
|
+
)
|
301
|
+
_reserved_only_with_query = mv.if_then(
|
302
|
+
conditions={"allowReserved": mv.UNKNOWN},
|
303
|
+
consequences={"in_": PARAMETER_STYLES ^ {"query"}},
|
304
|
+
)
|
305
|
+
_disallowed_if_schema = mv.if_then(
|
306
|
+
conditions={"schema_": mv.UNKNOWN}, consequences={"content": None}
|
307
|
+
)
|
308
|
+
_disallowed_if_content = mv.if_then(
|
309
|
+
conditions={"content": mv.UNKNOWN},
|
310
|
+
consequences={
|
311
|
+
"content": None,
|
312
|
+
"style": None,
|
313
|
+
"explode": None,
|
314
|
+
"allowReserved": None,
|
315
|
+
"schema_": None,
|
316
|
+
},
|
317
|
+
)
|
318
|
+
|
319
|
+
|
320
|
+
@specification_extensions("x-")
|
321
|
+
class MediaTypeObject(GenericObject):
|
322
|
+
"""
|
323
|
+
Validates the OpenAPI Specification media type object - §4.8.14
|
324
|
+
"""
|
325
|
+
|
326
|
+
schema_: Optional["SchemaObject"] = Field(alias="schema", default=None)
|
327
|
+
# FIXME: Define example
|
328
|
+
example: Optional[Any] = None
|
329
|
+
examples: Optional[dict[str, ExampleObject | ReferenceObject]] = None
|
330
|
+
encoding: Optional["EncodingObject"] = None
|
331
|
+
_reference: ClassVar[Reference] = Reference(
|
332
|
+
title=TITLE,
|
333
|
+
url="https://spec.openapis.org/oas/v3.1.1.html#media-type-object",
|
334
|
+
section="Tag Object",
|
335
|
+
)
|
336
|
+
|
337
|
+
|
338
|
+
type _ResponsesObjectReturnType = dict[str, "ReferenceObject | ResponseObject"]
|
339
|
+
|
340
|
+
|
341
|
+
@specification_extensions("x-")
|
342
|
+
class ResponsesObject(GenericObject):
|
343
|
+
"""
|
344
|
+
Validates the OpenAPI Specification responses object - §4.8.16
|
345
|
+
"""
|
346
|
+
|
347
|
+
model_config = ConfigDict(
|
348
|
+
extra="allow",
|
349
|
+
)
|
350
|
+
|
351
|
+
default: Optional["ResponseObject | ReferenceObject"] = None
|
352
|
+
_reference: ClassVar[Reference] = Reference(
|
353
|
+
title=TITLE,
|
354
|
+
url="https://spec.openapis.org/oas/v3.1.1.html#responses-object",
|
355
|
+
section="Responses Object",
|
356
|
+
)
|
357
|
+
|
358
|
+
@classmethod
|
359
|
+
def _choose_model(
|
360
|
+
cls, value: Any, field_name: str
|
361
|
+
) -> "ReferenceObject | ResponseObject":
|
362
|
+
"""
|
363
|
+
Choose the model to use for validation based on the type of value.
|
364
|
+
|
365
|
+
Args:
|
366
|
+
value: The value to validate.
|
367
|
+
|
368
|
+
Returns:
|
369
|
+
The model class to use for validation.
|
370
|
+
"""
|
371
|
+
|
372
|
+
message = f"{field_name} must be a ResponseObject or ReferenceObject, got {type(value)}" # pylint: disable=line-too-long
|
373
|
+
|
374
|
+
try:
|
375
|
+
return ResponseObject.model_validate(value)
|
376
|
+
except ValidationError:
|
377
|
+
try:
|
378
|
+
return ReferenceObject.model_validate(value)
|
379
|
+
except ValidationError as e:
|
380
|
+
raise ValueError(message, ResponsesObject._reference) from e
|
381
|
+
|
382
|
+
@model_validator(mode="before")
|
383
|
+
@classmethod
|
384
|
+
def validate_all_fields(cls, data: dict[str, Any]) -> _ResponsesObjectReturnType:
|
385
|
+
"""
|
386
|
+
Validates the responses object.
|
387
|
+
"""
|
388
|
+
|
389
|
+
validated_data: _ResponsesObjectReturnType = {}
|
390
|
+
|
391
|
+
for field_name, value in data.items():
|
392
|
+
|
393
|
+
# If the value is a specification extension, allow it
|
394
|
+
if field_name.startswith("x-"):
|
395
|
+
validated_data[field_name] = value
|
396
|
+
continue
|
397
|
+
|
398
|
+
# If the value is the fixed field, "default", allow it
|
399
|
+
if field_name == "default":
|
400
|
+
if isinstance(value, dict):
|
401
|
+
validated_data[field_name] = ResponsesObject._choose_model(
|
402
|
+
value, field_name
|
403
|
+
)
|
404
|
+
continue
|
405
|
+
|
406
|
+
# Otherwise, if the field appears like a valid HTTP status code or a range
|
407
|
+
if re.match(r"^[1-5]([0-9]{2}|XX)+$", str(field_name)):
|
408
|
+
|
409
|
+
# Double check and raise a value error if not
|
410
|
+
HTTPStatusCode(field_name)
|
411
|
+
|
412
|
+
# and validate as a ResponseObject or ReferenceObject
|
413
|
+
validated_data[field_name] = ResponsesObject._choose_model(
|
414
|
+
value, field_name
|
415
|
+
)
|
416
|
+
|
417
|
+
continue
|
418
|
+
|
419
|
+
# If the field is not a valid HTTP status code or "default"
|
420
|
+
raise ValueError(f"Invalid type for numeric field '{field_name}'")
|
421
|
+
|
422
|
+
return validated_data
|
423
|
+
|
424
|
+
|
425
|
+
class SchemaObject(GenericObject):
|
426
|
+
"""
|
427
|
+
Schema Object as per OAS 3.1.1 specification (section 4.8.24)
|
428
|
+
|
429
|
+
This model defines only the OpenAPI-specific fields explicitly.
|
430
|
+
Standard JSON Schema fields are allowed via the 'extra' config
|
431
|
+
and validated through jsonschema.
|
432
|
+
"""
|
433
|
+
|
434
|
+
model_config = ConfigDict(
|
435
|
+
populate_by_name=True,
|
436
|
+
extra="allow", # Allow all standard JSON Schema fields
|
437
|
+
)
|
438
|
+
|
439
|
+
# OpenAPI-specific fields not in standard JSON Schema
|
440
|
+
nullable: Optional[bool] = None # OAS 3.0 style nullable flag
|
441
|
+
discriminator: Optional[DiscriminatorObject] = None # Polymorphism support
|
442
|
+
readOnly: Optional[bool] = None # Declares property as read-only for requests
|
443
|
+
writeOnly: Optional[bool] = None # Declares property as write-only for responses
|
444
|
+
xml: Optional[XMLObject] = None # XML metadata
|
445
|
+
externalDocs: Optional[ExternalDocumentationObject] = None # External documentation
|
446
|
+
example: Optional[Any] = None # Example of schema
|
447
|
+
examples: Optional[list[Any]] = None # Examples of schema (OAS 3.1)
|
448
|
+
deprecated: Optional[bool] = None # Specifies schema is deprecated
|
449
|
+
|
450
|
+
# JSON Schema fields that need special handling in OAS context
|
451
|
+
ref: Optional[str] = Field(
|
452
|
+
default=None, alias="$ref"
|
453
|
+
) # Reference to another schema
|
454
|
+
|
455
|
+
_reference: ClassVar[Reference] = Reference(
|
456
|
+
title=TITLE,
|
457
|
+
url="https://spec.openapis.org/oas/v3.1.1.html#schema-object",
|
458
|
+
section="Link Object",
|
459
|
+
)
|
460
|
+
|
461
|
+
@model_validator(mode="after")
|
462
|
+
def validate_schema(self):
|
463
|
+
"""
|
464
|
+
Use jsonschema to validate the model as a valid JSON Schema
|
465
|
+
"""
|
466
|
+
schema_dict = self.model_dump(exclude_none=True, by_alias=True)
|
467
|
+
|
468
|
+
# Handle OAS 3.1 specific validations
|
469
|
+
|
470
|
+
# 1. Convert nullable to type array with null if needed
|
471
|
+
if schema_dict.get("nullable") is True and "type" in schema_dict:
|
472
|
+
type_val = schema_dict["type"]
|
473
|
+
if isinstance(type_val, str) and type_val != "null":
|
474
|
+
schema_dict["type"] = [type_val, "null"]
|
475
|
+
elif isinstance(type_val, list) and "null" not in type_val:
|
476
|
+
schema_dict["type"] = type_val + ["null"]
|
477
|
+
|
478
|
+
# 2. Validate the schema structure using jsonschema's meta-schema
|
479
|
+
# Get the right validator based on the declared $schema or default
|
480
|
+
# to Draft 2020-12
|
481
|
+
schema_version = schema_dict.get(
|
482
|
+
"$schema", "https://json-schema.org/draft/2020-12/schema"
|
483
|
+
)
|
484
|
+
try:
|
485
|
+
validator_cls: JSONSchemaValidator = validator_for( # type: ignore
|
486
|
+
{"$schema": schema_version}
|
487
|
+
)
|
488
|
+
meta_schema: JSON = validator_cls.META_SCHEMA # type: ignore
|
489
|
+
|
490
|
+
# This will validate the structure conforms to JSON Schema
|
491
|
+
validator_cls(meta_schema).validate(schema_dict) # type: ignore
|
492
|
+
except JSONVSchemeValidationError as e:
|
493
|
+
LogMixin.log(
|
494
|
+
Log(
|
495
|
+
message=f"Invalid JSON Schema: {e.message}",
|
496
|
+
type=ValueError,
|
497
|
+
reference=self._reference,
|
498
|
+
)
|
499
|
+
)
|
500
|
+
|
501
|
+
return self
|
502
|
+
|
503
|
+
|
504
|
+
class SecuritySchemeObject(OAS30SecuritySchemeObject):
|
505
|
+
"""
|
506
|
+
Validates the OpenAPI Security Scheme object - §4.8.27
|
507
|
+
"""
|
508
|
+
|
509
|
+
_SECURITY_SCHEME_TYPES: ClassVar[set[str]] = {
|
510
|
+
"apiKey",
|
511
|
+
"http",
|
512
|
+
"oauth2",
|
513
|
+
"openIdConnect",
|
514
|
+
"mutualTLS",
|
515
|
+
}
|
516
|
+
|
517
|
+
|
518
|
+
type _Requirement = dict[str, list[str]]
|
519
|
+
|
520
|
+
|
521
|
+
# NB This is implemented as a RootModel as there are no pre-defined field names.
|
522
|
+
class SecurityRequirementObject(RootModel[list[_Requirement] | _Requirement]):
|
523
|
+
"""
|
524
|
+
Validates the OpenAPI Specification security requirement object - §4.8.30:
|
525
|
+
"""
|
526
|
+
|
527
|
+
# FIXME: The name must be a valid Security Scheme - need to use post-processing
|
528
|
+
# FIXME If the security scheme is of type "oauth2" or "openIdConnect", then the
|
529
|
+
# value must be a list
|
530
|
+
_reference: ClassVar[Reference] = Reference(
|
531
|
+
title=TITLE,
|
532
|
+
url="https://spec.openapis.org/oas/3.1.1.html#security-requirement-object",
|
533
|
+
section="Security Requirement Object",
|
534
|
+
)
|
535
|
+
|
536
|
+
|
537
|
+
@specification_extensions("x-")
|
538
|
+
class ComponentsObject(GenericObject):
|
539
|
+
"""
|
540
|
+
Validates the OpenAPI Specification components object - §4.8.7
|
541
|
+
"""
|
542
|
+
|
543
|
+
schemas: Optional[dict[str, SchemaObject | ReferenceObject]] = None
|
544
|
+
responses: Optional[dict[str, ResponseObject | ReferenceObject]] = None
|
545
|
+
paremeters: Optional[dict[str, ParameterObject | ReferenceObject]] = None
|
546
|
+
examples: Optional[dict[str, ExampleObject | ReferenceObject]] = None
|
547
|
+
requestBodies: Optional[dict[str, RequestBodyObject | ReferenceObject]] = None
|
548
|
+
headers: Optional[dict[str, HeaderObject | ReferenceObject]] = None
|
549
|
+
securitySchemes: Optional[dict[str, SecuritySchemeObject | ReferenceObject]] = None
|
550
|
+
links: Optional[dict[str, LinkObject | ReferenceObject]] = None
|
551
|
+
callbacks: Optional[dict[str, CallbackObject | ReferenceObject]] = None
|
552
|
+
pathItems: Optional[dict[str, PathItemObject]] = None
|
553
|
+
_reference: ClassVar[Reference] = Reference(
|
554
|
+
title=TITLE,
|
555
|
+
url="https://spec.openapis.org/oas/v3.1.1.html#components-object",
|
556
|
+
section="Components Object",
|
557
|
+
)
|
558
|
+
|
559
|
+
@model_validator(mode="before")
|
560
|
+
@classmethod
|
561
|
+
def validate_all_fields(
|
562
|
+
cls, data: dict[str, dict[str, Any]]
|
563
|
+
) -> dict[str, dict[str, Any]]:
|
564
|
+
"""
|
565
|
+
Validates the components object.
|
566
|
+
|
567
|
+
Args:
|
568
|
+
data: The data to validate.
|
569
|
+
|
570
|
+
Returns:
|
571
|
+
The validated components object.
|
572
|
+
"""
|
573
|
+
|
574
|
+
pattern: str = r"^[a-zA-Z0-9\.\-_]+$"
|
575
|
+
|
576
|
+
# Validate each field in the components object
|
577
|
+
for field_name, value in data.items():
|
578
|
+
if field_name.startswith("x-"):
|
579
|
+
continue
|
580
|
+
|
581
|
+
if not isinstance(value, dict): # type: ignore
|
582
|
+
raise ValueError(
|
583
|
+
f"Invalid type for '{field_name}': expected dict, got {type(value)}"
|
584
|
+
)
|
585
|
+
|
586
|
+
for key in value.keys():
|
587
|
+
if not re.match(pattern, key):
|
588
|
+
raise ValueError(
|
589
|
+
f"Invalid key '{key}' in '{field_name}': must match pattern {pattern}" # pylint: disable=line-too-long
|
590
|
+
)
|
591
|
+
|
592
|
+
return data
|
593
|
+
|
594
|
+
|
595
|
+
@specification_extensions("x-")
|
596
|
+
class OpenAPIObject(GenericObject):
|
597
|
+
"""
|
598
|
+
Validates the OpenAPI Specification object - §4.1
|
599
|
+
"""
|
600
|
+
|
601
|
+
openapi: OpenAPI
|
602
|
+
info: InfoObject
|
603
|
+
jsonSchemaDialect: Optional[URI] = None
|
604
|
+
servers: Optional[list[ServerObject]] = Field(default=[ServerObject(url=URI("/"))])
|
605
|
+
paths: Optional[PathsObject] = None
|
606
|
+
webhooks: Optional[dict[str, PathItemObject]] = None
|
607
|
+
components: Optional[ComponentsObject] = None
|
608
|
+
security: Optional[list[SecurityRequirementObject]] = None
|
609
|
+
tags: Optional[list[TagObject]] = None
|
610
|
+
externalDocs: Optional[ExternalDocumentationObject] = None
|
611
|
+
_reference: ClassVar[Reference] = Reference(
|
612
|
+
title=TITLE,
|
613
|
+
url="https://spec.openapis.org/oas/3.1.1.html#openapi-object",
|
614
|
+
section="OpenAPI Object",
|
615
|
+
)
|
@@ -0,0 +1,89 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: amati
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Validates that a .yaml or .json file conforms to the OpenAPI Specifications 3.x. This is an ALPHA build.
|
5
|
+
Project-URL: Homepage, https://github.com/ben-alexander/amati
|
6
|
+
Project-URL: Issues, https://github.com/ben-alexander/amati/issues
|
7
|
+
Author-email: Ben <2551337+ben-alexander@users.noreply.github.com>
|
8
|
+
License-File: LICENSE
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
10
|
+
Classifier: Intended Audience :: Developers
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
12
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
13
|
+
Classifier: Topic :: Software Development :: Documentation
|
14
|
+
Classifier: Topic :: Software Development :: Testing :: Acceptance
|
15
|
+
Requires-Python: >=3.13
|
16
|
+
Requires-Dist: abnf>=2.3.1
|
17
|
+
Requires-Dist: idna>=3.10
|
18
|
+
Requires-Dist: jsonpickle>=4.1.1
|
19
|
+
Requires-Dist: jsonschema>=4.24.0
|
20
|
+
Requires-Dist: pydantic>=2.11.5
|
21
|
+
Requires-Dist: pyyaml>=6.0.2
|
22
|
+
Description-Content-Type: text/markdown
|
23
|
+
|
24
|
+
# amati
|
25
|
+
|
26
|
+
A programme designed to validate that a file conforms to [OpenAPI Specification](https://spec.openapis.org/oas/v3.1.1.html) (OAS).
|
27
|
+
|
28
|
+
Currently a proof of concept.
|
29
|
+
|
30
|
+
## Name
|
31
|
+
|
32
|
+
amati means to observe in Malay, especially with attention to detail. It's also one of the plurals of beloved or favourite in Italian.
|
33
|
+
|
34
|
+
## Architecture
|
35
|
+
|
36
|
+
This uses Pydantic, especially the validation, and Typing to construct the entire OAS as a single data type. Passing a dictionary to the top-level data type runs all the validation in the Pydantic models constructing a single set of inherited classes and datatypes that validate that the API specification is accurate.
|
37
|
+
|
38
|
+
Where the specification conforms, but relies on implementation-defined behavior (e.g. [data type formats](https://spec.openapis.org/oas/v3.1.1.html#data-type-format)), a warning will be raised.
|
39
|
+
|
40
|
+
## Requirements
|
41
|
+
|
42
|
+
* The latest version of [uv](https://docs.astral.sh/uv/)
|
43
|
+
* [git 2.49+](https://git-scm.com/downloads/linux)
|
44
|
+
|
45
|
+
## Testing and formatting
|
46
|
+
|
47
|
+
This project uses:
|
48
|
+
|
49
|
+
* [Pytest](https://docs.pytest.org/en/stable/) as a testing framework
|
50
|
+
* [PyLance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance) on strict mode for type checking
|
51
|
+
* [Pylint](https://www.pylint.org/) as a linter, using a modified version from [Google's style guide](https://google.github.io/styleguide/pyguide.html)
|
52
|
+
* [Hypothesis](https://hypothesis.readthedocs.io/en/latest/index.html) for test data generation
|
53
|
+
* [Coverage](https://coverage.readthedocs.io/en/7.6.8/) on both the tests and code for test coverage
|
54
|
+
* [Black](https://black.readthedocs.io/en/stable/index.html) for automated formatting
|
55
|
+
* [isort](https://pycqa.github.io/isort/) for import sorting
|
56
|
+
|
57
|
+
It's expected that there are no errors, no surviving mutants and 100% of the code is reached and executed.
|
58
|
+
|
59
|
+
amati runs tests on external specifications, detailed in `tests/data/.amati.tests.yaml`. To be able to run these tests the appropriate GitHub repos need to be local. Specific revisions of the repos can be downloaded by running
|
60
|
+
|
61
|
+
```sh
|
62
|
+
python scripts/tests/setup_test_specs.py
|
63
|
+
```
|
64
|
+
|
65
|
+
To run everything, from linting, type checking to downloading test specs run:
|
66
|
+
|
67
|
+
```sh
|
68
|
+
sh bin/checks.sh
|
69
|
+
```
|
70
|
+
|
71
|
+
## Building
|
72
|
+
|
73
|
+
The project uses a [`pyproject.toml` file](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#writing-pyproject-toml) to determine what to build.
|
74
|
+
|
75
|
+
To install, assuming that [uv](https://docs.astral.sh/uv/) is already installed and initialised
|
76
|
+
|
77
|
+
```sh
|
78
|
+
uv python install
|
79
|
+
uv venv
|
80
|
+
uv sync
|
81
|
+
```
|
82
|
+
|
83
|
+
### Data
|
84
|
+
|
85
|
+
There are some scripts to create the data needed by the project, for example, all the possible licences. If the data needs to be refreshed this can be done by running the contents of `/scripts/data`.
|
86
|
+
|
87
|
+
|
88
|
+
|
89
|
+
|