mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
chore: generate inner api spec (#44047)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}):
|
||||
|
||||
Reference in New Issue
Block a user