"""Inner API endpoints for app DSL import/export. Called by the enterprise admin-api service. Import requires ``creator_email`` to attribute the created app; workspace/membership validation is done by the Go admin-api caller. """ from http import HTTPStatus from uuid import UUID from flask import request from flask_restx import Resource from pydantic import BaseModel, Field, RootModel, ValidationError, field_validator from werkzeug.exceptions import Forbidden from controllers.common.fields import SimpleMessageResponse 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 from core.logging.context import get_request_id, get_trace_id from extensions.ext_application_services import application_services from fields.app_fields import AppExportResponse from fields.base import ResponseModel from libs.helper import dump_response from services.entities.app_entities import AppExportOptions from services.entities.dsl_entities import AppImportParams, Import, ImportMode, ImportStatus from services.errors.app import AppDiscoveryNotFoundError, IsDraftWorkflowError, WorkflowNotFoundError from services.errors.base import NoPermissionError class InnerAppDSLImportPayload(BaseModel): yaml_content: str = Field(description="YAML DSL content") creator_email: str = Field(description="Email of the workspace member who will own the imported app") name: str | None = Field(default=None, description="Override app name from DSL") description: str | None = Field(default=None, description="Override app description from DSL") class EnterpriseAppDSLExportQuery(BaseModel): include_secret: bool = Field(default=False, description="Whether to include secret values in the exported DSL") workflow_id: UUID | None = Field(default=None, description="Published workflow version ID to export") @field_validator("include_secret", mode="before") @classmethod def parse_include_secret(cls, value: object) -> bool: if isinstance(value, str): return value.lower() == "true" return bool(value) class EnterpriseAppDSLWorkflowErrorResponse(ResponseModel): code: str message: str 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") class EnterpriseAppDSLImport(Resource): @setup_required @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.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.FORBIDDEN, "Import permission denied or app quota exceeded") @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.""" args = validate_request(InnerAppDSLImportPayload) try: result = application_services().apps.imports.import_as_creator( workspace_id=workspace_id, creator_email=args.creator_email, params=AppImportParams( mode=ImportMode.YAML_CONTENT, yaml_content=args.yaml_content, name=args.name, description=args.description, ), request_id=get_request_id(), trace_id=get_trace_id(), ) except NoPermissionError as error: raise Forbidden(str(error)) from error if result is None: return dump_response( SimpleMessageResponse, {"message": f"account '{args.creator_email}' not found or inactive"} ), HTTPStatus.NOT_FOUND if result.status == ImportStatus.FAILED: return dump_response(Import, result), HTTPStatus.BAD_REQUEST if result.status == ImportStatus.PENDING: return dump_response(Import, result), HTTPStatus.ACCEPTED return dump_response(Import, result), HTTPStatus.OK @inner_api_ns.route("/enterprise/apps//dsl") class EnterpriseAppDSLExport(Resource): @setup_required @enterprise_inner_api_only @inner_api_ns.doc( "enterprise_app_dsl_export", params=query_params_from_model(EnterpriseAppDSLExportQuery), ) @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.""" try: query = EnterpriseAppDSLExportQuery.model_validate(request.args.to_dict(flat=True)) except ValidationError: return dump_response( EnterpriseAppDSLWorkflowErrorResponse, { "code": "invalid_workflow_id", "message": "workflow_id must be a valid UUID", "status": HTTPStatus.BAD_REQUEST, }, ), HTTPStatus.BAD_REQUEST workflow_id = str(query.workflow_id) if query.workflow_id else None try: data = application_services().apps.exports.export_for_inner( app_id, AppExportOptions(include_secret=query.include_secret, workflow_id=workflow_id) ) except AppDiscoveryNotFoundError: return dump_response(SimpleMessageResponse, {"message": "app not found"}), HTTPStatus.NOT_FOUND except WorkflowNotFoundError as exc: if not workflow_id: raise return dump_response( EnterpriseAppDSLWorkflowErrorResponse, {"code": "workflow_version_not_found", "message": str(exc), "status": HTTPStatus.NOT_FOUND}, ), HTTPStatus.NOT_FOUND except IsDraftWorkflowError as exc: if not workflow_id: raise return dump_response( EnterpriseAppDSLWorkflowErrorResponse, {"code": "workflow_version_not_published", "message": str(exc), "status": HTTPStatus.BAD_REQUEST}, ), HTTPStatus.BAD_REQUEST return dump_response(AppExportResponse, {"data": data}), HTTPStatus.OK