Skip to content

Commit 80d76cc

Browse files
authored
fix: control recursive limit for references (#111)
1 parent 233ac4a commit 80d76cc

5 files changed

Lines changed: 146 additions & 4 deletions

File tree

src/openapi_parser/parser.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ def parse(
182182
uri: str | None = None,
183183
spec_string: str | None = None,
184184
strict_enum: bool = True,
185+
recursion_limit: int = 1,
185186
) -> Specification:
186187
"""Parse specification document by URL/filepath or as a string.
187188
@@ -191,8 +192,9 @@ def parse(
191192
strict_enum (bool): Validate content types and string formats against the
192193
enums defined in openapi-parser. Note that the OpenAPI specification allows
193194
for custom values in these properties.
195+
recursion_limit (int): Maximum recursion depth for resolving references
194196
"""
195-
resolver = OpenAPIResolver(uri, spec_string)
197+
resolver = OpenAPIResolver(uri, spec_string, recursion_limit=recursion_limit)
196198
specification = resolver.resolve()
197199

198200
parser = _create_parser(strict_enum=strict_enum)

src/openapi_parser/resolver.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,47 @@
1212
logger = logging.getLogger(__name__)
1313

1414

15+
def _default_recursion_limit_handler(
16+
limit: int,
17+
parsed_url: Any,
18+
_recursions: tuple[Any, ...] = (),
19+
) -> dict[str, str]:
20+
"""Log warning and return minimal schema for circular reference."""
21+
logger.warning(
22+
"Recursion limit of %d reached at %s. "
23+
"Replacing circular reference with placeholder schema.",
24+
limit,
25+
str(parsed_url),
26+
)
27+
return {"type": "object"}
28+
29+
1530
class OpenAPIResolver:
1631
"""Resolves and validates OpenAPI specs using prance."""
1732

1833
_resolver: prance.ResolvingParser
1934

20-
def __init__(self, uri: str | None, spec_string: str | None = None) -> None:
35+
def __init__(
36+
self,
37+
uri: str | None,
38+
spec_string: str | None = None,
39+
recursion_limit: int = 1,
40+
) -> None:
2141
"""Initialize resolver.
2242
2343
Args:
2444
uri: Path or URL to the spec file
2545
spec_string: Raw spec string as alternative to uri
46+
recursion_limit: Maximum recursion depth for resolving references
2647
"""
2748
self._resolver = prance.ResolvingParser(
2849
uri,
2950
spec_string=spec_string,
3051
backend=OPENAPI_SPEC_VALIDATOR,
3152
strict=False,
3253
lazy=True,
54+
recursion_limit=recursion_limit,
55+
recursion_limit_handler=_default_recursion_limit_handler,
3356
)
3457

3558
def resolve(self) -> dict[str, Any]:

tests/data/recursive.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
openapi: 3.0.0
2+
3+
info:
4+
title: Recursive schema test
5+
version: 1.0.0
6+
7+
paths:
8+
/test:
9+
get:
10+
summary: Test endpoint
11+
operationId: Test
12+
responses:
13+
200:
14+
description: OK
15+
16+
components:
17+
schemas:
18+
Equipment:
19+
title: Equipment
20+
type: object
21+
properties:
22+
Features:
23+
type: array
24+
items:
25+
$ref: '#/components/schemas/Feature'
26+
Id:
27+
type: integer
28+
format: int64
29+
30+
Feature:
31+
title: Feature
32+
type: object
33+
properties:
34+
Equipments:
35+
type: array
36+
items:
37+
$ref: '#/components/schemas/Equipment'
38+
Id:
39+
type: integer
40+
format: int64

tests/test_resolver.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
import pytest
55

66
from openapi_parser.errors import ParserError
7-
from openapi_parser.resolver import OpenAPIResolver
7+
from openapi_parser.resolver import (
8+
OpenAPIResolver,
9+
_default_recursion_limit_handler,
10+
)
811

912

1013
@mock.patch("openapi_parser.resolver.prance.ResolvingParser")
@@ -27,3 +30,25 @@ def test_resolve_generic_error(mock_resolving_parser: mock.MagicMock) -> None:
2730

2831
with pytest.raises(ParserError, match="OpenAPI file parsing error"):
2932
resolver.resolve()
33+
34+
35+
@mock.patch("openapi_parser.resolver.prance.ResolvingParser")
36+
def test_custom_recursion_limit(
37+
mock_resolving_parser: mock.MagicMock,
38+
) -> None:
39+
OpenAPIResolver("fake.yaml", recursion_limit=10)
40+
41+
mock_resolving_parser.assert_called_once_with(
42+
"fake.yaml",
43+
spec_string=None,
44+
backend=mock.ANY,
45+
strict=False,
46+
lazy=True,
47+
recursion_limit=10,
48+
recursion_limit_handler=_default_recursion_limit_handler,
49+
)
50+
51+
52+
def test_default_recursion_limit_handler_returns_placeholder() -> None:
53+
result = _default_recursion_limit_handler(1, "http://example.com#/test")
54+
assert result == {"type": "object"}

tests/test_runner.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import pytest
22

33
from openapi_parser import parse
4-
from openapi_parser.specification import Specification
4+
from openapi_parser.enumeration import DataType
5+
from openapi_parser.specification import Array, Object, Specification
56
from tests.openapi_fixture import create_specification
67

78

@@ -14,3 +15,54 @@ def test_run_parser(swagger_specification: Specification) -> None:
1415
actual_specification = parse("tests/data/swagger.yml")
1516

1617
assert actual_specification == swagger_specification
18+
19+
20+
def test_parse_recursive_schema() -> None:
21+
actual_specification = parse("tests/data/recursive.yml")
22+
23+
assert actual_specification.version == "3.0.0"
24+
assert actual_specification.info.title == "Recursive schema test"
25+
assert "Equipment" in actual_specification.schemas
26+
assert "Feature" in actual_specification.schemas
27+
28+
29+
def test_parse_recursive_schema_with_recursion_limit_2() -> None:
30+
spec = parse("tests/data/recursive.yml", recursion_limit=2)
31+
32+
equipment = spec.schemas["Equipment"]
33+
assert isinstance(equipment, Object)
34+
35+
features = equipment.properties[0]
36+
assert features.name == "Features"
37+
assert isinstance(features.schema, Array)
38+
39+
feature_level_1 = features.schema.items
40+
assert isinstance(feature_level_1, Object)
41+
42+
equipment_level_2_schema = feature_level_1.properties[0].schema
43+
assert isinstance(equipment_level_2_schema, Array)
44+
equipment_level_2 = equipment_level_2_schema.items
45+
assert isinstance(equipment_level_2, Object)
46+
assert equipment_level_2.type == DataType.OBJECT
47+
assert len(equipment_level_2.properties) == 2
48+
assert equipment_level_2.properties[0].name == "Features"
49+
50+
feature_level_3_schema = equipment_level_2.properties[0].schema
51+
assert isinstance(feature_level_3_schema, Array)
52+
feature_level_3 = feature_level_3_schema.items
53+
assert isinstance(feature_level_3, Object)
54+
assert len(feature_level_3.properties) == 2
55+
assert feature_level_3.properties[0].name == "Equipments"
56+
57+
equipment_level_4_schema = feature_level_3.properties[0].schema
58+
assert isinstance(equipment_level_4_schema, Array)
59+
equipment_level_4 = equipment_level_4_schema.items
60+
assert isinstance(equipment_level_4, Object)
61+
assert len(equipment_level_4.properties) == 2
62+
assert equipment_level_4.properties[0].name == "Features"
63+
64+
placeholder_schema = equipment_level_4.properties[0].schema
65+
assert isinstance(placeholder_schema, Array)
66+
placeholder = placeholder_schema.items
67+
assert isinstance(placeholder, Object)
68+
assert len(placeholder.properties) == 0

0 commit comments

Comments
 (0)