diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index addeb44e00c..9fac0a6d1f7 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -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 diff --git a/api/controllers/inner_api/app/dsl.py b/api/controllers/inner_api/app/dsl.py index e411ef07c5e..301a754f2bb 100644 --- a/api/controllers/inner_api/app/dsl.py +++ b/api/controllers/inner_api/app/dsl.py @@ -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//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.""" diff --git a/api/controllers/inner_api/app/file_grants.py b/api/controllers/inner_api/app/file_grants.py index 0acc9754c73..7324710839b 100644 --- a/api/controllers/inner_api/app/file_grants.py +++ b/api/controllers/inner_api/app/file_grants.py @@ -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 {}) diff --git a/api/controllers/inner_api/mail.py b/api/controllers/inner_api/mail.py index f445c411880..4ee4a0e5602 100644 --- a/api/controllers/inner_api/mail.py +++ b/api/controllers/inner_api/mail.py @@ -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): diff --git a/api/controllers/inner_api/runtime_credentials.py b/api/controllers/inner_api/runtime_credentials.py index c828ab07822..96144cb99c3 100644 --- a/api/controllers/inner_api/runtime_credentials.py +++ b/api/controllers/inner_api/runtime_credentials.py @@ -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( diff --git a/api/controllers/inner_api/workspace/workspace.py b/api/controllers/inner_api/workspace/workspace.py index 2ecc2d7797b..180f0a0e95b 100644 --- a/api/controllers/inner_api/workspace/workspace.py +++ b/api/controllers/inner_api/workspace/workspace.py @@ -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//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 diff --git a/api/dev/generate_swagger_markdown_docs.py b/api/dev/generate_swagger_markdown_docs.py index 7d43ff8c9da..6b55b64bffb 100644 --- a/api/dev/generate_swagger_markdown_docs.py +++ b/api/dev/generate_swagger_markdown_docs.py @@ -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) diff --git a/api/dev/generate_swagger_specs.py b/api/dev/generate_swagger_specs.py index a99dd3ffaf6..d273490f0c5 100644 --- a/api/dev/generate_swagger_specs.py +++ b/api/dev/generate_swagger_specs.py @@ -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, } diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py index 00cfdede2b1..d432158e641 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -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() diff --git a/api/tests/unit_tests/controllers/inner_api/test_schema.py b/api/tests/unit_tests/controllers/inner_api/test_schema.py new file mode 100644 index 00000000000..d20f1ac007d --- /dev/null +++ b/api/tests/unit_tests/controllers/inner_api/test_schema.py @@ -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 diff --git a/api/tests/unit_tests/controllers/inner_api/workspace/test_workspace.py b/api/tests/unit_tests/controllers/inner_api/workspace/test_workspace.py index 64c5682339b..6216747a731 100644 --- a/api/tests/unit_tests/controllers/inner_api/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/inner_api/workspace/test_workspace.py @@ -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"}):