apcore-toolkit 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (45) hide show
  1. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/CHANGELOG.md +39 -0
  2. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/PKG-INFO +7 -2
  3. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/README.md +2 -0
  4. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/pyproject.toml +4 -2
  5. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/__init__.py +37 -2
  6. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/ai_enhancer.py +11 -1
  7. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/openapi.py +45 -3
  8. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/__init__.py +16 -2
  9. apcore_toolkit-0.3.0/src/apcore_toolkit/output/http_proxy_writer.py +185 -0
  10. apcore_toolkit-0.3.0/tests/test_http_proxy_writer.py +204 -0
  11. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_openapi.py +130 -0
  12. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_output_factory.py +5 -0
  13. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/.github/CODEOWNERS +0 -0
  14. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/.github/copilot-ignore +0 -0
  15. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/.github/workflows/ci.yml +0 -0
  16. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/.gitignore +0 -0
  17. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/.gitmessage +0 -0
  18. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/.pre-commit-config.yaml +0 -0
  19. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/formatting/__init__.py +0 -0
  20. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/formatting/markdown.py +0 -0
  21. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/errors.py +0 -0
  22. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/python_writer.py +0 -0
  23. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/registry_writer.py +0 -0
  24. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/types.py +0 -0
  25. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/verifiers.py +0 -0
  26. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/yaml_writer.py +0 -0
  27. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/pydantic_utils.py +0 -0
  28. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/scanner.py +0 -0
  29. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/schema_utils.py +0 -0
  30. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/serializers.py +0 -0
  31. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/types.py +0 -0
  32. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/conftest.py +0 -0
  33. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_ai_enhancer.py +0 -0
  34. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_markdown.py +0 -0
  35. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_pydantic_utils.py +0 -0
  36. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_python_writer.py +0 -0
  37. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_registry_writer.py +0 -0
  38. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_scanner.py +0 -0
  39. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_schema_utils.py +0 -0
  40. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_serializers.py +0 -0
  41. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_types.py +0 -0
  42. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_verifiers.py +0 -0
  43. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_write_error.py +0 -0
  44. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_write_result.py +0 -0
  45. {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_yaml_writer.py +0 -0
@@ -2,6 +2,45 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
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
+
5
44
  ## [0.2.0] - 2026-03-11
6
45
 
7
46
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apcore-toolkit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Shared scanner, schema extraction, and output toolkit for apcore framework adapters
5
5
  Project-URL: Homepage, https://aipartnerup.com
6
6
  Project-URL: Repository, https://github.com/aipartnerup/apcore-toolkit-python
@@ -10,15 +10,18 @@ Author-email: aipartnerup <tercel.yi@gmail.com>
10
10
  License-Expression: Apache-2.0
11
11
  Keywords: apcore,mcp,openapi,pydantic,scanner,schema,toolkit,yaml
12
12
  Requires-Python: >=3.11
13
- Requires-Dist: apcore>=0.13.0
13
+ Requires-Dist: apcore>=0.13.1
14
14
  Requires-Dist: pydantic>=2.0
15
15
  Requires-Dist: pyyaml>=6.0
16
16
  Provides-Extra: dev
17
17
  Requires-Dist: apdev[dev]>=0.2.1; extra == 'dev'
18
+ Requires-Dist: httpx>=0.24; extra == 'dev'
18
19
  Requires-Dist: mypy>=1.0; extra == 'dev'
19
20
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
20
21
  Requires-Dist: pytest>=7.0; extra == 'dev'
21
22
  Requires-Dist: ruff>=0.1; extra == 'dev'
23
+ Provides-Extra: http-proxy
24
+ Requires-Dist: httpx>=0.24; extra == 'http-proxy'
22
25
  Description-Content-Type: text/markdown
23
26
 
24
27
  <div align="center">
@@ -47,6 +50,8 @@ pip install apcore-toolkit
47
50
  | `YAMLWriter` | Generates `.binding.yaml` files for `apcore.BindingLoader` |
48
51
  | `PythonWriter` | Generates `@module`-decorated Python wrapper files |
49
52
  | `RegistryWriter` | Registers modules directly into an `apcore.Registry` |
53
+ | `HTTPProxyRegistryWriter` | Registers HTTP proxy modules that forward requests to a running API |
54
+ | `Enhancer` | Pluggable protocol for metadata enhancement |
50
55
  | `AIEnhancer` | SLM-based metadata enhancement for scanned modules |
51
56
  | `WriteResult` | Structured result type for all writer operations |
52
57
  | `WriteError` | Error class for I/O failures during write |
@@ -24,6 +24,8 @@ pip install apcore-toolkit
24
24
  | `YAMLWriter` | Generates `.binding.yaml` files for `apcore.BindingLoader` |
25
25
  | `PythonWriter` | Generates `@module`-decorated Python wrapper files |
26
26
  | `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 |
27
29
  | `AIEnhancer` | SLM-based metadata enhancement for scanned modules |
28
30
  | `WriteResult` | Structured result type for all writer operations |
29
31
  | `WriteError` | Error class for I/O failures during write |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apcore-toolkit"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Shared scanner, schema extraction, and output toolkit for apcore framework adapters"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -23,18 +23,20 @@ keywords = [
23
23
  "toolkit",
24
24
  ]
25
25
  dependencies = [
26
- "apcore>=0.13.0",
26
+ "apcore>=0.13.1",
27
27
  "pydantic>=2.0",
28
28
  "PyYAML>=6.0",
29
29
  ]
30
30
 
31
31
  [project.optional-dependencies]
32
+ http-proxy = ["httpx>=0.24"]
32
33
  dev = [
33
34
  "pytest>=7.0",
34
35
  "pytest-cov>=4.0",
35
36
  "ruff>=0.1",
36
37
  "mypy>=1.0",
37
38
  "apdev[dev]>=0.2.1",
39
+ "httpx>=0.24",
38
40
  ]
39
41
 
40
42
  [project.urls]
@@ -5,16 +5,33 @@ Public API re-exports for convenient access to core types and utilities.
5
5
 
6
6
  from importlib.metadata import PackageNotFoundError
7
7
  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
9
9
  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
+ )
10
16
  from apcore_toolkit.output import get_writer
17
+ from apcore_toolkit.output.errors import WriteError
11
18
  from apcore_toolkit.output.python_writer import PythonWriter
19
+ from apcore_toolkit.output.http_proxy_writer import HTTPProxyRegistryWriter
12
20
  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
+ )
14
30
  from apcore_toolkit.output.yaml_writer import YAMLWriter
15
31
  from apcore_toolkit.pydantic_utils import flatten_pydantic_params, resolve_target
16
32
  from apcore_toolkit.scanner import BaseScanner
17
33
  from apcore_toolkit.schema_utils import enrich_schema_descriptions
34
+ from apcore_toolkit.serializers import annotations_to_dict, module_to_dict, modules_to_dicts
18
35
  from apcore_toolkit.types import ScannedModule
19
36
 
20
37
  try:
@@ -25,14 +42,32 @@ except PackageNotFoundError:
25
42
  __all__ = [
26
43
  "AIEnhancer",
27
44
  "BaseScanner",
45
+ "HTTPProxyRegistryWriter",
46
+ "Enhancer",
47
+ "JSONVerifier",
48
+ "MagicBytesVerifier",
28
49
  "PythonWriter",
50
+ "RegistryVerifier",
29
51
  "RegistryWriter",
30
52
  "ScannedModule",
53
+ "SyntaxVerifier",
54
+ "Verifier",
55
+ "VerifyResult",
56
+ "WriteError",
31
57
  "WriteResult",
58
+ "YAMLVerifier",
32
59
  "YAMLWriter",
60
+ "annotations_to_dict",
33
61
  "enrich_schema_descriptions",
62
+ "extract_input_schema",
63
+ "extract_output_schema",
34
64
  "flatten_pydantic_params",
35
65
  "get_writer",
66
+ "module_to_dict",
67
+ "modules_to_dicts",
68
+ "resolve_ref",
69
+ "resolve_schema",
36
70
  "resolve_target",
71
+ "run_verifier_chain",
37
72
  "to_markdown",
38
73
  ]
@@ -14,7 +14,7 @@ import json
14
14
  import logging
15
15
  import os
16
16
  from dataclasses import replace
17
- from typing import Any
17
+ from typing import Any, Protocol
18
18
 
19
19
  from apcore import ModuleAnnotations
20
20
 
@@ -30,6 +30,16 @@ _DEFAULT_TIMEOUT = 30
30
30
  _DEFAULT_ANNOTATIONS = ModuleAnnotations()
31
31
 
32
32
 
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
+
33
43
  class AIEnhancer:
34
44
  """Enhances ScannedModule metadata using a local SLM.
35
45
 
@@ -48,6 +48,43 @@ def resolve_schema(
48
48
  return schema
49
49
 
50
50
 
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
+
51
88
  def extract_input_schema(
52
89
  operation: dict[str, Any],
53
90
  openapi_doc: dict[str, Any] | None = None,
@@ -90,6 +127,11 @@ def extract_input_schema(
90
127
  schema["properties"].update(body_schema.get("properties", {}))
91
128
  schema["required"].extend(body_schema.get("required", []))
92
129
 
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
+
93
135
  return schema
94
136
 
95
137
 
@@ -114,9 +156,9 @@ def extract_output_schema(
114
156
  if "schema" in json_content:
115
157
  schema: dict[str, Any] = json_content["schema"]
116
158
  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)
120
162
  return schema
121
163
 
122
164
  return {"type": "object", "properties": {}}
@@ -5,6 +5,11 @@ Provides a factory function to obtain a writer by format name.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ if TYPE_CHECKING:
11
+ from apcore_toolkit.output.http_proxy_writer import HTTPProxyRegistryWriter
12
+
8
13
  from apcore_toolkit.output.errors import WriteError as WriteError
9
14
  from apcore_toolkit.output.python_writer import PythonWriter
10
15
  from apcore_toolkit.output.registry_writer import RegistryWriter
@@ -19,11 +24,16 @@ from apcore_toolkit.output.verifiers import YAMLVerifier as YAMLVerifier
19
24
  from apcore_toolkit.output.yaml_writer import YAMLWriter
20
25
 
21
26
 
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:
23
30
  """Return a writer instance for the given output format.
24
31
 
25
32
  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``.
27
37
 
28
38
  Returns:
29
39
  A writer instance.
@@ -37,4 +47,8 @@ def get_writer(output_format: str) -> YAMLWriter | PythonWriter | RegistryWriter
37
47
  return PythonWriter()
38
48
  if output_format == "registry":
39
49
  return RegistryWriter()
50
+ if output_format == "http-proxy":
51
+ from apcore_toolkit.output.http_proxy_writer import HTTPProxyRegistryWriter
52
+
53
+ return HTTPProxyRegistryWriter(**kwargs)
40
54
  raise ValueError(f"Unknown output format: {output_format!r}")
@@ -0,0 +1,185 @@
1
+ """HTTP proxy registry writer.
2
+
3
+ Registers scanned modules as HTTP proxy classes that forward requests
4
+ to a running web API. This enables CLI execution without invoking
5
+ route handlers directly (which depend on framework DI systems).
6
+
7
+ Requires the ``httpx`` optional dependency::
8
+
9
+ pip install apcore-toolkit[http-proxy]
10
+
11
+ Usage::
12
+
13
+ from apcore_toolkit.output.http_proxy_writer import HTTPProxyRegistryWriter
14
+
15
+ writer = HTTPProxyRegistryWriter(
16
+ base_url="http://localhost:8000",
17
+ auth_header_factory=lambda: {"Authorization": "Bearer xxx"},
18
+ )
19
+ results = writer.write(modules, registry)
20
+
21
+ The writer reads ``http_method`` and ``url_path`` from each
22
+ ``ScannedModule.metadata`` dict (the framework-agnostic convention).
23
+ Framework-specific ``ScannedModule`` subclasses with top-level
24
+ ``http_method`` / ``url_path`` attributes are also supported.
25
+ """
26
+
27
+ import logging
28
+ import re
29
+ from collections.abc import Callable
30
+ from typing import Any
31
+
32
+ from apcore import ModuleAnnotations, Registry
33
+ from apcore_toolkit.output.types import WriteResult
34
+ from apcore_toolkit.types import ScannedModule
35
+
36
+ logger = logging.getLogger("apcore_toolkit")
37
+
38
+
39
+ def _get_http_fields(mod: Any) -> tuple[str, str]:
40
+ """Extract http_method and url_path from a ScannedModule.
41
+
42
+ Supports both:
43
+ - Toolkit ScannedModule (fields in ``metadata`` dict)
44
+ - Framework-specific ScannedModule (top-level attributes)
45
+ """
46
+ http_method = getattr(mod, "http_method", None) or mod.metadata.get("http_method", "GET")
47
+ url_path = getattr(mod, "url_path", None) or mod.metadata.get("url_path", "/")
48
+ return str(http_method), str(url_path)
49
+
50
+
51
+ class HTTPProxyRegistryWriter:
52
+ """Register scanned modules as HTTP proxy classes in the registry.
53
+
54
+ Each module's ``execute()`` sends an HTTP request to the target API
55
+ instead of calling the route handler directly.
56
+
57
+ Args:
58
+ base_url: Base URL of the target API (e.g. ``http://localhost:8000``).
59
+ auth_header_factory: Optional callable returning HTTP headers for
60
+ authentication (e.g. ``{"Authorization": "Bearer xxx"}``).
61
+ Called once per request.
62
+ timeout: HTTP request timeout in seconds.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ base_url: str,
68
+ auth_header_factory: Callable[[], dict[str, str]] | None = None,
69
+ timeout: float = 60.0,
70
+ ) -> None:
71
+ self._base_url = base_url
72
+ self._auth_header_factory = auth_header_factory
73
+ self._timeout = timeout
74
+
75
+ def write(
76
+ self,
77
+ modules: list[ScannedModule],
78
+ registry: Registry,
79
+ ) -> list[WriteResult]:
80
+ """Register each ScannedModule as an HTTP proxy module."""
81
+ results: list[WriteResult] = []
82
+ for mod in modules:
83
+ try:
84
+ module_instance = self._build_module_class(mod)()
85
+ registry.register(mod.module_id, module_instance)
86
+ results.append(WriteResult(module_id=mod.module_id, path=None, verified=True))
87
+ except Exception as exc:
88
+ logger.debug("Skipped %s: %s", mod.module_id, exc)
89
+ results.append(
90
+ WriteResult(
91
+ module_id=mod.module_id,
92
+ path=None,
93
+ verified=False,
94
+ verification_error=str(exc),
95
+ )
96
+ )
97
+ return results
98
+
99
+ def _build_module_class(self, mod: ScannedModule) -> type:
100
+ """Build a module class with HTTP proxy execute method."""
101
+ http_method, url_path = _get_http_fields(mod)
102
+ path_params = set(re.findall(r"\{(\w+)\}", url_path))
103
+ base_url = self._base_url
104
+ auth_factory = self._auth_header_factory
105
+ timeout = self._timeout
106
+
107
+ annotations = mod.annotations or ModuleAnnotations()
108
+
109
+ # Raw dict schemas are auto-wrapped by Registry._ensure_schema_adapter
110
+ # (apcore >= 0.13.1) during register(), so no manual wrapping needed.
111
+ raw_input = dict(mod.input_schema)
112
+ raw_output = dict(mod.output_schema)
113
+
114
+ class ProxyModule:
115
+ input_schema = raw_input
116
+ output_schema = raw_output
117
+ description = mod.description
118
+ documentation = mod.documentation
119
+
120
+ async def execute(self, inputs: dict[str, Any], ctx: Any = None) -> dict[str, Any]:
121
+ import httpx as _httpx
122
+
123
+ headers: dict[str, str] = {}
124
+ if auth_factory is not None:
125
+ headers.update(auth_factory())
126
+
127
+ actual_path = url_path
128
+ query: dict[str, Any] = {}
129
+ body: dict[str, Any] = {}
130
+
131
+ for key, value in inputs.items():
132
+ if key in path_params:
133
+ actual_path = actual_path.replace(f"{{{key}}}", str(value))
134
+ elif http_method == "GET":
135
+ query[key] = value
136
+ else:
137
+ body[key] = value
138
+
139
+ kwargs: dict[str, Any] = {}
140
+ if query:
141
+ kwargs["params"] = query
142
+ if body and http_method in ("POST", "PUT", "PATCH"):
143
+ kwargs["json"] = body
144
+
145
+ transport = _httpx.AsyncHTTPTransport(retries=0)
146
+ async with _httpx.AsyncClient(transport=transport, base_url=base_url, timeout=timeout) as client:
147
+ resp = await client.request(http_method, actual_path, headers=headers, **kwargs)
148
+
149
+ if 200 <= resp.status_code < 300:
150
+ if resp.status_code == 204:
151
+ return {}
152
+ return resp.json() # type: ignore[no-any-return]
153
+
154
+ error_msg = _extract_error_message(resp)
155
+ from apcore.errors import ModuleError
156
+
157
+ raise ModuleError(
158
+ code=f"HTTP_{resp.status_code}",
159
+ message=error_msg,
160
+ )
161
+
162
+ ProxyModule.__name__ = mod.module_id.replace(".", "_")
163
+ ProxyModule.__qualname__ = ProxyModule.__name__
164
+ ProxyModule.annotations = annotations # type: ignore[assignment]
165
+ ProxyModule.tags = mod.tags # type: ignore[attr-defined]
166
+
167
+ return ProxyModule
168
+
169
+
170
+ def _extract_error_message(resp: Any) -> str:
171
+ """Extract a human-readable error message from an HTTP error response."""
172
+ content_type = resp.headers.get("content-type", "")
173
+ if content_type.startswith("application/json"):
174
+ try:
175
+ body = resp.json()
176
+ return (
177
+ body.get("error_message")
178
+ or body.get("detail")
179
+ or body.get("error")
180
+ or body.get("message")
181
+ or resp.text[:200]
182
+ )
183
+ except Exception:
184
+ pass
185
+ return resp.text[:200]
@@ -0,0 +1,204 @@
1
+ """Tests for HTTPProxyRegistryWriter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+ from unittest.mock import AsyncMock, MagicMock
8
+
9
+ from apcore import Registry
10
+
11
+ from apcore_toolkit.output.http_proxy_writer import HTTPProxyRegistryWriter
12
+ from apcore_toolkit.types import ScannedModule
13
+
14
+
15
+ def _make_module(
16
+ module_id: str = "test.get_items.get",
17
+ http_method: str = "GET",
18
+ url_path: str = "/items",
19
+ input_schema: dict[str, Any] | None = None,
20
+ output_schema: dict[str, Any] | None = None,
21
+ ) -> ScannedModule:
22
+ return ScannedModule(
23
+ module_id=module_id,
24
+ description=f"Test {module_id}",
25
+ input_schema=input_schema or {"type": "object", "properties": {}},
26
+ output_schema=output_schema or {"type": "object", "properties": {}},
27
+ tags=["test"],
28
+ target="test:func",
29
+ metadata={"http_method": http_method, "url_path": url_path},
30
+ )
31
+
32
+
33
+ class TestHTTPProxyRegistryWriter:
34
+ def test_write_registers_all_modules(self) -> None:
35
+ registry = Registry()
36
+ writer = HTTPProxyRegistryWriter(base_url="http://localhost:8000")
37
+
38
+ modules = [
39
+ _make_module("test.list.get", "GET", "/items"),
40
+ _make_module("test.create.post", "POST", "/items"),
41
+ _make_module("test.get_item.get", "GET", "/items/{item_id}"),
42
+ ]
43
+
44
+ results = writer.write(modules, registry)
45
+ assert len(results) == 3
46
+ assert all(r.verified for r in results)
47
+
48
+ def test_write_returns_failure_for_bad_module(self) -> None:
49
+ registry = Registry()
50
+ writer = HTTPProxyRegistryWriter(base_url="http://localhost:8000")
51
+
52
+ # Module with nullable field via anyOf
53
+ mod = _make_module(
54
+ input_schema={
55
+ "type": "object",
56
+ "properties": {
57
+ "id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
58
+ "name": {"type": "string"},
59
+ },
60
+ }
61
+ )
62
+
63
+ results = writer.write([mod], registry)
64
+ # Should succeed or gracefully fail (no crash)
65
+ assert len(results) == 1
66
+
67
+ def test_proxy_sends_get_with_query_params(self) -> None:
68
+ registry = Registry()
69
+ writer = HTTPProxyRegistryWriter(
70
+ base_url="http://localhost:8000",
71
+ auth_header_factory=lambda: {"Authorization": "Bearer test-token"},
72
+ )
73
+
74
+ mod = _make_module(
75
+ input_schema={
76
+ "type": "object",
77
+ "properties": {"page": {"type": "integer"}, "size": {"type": "integer"}},
78
+ }
79
+ )
80
+ writer.write([mod], registry)
81
+
82
+ # Get the registered module instance
83
+ module_instance = registry._modules["test.get_items.get"]
84
+
85
+ # Mock httpx
86
+ mock_response = MagicMock()
87
+ mock_response.status_code = 200
88
+ mock_response.json.return_value = {"items": [], "total": 0}
89
+
90
+ mock_client = AsyncMock()
91
+ mock_client.request = AsyncMock(return_value=mock_response)
92
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
93
+ mock_client.__aexit__ = AsyncMock(return_value=None)
94
+
95
+ import httpx
96
+
97
+ original = httpx.AsyncClient
98
+ httpx.AsyncClient = MagicMock(return_value=mock_client)
99
+
100
+ try:
101
+ result = asyncio.run(module_instance.execute({"page": 1, "size": 10}))
102
+ assert result == {"items": [], "total": 0}
103
+
104
+ # Verify the request was made with query params
105
+ mock_client.request.assert_called_once()
106
+ call_args = mock_client.request.call_args
107
+ assert call_args[0] == ("GET", "/items")
108
+ assert call_args[1]["params"] == {"page": 1, "size": 10}
109
+ assert call_args[1]["headers"]["Authorization"] == "Bearer test-token"
110
+ finally:
111
+ httpx.AsyncClient = original
112
+
113
+ def test_proxy_sends_post_with_json_body(self) -> None:
114
+ registry = Registry()
115
+ writer = HTTPProxyRegistryWriter(base_url="http://localhost:8000")
116
+
117
+ mod = _make_module(
118
+ module_id="test.create.post",
119
+ http_method="POST",
120
+ url_path="/items",
121
+ input_schema={
122
+ "type": "object",
123
+ "properties": {"name": {"type": "string"}},
124
+ "required": ["name"],
125
+ },
126
+ )
127
+ writer.write([mod], registry)
128
+
129
+ module_instance = registry._modules["test.create.post"]
130
+
131
+ mock_response = MagicMock()
132
+ mock_response.status_code = 200
133
+ mock_response.json.return_value = {"id": "1", "name": "Test"}
134
+
135
+ mock_client = AsyncMock()
136
+ mock_client.request = AsyncMock(return_value=mock_response)
137
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
138
+ mock_client.__aexit__ = AsyncMock(return_value=None)
139
+
140
+ import httpx
141
+
142
+ original = httpx.AsyncClient
143
+ httpx.AsyncClient = MagicMock(return_value=mock_client)
144
+
145
+ try:
146
+ result = asyncio.run(module_instance.execute({"name": "Test"}))
147
+ assert result["name"] == "Test"
148
+
149
+ call_args = mock_client.request.call_args
150
+ assert call_args[0] == ("POST", "/items")
151
+ assert call_args[1]["json"] == {"name": "Test"}
152
+ finally:
153
+ httpx.AsyncClient = original
154
+
155
+ def test_proxy_substitutes_path_params(self) -> None:
156
+ registry = Registry()
157
+ writer = HTTPProxyRegistryWriter(base_url="http://localhost:8000")
158
+
159
+ mod = _make_module(
160
+ module_id="test.get_item.get",
161
+ http_method="GET",
162
+ url_path="/items/{item_id}",
163
+ input_schema={
164
+ "type": "object",
165
+ "properties": {"item_id": {"type": "string"}},
166
+ "required": ["item_id"],
167
+ },
168
+ )
169
+ writer.write([mod], registry)
170
+
171
+ module_instance = registry._modules["test.get_item.get"]
172
+
173
+ mock_response = MagicMock()
174
+ mock_response.status_code = 200
175
+ mock_response.json.return_value = {"id": "abc", "name": "Item"}
176
+
177
+ mock_client = AsyncMock()
178
+ mock_client.request = AsyncMock(return_value=mock_response)
179
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
180
+ mock_client.__aexit__ = AsyncMock(return_value=None)
181
+
182
+ import httpx
183
+
184
+ original = httpx.AsyncClient
185
+ httpx.AsyncClient = MagicMock(return_value=mock_client)
186
+
187
+ try:
188
+ result = asyncio.run(module_instance.execute({"item_id": "abc"}))
189
+ assert result["id"] == "abc"
190
+
191
+ call_args = mock_client.request.call_args
192
+ # Path param should be substituted, not sent as query
193
+ assert call_args[0] == ("GET", "/items/abc")
194
+ assert "params" not in call_args[1] or call_args[1]["params"] == {}
195
+ finally:
196
+ httpx.AsyncClient = original
197
+
198
+ def test_no_auth_headers_when_factory_is_none(self) -> None:
199
+ writer = HTTPProxyRegistryWriter(base_url="http://localhost:8000")
200
+ assert writer._auth_header_factory is None
201
+
202
+ def test_custom_timeout(self) -> None:
203
+ writer = HTTPProxyRegistryWriter(base_url="http://localhost:8000", timeout=120.0)
204
+ assert writer._timeout == 120.0
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from apcore_toolkit.openapi import (
6
+ _deep_resolve_refs,
6
7
  extract_input_schema,
7
8
  extract_output_schema,
8
9
  resolve_ref,
@@ -24,6 +25,35 @@ OPENAPI_DOC = {
24
25
  "type": "array",
25
26
  "items": {"$ref": "#/components/schemas/User"},
26
27
  },
28
+ "Address": {
29
+ "type": "object",
30
+ "properties": {
31
+ "street": {"type": "string"},
32
+ "city": {"type": "string"},
33
+ },
34
+ },
35
+ "UserWithAddress": {
36
+ "type": "object",
37
+ "properties": {
38
+ "id": {"type": "integer"},
39
+ "address": {"$ref": "#/components/schemas/Address"},
40
+ },
41
+ },
42
+ "AdminUser": {
43
+ "allOf": [
44
+ {"$ref": "#/components/schemas/User"},
45
+ {
46
+ "type": "object",
47
+ "properties": {"role": {"type": "string"}},
48
+ },
49
+ ],
50
+ },
51
+ "SelfRef": {
52
+ "type": "object",
53
+ "properties": {
54
+ "child": {"$ref": "#/components/schemas/SelfRef"},
55
+ },
56
+ },
27
57
  }
28
58
  }
29
59
  }
@@ -70,6 +100,74 @@ class TestResolveSchema:
70
100
  assert result == schema
71
101
 
72
102
 
103
+ class TestDeepResolveRefs:
104
+ def test_top_level_ref(self) -> None:
105
+ schema = {"$ref": "#/components/schemas/User"}
106
+ result = _deep_resolve_refs(schema, OPENAPI_DOC)
107
+ assert result["type"] == "object"
108
+ assert "id" in result["properties"]
109
+ assert "name" in result["properties"]
110
+
111
+ def test_nested_ref_in_properties(self) -> None:
112
+ schema = {
113
+ "type": "object",
114
+ "properties": {
115
+ "address": {"$ref": "#/components/schemas/Address"},
116
+ },
117
+ }
118
+ result = _deep_resolve_refs(schema, OPENAPI_DOC)
119
+ assert result["properties"]["address"]["type"] == "object"
120
+ assert "street" in result["properties"]["address"]["properties"]
121
+
122
+ def test_ref_in_allof(self) -> None:
123
+ schema = {
124
+ "allOf": [
125
+ {"$ref": "#/components/schemas/User"},
126
+ {"type": "object", "properties": {"extra": {"type": "boolean"}}},
127
+ ]
128
+ }
129
+ result = _deep_resolve_refs(schema, OPENAPI_DOC)
130
+ assert result["allOf"][0]["type"] == "object"
131
+ assert "id" in result["allOf"][0]["properties"]
132
+ assert result["allOf"][1]["properties"]["extra"]["type"] == "boolean"
133
+
134
+ def test_ref_in_anyof(self) -> None:
135
+ schema = {
136
+ "anyOf": [
137
+ {"$ref": "#/components/schemas/User"},
138
+ {"$ref": "#/components/schemas/Address"},
139
+ ]
140
+ }
141
+ result = _deep_resolve_refs(schema, OPENAPI_DOC)
142
+ assert result["anyOf"][0]["type"] == "object"
143
+ assert "name" in result["anyOf"][0]["properties"]
144
+ assert "street" in result["anyOf"][1]["properties"]
145
+
146
+ def test_ref_in_array_items(self) -> None:
147
+ schema = {"type": "array", "items": {"$ref": "#/components/schemas/User"}}
148
+ result = _deep_resolve_refs(schema, OPENAPI_DOC)
149
+ assert result["items"]["type"] == "object"
150
+ assert "id" in result["items"]["properties"]
151
+
152
+ def test_deeply_nested_ref(self) -> None:
153
+ result = _deep_resolve_refs({"$ref": "#/components/schemas/UserWithAddress"}, OPENAPI_DOC)
154
+ assert result["properties"]["address"]["type"] == "object"
155
+ assert "street" in result["properties"]["address"]["properties"]
156
+
157
+ def test_circular_ref_depth_limit(self) -> None:
158
+ result = _deep_resolve_refs({"$ref": "#/components/schemas/SelfRef"}, OPENAPI_DOC)
159
+ # Should not raise — depth limit stops recursion.
160
+ assert result["type"] == "object"
161
+ assert "child" in result["properties"]
162
+
163
+ def test_no_mutation_of_original(self) -> None:
164
+ original_address = OPENAPI_DOC["components"]["schemas"]["Address"]
165
+ original_props = dict(original_address.get("properties", {}))
166
+ schema = {"$ref": "#/components/schemas/UserWithAddress"}
167
+ _deep_resolve_refs(schema, OPENAPI_DOC)
168
+ assert OPENAPI_DOC["components"]["schemas"]["Address"]["properties"] == original_props
169
+
170
+
73
171
  class TestExtractInputSchema:
74
172
  def test_query_and_path_params(self) -> None:
75
173
  operation = {
@@ -118,6 +216,16 @@ class TestExtractInputSchema:
118
216
  result = extract_input_schema(operation, OPENAPI_DOC)
119
217
  assert result["properties"]["user"]["type"] == "object"
120
218
 
219
+ def test_nested_ref_in_body_properties(self) -> None:
220
+ operation = {
221
+ "requestBody": {
222
+ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserWithAddress"}}}
223
+ }
224
+ }
225
+ result = extract_input_schema(operation, OPENAPI_DOC)
226
+ assert result["properties"]["address"]["type"] == "object"
227
+ assert "street" in result["properties"]["address"]["properties"]
228
+
121
229
  def test_empty_operation(self) -> None:
122
230
  result = extract_input_schema({})
123
231
  assert result["type"] == "object"
@@ -184,6 +292,28 @@ class TestExtractOutputSchema:
184
292
  result = extract_output_schema(operation)
185
293
  assert result == {"type": "object", "properties": {}}
186
294
 
295
+ def test_nested_ref_in_response_properties(self) -> None:
296
+ operation = {
297
+ "responses": {
298
+ "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserWithAddress"}}}}
299
+ }
300
+ }
301
+ result = extract_output_schema(operation, OPENAPI_DOC)
302
+ assert result["type"] == "object"
303
+ assert result["properties"]["address"]["type"] == "object"
304
+ assert "street" in result["properties"]["address"]["properties"]
305
+
306
+ def test_allof_composition_in_response(self) -> None:
307
+ operation = {
308
+ "responses": {
309
+ "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUser"}}}}
310
+ }
311
+ }
312
+ result = extract_output_schema(operation, OPENAPI_DOC)
313
+ assert result["allOf"][0]["type"] == "object"
314
+ assert "id" in result["allOf"][0]["properties"]
315
+ assert result["allOf"][1]["properties"]["role"]["type"] == "string"
316
+
187
317
  def test_empty_responses(self) -> None:
188
318
  result = extract_output_schema({})
189
319
  assert result == {"type": "object", "properties": {}}
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import pytest
6
6
 
7
7
  from apcore_toolkit.output import get_writer
8
+ from apcore_toolkit.output.http_proxy_writer import HTTPProxyRegistryWriter
8
9
  from apcore_toolkit.output.python_writer import PythonWriter
9
10
  from apcore_toolkit.output.registry_writer import RegistryWriter
10
11
  from apcore_toolkit.output.yaml_writer import YAMLWriter
@@ -23,6 +24,10 @@ class TestGetWriter:
23
24
  writer = get_writer("registry")
24
25
  assert isinstance(writer, RegistryWriter)
25
26
 
27
+ def test_http_proxy(self) -> None:
28
+ writer = get_writer("http-proxy", base_url="http://localhost:8000")
29
+ assert isinstance(writer, HTTPProxyRegistryWriter)
30
+
26
31
  def test_unknown_format(self) -> None:
27
32
  with pytest.raises(ValueError, match="Unknown output format"):
28
33
  get_writer("json")