Skip to content

Commit bed940f

Browse files
committed
feat: Add HTTP proxy registry writer, enhance OpenAPI schema resolution, and introduce an Enhancer protocol.
1 parent e7e2322 commit bed940f

11 files changed

Lines changed: 680 additions & 10 deletions

File tree

CHANGELOG.md

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

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

5+
## [0.3.0] - 2026-03-19
6+
7+
### Added
8+
9+
- `_deep_resolve_refs()` — recursive `$ref` resolution for nested OpenAPI schemas,
10+
handling `allOf`/`anyOf`/`oneOf`, `items`, and `properties`. Depth-limited to 16
11+
levels to prevent infinite recursion on circular references.
12+
- `Enhancer` protocol — pluggable interface for metadata enhancement, allowing
13+
custom enhancers beyond the built-in `AIEnhancer`.
14+
- `HTTPProxyRegistryWriter` — registers scanned modules as HTTP proxy classes
15+
that forward requests to a running web API. Supports path parameter substitution,
16+
pluggable auth headers, and `2xx` success range (with `204` returning `{}`).
17+
Requires optional `httpx` dependency (`pip install apcore-toolkit[http-proxy]`).
18+
- `get_writer("http-proxy", base_url=...)` — factory support for the new
19+
HTTP proxy writer with `**kwargs` forwarding.
20+
- Expanded `__init__.py` public API: exports `Enhancer`, `HTTPProxyRegistryWriter`,
21+
`WriteError`, `Verifier`, `VerifyResult`, verifier classes, serializer functions,
22+
`resolve_ref`, `resolve_schema`, `extract_input_schema`, `extract_output_schema`,
23+
and `run_verifier_chain`.
24+
25+
### Fixed
26+
27+
- `extract_output_schema()` — now recursively resolves all nested `$ref` pointers
28+
(previously only handled the shallow case of array items with `$ref`).
29+
- `extract_input_schema()` — now recursively resolves `$ref` inside individual
30+
properties after assembly.
31+
- `get_writer()` return type annotation now includes `HTTPProxyRegistryWriter`.
32+
33+
### Tests
34+
35+
- 272 tests (up from 260), all passing
36+
- Added `TestDeepResolveRefs` (8 tests): top-level ref, nested properties,
37+
allOf/anyOf, array items, deeply nested refs, circular ref depth limit,
38+
immutability guarantee
39+
- Added nested `$ref` tests for `extract_input_schema` and `extract_output_schema`
40+
- Added `test_http_proxy` for `get_writer("http-proxy")` factory
41+
42+
---
43+
544
## [0.2.0] - 2026-03-11
645

746
### Added

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ pip install apcore-toolkit
2424
| `YAMLWriter` | Generates `.binding.yaml` files for `apcore.BindingLoader` |
2525
| `PythonWriter` | Generates `@module`-decorated Python wrapper files |
2626
| `RegistryWriter` | Registers modules directly into an `apcore.Registry` |
27+
| `HTTPProxyRegistryWriter` | Registers HTTP proxy modules that forward requests to a running API |
28+
| `Enhancer` | Pluggable protocol for metadata enhancement |
2729
| `AIEnhancer` | SLM-based metadata enhancement for scanned modules |
2830
| `WriteResult` | Structured result type for all writer operations |
2931
| `WriteError` | Error class for I/O failures during write |

pyproject.toml

Lines changed: 3 additions & 2 deletions
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.2.0"
7+
version = "0.3.0"
88
description = "Shared scanner, schema extraction, and output toolkit for apcore framework adapters"
99
requires-python = ">=3.11"
1010
readme = "README.md"
@@ -23,12 +23,13 @@ keywords = [
2323
"toolkit",
2424
]
2525
dependencies = [
26-
"apcore>=0.13.0",
26+
"apcore>=0.13.1",
2727
"pydantic>=2.0",
2828
"PyYAML>=6.0",
2929
]
3030

