amati 0.3.14__py3-none-any.whl → 0.3.16__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/_error_handler.py +33 -33
- amati/_references.py +226 -0
- amati/_resolve_forward_references.py +36 -60
- amati/amati.py +109 -16
- amati/exceptions.py +1 -1
- amati/fields/email.py +1 -1
- amati/fields/http_status_codes.py +2 -1
- amati/fields/iso9110.py +2 -1
- amati/fields/media.py +2 -1
- amati/fields/oas.py +1 -1
- amati/fields/spdx_licences.py +2 -1
- amati/fields/uri.py +2 -1
- amati/validators/_discriminators.py +8 -0
- amati/validators/generic.py +24 -16
- amati/validators/oas304.py +122 -50
- amati/validators/oas311.py +92 -37
- {amati-0.3.14.dist-info → amati-0.3.16.dist-info}/METADATA +1 -1
- {amati-0.3.14.dist-info → amati-0.3.16.dist-info}/RECORD +21 -19
- {amati-0.3.14.dist-info → amati-0.3.16.dist-info}/WHEEL +0 -0
- {amati-0.3.14.dist-info → amati-0.3.16.dist-info}/entry_points.txt +0 -0
- {amati-0.3.14.dist-info → amati-0.3.16.dist-info}/licenses/LICENSE +0 -0
amati/_error_handler.py
CHANGED
|
@@ -13,36 +13,36 @@ type JSONObject = dict[str, "JSONValue"]
|
|
|
13
13
|
type JSONValue = JSONPrimitive | JSONArray | JSONObject
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
16
|
+
class ErrorHandler:
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._errors: list[JSONObject] = []
|
|
19
|
+
|
|
20
|
+
def register_logs(self, logs: list[Log]):
|
|
21
|
+
self._errors.extend(cast(list[JSONObject], logs))
|
|
22
|
+
|
|
23
|
+
def register_log(self, log: Log):
|
|
24
|
+
self._errors.append(cast(JSONObject, log))
|
|
25
|
+
|
|
26
|
+
def register_errors(self, errors: list[JSONObject]):
|
|
27
|
+
self._errors.extend(errors)
|
|
28
|
+
|
|
29
|
+
def deduplicate(self):
|
|
30
|
+
"""
|
|
31
|
+
Remove duplicates by converting each dict to a JSON string for comparison.
|
|
32
|
+
"""
|
|
33
|
+
seen: set[str] = set()
|
|
34
|
+
unique_data: list[JSONObject] = []
|
|
35
|
+
|
|
36
|
+
item: JSONObject
|
|
37
|
+
for item in self._errors:
|
|
38
|
+
# Convert to JSON string with sorted keys for consistent hashing
|
|
39
|
+
item_json = json.dumps(item, sort_keys=True, separators=(",", ":"))
|
|
40
|
+
if item_json not in seen:
|
|
41
|
+
seen.add(item_json)
|
|
42
|
+
unique_data.append(item)
|
|
43
|
+
|
|
44
|
+
self._errors = unique_data
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def errors(self) -> list[JSONObject]:
|
|
48
|
+
return self._errors
|
amati/_references.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from amati.fields import URI, URIType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class URIReference:
|
|
12
|
+
"""Immutable record of a URI found during validation"""
|
|
13
|
+
|
|
14
|
+
uri: URI
|
|
15
|
+
source_document: Path
|
|
16
|
+
source_model_name: str # Just the string name for error reporting
|
|
17
|
+
source_field: str
|
|
18
|
+
target_model: type[BaseModel] # The model type to validate with
|
|
19
|
+
|
|
20
|
+
def resolve(self) -> Path:
|
|
21
|
+
"""Resolve URI relative to source document, see
|
|
22
|
+
https://spec.openapis.org/oas/v3.1.1.html#relative-references-in-api-description-uris
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
if self.uri.scheme == "file":
|
|
26
|
+
if not self.uri.path:
|
|
27
|
+
raise ValueError("File URI must have a path component")
|
|
28
|
+
|
|
29
|
+
netloc: Path | None = (
|
|
30
|
+
Path(self.uri.authority)
|
|
31
|
+
if self.uri.authority
|
|
32
|
+
else Path(self.uri.host)
|
|
33
|
+
if self.uri.host
|
|
34
|
+
else None
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
(netloc / self.uri.path).resolve()
|
|
39
|
+
if netloc
|
|
40
|
+
else Path(self.uri.path).resolve()
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if self.uri.type == URIType.ABSOLUTE:
|
|
44
|
+
raise NotImplementedError("Absolute URI resolution not implemented")
|
|
45
|
+
|
|
46
|
+
if self.uri.type == URIType.NETWORK_PATH:
|
|
47
|
+
return Path(self.uri).resolve()
|
|
48
|
+
|
|
49
|
+
if self.uri.type == URIType.RELATIVE:
|
|
50
|
+
path: Path = self.source_document.parent / self.uri.lstrip("/")
|
|
51
|
+
return path.resolve()
|
|
52
|
+
|
|
53
|
+
if self.uri.type == URIType.JSON_POINTER:
|
|
54
|
+
path: Path = self.source_document.parent / self.uri.lstrip("#/")
|
|
55
|
+
return path.resolve()
|
|
56
|
+
|
|
57
|
+
# Guard against future changes
|
|
58
|
+
raise ValueError(f"Unknown URI type: {self.uri.type}") # pragma: no cover
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class URIRegistry:
|
|
62
|
+
"""Registry for discovered URIs using the Singleton pattern.
|
|
63
|
+
|
|
64
|
+
This class maintains a central registry of all URI references discovered
|
|
65
|
+
during document validation. It tracks both the URIs themselves and which
|
|
66
|
+
documents have already been processed to avoid duplicate validation.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
_instance: Class-level singleton instance.
|
|
70
|
+
_uris: List of all registered URI references.
|
|
71
|
+
_processed: Set of file paths that have been validated.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
_instance = None
|
|
75
|
+
|
|
76
|
+
def __init__(self):
|
|
77
|
+
"""Initialize a new URIRegistry instance.
|
|
78
|
+
|
|
79
|
+
Note:
|
|
80
|
+
This should not be called directly. Use get_instance() instead
|
|
81
|
+
to obtain the singleton instance.
|
|
82
|
+
"""
|
|
83
|
+
self._uris: list[URIReference] = []
|
|
84
|
+
self._processed: set[Path] = set()
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def get_instance(cls) -> URIRegistry:
|
|
88
|
+
"""Get or create the singleton instance of URIRegistry.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
URIRegistry: The singleton instance of the registry.
|
|
92
|
+
"""
|
|
93
|
+
if cls._instance is None:
|
|
94
|
+
cls._instance = cls()
|
|
95
|
+
|
|
96
|
+
return cls._instance
|
|
97
|
+
|
|
98
|
+
def register(self, ref: URIReference):
|
|
99
|
+
"""Register a discovered URI reference.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
ref (URIReference): The URI reference to register, including
|
|
103
|
+
source document, and target model.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
if not isinstance(ref, URIReference): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
107
|
+
raise TypeError("ref must be an instance of URIReference")
|
|
108
|
+
|
|
109
|
+
self._uris.append(ref)
|
|
110
|
+
|
|
111
|
+
def mark_processed(self, path: Path):
|
|
112
|
+
"""Mark a document as having been validated.
|
|
113
|
+
|
|
114
|
+
The path is resolved to an absolute path before storage to ensure
|
|
115
|
+
consistent tracking regardless of how the path was specified.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
path (Path): The file path of the document that has been processed.
|
|
119
|
+
"""
|
|
120
|
+
self._processed.add(path.resolve())
|
|
121
|
+
|
|
122
|
+
def is_processed(self, path: Path) -> bool:
|
|
123
|
+
"""Check if a document has already been validated.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
path (Path): The file path to check.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
bool: True if the document has been processed, False otherwise.
|
|
130
|
+
"""
|
|
131
|
+
return path.resolve() in self._processed
|
|
132
|
+
|
|
133
|
+
def get_all_references(self) -> list[URIReference]:
|
|
134
|
+
"""Get all discovered URI references.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
list[URIReference]: A copy of the list of all registered URI
|
|
138
|
+
references. Returns a copy to prevent external modification
|
|
139
|
+
of the internal registry.
|
|
140
|
+
"""
|
|
141
|
+
return self._uris.copy()
|
|
142
|
+
|
|
143
|
+
def resolvable(self, path: Path) -> bool:
|
|
144
|
+
"""Check if the file referenced by a URI exists.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
path (Path): The file path to verify.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
bool: True if the path points to an existing file, False otherwise.
|
|
151
|
+
"""
|
|
152
|
+
return path.is_file()
|
|
153
|
+
|
|
154
|
+
def reset(self):
|
|
155
|
+
"""Reset the registry for a new validation run.
|
|
156
|
+
|
|
157
|
+
Clears all registered URIs and processed document records. This is
|
|
158
|
+
typically called at the beginning of a new validation session.
|
|
159
|
+
"""
|
|
160
|
+
self._uris.clear()
|
|
161
|
+
self._processed.clear()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class URICollectorMixin(BaseModel):
|
|
165
|
+
"""Mixin for Pydantic models to automatically collect URIs during validation.
|
|
166
|
+
|
|
167
|
+
This mixin hooks into the Pydantic model lifecycle to automatically
|
|
168
|
+
discover and register URI fields during model instantiation. It inspects
|
|
169
|
+
all fields after validation and registers any URI-type fields with the
|
|
170
|
+
URIRegistry for subsequent processing.
|
|
171
|
+
|
|
172
|
+
The mixin expects a 'current_document' key in the validation context
|
|
173
|
+
to track the source document for each URI reference.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def model_post_init(self, __context: dict[str, Any]) -> None:
|
|
177
|
+
"""Post-initialization hook to collect URI references from model fields.
|
|
178
|
+
|
|
179
|
+
This method is automatically called by Pydantic after model validation
|
|
180
|
+
is complete. It inspects all fields for URI types and registers them
|
|
181
|
+
with the singleton URIRegistry.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
__context (dict[str, Any]): Validation context dictionary. Expected
|
|
185
|
+
to contain a 'current_document' key with the path to the source
|
|
186
|
+
document being validated.
|
|
187
|
+
|
|
188
|
+
Note:
|
|
189
|
+
This method calls super().model_post_init() to ensure compatibility
|
|
190
|
+
with other mixins and the base model's initialization process.
|
|
191
|
+
|
|
192
|
+
Example:
|
|
193
|
+
Context should be passed during model instantiation:
|
|
194
|
+
>>> class MyModel(URICollectorMixin, BaseModel):
|
|
195
|
+
... ref: URI
|
|
196
|
+
>>> model = MyModel.model_validate(
|
|
197
|
+
... {"ref": "http://example.com/resource"},
|
|
198
|
+
... context={"current_document": "/path/to/doc.json"}
|
|
199
|
+
... )
|
|
200
|
+
"""
|
|
201
|
+
super().model_post_init(__context)
|
|
202
|
+
|
|
203
|
+
if not __context:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
current_doc = __context.get("current_document")
|
|
207
|
+
if not current_doc:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
# Inspect all fields for URI types
|
|
211
|
+
for field_name, field_value in self.model_dump().items():
|
|
212
|
+
if field_value is None:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
# Check if this field contains a URI
|
|
216
|
+
# Adjust this check based on your URI type implementation
|
|
217
|
+
if isinstance(field_value, URI):
|
|
218
|
+
ref = URIReference(
|
|
219
|
+
uri=field_value,
|
|
220
|
+
source_document=Path(current_doc),
|
|
221
|
+
source_model_name=self.__class__.__name__,
|
|
222
|
+
source_field=field_name,
|
|
223
|
+
# The linked document should be validated with the same model type
|
|
224
|
+
target_model=self.__class__,
|
|
225
|
+
)
|
|
226
|
+
URIRegistry.get_instance().register(ref)
|
|
@@ -5,6 +5,7 @@ without all its dependencies. This module rebuilds all models in a module.
|
|
|
5
5
|
|
|
6
6
|
import inspect
|
|
7
7
|
import sys
|
|
8
|
+
import typing
|
|
8
9
|
from collections import defaultdict
|
|
9
10
|
from types import ModuleType
|
|
10
11
|
|
|
@@ -26,20 +27,46 @@ class ModelDependencyResolver:
|
|
|
26
27
|
"""Register a Pydantic model for dependency analysis."""
|
|
27
28
|
self.models[model.__name__] = model
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
@staticmethod
|
|
31
|
+
def extract_all_references(annotation: typing.Any, refs: set[str] | None = None):
|
|
32
|
+
"""
|
|
33
|
+
Recursively extract all ForwardRef and type references from an annotation.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
annotation: A type annotation (potentially deeply nested)
|
|
37
|
+
refs: Set to accumulate references (used internally for recursion)
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Set of either ForwardRef objects or actual type/class objects
|
|
41
|
+
"""
|
|
42
|
+
if refs is None:
|
|
43
|
+
refs = set()
|
|
44
|
+
|
|
45
|
+
# Direct ForwardRef
|
|
46
|
+
if isinstance(annotation, typing.ForwardRef):
|
|
47
|
+
refs.add(annotation.__forward_arg__)
|
|
48
|
+
return refs
|
|
49
|
+
|
|
50
|
+
# Direct class reference
|
|
51
|
+
if isinstance(annotation, type):
|
|
52
|
+
refs.add(annotation.__name__)
|
|
53
|
+
return refs
|
|
54
|
+
|
|
55
|
+
for origin in typing.get_args(annotation):
|
|
56
|
+
ModelDependencyResolver.extract_all_references(origin, refs)
|
|
57
|
+
|
|
58
|
+
return refs
|
|
33
59
|
|
|
34
60
|
def _analyze_model_dependencies(self, model: type[BaseModel]) -> set[str]:
|
|
35
61
|
"""Analyze a single model's dependencies from its annotations."""
|
|
36
62
|
dependencies: set[str] = set()
|
|
37
63
|
|
|
38
64
|
for field_info in model.model_fields.values():
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
65
|
+
references = ModelDependencyResolver.extract_all_references(
|
|
66
|
+
field_info.annotation
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
dependencies.update(ref for ref in references if ref in self.models)
|
|
43
70
|
|
|
44
71
|
return dependencies
|
|
45
72
|
|
|
@@ -48,18 +75,10 @@ class ModelDependencyResolver:
|
|
|
48
75
|
self.dependencies.clear()
|
|
49
76
|
self.graph.clear()
|
|
50
77
|
|
|
51
|
-
# First pass: collect all dependencies
|
|
52
78
|
for model_name, model in self.models.items():
|
|
53
79
|
deps = self._analyze_model_dependencies(model)
|
|
54
80
|
self.dependencies[model_name] = deps
|
|
55
81
|
|
|
56
|
-
# Second pass: build directed graph
|
|
57
|
-
for model_name, deps in self.dependencies.items():
|
|
58
|
-
for dep in deps:
|
|
59
|
-
if dep in self.models:
|
|
60
|
-
# Build forward graph (dependency -> dependent)
|
|
61
|
-
self.graph[dep].append(model_name)
|
|
62
|
-
|
|
63
82
|
def _tarjan_scc(self) -> list[list[str]]:
|
|
64
83
|
"""Find strongly connected components using Tarjan's algorithm."""
|
|
65
84
|
index_counter = [0]
|
|
@@ -76,13 +95,6 @@ class ModelDependencyResolver:
|
|
|
76
95
|
stack.append(node)
|
|
77
96
|
on_stack[node] = True
|
|
78
97
|
|
|
79
|
-
for successor in self.graph[node]:
|
|
80
|
-
if successor not in index:
|
|
81
|
-
strongconnect(successor)
|
|
82
|
-
lowlinks[node] = min(lowlinks[node], lowlinks[successor])
|
|
83
|
-
elif on_stack[successor]:
|
|
84
|
-
lowlinks[node] = min(lowlinks[node], index[successor])
|
|
85
|
-
|
|
86
98
|
if lowlinks[node] == index[node]:
|
|
87
99
|
component: list[str] = []
|
|
88
100
|
while True:
|
|
@@ -99,41 +111,6 @@ class ModelDependencyResolver:
|
|
|
99
111
|
|
|
100
112
|
return sccs
|
|
101
113
|
|
|
102
|
-
def _topological_sort_sccs(self, sccs: list[list[str]]) -> list[list[str]]:
|
|
103
|
-
"""Topologically sort the strongly connected components."""
|
|
104
|
-
# Map each node to its SCC index
|
|
105
|
-
node_to_scc = {node: i for i, scc in enumerate(sccs) for node in scc}
|
|
106
|
-
|
|
107
|
-
# Find dependencies between SCCs
|
|
108
|
-
dependencies: set[tuple[int, ...]] = set()
|
|
109
|
-
for node in self.models:
|
|
110
|
-
for neighbor in self.graph[node]:
|
|
111
|
-
src_scc, dst_scc = node_to_scc[node], node_to_scc[neighbor]
|
|
112
|
-
if src_scc != dst_scc:
|
|
113
|
-
dependencies.add((src_scc, dst_scc))
|
|
114
|
-
|
|
115
|
-
# Count incoming edges for each SCC
|
|
116
|
-
in_degree = [0] * len(sccs)
|
|
117
|
-
for _, dst in dependencies:
|
|
118
|
-
in_degree[dst] += 1
|
|
119
|
-
|
|
120
|
-
# Process SCCs with no dependencies first
|
|
121
|
-
ready = [i for i, deg in enumerate(in_degree) if deg == 0]
|
|
122
|
-
result: list[list[str]] = []
|
|
123
|
-
|
|
124
|
-
while ready:
|
|
125
|
-
current = ready.pop()
|
|
126
|
-
result.append(sccs[current])
|
|
127
|
-
|
|
128
|
-
# Remove this SCC and update in-degrees
|
|
129
|
-
for src, dst in dependencies:
|
|
130
|
-
if src == current:
|
|
131
|
-
in_degree[dst] -= 1
|
|
132
|
-
if in_degree[dst] == 0:
|
|
133
|
-
ready.append(dst)
|
|
134
|
-
|
|
135
|
-
return result
|
|
136
|
-
|
|
137
114
|
def get_rebuild_order(self) -> list[list[str]]:
|
|
138
115
|
"""
|
|
139
116
|
Get the order in which models should be rebuilt.
|
|
@@ -141,8 +118,7 @@ class ModelDependencyResolver:
|
|
|
141
118
|
rebuilt together.
|
|
142
119
|
"""
|
|
143
120
|
self.build_dependency_graph()
|
|
144
|
-
|
|
145
|
-
return self._topological_sort_sccs(sccs)
|
|
121
|
+
return self._tarjan_scc()
|
|
146
122
|
|
|
147
123
|
def rebuild_models(self) -> None:
|
|
148
124
|
"""Rebuild all registered models in the correct dependency order."""
|
amati/amati.py
CHANGED
|
@@ -6,6 +6,7 @@ import importlib
|
|
|
6
6
|
import json
|
|
7
7
|
import sys
|
|
8
8
|
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
9
10
|
|
|
10
11
|
from jinja2 import Environment, FileSystemLoader
|
|
11
12
|
from loguru import logger
|
|
@@ -13,9 +14,11 @@ from pydantic import BaseModel, ValidationError
|
|
|
13
14
|
|
|
14
15
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
15
16
|
from amati._data.refresh import refresh
|
|
16
|
-
from amati._error_handler import
|
|
17
|
+
from amati._error_handler import ErrorHandler
|
|
17
18
|
from amati._logging import Log, Logger
|
|
19
|
+
from amati._references import URIRegistry
|
|
18
20
|
from amati._resolve_forward_references import resolve_forward_references
|
|
21
|
+
from amati.fields import URIType
|
|
19
22
|
from amati.file_handler import load_file
|
|
20
23
|
|
|
21
24
|
type JSONPrimitive = str | int | float | bool | None
|
|
@@ -24,15 +27,15 @@ type JSONObject = dict[str, "JSONValue"]
|
|
|
24
27
|
type JSONValue = JSONPrimitive | JSONArray | JSONObject
|
|
25
28
|
|
|
26
29
|
|
|
27
|
-
def
|
|
30
|
+
def _determine_version(data: JSONObject) -> str:
|
|
28
31
|
"""
|
|
29
|
-
|
|
32
|
+
Determines the OpenAPI specification version from the provided data.
|
|
30
33
|
|
|
31
34
|
Args:
|
|
32
35
|
data: A dictionary representing an OpenAPI specification
|
|
33
36
|
|
|
34
37
|
Returns:
|
|
35
|
-
|
|
38
|
+
The OpenAPI specification version as a string
|
|
36
39
|
"""
|
|
37
40
|
|
|
38
41
|
version: JSONValue = data.get("openapi")
|
|
@@ -43,6 +46,28 @@ def dispatch(data: JSONObject) -> tuple[BaseModel | None, list[JSONObject] | Non
|
|
|
43
46
|
if not version:
|
|
44
47
|
raise TypeError("An OpenAPI Specfication must contain a version.")
|
|
45
48
|
|
|
49
|
+
return version
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def dispatch(
|
|
53
|
+
data: JSONObject,
|
|
54
|
+
context: dict[str, Any],
|
|
55
|
+
version: str,
|
|
56
|
+
obj: str = "OpenAPIObject",
|
|
57
|
+
) -> tuple[BaseModel | None, list[JSONObject] | None]:
|
|
58
|
+
"""
|
|
59
|
+
Returns the correct model for the passed spec
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
data: A dictionary representing an OpenAPI specification
|
|
63
|
+
version: An optional Open API version string to override automatic detection.
|
|
64
|
+
The most common reason to provide the version is when validating references
|
|
65
|
+
outside of the context of a full specification document.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A pydantic model representing the API specification
|
|
69
|
+
"""
|
|
70
|
+
|
|
46
71
|
version_map: dict[str, str] = {
|
|
47
72
|
"3.1.1": "311",
|
|
48
73
|
"3.1.0": "311",
|
|
@@ -53,18 +78,91 @@ def dispatch(data: JSONObject) -> tuple[BaseModel | None, list[JSONObject] | Non
|
|
|
53
78
|
"3.0.0": "304",
|
|
54
79
|
}
|
|
55
80
|
|
|
56
|
-
|
|
81
|
+
module_name: str = f"amati.validators.oas{version_map[version]}"
|
|
57
82
|
|
|
83
|
+
module = importlib.import_module(module_name)
|
|
58
84
|
resolve_forward_references(module)
|
|
59
85
|
|
|
60
86
|
try:
|
|
61
|
-
model = module.
|
|
87
|
+
model = getattr(module, obj).model_validate(data, context=context)
|
|
62
88
|
except ValidationError as e:
|
|
63
89
|
return None, json.loads(e.json())
|
|
64
90
|
|
|
65
91
|
return model, None
|
|
66
92
|
|
|
67
93
|
|
|
94
|
+
def dispatch_all(spec: Path) -> tuple[BaseModel | None, ErrorHandler]:
|
|
95
|
+
""" """
|
|
96
|
+
|
|
97
|
+
registry = URIRegistry.get_instance()
|
|
98
|
+
registry.reset()
|
|
99
|
+
|
|
100
|
+
error_handler = ErrorHandler()
|
|
101
|
+
result: BaseModel | None = None
|
|
102
|
+
version: str | None = None
|
|
103
|
+
|
|
104
|
+
to_process: list[tuple[Path, str]] = [(spec, "OpenAPIObject")]
|
|
105
|
+
|
|
106
|
+
while to_process:
|
|
107
|
+
doc_path, obj = to_process.pop(0)
|
|
108
|
+
|
|
109
|
+
# Skip if already validated (handles circular references)
|
|
110
|
+
if registry.is_processed(doc_path):
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
logger.info(f"Validating: {doc_path}")
|
|
114
|
+
|
|
115
|
+
data: JSONObject = load_file(spec)
|
|
116
|
+
|
|
117
|
+
# Create validation context with current document path
|
|
118
|
+
context = {"current_document": str(doc_path)}
|
|
119
|
+
|
|
120
|
+
if obj == "OpenAPIObject":
|
|
121
|
+
version = _determine_version(data)
|
|
122
|
+
elif version is None:
|
|
123
|
+
raise ValueError("Version must be set in the OpenAPI object")
|
|
124
|
+
|
|
125
|
+
with Logger.context():
|
|
126
|
+
result, errors = dispatch(data, context, version, obj)
|
|
127
|
+
|
|
128
|
+
if errors:
|
|
129
|
+
error_handler.register_errors(errors)
|
|
130
|
+
|
|
131
|
+
error_handler.register_logs(Logger.logs)
|
|
132
|
+
|
|
133
|
+
registry.mark_processed(doc_path)
|
|
134
|
+
|
|
135
|
+
references = registry.get_all_references()
|
|
136
|
+
|
|
137
|
+
# Find references that originated from the document we just validated
|
|
138
|
+
# and haven't been processed yet
|
|
139
|
+
for ref in references:
|
|
140
|
+
if ref.source_document != doc_path:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
resolved_path = ref.resolve()
|
|
144
|
+
|
|
145
|
+
if registry.is_processed(resolved_path):
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
if resolved_path.exists() and ref.uri.type != URIType.ABSOLUTE:
|
|
149
|
+
to_process.append((resolved_path, ref.target_model.__name__))
|
|
150
|
+
else:
|
|
151
|
+
# File doesn't exist - record error
|
|
152
|
+
error_handler.register_log(
|
|
153
|
+
Log(
|
|
154
|
+
type="missing_reference",
|
|
155
|
+
loc=(ref.source_document.name,),
|
|
156
|
+
msg=f"Could not locate {ref.uri.type.value} URI",
|
|
157
|
+
input=ref.uri,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
references = registry.get_all_references()
|
|
162
|
+
|
|
163
|
+
return result, error_handler
|
|
164
|
+
|
|
165
|
+
|
|
68
166
|
def check(original: JSONObject, validated: BaseModel) -> bool:
|
|
69
167
|
"""
|
|
70
168
|
Runs a consistency check on the output of amati.
|
|
@@ -113,15 +211,10 @@ def run(
|
|
|
113
211
|
|
|
114
212
|
data = load_file(spec)
|
|
115
213
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
with Logger.context():
|
|
119
|
-
result, errors = dispatch(data)
|
|
120
|
-
logs.extend(Logger.logs)
|
|
121
|
-
|
|
122
|
-
if errors or logs:
|
|
123
|
-
handled_errors: list[JSONObject] = handle_errors(errors, logs)
|
|
214
|
+
result, handled_errors = dispatch_all(spec)
|
|
215
|
+
handled_errors.deduplicate()
|
|
124
216
|
|
|
217
|
+
if handled_errors.errors:
|
|
125
218
|
file_name = Path(Path(file_path).parts[-1])
|
|
126
219
|
error_file = file_name.with_suffix(file_name.suffix + ".errors")
|
|
127
220
|
error_path = spec.parent
|
|
@@ -137,7 +230,7 @@ def run(
|
|
|
137
230
|
)
|
|
138
231
|
|
|
139
232
|
with json_error_file.open("w", encoding="utf-8") as f:
|
|
140
|
-
f.write(json.dumps(handled_errors))
|
|
233
|
+
f.write(json.dumps(handled_errors.errors))
|
|
141
234
|
|
|
142
235
|
if html_report:
|
|
143
236
|
env = Environment(
|
|
@@ -147,7 +240,7 @@ def run(
|
|
|
147
240
|
template = env.get_template("TEMPLATE.html")
|
|
148
241
|
|
|
149
242
|
# Render the template with your data
|
|
150
|
-
html_output = template.render(errors=handled_errors)
|
|
243
|
+
html_output = template.render(errors=handled_errors.errors)
|
|
151
244
|
|
|
152
245
|
# Save the output to a file
|
|
153
246
|
|
amati/exceptions.py
CHANGED
|
@@ -10,7 +10,7 @@ class AmatiValueError(ValueError):
|
|
|
10
10
|
|
|
11
11
|
Attributes:
|
|
12
12
|
message (str): The explanation of why the exception was raised
|
|
13
|
-
|
|
13
|
+
reference_uri (str | None): The reference to the standard that
|
|
14
14
|
explains why the exception was raised
|
|
15
15
|
|
|
16
16
|
Inherits:
|
amati/fields/email.py
CHANGED
|
@@ -5,7 +5,7 @@ Validates an email according to the RFC5322 ABNF grammar - §3:
|
|
|
5
5
|
from abnf import ParseError
|
|
6
6
|
from abnf.grammars import rfc5322
|
|
7
7
|
|
|
8
|
-
from amati import AmatiValueError
|
|
8
|
+
from amati.exceptions import AmatiValueError
|
|
9
9
|
from amati.fields import Str as _Str
|
|
10
10
|
|
|
11
11
|
reference_uri = "https://www.rfc-editor.org/rfc/rfc5322#section-3"
|
|
@@ -9,7 +9,8 @@ or the numeric codes can be accessed via HTTPStatusCodeN.
|
|
|
9
9
|
import re
|
|
10
10
|
from typing import Self, cast
|
|
11
11
|
|
|
12
|
-
from amati import
|
|
12
|
+
from amati import get
|
|
13
|
+
from amati.exceptions import AmatiValueError
|
|
13
14
|
from amati.fields import Str as _Str
|
|
14
15
|
|
|
15
16
|
reference_uri = (
|
amati/fields/iso9110.py
CHANGED