OpenAPI initial support as capability source
This commit is contained in:
@@ -84,6 +84,13 @@ implementation state.
|
||||
revalidates its pinned dependency environment and reports `blocked` without
|
||||
consuming input when a required source is unavailable. Ordinary live
|
||||
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
|
||||
notifications for long-running workflow execution. Avoid inventing a custom
|
||||
"start" convention unless protocol-native behavior is insufficient.
|
||||
|
||||
@@ -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.
|
||||
@@ -7,9 +7,11 @@ authors = [{ name = "lda", email = "[email protected]" }]
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"fastmcp>=3.2.4",
|
||||
"httpx>=0.28",
|
||||
"jsonpatch>=1.33",
|
||||
"jsonschema>=4.26",
|
||||
"mcp[cli,rich]>=1",
|
||||
"openapi-core>=0.19",
|
||||
"pydantic>=2",
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}.",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "jaraco-classes"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "lda-wf"
|
||||
version = "0.0.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastmcp" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mcp", extra = ["cli", "rich"] },
|
||||
{ name = "openapi-core" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
|
||||
@@ -584,9 +609,11 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastmcp", specifier = ">=3.2.4" },
|
||||
{ name = "httpx", specifier = ">=0.28" },
|
||||
{ name = "jsonpatch", specifier = ">=1.33" },
|
||||
{ name = "jsonschema", specifier = ">=4.26" },
|
||||
{ name = "mcp", extras = ["cli", "rich"], specifier = ">=1" },
|
||||
{ name = "openapi-core", specifier = ">=0.19" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "mcp"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "openapi-pydantic"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "opentelemetry-api"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "rich"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "sse-starlette"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "zipp"
|
||||
version = "3.23.1"
|
||||
|
||||
Reference in New Issue
Block a user