workflowval 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- workflowval/__init__.py +3 -0
- workflowval/cli.py +584 -0
- workflowval/gen/__init__.py +194 -0
- workflowval/gen/source.py +109 -0
- workflowval/gen/style.md +17 -0
- workflowval/gen/templates/.gitkeep +0 -0
- workflowval/gen/templates/conftest.py.tmpl +332 -0
- workflowval/loop/__init__.py +55 -0
- workflowval/loop/runner.py +488 -0
- workflowval/loop/stats.py +178 -0
- workflowval/loop/trace.py +29 -0
- workflowval/models/__init__.py +19 -0
- workflowval/models/base.py +68 -0
- workflowval/models/fake.py +55 -0
- workflowval/models/litellm_adapter.py +153 -0
- workflowval/pipeline.py +906 -0
- workflowval/planner/__init__.py +62 -0
- workflowval/prompts/__init__.py +34 -0
- workflowval/prompts/planner.md +37 -0
- workflowval/prompts/reviewer.md +30 -0
- workflowval/prompts/task_answer_review.md +14 -0
- workflowval/prompts/task_finding.md +17 -0
- workflowval/prompts/task_fix_test.md +6 -0
- workflowval/prompts/task_fix_workflow.md +7 -0
- workflowval/prompts/task_patch.md +9 -0
- workflowval/prompts/task_plan.md +13 -0
- workflowval/prompts/task_plan_gaps.md +6 -0
- workflowval/prompts/task_report.md +9 -0
- workflowval/prompts/task_review_plan.md +17 -0
- workflowval/prompts/task_review_results.md +19 -0
- workflowval/prompts/task_triage.md +9 -0
- workflowval/prompts/writer.md +11 -0
- workflowval/py.typed +0 -0
- workflowval/redact.py +97 -0
- workflowval/report/__init__.py +162 -0
- workflowval/report/curl.py +75 -0
- workflowval/schemas/__init__.py +78 -0
- workflowval/schemas/_base.py +43 -0
- workflowval/schemas/auth.py +66 -0
- workflowval/schemas/labels.py +31 -0
- workflowval/schemas/results.py +222 -0
- workflowval/schemas/review.py +94 -0
- workflowval/schemas/state.py +32 -0
- workflowval/schemas/stats.py +36 -0
- workflowval/schemas/workflow.py +148 -0
- workflowval/spec/__init__.py +38 -0
- workflowval/spec/graph.py +196 -0
- workflowval/spec/loader.py +102 -0
- workflowval/spec/validation.py +98 -0
- workflowval/testrun.py +159 -0
- workflowval/tools/__init__.py +6 -0
- workflowval/tools/base.py +49 -0
- workflowval/tools/registry.py +637 -0
- workflowval/triage/__init__.py +199 -0
- workflowval-0.1.0.dist-info/METADATA +215 -0
- workflowval-0.1.0.dist-info/RECORD +59 -0
- workflowval-0.1.0.dist-info/WHEEL +4 -0
- workflowval-0.1.0.dist-info/entry_points.txt +2 -0
- workflowval-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""`workflows/<id>.json`: one reviewable workflow, which becomes one test file (FR-4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import Field, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
from workflowval.schemas._base import Strict
|
|
13
|
+
|
|
14
|
+
_PLACEHOLDER = re.compile(r"\{([A-Za-z_]\w*)\}")
|
|
15
|
+
_JSON_PATH = re.compile(r"\$(\.[A-Za-z_][\w-]*|\[\d+\])*")
|
|
16
|
+
BUILTIN_VALUES = frozenset({"unique", "unique_int"})
|
|
17
|
+
"""Placeholders always available: fresh values per test run, so runs don't collide (FR-5)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Source(StrEnum):
|
|
21
|
+
"""Who added the workflow."""
|
|
22
|
+
|
|
23
|
+
PLANNER = "planner"
|
|
24
|
+
REVIEWER = "reviewer"
|
|
25
|
+
USER = "user"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Basis(StrEnum):
|
|
29
|
+
"""Whether the rule is documented or was inferred by the Reviewer."""
|
|
30
|
+
|
|
31
|
+
DOCUMENTED = "documented"
|
|
32
|
+
ASSUMED = "assumed"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Expect(Strict):
|
|
36
|
+
"""Expected response. `body` maps JSONPath expressions to expected values."""
|
|
37
|
+
|
|
38
|
+
status: int = Field(ge=100, le=599)
|
|
39
|
+
body: dict[str, Any] = Field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
@field_validator("body")
|
|
42
|
+
@classmethod
|
|
43
|
+
def _body_keys_are_jsonpath(cls, value: dict[str, Any]) -> dict[str, Any]:
|
|
44
|
+
for key in value:
|
|
45
|
+
if not _JSON_PATH.fullmatch(key):
|
|
46
|
+
raise ValueError(f"expect.body key must be a JSONPath like $.a.b[0]: {key!r}")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CleanupStep(Strict):
|
|
51
|
+
"""A request that removes data the workflow created (NFR-3)."""
|
|
52
|
+
|
|
53
|
+
op: str = Field(min_length=1, description="Spec operationId")
|
|
54
|
+
params: dict[str, Any] = Field(default_factory=dict)
|
|
55
|
+
body: Any = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Step(CleanupStep):
|
|
59
|
+
"""A request plus its expectation.
|
|
60
|
+
|
|
61
|
+
`body` and `auth` are additions to the doc's example, which has no way to send a request body
|
|
62
|
+
or to call a route unauthenticated. See docs/m0-review.md.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
save: dict[str, str] = Field(default_factory=dict, description="Name to JSONPath")
|
|
66
|
+
expect: Expect
|
|
67
|
+
auth: bool = Field(default=True, description="False sends this request without credentials")
|
|
68
|
+
|
|
69
|
+
@field_validator("save")
|
|
70
|
+
@classmethod
|
|
71
|
+
def _save_values_are_jsonpath(cls, value: dict[str, str]) -> dict[str, str]:
|
|
72
|
+
for name, path in value.items():
|
|
73
|
+
if not _JSON_PATH.fullmatch(path):
|
|
74
|
+
raise ValueError(f"save[{name!r}] must be a JSONPath like $.a.b[0]: {path!r}")
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class KnownIssue(Strict):
|
|
79
|
+
"""A failure reviewed as real, which the test now expects. Written by code, never by a model.
|
|
80
|
+
|
|
81
|
+
It excuses only this check on this step: any other failure still fails the test.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
label: Literal["API_BUG", "SPEC_DRIFT", "CONFLICT"]
|
|
85
|
+
confidence: Literal["low", "med", "high"]
|
|
86
|
+
reason: str = Field(min_length=1)
|
|
87
|
+
step: int = Field(ge=0, description="Index in `steps` of the step that fails")
|
|
88
|
+
check: Literal["status", "body"]
|
|
89
|
+
status: int | None = Field(default=None, description="The status seen, for a status check")
|
|
90
|
+
report: str | None = Field(default=None, description="The bug report or spec patch file")
|
|
91
|
+
|
|
92
|
+
@model_validator(mode="after")
|
|
93
|
+
def _status_check_names_a_status(self) -> KnownIssue:
|
|
94
|
+
if self.check == "status" and self.status is None:
|
|
95
|
+
raise ValueError("a status check must name the status it saw")
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _placeholders(value: Any) -> Iterator[str]:
|
|
100
|
+
if isinstance(value, str):
|
|
101
|
+
yield from _PLACEHOLDER.findall(value)
|
|
102
|
+
elif isinstance(value, dict):
|
|
103
|
+
for item in value.values():
|
|
104
|
+
yield from _placeholders(item)
|
|
105
|
+
elif isinstance(value, list):
|
|
106
|
+
for item in value:
|
|
107
|
+
yield from _placeholders(item)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Workflow(Strict):
|
|
111
|
+
"""One user journey. A locked workflow belongs to the user; agents may never change it."""
|
|
112
|
+
|
|
113
|
+
id: str = Field(
|
|
114
|
+
pattern=r"^[a-z0-9_]+$",
|
|
115
|
+
description="Lowercase, digits and underscores: names workflows/<id>.json and test_<id>.py",
|
|
116
|
+
)
|
|
117
|
+
source: Source
|
|
118
|
+
basis: Basis
|
|
119
|
+
locked: bool = False
|
|
120
|
+
rule: str = Field(
|
|
121
|
+
pattern=r"\S",
|
|
122
|
+
description="The business rule this workflow checks, and where it comes from. Required.",
|
|
123
|
+
)
|
|
124
|
+
steps: list[Step] = Field(min_length=1)
|
|
125
|
+
cleanup: list[CleanupStep] = Field(default_factory=list)
|
|
126
|
+
known_issue: KnownIssue | None = None
|
|
127
|
+
|
|
128
|
+
@model_validator(mode="after")
|
|
129
|
+
def _check(self) -> Workflow:
|
|
130
|
+
if self.locked and self.source is not Source.USER:
|
|
131
|
+
raise ValueError("only workflows with source 'user' can be locked")
|
|
132
|
+
if self.known_issue is not None and self.known_issue.step >= len(self.steps):
|
|
133
|
+
raise ValueError("known_issue.step isn't one of this workflow's steps")
|
|
134
|
+
saved = set(BUILTIN_VALUES)
|
|
135
|
+
for index, step in enumerate(self.steps):
|
|
136
|
+
self._require_saved(_placeholders([step.params, step.body]), saved, f"steps[{index}]")
|
|
137
|
+
saved |= set(step.save)
|
|
138
|
+
self._require_saved(_placeholders(step.expect.body), saved, f"steps[{index}].expect")
|
|
139
|
+
for index, cleanup in enumerate(self.cleanup):
|
|
140
|
+
where = f"cleanup[{index}]"
|
|
141
|
+
self._require_saved(_placeholders([cleanup.params, cleanup.body]), saved, where)
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _require_saved(names: Iterator[str], saved: set[str], where: str) -> None:
|
|
146
|
+
for name in names:
|
|
147
|
+
if name not in saved:
|
|
148
|
+
raise ValueError(f"{where} uses {{{name}}}, which no earlier step saves")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""OpenAPI loading, the dependency graph and response validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from workflowval.spec.graph import (
|
|
6
|
+
Link,
|
|
7
|
+
Operation,
|
|
8
|
+
Parameter,
|
|
9
|
+
SpecGraph,
|
|
10
|
+
build_graph,
|
|
11
|
+
resolve_base_url,
|
|
12
|
+
)
|
|
13
|
+
from workflowval.spec.loader import (
|
|
14
|
+
SpecError,
|
|
15
|
+
load_raw_spec,
|
|
16
|
+
load_spec,
|
|
17
|
+
parse_spec,
|
|
18
|
+
resolve_refs,
|
|
19
|
+
spec_hash,
|
|
20
|
+
)
|
|
21
|
+
from workflowval.spec.validation import ResponseChecker, SpecMismatch
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Link",
|
|
25
|
+
"Operation",
|
|
26
|
+
"Parameter",
|
|
27
|
+
"ResponseChecker",
|
|
28
|
+
"SpecError",
|
|
29
|
+
"SpecGraph",
|
|
30
|
+
"SpecMismatch",
|
|
31
|
+
"build_graph",
|
|
32
|
+
"load_raw_spec",
|
|
33
|
+
"load_spec",
|
|
34
|
+
"parse_spec",
|
|
35
|
+
"resolve_base_url",
|
|
36
|
+
"resolve_refs",
|
|
37
|
+
"spec_hash",
|
|
38
|
+
]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""The internal graph: resources, operations, parameters and producer-to-consumer links (FR-3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
_METHODS = ("get", "put", "post", "delete", "patch", "head", "options")
|
|
12
|
+
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Parameter(BaseModel):
|
|
16
|
+
"""One operation parameter."""
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
location: str
|
|
20
|
+
required: bool = False
|
|
21
|
+
schema_: dict[str, Any] = Field(default_factory=dict, alias="schema")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Operation(BaseModel):
|
|
25
|
+
"""One API call."""
|
|
26
|
+
|
|
27
|
+
operation_id: str
|
|
28
|
+
method: str
|
|
29
|
+
path: str
|
|
30
|
+
resource: str
|
|
31
|
+
summary: str = ""
|
|
32
|
+
description: str = ""
|
|
33
|
+
parameters: list[Parameter] = Field(default_factory=list)
|
|
34
|
+
request_schema: dict[str, Any] | None = None
|
|
35
|
+
responses: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
|
36
|
+
security: list[str] = Field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Link(BaseModel):
|
|
40
|
+
"""A value one operation returns that another operation consumes."""
|
|
41
|
+
|
|
42
|
+
producer: str
|
|
43
|
+
field: str
|
|
44
|
+
consumer: str
|
|
45
|
+
parameter: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SpecGraph(BaseModel):
|
|
49
|
+
"""Everything the agents and the test generator need to know about an API."""
|
|
50
|
+
|
|
51
|
+
title: str = ""
|
|
52
|
+
base_path: str = ""
|
|
53
|
+
resources: list[str] = Field(default_factory=list)
|
|
54
|
+
operations: list[Operation] = Field(default_factory=list)
|
|
55
|
+
links: list[Link] = Field(default_factory=list)
|
|
56
|
+
security_schemes: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
def operation_at(self, method: str, path: str) -> Operation | None:
|
|
59
|
+
"""Find an operation by its method and path template."""
|
|
60
|
+
wanted = method.upper()
|
|
61
|
+
return next(
|
|
62
|
+
(op for op in self.operations if op.path == path and op.method.upper() == wanted),
|
|
63
|
+
None,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def operation(self, operation_id: str) -> Operation | None:
|
|
67
|
+
"""Find an operation by its id."""
|
|
68
|
+
return next((op for op in self.operations if op.operation_id == operation_id), None)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _synthetic_id(method: str, path: str) -> str:
|
|
72
|
+
return "_".join([method, *re.findall(r"[A-Za-z0-9]+", path)])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _json_schema(content: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
76
|
+
for media_type, media in (content or {}).items():
|
|
77
|
+
if "json" in media_type:
|
|
78
|
+
schema = media.get("schema")
|
|
79
|
+
return schema if isinstance(schema, dict) else {}
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build_operation(
|
|
84
|
+
path: str,
|
|
85
|
+
method: str,
|
|
86
|
+
raw: dict[str, Any],
|
|
87
|
+
inherited: list[Any],
|
|
88
|
+
default_security: list[dict[str, Any]],
|
|
89
|
+
) -> Operation:
|
|
90
|
+
merged = {(p["name"], p["in"]): p for p in [*inherited, *raw.get("parameters", [])]}
|
|
91
|
+
segments = [part for part in path.split("/") if part and not part.startswith("{")]
|
|
92
|
+
request = raw.get("requestBody")
|
|
93
|
+
responses = {
|
|
94
|
+
str(status): {
|
|
95
|
+
"description": response.get("description", ""),
|
|
96
|
+
"schema": _json_schema(response.get("content")),
|
|
97
|
+
}
|
|
98
|
+
for status, response in raw.get("responses", {}).items()
|
|
99
|
+
}
|
|
100
|
+
return Operation(
|
|
101
|
+
operation_id=raw.get("operationId") or _synthetic_id(method, path),
|
|
102
|
+
method=method.upper(),
|
|
103
|
+
path=path,
|
|
104
|
+
resource=segments[0] if segments else "",
|
|
105
|
+
summary=raw.get("summary", ""),
|
|
106
|
+
description=raw.get("description", ""),
|
|
107
|
+
parameters=[
|
|
108
|
+
Parameter(
|
|
109
|
+
name=p["name"],
|
|
110
|
+
location=p["in"],
|
|
111
|
+
required=bool(p.get("required")),
|
|
112
|
+
schema=p.get("schema") or {},
|
|
113
|
+
)
|
|
114
|
+
for p in merged.values()
|
|
115
|
+
],
|
|
116
|
+
request_schema=_json_schema(request.get("content")) if request else None,
|
|
117
|
+
responses=responses,
|
|
118
|
+
# An operation's own `security` replaces the spec's, and an empty list means no auth at all.
|
|
119
|
+
security=[name for option in raw.get("security", default_security) for name in option],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _is_success(status: str) -> bool:
|
|
124
|
+
return status == "default" or status.startswith("2")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _returned_fields(operation: Operation) -> set[str]:
|
|
128
|
+
fields: set[str] = set()
|
|
129
|
+
for status, response in operation.responses.items():
|
|
130
|
+
if _is_success(status):
|
|
131
|
+
fields |= set((response.get("schema") or {}).get("properties", {}))
|
|
132
|
+
return fields
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _singular(word: str) -> str:
|
|
136
|
+
return word[:-1] if word.endswith("s") and not word.endswith("ss") else word
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _matching_field(fields: set[str], parameter: str, collection: str) -> str | None:
|
|
140
|
+
"""Find the returned field that feeds a path parameter, most specific first."""
|
|
141
|
+
resource = _singular(collection.rstrip("/").rsplit("/", 1)[-1]).lower()
|
|
142
|
+
wanted = (parameter.lower(), f"{resource}id", f"{resource}_id", "id")
|
|
143
|
+
by_lower = {field.lower(): field for field in fields}
|
|
144
|
+
return next((by_lower[name] for name in wanted if name in by_lower), None)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _build_links(operations: list[Operation]) -> list[Link]:
|
|
148
|
+
links = []
|
|
149
|
+
for consumer in operations:
|
|
150
|
+
for match in _PATH_PARAM.finditer(consumer.path):
|
|
151
|
+
collection = consumer.path[: match.start()].rstrip("/")
|
|
152
|
+
for producer in operations:
|
|
153
|
+
if producer.method != "POST" or producer.path.rstrip("/") != collection:
|
|
154
|
+
continue
|
|
155
|
+
field = _matching_field(_returned_fields(producer), match.group(1), collection)
|
|
156
|
+
if field:
|
|
157
|
+
links.append(
|
|
158
|
+
Link(
|
|
159
|
+
producer=producer.operation_id,
|
|
160
|
+
field=field,
|
|
161
|
+
consumer=consumer.operation_id,
|
|
162
|
+
parameter=match.group(1),
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
return links
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def build_graph(spec: dict[str, Any]) -> SpecGraph:
|
|
169
|
+
"""Build the graph from a spec whose `$ref`s are already resolved."""
|
|
170
|
+
operations = []
|
|
171
|
+
for path, item in spec.get("paths", {}).items():
|
|
172
|
+
for method in _METHODS:
|
|
173
|
+
if method in item:
|
|
174
|
+
inherited = item.get("parameters", [])
|
|
175
|
+
operations.append(
|
|
176
|
+
_build_operation(
|
|
177
|
+
path, method, item[method], inherited, spec.get("security", [])
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
servers = spec.get("servers") or [{}]
|
|
181
|
+
return SpecGraph(
|
|
182
|
+
title=spec.get("info", {}).get("title", ""),
|
|
183
|
+
base_path=urlparse(servers[0].get("url", "")).path.rstrip("/"),
|
|
184
|
+
resources=sorted({op.resource for op in operations if op.resource}),
|
|
185
|
+
operations=operations,
|
|
186
|
+
links=_build_links(operations),
|
|
187
|
+
security_schemes=spec.get("components", {}).get("securitySchemes", {}),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def resolve_base_url(base_url: str, graph: SpecGraph) -> str:
|
|
192
|
+
"""Add the spec's server path to a base URL, unless the URL already ends with it."""
|
|
193
|
+
url = base_url.rstrip("/")
|
|
194
|
+
if graph.base_path and not urlparse(url).path.rstrip("/").endswith(graph.base_path):
|
|
195
|
+
return url + graph.base_path
|
|
196
|
+
return url
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Load and validate an OpenAPI 3.x JSON spec from a file or URL, and resolve its `$ref`s (FR-1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from openapi_spec_validator import validate
|
|
14
|
+
from openapi_spec_validator.validation.exceptions import OpenAPIValidationError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SpecError(Exception):
|
|
18
|
+
"""A spec can't be used. The message says what's wrong and where."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_spec_text(source: str) -> str:
|
|
22
|
+
"""Read a spec's text from a path or an http(s) URL."""
|
|
23
|
+
if urlparse(source).scheme in {"http", "https"}:
|
|
24
|
+
try:
|
|
25
|
+
response = httpx.get(source, timeout=30, follow_redirects=True)
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
except httpx.HTTPError as exc:
|
|
28
|
+
raise SpecError(f"can't fetch spec from {source}: {exc}") from exc
|
|
29
|
+
return response.text
|
|
30
|
+
path = Path(source)
|
|
31
|
+
if not path.is_file():
|
|
32
|
+
raise SpecError(f"spec file not found: {source}")
|
|
33
|
+
return path.read_text(encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_spec(text: str, source: str) -> dict[str, Any]:
|
|
37
|
+
"""Parse and validate spec text. Only JSON is supported in v1."""
|
|
38
|
+
try:
|
|
39
|
+
spec = json.loads(text)
|
|
40
|
+
except json.JSONDecodeError as exc:
|
|
41
|
+
raise SpecError(
|
|
42
|
+
f"{source} isn't valid JSON ({exc}). Only JSON specs are supported: "
|
|
43
|
+
"convert YAML to JSON first."
|
|
44
|
+
) from exc
|
|
45
|
+
if not isinstance(spec, dict):
|
|
46
|
+
raise SpecError(f"{source} must be a JSON object, not {type(spec).__name__}")
|
|
47
|
+
version = str(spec.get("openapi", ""))
|
|
48
|
+
if not version.startswith(("3.0", "3.1")):
|
|
49
|
+
found = version or "no 'openapi' field"
|
|
50
|
+
raise SpecError(f"{source}: only OpenAPI 3.0 and 3.1 are supported, found {found}")
|
|
51
|
+
try:
|
|
52
|
+
validate(spec)
|
|
53
|
+
except OpenAPIValidationError as exc:
|
|
54
|
+
where = "/".join(str(part) for part in exc.absolute_path) or "(top level)"
|
|
55
|
+
raise SpecError(f"{source}: invalid spec at {where}: {exc.message}") from exc
|
|
56
|
+
return spec
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_raw_spec(source: str) -> dict[str, Any]:
|
|
60
|
+
"""Load a spec as written, with its `$ref`s. Spec patches apply to this form."""
|
|
61
|
+
return parse_spec(read_spec_text(source), source)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_spec(source: str) -> dict[str, Any]:
|
|
65
|
+
"""Load a spec from a path or URL, validated, with every `$ref` resolved."""
|
|
66
|
+
return resolve_refs(parse_spec(read_spec_text(source), source))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def spec_hash(spec: dict[str, Any]) -> str:
|
|
70
|
+
"""Return a stable hash of a spec, used to decide whether to re-plan."""
|
|
71
|
+
return hashlib.sha256(json.dumps(spec, sort_keys=True).encode()).hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _lookup(root: dict[str, Any], ref: str) -> Any:
|
|
75
|
+
if not ref.startswith("#/"):
|
|
76
|
+
raise SpecError(f"only local $refs are supported, found {ref!r}")
|
|
77
|
+
node: Any = root
|
|
78
|
+
for part in ref[2:].split("/"):
|
|
79
|
+
key = part.replace("~1", "/").replace("~0", "~")
|
|
80
|
+
if not isinstance(node, dict) or key not in node:
|
|
81
|
+
raise SpecError(f"$ref points at nothing: {ref}")
|
|
82
|
+
node = node[key]
|
|
83
|
+
return node
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resolve_refs(spec: dict[str, Any]) -> dict[str, Any]:
|
|
87
|
+
"""Return a copy with `$ref`s replaced. A recursive reference stays as a `$ref`, marking it."""
|
|
88
|
+
|
|
89
|
+
def walk(node: Any, active: tuple[str, ...]) -> Any:
|
|
90
|
+
if isinstance(node, list):
|
|
91
|
+
return [walk(item, active) for item in node]
|
|
92
|
+
if not isinstance(node, dict):
|
|
93
|
+
return node
|
|
94
|
+
ref = node.get("$ref")
|
|
95
|
+
if isinstance(ref, str):
|
|
96
|
+
if ref in active:
|
|
97
|
+
return {"$ref": ref}
|
|
98
|
+
return walk(copy.deepcopy(_lookup(spec, ref)), (*active, ref))
|
|
99
|
+
return {key: walk(value, active) for key, value in node.items()}
|
|
100
|
+
|
|
101
|
+
resolved: dict[str, Any] = walk(spec, ())
|
|
102
|
+
return resolved
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Checks live responses against a spec. The one boundary around openapi-core (FR-8)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from openapi_core import Config, OpenAPI
|
|
11
|
+
from openapi_core.exceptions import OpenAPIError
|
|
12
|
+
from openapi_core.testing import MockRequest, MockResponse
|
|
13
|
+
|
|
14
|
+
from workflowval.redact import MASK
|
|
15
|
+
|
|
16
|
+
_SERVER = "http://workflowval.invalid"
|
|
17
|
+
_MAX_MESSAGE = 300
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class SpecMismatch:
|
|
22
|
+
"""One way a response breaks the spec. `key` is the same however the value differs."""
|
|
23
|
+
|
|
24
|
+
key: str
|
|
25
|
+
message: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _without_servers(spec: dict[str, Any]) -> dict[str, Any]:
|
|
29
|
+
"""Point the spec at one fixed server, since callers check by operation path, not by host."""
|
|
30
|
+
copied = copy.deepcopy(spec)
|
|
31
|
+
copied["servers"] = [{"url": _SERVER}]
|
|
32
|
+
for item in copied.get("paths", {}).values():
|
|
33
|
+
item.pop("servers", None)
|
|
34
|
+
for operation in item.values():
|
|
35
|
+
if isinstance(operation, dict):
|
|
36
|
+
operation.pop("servers", None)
|
|
37
|
+
return copied
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _schema_errors(exc: BaseException) -> list[Any]:
|
|
41
|
+
seen: BaseException | None = exc
|
|
42
|
+
while seen is not None:
|
|
43
|
+
errors = getattr(seen, "schema_errors", None)
|
|
44
|
+
if errors:
|
|
45
|
+
return list(errors)
|
|
46
|
+
seen = seen.__cause__
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _from_schema_error(error: Any) -> SpecMismatch:
|
|
51
|
+
parts = list(error.absolute_path)
|
|
52
|
+
path = "$" + "".join("[]" if isinstance(part, int) else f".{part}" for part in parts)
|
|
53
|
+
message = str(error.message)
|
|
54
|
+
# The key must be the same wherever one schema shows up, in a list or under another status, and
|
|
55
|
+
# whatever the bad value was. So it uses the field's name and the rule broken, not the path.
|
|
56
|
+
if error.validator == "required":
|
|
57
|
+
return SpecMismatch(message, f"{path}: {message}"[:_MAX_MESSAGE])
|
|
58
|
+
names = [part for part in parts if not isinstance(part, int)]
|
|
59
|
+
field = names[-1] if names else "$"
|
|
60
|
+
shape = f"{field}: {error.validator} {error.validator_value!r}"[:_MAX_MESSAGE]
|
|
61
|
+
return SpecMismatch(shape, f"{path}: {message}"[:_MAX_MESSAGE])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ResponseChecker:
|
|
65
|
+
"""Checks responses against one spec, whichever host they came from."""
|
|
66
|
+
|
|
67
|
+
def check_call(self, call: Mapping[str, Any]) -> list[SpecMismatch]:
|
|
68
|
+
"""Check one recorded call. A 5xx is an API failure, and its body says nothing of drift."""
|
|
69
|
+
template, status = call.get("template"), call.get("status")
|
|
70
|
+
if not isinstance(template, str) or not isinstance(status, int) or status >= 500:
|
|
71
|
+
return []
|
|
72
|
+
body = str(call.get("raw_body") or "").encode()
|
|
73
|
+
found = self.check(str(call["method"]), template, status, call.get("content_type"), body)
|
|
74
|
+
# A value that was hidden can't be judged: the mask itself would break a format rule.
|
|
75
|
+
return [mismatch for mismatch in found if MASK not in mismatch.message]
|
|
76
|
+
|
|
77
|
+
def __init__(self, spec: dict[str, Any], *, validate_spec: bool = False) -> None:
|
|
78
|
+
config = None if validate_spec else Config(spec_validator_cls=None)
|
|
79
|
+
self._api = OpenAPI.from_dict(_without_servers(spec), config=config)
|
|
80
|
+
|
|
81
|
+
def check(
|
|
82
|
+
self, method: str, path: str, status: int, content_type: str | None, body: bytes
|
|
83
|
+
) -> list[SpecMismatch]:
|
|
84
|
+
"""Return the ways a response breaks the spec. `path` is the spec's path template."""
|
|
85
|
+
request = MockRequest(_SERVER, method.lower(), path)
|
|
86
|
+
response = MockResponse(body, status_code=status, content_type=content_type or "")
|
|
87
|
+
try:
|
|
88
|
+
self._api.validate_response(request, response)
|
|
89
|
+
except OpenAPIError as exc:
|
|
90
|
+
errors = _schema_errors(exc)
|
|
91
|
+
if errors:
|
|
92
|
+
return [_from_schema_error(error) for error in errors]
|
|
93
|
+
kind = type(exc).__name__
|
|
94
|
+
detail = str(exc.__cause__ or exc)[:_MAX_MESSAGE]
|
|
95
|
+
if kind == "ResponseNotFound":
|
|
96
|
+
return [SpecMismatch(f"status {status}", f"status {status} isn't in the spec")]
|
|
97
|
+
return [SpecMismatch(f"{kind} on status {status}", detail)]
|
|
98
|
+
return []
|