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.
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/CHANGELOG.md +39 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/PKG-INFO +7 -2
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/README.md +2 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/pyproject.toml +4 -2
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/__init__.py +37 -2
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/ai_enhancer.py +11 -1
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/openapi.py +45 -3
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/__init__.py +16 -2
- apcore_toolkit-0.3.0/src/apcore_toolkit/output/http_proxy_writer.py +185 -0
- apcore_toolkit-0.3.0/tests/test_http_proxy_writer.py +204 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_openapi.py +130 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_output_factory.py +5 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/.github/CODEOWNERS +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/.github/copilot-ignore +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/.github/workflows/ci.yml +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/.gitignore +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/.gitmessage +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/.pre-commit-config.yaml +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/formatting/__init__.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/formatting/markdown.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/errors.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/python_writer.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/registry_writer.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/types.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/verifiers.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/output/yaml_writer.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/pydantic_utils.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/scanner.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/schema_utils.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/serializers.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/src/apcore_toolkit/types.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/conftest.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_ai_enhancer.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_markdown.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_pydantic_utils.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_python_writer.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_registry_writer.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_scanner.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_schema_utils.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_serializers.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_types.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_verifiers.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_write_error.py +0 -0
- {apcore_toolkit-0.2.0 → apcore_toolkit-0.3.0}/tests/test_write_result.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
118
|
-
if
|
|
119
|
-
schema
|
|
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(
|
|
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"``,
|
|
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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|