fix(snippet): support snippet history version export (#41012)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
FFXN
2026-08-20 08:52:45 +00:00
committed by GitHub
parent f97fc01c2f
commit 248df5dd25
14 changed files with 308 additions and 16 deletions
@@ -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")
+12 -5
View File
@@ -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"
+11 -3
View File
@@ -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, <br>**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, <br>**Default:** false | Whether to include secret variables | No |
| include_secret | string, <br>**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, <br>**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.
+13 -3
View File
@@ -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 = {
@@ -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):
@@ -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 = {
@@ -2699,6 +2699,7 @@ export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportData = {
}
query?: {
include_secret?: string
workflow_id?: string
}
url: '/workspaces/current/customized-snippets/{snippet_id}/export'
}
@@ -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(),
})
/**
@@ -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)
})
})
})
@@ -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()
})
})
@@ -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 }
}
@@ -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,
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
}
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',
},
})
})
})
+6 -3
View File
@@ -272,11 +272,14 @@ export const useIncrementSnippetUseCountMutation = () => {
}
export const useExportSnippetMutation = () => {
return useMutation<string, Error, { snippetId: string; include?: boolean }>({
mutationFn: ({ snippetId, include = false }) => {
return useMutation<string, Error, { snippetId: string; include?: boolean; workflowId?: string }>({
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,
},
})
},
})