chore: generate inner api spec (#44047)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yunlu Wen
2026-10-14 10:18:00 +00:00
committed by GitHub
co-authored by Devin
parent 341660f9b0
commit d23a8b2941
11 changed files with 526 additions and 103 deletions
+9
View File
@@ -10,6 +10,15 @@ api = ExternalApi(
version="1.0",
title="Inner API",
description="Internal APIs for enterprise features, billing, knowledge retrieval, and plugin communication",
authorizations={
"InnerApiKey": {
"type": "apiKey",
"in": "header",
"name": "X-Inner-Api-Key",
"description": "INNER_API_KEY for enterprise callers; INNER_API_KEY_FOR_PLUGIN for plugin/agent callers.",
}
},
security="InnerApiKey",
)
# Create namespace
+38 -14
View File
@@ -10,10 +10,15 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, ValidationError, field_validator
from pydantic import BaseModel, Field, RootModel, ValidationError, field_validator
from controllers.common.fields import SimpleMessageResponse
from controllers.common.schema import query_params_from_model, register_schema_model
from controllers.common.schema import (
query_params_from_model,
register_response_schema_model,
register_response_schema_models,
register_schema_model,
)
from controllers.console.wraps import setup_required, validate_request
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
@@ -52,7 +57,14 @@ class EnterpriseAppDSLWorkflowErrorResponse(ResponseModel):
status: int
class EnterpriseAppDSLExportNotFoundResponse(RootModel[SimpleMessageResponse | EnterpriseAppDSLWorkflowErrorResponse]):
"""Export 404: either an app-level message or a workflow-version error."""
register_schema_model(inner_api_ns, InnerAppDSLImportPayload)
register_response_schema_models(inner_api_ns, Import, AppExportResponse, EnterpriseAppDSLWorkflowErrorResponse)
register_response_schema_model(inner_api_ns, SimpleMessageResponse)
register_response_schema_model(inner_api_ns, EnterpriseAppDSLExportNotFoundResponse)
@inner_api_ns.route("/enterprise/workspaces/<string:workspace_id>/dsl/import")
@@ -61,13 +73,19 @@ class EnterpriseAppDSLImport(Resource):
@enterprise_inner_api_only
@inner_api_ns.doc("enterprise_app_dsl_import")
@inner_api_ns.expect(inner_api_ns.models[InnerAppDSLImportPayload.__name__])
@inner_api_ns.doc(
responses={
HTTPStatus.OK: "Import completed",
HTTPStatus.ACCEPTED: "Import pending (DSL version mismatch requires confirmation)",
HTTPStatus.BAD_REQUEST: "Import failed (business error)",
HTTPStatus.NOT_FOUND: "Creator account not found or inactive",
}
@inner_api_ns.response(HTTPStatus.OK, "Import completed", inner_api_ns.models[Import.__name__])
@inner_api_ns.response(
HTTPStatus.ACCEPTED,
"Import pending (DSL version mismatch requires confirmation)",
inner_api_ns.models[Import.__name__],
)
@inner_api_ns.response(
HTTPStatus.BAD_REQUEST, "Import failed (business error)", inner_api_ns.models[Import.__name__]
)
@inner_api_ns.response(
HTTPStatus.NOT_FOUND,
"Creator account not found or inactive",
inner_api_ns.models[SimpleMessageResponse.__name__],
)
def post(self, workspace_id: str):
"""Import a DSL into a workspace on behalf of a specified creator."""
@@ -103,11 +121,17 @@ class EnterpriseAppDSLExport(Resource):
@inner_api_ns.doc(
"enterprise_app_dsl_export",
params=query_params_from_model(EnterpriseAppDSLExportQuery),
responses={
200: "Export successful",
400: "Invalid workflow ID or unpublished workflow version",
404: "App or workflow version not found",
},
)
@inner_api_ns.response(200, "Export successful", inner_api_ns.models[AppExportResponse.__name__])
@inner_api_ns.response(
400,
"Invalid workflow ID or unpublished workflow version",
inner_api_ns.models[EnterpriseAppDSLWorkflowErrorResponse.__name__],
)
@inner_api_ns.response(
404,
"App or workflow version not found",
inner_api_ns.models[EnterpriseAppDSLExportNotFoundResponse.__name__],
)
def get(self, app_id: str):
"""Export an app's DSL as YAML."""
+8 -6
View File
@@ -19,6 +19,7 @@ from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from fields.file_grant_fields import ResolvedFileResponse
from libs.exception import BaseHTTPException
from libs.external_api import error_responses
from services.entities.file_grant_entities import FileGrantMintRequest, FileGrantScope, FileKind, FileRef
from services.file_grant_service import (
AppNotFoundError,
@@ -119,6 +120,13 @@ class EnterpriseFileGrantApi(Resource):
@setup_required
@enterprise_inner_api_only
@error_responses(
InvalidGrantRequestError,
GrantTtlTooLongError,
InvalidSubjectError,
GrantAppNotFoundError,
GrantedFileNotFoundError,
)
@inner_api_ns.doc("enterprise_mint_file_grant")
@inner_api_ns.expect(inner_api_ns.models[FileGrantMintPayload.__name__])
@inner_api_ns.response(
@@ -126,12 +134,6 @@ class EnterpriseFileGrantApi(Resource):
"File grant minted",
inner_api_ns.models[FileGrantMintResponse.__name__],
)
@inner_api_ns.doc(
responses={
400: "Invalid request, subject, or grant TTL",
404: "App not found, or a required file is not owned by the subject",
}
)
def post(self):
try:
payload = FileGrantMintPayload.model_validate(inner_api_ns.payload or {})
+5 -5
View File
@@ -3,7 +3,8 @@ from typing import Any
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import register_schema_model
from controllers.common.fields import SimpleMessageResponse
from controllers.common.schema import register_response_schema_model, register_schema_model
from controllers.console.wraps import model_validate, setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import inner_api_only
@@ -19,6 +20,7 @@ class InnerMailPayload(BaseModel):
register_schema_model(inner_api_ns, InnerMailPayload)
register_response_schema_model(inner_api_ns, SimpleMessageResponse)
class BaseMail(Resource):
@@ -37,7 +39,7 @@ class BaseMail(Resource):
substitutions=args.substitutions,
)
)
return {"message": "success"}, 200
return SimpleMessageResponse(message="success").model_dump(mode="json"), 200
@inner_api_ns.route("/enterprise/mail")
@@ -45,9 +47,7 @@ class EnterpriseMail(BaseMail):
@inner_api_ns.doc("send_enterprise_mail")
@inner_api_ns.doc(description="Send internal email for enterprise features")
@inner_api_ns.expect(inner_api_ns.models[InnerMailPayload.__name__])
@inner_api_ns.doc(
responses={200: "Email sent successfully", 401: "Unauthorized - invalid API key", 404: "Service not available"}
)
@inner_api_ns.response(200, "Email sent successfully", inner_api_ns.models[SimpleMessageResponse.__name__])
@inner_api_only
@setup_required
def post(self):
@@ -14,7 +14,11 @@ from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from controllers.common.schema import register_schema_model
from controllers.common.fields import SimpleMessageResponse
from controllers.common.schema import (
register_response_schema_models,
register_schema_model,
)
from controllers.console.wraps import model_validate, setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
@@ -24,6 +28,7 @@ from core.helper.provider_encryption import create_provider_encrypter
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
from core.tools.tool_manager import ToolManager
from extensions.ext_database import db
from fields.base import ResponseModel
from models.provider import ProviderCredential
from models.tools import BuiltinToolProvider
@@ -51,32 +56,44 @@ class InnerRuntimeCredentialsResolvePayload(BaseModel):
credentials: list[InnerRuntimeCredentialResolveItem] = Field(default_factory=list)
class InnerRuntimeCredentialResponse(ResponseModel):
credential_id: str
provider: str
kind: str
values: dict[str, Any]
class InnerRuntimeCredentialsResolveResponse(ResponseModel):
credentials: list[InnerRuntimeCredentialResponse] = Field(default_factory=list)
register_schema_model(inner_api_ns, InnerRuntimeCredentialsResolvePayload)
register_response_schema_models(inner_api_ns, InnerRuntimeCredentialResponse, InnerRuntimeCredentialsResolveResponse)
@inner_api_ns.route("/enterprise/credentials/resolve")
class EnterpriseRuntimeCredentialsResolve(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc(
"enterprise_runtime_credentials_resolve",
responses={
200: "Credentials resolved",
400: "Invalid request or credential config",
404: "Provider or credential not found",
},
)
@inner_api_ns.doc("enterprise_runtime_credentials_resolve")
@inner_api_ns.expect(inner_api_ns.models[InnerRuntimeCredentialsResolvePayload.__name__])
@inner_api_ns.response(
200, "Credentials resolved", inner_api_ns.models[InnerRuntimeCredentialsResolveResponse.__name__]
)
@inner_api_ns.response(
400, "Invalid request or credential config", inner_api_ns.models[SimpleMessageResponse.__name__]
)
@inner_api_ns.response(404, "Provider or credential not found", inner_api_ns.models[SimpleMessageResponse.__name__])
@model_validate(InnerRuntimeCredentialsResolvePayload)
def post(self, args: InnerRuntimeCredentialsResolvePayload):
if not args.credentials:
return {"credentials": []}, 200
return InnerRuntimeCredentialsResolveResponse(credentials=[]).model_dump(mode="json"), 200
# Model resolution shares one provider configuration set; build it lazily
# so a tool-only request never pays for the plugin daemon round trip.
model_configurations = None
resolved: list[dict[str, Any]] = []
resolved: list[InnerRuntimeCredentialResponse] = []
for item in args.credentials:
if item.kind == _KIND_MODEL:
if model_configurations is None:
@@ -88,18 +105,18 @@ class EnterpriseRuntimeCredentialsResolve(Resource):
else:
return {"message": f"unsupported credential kind '{item.kind}'"}, 400
if error is not None:
if values is None:
return error
resolved.append(
{
"credential_id": item.credential_id,
"kind": item.kind,
"provider": item.provider,
"values": values,
}
InnerRuntimeCredentialResponse(
credential_id=item.credential_id,
kind=item.kind,
provider=item.provider,
values=values,
)
)
return {"credentials": resolved}, 200
return InnerRuntimeCredentialsResolveResponse(credentials=resolved).model_dump(mode="json"), 200
def _resolve_model(
@@ -1,15 +1,19 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel
from controllers.common.errors import InvalidArgumentError
from controllers.common.schema import register_schema_models
from controllers.common.fields import SimpleMessageResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.wraps import setup_required, validate_request
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import inner_api_only
from enums.account import TenantAccountRole
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from services.account_errors import AccountNotFoundError
from services.errors.workspace import (
InvalidWorkspaceMemberRoleError,
@@ -40,6 +44,56 @@ class WorkspaceMemberPayload(BaseModel):
register_schema_models(inner_api_ns, WorkspaceCreatePayload, WorkspaceOwnerlessPayload, WorkspaceMemberPayload)
class WorkspaceResponse(ResponseModel):
id: str
name: str
plan: str
status: str
created_at: str | None = None
updated_at: str | None = None
class WorkspaceOwnerlessTenantResponse(WorkspaceResponse):
encrypt_public_key: str | None = None
custom_config: dict[str, Any]
class WorkspaceCreateResponse(ResponseModel):
message: str
tenant: WorkspaceResponse
class WorkspaceOwnerlessCreateResponse(ResponseModel):
message: str
tenant: WorkspaceOwnerlessTenantResponse
class WorkspaceMemberInfoResponse(ResponseModel):
workspace_id: str
account_id: str
role: str
class WorkspaceMemberResponse(ResponseModel):
message: str
member: WorkspaceMemberInfoResponse
def _iso_utc(value: datetime | None) -> str | None:
return value.isoformat() + "Z" if value else None
register_response_schema_models(
inner_api_ns,
WorkspaceResponse,
WorkspaceOwnerlessTenantResponse,
WorkspaceCreateResponse,
WorkspaceOwnerlessCreateResponse,
WorkspaceMemberResponse,
SimpleMessageResponse,
)
@inner_api_ns.route("/enterprise/workspace")
class EnterpriseWorkspace(Resource):
@setup_required
@@ -47,13 +101,8 @@ class EnterpriseWorkspace(Resource):
@inner_api_ns.doc("create_enterprise_workspace")
@inner_api_ns.doc(description="Create a new enterprise workspace with owner assignment")
@inner_api_ns.expect(inner_api_ns.models[WorkspaceCreatePayload.__name__])
@inner_api_ns.doc(
responses={
200: "Workspace created successfully",
401: "Unauthorized - invalid API key",
404: "Owner account not found or service not available",
}
)
@inner_api_ns.response(200, "Workspace created successfully", inner_api_ns.models[WorkspaceCreateResponse.__name__])
@inner_api_ns.response(404, "Owner account not found", inner_api_ns.models[SimpleMessageResponse.__name__])
def post(self):
args = validate_request(WorkspaceCreatePayload)
try:
@@ -62,17 +111,17 @@ class EnterpriseWorkspace(Resource):
return {"message": "owner account not found."}, 404
except WorkspacesLimitExceededError as exc:
raise InvalidArgumentError(str(exc)) from exc
return {
"message": "enterprise workspace created.",
"tenant": {
"id": tenant.id,
"name": tenant.name,
"plan": tenant.plan,
"status": tenant.status,
"created_at": tenant.created_at.isoformat() + "Z" if tenant.created_at else None,
"updated_at": tenant.updated_at.isoformat() + "Z" if tenant.updated_at else None,
},
}
return WorkspaceCreateResponse(
message="enterprise workspace created.",
tenant=WorkspaceResponse(
id=tenant.id,
name=tenant.name,
plan=tenant.plan,
status=tenant.status,
created_at=_iso_utc(tenant.created_at),
updated_at=_iso_utc(tenant.updated_at),
),
).model_dump(mode="json")
@inner_api_ns.route("/enterprise/workspace/ownerless")
@@ -82,29 +131,25 @@ class EnterpriseWorkspaceNoOwnerEmail(Resource):
@inner_api_ns.doc("create_enterprise_workspace_ownerless")
@inner_api_ns.doc(description="Create a new enterprise workspace without initial owner assignment")
@inner_api_ns.expect(inner_api_ns.models[WorkspaceOwnerlessPayload.__name__])
@inner_api_ns.doc(
responses={
200: "Workspace created successfully",
401: "Unauthorized - invalid API key",
404: "Service not available",
}
@inner_api_ns.response(
200, "Workspace created successfully", inner_api_ns.models[WorkspaceOwnerlessCreateResponse.__name__]
)
def post(self):
args = validate_request(WorkspaceOwnerlessPayload)
tenant = application_services().workspaces.provisioning.create(name=args.name)
return {
"message": "enterprise workspace created.",
"tenant": {
"id": tenant.id,
"name": tenant.name,
"encrypt_public_key": tenant.encrypt_public_key,
"plan": tenant.plan,
"status": tenant.status,
"custom_config": tenant.custom_config,
"created_at": tenant.created_at.isoformat() + "Z" if tenant.created_at else None,
"updated_at": tenant.updated_at.isoformat() + "Z" if tenant.updated_at else None,
},
}
return WorkspaceOwnerlessCreateResponse(
message="enterprise workspace created.",
tenant=WorkspaceOwnerlessTenantResponse(
id=tenant.id,
name=tenant.name,
plan=tenant.plan,
status=tenant.status,
created_at=_iso_utc(tenant.created_at),
updated_at=_iso_utc(tenant.updated_at),
encrypt_public_key=tenant.encrypt_public_key,
custom_config=tenant.custom_config,
),
).model_dump(mode="json")
@inner_api_ns.route("/enterprise/workspace/member")
@@ -114,14 +159,11 @@ class EnterpriseWorkspaceMember(Resource):
@inner_api_ns.doc("join_enterprise_workspace_member")
@inner_api_ns.doc(description="Add an existing account to an enterprise workspace")
@inner_api_ns.expect(inner_api_ns.models[WorkspaceMemberPayload.__name__])
@inner_api_ns.doc(
responses={
200: "Workspace member joined successfully",
400: "Invalid workspace member role",
401: "Unauthorized - invalid API key",
404: "Workspace or account not found",
}
@inner_api_ns.response(
200, "Workspace member joined successfully", inner_api_ns.models[WorkspaceMemberResponse.__name__]
)
@inner_api_ns.response(400, "Invalid workspace member role", inner_api_ns.models[SimpleMessageResponse.__name__])
@inner_api_ns.response(404, "Workspace or account not found", inner_api_ns.models[SimpleMessageResponse.__name__])
def post(self):
args = validate_request(WorkspaceMemberPayload)
try:
@@ -138,14 +180,14 @@ class EnterpriseWorkspaceMember(Resource):
return {"message": "workspace not found."}, 404
except AccountNotFoundError:
return {"message": "account not found."}, 404
return {
"message": "enterprise workspace member joined.",
"member": {
"workspace_id": member.workspace_id,
"account_id": member.account_id,
"role": member.role,
},
}
return WorkspaceMemberResponse(
message="enterprise workspace member joined.",
member=WorkspaceMemberInfoResponse(
workspace_id=member.workspace_id,
account_id=member.account_id,
role=member.role,
),
).model_dump(mode="json")
@inner_api_ns.route("/enterprise/workspace/<uuid:workspace_id>/resources")
@@ -154,7 +196,7 @@ class EnterpriseWorkspaceResourceDeletion(Resource):
@inner_api_only
@inner_api_ns.doc("delete_enterprise_workspace_resources")
@inner_api_ns.doc(description="Schedule App, Dataset and Agent cleanup; retain the workspace and memberships")
@inner_api_ns.doc(responses={202: "Cleanup accepted", 401: "Invalid API key"})
@inner_api_ns.response(202, "Cleanup accepted", inner_api_ns.models[SimpleMessageResponse.__name__])
def delete(self, workspace_id: UUID):
application_services().workspaces.resource_cleanup.cleanup(str(workspace_id))
return {"message": "workspace resource cleanup accepted."}, 202
return SimpleMessageResponse(message="workspace resource cleanup accepted.").model_dump(mode="json"), 202
+3 -1
View File
@@ -28,6 +28,8 @@ logger = logging.getLogger(__name__)
SWAGGER_MARKDOWN_PACKAGE = "swagger-markdown@3.0.0"
CONSOLE_OPENAPI_FILENAME = "console-openapi.json"
STALE_COMBINED_MARKDOWN_FILENAME = "api-reference.md"
# The inner API is a trusted server-to-server surface, not part of the published API reference.
MARKDOWN_SPEC_TARGETS = tuple(target for target in SPEC_TARGETS if target.filename != "inner-openapi.json")
def _schema_ref_name(schema: object) -> str | None:
@@ -368,7 +370,7 @@ def generate_markdown_docs(
with tempfile.TemporaryDirectory(prefix="dify-api-docs-") as temp_dir:
temp_markdown_dir = Path(temp_dir)
for target in SPEC_TARGETS:
for target in MARKDOWN_SPEC_TARGETS:
openapi_path = openapi_paths_by_name[target.filename]
markdown_path = markdown_dir / f"{openapi_path.stem}.md"
_convert_spec_to_markdown(openapi_path, markdown_path)
+8 -1
View File
@@ -49,6 +49,8 @@ SPEC_TARGETS: tuple[SpecTarget, ...] = (
SpecTarget(route="/api/openapi.json", filename="web-openapi.json"),
SpecTarget(route="/v1/openapi.json", filename="service-openapi.json"),
SpecTarget(route="/openapi/v1/openapi.json", filename="openapi-openapi.json"),
# Not consumed by @dify/contracts; enterprise generates its inner API client from this spec.
SpecTarget(route="/inner/api/openapi.json", filename="inner-openapi.json"),
)
@@ -167,6 +169,8 @@ def create_spec_app() -> Flask:
from controllers.console import bp as console_bp
from controllers.console import console_ns
from controllers.inner_api import bp as inner_api_bp
from controllers.inner_api import inner_api_ns
from controllers.openapi import bp as openapi_bp
from controllers.openapi import openapi_ns
from controllers.service_api import bp as service_api_bp
@@ -178,8 +182,9 @@ def create_spec_app() -> Flask:
app.register_blueprint(web_bp)
app.register_blueprint(service_api_bp)
app.register_blueprint(openapi_bp)
app.register_blueprint(inner_api_bp)
for namespace in (console_ns, web_ns, service_api_ns, openapi_ns):
for namespace in (console_ns, web_ns, service_api_ns, openapi_ns, inner_api_ns):
for api in namespace.apis:
_materialize_inline_model_definitions(api)
@@ -190,6 +195,7 @@ def _target_apis() -> dict[str, Api]:
"""Return the API instance that owns each exported OpenAPI target."""
from controllers.console import api as console_api
from controllers.inner_api import api as inner_api
from controllers.openapi import api as openapi_api
from controllers.service_api import api as service_api
from controllers.web import api as web_api
@@ -199,6 +205,7 @@ def _target_apis() -> dict[str, Api]:
"/api/openapi.json": web_api,
"/v1/openapi.json": service_api,
"/openapi/v1/openapi.json": openapi_api,
"/inner/api/openapi.json": inner_api,
}
@@ -71,7 +71,7 @@ def _reset_schema_cache(api):
api.__dict__.pop("__schema__", None)
def test_generate_specs_writes_console_web_and_service_openapi_files(tmp_path: Path):
def test_generate_specs_writes_all_openapi_files(tmp_path: Path):
module = _load_generate_swagger_specs_module()
written_paths = module.generate_specs(tmp_path)
@@ -81,6 +81,7 @@ def test_generate_specs_writes_console_web_and_service_openapi_files(tmp_path: P
"web-openapi.json",
"service-openapi.json",
"openapi-openapi.json",
"inner-openapi.json",
]
for path in written_paths:
@@ -88,6 +89,20 @@ def test_generate_specs_writes_console_web_and_service_openapi_files(tmp_path: P
assert payload["openapi"] == "3.1.0"
assert "paths" in payload
inner_path = next(path for path in written_paths if path.name == "inner-openapi.json")
inner_payload = json.loads(inner_path.read_text(encoding="utf-8"))
assert inner_payload["security"] == [{"InnerApiKey": []}]
assert inner_payload["components"]["securitySchemes"] == {
"InnerApiKey": {
"type": "apiKey",
"in": "header",
"name": "X-Inner-Api-Key",
"description": "INNER_API_KEY for enterprise callers; INNER_API_KEY_FOR_PLUGIN for plugin/agent callers.",
}
}
assert "Bearer" not in inner_payload["components"]["securitySchemes"]
assert "/enterprise/apps/{app_id}/dsl" in inner_payload["paths"]
def test_generate_specs_match_live_openapi_payloads(tmp_path: Path):
module = _load_generate_swagger_specs_module()
@@ -127,11 +142,12 @@ import sys
from pathlib import Path
from controllers.console import api as console_api
from controllers.inner_api import api as inner_api
from controllers.openapi import api as openapi_api
from controllers.service_api import api as service_api
from controllers.web import api as web_api
apis = (console_api, web_api, service_api, openapi_api)
apis = (console_api, web_api, service_api, openapi_api, inner_api)
assert all(api._add_specs is False for api in apis)
from dev.generate_swagger_specs import generate_specs
@@ -142,6 +158,7 @@ assert [path.name for path in written_paths] == [
"web-openapi.json",
"service-openapi.json",
"openapi-openapi.json",
"inner-openapi.json",
]
"""
env = os.environ.copy()
@@ -0,0 +1,294 @@
"""Coverage regressions for the enterprise inner API OpenAPI document."""
import importlib.util
import json
import re
import sys
from collections.abc import Iterator
from pathlib import Path
from types import ModuleType
from typing import cast
import pytest
from jsonschema import Draft202012Validator, ValidationError
type Json = dict[str, object]
HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put", "trace"}
WRITE_METHODS = {"patch", "post", "put"}
ENTERPRISE_PREFIX = "/enterprise/"
EXPECTED_ENTERPRISE_OPERATION_IDS = {
"create_enterprise_workspace",
"create_enterprise_workspace_ownerless",
"delete_enterprise_workspace_resources",
"enterprise_app_dsl_export",
"enterprise_app_dsl_import",
"enterprise_mint_file_grant",
"enterprise_runtime_credentials_resolve",
"join_enterprise_workspace_member",
"send_enterprise_mail",
}
def _obj(value: object) -> Json:
return cast(Json, value)
def _at(value: object, *keys: str) -> Json:
"""Walk nested JSON objects by key; each step is cast because the spec is untyped JSON."""
node = _obj(value)
for key in keys:
node = _obj(node[key])
return node
def _load_generate_swagger_specs_module() -> ModuleType:
api_dir = Path(__file__).resolve().parents[4]
script_path = api_dir / "dev" / "generate_swagger_specs.py"
spec = importlib.util.spec_from_file_location("generate_swagger_specs", script_path)
assert spec
assert spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module) # type: ignore[attr-defined]
return module
@pytest.fixture(scope="module")
def inner_spec(tmp_path_factory: pytest.TempPathFactory) -> Json:
module = _load_generate_swagger_specs_module()
written_paths = module.generate_specs(tmp_path_factory.mktemp("inner_api_spec"))
inner_path = next(path for path in written_paths if path.name == "inner-openapi.json")
return _obj(json.loads(inner_path.read_text(encoding="utf-8")))
def _enterprise_operations(spec: Json) -> Iterator[tuple[str, str, Json, Json]]:
for path, path_item in _obj(spec["paths"]).items():
if not path.startswith(ENTERPRISE_PREFIX):
continue
item = _obj(path_item)
for method, operation in item.items():
if method in HTTP_METHODS:
yield path, method, item, _obj(operation)
def _operation(spec: Json, path: str, method: str) -> Json:
return _at(spec, "paths", path, method)
def _schema(spec: Json, name: str) -> Json:
return _at(spec, "components", "schemas", name)
def _refs(value: object) -> Iterator[str]:
if isinstance(value, dict):
mapping = _obj(value)
ref = mapping.get("$ref")
if isinstance(ref, str):
yield ref
for child in mapping.values():
yield from _refs(child)
elif isinstance(value, list):
for child in cast(list[object], value):
yield from _refs(child)
def _validator(spec: Json, name: str) -> Draft202012Validator:
return Draft202012Validator({"$ref": f"#/components/schemas/{name}", "components": spec["components"]})
def test_enterprise_operations_declare_typed_request_bodies(inner_spec: Json) -> None:
operations = list(_enterprise_operations(inner_spec))
assert operations
for path, method, _, operation in operations:
if method in WRITE_METHODS:
media = _at(operation, "requestBody", "content", "application/json")
assert "schema" in media, f"{method} {path} request body has no schema"
if method == "get":
assert "requestBody" not in operation, f"GET {path} must not declare a request body"
def test_enterprise_success_responses_are_typed(inner_spec: Json) -> None:
for path, method, _, operation in _enterprise_operations(inner_spec):
for code, response in _obj(operation["responses"]).items():
if not code.startswith("2"):
continue
content = _obj(_obj(response).get("content"))
assert content, f"{method} {path} {code} has no content"
for media_type, media in content.items():
assert "schema" in _obj(media), f"{method} {path} {code} {media_type} has no schema"
def test_enterprise_error_responses_are_typed(inner_spec: Json) -> None:
for path, method, _, operation in _enterprise_operations(inner_spec):
for code, response in _obj(operation["responses"]).items():
if code.startswith("2"):
continue
content = _obj(_obj(response).get("content"))
assert content, f"{method} {path} {code} is not typed"
json_media = _obj(content.get("application/json"))
assert "schema" in json_media, f"{method} {path} {code} has no JSON schema"
def test_enterprise_path_placeholders_have_required_path_parameters(inner_spec: Json) -> None:
for path, method, path_item, operation in _enterprise_operations(inner_spec):
parameters = [
*cast(list[Json], path_item.get("parameters", [])),
*cast(list[Json], operation.get("parameters", [])),
]
declared = {
parameter["name"]
for parameter in parameters
if parameter.get("in") == "path" and parameter.get("required") is True
}
for placeholder in re.findall(r"{(\w+)}", path):
assert placeholder in declared, f"{method} {path} missing required path parameter {placeholder}"
def test_enterprise_schema_references_resolve(inner_spec: Json) -> None:
schemas = _at(inner_spec, "components", "schemas")
for _, _, _, operation in _enterprise_operations(inner_spec):
for ref in _refs(operation):
assert ref.removeprefix("#/components/schemas/") in schemas, ref
def test_enterprise_operation_ids_are_unchanged(inner_spec: Json) -> None:
operation_ids = [operation["operationId"] for _, _, _, operation in _enterprise_operations(inner_spec)]
assert len(operation_ids) == len(set(operation_ids))
assert set(operation_ids) == EXPECTED_ENTERPRISE_OPERATION_IDS
def test_enterprise_mail_documents_simple_message_response(inner_spec: Json) -> None:
media = _at(_operation(inner_spec, "/enterprise/mail", "post"), "responses", "200", "content", "application/json")
assert media["schema"] == {"$ref": "#/components/schemas/SimpleMessageResponse"}
def test_ownerless_workspace_custom_config_is_object(inner_spec: Json) -> None:
schema = _schema(inner_spec, "WorkspaceOwnerlessTenantResponse")
custom_config = _at(schema, "properties", "custom_config")
assert custom_config["type"] == "object"
assert custom_config["additionalProperties"] is True
assert "custom_config" in cast(list[str], schema["required"])
def test_owner_workspace_variant_excludes_custom_config_and_public_key(inner_spec: Json) -> None:
owner = _obj(_schema(inner_spec, "WorkspaceResponse")["properties"])
assert {"custom_config", "encrypt_public_key"}.isdisjoint(owner)
def test_runtime_credential_values_accept_arbitrary_fields(inner_spec: Json) -> None:
values = _at(_schema(inner_spec, "InnerRuntimeCredentialResponse"), "properties", "values")
assert values["type"] == "object"
assert values["additionalProperties"] is True
def test_import_response_excludes_app_created(inner_spec: Json) -> None:
assert "app_created" not in _obj(_schema(inner_spec, "Import")["properties"])
def test_dsl_export_documents_yaml_data_string(inner_spec: Json) -> None:
assert _at(_schema(inner_spec, "AppExportResponse"), "properties", "data")["type"] == "string"
def test_ownerless_create_sample_validates_custom_config_object(inner_spec: Json) -> None:
tenant = {
"id": "t",
"name": "n",
"plan": "p",
"status": "normal",
"encrypt_public_key": None,
"custom_config": {"remove_webapp_brand": True},
"created_at": None,
"updated_at": None,
}
validator = _validator(inner_spec, "WorkspaceOwnerlessCreateResponse")
validator.validate({"message": "ok", "tenant": tenant})
validator.validate({"message": "ok", "tenant": {**tenant, "custom_config": {}}})
with pytest.raises(ValidationError):
validator.validate({"message": "ok", "tenant": {**tenant, "custom_config": json.dumps({"a": 1})}})
def test_credentials_sample_accepts_arbitrary_values(inner_spec: Json) -> None:
_validator(inner_spec, "InnerRuntimeCredentialsResolveResponse").validate(
{"credentials": [{"credential_id": "c", "provider": "p", "kind": "model", "values": {"api_key": "k", "x": 1}}]}
)
ERROR_RESPONSE_REF = {"$ref": "#/components/schemas/ErrorResponse"}
LEGACY_SIMPLE_MESSAGE_RESPONSES = {
"create_enterprise_workspace": ("404",),
"enterprise_app_dsl_import": ("404",),
"enterprise_runtime_credentials_resolve": ("400", "404"),
"join_enterprise_workspace_member": ("400", "404"),
}
FILE_GRANT_BUSINESS_COMPONENTS = {
"400": {"InvalidRequestError", "GrantTtlTooLongError", "InvalidSubjectError"},
"404": {"AppNotFoundError", "FileNotFoundError"},
}
def _response_schema(operation: Json, code: str) -> Json:
return _at(operation, "responses", code, "content", "application/json", "schema")
def _alternatives(schema: Json) -> list[Json]:
return [_obj(item) for item in cast(list[object], schema["anyOf"])] if "anyOf" in schema else [schema]
def _operations_by_id(spec: Json) -> dict[str, Json]:
return {str(operation["operationId"]): operation for _, _, _, operation in _enterprise_operations(spec)}
def test_enterprise_operations_document_default_error_response(inner_spec: Json) -> None:
for path, method, _, operation in _enterprise_operations(inner_spec):
assert _response_schema(operation, "default") == ERROR_RESPONSE_REF, f"{method} {path}"
def test_enterprise_documented_errors_include_standard_error_body(inner_spec: Json) -> None:
for path, method, _, operation in _enterprise_operations(inner_spec):
for code in _obj(operation["responses"]):
if code == "default" or not code.startswith(("4", "5")):
continue
assert ERROR_RESPONSE_REF in _alternatives(_response_schema(operation, code)), f"{method} {path} {code}"
def test_legacy_message_statuses_document_simple_message(inner_spec: Json) -> None:
operations = _operations_by_id(inner_spec)
for operation_id, codes in LEGACY_SIMPLE_MESSAGE_RESPONSES.items():
for code in codes:
assert {"$ref": "#/components/schemas/SimpleMessageResponse"} in _alternatives(
_response_schema(operations[operation_id], code)
), f"{operation_id} {code}"
def test_dsl_export_not_found_union_documents_simple_message(inner_spec: Json) -> None:
operation = _operations_by_id(inner_spec)["enterprise_app_dsl_export"]
assert _alternatives(_response_schema(operation, "404"))[0] == {
"$ref": "#/components/schemas/EnterpriseAppDSLExportNotFoundResponse"
}
union = _alternatives(_schema(inner_spec, "EnterpriseAppDSLExportNotFoundResponse"))
assert {"$ref": "#/components/schemas/SimpleMessageResponse"} in union
def test_file_grants_document_business_error_components(inner_spec: Json) -> None:
operation = _operations_by_id(inner_spec)["enterprise_mint_file_grant"]
for code, expected in FILE_GRANT_BUSINESS_COMPONENTS.items():
refs = {
str(item["$ref"]).rsplit("/", 1)[-1]
for item in _alternatives(_response_schema(operation, code))
if "$ref" in item
}
assert expected <= refs, (code, refs)
for name in expected:
error_body = cast(list[object], _schema(inner_spec, name)["allOf"])[1]
assert _at(error_body, "properties", "status")["const"] == int(code), name
@@ -87,6 +87,15 @@ def test_create_serialization(
assert "custom_config" not in body["tenant"]
def test_ownerless_create_keeps_empty_custom_config_object(app: Flask, provisioning: Mock) -> None:
provisioning.create.return_value = CreatedWorkspace(
"w1", "Test", "sandbox", "normal", datetime(2026, 1, 1), None, "public-key", {}
)
with app.test_request_context(method="POST", json={"name": "Test"}):
body = unwrap(controller.EnterpriseWorkspaceNoOwnerEmail.post)(controller.EnterpriseWorkspaceNoOwnerEmail())
assert body["tenant"]["custom_config"] == {}
def test_owner_missing(app: Flask, provisioning: Mock) -> None:
provisioning.create.side_effect = WorkspaceOwnerNotFoundError()
with app.test_request_context(method="POST", json={"name": "Test", "owner_email": "absent@example.com"}):