otterapi 0.0.5__py3-none-any.whl → 0.0.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- README.md +581 -8
- otterapi/__init__.py +73 -0
- otterapi/cli.py +327 -29
- otterapi/codegen/__init__.py +115 -0
- otterapi/codegen/ast_utils.py +134 -5
- otterapi/codegen/client.py +1271 -0
- otterapi/codegen/codegen.py +1736 -0
- otterapi/codegen/dataframes.py +392 -0
- otterapi/codegen/emitter.py +473 -0
- otterapi/codegen/endpoints.py +2597 -343
- otterapi/codegen/pagination.py +1026 -0
- otterapi/codegen/schema.py +593 -0
- otterapi/codegen/splitting.py +1397 -0
- otterapi/codegen/types.py +1345 -0
- otterapi/codegen/utils.py +180 -1
- otterapi/config.py +1017 -24
- otterapi/exceptions.py +231 -0
- otterapi/openapi/__init__.py +46 -0
- otterapi/openapi/v2/__init__.py +86 -0
- otterapi/openapi/v2/spec.json +1607 -0
- otterapi/openapi/v2/v2.py +1776 -0
- otterapi/openapi/v3/__init__.py +131 -0
- otterapi/openapi/v3/spec.json +1651 -0
- otterapi/openapi/v3/v3.py +1557 -0
- otterapi/openapi/v3_1/__init__.py +133 -0
- otterapi/openapi/v3_1/spec.json +1411 -0
- otterapi/openapi/v3_1/v3_1.py +798 -0
- otterapi/openapi/v3_2/__init__.py +133 -0
- otterapi/openapi/v3_2/spec.json +1666 -0
- otterapi/openapi/v3_2/v3_2.py +777 -0
- otterapi/tests/__init__.py +3 -0
- otterapi/tests/fixtures/__init__.py +455 -0
- otterapi/tests/test_ast_utils.py +680 -0
- otterapi/tests/test_codegen.py +610 -0
- otterapi/tests/test_dataframe.py +1038 -0
- otterapi/tests/test_exceptions.py +493 -0
- otterapi/tests/test_openapi_support.py +616 -0
- otterapi/tests/test_openapi_upgrade.py +215 -0
- otterapi/tests/test_pagination.py +1101 -0
- otterapi/tests/test_splitting_config.py +319 -0
- otterapi/tests/test_splitting_integration.py +427 -0
- otterapi/tests/test_splitting_resolver.py +512 -0
- otterapi/tests/test_splitting_tree.py +525 -0
- otterapi-0.0.6.dist-info/METADATA +627 -0
- otterapi-0.0.6.dist-info/RECORD +48 -0
- {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/WHEEL +1 -1
- otterapi/codegen/generator.py +0 -358
- otterapi/codegen/openapi_processor.py +0 -27
- otterapi/codegen/type_generator.py +0 -559
- otterapi-0.0.5.dist-info/METADATA +0 -54
- otterapi-0.0.5.dist-info/RECORD +0 -16
- {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""Test suite for OpenAPI version support and external reference resolution.
|
|
2
|
+
|
|
3
|
+
This module tests the SchemaLoader's ability to handle different OpenAPI versions,
|
|
4
|
+
automatic version detection, upgrade paths, and external $ref resolution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest.mock import MagicMock, patch
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from otterapi.codegen.schema import SchemaLoader
|
|
16
|
+
from otterapi.exceptions import SchemaLoadError, SchemaValidationError
|
|
17
|
+
|
|
18
|
+
# Sample OpenAPI specs for different versions
|
|
19
|
+
SWAGGER_20_SPEC = {
|
|
20
|
+
'swagger': '2.0',
|
|
21
|
+
'info': {'title': 'Swagger 2.0 API', 'version': '1.0.0'},
|
|
22
|
+
'host': 'api.example.com',
|
|
23
|
+
'basePath': '/v1',
|
|
24
|
+
'schemes': ['https'],
|
|
25
|
+
'paths': {
|
|
26
|
+
'/pets': {
|
|
27
|
+
'get': {
|
|
28
|
+
'operationId': 'listPets',
|
|
29
|
+
'produces': ['application/json'],
|
|
30
|
+
'responses': {
|
|
31
|
+
'200': {
|
|
32
|
+
'description': 'A list of pets',
|
|
33
|
+
'schema': {
|
|
34
|
+
'type': 'array',
|
|
35
|
+
'items': {'$ref': '#/definitions/Pet'},
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
'definitions': {
|
|
43
|
+
'Pet': {
|
|
44
|
+
'type': 'object',
|
|
45
|
+
'required': ['name'],
|
|
46
|
+
'properties': {
|
|
47
|
+
'id': {'type': 'integer', 'format': 'int64'},
|
|
48
|
+
'name': {'type': 'string'},
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
OPENAPI_30_SPEC = {
|
|
55
|
+
'openapi': '3.0.3',
|
|
56
|
+
'info': {'title': 'OpenAPI 3.0 API', 'version': '1.0.0'},
|
|
57
|
+
'paths': {
|
|
58
|
+
'/pets': {
|
|
59
|
+
'get': {
|
|
60
|
+
'operationId': 'listPets',
|
|
61
|
+
'responses': {
|
|
62
|
+
'200': {
|
|
63
|
+
'description': 'A list of pets',
|
|
64
|
+
'content': {
|
|
65
|
+
'application/json': {
|
|
66
|
+
'schema': {
|
|
67
|
+
'type': 'array',
|
|
68
|
+
'items': {'$ref': '#/components/schemas/Pet'},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
'components': {
|
|
78
|
+
'schemas': {
|
|
79
|
+
'Pet': {
|
|
80
|
+
'type': 'object',
|
|
81
|
+
'required': ['name'],
|
|
82
|
+
'properties': {
|
|
83
|
+
'id': {'type': 'integer', 'format': 'int64'},
|
|
84
|
+
'name': {'type': 'string'},
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
OPENAPI_31_SPEC = {
|
|
92
|
+
'openapi': '3.1.0',
|
|
93
|
+
'info': {'title': 'OpenAPI 3.1 API', 'version': '1.0.0', 'summary': 'A test API'},
|
|
94
|
+
'paths': {
|
|
95
|
+
'/pets': {
|
|
96
|
+
'get': {
|
|
97
|
+
'operationId': 'listPets',
|
|
98
|
+
'responses': {
|
|
99
|
+
'200': {
|
|
100
|
+
'description': 'A list of pets',
|
|
101
|
+
'content': {
|
|
102
|
+
'application/json': {
|
|
103
|
+
'schema': {
|
|
104
|
+
'type': 'array',
|
|
105
|
+
'items': {'$ref': '#/components/schemas/Pet'},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
'components': {
|
|
115
|
+
'schemas': {
|
|
116
|
+
'Pet': {
|
|
117
|
+
'type': 'object',
|
|
118
|
+
'required': ['name'],
|
|
119
|
+
'properties': {
|
|
120
|
+
'id': {'type': 'integer'},
|
|
121
|
+
'name': {'type': 'string'},
|
|
122
|
+
'tags': {'type': ['array', 'null'], 'items': {'type': 'string'}},
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestVersionDetection:
|
|
131
|
+
"""Tests for automatic OpenAPI version detection."""
|
|
132
|
+
|
|
133
|
+
def test_detect_swagger_20(self):
|
|
134
|
+
"""Test detection of Swagger 2.0 version."""
|
|
135
|
+
loader = SchemaLoader()
|
|
136
|
+
version = loader.get_detected_version(SWAGGER_20_SPEC)
|
|
137
|
+
assert version == '2.0'
|
|
138
|
+
|
|
139
|
+
def test_detect_openapi_30(self):
|
|
140
|
+
"""Test detection of OpenAPI 3.0 version."""
|
|
141
|
+
loader = SchemaLoader()
|
|
142
|
+
version = loader.get_detected_version(OPENAPI_30_SPEC)
|
|
143
|
+
assert version == '3.0'
|
|
144
|
+
|
|
145
|
+
def test_detect_openapi_31(self):
|
|
146
|
+
"""Test detection of OpenAPI 3.1 version."""
|
|
147
|
+
loader = SchemaLoader()
|
|
148
|
+
version = loader.get_detected_version(OPENAPI_31_SPEC)
|
|
149
|
+
assert version == '3.1'
|
|
150
|
+
|
|
151
|
+
def test_detect_openapi_32(self):
|
|
152
|
+
"""Test detection of OpenAPI 3.2 version."""
|
|
153
|
+
loader = SchemaLoader()
|
|
154
|
+
spec = {'openapi': '3.2.0', 'info': {'title': 'Test', 'version': '1.0'}}
|
|
155
|
+
version = loader.get_detected_version(spec)
|
|
156
|
+
assert version == '3.2'
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TestSwagger20Support:
|
|
160
|
+
"""Tests for Swagger 2.0 loading and upgrade."""
|
|
161
|
+
|
|
162
|
+
@pytest.fixture
|
|
163
|
+
def temp_dir(self):
|
|
164
|
+
"""Create a temporary directory."""
|
|
165
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
166
|
+
yield Path(tmpdir)
|
|
167
|
+
|
|
168
|
+
def test_load_swagger_20_json(self, temp_dir):
|
|
169
|
+
"""Test loading Swagger 2.0 from JSON file."""
|
|
170
|
+
spec_file = temp_dir / 'swagger.json'
|
|
171
|
+
spec_file.write_text(json.dumps(SWAGGER_20_SPEC))
|
|
172
|
+
|
|
173
|
+
loader = SchemaLoader()
|
|
174
|
+
schema = loader.load(str(spec_file))
|
|
175
|
+
|
|
176
|
+
# Should be upgraded to OpenAPI 3.2
|
|
177
|
+
assert hasattr(schema, 'openapi')
|
|
178
|
+
assert schema.openapi.startswith('3.')
|
|
179
|
+
|
|
180
|
+
def test_load_swagger_20_yaml(self, temp_dir):
|
|
181
|
+
"""Test loading Swagger 2.0 from YAML file."""
|
|
182
|
+
spec_file = temp_dir / 'swagger.yaml'
|
|
183
|
+
spec_file.write_text(yaml.dump(SWAGGER_20_SPEC))
|
|
184
|
+
|
|
185
|
+
loader = SchemaLoader()
|
|
186
|
+
schema = loader.load(str(spec_file))
|
|
187
|
+
|
|
188
|
+
# Should be upgraded to OpenAPI 3.2
|
|
189
|
+
assert hasattr(schema, 'openapi')
|
|
190
|
+
assert schema.openapi.startswith('3.')
|
|
191
|
+
|
|
192
|
+
def test_swagger_20_upgrade_preserves_paths(self, temp_dir):
|
|
193
|
+
"""Test that Swagger 2.0 upgrade preserves path definitions."""
|
|
194
|
+
spec_file = temp_dir / 'swagger.json'
|
|
195
|
+
spec_file.write_text(json.dumps(SWAGGER_20_SPEC))
|
|
196
|
+
|
|
197
|
+
loader = SchemaLoader()
|
|
198
|
+
schema = loader.load(str(spec_file))
|
|
199
|
+
|
|
200
|
+
# Check paths are preserved
|
|
201
|
+
assert schema.paths is not None
|
|
202
|
+
assert '/pets' in schema.paths.root
|
|
203
|
+
|
|
204
|
+
def test_swagger_20_upgrade_converts_definitions_to_components(self, temp_dir):
|
|
205
|
+
"""Test that Swagger 2.0 definitions are converted to components/schemas."""
|
|
206
|
+
spec_file = temp_dir / 'swagger.json'
|
|
207
|
+
spec_file.write_text(json.dumps(SWAGGER_20_SPEC))
|
|
208
|
+
|
|
209
|
+
loader = SchemaLoader()
|
|
210
|
+
schema = loader.load(str(spec_file))
|
|
211
|
+
|
|
212
|
+
# Check components/schemas exist
|
|
213
|
+
assert schema.components is not None
|
|
214
|
+
assert schema.components.schemas is not None
|
|
215
|
+
assert 'Pet' in schema.components.schemas
|
|
216
|
+
|
|
217
|
+
def test_swagger_20_upgrade_warnings(self, temp_dir):
|
|
218
|
+
"""Test that upgrade warnings are collected."""
|
|
219
|
+
spec_file = temp_dir / 'swagger.json'
|
|
220
|
+
spec_file.write_text(json.dumps(SWAGGER_20_SPEC))
|
|
221
|
+
|
|
222
|
+
loader = SchemaLoader()
|
|
223
|
+
loader.load(str(spec_file))
|
|
224
|
+
|
|
225
|
+
# Warnings should be available (may or may not be empty)
|
|
226
|
+
warnings = loader.get_upgrade_warnings()
|
|
227
|
+
assert isinstance(warnings, list)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class TestOpenAPI30Support:
|
|
231
|
+
"""Tests for OpenAPI 3.0 loading and upgrade."""
|
|
232
|
+
|
|
233
|
+
@pytest.fixture
|
|
234
|
+
def temp_dir(self):
|
|
235
|
+
"""Create a temporary directory."""
|
|
236
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
237
|
+
yield Path(tmpdir)
|
|
238
|
+
|
|
239
|
+
def test_load_openapi_30_json(self, temp_dir):
|
|
240
|
+
"""Test loading OpenAPI 3.0 from JSON file."""
|
|
241
|
+
spec_file = temp_dir / 'openapi.json'
|
|
242
|
+
spec_file.write_text(json.dumps(OPENAPI_30_SPEC))
|
|
243
|
+
|
|
244
|
+
loader = SchemaLoader()
|
|
245
|
+
schema = loader.load(str(spec_file))
|
|
246
|
+
|
|
247
|
+
assert schema is not None
|
|
248
|
+
assert schema.info.title == 'OpenAPI 3.0 API'
|
|
249
|
+
|
|
250
|
+
def test_load_openapi_30_yaml(self, temp_dir):
|
|
251
|
+
"""Test loading OpenAPI 3.0 from YAML file."""
|
|
252
|
+
spec_file = temp_dir / 'openapi.yaml'
|
|
253
|
+
spec_file.write_text(yaml.dump(OPENAPI_30_SPEC))
|
|
254
|
+
|
|
255
|
+
loader = SchemaLoader()
|
|
256
|
+
schema = loader.load(str(spec_file))
|
|
257
|
+
|
|
258
|
+
assert schema is not None
|
|
259
|
+
assert schema.info.title == 'OpenAPI 3.0 API'
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class TestOpenAPI31Support:
|
|
263
|
+
"""Tests for OpenAPI 3.1 loading and upgrade."""
|
|
264
|
+
|
|
265
|
+
@pytest.fixture
|
|
266
|
+
def temp_dir(self):
|
|
267
|
+
"""Create a temporary directory."""
|
|
268
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
269
|
+
yield Path(tmpdir)
|
|
270
|
+
|
|
271
|
+
def test_load_openapi_31_json(self, temp_dir):
|
|
272
|
+
"""Test loading OpenAPI 3.1 from JSON file."""
|
|
273
|
+
spec_file = temp_dir / 'openapi.json'
|
|
274
|
+
spec_file.write_text(json.dumps(OPENAPI_31_SPEC))
|
|
275
|
+
|
|
276
|
+
loader = SchemaLoader()
|
|
277
|
+
schema = loader.load(str(spec_file))
|
|
278
|
+
|
|
279
|
+
assert schema is not None
|
|
280
|
+
assert schema.info.title == 'OpenAPI 3.1 API'
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class TestYAMLSupport:
|
|
284
|
+
"""Tests for YAML file support."""
|
|
285
|
+
|
|
286
|
+
@pytest.fixture
|
|
287
|
+
def temp_dir(self):
|
|
288
|
+
"""Create a temporary directory."""
|
|
289
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
290
|
+
yield Path(tmpdir)
|
|
291
|
+
|
|
292
|
+
def test_load_yaml_file(self, temp_dir):
|
|
293
|
+
"""Test loading a YAML OpenAPI spec."""
|
|
294
|
+
spec_file = temp_dir / 'api.yaml'
|
|
295
|
+
spec_file.write_text(yaml.dump(OPENAPI_30_SPEC))
|
|
296
|
+
|
|
297
|
+
loader = SchemaLoader()
|
|
298
|
+
schema = loader.load(str(spec_file))
|
|
299
|
+
|
|
300
|
+
assert schema is not None
|
|
301
|
+
assert schema.info.title == 'OpenAPI 3.0 API'
|
|
302
|
+
|
|
303
|
+
def test_load_yml_extension(self, temp_dir):
|
|
304
|
+
"""Test loading with .yml extension."""
|
|
305
|
+
spec_file = temp_dir / 'api.yml'
|
|
306
|
+
spec_file.write_text(yaml.dump(OPENAPI_30_SPEC))
|
|
307
|
+
|
|
308
|
+
loader = SchemaLoader()
|
|
309
|
+
schema = loader.load(str(spec_file))
|
|
310
|
+
|
|
311
|
+
assert schema is not None
|
|
312
|
+
|
|
313
|
+
def test_yaml_with_anchors(self, temp_dir):
|
|
314
|
+
"""Test loading YAML with anchors and aliases."""
|
|
315
|
+
yaml_content = """
|
|
316
|
+
openapi: "3.0.3"
|
|
317
|
+
info:
|
|
318
|
+
title: Test API
|
|
319
|
+
version: "1.0.0"
|
|
320
|
+
paths:
|
|
321
|
+
/test:
|
|
322
|
+
get:
|
|
323
|
+
operationId: getTest
|
|
324
|
+
responses:
|
|
325
|
+
"200":
|
|
326
|
+
description: OK
|
|
327
|
+
content:
|
|
328
|
+
application/json:
|
|
329
|
+
schema:
|
|
330
|
+
type: object
|
|
331
|
+
properties:
|
|
332
|
+
id:
|
|
333
|
+
type: integer
|
|
334
|
+
"""
|
|
335
|
+
spec_file = temp_dir / 'api.yaml'
|
|
336
|
+
spec_file.write_text(yaml_content)
|
|
337
|
+
|
|
338
|
+
loader = SchemaLoader()
|
|
339
|
+
schema = loader.load(str(spec_file))
|
|
340
|
+
|
|
341
|
+
assert schema is not None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class TestExternalRefResolution:
|
|
345
|
+
"""Tests for external $ref resolution."""
|
|
346
|
+
|
|
347
|
+
@pytest.fixture
|
|
348
|
+
def temp_dir(self):
|
|
349
|
+
"""Create a temporary directory."""
|
|
350
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
351
|
+
yield Path(tmpdir)
|
|
352
|
+
|
|
353
|
+
def test_resolve_relative_file_ref(self, temp_dir):
|
|
354
|
+
"""Test resolving a relative file $ref."""
|
|
355
|
+
# Create the referenced schema file
|
|
356
|
+
pet_schema = {
|
|
357
|
+
'type': 'object',
|
|
358
|
+
'properties': {
|
|
359
|
+
'id': {'type': 'integer'},
|
|
360
|
+
'name': {'type': 'string'},
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
schemas_dir = temp_dir / 'schemas'
|
|
364
|
+
schemas_dir.mkdir()
|
|
365
|
+
(schemas_dir / 'Pet.json').write_text(json.dumps(pet_schema))
|
|
366
|
+
|
|
367
|
+
# Create main spec with external ref
|
|
368
|
+
main_spec = {
|
|
369
|
+
'openapi': '3.0.3',
|
|
370
|
+
'info': {'title': 'Test API', 'version': '1.0.0'},
|
|
371
|
+
'paths': {
|
|
372
|
+
'/pets': {
|
|
373
|
+
'get': {
|
|
374
|
+
'operationId': 'listPets',
|
|
375
|
+
'responses': {
|
|
376
|
+
'200': {
|
|
377
|
+
'description': 'OK',
|
|
378
|
+
'content': {
|
|
379
|
+
'application/json': {
|
|
380
|
+
'schema': {'$ref': './schemas/Pet.json'}
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
(temp_dir / 'api.json').write_text(json.dumps(main_spec))
|
|
390
|
+
|
|
391
|
+
# Load with external ref resolution
|
|
392
|
+
loader = SchemaLoader(resolve_external_refs=True, base_path=temp_dir)
|
|
393
|
+
schema = loader.load(str(temp_dir / 'api.json'))
|
|
394
|
+
|
|
395
|
+
assert schema is not None
|
|
396
|
+
|
|
397
|
+
def test_external_ref_disabled_by_default(self, temp_dir):
|
|
398
|
+
"""Test that external refs are not resolved by default."""
|
|
399
|
+
main_spec = {
|
|
400
|
+
'openapi': '3.0.3',
|
|
401
|
+
'info': {'title': 'Test API', 'version': '1.0.0'},
|
|
402
|
+
'paths': {},
|
|
403
|
+
'components': {'schemas': {'Pet': {'$ref': './external/Pet.json'}}},
|
|
404
|
+
}
|
|
405
|
+
(temp_dir / 'api.json').write_text(json.dumps(main_spec))
|
|
406
|
+
|
|
407
|
+
# Load without external ref resolution (default)
|
|
408
|
+
loader = SchemaLoader()
|
|
409
|
+
# This should load but keep the external ref as-is
|
|
410
|
+
# The validation might fail or pass depending on the spec
|
|
411
|
+
# Just test that it doesn't crash
|
|
412
|
+
try:
|
|
413
|
+
loader.load(str(temp_dir / 'api.json'))
|
|
414
|
+
except (SchemaLoadError, SchemaValidationError):
|
|
415
|
+
pass # Expected if external ref can't be validated
|
|
416
|
+
|
|
417
|
+
def test_resolve_ref_with_json_pointer(self, temp_dir):
|
|
418
|
+
"""Test resolving a ref with JSON pointer (file.json#/path/to/schema)."""
|
|
419
|
+
# Create shared definitions file
|
|
420
|
+
shared_defs = {
|
|
421
|
+
'definitions': {
|
|
422
|
+
'Pet': {
|
|
423
|
+
'type': 'object',
|
|
424
|
+
'properties': {
|
|
425
|
+
'id': {'type': 'integer'},
|
|
426
|
+
'name': {'type': 'string'},
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
(temp_dir / 'shared.json').write_text(json.dumps(shared_defs))
|
|
432
|
+
|
|
433
|
+
# Create main spec referencing with JSON pointer
|
|
434
|
+
main_spec = {
|
|
435
|
+
'openapi': '3.0.3',
|
|
436
|
+
'info': {'title': 'Test API', 'version': '1.0.0'},
|
|
437
|
+
'paths': {
|
|
438
|
+
'/pets': {
|
|
439
|
+
'get': {
|
|
440
|
+
'operationId': 'getPet',
|
|
441
|
+
'responses': {
|
|
442
|
+
'200': {
|
|
443
|
+
'description': 'OK',
|
|
444
|
+
'content': {
|
|
445
|
+
'application/json': {
|
|
446
|
+
'schema': {
|
|
447
|
+
'$ref': './shared.json#/definitions/Pet'
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
(temp_dir / 'api.json').write_text(json.dumps(main_spec))
|
|
458
|
+
|
|
459
|
+
loader = SchemaLoader(resolve_external_refs=True, base_path=temp_dir)
|
|
460
|
+
schema = loader.load(str(temp_dir / 'api.json'))
|
|
461
|
+
|
|
462
|
+
assert schema is not None
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class TestErrorHandling:
|
|
466
|
+
"""Tests for error handling in schema loading."""
|
|
467
|
+
|
|
468
|
+
@pytest.fixture
|
|
469
|
+
def temp_dir(self):
|
|
470
|
+
"""Create a temporary directory."""
|
|
471
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
472
|
+
yield Path(tmpdir)
|
|
473
|
+
|
|
474
|
+
def test_file_not_found(self, temp_dir):
|
|
475
|
+
"""Test error when file doesn't exist."""
|
|
476
|
+
loader = SchemaLoader()
|
|
477
|
+
|
|
478
|
+
with pytest.raises(SchemaLoadError) as exc_info:
|
|
479
|
+
loader.load(str(temp_dir / 'nonexistent.json'))
|
|
480
|
+
|
|
481
|
+
assert 'nonexistent.json' in str(exc_info.value)
|
|
482
|
+
|
|
483
|
+
def test_invalid_json(self, temp_dir):
|
|
484
|
+
"""Test error with invalid JSON."""
|
|
485
|
+
spec_file = temp_dir / 'invalid.json'
|
|
486
|
+
spec_file.write_text('not valid json {{{')
|
|
487
|
+
|
|
488
|
+
loader = SchemaLoader()
|
|
489
|
+
|
|
490
|
+
with pytest.raises(SchemaLoadError):
|
|
491
|
+
loader.load(str(spec_file))
|
|
492
|
+
|
|
493
|
+
def test_invalid_yaml(self, temp_dir):
|
|
494
|
+
"""Test error with invalid YAML."""
|
|
495
|
+
spec_file = temp_dir / 'invalid.yaml'
|
|
496
|
+
spec_file.write_text('foo: bar: baz: invalid')
|
|
497
|
+
|
|
498
|
+
loader = SchemaLoader()
|
|
499
|
+
|
|
500
|
+
with pytest.raises(SchemaLoadError):
|
|
501
|
+
loader.load(str(spec_file))
|
|
502
|
+
|
|
503
|
+
def test_invalid_openapi_schema(self, temp_dir):
|
|
504
|
+
"""Test error with invalid OpenAPI schema."""
|
|
505
|
+
spec_file = temp_dir / 'invalid.json'
|
|
506
|
+
# Missing required fields
|
|
507
|
+
spec_file.write_text(json.dumps({'foo': 'bar'}))
|
|
508
|
+
|
|
509
|
+
loader = SchemaLoader()
|
|
510
|
+
|
|
511
|
+
with pytest.raises((SchemaLoadError, SchemaValidationError)):
|
|
512
|
+
loader.load(str(spec_file))
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class TestURLLoading:
|
|
516
|
+
"""Tests for loading schemas from URLs."""
|
|
517
|
+
|
|
518
|
+
def test_url_detection(self):
|
|
519
|
+
"""Test URL detection logic."""
|
|
520
|
+
loader = SchemaLoader()
|
|
521
|
+
|
|
522
|
+
assert loader._is_url('https://api.example.com/openapi.json')
|
|
523
|
+
assert loader._is_url('http://localhost:8080/api.yaml')
|
|
524
|
+
assert not loader._is_url('./api.json')
|
|
525
|
+
assert not loader._is_url('/absolute/path/api.json')
|
|
526
|
+
assert not loader._is_url('relative/path/api.json')
|
|
527
|
+
|
|
528
|
+
@patch('httpx.get')
|
|
529
|
+
def test_load_from_url(self, mock_get):
|
|
530
|
+
"""Test loading schema from URL."""
|
|
531
|
+
mock_response = MagicMock()
|
|
532
|
+
mock_response.text = json.dumps(OPENAPI_30_SPEC)
|
|
533
|
+
mock_response.headers = {'content-type': 'application/json'}
|
|
534
|
+
mock_response.raise_for_status = MagicMock()
|
|
535
|
+
mock_get.return_value = mock_response
|
|
536
|
+
|
|
537
|
+
loader = SchemaLoader()
|
|
538
|
+
schema = loader.load('https://api.example.com/openapi.json')
|
|
539
|
+
|
|
540
|
+
assert schema is not None
|
|
541
|
+
assert schema.info.title == 'OpenAPI 3.0 API'
|
|
542
|
+
mock_get.assert_called_once()
|
|
543
|
+
|
|
544
|
+
@patch('httpx.get')
|
|
545
|
+
def test_load_yaml_from_url(self, mock_get):
|
|
546
|
+
"""Test loading YAML schema from URL."""
|
|
547
|
+
mock_response = MagicMock()
|
|
548
|
+
mock_response.text = yaml.dump(OPENAPI_30_SPEC)
|
|
549
|
+
mock_response.headers = {'content-type': 'application/x-yaml'}
|
|
550
|
+
mock_response.raise_for_status = MagicMock()
|
|
551
|
+
mock_get.return_value = mock_response
|
|
552
|
+
|
|
553
|
+
loader = SchemaLoader()
|
|
554
|
+
schema = loader.load('https://api.example.com/openapi.yaml')
|
|
555
|
+
|
|
556
|
+
assert schema is not None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
class TestCaching:
|
|
560
|
+
"""Tests for caching behavior."""
|
|
561
|
+
|
|
562
|
+
@pytest.fixture
|
|
563
|
+
def temp_dir(self):
|
|
564
|
+
"""Create a temporary directory."""
|
|
565
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
566
|
+
yield Path(tmpdir)
|
|
567
|
+
|
|
568
|
+
def test_external_ref_caching(self, temp_dir):
|
|
569
|
+
"""Test that external refs are cached."""
|
|
570
|
+
# Create shared schema
|
|
571
|
+
pet_schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}}
|
|
572
|
+
(temp_dir / 'Pet.json').write_text(json.dumps(pet_schema))
|
|
573
|
+
|
|
574
|
+
# Create spec that references Pet twice
|
|
575
|
+
main_spec = {
|
|
576
|
+
'openapi': '3.0.3',
|
|
577
|
+
'info': {'title': 'Test', 'version': '1.0'},
|
|
578
|
+
'paths': {
|
|
579
|
+
'/pets': {
|
|
580
|
+
'get': {
|
|
581
|
+
'operationId': 'getPets',
|
|
582
|
+
'responses': {
|
|
583
|
+
'200': {
|
|
584
|
+
'description': 'OK',
|
|
585
|
+
'content': {
|
|
586
|
+
'application/json': {
|
|
587
|
+
'schema': {
|
|
588
|
+
'type': 'array',
|
|
589
|
+
'items': {'$ref': './Pet.json'},
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
'post': {
|
|
597
|
+
'operationId': 'createPet',
|
|
598
|
+
'requestBody': {
|
|
599
|
+
'content': {
|
|
600
|
+
'application/json': {'schema': {'$ref': './Pet.json'}}
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
'responses': {'201': {'description': 'Created'}},
|
|
604
|
+
},
|
|
605
|
+
}
|
|
606
|
+
},
|
|
607
|
+
}
|
|
608
|
+
(temp_dir / 'api.json').write_text(json.dumps(main_spec))
|
|
609
|
+
|
|
610
|
+
loader = SchemaLoader(resolve_external_refs=True, base_path=temp_dir)
|
|
611
|
+
|
|
612
|
+
# Load the spec to trigger external ref resolution
|
|
613
|
+
loader.load(str(temp_dir / 'api.json'))
|
|
614
|
+
|
|
615
|
+
# Should have cached the Pet.json file
|
|
616
|
+
assert len(loader._external_cache) >= 1
|