From 91da784f84f544c840cd645addff2b988e4d648c Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:38:28 +0800 Subject: [PATCH] refactor: init orpc contract (#30885) Co-authored-by: yyh --- .../access-control.spec.tsx | 7 - .../chat/chat-with-history/hooks.spec.tsx | 8 +- .../plans/cloud-plan-item/index.spec.tsx | 18 ++- .../pricing/plans/cloud-plan-item/index.tsx | 5 +- .../hooks/use-marketplace-all-plugins.ts | 10 +- .../model-provider-page/hooks.ts | 10 +- .../components/plugins/marketplace/hooks.ts | 24 +-- .../plugins/marketplace/hydration-server.tsx | 4 +- .../plugins/marketplace/index.spec.tsx | 141 ++++++++++-------- .../components/plugins/marketplace/query.ts | 27 ++-- .../components/plugins/marketplace/state.ts | 4 +- .../components/plugins/marketplace/types.ts | 6 +- .../components/plugins/marketplace/utils.ts | 87 ++++------- web/app/install/installForm.spec.tsx | 1 - web/context/global-public-context.tsx | 4 +- web/contract/base.ts | 3 + web/contract/console.ts | 34 +++++ web/contract/marketplace.ts | 56 +++++++ web/contract/router.ts | 19 +++ web/hooks/use-document-title.spec.ts | 4 - web/package.json | 4 + web/pnpm-lock.yaml | 137 ++++++++++++++++- web/service/base.ts | 5 + web/service/billing.ts | 16 +- web/service/client.ts | 61 ++++++++ web/service/common.ts | 5 - web/service/fetch.ts | 10 +- web/service/use-billing.ts | 15 +- web/service/use-plugins.ts | 24 +-- 29 files changed, 520 insertions(+), 229 deletions(-) create mode 100644 web/contract/base.ts create mode 100644 web/contract/console.ts create mode 100644 web/contract/marketplace.ts create mode 100644 web/contract/router.ts create mode 100644 web/service/client.ts diff --git a/web/app/components/app/app-access-control/access-control.spec.tsx b/web/app/components/app/app-access-control/access-control.spec.tsx index 0624cb316b..b73ed5c266 100644 --- a/web/app/components/app/app-access-control/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/access-control.spec.tsx @@ -34,13 +34,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/service/common', () => ({ - fetchCurrentWorkspace: vi.fn(), - fetchLangGeniusVersion: vi.fn(), - fetchUserProfile: vi.fn(), - getSystemFeatures: vi.fn(), -})) - vi.mock('@/service/access-control', () => ({ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), diff --git a/web/app/components/base/chat/chat-with-history/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx index a6d51d8643..f6a8f25cbb 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx @@ -170,8 +170,12 @@ describe('useChatWithHistory', () => { await waitFor(() => { expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', false, 'app-1') }) - expect(result.current.pinnedConversationList).toEqual(pinnedData.data) - expect(result.current.conversationList).toEqual(listData.data) + await waitFor(() => { + expect(result.current.pinnedConversationList).toEqual(pinnedData.data) + }) + await waitFor(() => { + expect(result.current.conversationList).toEqual(listData.data) + }) }) }) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx index 4473ef98fa..680243a474 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx @@ -3,7 +3,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { useAppContext } from '@/context/app-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' -import { fetchBillingUrl, fetchSubscriptionUrls } from '@/service/billing' +import { fetchSubscriptionUrls } from '@/service/billing' +import { consoleClient } from '@/service/client' import Toast from '../../../../base/toast' import { ALL_PLANS } from '../../../config' import { Plan } from '../../../type' @@ -21,10 +22,15 @@ vi.mock('@/context/app-context', () => ({ })) vi.mock('@/service/billing', () => ({ - fetchBillingUrl: vi.fn(), fetchSubscriptionUrls: vi.fn(), })) +vi.mock('@/service/client', () => ({ + consoleClient: { + billingUrl: vi.fn(), + }, +})) + vi.mock('@/hooks/use-async-window-open', () => ({ useAsyncWindowOpen: vi.fn(), })) @@ -37,7 +43,7 @@ vi.mock('../../assets', () => ({ const mockUseAppContext = useAppContext as Mock const mockUseAsyncWindowOpen = useAsyncWindowOpen as Mock -const mockFetchBillingUrl = fetchBillingUrl as Mock +const mockBillingUrl = consoleClient.billingUrl as Mock const mockFetchSubscriptionUrls = fetchSubscriptionUrls as Mock const mockToastNotify = Toast.notify as Mock @@ -69,7 +75,7 @@ beforeEach(() => { vi.clearAllMocks() mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true }) mockUseAsyncWindowOpen.mockReturnValue(vi.fn(async open => await open())) - mockFetchBillingUrl.mockResolvedValue({ url: 'https://billing.example' }) + mockBillingUrl.mockResolvedValue({ url: 'https://billing.example' }) mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://subscription.example' }) assignedHref = '' }) @@ -143,7 +149,7 @@ describe('CloudPlanItem', () => { type: 'error', message: 'billing.buyPermissionDeniedTip', })) - expect(mockFetchBillingUrl).not.toHaveBeenCalled() + expect(mockBillingUrl).not.toHaveBeenCalled() }) it('should open billing portal when upgrading current paid plan', async () => { @@ -162,7 +168,7 @@ describe('CloudPlanItem', () => { fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' })) await waitFor(() => { - expect(mockFetchBillingUrl).toHaveBeenCalledTimes(1) + expect(mockBillingUrl).toHaveBeenCalledTimes(1) }) expect(openWindow).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx index b694dc57e2..d9c4d3f75b 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx @@ -6,7 +6,8 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useAppContext } from '@/context/app-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' -import { fetchBillingUrl, fetchSubscriptionUrls } from '@/service/billing' +import { fetchSubscriptionUrls } from '@/service/billing' +import { consoleClient } from '@/service/client' import Toast from '../../../../base/toast' import { ALL_PLANS } from '../../../config' import { Plan } from '../../../type' @@ -76,7 +77,7 @@ const CloudPlanItem: FC = ({ try { if (isCurrentPaidPlan) { await openAsyncWindow(async () => { - const res = await fetchBillingUrl() + const res = await consoleClient.billingUrl() if (res.url) return res.url throw new Error('Failed to open billing page') diff --git a/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts b/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts index 0c2154210c..90ef6e78a4 100644 --- a/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts +++ b/web/app/components/header/account-setting/data-source-page-new/hooks/use-marketplace-all-plugins.ts @@ -30,8 +30,8 @@ export const useMarketplaceAllPlugins = (providers: any[], searchText: string) = category: PluginCategoryEnum.datasource, exclude, type: 'plugin', - sortBy: 'install_count', - sortOrder: 'DESC', + sort_by: 'install_count', + sort_order: 'DESC', }) } else { @@ -39,10 +39,10 @@ export const useMarketplaceAllPlugins = (providers: any[], searchText: string) = query: '', category: PluginCategoryEnum.datasource, type: 'plugin', - pageSize: 1000, + page_size: 1000, exclude, - sortBy: 'install_count', - sortOrder: 'DESC', + sort_by: 'install_count', + sort_order: 'DESC', }) } }, [queryPlugins, queryPluginsWithDebounced, searchText, exclude]) diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.ts b/web/app/components/header/account-setting/model-provider-page/hooks.ts index 0e35f0fb31..6aba41d4e4 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.ts @@ -275,8 +275,8 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText: category: PluginCategoryEnum.model, exclude, type: 'plugin', - sortBy: 'install_count', - sortOrder: 'DESC', + sort_by: 'install_count', + sort_order: 'DESC', }) } else { @@ -284,10 +284,10 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText: query: '', category: PluginCategoryEnum.model, type: 'plugin', - pageSize: 1000, + page_size: 1000, exclude, - sortBy: 'install_count', - sortOrder: 'DESC', + sort_by: 'install_count', + sort_order: 'DESC', }) } }, [queryPlugins, queryPluginsWithDebounced, searchText, exclude]) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index b1e4f50767..60ba0e0bee 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -100,11 +100,11 @@ export const useMarketplacePlugins = () => { const [queryParams, setQueryParams] = useState() const normalizeParams = useCallback((pluginsSearchParams: PluginsSearchParams) => { - const pageSize = pluginsSearchParams.pageSize || 40 + const page_size = pluginsSearchParams.page_size || 40 return { ...pluginsSearchParams, - pageSize, + page_size, } }, []) @@ -116,20 +116,20 @@ export const useMarketplacePlugins = () => { plugins: [] as Plugin[], total: 0, page: 1, - pageSize: 40, + page_size: 40, } } const params = normalizeParams(queryParams) const { query, - sortBy, - sortOrder, + sort_by, + sort_order, category, tags, exclude, type, - pageSize, + page_size, } = params const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' @@ -137,10 +137,10 @@ export const useMarketplacePlugins = () => { const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, { body: { page: pageParam, - page_size: pageSize, + page_size, query, - sort_by: sortBy, - sort_order: sortOrder, + sort_by, + sort_order, category: category !== 'all' ? category : '', tags, exclude, @@ -154,7 +154,7 @@ export const useMarketplacePlugins = () => { plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)), total: res.data.total, page: pageParam, - pageSize, + page_size, } } catch { @@ -162,13 +162,13 @@ export const useMarketplacePlugins = () => { plugins: [], total: 0, page: pageParam, - pageSize, + page_size, } } }, getNextPageParam: (lastPage) => { const nextPage = lastPage.page + 1 - const loaded = lastPage.page * lastPage.pageSize + const loaded = lastPage.page * lastPage.page_size return loaded < (lastPage.total || 0) ? nextPage : undefined }, initialPageParam: 1, diff --git a/web/app/components/plugins/marketplace/hydration-server.tsx b/web/app/components/plugins/marketplace/hydration-server.tsx index 0aa544cff1..b01f4dd463 100644 --- a/web/app/components/plugins/marketplace/hydration-server.tsx +++ b/web/app/components/plugins/marketplace/hydration-server.tsx @@ -2,8 +2,8 @@ import type { SearchParams } from 'nuqs' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { createLoader } from 'nuqs/server' import { getQueryClientServer } from '@/context/query-client-server' +import { marketplaceQuery } from '@/service/client' import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' -import { marketplaceKeys } from './query' import { marketplaceSearchParamsParsers } from './search-params' import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' @@ -23,7 +23,7 @@ async function getDehydratedState(searchParams?: Promise) { const queryClient = getQueryClientServer() await queryClient.prefetchQuery({ - queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)), + queryKey: marketplaceQuery.collections.queryKey({ input: { query: getCollectionsParams(params.category) } }), queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), }) return dehydrate(queryClient) diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx index 1a3cd15b6b..dc2513ac05 100644 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ b/web/app/components/plugins/marketplace/index.spec.tsx @@ -60,10 +60,10 @@ vi.mock('@/service/use-plugins', () => ({ // Mock tanstack query const mockFetchNextPage = vi.fn() const mockHasNextPage = false -let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, pageSize: number }> } | undefined +let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, page_size: number }> } | undefined let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise) | null = null let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise) | null = null -let capturedGetNextPageParam: ((lastPage: { page: number, pageSize: number, total: number }) => number | undefined) | null = null +let capturedGetNextPageParam: ((lastPage: { page: number, page_size: number, total: number }) => number | undefined) | null = null vi.mock('@tanstack/react-query', () => ({ useQuery: vi.fn(({ queryFn, enabled }: { queryFn: (ctx: { signal: AbortSignal }) => Promise, enabled: boolean }) => { @@ -83,7 +83,7 @@ vi.mock('@tanstack/react-query', () => ({ }), useInfiniteQuery: vi.fn(({ queryFn, getNextPageParam, enabled: _enabled }: { queryFn: (ctx: { pageParam: number, signal: AbortSignal }) => Promise - getNextPageParam: (lastPage: { page: number, pageSize: number, total: number }) => number | undefined + getNextPageParam: (lastPage: { page: number, page_size: number, total: number }) => number | undefined enabled: boolean }) => { // Capture queryFn and getNextPageParam for later testing @@ -97,9 +97,9 @@ vi.mock('@tanstack/react-query', () => ({ // Call getNextPageParam to increase coverage if (getNextPageParam) { // Test with more data available - getNextPageParam({ page: 1, pageSize: 40, total: 100 }) + getNextPageParam({ page: 1, page_size: 40, total: 100 }) // Test with no more data - getNextPageParam({ page: 3, pageSize: 40, total: 100 }) + getNextPageParam({ page: 3, page_size: 40, total: 100 }) } return { data: mockInfiniteQueryData, @@ -151,6 +151,7 @@ vi.mock('@/service/base', () => ({ // Mock config vi.mock('@/config', () => ({ + API_PREFIX: '/api', APP_VERSION: '1.0.0', IS_MARKETPLACE: false, MARKETPLACE_API_PREFIX: 'https://marketplace.dify.ai/api/v1', @@ -731,10 +732,10 @@ describe('useMarketplacePlugins', () => { expect(() => { result.current.queryPlugins({ query: 'test', - sortBy: 'install_count', - sortOrder: 'DESC', + sort_by: 'install_count', + sort_order: 'DESC', category: 'tool', - pageSize: 20, + page_size: 20, }) }).not.toThrow() }) @@ -747,7 +748,7 @@ describe('useMarketplacePlugins', () => { result.current.queryPlugins({ query: 'test', type: 'bundle', - pageSize: 40, + page_size: 40, }) }).not.toThrow() }) @@ -798,8 +799,8 @@ describe('useMarketplacePlugins', () => { result.current.queryPlugins({ query: 'test', category: 'all', - sortBy: 'install_count', - sortOrder: 'DESC', + sort_by: 'install_count', + sort_order: 'DESC', }) }).not.toThrow() }) @@ -824,7 +825,7 @@ describe('useMarketplacePlugins', () => { expect(() => { result.current.queryPlugins({ query: 'test', - pageSize: 100, + page_size: 100, }) }).not.toThrow() }) @@ -843,7 +844,7 @@ describe('Hooks queryFn Coverage', () => { // Set mock data to have pages mockInfiniteQueryData = { pages: [ - { plugins: [{ name: 'plugin1' }], total: 10, page: 1, pageSize: 40 }, + { plugins: [{ name: 'plugin1' }], total: 10, page: 1, page_size: 40 }, ], } @@ -863,8 +864,8 @@ describe('Hooks queryFn Coverage', () => { it('should expose page and total from infinite query data', async () => { mockInfiniteQueryData = { pages: [ - { plugins: [{ name: 'plugin1' }, { name: 'plugin2' }], total: 20, page: 1, pageSize: 40 }, - { plugins: [{ name: 'plugin3' }], total: 20, page: 2, pageSize: 40 }, + { plugins: [{ name: 'plugin1' }, { name: 'plugin2' }], total: 20, page: 1, page_size: 40 }, + { plugins: [{ name: 'plugin3' }], total: 20, page: 2, page_size: 40 }, ], } @@ -893,7 +894,7 @@ describe('Hooks queryFn Coverage', () => { it('should return total from first page when query is set and data exists', async () => { mockInfiniteQueryData = { pages: [ - { plugins: [], total: 50, page: 1, pageSize: 40 }, + { plugins: [], total: 50, page: 1, page_size: 40 }, ], } @@ -917,8 +918,8 @@ describe('Hooks queryFn Coverage', () => { type: 'plugin', query: 'search test', category: 'model', - sortBy: 'version_updated_at', - sortOrder: 'ASC', + sort_by: 'version_updated_at', + sort_order: 'ASC', }) expect(result.current).toBeDefined() @@ -1027,13 +1028,13 @@ describe('Advanced Hook Integration', () => { // Test with all possible parameters result.current.queryPlugins({ query: 'comprehensive test', - sortBy: 'install_count', - sortOrder: 'DESC', + sort_by: 'install_count', + sort_order: 'DESC', category: 'tool', tags: ['tag1', 'tag2'], exclude: ['excluded-plugin'], type: 'plugin', - pageSize: 50, + page_size: 50, }) expect(result.current).toBeDefined() @@ -1081,9 +1082,9 @@ describe('Direct queryFn Coverage', () => { result.current.queryPlugins({ query: 'direct test', category: 'tool', - sortBy: 'install_count', - sortOrder: 'DESC', - pageSize: 40, + sort_by: 'install_count', + sort_order: 'DESC', + page_size: 40, }) // Now queryFn should be captured and enabled @@ -1255,7 +1256,7 @@ describe('Direct queryFn Coverage', () => { result.current.queryPlugins({ query: 'structure test', - pageSize: 20, + page_size: 20, }) if (capturedInfiniteQueryFn) { @@ -1264,14 +1265,14 @@ describe('Direct queryFn Coverage', () => { plugins: unknown[] total: number page: number - pageSize: number + page_size: number } // Verify the returned structure expect(response).toHaveProperty('plugins') expect(response).toHaveProperty('total') expect(response).toHaveProperty('page') - expect(response).toHaveProperty('pageSize') + expect(response).toHaveProperty('page_size') } }) }) @@ -1296,7 +1297,7 @@ describe('flatMap Coverage', () => { ], total: 5, page: 1, - pageSize: 40, + page_size: 40, }, { plugins: [ @@ -1304,7 +1305,7 @@ describe('flatMap Coverage', () => { ], total: 5, page: 2, - pageSize: 40, + page_size: 40, }, ], } @@ -1336,8 +1337,8 @@ describe('flatMap Coverage', () => { it('should test hook with pages data for flatMap path', async () => { mockInfiniteQueryData = { pages: [ - { plugins: [], total: 100, page: 1, pageSize: 40 }, - { plugins: [], total: 100, page: 2, pageSize: 40 }, + { plugins: [], total: 100, page: 1, page_size: 40 }, + { plugins: [], total: 100, page: 2, page_size: 40 }, ], } @@ -1371,7 +1372,7 @@ describe('flatMap Coverage', () => { plugins: unknown[] total: number page: number - pageSize: number + page_size: number } // When error is caught, should return fallback data expect(response.plugins).toEqual([]) @@ -1392,15 +1393,15 @@ describe('flatMap Coverage', () => { // Test getNextPageParam function directly if (capturedGetNextPageParam) { // When there are more pages - const nextPage = capturedGetNextPageParam({ page: 1, pageSize: 40, total: 100 }) + const nextPage = capturedGetNextPageParam({ page: 1, page_size: 40, total: 100 }) expect(nextPage).toBe(2) // When all data is loaded - const noMorePages = capturedGetNextPageParam({ page: 3, pageSize: 40, total: 100 }) + const noMorePages = capturedGetNextPageParam({ page: 3, page_size: 40, total: 100 }) expect(noMorePages).toBeUndefined() // Edge case: exactly at boundary - const atBoundary = capturedGetNextPageParam({ page: 2, pageSize: 50, total: 100 }) + const atBoundary = capturedGetNextPageParam({ page: 2, page_size: 50, total: 100 }) expect(atBoundary).toBeUndefined() } }) @@ -1427,7 +1428,7 @@ describe('flatMap Coverage', () => { plugins: unknown[] total: number page: number - pageSize: number + page_size: number } // Catch block should return fallback values expect(response.plugins).toEqual([]) @@ -1446,7 +1447,7 @@ describe('flatMap Coverage', () => { plugins: [{ name: 'test-plugin-1' }, { name: 'test-plugin-2' }], total: 10, page: 1, - pageSize: 40, + page_size: 40, }, ], } @@ -1489,9 +1490,12 @@ describe('Async Utils', () => { { type: 'plugin', org: 'test', name: 'plugin2' }, ] - globalThis.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve({ data: { plugins: mockPlugins } }), - }) + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { plugins: mockPlugins } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) const { getMarketplacePluginsByCollectionId } = await import('./utils') const result = await getMarketplacePluginsByCollectionId('test-collection', { @@ -1514,19 +1518,26 @@ describe('Async Utils', () => { }) it('should pass abort signal when provided', async () => { - const mockPlugins = [{ type: 'plugin', org: 'test', name: 'plugin1' }] - globalThis.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve({ data: { plugins: mockPlugins } }), - }) + const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1' }] + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { plugins: mockPlugins } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) const controller = new AbortController() const { getMarketplacePluginsByCollectionId } = await import('./utils') await getMarketplacePluginsByCollectionId('test-collection', {}, { signal: controller.signal }) + // oRPC uses Request objects, so check that fetch was called with a Request containing the right URL expect(globalThis.fetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ signal: controller.signal }), + expect.any(Request), + expect.any(Object), ) + const call = vi.mocked(globalThis.fetch).mock.calls[0] + const request = call[0] as Request + expect(request.url).toContain('test-collection') }) }) @@ -1535,19 +1546,25 @@ describe('Async Utils', () => { const mockCollections = [ { name: 'collection1', label: {}, description: {}, rule: '', created_at: '', updated_at: '' }, ] - const mockPlugins = [{ type: 'plugin', org: 'test', name: 'plugin1' }] + const mockPlugins = [{ type: 'plugins', org: 'test', name: 'plugin1' }] let callCount = 0 globalThis.fetch = vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) { - return Promise.resolve({ - json: () => Promise.resolve({ data: { collections: mockCollections } }), - }) + return Promise.resolve( + new Response(JSON.stringify({ data: { collections: mockCollections } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) } - return Promise.resolve({ - json: () => Promise.resolve({ data: { plugins: mockPlugins } }), - }) + return Promise.resolve( + new Response(JSON.stringify({ data: { plugins: mockPlugins } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) }) const { getMarketplaceCollectionsAndPlugins } = await import('./utils') @@ -1571,9 +1588,12 @@ describe('Async Utils', () => { }) it('should append condition and type to URL when provided', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve({ data: { collections: [] } }), - }) + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { collections: [] } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) const { getMarketplaceCollectionsAndPlugins } = await import('./utils') await getMarketplaceCollectionsAndPlugins({ @@ -1581,10 +1601,11 @@ describe('Async Utils', () => { type: 'bundle', }) - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining('condition=category=tool'), - expect.any(Object), - ) + // oRPC uses Request objects, so check that fetch was called with a Request containing the right URL + expect(globalThis.fetch).toHaveBeenCalled() + const call = vi.mocked(globalThis.fetch).mock.calls[0] + const request = call[0] as Request + expect(request.url).toContain('condition=category%3Dtool') }) }) }) diff --git a/web/app/components/plugins/marketplace/query.ts b/web/app/components/plugins/marketplace/query.ts index c5a1421146..35d99a2bd5 100644 --- a/web/app/components/plugins/marketplace/query.ts +++ b/web/app/components/plugins/marketplace/query.ts @@ -1,22 +1,14 @@ -import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './types' +import type { PluginsSearchParams } from './types' +import type { MarketPlaceInputs } from '@/contract/router' import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { marketplaceQuery } from '@/service/client' import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils' -// TODO: Avoid manual maintenance of query keys and better service management, -// https://github.com/langgenius/dify/issues/30342 - -export const marketplaceKeys = { - all: ['marketplace'] as const, - collections: (params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collections', params] as const, - collectionPlugins: (collectionId: string, params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collectionPlugins', collectionId, params] as const, - plugins: (params?: PluginsSearchParams) => [...marketplaceKeys.all, 'plugins', params] as const, -} - export function useMarketplaceCollectionsAndPlugins( - collectionsParams: CollectionsAndPluginsSearchParams, + collectionsParams: MarketPlaceInputs['collections']['query'], ) { return useQuery({ - queryKey: marketplaceKeys.collections(collectionsParams), + queryKey: marketplaceQuery.collections.queryKey({ input: { query: collectionsParams } }), queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }), }) } @@ -25,11 +17,16 @@ export function useMarketplacePlugins( queryParams: PluginsSearchParams | undefined, ) { return useInfiniteQuery({ - queryKey: marketplaceKeys.plugins(queryParams), + queryKey: marketplaceQuery.searchAdvanced.queryKey({ + input: { + body: queryParams!, + params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' }, + }, + }), queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal), getNextPageParam: (lastPage) => { const nextPage = lastPage.page + 1 - const loaded = lastPage.page * lastPage.pageSize + const loaded = lastPage.page * lastPage.page_size return loaded < (lastPage.total || 0) ? nextPage : undefined }, initialPageParam: 1, diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index 1c1abfc0a1..9c76a21e92 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -26,8 +26,8 @@ export function useMarketplaceData() { query: searchPluginText, category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, tags: filterPluginTags, - sortBy: sort.sortBy, - sortOrder: sort.sortOrder, + sort_by: sort.sortBy, + sort_order: sort.sortOrder, type: getMarketplaceListFilterType(activePluginType), } }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) diff --git a/web/app/components/plugins/marketplace/types.ts b/web/app/components/plugins/marketplace/types.ts index 4145f69248..e4e2dbd935 100644 --- a/web/app/components/plugins/marketplace/types.ts +++ b/web/app/components/plugins/marketplace/types.ts @@ -30,9 +30,9 @@ export type MarketplaceCollectionPluginsResponse = { export type PluginsSearchParams = { query: string page?: number - pageSize?: number - sortBy?: string - sortOrder?: string + page_size?: number + sort_by?: string + sort_order?: string category?: string tags?: string[] exclude?: string[] diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index eaf299314c..01f3c59284 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -4,14 +4,12 @@ import type { MarketplaceCollection, PluginsSearchParams, } from '@/app/components/plugins/marketplace/types' -import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types' +import type { Plugin } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { - APP_VERSION, - IS_MARKETPLACE, MARKETPLACE_API_PREFIX, } from '@/config' -import { postMarketplace } from '@/service/base' +import { marketplaceClient } from '@/service/client' import { getMarketplaceUrl } from '@/utils/var' import { PLUGIN_TYPE_SEARCH_MAP } from './constants' @@ -19,10 +17,6 @@ type MarketplaceFetchOptions = { signal?: AbortSignal } -const getMarketplaceHeaders = () => new Headers({ - 'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0', -}) - export const getPluginIconInMarketplace = (plugin: Plugin) => { if (plugin.type === 'bundle') return `${MARKETPLACE_API_PREFIX}/bundles/${plugin.org}/${plugin.name}/icon` @@ -65,24 +59,15 @@ export const getMarketplacePluginsByCollectionId = async ( let plugins: Plugin[] = [] try { - const url = `${MARKETPLACE_API_PREFIX}/collections/${collectionId}/plugins` - const headers = getMarketplaceHeaders() - const marketplaceCollectionPluginsData = await globalThis.fetch( - url, - { - cache: 'no-store', - method: 'POST', - headers, - signal: options?.signal, - body: JSON.stringify({ - category: query?.category, - exclude: query?.exclude, - type: query?.type, - }), + const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins({ + params: { + collectionId, }, - ) - const marketplaceCollectionPluginsDataJson = await marketplaceCollectionPluginsData.json() - plugins = (marketplaceCollectionPluginsDataJson.data.plugins || []).map((plugin: Plugin) => getFormattedPlugin(plugin)) + body: query, + }, { + signal: options?.signal, + }) + plugins = (marketplaceCollectionPluginsDataJson.data?.plugins || []).map(plugin => getFormattedPlugin(plugin)) } // eslint-disable-next-line unused-imports/no-unused-vars catch (e) { @@ -99,22 +84,16 @@ export const getMarketplaceCollectionsAndPlugins = async ( let marketplaceCollections: MarketplaceCollection[] = [] let marketplaceCollectionPluginsMap: Record = {} try { - let marketplaceUrl = `${MARKETPLACE_API_PREFIX}/collections?page=1&page_size=100` - if (query?.condition) - marketplaceUrl += `&condition=${query.condition}` - if (query?.type) - marketplaceUrl += `&type=${query.type}` - const headers = getMarketplaceHeaders() - const marketplaceCollectionsData = await globalThis.fetch( - marketplaceUrl, - { - headers, - cache: 'no-store', - signal: options?.signal, + const marketplaceCollectionsDataJson = await marketplaceClient.collections({ + query: { + ...query, + page: 1, + page_size: 100, }, - ) - const marketplaceCollectionsDataJson = await marketplaceCollectionsData.json() - marketplaceCollections = marketplaceCollectionsDataJson.data.collections || [] + }, { + signal: options?.signal, + }) + marketplaceCollections = marketplaceCollectionsDataJson.data?.collections || [] await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => { const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options) @@ -143,42 +122,42 @@ export const getMarketplacePlugins = async ( plugins: [] as Plugin[], total: 0, page: 1, - pageSize: 40, + page_size: 40, } } const { query, - sortBy, - sortOrder, + sort_by, + sort_order, category, tags, type, - pageSize = 40, + page_size = 40, } = queryParams - const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' try { - const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, { + const res = await marketplaceClient.searchAdvanced({ + params: { + kind: type === 'bundle' ? 'bundles' : 'plugins', + }, body: { page: pageParam, - page_size: pageSize, + page_size, query, - sort_by: sortBy, - sort_order: sortOrder, + sort_by, + sort_order, category: category !== 'all' ? category : '', tags, - type, }, - signal, - }) + }, { signal }) const resPlugins = res.data.bundles || res.data.plugins || [] return { plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)), total: res.data.total, page: pageParam, - pageSize, + page_size, } } catch { @@ -186,7 +165,7 @@ export const getMarketplacePlugins = async ( plugins: [], total: 0, page: pageParam, - pageSize, + page_size, } } } diff --git a/web/app/install/installForm.spec.tsx b/web/app/install/installForm.spec.tsx index 5efd5cebb6..17ce35d6a1 100644 --- a/web/app/install/installForm.spec.tsx +++ b/web/app/install/installForm.spec.tsx @@ -16,7 +16,6 @@ vi.mock('@/service/common', () => ({ fetchInitValidateStatus: vi.fn(), setup: vi.fn(), login: vi.fn(), - getSystemFeatures: vi.fn(), })) vi.mock('@/context/global-public-context', async (importOriginal) => { diff --git a/web/context/global-public-context.tsx b/web/context/global-public-context.tsx index 9b2b0834e2..3a570fc7ef 100644 --- a/web/context/global-public-context.tsx +++ b/web/context/global-public-context.tsx @@ -4,7 +4,7 @@ import type { SystemFeatures } from '@/types/feature' import { useQuery } from '@tanstack/react-query' import { create } from 'zustand' import Loading from '@/app/components/base/loading' -import { getSystemFeatures } from '@/service/common' +import { consoleClient } from '@/service/client' import { defaultSystemFeatures } from '@/types/feature' import { fetchSetupStatusWithCache } from '@/utils/setup-status' @@ -22,7 +22,7 @@ const systemFeaturesQueryKey = ['systemFeatures'] as const const setupStatusQueryKey = ['setupStatus'] as const async function fetchSystemFeatures() { - const data = await getSystemFeatures() + const data = await consoleClient.systemFeatures() const { setSystemFeatures } = useGlobalPublicStore.getState() setSystemFeatures({ ...defaultSystemFeatures, ...data }) return data diff --git a/web/contract/base.ts b/web/contract/base.ts new file mode 100644 index 0000000000..764db9d554 --- /dev/null +++ b/web/contract/base.ts @@ -0,0 +1,3 @@ +import { oc } from '@orpc/contract' + +export const base = oc.$route({ inputStructure: 'detailed' }) diff --git a/web/contract/console.ts b/web/contract/console.ts new file mode 100644 index 0000000000..ec929d1357 --- /dev/null +++ b/web/contract/console.ts @@ -0,0 +1,34 @@ +import type { SystemFeatures } from '@/types/feature' +import { type } from '@orpc/contract' +import { base } from './base' + +export const systemFeaturesContract = base + .route({ + path: '/system-features', + method: 'GET', + }) + .input(type()) + .output(type()) + +export const billingUrlContract = base + .route({ + path: '/billing/invoices', + method: 'GET', + }) + .input(type()) + .output(type<{ url: string }>()) + +export const bindPartnerStackContract = base + .route({ + path: '/billing/partners/{partnerKey}/tenants', + method: 'PUT', + }) + .input(type<{ + params: { + partnerKey: string + } + body: { + click_id: string + } + }>()) + .output(type()) diff --git a/web/contract/marketplace.ts b/web/contract/marketplace.ts new file mode 100644 index 0000000000..3573ba5c24 --- /dev/null +++ b/web/contract/marketplace.ts @@ -0,0 +1,56 @@ +import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, PluginsSearchParams } from '@/app/components/plugins/marketplace/types' +import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types' +import { type } from '@orpc/contract' +import { base } from './base' + +export const collectionsContract = base + .route({ + path: '/collections', + method: 'GET', + }) + .input( + type<{ + query?: CollectionsAndPluginsSearchParams & { page?: number, page_size?: number } + }>(), + ) + .output( + type<{ + data?: { + collections?: MarketplaceCollection[] + } + }>(), + ) + +export const collectionPluginsContract = base + .route({ + path: '/collections/{collectionId}/plugins', + method: 'POST', + }) + .input( + type<{ + params: { + collectionId: string + } + body?: CollectionsAndPluginsSearchParams + }>(), + ) + .output( + type<{ + data?: { + plugins?: Plugin[] + } + }>(), + ) + +export const searchAdvancedContract = base + .route({ + path: '/{kind}/search/advanced', + method: 'POST', + }) + .input(type<{ + params: { + kind: 'plugins' | 'bundles' + } + body: Omit + }>()) + .output(type<{ data: PluginsFromMarketplaceResponse }>()) diff --git a/web/contract/router.ts b/web/contract/router.ts new file mode 100644 index 0000000000..d83cffb7b8 --- /dev/null +++ b/web/contract/router.ts @@ -0,0 +1,19 @@ +import type { InferContractRouterInputs } from '@orpc/contract' +import { billingUrlContract, bindPartnerStackContract, systemFeaturesContract } from './console' +import { collectionPluginsContract, collectionsContract, searchAdvancedContract } from './marketplace' + +export const marketplaceRouterContract = { + collections: collectionsContract, + collectionPlugins: collectionPluginsContract, + searchAdvanced: searchAdvancedContract, +} + +export type MarketPlaceInputs = InferContractRouterInputs + +export const consoleRouterContract = { + systemFeatures: systemFeaturesContract, + billingUrl: billingUrlContract, + bindPartnerStack: bindPartnerStackContract, +} + +export type ConsoleInputs = InferContractRouterInputs diff --git a/web/hooks/use-document-title.spec.ts b/web/hooks/use-document-title.spec.ts index efa72cac5c..7ce1e693db 100644 --- a/web/hooks/use-document-title.spec.ts +++ b/web/hooks/use-document-title.spec.ts @@ -23,10 +23,6 @@ vi.mock('@/context/global-public-context', async (importOriginal) => { } }) -vi.mock('@/service/common', () => ({ - getSystemFeatures: vi.fn(() => ({ ...defaultSystemFeatures })), -})) - /** * Test behavior when system features are still loading * Title should remain empty to prevent flicker diff --git a/web/package.json b/web/package.json index 44cc9196f4..fab33f7608 100644 --- a/web/package.json +++ b/web/package.json @@ -69,6 +69,10 @@ "@monaco-editor/react": "^4.7.0", "@octokit/core": "^6.1.6", "@octokit/request-error": "^6.1.8", + "@orpc/client": "^1.13.4", + "@orpc/contract": "^1.13.4", + "@orpc/openapi-client": "^1.13.4", + "@orpc/tanstack-query": "^1.13.4", "@remixicon/react": "^4.7.0", "@sentry/react": "^8.55.0", "@svgdotjs/svg.js": "^3.2.5", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 853c366025..c8797e3d65 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -108,6 +108,18 @@ importers: '@octokit/request-error': specifier: ^6.1.8 version: 6.1.8 + '@orpc/client': + specifier: ^1.13.4 + version: 1.13.4 + '@orpc/contract': + specifier: ^1.13.4 + version: 1.13.4 + '@orpc/openapi-client': + specifier: ^1.13.4 + version: 1.13.4 + '@orpc/tanstack-query': + specifier: ^1.13.4 + version: 1.13.4(@orpc/client@1.13.4)(@tanstack/query-core@5.90.12) '@remixicon/react': specifier: ^4.7.0 version: 4.7.0(react@19.2.3) @@ -2291,6 +2303,38 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@orpc/client@1.13.4': + resolution: {integrity: sha512-s13GPMeoooJc5Th2EaYT5HMFtWG8S03DUVytYfJv8pIhP87RYKl94w52A36denH6r/B4LaAgBeC9nTAOslK+Og==} + + '@orpc/contract@1.13.4': + resolution: {integrity: sha512-TIxyaF67uOlihCRcasjHZxguZpbqfNK7aMrDLnhoufmQBE4OKvguNzmrOFHgsuM0OXoopX0Nuhun1ccaxKP10A==} + + '@orpc/openapi-client@1.13.4': + resolution: {integrity: sha512-tRUcY4E6sgpS5bY/9nNES/Q/PMyYyPOsI4TuhwLhfgxOb0GFPwYKJ6Kif7KFNOhx4fkN/jTOfE1nuWuIZU1gyg==} + + '@orpc/shared@1.13.4': + resolution: {integrity: sha512-TYt9rLG/BUkNQBeQ6C1tEiHS/Seb8OojHgj9GlvqyjHJhMZx5qjsIyTW6RqLPZJ4U2vgK6x4Her36+tlFCKJug==} + peerDependencies: + '@opentelemetry/api': '>=1.9.0' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@orpc/standard-server-fetch@1.13.4': + resolution: {integrity: sha512-/zmKwnuxfAXbppJpgr1CMnQX3ptPlYcDzLz1TaVzz9VG/Xg58Ov3YhabS2Oi1utLVhy5t4kaCppUducAvoKN+A==} + + '@orpc/standard-server-peer@1.13.4': + resolution: {integrity: sha512-UfqnTLqevjCKUk4cmImOG8cQUwANpV1dp9e9u2O1ki6BRBsg/zlXFg6G2N6wP0zr9ayIiO1d2qJdH55yl/1BNw==} + + '@orpc/standard-server@1.13.4': + resolution: {integrity: sha512-ZOzgfVp6XUg+wVYw+gqesfRfGPtQbnBIrIiSnFMtZF+6ncmFJeF2Shc4RI2Guqc0Qz25juy8Ogo4tX3YqysOcg==} + + '@orpc/tanstack-query@1.13.4': + resolution: {integrity: sha512-gCL/kh3kf6OUGKfXxSoOZpcX1jNYzxGfo/PkLQKX7ui4xiTbfWw3sCDF30sNS4I7yAOnBwDwJ3N2xzfkTftOBg==} + peerDependencies: + '@orpc/client': 1.13.4 + '@tanstack/query-core': '>=5.80.2' + '@oxc-resolver/binding-android-arm-eabi@11.15.0': resolution: {integrity: sha512-Q+lWuFfq7whNelNJIP1dhXaVz4zO9Tu77GcQHyxDWh3MaCoO2Bisphgzmsh4ZoUe2zIchQh6OvQL99GlWHg9Tw==} cpu: [arm] @@ -6685,6 +6729,9 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + opener@1.5.2: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true @@ -7081,6 +7128,10 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + radash@12.1.1: + resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==} + engines: {node: '>=14.18.0'} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -7826,6 +7877,10 @@ packages: tabbable@6.3.0: resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} @@ -8027,13 +8082,17 @@ packages: resolution: {integrity: sha512-5zknd7Dss75pMSED270A1RQS3KloqRJA9XbXLe0eCxyw7xXFb3rd+9B0UQ/0E+LQT6lnrLviEolYORlRWamn4w==} engines: {node: '>=16'} + type-fest@5.4.0: + resolution: {integrity: sha512-wfkA6r0tBpVfGiyO+zbf9e10QkRQSlK9F2UvyfnjoCmrvH2bjHyhPzhugSBOuq1dog3P0+FKckqe+Xf6WKVjwg==} + engines: {node: '>=20'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true - ufo@1.6.1: - resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + ufo@1.6.2: + resolution: {integrity: sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==} uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} @@ -10638,6 +10697,66 @@ snapshots: '@open-draft/until@2.1.0': {} + '@orpc/client@1.13.4': + dependencies: + '@orpc/shared': 1.13.4 + '@orpc/standard-server': 1.13.4 + '@orpc/standard-server-fetch': 1.13.4 + '@orpc/standard-server-peer': 1.13.4 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/contract@1.13.4': + dependencies: + '@orpc/client': 1.13.4 + '@orpc/shared': 1.13.4 + '@standard-schema/spec': 1.1.0 + openapi-types: 12.1.3 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/openapi-client@1.13.4': + dependencies: + '@orpc/client': 1.13.4 + '@orpc/contract': 1.13.4 + '@orpc/shared': 1.13.4 + '@orpc/standard-server': 1.13.4 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/shared@1.13.4': + dependencies: + radash: 12.1.1 + type-fest: 5.4.0 + + '@orpc/standard-server-fetch@1.13.4': + dependencies: + '@orpc/shared': 1.13.4 + '@orpc/standard-server': 1.13.4 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server-peer@1.13.4': + dependencies: + '@orpc/shared': 1.13.4 + '@orpc/standard-server': 1.13.4 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server@1.13.4': + dependencies: + '@orpc/shared': 1.13.4 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/tanstack-query@1.13.4(@orpc/client@1.13.4)(@tanstack/query-core@5.90.12)': + dependencies: + '@orpc/client': 1.13.4 + '@orpc/shared': 1.13.4 + '@tanstack/query-core': 5.90.12 + transitivePeerDependencies: + - '@opentelemetry/api' + '@oxc-resolver/binding-android-arm-eabi@11.15.0': optional: true @@ -15603,7 +15722,7 @@ snapshots: acorn: 8.15.0 pathe: 2.0.3 pkg-types: 1.3.1 - ufo: 1.6.1 + ufo: 1.6.2 monaco-editor@0.55.1: dependencies: @@ -15766,6 +15885,8 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openapi-types@12.1.3: {} + opener@1.5.2: {} optionator@0.9.4: @@ -16181,6 +16302,8 @@ snapshots: queue-microtask@1.2.3: {} + radash@12.1.1: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -17098,6 +17221,8 @@ snapshots: tabbable@6.3.0: {} + tagged-tag@1.0.0: {} + tailwind-merge@2.6.0: {} tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2): @@ -17305,9 +17430,13 @@ snapshots: type-fest@4.2.0: optional: true + type-fest@5.4.0: + dependencies: + tagged-tag: 1.0.0 + typescript@5.9.3: {} - ufo@1.6.1: {} + ufo@1.6.2: {} uglify-js@3.19.3: {} diff --git a/web/service/base.ts b/web/service/base.ts index 2ab115f96c..fb32ce6bcf 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -81,6 +81,11 @@ export type IOtherOptions = { needAllResponseContent?: boolean deleteContentType?: boolean silent?: boolean + + /** If true, behaves like standard fetch: no URL prefix, returns raw Response */ + fetchCompat?: boolean + request?: Request + onData?: IOnData // for stream onThought?: IOnThought onFile?: IOnFile diff --git a/web/service/billing.ts b/web/service/billing.ts index f06c4f06c6..075ab71ade 100644 --- a/web/service/billing.ts +++ b/web/service/billing.ts @@ -1,5 +1,5 @@ import type { CurrentPlanInfoBackend, SubscriptionUrlsBackend } from '@/app/components/billing/type' -import { get, put } from './base' +import { get } from './base' export const fetchCurrentPlanInfo = () => { return get('/features') @@ -8,17 +8,3 @@ export const fetchCurrentPlanInfo = () => { export const fetchSubscriptionUrls = (plan: string, interval: string) => { return get(`/billing/subscription?plan=${plan}&interval=${interval}`) } - -export const fetchBillingUrl = () => { - return get<{ url: string }>('/billing/invoices') -} - -export const bindPartnerStackInfo = (partnerKey: string, clickId: string) => { - return put(`/billing/partners/${partnerKey}/tenants`, { - body: { - click_id: clickId, - }, - }, { - silent: true, - }) -} diff --git a/web/service/client.ts b/web/service/client.ts new file mode 100644 index 0000000000..c9c92ddd15 --- /dev/null +++ b/web/service/client.ts @@ -0,0 +1,61 @@ +import type { ContractRouterClient } from '@orpc/contract' +import type { JsonifiedClient } from '@orpc/openapi-client' +import { createORPCClient, onError } from '@orpc/client' +import { OpenAPILink } from '@orpc/openapi-client/fetch' +import { createTanstackQueryUtils } from '@orpc/tanstack-query' +import { + API_PREFIX, + APP_VERSION, + IS_MARKETPLACE, + MARKETPLACE_API_PREFIX, +} from '@/config' +import { + consoleRouterContract, + marketplaceRouterContract, +} from '@/contract/router' +import { request } from './base' + +const getMarketplaceHeaders = () => new Headers({ + 'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0', +}) + +const marketplaceLink = new OpenAPILink(marketplaceRouterContract, { + url: MARKETPLACE_API_PREFIX, + headers: () => (getMarketplaceHeaders()), + fetch: (request, init) => { + return globalThis.fetch(request, { + ...init, + cache: 'no-store', + }) + }, + interceptors: [ + onError((error) => { + console.error(error) + }), + ], +}) + +export const marketplaceClient: JsonifiedClient> = createORPCClient(marketplaceLink) +export const marketplaceQuery = createTanstackQueryUtils(marketplaceClient, { path: ['marketplace'] }) + +const consoleLink = new OpenAPILink(consoleRouterContract, { + url: API_PREFIX, + fetch: (input, init) => { + return request( + input.url, + init, + { + fetchCompat: true, + request: input, + }, + ) + }, + interceptors: [ + onError((error) => { + console.error(error) + }), + ], +}) + +export const consoleClient: JsonifiedClient> = createORPCClient(consoleLink) +export const consoleQuery = createTanstackQueryUtils(consoleClient, { path: ['console'] }) diff --git a/web/service/common.ts b/web/service/common.ts index 5fc4850d5f..70211d10d3 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -34,7 +34,6 @@ import type { UserProfileOriginResponse, } from '@/models/common' import type { RETRIEVE_METHOD } from '@/types/app' -import type { SystemFeatures } from '@/types/feature' import { del, get, patch, post, put } from './base' type LoginSuccess = { @@ -307,10 +306,6 @@ export const fetchSupportRetrievalMethods = (url: string): Promise(url) } -export const getSystemFeatures = (): Promise => { - return get('/system-features') -} - export const enableModel = (url: string, body: { model: string, model_type: ModelTypeEnum }): Promise => patch(url, { body }) diff --git a/web/service/fetch.ts b/web/service/fetch.ts index d0af932d73..13be7ae97b 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -136,6 +136,8 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: needAllResponseContent, deleteContentType, getAbortController, + fetchCompat = false, + request, } = otherOptions let base: string @@ -181,7 +183,7 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: }, }) - const res = await client(fetchPathname, { + const res = await client(request || fetchPathname, { ...init, headers, credentials: isMarketplaceAPI @@ -190,8 +192,8 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: retry: { methods: [], }, - ...(bodyStringify ? { json: body } : { body: body as BodyInit }), - searchParams: params, + ...(bodyStringify && !fetchCompat ? { json: body } : { body: body as BodyInit }), + searchParams: !fetchCompat ? params : undefined, fetch(resource: RequestInfo | URL, options?: RequestInit) { if (resource instanceof Request && options) { const mergedHeaders = new Headers(options.headers || {}) @@ -204,7 +206,7 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: }, }) - if (needAllResponseContent) + if (needAllResponseContent || fetchCompat) return res as T const contentType = res.headers.get('content-type') if ( diff --git a/web/service/use-billing.ts b/web/service/use-billing.ts index 3dc2b8a994..794b192d5c 100644 --- a/web/service/use-billing.ts +++ b/web/service/use-billing.ts @@ -1,21 +1,22 @@ import { useMutation, useQuery } from '@tanstack/react-query' -import { bindPartnerStackInfo, fetchBillingUrl } from '@/service/billing' - -const NAME_SPACE = 'billing' +import { consoleClient, consoleQuery } from '@/service/client' export const useBindPartnerStackInfo = () => { return useMutation({ - mutationKey: [NAME_SPACE, 'bind-partner-stack'], - mutationFn: (data: { partnerKey: string, clickId: string }) => bindPartnerStackInfo(data.partnerKey, data.clickId), + mutationKey: consoleQuery.bindPartnerStack.mutationKey(), + mutationFn: (data: { partnerKey: string, clickId: string }) => consoleClient.bindPartnerStack({ + params: { partnerKey: data.partnerKey }, + body: { click_id: data.clickId }, + }), }) } export const useBillingUrl = (enabled: boolean) => { return useQuery({ - queryKey: [NAME_SPACE, 'url'], + queryKey: consoleQuery.billingUrl.queryKey(), enabled, queryFn: async () => { - const res = await fetchBillingUrl() + const res = await consoleClient.billingUrl() return res.url }, }) diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 4e9776df97..5267503a11 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -488,23 +488,23 @@ export const useMutationPluginsFromMarketplace = () => { mutationFn: (pluginsSearchParams: PluginsSearchParams) => { const { query, - sortBy, - sortOrder, + sort_by, + sort_order, category, tags, exclude, type, page = 1, - pageSize = 40, + page_size = 40, } = pluginsSearchParams const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' return postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, { body: { page, - page_size: pageSize, + page_size, query, - sort_by: sortBy, - sort_order: sortOrder, + sort_by, + sort_order, category: category !== 'all' ? category : '', tags, exclude, @@ -535,23 +535,23 @@ export const useFetchPluginListOrBundleList = (pluginsSearchParams: PluginsSearc queryFn: () => { const { query, - sortBy, - sortOrder, + sort_by, + sort_order, category, tags, exclude, type, page = 1, - pageSize = 40, + page_size = 40, } = pluginsSearchParams const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' return postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, { body: { page, - page_size: pageSize, + page_size, query, - sort_by: sortBy, - sort_order: sortOrder, + sort_by, + sort_order, category: category !== 'all' ? category : '', tags, exclude,