Skip to content

Commit edc0a57

Browse files
tercelclaude
andcommitted
feat: add public deep_resolve_refs() for recursive $ref resolution (0.4.1)
- Expose existing _deep_resolve_refs as public API, matching TypeScript deepResolveRefs. 12 new tests covering nested refs, allOf/anyOf/oneOf, circular ref depth limiting, and array items. - README: fix apcore dependency version (0.13.1 → 0.14.0), add 10 missing public API entries to Core Modules table. - Bump version to 0.4.1. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e3a8dff commit edc0a57

6 files changed

Lines changed: 141 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.4.1] - 2026-03-25
6+
7+
### Added
8+
9+
- **`deep_resolve_refs()`** — public API for recursive `$ref` resolution in OpenAPI schemas (previously internal `_deep_resolve_refs`). Resolves nested `allOf`/`anyOf`/`oneOf`, `items`, and `properties`. Depth-limited to 16 levels.
10+
11+
### Fixed
12+
13+
- README: apcore dependency version updated from `>= 0.13.1` to `>= 0.14.0` (matches pyproject.toml).
14+
- README: Core Modules table now lists all public API functions (added 10 missing entries).
15+
516
## [0.4.0] - 2026-03-23
617

718
### Added

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ pip install apcore-toolkit
4343
| `get_writer` | Factory function for writer instances |
4444
| `DisplayResolver` | Sparse binding.yaml display overlay — resolves surface-facing alias, description, guidance, tags into `metadata["display"]` (§5.13) |
4545
| `ConventionScanner` | Scans a `commands/` directory of plain Python files for public functions and converts them to `ScannedModule` instances with schema inferred from type annotations (§5.14) |
46+
| `extract_input_schema` | Merges OpenAPI query, path, and request body params into a single JSON Schema |
47+
| `extract_output_schema` | Extracts response schema from OpenAPI operation objects |
48+
| `resolve_ref` | Resolves a single internal `$ref` JSON pointer |
49+
| `resolve_schema` | Resolves a top-level `$ref` in a schema |
50+
| `deep_resolve_refs` | Recursively resolves all `$ref` pointers, depth-limited to 16 levels |
51+
| `annotations_to_dict` | Converts `ModuleAnnotations` to a plain dict |
52+
| `module_to_dict` | Converts a `ScannedModule` to a dict for JSON/YAML serialization |
53+
| `modules_to_dicts` | Batch version of `module_to_dict` |
54+
| `run_verifier_chain` | Runs multiple verifiers in sequence, stopping on first failure |
4655

4756
## Usage
4857

