mirror of
https://github.com/langgenius/dify.git
synced 2026-08-29 03:45:08 +08:00
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:
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user