diff --git a/api/controllers/console/snippets/payloads.py b/api/controllers/console/snippets/payloads.py index de4115b6576..86b7735de6f 100644 --- a/api/controllers/console/snippets/payloads.py +++ b/api/controllers/console/snippets/payloads.py @@ -167,3 +167,9 @@ class IncludeSecretQuery(BaseModel): """Query parameter for including secret variables in export.""" include_secret: str = Field(default="false", description="Whether to include secret variables") + + +class SnippetExportQuery(IncludeSecretQuery): + """Query parameters for exporting a snippet workflow as DSL.""" + + workflow_id: str | None = Field(default=None, description="Specific published workflow version to export") diff --git a/api/controllers/console/workspace/snippets.py b/api/controllers/console/workspace/snippets.py index 8c254dfa58b..f5e2f85a0b7 100644 --- a/api/controllers/console/workspace/snippets.py +++ b/api/controllers/console/workspace/snippets.py @@ -17,7 +17,7 @@ from controllers.console import console_ns from controllers.console.app.wraps import with_session from controllers.console.snippets.payloads import ( CreateSnippetPayload, - IncludeSecretQuery, + SnippetExportQuery, SnippetImportPayload, SnippetListQuery, UpdateSnippetPayload, @@ -89,7 +89,7 @@ register_schema_models( CreateSnippetPayload, UpdateSnippetPayload, SnippetImportPayload, - IncludeSecretQuery, + SnippetExportQuery, ) register_response_schema_models( console_ns, @@ -289,7 +289,7 @@ class CustomizedSnippetExportApi(Resource): @console_ns.doc("export_customized_snippet") @console_ns.doc(description="Export snippet configuration as DSL") @console_ns.doc(params={"snippet_id": "Snippet ID to export"}) - @console_ns.doc(params=query_params_from_model(IncludeSecretQuery)) + @console_ns.doc(params=query_params_from_model(SnippetExportQuery)) @console_ns.response(200, "Snippet exported successfully", console_ns.models[TextFileResponse.__name__]) @console_ns.response(404, "Snippet not found") @setup_required @@ -312,11 +312,18 @@ class CustomizedSnippetExportApi(Resource): raise NotFound("Snippet not found") # Get include_secret parameter - query = IncludeSecretQuery.model_validate(request.args.to_dict()) + query = SnippetExportQuery.model_validate(request.args.to_dict()) with Session(db.engine) as session: export_service = SnippetDslService(session) - result = export_service.export_snippet_dsl(snippet=snippet, include_secret=query.include_secret == "true") + try: + result = export_service.export_snippet_dsl( + snippet=snippet, + include_secret=query.include_secret == "true", + workflow_id=query.workflow_id, + ) + except ValueError as exc: + raise NotFound(str(exc)) from exc # Set filename with .snippet extension filename = f"{snippet.name}.snippet" diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index ecdd1f40916..6e97fe124e3 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -9929,6 +9929,7 @@ Export snippet configuration as DSL | ---- | ---------- | ----------- | -------- | ------ | | snippet_id | path | Snippet ID to export | Yes | string (uuid) | | include_secret | query | Whether to include secret variables | No | string,
**Default:** false | +| workflow_id | query | Specific published workflow version to export | No | string | #### Responses @@ -18218,11 +18219,9 @@ How Dify forwards the end-user's identity to an MCP server. #### IncludeSecretQuery -Query parameter for including secret variables in export. - | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| include_secret | string,
**Default:** false | Whether to include secret variables | No | +| include_secret | string,
**Default:** false | Whether to include secret values in the exported DSL | No | #### IndexingEstimate @@ -21421,6 +21420,15 @@ Payload for syncing snippet draft workflow. | hash | string | | No | | input_fields | [ object ] | | No | +#### SnippetExportQuery + +Query parameters for exporting a snippet workflow as DSL. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| include_secret | string,
**Default:** false | Whether to include secret variables | No | +| workflow_id | string | Specific published workflow version to export | No | + #### SnippetImportPayload Payload for importing snippet from DSL. diff --git a/api/services/snippet_dsl_service.py b/api/services/snippet_dsl_service.py index b7d45d5caba..78361700ad4 100644 --- a/api/services/snippet_dsl_service.py +++ b/api/services/snippet_dsl_service.py @@ -497,17 +497,27 @@ class SnippetDslService: ) return snippet - def export_snippet_dsl(self, snippet: CustomizedSnippet, include_secret: bool = False) -> str: + def export_snippet_dsl( + self, snippet: CustomizedSnippet, include_secret: bool = False, workflow_id: str | None = None + ) -> str: """ Export snippet as DSL :param snippet: CustomizedSnippet instance :param include_secret: Whether include secret variable + :param workflow_id: Optional published workflow version to export; defaults to the draft workflow :return: YAML string """ snippet_service = self._snippet_service() - workflow = snippet_service.get_draft_workflow(snippet=snippet) + workflow = ( + snippet_service.get_published_workflow_by_id(snippet=snippet, workflow_id=workflow_id) + if workflow_id + else snippet_service.get_draft_workflow(snippet=snippet) + ) if not workflow: - raise ValueError("Missing draft workflow configuration, please check.") + workflow_description = ( + f"published workflow {workflow_id}" if workflow_id else "draft workflow configuration" + ) + raise ValueError(f"Missing {workflow_description}, please check.") icon_info = snippet.icon_info or {} export_data = { diff --git a/api/tests/unit_tests/controllers/console/workspace/test_snippets.py b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py index 211d9229a2a..e89aa6a6b57 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_snippets.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py @@ -354,14 +354,40 @@ def test_export_snippet_returns_yaml_attachment(app: Flask, monkeypatch: pytest. api = snippets_module.CustomizedSnippetExportApi() handler = unwrap(api.get) - with app.test_request_context("/workspaces/current/customized-snippets/snippet-1/export?include_secret=true"): + with app.test_request_context( + "/workspaces/current/customized-snippets/snippet-1/export?include_secret=true&workflow_id=workflow-1" + ): response = handler(api, "tenant-1", snippet_id="snippet-1") assert response.status_code == 200 assert response.get_data(as_text=True) == "version: 0.1.0\nkind: snippet\n" assert response.headers["Content-Type"] == "application/x-yaml" assert "Snippet%20One.snippet" in response.headers["Content-Disposition"] - export_snippet_dsl.assert_called_once_with(snippet=snippet, include_secret=True) + export_snippet_dsl.assert_called_once_with(snippet=snippet, include_secret=True, workflow_id="workflow-1") + + +def test_export_snippet_raises_not_found_for_missing_workflow(app: Flask, monkeypatch: pytest.MonkeyPatch): + snippet = _snippet(name="Snippet One") + + monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet)) + monkeypatch.setattr( + snippets_module, + "SnippetDslService", + Mock( + return_value=SimpleNamespace( + export_snippet_dsl=Mock(side_effect=ValueError("Missing published workflow workflow-1")) + ) + ), + ) + monkeypatch.setattr(snippets_module, "Session", _SessionContext) + monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object())) + + api = snippets_module.CustomizedSnippetExportApi() + handler = unwrap(api.get) + + with app.test_request_context("/workspaces/current/customized-snippets/snippet-1/export?workflow_id=workflow-1"): + with pytest.raises(NotFound, match="Missing published workflow workflow-1"): + handler(api, "tenant-1", snippet_id="snippet-1") def test_import_snippet_returns_202_for_pending_confirmation(app: Flask, monkeypatch: pytest.MonkeyPatch): diff --git a/api/tests/unit_tests/services/test_snippet_dsl_service.py b/api/tests/unit_tests/services/test_snippet_dsl_service.py index b706da797da..f213dd95267 100644 --- a/api/tests/unit_tests/services/test_snippet_dsl_service.py +++ b/api/tests/unit_tests/services/test_snippet_dsl_service.py @@ -650,6 +650,40 @@ def test_export_snippet_dsl_returns_yaml(monkeypatch: pytest.MonkeyPatch): assert "input_fields:" in result +def test_export_snippet_dsl_uses_requested_published_workflow(monkeypatch: pytest.MonkeyPatch): + service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) + workflow = SimpleNamespace( + to_dict=Mock(return_value={"graph": {"nodes": []}}), + graph_dict={"nodes": []}, + ) + snippet = SimpleNamespace( + tenant_id="tenant-1", + name="Exported", + description=None, + type="node", + icon_info=None, + input_fields_list=[], + ) + get_published_workflow_by_id = Mock(return_value=workflow) + get_draft_workflow = Mock() + monkeypatch.setattr( + "services.snippet_dsl_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace( + get_draft_workflow=get_draft_workflow, + get_published_workflow_by_id=get_published_workflow_by_id, + ), + ) + monkeypatch.setattr( + "services.snippet_dsl_service.DependenciesAnalysisService.generate_dependencies", + Mock(return_value=[]), + ) + + service.export_snippet_dsl(snippet, workflow_id="workflow-1") + + get_published_workflow_by_id.assert_called_once_with(snippet=snippet, workflow_id="workflow-1") + get_draft_workflow.assert_not_called() + + def test_append_workflow_export_data_filters_credentials_and_extracts_dependencies(monkeypatch: pytest.MonkeyPatch): service = SnippetDslService(session=SimpleNamespace()) workflow_dict = { diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index a7c53ab7a4b..99dab0f6291 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -2699,6 +2699,7 @@ export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportData = { } query?: { include_secret?: string + workflow_id?: string } url: '/workspaces/current/customized-snippets/{snippet_id}/export' } diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 8bcd255b3c9..c55c0e70099 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -3609,6 +3609,7 @@ export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportPath = z.ob export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportQuery = z.object({ include_secret: z.string().optional().default('false'), + workflow_id: z.string().optional(), }) /** diff --git a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx index b9cb9c000cf..d1fe99577cd 100644 --- a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx @@ -24,6 +24,7 @@ const mockHandleStartWorkflowRun = vi.fn() const mockHandleStopRun = vi.fn() const mockHandleWorkflowStartRunInWorkflow = vi.fn() const mockHandleCheckBeforePublish = vi.fn() +const mockHandleExportDSL = vi.fn() const mockUseAvailableNodesMetaData = vi.hoisted(() => vi.fn()) const mockConsoleState = vi.hoisted(() => ({ workspacePermissionKeys: ['snippets.create_and_modify'] as string[], @@ -141,6 +142,12 @@ vi.mock('../../hooks/use-snippet-start-run', () => ({ }), })) +vi.mock('../hooks/use-snippet-dsl', () => ({ + useSnippetDSL: () => ({ + handleExportDSL: mockHandleExportDSL, + }), +})) + vi.mock('@/app/components/workflow', () => ({ WorkflowWithInnerContext: ({ children, @@ -642,4 +649,12 @@ describe('SnippetMain', () => { }) }) }) + + describe('DSL Export', () => { + it('should pass the snippet DSL export handler to WorkflowWithInnerContext', () => { + renderSnippetMain() + + expect(capturedHooksStore?.handleExportDSL).toBe(mockHandleExportDSL) + }) + }) }) diff --git a/web/app/components/snippets/components/hooks/__tests__/use-snippet-dsl.spec.ts b/web/app/components/snippets/components/hooks/__tests__/use-snippet-dsl.spec.ts new file mode 100644 index 00000000000..0a02982a096 --- /dev/null +++ b/web/app/components/snippets/components/hooks/__tests__/use-snippet-dsl.spec.ts @@ -0,0 +1,62 @@ +import { toast } from '@langgenius/dify-ui/toast' +import { renderHook } from '@testing-library/react' +import { act } from 'react' +import { useExportSnippetMutation } from '@/service/use-snippets' +import { downloadBlob } from '@/utils/download' +import { useSnippetDSL } from '../use-snippet-dsl' + +const mockMutateAsync = vi.fn() + +vi.mock('@/service/use-snippets', () => ({ + useExportSnippetMutation: vi.fn(() => ({ + mutateAsync: mockMutateAsync, + })), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: vi.fn(), +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { + error: vi.fn(), + }, +})) + +describe('useSnippetDSL', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMutateAsync.mockResolvedValue('kind: snippet') + }) + + it('exports the requested historical workflow version', async () => { + const { result } = renderHook(() => + useSnippetDSL({ snippetId: 'snippet-1', snippetName: 'My Snippet' }), + ) + + await act(() => result.current.handleExportDSL(false, 'workflow-1')) + + expect(useExportSnippetMutation).toHaveBeenCalled() + expect(mockMutateAsync).toHaveBeenCalledWith({ + snippetId: 'snippet-1', + include: false, + workflowId: 'workflow-1', + }) + expect(downloadBlob).toHaveBeenCalledWith({ + data: expect.any(Blob), + fileName: 'My Snippet.yml', + }) + }) + + it('shows an error when exporting fails', async () => { + mockMutateAsync.mockRejectedValueOnce(new Error('failed')) + const { result } = renderHook(() => + useSnippetDSL({ snippetId: 'snippet-1', snippetName: 'My Snippet' }), + ) + + await act(() => result.current.handleExportDSL(false, 'workflow-1')) + + expect(toast.error).toHaveBeenCalledWith('snippet.exportFailed') + expect(downloadBlob).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/snippets/components/hooks/use-snippet-dsl.ts b/web/app/components/snippets/components/hooks/use-snippet-dsl.ts new file mode 100644 index 00000000000..eba5a0879e9 --- /dev/null +++ b/web/app/components/snippets/components/hooks/use-snippet-dsl.ts @@ -0,0 +1,34 @@ +import { toast } from '@langgenius/dify-ui/toast' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useExportSnippetMutation } from '@/service/use-snippets' +import { downloadBlob } from '@/utils/download' + +type UseSnippetDSLOptions = { + snippetId: string + snippetName: string +} + +export const useSnippetDSL = ({ snippetId, snippetName }: UseSnippetDSLOptions) => { + const { t } = useTranslation('snippet') + const exportSnippetMutation = useExportSnippetMutation() + + const handleExportDSL = useCallback( + async (include = false, workflowId?: string) => { + try { + const data = await exportSnippetMutation.mutateAsync({ + snippetId, + include, + workflowId, + }) + const file = new Blob([data], { type: 'application/yaml' }) + downloadBlob({ data: file, fileName: `${snippetName}.yml` }) + } catch { + toast.error(t(($) => $.exportFailed)) + } + }, + [exportSnippetMutation, snippetId, snippetName, t], + ) + + return { handleExportDSL } +} diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index 84d3a7ee542..ed53c71ba0c 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -26,6 +26,7 @@ import { useSnippetRun } from '../hooks/use-snippet-run' import { useSnippetStartRun } from '../hooks/use-snippet-start-run' import { useSnippetDetailStore } from '../store' import { canCreateAndModifySnippets } from '../utils/permission' +import { useSnippetDSL } from './hooks/use-snippet-dsl' import { useSnippetInputFieldActions } from './hooks/use-snippet-input-field-actions' import { useSnippetPublish } from './hooks/use-snippet-publish' import SnippetChildren from './snippet-children' @@ -203,6 +204,7 @@ const SnippetMain = ({ canEdit: canEditSnippet, snippetId, }) + const { handleExportDSL } = useSnippetDSL({ snippetId, snippetName: snippet.name }) const { handleStartWorkflowRun, handleWorkflowStartRunInWorkflow } = useSnippetStartRun({ handleRun, }) @@ -320,6 +322,7 @@ const SnippetMain = ({ handleStopRun, handleStartWorkflowRun, handleWorkflowStartRunInWorkflow, + handleExportDSL, getWorkflowRunAndTraceUrl, availableNodesMetaData, fetchInspectVars, @@ -366,6 +369,7 @@ const SnippetMain = ({ handleStartWorkflowRun, handleStopRun, handleWorkflowStartRunInWorkflow, + handleExportDSL, getWorkflowRunAndTraceUrl, hasNodeInspectVars, hasSetInspectVar, diff --git a/web/service/__tests__/use-snippets.spec.tsx b/web/service/__tests__/use-snippets.spec.tsx new file mode 100644 index 00000000000..e254874af9b --- /dev/null +++ b/web/service/__tests__/use-snippets.spec.tsx @@ -0,0 +1,81 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { useExportSnippetMutation } from '../use-snippets' + +const mockExportSnippet = vi.hoisted(() => vi.fn()) + +vi.mock('@/service/client', () => ({ + consoleClient: { + workspaces: { + current: { + customizedSnippets: { + bySnippetId: { + export: { + get: mockExportSnippet, + }, + }, + }, + }, + }, + }, + consoleQuery: { + snippets: { + key: vi.fn(() => ['snippets']), + }, + workspaces: { + current: { + customizedSnippets: { + key: vi.fn(() => ['customized-snippets']), + bySnippetId: { + export: { + get: { + mutationKey: vi.fn(() => ['customized-snippets', 'export']), + }, + }, + }, + }, + }, + }, + }, +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }) + + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +describe('useExportSnippetMutation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExportSnippet.mockResolvedValue('kind: snippet') + }) + + it('exports the requested historical workflow version', async () => { + const { result } = renderHook(() => useExportSnippetMutation(), { wrapper: createWrapper() }) + + await act(async () => { + await result.current.mutateAsync({ + snippetId: 'snippet-1', + workflowId: 'workflow-1', + }) + }) + + expect(mockExportSnippet).toHaveBeenCalledWith({ + params: { snippet_id: 'snippet-1' }, + query: { + include_secret: 'false', + workflow_id: 'workflow-1', + }, + }) + }) +}) diff --git a/web/service/use-snippets.ts b/web/service/use-snippets.ts index d0ec5d2f18b..58a10fa22e0 100644 --- a/web/service/use-snippets.ts +++ b/web/service/use-snippets.ts @@ -272,11 +272,14 @@ export const useIncrementSnippetUseCountMutation = () => { } export const useExportSnippetMutation = () => { - return useMutation({ - mutationFn: ({ snippetId, include = false }) => { + return useMutation({ + mutationFn: ({ snippetId, include = false, workflowId }) => { return customizedSnippetsClient.bySnippetId.export.get({ params: { snippet_id: snippetId }, - query: { include_secret: include ? 'true' : 'false' }, + query: { + include_secret: include ? 'true' : 'false', + workflow_id: workflowId, + }, }) }, })