mirror of
https://github.com/langgenius/dify.git
synced 2026-07-23 20:18:40 +08:00
386 lines
15 KiB
Python
386 lines
15 KiB
Python
"""Validate Dify Console KnowledgeFS declarations against a pinned OpenAPI document.
|
|
|
|
The OpenAPI document is exported only during explicit development validation. Runtime declarations live with Dify
|
|
product policy; this module validates their transport metadata without generating a complete operation catalog.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from typing import Any, Literal, TypedDict
|
|
|
|
API_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(API_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(API_ROOT))
|
|
|
|
WORKSPACE_ROOT = API_ROOT.parent
|
|
LOCK_PATH = API_ROOT / "knowledge-fs-contract.lock.json"
|
|
DEFAULT_REPOSITORY = WORKSPACE_ROOT.parent / "knowledge-fs"
|
|
OPENAPI_METHODS = ("delete", "get", "head", "options", "patch", "post", "put", "trace")
|
|
PROXY_METHODS = frozenset({"delete", "get", "patch", "post", "put"})
|
|
CONSOLE_PROXY_ERROR_SCHEMA_NAME = "ConsoleProxyError"
|
|
CONSOLE_PROXY_ERROR_SCHEMA: dict[str, Any] = {
|
|
"type": "object",
|
|
"required": ["code", "message", "status"],
|
|
"properties": {
|
|
"code": {"type": "string"},
|
|
"message": {"type": "string"},
|
|
"status": {"type": "integer"},
|
|
},
|
|
}
|
|
|
|
|
|
class ContractDeclaration(TypedDict):
|
|
"""KnowledgeFS transport contract declared by one Dify Console registry entry."""
|
|
|
|
operation_id: str
|
|
method: str
|
|
path: str
|
|
required_scope: str | None
|
|
response_kind: str
|
|
max_response_bytes: int
|
|
request_headers: tuple[str, ...]
|
|
response_headers: tuple[str, ...]
|
|
response_media_types: tuple[str, ...]
|
|
error_status_map: tuple[tuple[int, int], ...]
|
|
|
|
|
|
type DeclarationField = Literal[
|
|
"method",
|
|
"path",
|
|
"required_scope",
|
|
"response_kind",
|
|
"max_response_bytes",
|
|
"request_headers",
|
|
"response_headers",
|
|
"response_media_types",
|
|
]
|
|
|
|
DECLARATION_FIELDS: tuple[DeclarationField, ...] = (
|
|
"method",
|
|
"path",
|
|
"required_scope",
|
|
"response_kind",
|
|
"max_response_bytes",
|
|
"request_headers",
|
|
"response_headers",
|
|
"response_media_types",
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
"""Update or verify the pin and validate Console declarations against its OpenAPI document."""
|
|
parser = argparse.ArgumentParser()
|
|
mode = parser.add_mutually_exclusive_group()
|
|
mode.add_argument("--check", action="store_true")
|
|
mode.add_argument("--update-lock", action="store_true")
|
|
parser.add_argument("--repository", type=Path, default=DEFAULT_REPOSITORY)
|
|
parser.add_argument("--output-openapi", type=Path)
|
|
args = parser.parse_args()
|
|
|
|
repository = args.repository.resolve()
|
|
lock = json.loads(LOCK_PATH.read_text())
|
|
tracked_changes = run("git", "status", "--porcelain", "--untracked-files=no", cwd=repository).strip()
|
|
if tracked_changes:
|
|
raise RuntimeError("KnowledgeFS checkout must not contain tracked changes during contract export")
|
|
|
|
commit = run("git", "rev-parse", "HEAD", cwd=repository).strip()
|
|
if not args.update_lock and commit != lock["commit"]:
|
|
raise RuntimeError(
|
|
f"KnowledgeFS checkout mismatch: expected {lock['commit']}, received {commit}. "
|
|
"Use the pinned commit or pass --update-lock intentionally."
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory(prefix="dify-knowledge-fs-contract-") as directory:
|
|
openapi_path = Path(directory) / "knowledge-fs.openapi.json"
|
|
subprocess.run(
|
|
["pnpm", "openapi:export", "--", "--output", str(openapi_path)],
|
|
cwd=repository,
|
|
check=True,
|
|
)
|
|
openapi_content = openapi_path.read_bytes()
|
|
|
|
openapi_sha256 = sha256(openapi_content)
|
|
if not args.update_lock and openapi_sha256 != lock["openapiSha256"]:
|
|
raise RuntimeError(
|
|
f"KnowledgeFS OpenAPI hash mismatch: expected {lock['openapiSha256']}, received {openapi_sha256}"
|
|
)
|
|
|
|
document: dict[str, Any] = json.loads(openapi_content)
|
|
declarations = console_contract_declarations()
|
|
validate_declarations(document, declarations)
|
|
|
|
if args.output_openapi:
|
|
filtered_document = filter_openapi_document(document, declarations)
|
|
filtered_document["x-dify-source-openapi-sha256"] = openapi_sha256
|
|
filtered_document["x-dify-console-declarations-sha256"] = contract_declarations_sha256(declarations)
|
|
args.output_openapi.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output_openapi.write_text(json.dumps(filtered_document, indent=2) + "\n")
|
|
|
|
if args.update_lock:
|
|
LOCK_PATH.write_text(
|
|
json.dumps(
|
|
{
|
|
"commit": commit,
|
|
"openapiSha256": openapi_sha256,
|
|
"repository": lock["repository"],
|
|
},
|
|
indent=2,
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
|
|
def validate_declarations(document: dict[str, Any], declarations: tuple[ContractDeclaration, ...]) -> None:
|
|
"""Validate Dify Console declarations against matching pinned OpenAPI operations."""
|
|
operations_by_id: dict[str, list[tuple[str, str, dict[str, Any], dict[str, Any]]]] = {}
|
|
for path, path_item in document.get("paths", {}).items():
|
|
for method in OPENAPI_METHODS:
|
|
operation = path_item.get(method)
|
|
if operation is None:
|
|
continue
|
|
operation_id = operation.get("operationId")
|
|
if isinstance(operation_id, str) and operation_id:
|
|
operations_by_id.setdefault(operation_id, []).append((method, path, path_item, operation))
|
|
|
|
declared_ids: set[str] = set()
|
|
for declaration in declarations:
|
|
operation_id = declaration["operation_id"]
|
|
if operation_id in declared_ids:
|
|
raise ValueError(f"Dify Console registry has duplicate operationId: {operation_id}")
|
|
declared_ids.add(operation_id)
|
|
|
|
matches = operations_by_id.get(operation_id, [])
|
|
if not matches:
|
|
raise ValueError(f"KnowledgeFS OpenAPI has no operationId: {operation_id}")
|
|
if len(matches) > 1:
|
|
raise ValueError(f"KnowledgeFS OpenAPI has duplicate operationId: {operation_id}")
|
|
|
|
method, path, path_item, operation = matches[0]
|
|
if not path.startswith("/"):
|
|
raise ValueError(f"KnowledgeFS OpenAPI path must be absolute: {path}")
|
|
if method not in PROXY_METHODS:
|
|
raise ValueError(f"KnowledgeFS proxy does not support {method.upper()} {path}")
|
|
expected: dict[DeclarationField, object] = {
|
|
"method": method.upper(),
|
|
"path": path[1:],
|
|
"required_scope": required_scope(operation),
|
|
"response_kind": response_kind(operation),
|
|
"max_response_bytes": required_max_response_bytes(operation),
|
|
"request_headers": request_header_names(path_item, operation),
|
|
"response_headers": response_header_names(operation),
|
|
"response_media_types": response_media_types(operation),
|
|
}
|
|
for field in DECLARATION_FIELDS:
|
|
expected_value = expected[field]
|
|
received_value = declaration[field]
|
|
if received_value != expected_value:
|
|
raise ValueError(
|
|
f"KnowledgeFS operation {operation_id} field {field} drifted: "
|
|
f"expected {expected_value!r}, received {received_value!r}"
|
|
)
|
|
validate_error_status_map(operation_id, declaration["error_status_map"])
|
|
|
|
|
|
def filter_openapi_document(
|
|
document: dict[str, Any],
|
|
declarations: tuple[ContractDeclaration, ...],
|
|
) -> dict[str, Any]:
|
|
"""Return a code-generation document containing only Console-allowlisted operations."""
|
|
filtered_document: dict[str, Any] = {
|
|
key: value for key, value in document.items() if key not in {"components", "paths"}
|
|
}
|
|
source_paths = document.get("paths", {})
|
|
filtered_paths: dict[str, Any] = {}
|
|
|
|
for declaration in declarations:
|
|
path = f"/{declaration['path']}"
|
|
method = declaration["method"].lower()
|
|
source_path_item = source_paths[path]
|
|
path_metadata = {key: value for key, value in source_path_item.items() if key not in OPENAPI_METHODS}
|
|
filtered_path_item = filtered_paths.setdefault(path, path_metadata)
|
|
filtered_operation = deepcopy(source_path_item[method])
|
|
_rewrite_proxy_error_responses(filtered_operation, declaration["error_status_map"])
|
|
filtered_path_item[method] = filtered_operation
|
|
|
|
filtered_document["paths"] = filtered_paths
|
|
|
|
source_components = document.get("components", {})
|
|
filtered_components = {key: value for key, value in source_components.items() if key != "schemas"}
|
|
source_schemas = source_components.get("schemas", {})
|
|
available_schemas = {**source_schemas, CONSOLE_PROXY_ERROR_SCHEMA_NAME: CONSOLE_PROXY_ERROR_SCHEMA}
|
|
schema_names = _referenced_schema_names(filtered_paths, available_schemas)
|
|
filtered_components["schemas"] = {
|
|
name: schema for name, schema in available_schemas.items() if name in schema_names
|
|
}
|
|
filtered_document["components"] = filtered_components
|
|
return filtered_document
|
|
|
|
|
|
def validate_error_status_map(operation_id: str, error_status_map: tuple[tuple[int, int], ...]) -> None:
|
|
"""Validate the status normalization advertised by one Console operation."""
|
|
upstream_statuses: set[int] = set()
|
|
for upstream_status, console_status in error_status_map:
|
|
if upstream_status in upstream_statuses:
|
|
raise ValueError(f"KnowledgeFS operation {operation_id} has duplicate error status: {upstream_status}")
|
|
if not 400 <= upstream_status <= 599 or not 400 <= console_status <= 599:
|
|
raise ValueError(f"KnowledgeFS operation {operation_id} has invalid error status mapping")
|
|
upstream_statuses.add(upstream_status)
|
|
|
|
|
|
def _rewrite_proxy_error_responses(
|
|
operation: dict[str, Any],
|
|
error_status_map: tuple[tuple[int, int], ...],
|
|
) -> None:
|
|
responses = operation.setdefault("responses", {})
|
|
proxy_error_response = {
|
|
"description": "Error normalized by the Dify Console KnowledgeFS proxy.",
|
|
"content": {
|
|
"application/json": {"schema": {"$ref": f"#/components/schemas/{CONSOLE_PROXY_ERROR_SCHEMA_NAME}"}}
|
|
},
|
|
}
|
|
for upstream_status, console_status in error_status_map:
|
|
existing_target = responses.get(str(console_status)) if upstream_status != console_status else None
|
|
responses.pop(str(upstream_status), None)
|
|
normalized_response: dict[str, Any] = deepcopy(proxy_error_response)
|
|
existing_schema = (
|
|
existing_target.get("content", {}).get("application/json", {}).get("schema")
|
|
if isinstance(existing_target, dict)
|
|
else None
|
|
)
|
|
if existing_schema is not None:
|
|
normalized_response["content"]["application/json"]["schema"] = {
|
|
"oneOf": [
|
|
deepcopy(existing_schema),
|
|
{"$ref": f"#/components/schemas/{CONSOLE_PROXY_ERROR_SCHEMA_NAME}"},
|
|
]
|
|
}
|
|
responses[str(console_status)] = normalized_response
|
|
|
|
|
|
def _referenced_schema_names(value: Any, schemas: dict[str, Any]) -> set[str]:
|
|
reference_prefix = "#/components/schemas/"
|
|
selected: set[str] = set()
|
|
pending: list[Any] = [value]
|
|
|
|
while pending:
|
|
current = pending.pop()
|
|
if isinstance(current, list):
|
|
pending.extend(current)
|
|
continue
|
|
if not isinstance(current, dict):
|
|
continue
|
|
|
|
reference = current.get("$ref")
|
|
if isinstance(reference, str) and reference.startswith(reference_prefix):
|
|
name = reference.removeprefix(reference_prefix)
|
|
if name not in selected:
|
|
if name not in schemas:
|
|
raise ValueError(f"KnowledgeFS OpenAPI references missing schema: {name}")
|
|
selected.add(name)
|
|
pending.append(schemas[name])
|
|
pending.extend(current.values())
|
|
|
|
return selected
|
|
|
|
|
|
def console_contract_declarations() -> tuple[ContractDeclaration, ...]:
|
|
"""Return transport declarations from the runtime Console operation registry."""
|
|
from services.knowledge_fs_operations import KNOWLEDGE_FS_CONSOLE_OPERATIONS
|
|
|
|
return tuple(
|
|
{
|
|
"operation_id": operation.operation_id,
|
|
"method": operation.method,
|
|
"path": operation.path,
|
|
"required_scope": operation.required_scope,
|
|
"response_kind": operation.response_kind,
|
|
"max_response_bytes": operation.max_response_bytes,
|
|
"request_headers": operation.request_headers,
|
|
"response_headers": operation.response_headers,
|
|
"response_media_types": operation.response_media_types,
|
|
"error_status_map": operation.error_status_map,
|
|
}
|
|
for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS
|
|
)
|
|
|
|
|
|
def contract_declarations_sha256(declarations: tuple[ContractDeclaration, ...]) -> str:
|
|
"""Return a stable digest for the runtime Console operation declarations."""
|
|
content = json.dumps(declarations, separators=(",", ":"), sort_keys=True).encode()
|
|
return sha256(content)
|
|
|
|
|
|
def response_kind(operation: dict[str, Any]) -> str:
|
|
media_types = response_media_types(operation)
|
|
if "text/event-stream" in media_types:
|
|
return "stream"
|
|
if "application/octet-stream" in media_types:
|
|
return "binary"
|
|
return "buffered"
|
|
|
|
|
|
def response_media_types(operation: dict[str, Any]) -> tuple[str, ...]:
|
|
media_types: set[str] = set()
|
|
for status, response in operation.get("responses", {}).items():
|
|
if status == "2XX" or (len(status) == 3 and status.startswith("2") and status.isdigit()):
|
|
media_types.update(response.get("content", {}))
|
|
return tuple(sorted(media_types))
|
|
|
|
|
|
def required_scope(operation: dict[str, Any]) -> str | None:
|
|
scope = operation.get("x-knowledge-fs-required-scope")
|
|
if scope in ("knowledge-spaces:read", "knowledge-spaces:write"):
|
|
return scope
|
|
if operation.get("security") == []:
|
|
return None
|
|
raise ValueError(f"KnowledgeFS operation has no supported required scope: {scope}")
|
|
|
|
|
|
def required_max_response_bytes(operation: dict[str, Any]) -> int:
|
|
value = operation.get("x-knowledge-fs-max-response-bytes")
|
|
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
raise ValueError(f"KnowledgeFS operation has no valid response byte limit: {value}")
|
|
return value
|
|
|
|
|
|
def request_header_names(path_item: dict[str, Any], operation: dict[str, Any]) -> tuple[str, ...]:
|
|
names: set[str] = set()
|
|
for parameter in [*path_item.get("parameters", []), *operation.get("parameters", [])]:
|
|
if "$ref" in parameter:
|
|
raise ValueError(f"KnowledgeFS request header references are not supported: {parameter['$ref']}")
|
|
if parameter.get("in") == "header":
|
|
names.add(parameter["name"].lower())
|
|
return tuple(sorted(names))
|
|
|
|
|
|
def response_header_names(operation: dict[str, Any]) -> tuple[str, ...]:
|
|
return tuple(
|
|
sorted(
|
|
{
|
|
name.lower()
|
|
for response in operation.get("responses", {}).values()
|
|
for name in response.get("headers", {})
|
|
}
|
|
)
|
|
)
|
|
|
|
|
|
def sha256(content: bytes) -> str:
|
|
return hashlib.sha256(content).hexdigest()
|
|
|
|
|
|
def run(*command: str, cwd: Path) -> str:
|
|
return subprocess.run(command, cwd=cwd, check=True, capture_output=True, text=True).stdout
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|