OpenAPI initial support as capability source

This commit is contained in:
lda
2026-05-27 18:57:25 +07:00 Verified
parent 38fadc9f10
commit 2d41336fed
21 changed files with 2541 additions and 0 deletions
+7
View File
@@ -84,6 +84,13 @@ implementation state.
revalidates its pinned dependency environment and reports `blocked` without revalidates its pinned dependency environment and reports `blocked` without
consuming input when a required source is unavailable. Ordinary live consuming input when a required source is unavailable. Ordinary live
tool/source failures remain failed runs, not implicit pauses. tool/source failures remain failed runs, not implicit pauses.
- **OpenAPI capability sources**: raw OpenAPI operations can be represented as
workflow-facing capabilities using the OpenAPI document as the source of
truth. Runtime execution now follows the `openapi-core` plan: public payloads
keep OpenAPI names, generic `httpx` builds requests, and `openapi-core`
validates/unmarshals requests and responses. Generated Python client parsing
is explicitly retired. See
[OpenAPI capability sources](./openapi_capability_source.md).
- **Protocol-native long-running runs**: investigate MCP tasks/progress - **Protocol-native long-running runs**: investigate MCP tasks/progress
notifications for long-running workflow execution. Avoid inventing a custom notifications for long-running workflow execution. Avoid inventing a custom
"start" convention unless protocol-native behavior is insufficient. "start" convention unless protocol-native behavior is insufficient.
+48
View File
@@ -0,0 +1,48 @@
# OpenAPI Capability Sources
OpenAPI sources expose raw API operations as workflow capabilities.
The OpenAPI document is the source of truth for operation inventory, public
input names, JSON Schema contracts, request paths, request bodies, response
schemas, and declared status codes. Runtime execution uses a generic `httpx`
request builder plus `openapi-core` validation/unmarshalling. It does not parse
generated Python clients and does not rename public OpenAPI fields.
## Payload Shape
Workflow inputs stay OpenAPI-shaped:
```json
{
"path": {"petId": "pet-1"},
"query": {"includeOwner": true},
"header": {"X-Trace-ID": "trace-1"},
"cookie": {},
"body": {"name": "Fluffy"}
}
```
The workflow-facing field is `petId`, not a generated Python name like
`pet_id`.
## Outcomes
Raw OpenAPI nodes expose transport-level outcomes:
- `ok`: declared 2xx response and response validation passed.
- `http_error`: declared non-2xx response and response validation passed.
- `unexpected_status`: response status was not declared and no `default`
response covered it.
- `validation_error`: request or response failed OpenAPI validation.
- `transport_error`: HTTP failed before a response existed.
Business outcomes such as `not_found`, `rate_limited`, or `needs_input` belong
in saved wrappers, not raw OpenAPI operation nodes.
## Current Limits
- Auth integration is future work. For now, source configuration owns the base
URL only.
- Multipart, form, and binary request/response handling are future work.
- Rich business outcome mapping is wrapper territory.
- OpenAPI operation nodes are workflow capabilities, not top-level MCP tools.
@@ -0,0 +1,25 @@
# Superseded OpenAPI Generated-Client Plan
This plan is intentionally retired.
The generated-client approach made runtime execution depend on parsing
`openapi-python-client` generated Python functions to recover parameter-name
mappings such as:
```text
OpenAPI public name: petId
generated Python kwarg: pet_id
```
That dependency direction is too fragile. Do not continue the generated-client
tasks from this file's old history.
Use the replacement plan instead:
```text
docs/superpowers/plans/2026-05-27-openapi-core-capability-source.md
```
The replacement plan uses the OpenAPI document as source of truth,
`openapi-core` for validation/unmarshalling, and a small generic `httpx`
request builder for execution.
@@ -0,0 +1,631 @@
# OpenAPI Core Capability Source Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let a saved OpenAPI document become a workflow capability source whose operations appear as workflow-facing `NodeSpec`s and execute through spec-driven HTTP requests, without parsing generated Python client code.
**Architecture:** The OpenAPI document is the source of truth for inventory, JSON Schemas, operation paths, parameters, request bodies, and response validation. `openapi-core` validates/unmarshals OpenAPI requests and responses; a small local `httpx` adapter builds and sends HTTP requests from public OpenAPI-shaped payloads. No generated Python client runtime, no AST parsing, and no case-conversion dependency.
**Tech Stack:** Python 3.14, `openapi-core`, `httpx`, existing `jsonschema`, `wf_authoring.NodeSpec`, `wf_platform.CapabilitySource`, `wf_mcp` service registration.
---
## Why This Replaces The Generated-Client Plan
The first plan made the ugly part parameter-name recovery:
```text
OpenAPI/public input: path.petId, header.X-Trace-ID
generated Python fn: pet_id, x_trace_id
```
Because `openapi-python-client` did not expose a stable operation manifest for those mappings, the implementation parsed generated endpoint functions. That is the wrong dependency direction. We should not inspect generated Python to recover metadata already present in the OpenAPI document.
The revised runtime shape is:
```text
workflow input
-> OpenAPI-shaped request parts
-> openapi-core validates/unmarshals request
-> local httpx request builder sends request
-> openapi-core validates/unmarshals response
-> generic workflow outcome
```
This makes validation and execution boring. Outcome mapping remains intentionally generic until saved wrappers add business semantics.
## Non-Goals
- Do not generate Python clients for v1 runtime execution.
- Do not parse generated Python, generated docstrings, or generated function signatures.
- Do not invent a full OpenAPI validator. Use `openapi-core` for request/response validation where possible and `jsonschema` for existing schema-boundary checks.
- Do not make every HTTP status a business outcome. Raw OpenAPI operations expose generic transport outcomes.
- Do not implement custom auth UX in this slice. Leave auth as explicit configuration fields and later integrate with the existing auth/store layer.
- Do not expose every OpenAPI operation as a top-level MCP tool. Expose operations as workflow capabilities first.
## V1 Public Payload Shape
Workflow inputs stay OpenAPI-shaped:
```json
{
"path": {"petId": "pet-1"},
"query": {"includeOwner": true},
"header": {"X-Trace-ID": "abc"},
"cookie": {},
"body": {"name": "Ada"}
}
```
No `petId -> pet_id` translation exists because there is no generated Python function.
## V1 Outcome Semantics
Every raw OpenAPI operation node exposes:
```text
ok
http_error
unexpected_status
validation_error
transport_error
```
Rules:
- `ok`: response status is a declared 2xx response and response validation passes.
- `http_error`: response status is a declared non-2xx response and response validation passes.
- `unexpected_status`: response status is not declared and no `default` response covers it.
- `validation_error`: request or response does not match the OpenAPI document.
- `transport_error`: HTTP client raises before a response exists.
Output shape:
```json
{
"status_code": 200,
"headers": {},
"body": {},
"validation_errors": []
}
```
`body` is JSON when the response is JSON, text for text responses, bytes/base64 later if needed. Keep binary response support out of v1 unless a test fixture forces it.
## Planned File Structure
- Keep: `src/wf_openapi/__init__.py`
- Public exports for the optional OpenAPI capability-source package.
- Keep/modify: `src/wf_openapi/models.py`
- Operation/source/execution models. Remove generated-client metadata.
- Keep/modify: `src/wf_openapi/spec.py`
- Load OpenAPI documents, normalize operations, merge inherited path-item parameters with operation-local overrides.
- Keep/modify: `src/wf_openapi/schemas.py`
- Produce JSON Schema contracts from effective OpenAPI operation inputs/outputs.
- Replace: `src/wf_openapi/executor.py`
- Generic `httpx` + `openapi-core` operation executor.
- Remove or repurpose: `src/wf_openapi/codegen.py`
- Delete generated-client runtime helpers. If kept temporarily, it must not be used by runtime/source tests.
- Create: `src/wf_openapi/request.py`
- Build method, URL, headers, cookies, query params, and JSON body from OpenAPI-shaped payload.
- Create: `src/wf_openapi/validation.py`
- Thin adapter between local request/response objects and `openapi-core` protocols.
- Keep/modify: `src/wf_openapi/source.py`
- Build `CapabilitySource` and `NodeSpec`s using generic execution config.
- Tests:
- `tests/openapi/test_spec_inventory.py`
- `tests/openapi/test_schemas.py` if split becomes useful.
- `tests/openapi/test_request_builder.py`
- `tests/openapi/test_executor.py`
- `tests/openapi/test_source.py`
---
## Task 1: Dependency And Plan Reset
**Files:**
- Modify: `pyproject.toml`
- Modify: `uv.lock`
- Modify: `docs/superpowers/plans/2026-05-27-openapi-capability-source.md`
- Test: `tests/openapi/test_codegen_executor.py` may be removed or replaced later.
- [ ] **Step 1: Replace runtime dependency**
In `pyproject.toml`, remove `openapi-python-client` unless another committed package already uses it. Add:
```toml
"openapi-core>=0.19",
```
Keep `httpx` if already present transitively or directly; add it directly if `wf_openapi` imports it.
- [ ] **Step 2: Refresh lockfile**
Run:
```bash
uv lock
```
Expected: lockfile updates successfully.
- [ ] **Step 3: Mark generated-client plan superseded**
Keep the superseded note at the top of:
```text
docs/superpowers/plans/2026-05-27-openapi-capability-source.md
```
Expected: future agents do not continue Task 5/6 AST parsing work.
- [ ] **Step 4: Verify import availability**
Run:
```bash
uv run python -c "import openapi_core, httpx; print(openapi_core.__name__, httpx.__name__)"
```
Expected: prints `openapi_core httpx`.
---
## Task 2: Remove Generated-Client Runtime Coupling
**Files:**
- Modify: `src/wf_openapi/codegen.py`
- Modify: `src/wf_openapi/executor.py`
- Modify: `tests/openapi/test_codegen_executor.py`
- [ ] **Step 1: Write failing guard test**
Add a test that proves runtime no longer imports generated-client metadata:
```python
def test_openapi_runtime_does_not_require_generated_manifest() -> None:
from wf_openapi.executor import OpenApiExecutionConfig
config = OpenApiExecutionConfig(base_url="https://api.example.test")
assert config.base_url == "https://api.example.test"
assert not hasattr(config, "generated_package")
assert not hasattr(config, "operation_modules")
assert not hasattr(config, "parameter_arguments")
```
Run:
```bash
uv run pytest -q tests/openapi/test_codegen_executor.py::test_openapi_runtime_does_not_require_generated_manifest
```
Expected before implementation: FAIL because generated-client fields still exist.
- [ ] **Step 2: Simplify execution config**
Replace generated-client config with:
```python
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class OpenApiExecutionConfig:
"""Runtime config for spec-driven OpenAPI HTTP execution."""
base_url: str
timeout_seconds: float = 30.0
```
Do not include generated package/module/parameter mapping fields.
- [ ] **Step 3: Remove generated manifest helpers from runtime path**
Delete or quarantine:
```python
GeneratedOperationMetadata
load_generated_operation_manifest
generate_openapi_client
```
If `codegen.py` remains, its module docstring must say it is experimental/offline tooling and not used by source/executor runtime.
- [ ] **Step 4: Run focused tests**
Run:
```bash
uv run pytest -q tests/openapi
uv run ruff check src/wf_openapi tests/openapi
uv run basedpyright --level error src/wf_openapi tests/openapi
```
Expected: generated-client tests that no longer match are removed/replaced; remaining tests pass.
---
## Task 3: Generic Request Builder
**Files:**
- Create: `src/wf_openapi/request.py`
- Test: `tests/openapi/test_request_builder.py`
- [ ] **Step 1: Write request builder tests**
Create `tests/openapi/test_request_builder.py`:
```python
from wf_openapi.request import build_http_request_parts
from wf_openapi.spec import load_openapi_operations
FIXTURE = "tests/openapi/fixtures/petstore_minimal.openapi.json"
def test_build_http_request_parts_uses_public_openapi_names() -> None:
operation = next(op for op in load_openapi_operations(FIXTURE) if op.name == "get_pet")
parts = build_http_request_parts(
operation,
base_url="https://api.example.test/v1",
payload={
"path": {"petId": "pet-1"},
"query": {"includeOwner": True},
"header": {"X-Trace-ID": "trace-1"},
},
)
assert parts.method == "GET"
assert parts.url == "https://api.example.test/v1/pets/pet-1"
assert parts.params["includeOwner"] is True
assert parts.headers["X-Trace-ID"] == "trace-1"
```
Expected before implementation: FAIL because `wf_openapi.request` does not exist.
- [ ] **Step 2: Implement request parts**
Create:
```python
from dataclasses import dataclass, field
from typing import Any, Mapping
from urllib.parse import quote
from wf_openapi.models import OpenApiOperation
@dataclass(frozen=True, slots=True)
class HttpRequestParts:
"""OpenAPI-shaped request parts ready for httpx."""
method: str
url: str
params: dict[str, Any] = field(default_factory=dict)
headers: dict[str, str] = field(default_factory=dict)
cookies: dict[str, str] = field(default_factory=dict)
json: Any | None = None
def build_http_request_parts(
operation: OpenApiOperation,
*,
base_url: str,
payload: Mapping[str, Any],
) -> HttpRequestParts:
"""Build an HTTP request without renaming public OpenAPI fields."""
path_values = _mapping(payload, "path")
path = operation.path
for parameter in operation.effective_parameters:
if parameter.get("in") != "path":
continue
name = parameter["name"]
if name not in path_values:
raise ValueError(f"missing path parameter {name!r}")
path = path.replace("{" + name + "}", quote(str(path_values[name]), safe=""))
return HttpRequestParts(
method=operation.method.upper(),
url=base_url.rstrip("/") + path,
params=dict(_mapping(payload, "query")),
headers={str(k): str(v) for k, v in _mapping(payload, "header").items()},
cookies={str(k): str(v) for k, v in _mapping(payload, "cookie").items()},
json=payload.get("body"),
)
def _mapping(payload: Mapping[str, Any], key: str) -> Mapping[str, Any]:
value = payload.get(key, {})
if not isinstance(value, Mapping):
raise ValueError(f"{key} must be an object")
return value
```
- [ ] **Step 3: Add edge tests**
Add tests for:
```text
missing path parameter -> ValueError
non-object query/header/cookie/path -> ValueError
body passes through as json payload
```
- [ ] **Step 4: Verify**
Run:
```bash
uv run pytest -q tests/openapi/test_request_builder.py
```
Expected: PASS.
---
## Task 4: openapi-core Validation Adapter
**Files:**
- Create: `src/wf_openapi/validation.py`
- Test: `tests/openapi/test_validation.py`
- [ ] **Step 1: Write validation tests**
Create tests that load the fixture and validate a request built from public payload:
```python
from wf_openapi.request import build_http_request_parts
from wf_openapi.spec import load_openapi, load_openapi_operations
from wf_openapi.validation import validate_openapi_request
FIXTURE = "tests/openapi/fixtures/petstore_minimal.openapi.json"
def test_validate_openapi_request_accepts_public_payload() -> None:
document = load_openapi(FIXTURE)
operation = next(op for op in load_openapi_operations(FIXTURE) if op.name == "get_pet")
parts = build_http_request_parts(
operation,
base_url="https://api.example.test",
payload={"path": {"petId": "pet-1"}},
)
result = validate_openapi_request(document, parts)
assert result.valid is True
assert result.errors == []
```
Expected before implementation: FAIL because validation adapter does not exist.
- [ ] **Step 2: Implement minimal protocol objects**
Implement local request/response protocol adapters required by `openapi-core`. Keep them in `validation.py` and document that they are intentionally thin protocol shims.
The adapter must carry:
```text
method
full_url_pattern or path pattern if required by openapi-core
parameters/path/query/header/cookie
body
mimetype
```
If `openapi-core` requires a different protocol shape, adapt only this file.
- [ ] **Step 3: Validate response path**
Add:
```python
def validate_openapi_response(document, request_parts, response_parts) -> ValidationResult:
...
```
Test declared `200` response and undeclared status behavior.
- [ ] **Step 4: Verify**
Run:
```bash
uv run pytest -q tests/openapi/test_validation.py
uv run basedpyright --level error src/wf_openapi/validation.py tests/openapi/test_validation.py
```
Expected: PASS.
---
## Task 5: Generic HTTP Executor
**Files:**
- Modify: `src/wf_openapi/executor.py`
- Test: `tests/openapi/test_executor.py`
- [ ] **Step 1: Write executor tests with mocked transport**
Use `httpx.MockTransport`:
```python
import httpx
from wf_openapi.executor import OpenApiExecutionConfig, call_openapi_operation
from wf_openapi.spec import load_openapi, load_openapi_operations
FIXTURE = "tests/openapi/fixtures/petstore_minimal.openapi.json"
async def test_call_openapi_operation_maps_success() -> None:
document = load_openapi(FIXTURE)
operation = next(op for op in load_openapi_operations(FIXTURE) if op.name == "get_pet")
async def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/pets/pet-1"
return httpx.Response(200, json={"id": "pet-1"})
result = await call_openapi_operation(
document,
operation,
OpenApiExecutionConfig(base_url="https://api.example.test"),
{"path": {"petId": "pet-1"}},
client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
)
assert result.outcome == "ok"
assert result.value.status_code == 200
assert result.value.body["id"] == "pet-1"
```
Expected before implementation: FAIL because executor still uses generated client or wrong signature.
- [ ] **Step 2: Implement executor**
`call_openapi_operation(...)` should:
```text
build request parts
validate/unmarshal request
send with httpx.AsyncClient
parse response body by content-type
validate/unmarshal response
return NodeReturn with generic outcome
```
Transport exceptions become `transport_error`. Validation failures become `validation_error`.
- [ ] **Step 3: Add outcome tests**
Add tests for:
```text
declared non-2xx -> http_error
undeclared status -> unexpected_status
invalid request -> validation_error
httpx transport exception -> transport_error
```
- [ ] **Step 4: Verify**
Run:
```bash
uv run pytest -q tests/openapi/test_executor.py
```
Expected: PASS.
---
## Task 6: Source Integration
**Files:**
- Modify: `src/wf_openapi/source.py`
- Test: `tests/openapi/test_source.py`
- [ ] **Step 1: Write source execution test**
Build a `CapabilitySource`, get `source.capabilities.node_specs["petstore.default.get_pet"]`, call its async handler with public payload, and use `httpx.MockTransport` through runtime/config injection.
Expected before implementation: FAIL because source still uses generated metadata or does not pass executor dependencies.
- [ ] **Step 2: Update source builder**
`build_openapi_capability_source(...)` should accept:
```python
document_path: Path
source_id: str
base_url: str
```
It should not accept:
```text
generated_package
operation_modules
parameter_arguments
```
- [ ] **Step 3: Preserve schema contracts**
Ensure each `NodeSpec` still exposes:
```text
input_schema_contract from operation input schema
output_schema_contract from operation output schema
outcomes = ("ok", "http_error", "unexpected_status", "validation_error", "transport_error")
```
- [ ] **Step 4: Verify**
Run:
```bash
uv run pytest -q tests/openapi/test_source.py
```
Expected: PASS.
---
## Task 7: Docs And Final Cleanup
**Files:**
- Create: `docs/openapi_capability_source.md`
- Modify: `docs/current_roadmap.md`
- Delete or rewrite: generated-client-only tests/files if no longer used.
- [ ] **Step 1: Document the boundary**
Create `docs/openapi_capability_source.md` with:
```markdown
# OpenAPI Capability Sources
OpenAPI sources expose raw API operations as workflow capabilities.
The OpenAPI document is the source of truth. Runtime execution uses a generic
httpx request builder and openapi-core validation. The runtime does not parse
generated Python clients and does not rename public OpenAPI fields.
Raw OpenAPI nodes expose generic transport outcomes. Saved wrappers should add
business-specific outcomes such as `not_found`, `rate_limited`, or
`needs_input`.
```
- [ ] **Step 2: Note deferred auth/body/binary support**
Document:
```text
auth integration: future
binary/multipart request bodies: future
rich outcome mapping: wrappers, not raw operations
```
- [ ] **Step 3: Final verification**
Run:
```bash
uv run pytest -q tests/openapi
uv run ruff check src/wf_openapi tests/openapi
uv run ruff format --check src/wf_openapi tests/openapi
uv run basedpyright --level error src/wf_openapi tests/openapi
```
Expected: all pass.
---
## Self-Review
- The plan no longer requires generated Python client parsing.
- Public OpenAPI names remain public workflow names.
- Validation is library-backed through `openapi-core`, not hand-rolled.
- HTTP execution is locally owned but small and testable with `httpx.MockTransport`.
- Outcome mapping stays generic and wrapper-friendly.
- Auth, multipart/binary, and business outcomes are deferred explicitly.
+2
View File
@@ -7,9 +7,11 @@ authors = [{ name = "lda", email = "[email protected]" }]
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"fastmcp>=3.2.4", "fastmcp>=3.2.4",
"httpx>=0.28",
"jsonpatch>=1.33", "jsonpatch>=1.33",
"jsonschema>=4.26", "jsonschema>=4.26",
"mcp[cli,rich]>=1", "mcp[cli,rich]>=1",
"openapi-core>=0.19",
"pydantic>=2", "pydantic>=2",
] ]
+19
View File
@@ -0,0 +1,19 @@
from .executor import (
OpenApiExecutionConfig,
OpenApiOperationOutput,
call_openapi_operation,
)
from .models import OpenApiOperation
from .source import OPENAPI_OUTCOMES, build_openapi_capability_source
from .spec import load_openapi_document, load_openapi_operations
__all__ = [
"OPENAPI_OUTCOMES",
"OpenApiExecutionConfig",
"OpenApiOperationOutput",
"OpenApiOperation",
"build_openapi_capability_source",
"call_openapi_operation",
"load_openapi_document",
"load_openapi_operations",
]
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from openapi_core import OpenAPI
from pydantic import BaseModel, ConfigDict
from wf_authoring import NodeReturn
from .models import OpenApiOperation
from .request import HttpRequestParts, build_http_request_parts
from .validation import (
HttpResponseParts,
validate_openapi_request,
validate_openapi_response,
)
@dataclass(frozen=True, slots=True)
class OpenApiExecutionConfig:
"""Runtime config for spec-driven OpenAPI HTTP execution."""
base_url: str
timeout_seconds: float = 30.0
class OpenApiOperationOutput(BaseModel):
"""Generic transport output for raw OpenAPI operation nodes."""
model_config = ConfigDict(extra="allow")
status_code: int
headers: dict[str, str]
body: Any
validation_errors: list[str] = []
async def call_openapi_operation(
app: OpenAPI,
operation: OpenApiOperation,
config: OpenApiExecutionConfig,
payload: dict[str, Any],
*,
client: httpx.AsyncClient | None = None,
) -> NodeReturn[OpenApiOperationOutput]:
"""Execute one raw OpenAPI operation through generic HTTP machinery."""
request = build_http_request_parts(
operation,
base_url=config.base_url,
payload=payload,
)
request_validation = validate_openapi_request(app, request)
if not request_validation.valid:
return NodeReturn(
outcome="validation_error",
output=OpenApiOperationOutput(
status_code=0,
headers={},
body=None,
validation_errors=request_validation.errors,
),
)
close_client = client is None
active_client = client or httpx.AsyncClient(timeout=config.timeout_seconds)
try:
try:
response = await _send_request(active_client, request)
except httpx.HTTPError as exc:
return NodeReturn(
outcome="transport_error",
output=OpenApiOperationOutput(
status_code=0,
headers={},
body=None,
validation_errors=[str(exc)],
),
)
finally:
if close_client:
await active_client.aclose()
body = _response_body(response)
headers = {str(key): str(value) for key, value in response.headers.items()}
output = OpenApiOperationOutput(
status_code=response.status_code,
headers=headers,
body=body,
validation_errors=[],
)
if not _status_declared(operation, response.status_code):
output.validation_errors = [
f"response status {response.status_code} is not declared"
]
return NodeReturn(outcome="unexpected_status", output=output)
response_validation = validate_openapi_response(
app,
request,
HttpResponseParts(
status_code=response.status_code,
headers=headers,
data=response.content,
),
)
if not response_validation.valid:
output.validation_errors = response_validation.errors
return NodeReturn(outcome="validation_error", output=output)
if 200 <= response.status_code < 300:
return NodeReturn(outcome="ok", output=output)
return NodeReturn(outcome="http_error", output=output)
async def _send_request(
client: httpx.AsyncClient,
request: HttpRequestParts,
) -> httpx.Response:
kwargs: dict[str, Any] = {
"method": request.method,
"url": request.url,
}
if request.params:
kwargs["params"] = request.params
if request.headers:
kwargs["headers"] = request.headers
if request.cookies:
kwargs["cookies"] = request.cookies
if request.json is not None:
kwargs["json"] = request.json
return await client.request(**kwargs)
def _response_body(response: httpx.Response) -> Any:
content_type = response.headers.get("content-type", "").lower()
if not response.content:
return None
if "json" in content_type:
return response.json()
return response.text
def _status_declared(operation: OpenApiOperation, status_code: int) -> bool:
responses = operation.raw_operation.get("responses", {})
if not isinstance(responses, dict):
return False
status = str(status_code)
if status in responses or "default" in responses:
return True
status_range = f"{status[0]}XX"
return status_range in responses
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
JsonObject = dict[str, Any]
@dataclass(frozen=True, slots=True)
class OpenApiOperation:
"""Normalized operation metadata extracted from one OpenAPI document."""
name: str
operation_id: str
method: Literal["get", "post", "put", "patch", "delete", "options", "head"]
path: str
summary: str | None
description: str | None
effective_parameters: tuple[JsonObject, ...]
has_request_body: bool
raw_operation: JsonObject
document_path: Path
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Mapping
from urllib.parse import quote
from .models import OpenApiOperation
@dataclass(frozen=True, slots=True)
class HttpRequestParts:
"""OpenAPI-shaped request parts ready for `httpx` execution."""
method: str
url: str
params: dict[str, Any] = field(default_factory=dict)
headers: dict[str, str] = field(default_factory=dict)
cookies: dict[str, str] = field(default_factory=dict)
json: Any | None = None
def build_http_request_parts(
operation: OpenApiOperation,
*,
base_url: str,
payload: Mapping[str, Any],
) -> HttpRequestParts:
"""Build an HTTP request without renaming public OpenAPI fields."""
path_values = _mapping(payload, "path")
path = operation.path
for parameter in operation.effective_parameters:
if parameter.get("in") != "path":
continue
name = parameter.get("name")
if not isinstance(name, str):
raise ValueError(
f"path parameter metadata for {operation.name!r} is invalid"
)
if name not in path_values:
raise ValueError(f"missing path parameter {name!r}")
path = path.replace("{" + name + "}", quote(str(path_values[name]), safe=""))
return HttpRequestParts(
method=operation.method.upper(),
url=base_url.rstrip("/") + path,
params=dict(_mapping(payload, "query")),
headers={
str(key): str(value) for key, value in _mapping(payload, "header").items()
},
cookies={
str(key): str(value) for key, value in _mapping(payload, "cookie").items()
},
json=payload.get("body"),
)
def _mapping(payload: Mapping[str, Any], key: str) -> Mapping[str, Any]:
"""Read one OpenAPI parameter group and reject lossy non-object values."""
value = payload.get(key, {})
if not isinstance(value, Mapping):
raise ValueError(f"{key} must be an object")
return value
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any, cast
from .models import JsonObject, OpenApiOperation
PARAMETER_LOCATIONS = ("path", "query", "header", "cookie")
def input_schema_for_operation(operation: OpenApiOperation) -> JsonObject:
"""Build the node input JSON Schema from operation params and JSON body.
OpenAPI schemas containing `$ref` are not self-contained; validate them
with the original document context. This adapter intentionally does not
resolve references.
"""
properties: dict[str, Any] = {}
required: list[str] = []
for group_name, group_schema in _parameter_group_schemas(operation).items():
properties[group_name] = group_schema
if group_schema.get("required"):
required.append(group_name)
body_schema = _json_request_body_schema(operation)
if body_schema is not None:
properties["body"] = body_schema
if _request_body_required(operation):
required.append("body")
return {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": False,
}
def output_schema_for_operation(operation: OpenApiOperation) -> JsonObject:
"""Build generic transport output schema for one OpenAPI operation."""
body_schema = _first_success_json_response_schema(operation) or {}
return {
"type": "object",
"properties": {
"status_code": {"type": "integer"},
"headers": {
"type": "object",
"additionalProperties": {"type": "string"},
},
"body": body_schema,
},
"required": ["status_code", "headers", "body"],
"additionalProperties": False,
}
def _parameter_group_schemas(operation: OpenApiOperation) -> dict[str, JsonObject]:
grouped: dict[str, dict[str, Any]] = {}
for parameter in operation.effective_parameters:
if not isinstance(parameter, dict):
continue
location = parameter.get("in")
name = parameter.get("name")
schema = parameter.get("schema")
if location not in PARAMETER_LOCATIONS:
continue
if not isinstance(name, str) or not isinstance(schema, dict):
continue
# Group by OpenAPI parameter location so workflow inputs stay explicit:
# {"path": {...}, "query": {...}} instead of one ambiguous flat object.
group = grouped.setdefault(
location,
{
"type": "object",
"properties": {},
"required": [],
"additionalProperties": False,
},
)
group["properties"][name] = deepcopy(schema)
if parameter.get("required") is True:
group["required"].append(name)
return {
name: cast(JsonObject, grouped[name])
for name in PARAMETER_LOCATIONS
if name in grouped
}
def _json_request_body_schema(operation: OpenApiOperation) -> JsonObject | None:
request_body = operation.raw_operation.get("requestBody")
if not isinstance(request_body, dict):
return None
content = request_body.get("content")
if not isinstance(content, dict):
return None
json_media = content.get("application/json")
if not isinstance(json_media, dict):
return None
schema = json_media.get("schema")
return cast(JsonObject, deepcopy(schema)) if isinstance(schema, dict) else None
def _request_body_required(operation: OpenApiOperation) -> bool:
request_body = operation.raw_operation.get("requestBody")
return isinstance(request_body, dict) and request_body.get("required") is True
def _first_success_json_response_schema(
operation: OpenApiOperation,
) -> JsonObject | None:
responses = operation.raw_operation.get("responses")
if not isinstance(responses, dict):
return None
for code in sorted(responses):
if not str(code).startswith("2"):
continue
response = responses[code]
if not isinstance(response, dict):
continue
content = response.get("content")
if not isinstance(content, dict):
continue
json_media = content.get("application/json")
if not isinstance(json_media, dict):
continue
schema = json_media.get("schema")
if isinstance(schema, dict):
return cast(JsonObject, deepcopy(schema))
return None
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from pathlib import Path
from pydantic import BaseModel, ConfigDict
from wf_authoring import NodeReturn, NodeSpec
from wf_core import RuntimeContext
from wf_platform import (
CapabilityBuckets,
CapabilitySource,
SourcePermissions,
SourceVisibility,
)
from .executor import (
OpenApiExecutionConfig,
OpenApiOperationOutput,
call_openapi_operation,
)
from .schemas import input_schema_for_operation, output_schema_for_operation
from .spec import load_openapi_operations
from .validation import load_openapi_app
OPENAPI_OUTCOMES = (
"ok",
"http_error",
"unexpected_status",
"validation_error",
"transport_error",
)
class OpenApiNodePayload(BaseModel):
"""Loose runtime boundary; public validation remains the JSON Schema contract."""
model_config = ConfigDict(extra="allow")
def build_openapi_capability_source(
*,
source_id: str,
document_path: Path,
base_url: str,
) -> CapabilitySource:
"""Build NodeSpecs from public OpenAPI schemas and generic HTTP execution."""
app = load_openapi_app(document_path)
operations = load_openapi_operations(document_path)
specs: dict[str, NodeSpec[OpenApiNodePayload, OpenApiOperationOutput]] = {}
for operation in operations:
name = f"{source_id}.{operation.name}"
config = OpenApiExecutionConfig(
base_url=base_url,
)
async def handler(
payload: OpenApiNodePayload,
ctx: RuntimeContext,
*,
_app=app,
_operation=operation,
_config: OpenApiExecutionConfig = config,
) -> NodeReturn[OpenApiOperationOutput]:
_ = ctx
return await call_openapi_operation(
_app,
_operation,
_config,
payload.model_dump(mode="json"),
)
specs[name] = NodeSpec(
name=name,
input_model=OpenApiNodePayload,
output_model=OpenApiOperationOutput,
outcomes=OPENAPI_OUTCOMES,
fn=handler,
description=operation.summary or operation.description,
is_async=True,
input_schema_contract=input_schema_for_operation(operation),
output_schema_contract=output_schema_for_operation(operation),
)
return CapabilitySource(
id=source_id,
kind="connection",
capabilities=CapabilityBuckets(node_specs=specs),
enabled=True,
visibility=SourceVisibility(
planner=True,
mcp_client=False,
admin_dashboard=True,
),
permissions=SourcePermissions(
calls_upstream=True,
),
description=f"OpenAPI capability source for {document_path.name}.",
)
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any, Literal, cast
from .models import JsonObject, OpenApiOperation
HTTP_METHOD_ORDER: tuple[str, ...] = (
"get",
"post",
"put",
"patch",
"delete",
"options",
"head",
)
HTTP_METHODS: set[str] = set(HTTP_METHOD_ORDER)
def load_openapi_document(path: Path) -> JsonObject:
"""Load one local OpenAPI JSON document.
This is only file IO plus basic object-shape checking. Full OpenAPI
validation belongs to an OpenAPI validator dependency, not this module.
"""
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("OpenAPI document must be a JSON object")
return cast(JsonObject, payload)
def load_openapi_operations(path: Path) -> list[OpenApiOperation]:
"""Return workflow-stable operation inventory from an OpenAPI document."""
document = load_openapi_document(path)
paths = document.get("paths")
if not isinstance(paths, dict):
raise ValueError("OpenAPI document must contain object field 'paths'")
operations: list[OpenApiOperation] = []
operation_names: set[str] = set()
for raw_path in sorted(paths):
path_item = paths[raw_path]
if not isinstance(raw_path, str) or not isinstance(path_item, dict):
continue
for method in HTTP_METHOD_ORDER:
raw_operation = path_item.get(method)
if not isinstance(raw_operation, dict):
continue
operation_id = raw_operation.get("operationId")
if operation_id is None:
operation_name = _fallback_operation_name(method, raw_path)
operation_id = operation_name
elif isinstance(operation_id, str):
operation_name = _operation_name(operation_id)
if not operation_name:
raise ValueError(
f"OpenAPI operationId {operation_id!r} does not produce a usable operation name"
)
else:
operation_name = _fallback_operation_name(method, raw_path)
operation_id = operation_name
if operation_name in operation_names:
raise ValueError(
f"Duplicate normalized OpenAPI operation name {operation_name!r}"
)
operation_names.add(operation_name)
operations.append(
OpenApiOperation(
name=operation_name,
operation_id=operation_id,
method=cast(
Literal[
"get", "post", "put", "patch", "delete", "options", "head"
],
method,
),
path=raw_path,
summary=_optional_string(raw_operation.get("summary")),
description=_optional_string(raw_operation.get("description")),
effective_parameters=_effective_parameters(
path_item, raw_operation
),
has_request_body="requestBody" in raw_operation,
raw_operation=cast(dict[str, Any], raw_operation),
document_path=path,
)
)
return operations
def _effective_parameters(
path_item: dict[str, Any], raw_operation: dict[str, Any]
) -> tuple[JsonObject, ...]:
"""Merge inherited and local parameters using OpenAPI override identity.
A parameter is identified by its public `(name, in)` pair. Operation-local
entries replace matching path-item entries while retaining inherited entries
that are not overridden.
"""
merged: dict[tuple[object, object], JsonObject] = {}
for owner in (path_item, raw_operation):
parameters = owner.get("parameters", [])
if not isinstance(parameters, list):
continue
for parameter in parameters:
if not isinstance(parameter, dict):
continue
key = (parameter.get("name"), parameter.get("in"))
merged[key] = cast(JsonObject, parameter)
return tuple(merged.values())
def _operation_name(operation_id: str) -> str:
"""Convert operationId into a stable snake_case workflow capability key."""
words = re.sub(r"(?<!^)(?=[A-Z])", "_", operation_id).replace("-", "_")
return re.sub(r"_+", "_", re.sub(r"[^A-Za-z0-9_]+", "_", words)).strip("_").lower()
def _fallback_operation_name(method: str, raw_path: str) -> str:
"""Build a usable name for operations that omit operationId.
The HTTP method alone is not specific enough, so paths with no usable
normalized segments are rejected instead of producing ambiguous names.
"""
path_segments = [
normalized
for segment in raw_path.split("/")
if (normalized := _operation_name(segment.strip("{}")))
]
if not path_segments:
raise ValueError(
f"OpenAPI fallback operation name for {method.upper()} {raw_path} "
"does not produce a usable operation name"
)
return "_".join([method, *path_segments])
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) else None
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from openapi_core import OpenAPI
from openapi_core.datatypes import RequestParameters
from werkzeug.datastructures import Headers, ImmutableMultiDict
from .request import HttpRequestParts
@dataclass(frozen=True, slots=True)
class HttpResponseParts:
"""HTTP response data in the minimal shape `openapi-core` needs."""
status_code: int
headers: dict[str, str] = field(default_factory=dict)
data: bytes | None = None
@dataclass(frozen=True, slots=True)
class OpenApiValidationResult:
"""Small validation result that keeps `openapi-core` details local."""
valid: bool
errors: list[str] = field(default_factory=list)
data: Any = None
def load_openapi_app(document_path: Path) -> OpenAPI:
"""Load an OpenAPI app used for request/response validation."""
return OpenAPI.from_file_path(str(document_path))
def validate_openapi_request(
app: OpenAPI,
request: HttpRequestParts,
) -> OpenApiValidationResult:
"""Validate and unmarshal one outgoing request."""
try:
result = app.unmarshal_request(_OpenApiCoreRequest(request))
except Exception as exc:
return OpenApiValidationResult(valid=False, errors=[str(exc)])
errors = _error_messages(getattr(result, "errors", []))
if errors:
return OpenApiValidationResult(valid=False, errors=errors)
return OpenApiValidationResult(valid=True, data=result)
def validate_openapi_response(
app: OpenAPI,
request: HttpRequestParts,
response: HttpResponseParts,
) -> OpenApiValidationResult:
"""Validate and unmarshal one incoming response."""
try:
result = app.unmarshal_response(
_OpenApiCoreRequest(request),
_OpenApiCoreResponse(response),
)
except Exception as exc:
return OpenApiValidationResult(valid=False, errors=[str(exc)])
errors = _error_messages(getattr(result, "errors", []))
if errors:
return OpenApiValidationResult(valid=False, errors=errors)
return OpenApiValidationResult(valid=True, data=result.data)
def _error_messages(errors: object) -> list[str]:
"""Normalize `openapi-core` result errors without exporting its classes."""
if not errors:
return []
if not isinstance(errors, list):
return [str(errors)]
return [str(error) for error in errors]
class _OpenApiCoreRequest:
"""Protocol shim from local request parts to `openapi-core`.
The rest of `wf_openapi` should talk in terms of `HttpRequestParts`; this
adapter is the only place that knows `openapi-core`'s protocol attributes.
"""
def __init__(self, request: HttpRequestParts) -> None:
self._request = request
self._url = urlparse(request.url, allow_fragments=False)
self.parameters = RequestParameters(
query=ImmutableMultiDict(request.params.items()),
header=Headers(request.headers),
cookie=ImmutableMultiDict(request.cookies.items()),
)
@property
def host_url(self) -> str:
return f"{self._url.scheme}://{self._url.netloc}"
@property
def path(self) -> str:
return self._url.path
@property
def method(self) -> str:
return self._request.method.lower()
@property
def body(self) -> bytes | None:
if self._request.json is None:
return None
return json.dumps(self._request.json).encode()
@property
def content_type(self) -> str:
if self._request.json is not None:
return "application/json"
return self._request.headers.get("content-type", "")
class _OpenApiCoreResponse:
"""Protocol shim from local response parts to `openapi-core`."""
def __init__(self, response: HttpResponseParts) -> None:
self._response = response
@property
def status_code(self) -> int:
return self._response.status_code
@property
def content_type(self) -> str:
return self._response.headers.get("content-type", "")
@property
def headers(self) -> Headers:
return Headers(self._response.headers)
@property
def data(self) -> bytes | None:
return self._response.data
@@ -0,0 +1,105 @@
{
"openapi": "3.1.0",
"info": {
"title": "Petstore Minimal",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.test"
}
],
"paths": {
"/pets/{petId}": {
"parameters": [
{
"name": "petId",
"in": "path",
"required": true,
"schema": { "type": "string" }
}
],
"get": {
"operationId": "getPet",
"parameters": [
{
"name": "includeOwner",
"in": "query",
"required": false,
"schema": { "type": "boolean", "default": false }
}
],
"responses": {
"200": {
"description": "Pet found",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Pet" }
}
}
},
"404": {
"description": "Pet not found",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Error" }
}
}
}
}
}
},
"/pets": {
"post": {
"operationId": "createPet",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CreatePetRequest" }
}
}
},
"responses": {
"201": {
"description": "Pet created",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Pet" }
}
}
}
}
}
}
},
"components": {
"schemas": {
"CreatePetRequest": {
"type": "object",
"properties": {
"name": { "type": "string" }
},
"required": ["name"],
"additionalProperties": false
},
"Pet": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
},
"required": ["id", "name"],
"additionalProperties": false
},
"Error": {
"type": "object",
"properties": {
"message": { "type": "string" }
},
"required": ["message"],
"additionalProperties": false
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from wf_openapi.executor import OpenApiExecutionConfig
def test_openapi_runtime_does_not_require_generated_manifest() -> None:
config = OpenApiExecutionConfig(base_url="https://api.example.test")
assert config.base_url == "https://api.example.test"
assert not hasattr(config, "generated_package")
assert not hasattr(config, "operation_modules")
assert not hasattr(config, "parameter_arguments")
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
import asyncio
from pathlib import Path
import httpx
from wf_authoring import NodeReturn
from wf_openapi.executor import OpenApiExecutionConfig, call_openapi_operation
from wf_openapi.executor import OpenApiOperationOutput
from wf_openapi.spec import load_openapi_operations
from wf_openapi.validation import load_openapi_app
FIXTURE = Path("tests/openapi/fixtures/petstore_minimal.openapi.json")
def test_call_openapi_operation_maps_success() -> None:
app = load_openapi_app(FIXTURE)
operation = next(
op for op in load_openapi_operations(FIXTURE) if op.name == "get_pet"
)
async def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/pets/pet-1"
assert request.url.params["includeOwner"] == "true"
return httpx.Response(200, json={"id": "pet-1", "name": "Fluffy"})
async def run() -> NodeReturn[OpenApiOperationOutput]:
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
return await call_openapi_operation(
app,
operation,
OpenApiExecutionConfig(base_url="https://api.example.test"),
{"path": {"petId": "pet-1"}, "query": {"includeOwner": "true"}},
client=client,
)
result = asyncio.run(run())
assert result.outcome == "ok"
assert result.output.status_code == 200
assert result.output.body["id"] == "pet-1"
def test_call_openapi_operation_maps_declared_http_error() -> None:
app = load_openapi_app(FIXTURE)
operation = next(
op for op in load_openapi_operations(FIXTURE) if op.name == "get_pet"
)
async def handler(request: httpx.Request) -> httpx.Response:
_ = request
return httpx.Response(404, json={"message": "missing"})
async def run() -> NodeReturn[OpenApiOperationOutput]:
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
return await call_openapi_operation(
app,
operation,
OpenApiExecutionConfig(base_url="https://api.example.test"),
{"path": {"petId": "missing"}},
client=client,
)
result = asyncio.run(run())
assert result.outcome == "http_error"
assert result.output.status_code == 404
assert result.output.body["message"] == "missing"
def test_call_openapi_operation_maps_unexpected_status() -> None:
app = load_openapi_app(FIXTURE)
operation = next(
op for op in load_openapi_operations(FIXTURE) if op.name == "get_pet"
)
async def handler(request: httpx.Request) -> httpx.Response:
_ = request
return httpx.Response(418, json={"message": "teapot"})
async def run() -> NodeReturn[OpenApiOperationOutput]:
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
return await call_openapi_operation(
app,
operation,
OpenApiExecutionConfig(base_url="https://api.example.test"),
{"path": {"petId": "pet-1"}},
client=client,
)
result = asyncio.run(run())
assert result.outcome == "unexpected_status"
assert result.output.status_code == 418
assert result.output.validation_errors
def test_call_openapi_operation_maps_invalid_request_to_validation_error() -> None:
app = load_openapi_app(FIXTURE)
operation = next(
op for op in load_openapi_operations(FIXTURE) if op.name == "create_pet"
)
async def handler(request: httpx.Request) -> httpx.Response:
raise AssertionError("invalid request should not be sent")
async def run() -> NodeReturn[OpenApiOperationOutput]:
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
return await call_openapi_operation(
app,
operation,
OpenApiExecutionConfig(base_url="https://api.example.test"),
{"body": {"extra": "field"}},
client=client,
)
result = asyncio.run(run())
assert result.outcome == "validation_error"
assert result.output.status_code == 0
assert result.output.validation_errors
def test_call_openapi_operation_maps_invalid_response_to_validation_error() -> None:
app = load_openapi_app(FIXTURE)
operation = next(
op for op in load_openapi_operations(FIXTURE) if op.name == "get_pet"
)
async def handler(request: httpx.Request) -> httpx.Response:
_ = request
return httpx.Response(200, json={"id": "pet-1"})
async def run() -> NodeReturn[OpenApiOperationOutput]:
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
return await call_openapi_operation(
app,
operation,
OpenApiExecutionConfig(base_url="https://api.example.test"),
{"path": {"petId": "pet-1"}},
client=client,
)
result = asyncio.run(run())
assert result.outcome == "validation_error"
assert result.output.status_code == 200
assert result.output.validation_errors
def test_call_openapi_operation_maps_transport_error() -> None:
app = load_openapi_app(FIXTURE)
operation = next(
op for op in load_openapi_operations(FIXTURE) if op.name == "get_pet"
)
async def handler(request: httpx.Request) -> httpx.Response:
_ = request
raise httpx.ConnectError("offline")
async def run() -> NodeReturn[OpenApiOperationOutput]:
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
return await call_openapi_operation(
app,
operation,
OpenApiExecutionConfig(base_url="https://api.example.test"),
{"path": {"petId": "pet-1"}},
client=client,
)
result = asyncio.run(run())
assert result.outcome == "transport_error"
assert result.output.status_code == 0
assert result.output.validation_errors == ["offline"]
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import json
from pathlib import Path
from wf_openapi.request import HttpRequestParts, build_http_request_parts
from wf_openapi.spec import load_openapi_operations
from wf_openapi.validation import (
HttpResponseParts,
load_openapi_app,
validate_openapi_request,
validate_openapi_response,
)
FIXTURE = Path(__file__).parent / "fixtures" / "petstore_minimal.openapi.json"
def test_validate_openapi_request_accepts_public_payload() -> None:
app = load_openapi_app(FIXTURE)
operation = next(
operation
for operation in load_openapi_operations(FIXTURE)
if operation.name == "get_pet"
)
parts = build_http_request_parts(
operation,
base_url="https://api.example.test",
payload={"path": {"petId": "pet-1"}, "query": {"includeOwner": "true"}},
)
result = validate_openapi_request(app, parts)
assert result.valid is True
assert result.errors == []
def test_validate_openapi_request_reports_invalid_body() -> None:
app = load_openapi_app(FIXTURE)
operation = next(
operation
for operation in load_openapi_operations(FIXTURE)
if operation.name == "create_pet"
)
parts = build_http_request_parts(
operation,
base_url="https://api.example.test",
payload={"body": {"extra": "field"}},
)
result = validate_openapi_request(app, parts)
assert result.valid is False
assert result.errors
def test_validate_openapi_response_accepts_declared_response() -> None:
app = load_openapi_app(FIXTURE)
operation = next(
operation
for operation in load_openapi_operations(FIXTURE)
if operation.name == "get_pet"
)
request = build_http_request_parts(
operation,
base_url="https://api.example.test",
payload={"path": {"petId": "pet-1"}},
)
response = HttpResponseParts(
status_code=200,
headers={"content-type": "application/json"},
data=json.dumps({"id": "pet-1", "name": "Fluffy"}).encode(),
)
result = validate_openapi_response(app, request, response)
assert result.valid is True
assert result.errors == []
assert result.data["id"] == "pet-1"
def test_validate_openapi_response_reports_undeclared_status() -> None:
app = load_openapi_app(FIXTURE)
request = HttpRequestParts(
method="GET",
url="https://api.example.test/pets/pet-1",
)
response = HttpResponseParts(
status_code=418,
headers={"content-type": "application/json"},
data=b'{"message": "teapot"}',
)
result = validate_openapi_response(app, request, response)
assert result.valid is False
assert result.errors
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
from pathlib import Path
import pytest
from wf_openapi.request import build_http_request_parts
from wf_openapi.spec import load_openapi_operations
FIXTURE = Path(__file__).parent / "fixtures" / "petstore_minimal.openapi.json"
def test_build_http_request_parts_uses_public_openapi_names() -> None:
operation = next(
operation
for operation in load_openapi_operations(FIXTURE)
if operation.name == "get_pet"
)
parts = build_http_request_parts(
operation,
base_url="https://api.example.test/v1",
payload={
"path": {"petId": "pet 1"},
"query": {"includeOwner": True},
"header": {"X-Trace-ID": "trace-1"},
"cookie": {"sessionId": "session-1"},
},
)
assert parts.method == "GET"
assert parts.url == "https://api.example.test/v1/pets/pet%201"
assert parts.params["includeOwner"] is True
assert parts.headers["X-Trace-ID"] == "trace-1"
assert parts.cookies["sessionId"] == "session-1"
def test_build_http_request_parts_requires_path_parameters() -> None:
operation = next(
operation
for operation in load_openapi_operations(FIXTURE)
if operation.name == "get_pet"
)
with pytest.raises(ValueError, match="missing path parameter 'petId'"):
build_http_request_parts(
operation,
base_url="https://api.example.test",
payload={"path": {}},
)
@pytest.mark.parametrize("group", ["path", "query", "header", "cookie"])
def test_build_http_request_parts_rejects_non_object_parameter_groups(
group: str,
) -> None:
operation = next(
operation
for operation in load_openapi_operations(FIXTURE)
if operation.name == "get_pet"
)
with pytest.raises(ValueError, match=rf"{group} must be an object"):
payload: dict[str, object] = {"path": {"petId": "pet-1"}}
payload[group] = ["not", "an", "object"]
build_http_request_parts(
operation,
base_url="https://api.example.test",
payload=payload,
)
def test_build_http_request_parts_passes_body_through_as_json() -> None:
operation = next(
operation
for operation in load_openapi_operations(FIXTURE)
if operation.name == "create_pet"
)
parts = build_http_request_parts(
operation,
base_url="https://api.example.test",
payload={"body": {"name": "Fluffy"}},
)
assert parts.method == "POST"
assert parts.url == "https://api.example.test/pets"
assert isinstance(parts.json, dict)
assert parts.json["name"] == "Fluffy"
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from openapi_core import OpenAPI
from wf_authoring import NodeReturn
from wf_core import RuntimeContext
from wf_openapi import source as source_module
from wf_openapi.executor import OpenApiExecutionConfig, OpenApiOperationOutput
from wf_openapi.models import OpenApiOperation
from wf_openapi.source import build_openapi_capability_source
FIXTURE = Path(__file__).parent / "fixtures" / "petstore_minimal.openapi.json"
def test_build_openapi_capability_source_exposes_operations_as_node_specs() -> None:
source = build_openapi_capability_source(
source_id="petstore.default",
document_path=FIXTURE,
base_url="https://api.example.test",
)
assert source.id == "petstore.default"
assert source.kind == "connection"
assert sorted(source.capabilities.node_specs) == [
"petstore.default.create_pet",
"petstore.default.get_pet",
]
spec = source.capabilities.node_specs["petstore.default.get_pet"]
node_def = spec.to_node_def()
assert spec.name == "petstore.default.get_pet"
assert spec.is_async is True
assert (
node_def.input_schema.properties["path"]["properties"]["petId"]["type"]
== "string"
)
assert (
node_def.output_schema.properties["body"]["$ref"] == "#/components/schemas/Pet"
)
assert spec.outcomes == (
"ok",
"http_error",
"unexpected_status",
"validation_error",
"transport_error",
)
def test_source_node_passes_operation_config_and_payload_to_execution(
monkeypatch,
) -> None:
captured: dict[str, Any] = {}
async def capture_call(
app: OpenAPI,
operation: OpenApiOperation,
config: OpenApiExecutionConfig,
payload: dict[str, object],
) -> NodeReturn[OpenApiOperationOutput]:
captured["app"] = app
captured["operation"] = operation
captured["config"] = config
captured["payload"] = payload
return NodeReturn(
outcome="ok",
output=OpenApiOperationOutput(
status_code=200,
headers={},
body={},
validation_errors=[],
),
)
monkeypatch.setattr(source_module, "call_openapi_operation", capture_call)
source = build_openapi_capability_source(
source_id="petstore.default",
document_path=FIXTURE,
base_url="https://api.example.test",
)
handler = source.capabilities.node_specs[
"petstore.default.get_pet"
].to_async_registry_handler()
async def run_handler() -> dict[str, object]:
return await handler(
{"path": {"petId": "pet-1"}},
RuntimeContext(current_node_id="petstore.default.get_pet"),
)
asyncio.run(run_handler())
operation = captured["operation"]
config = captured["config"]
assert isinstance(captured["app"], OpenAPI)
assert isinstance(operation, OpenApiOperation)
assert operation.name == "get_pet"
assert isinstance(config, OpenApiExecutionConfig)
assert config.base_url == "https://api.example.test"
assert captured["payload"]["path"]["petId"] == "pet-1"
+326
View File
@@ -0,0 +1,326 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from wf_openapi.spec import load_openapi_operations
from wf_openapi.schemas import input_schema_for_operation, output_schema_for_operation
FIXTURE = Path(__file__).parent / "fixtures" / "petstore_minimal.openapi.json"
def test_load_openapi_operations_discovers_operation_ids() -> None:
operations = load_openapi_operations(FIXTURE)
names = [operation.name for operation in operations]
assert names == ["create_pet", "get_pet"]
assert operations[0].operation_id == "createPet"
assert operations[0].method == "post"
assert operations[0].path == "/pets"
def test_load_openapi_operations_uses_deterministic_path_and_method_order(
tmp_path: Path,
) -> None:
spec_path = _write_openapi(
tmp_path,
{
"/z-last": {
"post": {"operationId": "createLast"},
"get": {"operationId": "getLast"},
},
"/a-first": {
"delete": {"operationId": "deleteFirst"},
"get": {"operationId": "getFirst"},
"post": {"operationId": "createFirst"},
},
},
)
operations = load_openapi_operations(spec_path)
assert [operation.name for operation in operations] == [
"get_first",
"create_first",
"delete_first",
"get_last",
"create_last",
]
def test_load_openapi_operations_rejects_duplicate_normalized_operation_names(
tmp_path: Path,
) -> None:
spec_path = _write_openapi(
tmp_path,
{
"/pets": {
"get": {"operationId": "get-pet"},
"post": {"operationId": "get_pet"},
},
},
)
with pytest.raises(
ValueError, match="Duplicate normalized OpenAPI operation name 'get_pet'"
):
load_openapi_operations(spec_path)
@pytest.mark.parametrize(
("paths", "message"),
[
(
{"/pets": {"get": {"operationId": "!!!"}}},
"OpenAPI operationId '!!!' does not produce a usable operation name",
),
(
{"///": {"get": {}}},
"OpenAPI fallback operation name for GET /// does not produce a usable operation name",
),
],
)
def test_load_openapi_operations_rejects_unusable_operation_names(
tmp_path: Path,
paths: dict[str, object],
message: str,
) -> None:
spec_path = _write_openapi(tmp_path, paths)
with pytest.raises(ValueError, match=message):
load_openapi_operations(spec_path)
def test_operation_input_schema_combines_params_and_body() -> None:
operations = load_openapi_operations(FIXTURE)
create_pet = operations[0]
get_pet = operations[1]
create_schema = input_schema_for_operation(create_pet)
get_schema = input_schema_for_operation(get_pet)
assert (
create_schema["properties"]["body"]["$ref"]
== "#/components/schemas/CreatePetRequest"
)
assert "body" in create_schema["required"]
assert get_schema["properties"]["path"]["properties"]["petId"]["type"] == "string"
assert (
get_schema["properties"]["query"]["properties"]["includeOwner"]["type"]
== "boolean"
)
assert "body" not in get_schema["properties"]
def test_operation_input_schema_emits_parameter_groups_in_canonical_order(
tmp_path: Path,
) -> None:
spec_path = _write_openapi(
tmp_path,
{
"/pets/{petId}": {
"get": {
"operationId": "getPet",
"parameters": [
{
"name": "session",
"in": "cookie",
"schema": {"type": "string"},
},
{"name": "trace", "in": "header", "schema": {"type": "string"}},
{
"name": "includeOwner",
"in": "query",
"schema": {"type": "boolean"},
},
{
"name": "petId",
"in": "path",
"required": True,
"schema": {"type": "string"},
},
],
},
},
},
)
operation = load_openapi_operations(spec_path)[0]
schema = input_schema_for_operation(operation)
assert list(schema["properties"]) == ["path", "query", "header", "cookie"]
def test_operation_effective_parameters_inherit_path_item_and_apply_operation_overrides(
tmp_path: Path,
) -> None:
spec_path = _write_openapi(
tmp_path,
{
"/pets": {
"parameters": [
{
"name": "locale",
"in": "query",
"required": True,
"schema": {"type": "string"},
},
{
"name": "trace",
"in": "header",
"required": True,
"schema": {"type": "string"},
},
],
"get": {
"operationId": "listPets",
"parameters": [
{
"name": "trace",
"in": "header",
"schema": {"type": "integer"},
},
],
},
},
},
)
operation = load_openapi_operations(spec_path)[0]
schema = input_schema_for_operation(operation)
assert [
(parameter["name"], parameter["in"])
for parameter in operation.effective_parameters
] == [
("locale", "query"),
("trace", "header"),
]
assert schema["properties"]["query"]["properties"]["locale"]["type"] == "string"
assert schema["properties"]["header"]["properties"]["trace"]["type"] == "integer"
assert schema["properties"]["header"]["required"] == []
def test_operation_records_optional_request_body_for_execution_boundary(
tmp_path: Path,
) -> None:
operation = load_openapi_operations(
_write_openapi(
tmp_path,
{
"/pets": {
"post": {
"operationId": "createPet",
"requestBody": {
"required": False,
"content": {
"application/json": {"schema": {"type": "object"}},
},
},
},
},
},
)
)[0]
assert operation.has_request_body is True
assert "body" not in input_schema_for_operation(operation)["required"]
def test_extracted_schemas_are_copied_from_raw_operation() -> None:
operations = load_openapi_operations(FIXTURE)
create_pet = operations[0]
get_pet = operations[1]
input_schema = input_schema_for_operation(get_pet)
create_input_schema = input_schema_for_operation(create_pet)
output_schema = output_schema_for_operation(create_pet)
input_schema["properties"]["query"]["properties"]["includeOwner"]["type"] = "string"
create_input_schema["properties"]["body"]["$ref"] = "#/mutated/request"
output_schema["properties"]["body"]["$ref"] = "#/mutated/response"
raw_query_schema = get_pet.raw_operation["parameters"][0]["schema"]
raw_request_schema = create_pet.raw_operation["requestBody"]["content"][
"application/json"
]["schema"]
raw_response_schema = create_pet.raw_operation["responses"]["201"]["content"][
"application/json"
]["schema"]
assert raw_query_schema["type"] == "boolean"
assert raw_request_schema["$ref"] == "#/components/schemas/CreatePetRequest"
assert raw_response_schema["$ref"] == "#/components/schemas/Pet"
second_input_schema = input_schema_for_operation(get_pet)
second_create_input_schema = input_schema_for_operation(create_pet)
second_output_schema = output_schema_for_operation(create_pet)
assert (
second_input_schema["properties"]["query"]["properties"]["includeOwner"]["type"]
== "boolean"
)
assert (
second_create_input_schema["properties"]["body"]["$ref"]
== "#/components/schemas/CreatePetRequest"
)
assert (
second_output_schema["properties"]["body"]["$ref"] == "#/components/schemas/Pet"
)
def test_operation_output_schema_uses_first_success_json_response() -> None:
operations = load_openapi_operations(FIXTURE)
schema = output_schema_for_operation(operations[0])
assert schema["properties"]["status_code"]["type"] == "integer"
assert schema["properties"]["headers"]["type"] == "object"
assert schema["properties"]["body"]["$ref"] == "#/components/schemas/Pet"
def test_operation_output_schema_uses_empty_body_when_no_success_json_schema(
tmp_path: Path,
) -> None:
spec_path = _write_openapi(
tmp_path,
{
"/pets": {
"post": {
"operationId": "createPet",
"responses": {
"204": {"description": "No content"},
"400": {
"description": "Invalid pet",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Error"}
}
},
},
},
},
},
},
)
operation = load_openapi_operations(spec_path)[0]
schema = output_schema_for_operation(operation)
assert schema["properties"]["body"] == {}
def _write_openapi(tmp_path: Path, paths: dict[str, object]) -> Path:
"""Write the minimum document shape needed by the operation inventory tests."""
spec_path = tmp_path / "openapi.json"
spec_path.write_text(
json.dumps(
{
"openapi": "3.1.0",
"info": {"title": "Test", "version": "1"},
"paths": paths,
}
),
encoding="utf-8",
)
return spec_path
Generated
+143
View File
@@ -422,6 +422,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "isodate"
version = "0.7.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" },
]
[[package]] [[package]]
name = "jaraco-classes" name = "jaraco-classes"
version = "3.4.0" version = "3.4.0"
@@ -564,15 +573,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
] ]
[[package]]
name = "lazy-object-proxy"
version = "1.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582, upload-time = "2025-08-22T13:49:49.366Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059, upload-time = "2025-08-22T13:49:50.488Z" },
{ url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034, upload-time = "2025-08-22T13:49:54.224Z" },
{ url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529, upload-time = "2025-08-22T13:49:55.29Z" },
{ url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391, upload-time = "2025-08-22T13:49:56.35Z" },
{ url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" },
]
[[package]] [[package]]
name = "lda-wf" name = "lda-wf"
version = "0.0.1" version = "0.0.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastmcp" }, { name = "fastmcp" },
{ name = "httpx" },
{ name = "jsonpatch" }, { name = "jsonpatch" },
{ name = "jsonschema" }, { name = "jsonschema" },
{ name = "mcp", extra = ["cli", "rich"] }, { name = "mcp", extra = ["cli", "rich"] },
{ name = "openapi-core" },
{ name = "pydantic" }, { name = "pydantic" },
] ]
@@ -584,9 +609,11 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastmcp", specifier = ">=3.2.4" }, { name = "fastmcp", specifier = ">=3.2.4" },
{ name = "httpx", specifier = ">=0.28" },
{ name = "jsonpatch", specifier = ">=1.33" }, { name = "jsonpatch", specifier = ">=1.33" },
{ name = "jsonschema", specifier = ">=4.26" }, { name = "jsonschema", specifier = ">=4.26" },
{ name = "mcp", extras = ["cli", "rich"], specifier = ">=1" }, { name = "mcp", extras = ["cli", "rich"], specifier = ">=1" },
{ name = "openapi-core", specifier = ">=0.19" },
{ name = "pydantic", specifier = ">=2" }, { name = "pydantic", specifier = ">=2" },
] ]
@@ -605,6 +632,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
] ]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]] [[package]]
name = "mcp" name = "mcp"
version = "1.27.0" version = "1.27.0"
@@ -657,6 +714,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" },
] ]
[[package]]
name = "openapi-core"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "isodate" },
{ name = "jsonschema" },
{ name = "jsonschema-path" },
{ name = "more-itertools" },
{ name = "openapi-schema-validator" },
{ name = "openapi-spec-validator" },
{ name = "typing-extensions" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/07/ad02621876c983308abe7bc9604b4d0141fde97fb49fb252ed9af5ad0090/openapi_core-0.23.1.tar.gz", hash = "sha256:8021c9cf5fbb356ea5694c233fbfba0dc7ec595dfbdc1d2b295ac13f3de4fdad", size = 124348, upload-time = "2026-04-02T23:47:59.177Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/a1/7c6ef56641a2c326a211ca7709197b2b9bd91fe02931460faa51d4a8bda1/openapi_core-0.23.1-py3-none-any.whl", hash = "sha256:40eda0b6e4c2aa0d0e4fb864e1f461b68bb59c5c41fe154c03fda49bf41d2dc9", size = 115211, upload-time = "2026-04-02T23:47:57.751Z" },
]
[[package]] [[package]]
name = "openapi-pydantic" name = "openapi-pydantic"
version = "0.5.1" version = "0.5.1"
@@ -669,6 +745,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" },
] ]
[[package]]
name = "openapi-schema-validator"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonschema" },
{ name = "jsonschema-specifications" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "referencing" },
{ name = "rfc3339-validator" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/4b/67b24b2b23d96ea862be2cca3632a546f67a22461200831213e80c3c6011/openapi_schema_validator-0.8.1.tar.gz", hash = "sha256:4c57266ce8cbfa37bb4eb4d62cdb7d19356c3a468e3535743c4562863e1790da", size = 23134, upload-time = "2026-03-02T08:46:29.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/87/e9f29f463b230d4b47d65e17858c595153a8ca8c1775f16e406aa82d455d/openapi_schema_validator-0.8.1-py3-none-any.whl", hash = "sha256:0f5859794c5bfa433d478dc5ac5e5768d50adc56b14380c8a6fd3a8113e89c9b", size = 19211, upload-time = "2026-03-02T08:46:28.154Z" },
]
[[package]]
name = "openapi-spec-validator"
version = "0.8.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonschema" },
{ name = "jsonschema-path" },
{ name = "lazy-object-proxy" },
{ name = "openapi-schema-validator" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/3f/aa0c1150627b4e683ae5673486b7d5cf2623a8821601863ee389e430965a/openapi_spec_validator-0.8.5.tar.gz", hash = "sha256:93b04ef5321d5866b2502371123d86333e5c1444f051d323e02525d9e83c7622", size = 1756845, upload-time = "2026-04-24T15:25:21.334Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/96/d7dfe1cc0be2df22d7a97ffb0f8bb00b10d92749aa6e64ffa7cc9a041580/openapi_spec_validator-0.8.5-py3-none-any.whl", hash = "sha256:3669106361856934153991e30714616a294865a33f6411a4c25d1dc2d08cfbc2", size = 50334, upload-time = "2026-04-24T15:25:19.65Z" },
]
[[package]] [[package]]
name = "opentelemetry-api" name = "opentelemetry-api"
version = "1.41.1" version = "1.41.1"
@@ -951,6 +1061,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
] ]
[[package]]
name = "rfc3339-validator"
version = "0.1.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "15.0.0" version = "15.0.0"
@@ -1036,6 +1158,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sse-starlette" name = "sse-starlette"
version = "3.3.4" version = "3.3.4"
@@ -1180,6 +1311,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
] ]
[[package]]
name = "werkzeug"
version = "3.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
]
[[package]] [[package]]
name = "zipp" name = "zipp"
version = "3.23.1" version = "3.23.1"