OpenAPI initial support as capability source
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user