3131
[project.optional-dependencies]
32+
http-proxy = ["httpx>=0.24"]
3233
dev = [
3334
"pytest>=7.0",
3435
"pytest-cov>=4.0",

src/apcore_toolkit/__init__.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,33 @@
55

66
from importlib.metadata import PackageNotFoundError
77
from importlib.metadata import version as _get_version
8-
from apcore_toolkit.ai_enhancer import AIEnhancer
8+
from apcore_toolkit.ai_enhancer import AIEnhancer, Enhancer
99
from apcore_toolkit.formatting import to_markdown
10+
from apcore_toolkit.openapi import (
11+
extract_input_schema,
12+
extract_output_schema,
13+
resolve_ref,
14+
resolve_schema,
15+
)
1016
from apcore_toolkit.output import get_writer
17+
from apcore_toolkit.output.errors import WriteError
1118
from apcore_toolkit.output.python_writer import PythonWriter
19+
from apcore_toolkit.output.http_proxy_writer import HTTPProxyRegistryWriter
1220
from apcore_toolkit.output.registry_writer import RegistryWriter
13-
from apcore_toolkit.output.types import WriteResult
21+
from apcore_toolkit.output.types import Verifier, VerifyResult, WriteResult
22+
from apcore_toolkit.output.verifiers import (
23+
JSONVerifier,
24+
MagicBytesVerifier,
25+
RegistryVerifier,
26+
SyntaxVerifier,
27+
YAMLVerifier,
28+
run_verifier_chain,
29+
)
1430
from apcore_toolkit.output.yaml_writer import YAMLWriter
1531
from apcore_toolkit.pydantic_utils import flatten_pydantic_params, resolve_target
1632
from apcore_toolkit.scanner import BaseScanner
1733
from apcore_toolkit.schema_utils import enrich_schema_descriptions
34+
from apcore_toolkit.serializers import annotations_to_dict, module_to_dict, modules_to_dicts
1835
from apcore_toolkit.types import ScannedModule
1936

2037
try:
@@ -25,14 +42,32 @@
2542
__all__ = [
2643
"AIEnhancer",
2744
"BaseScanner",
45+
"HTTPProxyRegistryWriter",
46+
"Enhancer",
47+
"JSONVerifier",
48+
"MagicBytesVerifier",
2849
"PythonWriter",
50+
"RegistryVerifier",
2951
"RegistryWriter",
3052
"ScannedModule",
53+
"SyntaxVerifier",
54+
"Verifier",
55+
"VerifyResult",
56+
"WriteError",
3157
"WriteResult",
58+
"YAMLVerifier",
3259
"YAMLWriter",
60+
"annotations_to_dict",
3361
"enrich_schema_descriptions",
62+
"extract_input_schema",
63+
"extract_output_schema",
3464
"flatten_pydantic_params",
3565
"get_writer",
66+
"module_to_dict",
67+
"modules_to_dicts",
68+
"resolve_ref",
69+
"resolve_schema",
3670
"resolve_target",
71+
"run_verifier_chain",
3772
"to_markdown",
3873
]

src/apcore_toolkit/ai_enhancer.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import logging
1515
import os
1616
from dataclasses import replace
17-
from typing import Any
17+
from typing import Any, Protocol
1818

1919
from apcore import ModuleAnnotations
2020

@@ -30,6 +30,16 @@
3030
_DEFAULT_ANNOTATIONS = ModuleAnnotations()
3131

3232

33+
class Enhancer(Protocol):
34+
"""Protocol for pluggable metadata enhancement.
35+
36+
Any class implementing this protocol can be used to fill metadata gaps
37+
in scanned modules. See the AI Enhancement Guide for details.
38+
"""
39+
40+
def enhance(self, modules: list[ScannedModule]) -> list[ScannedModule]: ...
41+
42+
3343
class AIEnhancer:
3444
"""Enhances ScannedModule metadata using a local SLM.
3545

src/apcore_toolkit/openapi.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,43 @@ def resolve_schema(
4848
return schema
4949

5050

51+
def _deep_resolve_refs(
52+
schema: dict[str, Any],
53+
openapi_doc: dict[str, Any],
54+
_depth: int = 0,
55+
) -> dict[str, Any]:
56+
"""Recursively resolve all ``$ref`` pointers in a schema.
57+
58+
Handles nested ``$ref``, ``allOf``, ``anyOf``, ``oneOf``, and ``items``.
59+
Depth-limited to 16 levels to prevent infinite recursion.
60+
"""
61+
if _depth > 16:
62+
return schema
63+
64+
if "$ref" in schema:
65+
resolved = resolve_ref(schema["$ref"], openapi_doc)
66+
return _deep_resolve_refs(resolved, openapi_doc, _depth + 1)
67+
68+
result = dict(schema)
69+
70+
# Resolve inside allOf/anyOf/oneOf
71+
for key in ("allOf", "anyOf", "oneOf"):
72+
if key in result and isinstance(result[key], list):
73+
result[key] = [_deep_resolve_refs(item, openapi_doc, _depth + 1) for item in result[key]]
74+
75+
# Resolve array items
76+
if "items" in result and isinstance(result["items"], dict):
77+
result["items"] = _deep_resolve_refs(result["items"], openapi_doc, _depth + 1)
78+
79+
# Resolve nested properties
80+
if "properties" in result and isinstance(result["properties"], dict):
81+
result["properties"] = {
82+
k: _deep_resolve_refs(v, openapi_doc, _depth + 1) for k, v in result["properties"].items()
83+
}
84+
85+
return result
86+
87+
5188
def extract_input_schema(
5289
operation: dict[str, Any],
5390
openapi_doc: dict[str, Any] | None = None,
@@ -90,6 +127,11 @@ def extract_input_schema(
90127
schema["properties"].update(body_schema.get("properties", {}))
91128
schema["required"].extend(body_schema.get("required", []))
92129

130+
# Recursively resolve $ref inside individual properties
131+
if openapi_doc:
132+
for prop_name, prop_schema in list(schema["properties"].items()):
133+
schema["properties"][prop_name] = _deep_resolve_refs(prop_schema, openapi_doc)
134+
93135
return schema
94136

95137

@@ -114,9 +156,9 @@ def extract_output_schema(
114156
if "schema" in json_content:
115157
schema: dict[str, Any] = json_content["schema"]
116158
schema = resolve_schema(schema, openapi_doc)
117-
# Handle array with $ref items
118-
if schema.get("type") == "array" and "$ref" in schema.get("items", {}):
119-
schema["items"] = resolve_schema(schema["items"], openapi_doc)
159+
# Recursively resolve all nested $ref pointers
160+
if openapi_doc:
161+
schema = _deep_resolve_refs(schema, openapi_doc)
120162
return schema
121163

122164
return {"type": "object", "properties": {}}

src/apcore_toolkit/output/__init__.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55

66
from __future__ import annotations
77

8+
from typing import TYPE_CHECKING, Any
9+
10+
if TYPE_CHECKING:
11+
from apcore_toolkit.output.http_proxy_writer import HTTPProxyRegistryWriter
12+
813
from apcore_toolkit.output.errors import WriteError as WriteError
914
from apcore_toolkit.output.python_writer import PythonWriter
1015
from apcore_toolkit.output.registry_writer import RegistryWriter
@@ -19,11 +24,16 @@
1924
from apcore_toolkit.output.yaml_writer import YAMLWriter
2025

2126

22-
def get_writer(output_format: str) -> YAMLWriter | PythonWriter | RegistryWriter:
27+
def get_writer(
28+
output_format: str, **kwargs: Any
29+
) -> YAMLWriter | PythonWriter | RegistryWriter | HTTPProxyRegistryWriter:
2330
"""Return a writer instance for the given output format.
2431
2532
Args:
26-
output_format: Output format name (``"yaml"``, ``"python"``, or ``"registry"``).
33+
output_format: Output format name (``"yaml"``, ``"python"``,
34+
``"registry"``, or ``"http-proxy"``).
35+
**kwargs: Passed to the writer constructor. For ``"http-proxy"``:
36+
``base_url``, ``auth_header_factory``, ``timeout``.
2737
2838
Returns:
2939
A writer instance.
@@ -37,4 +47,8 @@ def get_writer(output_format: str) -> YAMLWriter | PythonWriter | RegistryWriter
3747
return PythonWriter()
3848
if output_format == "registry":
3949
return RegistryWriter()
50+
if output_format == "http-proxy":
51+
from apcore_toolkit.output.http_proxy_writer import HTTPProxyRegistryWriter
52+
53+
return HTTPProxyRegistryWriter(**kwargs)
4054
raise ValueError(f"Unknown output format: {output_format!r}")

0 commit comments

Comments
 (0)