OpenAPI initial support as capability source

This commit is contained in:
lda
2026-05-27 18:57:25 +07:00 Verified
parent 38fadc9f10
commit 2d41336fed
21 changed files with 2541 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
from .executor import (
OpenApiExecutionConfig,
OpenApiOperationOutput,
call_openapi_operation,
)
from .models import OpenApiOperation
from .source import OPENAPI_OUTCOMES, build_openapi_capability_source
from .spec import load_openapi_document, load_openapi_operations
__all__ = [
"OPENAPI_OUTCOMES",
"OpenApiExecutionConfig",
"OpenApiOperationOutput",
"OpenApiOperation",
"build_openapi_capability_source",
"call_openapi_operation",
"load_openapi_document",
"load_openapi_operations",
]
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from openapi_core import OpenAPI
from pydantic import BaseModel, ConfigDict
from wf_authoring import NodeReturn
from .models import OpenApiOperation
from .request import HttpRequestParts, build_http_request_parts
from .validation import (
HttpResponseParts,
validate_openapi_request,
validate_openapi_response,
)
@dataclass(frozen=True, slots=True)
class OpenApiExecutionConfig:
"""Runtime config for spec-driven OpenAPI HTTP execution."""
base_url: str
timeout_seconds: float = 30.0
class OpenApiOperationOutput(BaseModel):
"""Generic transport output for raw OpenAPI operation nodes."""
model_config = ConfigDict(extra="allow")
status_code: int
headers: dict[str, str]
body: Any
validation_errors: list[str] = []
async def call_openapi_operation(
app: OpenAPI,
operation: OpenApiOperation,
config: OpenApiExecutionConfig,
payload: dict[str, Any],
*,
client: httpx.AsyncClient | None = None,
) -> NodeReturn[OpenApiOperationOutput]:
"""Execute one raw OpenAPI operation through generic HTTP machinery."""
request = build_http_request_parts(
operation,
base_url=config.base_url,
payload=payload,
)
request_validation = validate_openapi_request(app, request)
if not request_validation.valid:
return NodeReturn(
outcome="validation_error",
output=OpenApiOperationOutput(
status_code=0,
headers={},
body=None,
validation_errors=request_validation.errors,
),
)
close_client = client is None
active_client = client or httpx.AsyncClient(timeout=config.timeout_seconds)
try:
try:
response = await _send_request(active_client, request)
except httpx.HTTPError as exc:
return NodeReturn(
outcome="transport_error",
output=OpenApiOperationOutput(
status_code=0,
headers={},
body=None,
validation_errors=[str(exc)],
),
)
finally:
if close_client:
await active_client.aclose()
body = _response_body(response)
headers = {str(key): str(value) for key, value in response.headers.items()}
output = OpenApiOperationOutput(
status_code=response.status_code,
headers=headers,
body=body,
validation_errors=[],
)
if not _status_declared(operation, response.status_code):
output.validation_errors = [
f"response status {response.status_code} is not declared"
]
return NodeReturn(outcome="unexpected_status", output=output)
response_validation = validate_openapi_response(
app,
request,
HttpResponseParts(
status_code=response.status_code,
headers=headers,
data=response.content,
),
)
if not response_validation.valid:
output.validation_errors = response_validation.errors
return NodeReturn(outcome="validation_error", output=output)
if 200 <= response.status_code < 300:
return NodeReturn(outcome="ok", output=output)
return NodeReturn(outcome="http_error", output=output)
async def _send_request(
client: httpx.AsyncClient,
request: HttpRequestParts,
) -> httpx.Response:
kwargs: dict[str, Any] = {
"method": request.method,
"url": request.url,
}
if request.params:
kwargs["params"] = request.params
if request.headers:
kwargs["headers"] = request.headers
if request.cookies:
kwargs["cookies"] = request.cookies
if request.json is not None:
kwargs["json"] = request.json
return await client.request(**kwargs)
def _response_body(response: httpx.Response) -> Any:
content_type = response.headers.get("content-type", "").lower()
if not response.content:
return None
if "json" in content_type:
return response.json()
return response.text
def _status_declared(operation: OpenApiOperation, status_code: int) -> bool:
responses = operation.raw_operation.get("responses", {})
if not isinstance(responses, dict):
return False
status = str(status_code)
if status in responses or "default" in responses:
return True
status_range = f"{status[0]}XX"
return status_range in responses
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
JsonObject = dict[str, Any]
@dataclass(frozen=True, slots=True)
class OpenApiOperation:
"""Normalized operation metadata extracted from one OpenAPI document."""
name: str
operation_id: str
method: Literal["get", "post", "put", "patch", "delete", "options", "head"]
path: str
summary: str | None
description: str | None
effective_parameters: tuple[JsonObject, ...]
has_request_body: bool
raw_operation: JsonObject
document_path: Path
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Mapping
from urllib.parse import quote
from .models import OpenApiOperation
@dataclass(frozen=True, slots=True)
class HttpRequestParts:
"""OpenAPI-shaped request parts ready for `httpx` execution."""
method: str
url: str
params: dict[str, Any] = field(default_factory=dict)
headers: dict[str, str] = field(default_factory=dict)
cookies: dict[str, str] = field(default_factory=dict)
json: Any | None = None
def build_http_request_parts(
operation: OpenApiOperation,
*,
base_url: str,
payload: Mapping[str, Any],
) -> HttpRequestParts:
"""Build an HTTP request without renaming public OpenAPI fields."""
path_values = _mapping(payload, "path")
path = operation.path
for parameter in operation.effective_parameters:
if parameter.get("in") != "path":
continue
name = parameter.get("name")
if not isinstance(name, str):
raise ValueError(
f"path parameter metadata for {operation.name!r} is invalid"
)
if name not in path_values:
raise ValueError(f"missing path parameter {name!r}")
path = path.replace("{" + name + "}", quote(str(path_values[name]), safe=""))
return HttpRequestParts(
method=operation.method.upper(),
url=base_url.rstrip("/") + path,
params=dict(_mapping(payload, "query")),
headers={
str(key): str(value) for key, value in _mapping(payload, "header").items()
},
cookies={
str(key): str(value) for key, value in _mapping(payload, "cookie").items()
},
json=payload.get("body"),
)
def _mapping(payload: Mapping[str, Any], key: str) -> Mapping[str, Any]:
"""Read one OpenAPI parameter group and reject lossy non-object values."""
value = payload.get(key, {})
if not isinstance(value, Mapping):
raise ValueError(f"{key} must be an object")
return value
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any, cast
from .models import JsonObject, OpenApiOperation
PARAMETER_LOCATIONS = ("path", "query", "header", "cookie")
def input_schema_for_operation(operation: OpenApiOperation) -> JsonObject:
"""Build the node input JSON Schema from operation params and JSON body.
OpenAPI schemas containing `$ref` are not self-contained; validate them
with the original document context. This adapter intentionally does not
resolve references.
"""
properties: dict[str, Any] = {}
required: list[str] = []
for group_name, group_schema in _parameter_group_schemas(operation).items():
properties[group_name] = group_schema
if group_schema.get("required"):
required.append(group_name)
body_schema = _json_request_body_schema(operation)
if body_schema is not None:
properties["body"] = body_schema
if _request_body_required(operation):
required.append("body")
return {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": False,
}
def output_schema_for_operation(operation: OpenApiOperation) -> JsonObject:
"""Build generic transport output schema for one OpenAPI operation."""
body_schema = _first_success_json_response_schema(operation) or {}
return {
"type": "object",
"properties": {
"status_code": {"type": "integer"},
"headers": {
"type": "object",
"additionalProperties": {"type": "string"},
},
"body": body_schema,
},
"required": ["status_code", "headers", "body"],
"additionalProperties": False,
}
def _parameter_group_schemas(operation: OpenApiOperation) -> dict[str, JsonObject]:
grouped: dict[str, dict[str, Any]] = {}
for parameter in operation.effective_parameters:
if not isinstance(parameter, dict):
continue
location = parameter.get("in")
name = parameter.get("name")
schema = parameter.get("schema")
if location not in PARAMETER_LOCATIONS:
continue
if not isinstance(name, str) or not isinstance(schema, dict):
continue
# Group by OpenAPI parameter location so workflow inputs stay explicit:
# {"path": {...}, "query": {...}} instead of one ambiguous flat object.
group = grouped.setdefault(
location,
{
"type": "object",
"properties": {},
"required": [],
"additionalProperties": False,
},
)
group["properties"][name] = deepcopy(schema)
if parameter.get("required") is True:
group["required"].append(name)
return {
name: cast(JsonObject, grouped[name])
for name in PARAMETER_LOCATIONS
if name in grouped
}
def _json_request_body_schema(operation: OpenApiOperation) -> JsonObject | None:
request_body = operation.raw_operation.get("requestBody")
if not isinstance(request_body, dict):
return None
content = request_body.get("content")
if not isinstance(content, dict):
return None
json_media = content.get("application/json")
if not isinstance(json_media, dict):
return None
schema = json_media.get("schema")
return cast(JsonObject, deepcopy(schema)) if isinstance(schema, dict) else None
def _request_body_required(operation: OpenApiOperation) -> bool:
request_body = operation.raw_operation.get("requestBody")
return isinstance(request_body, dict) and request_body.get("required") is True
def _first_success_json_response_schema(
operation: OpenApiOperation,
) -> JsonObject | None:
responses = operation.raw_operation.get("responses")
if not isinstance(responses, dict):
return None
for code in sorted(responses):
if not str(code).startswith("2"):
continue
response = responses[code]
if not isinstance(response, dict):
continue
content = response.get("content")
if not isinstance(content, dict):
continue
json_media = content.get("application/json")
if not isinstance(json_media, dict):
continue
schema = json_media.get("schema")
if isinstance(schema, dict):
return cast(JsonObject, deepcopy(schema))
return None
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from pathlib import Path
from pydantic import BaseModel, ConfigDict
from wf_authoring import NodeReturn, NodeSpec
from wf_core import RuntimeContext
from wf_platform import (
CapabilityBuckets,
CapabilitySource,
SourcePermissions,
SourceVisibility,
)
from .executor import (
OpenApiExecutionConfig,
OpenApiOperationOutput,
call_openapi_operation,
)
from .schemas import input_schema_for_operation, output_schema_for_operation
from .spec import load_openapi_operations
from .validation import load_openapi_app
OPENAPI_OUTCOMES = (
"ok",
"http_error",
"unexpected_status",
"validation_error",
"transport_error",
)
class OpenApiNodePayload(BaseModel):
"""Loose runtime boundary; public validation remains the JSON Schema contract."""
model_config = ConfigDict(extra="allow")
def build_openapi_capability_source(
*,
source_id: str,
document_path: Path,
base_url: str,
) -> CapabilitySource:
"""Build NodeSpecs from public OpenAPI schemas and generic HTTP execution."""
app = load_openapi_app(document_path)
operations = load_openapi_operations(document_path)
specs: dict[str, NodeSpec[OpenApiNodePayload, OpenApiOperationOutput]] = {}
for operation in operations:
name = f"{source_id}.{operation.name}"
config = OpenApiExecutionConfig(
base_url=base_url,
)
async def handler(
payload: OpenApiNodePayload,
ctx: RuntimeContext,
*,
_app=app,
_operation=operation,
_config: OpenApiExecutionConfig = config,
) -> NodeReturn[OpenApiOperationOutput]:
_ = ctx
return await call_openapi_operation(
_app,
_operation,
_config,
payload.model_dump(mode="json"),
)
specs[name] = NodeSpec(
name=name,
input_model=OpenApiNodePayload,
output_model=OpenApiOperationOutput,
outcomes=OPENAPI_OUTCOMES,
fn=handler,
description=operation.summary or operation.description,
is_async=True,
input_schema_contract=input_schema_for_operation(operation),
output_schema_contract=output_schema_for_operation(operation),
)
return CapabilitySource(
id=source_id,
kind="connection",
capabilities=CapabilityBuckets(node_specs=specs),
enabled=True,
visibility=SourceVisibility(
planner=True,
mcp_client=False,
admin_dashboard=True,
),
permissions=SourcePermissions(
calls_upstream=True,
),
description=f"OpenAPI capability source for {document_path.name}.",
)
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any, Literal, cast
from .models import JsonObject, OpenApiOperation
HTTP_METHOD_ORDER: tuple[str, ...] = (
"get",
"post",
"put",
"patch",
"delete",
"options",
"head",
)
HTTP_METHODS: set[str] = set(HTTP_METHOD_ORDER)
def load_openapi_document(path: Path) -> JsonObject:
"""Load one local OpenAPI JSON document.
This is only file IO plus basic object-shape checking. Full OpenAPI
validation belongs to an OpenAPI validator dependency, not this module.
"""
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("OpenAPI document must be a JSON object")
return cast(JsonObject, payload)
def load_openapi_operations(path: Path) -> list[OpenApiOperation]:
"""Return workflow-stable operation inventory from an OpenAPI document."""
document = load_openapi_document(path)
paths = document.get("paths")
if not isinstance(paths, dict):
raise ValueError("OpenAPI document must contain object field 'paths'")
operations: list[OpenApiOperation] = []
operation_names: set[str] = set()
for raw_path in sorted(paths):
path_item = paths[raw_path]
if not isinstance(raw_path, str) or not isinstance(path_item, dict):
continue
for method in HTTP_METHOD_ORDER:
raw_operation = path_item.get(method)
if not isinstance(raw_operation, dict):
continue
operation_id = raw_operation.get("operationId")
if operation_id is None:
operation_name = _fallback_operation_name(method, raw_path)
operation_id = operation_name
elif isinstance(operation_id, str):
operation_name = _operation_name(operation_id)
if not operation_name:
raise ValueError(
f"OpenAPI operationId {operation_id!r} does not produce a usable operation name"
)
else:
operation_name = _fallback_operation_name(method, raw_path)
operation_id = operation_name
if operation_name in operation_names:
raise ValueError(
f"Duplicate normalized OpenAPI operation name {operation_name!r}"
)
operation_names.add(operation_name)
operations.append(
OpenApiOperation(
name=operation_name,
operation_id=operation_id,
method=cast(
Literal[
"get", "post", "put", "patch", "delete", "options", "head"
],
method,
),
path=raw_path,
summary=_optional_string(raw_operation.get("summary")),
description=_optional_string(raw_operation.get("description")),
effective_parameters=_effective_parameters(
path_item, raw_operation
),
has_request_body="requestBody" in raw_operation,
raw_operation=cast(dict[str, Any], raw_operation),
document_path=path,
)
)
return operations
def _effective_parameters(
path_item: dict[str, Any], raw_operation: dict[str, Any]
) -> tuple[JsonObject, ...]:
"""Merge inherited and local parameters using OpenAPI override identity.
A parameter is identified by its public `(name, in)` pair. Operation-local
entries replace matching path-item entries while retaining inherited entries
that are not overridden.
"""
merged: dict[tuple[object, object], JsonObject] = {}
for owner in (path_item, raw_operation):
parameters = owner.get("parameters", [])
if not isinstance(parameters, list):
continue
for parameter in parameters:
if not isinstance(parameter, dict):
continue
key = (parameter.get("name"), parameter.get("in"))
merged[key] = cast(JsonObject, parameter)
return tuple(merged.values())
def _operation_name(operation_id: str) -> str:
"""Convert operationId into a stable snake_case workflow capability key."""
words = re.sub(r"(?<!^)(?=[A-Z])", "_", operation_id).replace("-", "_")
return re.sub(r"_+", "_", re.sub(r"[^A-Za-z0-9_]+", "_", words)).strip("_").lower()
def _fallback_operation_name(method: str, raw_path: str) -> str:
"""Build a usable name for operations that omit operationId.
The HTTP method alone is not specific enough, so paths with no usable
normalized segments are rejected instead of producing ambiguous names.
"""
path_segments = [
normalized
for segment in raw_path.split("/")
if (normalized := _operation_name(segment.strip("{}")))
]
if not path_segments:
raise ValueError(
f"OpenAPI fallback operation name for {method.upper()} {raw_path} "
"does not produce a usable operation name"
)
return "_".join([method, *path_segments])
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) else None
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from openapi_core import OpenAPI
from openapi_core.datatypes import RequestParameters
from werkzeug.datastructures import Headers, ImmutableMultiDict
from .request import HttpRequestParts
@dataclass(frozen=True, slots=True)
class HttpResponseParts:
"""HTTP response data in the minimal shape `openapi-core` needs."""
status_code: int
headers: dict[str, str] = field(default_factory=dict)
data: bytes | None = None
@dataclass(frozen=True, slots=True)
class OpenApiValidationResult:
"""Small validation result that keeps `openapi-core` details local."""
valid: bool
errors: list[str] = field(default_factory=list)
data: Any = None
def load_openapi_app(document_path: Path) -> OpenAPI:
"""Load an OpenAPI app used for request/response validation."""
return OpenAPI.from_file_path(str(document_path))
def validate_openapi_request(
app: OpenAPI,
request: HttpRequestParts,
) -> OpenApiValidationResult:
"""Validate and unmarshal one outgoing request."""
try:
result = app.unmarshal_request(_OpenApiCoreRequest(request))
except Exception as exc:
return OpenApiValidationResult(valid=False, errors=[str(exc)])
errors = _error_messages(getattr(result, "errors", []))
if errors:
return OpenApiValidationResult(valid=False, errors=errors)
return OpenApiValidationResult(valid=True, data=result)
def validate_openapi_response(
app: OpenAPI,
request: HttpRequestParts,
response: HttpResponseParts,
) -> OpenApiValidationResult:
"""Validate and unmarshal one incoming response."""
try:
result = app.unmarshal_response(
_OpenApiCoreRequest(request),
_OpenApiCoreResponse(response),
)
except Exception as exc:
return OpenApiValidationResult(valid=False, errors=[str(exc)])
errors = _error_messages(getattr(result, "errors", []))
if errors:
return OpenApiValidationResult(valid=False, errors=errors)
return OpenApiValidationResult(valid=True, data=result.data)
def _error_messages(errors: object) -> list[str]:
"""Normalize `openapi-core` result errors without exporting its classes."""
if not errors:
return []
if not isinstance(errors, list):
return [str(errors)]
return [str(error) for error in errors]
class _OpenApiCoreRequest:
"""Protocol shim from local request parts to `openapi-core`.
The rest of `wf_openapi` should talk in terms of `HttpRequestParts`; this
adapter is the only place that knows `openapi-core`'s protocol attributes.
"""
def __init__(self, request: HttpRequestParts) -> None:
self._request = request
self._url = urlparse(request.url, allow_fragments=False)
self.parameters = RequestParameters(
query=ImmutableMultiDict(request.params.items()),
header=Headers(request.headers),
cookie=ImmutableMultiDict(request.cookies.items()),
)
@property
def host_url(self) -> str:
return f"{self._url.scheme}://{self._url.netloc}"
@property
def path(self) -> str:
return self._url.path
@property
def method(self) -> str:
return self._request.method.lower()
@property
def body(self) -> bytes | None:
if self._request.json is None:
return None
return json.dumps(self._request.json).encode()
@property
def content_type(self) -> str:
if self._request.json is not None:
return "application/json"
return self._request.headers.get("content-type", "")
class _OpenApiCoreResponse:
"""Protocol shim from local response parts to `openapi-core`."""
def __init__(self, response: HttpResponseParts) -> None:
self._response = response
@property
def status_code(self) -> int:
return self._response.status_code
@property
def content_type(self) -> str:
return self._response.headers.get("content-type", "")
@property
def headers(self) -> Headers:
return Headers(self._response.headers)
@property
def data(self) -> bytes | None:
return self._response.data