@@ -214,7 +223,7 @@ Input and output schemas are inferred from PEP 484 type annotations. Use `includ
214223
## Requirements
215224

216225
- Python >= 3.11
217-
- apcore >= 0.13.1
226+
- apcore >= 0.14.0
218227
- pydantic >= 2.0
219228
- PyYAML >= 6.0
220229

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "apcore-toolkit"
7-
version = "0.4.0"
7+
version = "0.4.1"
88
description = "Shared scanner, schema extraction, and output toolkit for apcore framework adapters"
99
requires-python = ">=3.11"
1010
readme = "README.md"

src/apcore_toolkit/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from apcore_toolkit.display import DisplayResolver
1010
from apcore_toolkit.formatting import to_markdown
1111
from apcore_toolkit.openapi import (
12+
deep_resolve_refs,
1213
extract_input_schema,
1314
extract_output_schema,
1415
resolve_ref,
@@ -62,6 +63,7 @@
6263
"YAMLVerifier",
6364
"YAMLWriter",
6465
"annotations_to_dict",
66+
"deep_resolve_refs",
6567
"enrich_schema_descriptions",
6668
"extract_input_schema",
6769
"extract_output_schema",

src/apcore_toolkit/openapi.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,27 @@ def _deep_resolve_refs(
8585
return result
8686

8787

88+
def deep_resolve_refs(
89+
schema: dict[str, Any],
90+
openapi_doc: dict[str, Any],
91+
depth: int = 0,
92+
) -> dict[str, Any]:
93+
"""Recursively resolve all ``$ref`` pointers in a schema.
94+
95+
Handles nested ``$ref``, ``allOf``, ``anyOf``, ``oneOf``, and ``items``.
96+
Depth-limited to 16 levels to prevent infinite recursion on circular refs.
97+
98+
Args:
99+
schema: A JSON Schema dict (possibly containing ``$ref`` pointers).
100+
openapi_doc: The full OpenAPI document dict.
101+
depth: Current recursion depth (callers should not set this).
102+
103+
Returns:
104+
A new schema dict with all ``$ref`` pointers resolved.
105+
"""
106+
return _deep_resolve_refs(schema, openapi_doc, depth)
107+
108+
88109
def extract_input_schema(
89110
operation: dict[str, Any],
90111
openapi_doc: dict[str, Any] | None = None,

tests/test_openapi.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from apcore_toolkit.openapi import (
66
_deep_resolve_refs,
7+
deep_resolve_refs,
78
extract_input_schema,
89
extract_output_schema,
910
resolve_ref,
@@ -168,6 +169,101 @@ def test_no_mutation_of_original(self) -> None:
168169
assert OPENAPI_DOC["components"]["schemas"]["Address"]["properties"] == original_props
169170

170171

172+
class TestDeepResolveRefsPublic:
173+
"""Tests for the public deep_resolve_refs wrapper."""
174+
175+
def test_top_level_ref(self) -> None:
176+
schema = {"$ref": "#/components/schemas/User"}
177+
result = deep_resolve_refs(schema, OPENAPI_DOC)
178+
assert result["type"] == "object"
179+
assert "id" in result["properties"]
180+
assert "name" in result["properties"]
181+
182+
def test_nested_ref_in_properties(self) -> None:
183+
schema = {
184+
"type": "object",
185+
"properties": {
186+
"address": {"$ref": "#/components/schemas/Address"},
187+
},
188+
}
189+
result = deep_resolve_refs(schema, OPENAPI_DOC)
190+
assert result["properties"]["address"]["type"] == "object"
191+
assert "street" in result["properties"]["address"]["properties"]
192+
193+
def test_ref_in_allof(self) -> None:
194+
schema = {
195+
"allOf": [
196+
{"$ref": "#/components/schemas/User"},
197+
{"type": "object", "properties": {"extra": {"type": "boolean"}}},
198+
]
199+
}
200+
result = deep_resolve_refs(schema, OPENAPI_DOC)
201+
assert result["allOf"][0]["type"] == "object"
202+
assert "id" in result["allOf"][0]["properties"]
203+
204+
def test_ref_in_anyof(self) -> None:
205+
schema = {
206+
"anyOf": [
207+
{"$ref": "#/components/schemas/User"},
208+
{"$ref": "#/components/schemas/Address"},
209+
]
210+
}
211+
result = deep_resolve_refs(schema, OPENAPI_DOC)
212+
assert "name" in result["anyOf"][0]["properties"]
213+
assert "street" in result["anyOf"][1]["properties"]
214+
215+
def test_ref_in_oneof(self) -> None:
216+
schema = {
217+
"oneOf": [
218+
{"$ref": "#/components/schemas/User"},
219+
{"$ref": "#/components/schemas/Address"},
220+
]
221+
}
222+
result = deep_resolve_refs(schema, OPENAPI_DOC)
223+
assert result["oneOf"][0]["type"] == "object"
224+
assert "name" in result["oneOf"][0]["properties"]
225+
assert "city" in result["oneOf"][1]["properties"]
226+
227+
def test_ref_in_array_items(self) -> None:
228+
schema = {"type": "array", "items": {"$ref": "#/components/schemas/User"}}
229+
result = deep_resolve_refs(schema, OPENAPI_DOC)
230+
assert result["items"]["type"] == "object"
231+
assert "id" in result["items"]["properties"]
232+
233+
def test_deeply_nested_ref(self) -> None:
234+
result = deep_resolve_refs({"$ref": "#/components/schemas/UserWithAddress"}, OPENAPI_DOC)
235+
assert result["properties"]["address"]["type"] == "object"
236+
assert "street" in result["properties"]["address"]["properties"]
237+
238+
def test_circular_ref_depth_limit(self) -> None:
239+
result = deep_resolve_refs({"$ref": "#/components/schemas/SelfRef"}, OPENAPI_DOC)
240+
assert result["type"] == "object"
241+
assert "child" in result["properties"]
242+
243+
def test_no_mutation_of_original(self) -> None:
244+
original_address = OPENAPI_DOC["components"]["schemas"]["Address"]
245+
original_props = dict(original_address.get("properties", {}))
246+
deep_resolve_refs({"$ref": "#/components/schemas/UserWithAddress"}, OPENAPI_DOC)
247+
assert OPENAPI_DOC["components"]["schemas"]["Address"]["properties"] == original_props
248+
249+
def test_custom_depth_parameter(self) -> None:
250+
"""Passing depth=17 should short-circuit immediately."""
251+
schema = {"$ref": "#/components/schemas/User"}
252+
result = deep_resolve_refs(schema, OPENAPI_DOC, depth=17)
253+
# Should return the unresolved schema since depth > 16
254+
assert "$ref" in result
255+
256+
def test_plain_schema_no_refs(self) -> None:
257+
schema = {"type": "string", "description": "A simple string"}
258+
result = deep_resolve_refs(schema, OPENAPI_DOC)
259+
assert result == {"type": "string", "description": "A simple string"}
260+
261+
def test_importable_from_package(self) -> None:
262+
from apcore_toolkit import deep_resolve_refs as public_fn
263+
264+
assert callable(public_fn)
265+
266+
171267
class TestExtractInputSchema:
172268
def test_query_and_path_params(self) -> None:
173269
operation = {

0 commit comments

Comments
 (0)