dify/api/controllers/openapi/app_dsl.py
Yunlu Wen 0a051b598f
feat: support import / export dsl in CLI (#37232)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: cheatofrom <85830867+cheatofrom@users.noreply.github.com>
Co-authored-by: Escape0707 <tothesong@gmail.com>
Co-authored-by: Rohit Gahlawat <personal.rg56@gmail.com>
Co-authored-by: L1nSn0w <l1nsn0w@qq.com>
Co-authored-by: 盐粒 Yanli <yanli@dify.ai>
2026-06-10 09:51:40 +00:00

168 lines
6.7 KiB
Python

from __future__ import annotations
from typing import cast
from flask_restx import Resource
from sqlalchemy.orm import Session
from controllers.openapi import openapi_ns
from controllers.openapi._contract import accepts, returns
from controllers.openapi._models import AppDslExportQuery, AppDslExportResponse, AppDslImportPayload
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from models import Account, App
from models.account import TenantAccountRole
from services.app_dsl_service import AppDslService, Import
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.errors.app import WorkflowNotFoundError
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports")
class AppDslImportApi(Resource):
"""Import a DSL YAML string into the specified workspace.
Use ``mode=yaml-content`` with ``yaml_content`` for inline YAML, or
``mode=yaml-url`` with ``yaml_url`` for a remote URL. Provide ``app_id``
to overwrite an existing workflow or advanced-chat app; omit it to create
a new app.
Returns 202 when the DSL version requires an explicit confirmation step
(major version mismatch). Callers must then POST to the confirm endpoint.
Returns 400 when the import failed due to invalid DSL or a business error.
"""
@auth_router.guard_workspace(
scope=Scope.WORKSPACE_WRITE,
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@returns(200, Import, "Import completed")
@returns(202, Import, "Import pending confirmation")
@returns(400, Import, "Import failed")
@accepts(body=AppDslImportPayload)
def post(self, workspace_id: str, *, auth_data: AuthData, body: AppDslImportPayload):
account = cast(Account, auth_data.caller)
with Session(db.engine, expire_on_commit=False) as session:
service = AppDslService(session)
result = service.import_app(
account=account,
import_mode=body.mode,
yaml_content=body.yaml_content,
yaml_url=body.yaml_url,
name=body.name,
description=body.description,
icon_type=body.icon_type,
icon=body.icon,
icon_background=body.icon_background,
app_id=body.app_id,
)
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
match result.status:
case ImportStatus.FAILED:
return result, 400
case ImportStatus.PENDING:
return result, 202
case _:
return result, 200
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports/<string:import_id>/confirm")
class AppDslImportConfirmApi(Resource):
"""Confirm a pending DSL import identified by ``import_id``.
Required only when the initial import returned 202 (major DSL version
mismatch that requires explicit acknowledgement). The pending state is
stored in Redis for 10 minutes; this call retrieves it and completes the
import under the given workspace.
Returns 400 when the pending data has expired or the import fails.
"""
@auth_router.guard_workspace(
scope=Scope.WORKSPACE_WRITE,
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@returns(200, Import, "Import confirmed")
@returns(400, Import, "Import failed")
def post(self, workspace_id: str, import_id: str, *, auth_data: AuthData):
account = cast(Account, auth_data.caller)
with Session(db.engine, expire_on_commit=False) as session:
service = AppDslService(session)
result = service.confirm_import(import_id=import_id, account=account)
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
if result.status == ImportStatus.FAILED:
return result, 400
return result, 200
@openapi_ns.route("/apps/<string:app_id>/export")
class AppDslExportApi(Resource):
"""Export an app's current draft configuration as a DSL YAML string.
The auth pipeline resolves the app and its tenant from ``app_id``. Pass
``include_secret=true`` to embed encrypted credential values (e.g. tool
node secrets); omit it to produce a portable, sharable DSL safe to share.
Note: the pipeline enforces ``app.enable_api`` for all ``/apps/<app_id>``
routes in the openapi group. Apps with the service API disabled will
receive a 403; enable the API in the console first if needed.
"""
@auth_router.guard(
scope=Scope.APPS_READ,
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@accepts(query=AppDslExportQuery)
@returns(200, AppDslExportResponse, "Export successful")
def get(self, app_id: str, *, auth_data: AuthData, query: AppDslExportQuery):
app = cast(App, auth_data.app)
try:
data = AppDslService.export_dsl(
app_model=app,
include_secret=query.include_secret,
workflow_id=query.workflow_id,
)
except WorkflowNotFoundError as exc:
return str(exc), 404
return AppDslExportResponse(data=data), 200
@openapi_ns.route("/apps/<string:app_id>/check-dependencies")
class AppDslCheckDependenciesApi(Resource):
"""Check for leaked plugin dependencies after a DSL import.
Call this after an import that reported ``COMPLETED_WITH_WARNINGS`` to
find which plugin dependencies referenced in the DSL are not yet installed
in the workspace. Returns an empty ``leaked_dependencies`` list when all
dependencies are satisfied.
"""
@auth_router.guard(
scope=Scope.APPS_READ,
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@returns(200, CheckDependenciesResult, "Dependencies checked")
def get(self, app_id: str, *, auth_data: AuthData):
app = cast(App, auth_data.app)
with Session(db.engine, expire_on_commit=False) as session:
service = AppDslService(session)
result = service.check_dependencies(app_model=app)
return result, 200