From 37ae8db7d9678873650946cdfcfb62803c22843e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9A=A9=ED=83=9C?= Date: Tue, 8 Apr 2025 01:02:44 +0900 Subject: [PATCH 1/9] feat(scoped-query): introduce useScopedQuery for scope-based API access control Signed-off-by: piggggggggy --- .../_common/composables/use-scoped-query.ts | 82 ----------- .../src/query/composables/use-scoped-query.ts | 136 ++++++++++++++++++ .../query/query-key/_types/query-key-type.ts | 2 +- 3 files changed, 137 insertions(+), 83 deletions(-) delete mode 100644 apps/web/src/api-clients/_common/composables/use-scoped-query.ts create mode 100644 apps/web/src/query/composables/use-scoped-query.ts diff --git a/apps/web/src/api-clients/_common/composables/use-scoped-query.ts b/apps/web/src/api-clients/_common/composables/use-scoped-query.ts deleted file mode 100644 index 77d4f3d733..0000000000 --- a/apps/web/src/api-clients/_common/composables/use-scoped-query.ts +++ /dev/null @@ -1,82 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck -/** - * useScopedQuery - A custom wrapper around `useQuery` to enforce scope-based API fetching. - * - * ## Why this hook exists? - * This hook was created to integrate **scope-based API access control** with Vue Query. - * It ensures that queries are only executed when the user's granted scope matches the required scope. - * Additionally, it automatically handles loading states and prevents unnecessary queries. - * - * ## Functionality - * - Extends `useQuery` with **grant scope validation**. - * - Runs queries only when **the user’s scope is valid** and the app is **ready**. - * - Uses Vue’s **reactivity** to dynamically compute the `enabled` state. - * - Supports both **static and reactive `enabled` values**. - * - * ## Parameters: - * - `options`: Standard **Vue Query options** (`UseQueryOptions`). - * - `requiredScopes`: A list of **required grant scopes** to determine if the query should execute. - * - * ## Supported Query Options: - * The following options are commonly used within our team: - * - `queryKey`: **Unique key** to identify the query in the cache. - * - `queryFn`: **Function** to fetch data from the API. - * - `select`: **Transform function** to modify response data. - * - `initialData`: **Default value** for the query when no data is available. - * - `staleTime`: **Cache duration** before the query becomes stale. - * - `enabled`: **Boolean or reactive ref** to control when the query should execute. - * - * ## Example Usage: - * - * const myQuery = useScopedQuery({ - * queryKey: ['dashboard', dashboardId], - * queryFn: () => fetchDashboardData(dashboardId), - * select: (data) => data.results, - * initialData: { results: [] }, - * staleTime: 1000 * 60 * 5, // 5 minutes - * enabled: computed(() => isUserAuthorized.value), - * }, ['DOMAIN', 'WORKSPACE']); - * - */ - - -import type { MaybeRef } from '@vueuse/core'; -import { toValue } from '@vueuse/core'; -import { computed, reactive } from 'vue'; - -import type { - UseQueryOptions, -} from '@tanstack/vue-query'; -import { useQuery } from '@tanstack/vue-query'; - -import type { GrantScope } from '@/api-clients/identity/token/schema/type'; - -import { useAppContextStore } from '@/store/app-context/app-context-store'; -import { useUserStore } from '@/store/user/user-store'; - -export const useScopedQuery = ( - options: UseQueryOptions, - requiredScopes: GrantScope[] = [], -) => { - const appContextStore = useAppContextStore(); - const userStore = useUserStore(); - - const _state = reactive({ - currentGrantScope: computed(() => userStore.state.currentGrantInfo?.scope || 'USER'), - isLoading: computed(() => appContextStore.getters.globalGrantLoading), - isValidScope: computed(() => requiredScopes.includes(_state.currentGrantScope)), - }); - - const queryEnabled = computed(() => { - const _inheritedEnabled = options?.enabled as MaybeRef | undefined; - if (_inheritedEnabled === undefined) return undefined; - if (_inheritedEnabled !== undefined && !toValue(_inheritedEnabled)) return false; - return _state.isValidScope && !_state.isLoading; - }); - - return useQuery({ - ...options, - enabled: queryEnabled, - }); -}; diff --git a/apps/web/src/query/composables/use-scoped-query.ts b/apps/web/src/query/composables/use-scoped-query.ts new file mode 100644 index 0000000000..9d3b767092 --- /dev/null +++ b/apps/web/src/query/composables/use-scoped-query.ts @@ -0,0 +1,136 @@ +/** + * useScopedQuery - A custom wrapper around `useQuery` to enforce scope-based API fetching. + * + * ## Why this hook exists? + * This hook was created to integrate **scope-based API access control** with Vue Query. + * It ensures that queries are only executed when the user's granted scope matches the required scope. + * Additionally, it automatically handles loading states and prevents unnecessary queries. + * + * ## Functionality + * - Extends `useQuery` with **grant scope validation**. + * - Runs queries only when **the user's scope is valid** and the app is **ready**. + * - Uses Vue's **reactivity** to dynamically compute the `enabled` state. + * - Supports both **static and reactive `enabled` values**. + * + * ## Parameters: + * - `options`: Standard **Vue Query options** (`UseQueryOptions`). + * - `requiredScopes`: A list of **required grant scopes** to determine if the query should execute. + * + * ## Supported Query Options: + * - `queryKey`, `queryFn`, `select`, `initialData`, `staleTime`, `enabled` (static or reactive) + * + * ## Example: + * const query = useScopedQuery( + * { + * queryKey: ['dashboard', dashboardId], + * queryFn: () => fetchDashboardData(dashboardId), + * enabled: computed(() => isUserAuthorized.value), + * }, + * ['DOMAIN', 'WORKSPACE'] + * ); + */ + +import type { MaybeRef } from '@vueuse/core'; +import { toValue } from '@vueuse/core'; +import type { ComputedRef } from 'vue'; +import { computed } from 'vue'; + +import { + useQuery, type UseQueryOptions, type UseQueryReturnType, +} from '@tanstack/vue-query'; + +import type { GrantScope } from '@/api-clients/identity/token/schema/type'; +import type { QueryKeyArray } from '@/query/query-key/_types/query-key-type'; + +import { useAppContextStore } from '@/store/app-context/app-context-store'; +import { useUserStore } from '@/store/user/user-store'; + + +type ScopedEnabled = MaybeRef; + + +export const useScopedQuery = ( + options: UseQueryOptions, + requiredScopes: [GrantScope, ...GrantScope[]], +): UseQueryReturnType => { + // Warns if `requiredScopes` array is missing or empty during development + if (import.meta.env.DEV) { + _warnMissingRequiredScopes(requiredScopes); + } + + const appContextStore = useAppContextStore(); + const userStore = useUserStore(); + + const currentGrantScope = computed( + () => userStore.state.currentGrantInfo?.scope, + ); + const isAppReady = computed(() => appContextStore.getters.globalGrantLoading); + + const isValidScope = computed(() => currentGrantScope.value !== undefined + && requiredScopes.includes(currentGrantScope.value)); + + const rawEnabled = (options as { enabled?: ScopedEnabled }).enabled; + const queryEnabled = computed(() => { + const inheritedEnabled = rawEnabled !== undefined ? toValue(rawEnabled) : true; + return inheritedEnabled && isValidScope.value && isAppReady.value; + }); + + // Logs a warning once per queryKey when the current scope is invalid for this query + if (import.meta.env.DEV) { + _warnInvalidScopeOnce({ + queryKey: _extractQueryKey((options as any).queryKey), + enabled: toValue(queryEnabled), + currentScope: currentGrantScope.value, + requiredScopes, + isAppReady: isAppReady.value, + }); + } + + return useQuery({ + ...options, + enabled: queryEnabled, + }); +}; + +const _extractQueryKey = (input: unknown): QueryKeyArray => toValue(input as ComputedRef); + +const _warnedKeys = new Set(); + +function _warnMissingRequiredScopes(scopes: GrantScope[]) { + if (import.meta.env.DEV && (!scopes || scopes.length === 0)) { + console.warn('[useScopedQuery] `requiredScopes` is missing or empty.', { + suggestion: 'Pass at least one valid scope like [\'DOMAIN\'], [\'WORKSPACE\'], etc.', + }); + } +} + +function _warnInvalidScopeOnce(params: { + queryKey: QueryKeyArray; + enabled: boolean; + currentScope: GrantScope | undefined; + requiredScopes: GrantScope[]; + isAppReady: boolean; +}) { + if (!import.meta.env.DEV) return; + + const { + queryKey, enabled, currentScope, requiredScopes, isAppReady, + } = params; + if (!enabled || !isAppReady || !currentScope) return; + + if (requiredScopes.includes(currentScope)) return; + + const key = Array.isArray(queryKey) + ? queryKey.join(':') + : String(queryKey); + + if (_warnedKeys.has(key)) return; + + _warnedKeys.add(key); + + console.warn('[useScopedQuery] Query not executed due to invalid grant scope.', { + queryKey, + currentScope, + requiredScopes, + }); +} diff --git a/apps/web/src/query/query-key/_types/query-key-type.ts b/apps/web/src/query/query-key/_types/query-key-type.ts index 88695444ed..8db52a2a3e 100644 --- a/apps/web/src/query/query-key/_types/query-key-type.ts +++ b/apps/web/src/query/query-key/_types/query-key-type.ts @@ -1,7 +1,7 @@ import type { API_DOC } from '@/api-clients/_common/constants/api-doc-constant'; -export type QueryKeyArray = unknown[]; +export type QueryKeyArray = readonly unknown[]; export type QueryScope = 'service' | 'reference'; From 56c5cb85208b32f70c2e406055c6c446f448c4c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9A=A9=ED=83=9C?= Date: Tue, 8 Apr 2025 01:10:02 +0900 Subject: [PATCH 2/9] chore: apply changed path Signed-off-by: piggggggggy --- .../widgets/_composables/use-widget-data-table-list-query.ts | 2 +- .../widgets/_composables/use-widget-data-table-query.ts | 2 +- .../common/modules/widgets/_composables/use-widget-query.ts | 2 +- .../dashboards/composables/use-dashboard-folder-query.ts | 2 +- .../services/dashboards/composables/use-dashboard-get-query.ts | 2 +- .../src/services/dashboards/composables/use-dashboard-query.ts | 3 ++- .../dashboards/composables/use-dashboard-search-query.ts | 2 +- .../dashboards/composables/use-dashboard-widget-list-query.ts | 2 +- .../src/services/project/v2/components/ProjectDetailTab.vue | 2 +- 9 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-list-query.ts b/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-list-query.ts index 00e1c3ca4a..e3f64b72f7 100644 --- a/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-list-query.ts +++ b/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-list-query.ts @@ -3,11 +3,11 @@ import { computed } from 'vue'; import type { QueryKey } from '@tanstack/vue-query'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import { usePrivateDataTableApi } from '@/api-clients/dashboard/private-data-table/composables/use-private-data-table-api'; import { usePublicDataTableApi } from '@/api-clients/dashboard/public-data-table/composables/use-public-data-table-api'; import type { DataTableListParameters } from '@/api-clients/dashboard/public-data-table/schema/api-verbs/list'; import type { DataTableUpdateParameters } from '@/api-clients/dashboard/public-data-table/schema/api-verbs/update'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; import type { DataTableModel } from '@/common/modules/widgets/types/widget-data-table-type'; diff --git a/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-query.ts b/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-query.ts index 032d0fa9cb..da12af8543 100644 --- a/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-query.ts +++ b/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-query.ts @@ -1,10 +1,10 @@ import type { ComputedRef } from 'vue'; import { computed } from 'vue'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import { usePrivateDataTableApi } from '@/api-clients/dashboard/private-data-table/composables/use-private-data-table-api'; import type { DataTableGetParameters } from '@/api-clients/dashboard/private-data-table/schema/api-verbs/get'; import { usePublicDataTableApi } from '@/api-clients/dashboard/public-data-table/composables/use-public-data-table-api'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; diff --git a/apps/web/src/common/modules/widgets/_composables/use-widget-query.ts b/apps/web/src/common/modules/widgets/_composables/use-widget-query.ts index 6702f3d924..e06be200e0 100644 --- a/apps/web/src/common/modules/widgets/_composables/use-widget-query.ts +++ b/apps/web/src/common/modules/widgets/_composables/use-widget-query.ts @@ -3,12 +3,12 @@ import { computed } from 'vue'; import type { QueryKey } from '@tanstack/vue-query'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import type { WidgetModel, WidgetUpdateParams } from '@/api-clients/dashboard/_types/widget-type'; import { usePrivateWidgetApi } from '@/api-clients/dashboard/private-widget/composables/use-private-widget-api'; import type { PrivateWidgetGetParameters } from '@/api-clients/dashboard/private-widget/schema/api-verbs/get'; import { usePublicWidgetApi } from '@/api-clients/dashboard/public-widget/composables/use-public-widget-api'; import type { PublicWidgetGetParameters } from '@/api-clients/dashboard/public-widget/schema/api-verbs/get'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; diff --git a/apps/web/src/services/dashboards/composables/use-dashboard-folder-query.ts b/apps/web/src/services/dashboards/composables/use-dashboard-folder-query.ts index 69d1d4bab3..b57e199935 100644 --- a/apps/web/src/services/dashboards/composables/use-dashboard-folder-query.ts +++ b/apps/web/src/services/dashboards/composables/use-dashboard-folder-query.ts @@ -7,7 +7,6 @@ import type { QueryKey } from '@tanstack/vue-query'; import { ApiQueryHelper } from '@cloudforet/core-lib/space-connector/helper'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import type { ListResponse } from '@/api-clients/_common/schema/api-verbs/list'; import type { FolderModel, FolderUpdateParams } from '@/api-clients/dashboard/_types/folder-type'; import { usePrivateFolderApi } from '@/api-clients/dashboard/private-folder/composables/use-private-folder-api'; @@ -16,6 +15,7 @@ import type { PrivateFolderModel } from '@/api-clients/dashboard/private-folder/ import { usePublicFolderApi } from '@/api-clients/dashboard/public-folder/composables/use-public-folder-api'; import type { PublicFolderUpdateParameters } from '@/api-clients/dashboard/public-folder/schema/api-verbs/update'; import type { PublicFolderModel } from '@/api-clients/dashboard/public-folder/schema/model'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; import { useAppContextStore } from '@/store/app-context/app-context-store'; diff --git a/apps/web/src/services/dashboards/composables/use-dashboard-get-query.ts b/apps/web/src/services/dashboards/composables/use-dashboard-get-query.ts index ac013223ad..5b2c3d5706 100644 --- a/apps/web/src/services/dashboards/composables/use-dashboard-get-query.ts +++ b/apps/web/src/services/dashboards/composables/use-dashboard-get-query.ts @@ -3,7 +3,6 @@ import { computed } from 'vue'; import type { QueryKey } from '@tanstack/vue-query'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import type { DashboardModel, DashboardUpdateParams } from '@/api-clients/dashboard/_types/dashboard-type'; import { usePrivateDashboardApi } from '@/api-clients/dashboard/private-dashboard/composables/use-private-dashboard-api'; import type { PrivateDashboardGetParameters } from '@/api-clients/dashboard/private-dashboard/schema/api-verbs/get'; @@ -13,6 +12,7 @@ import { usePublicDashboardApi } from '@/api-clients/dashboard/public-dashboard/ import type { PublicDashboardGetParameters } from '@/api-clients/dashboard/public-dashboard/schema/api-verbs/get'; import type { PublicDashboardUpdateParameters } from '@/api-clients/dashboard/public-dashboard/schema/api-verbs/update'; import type { PublicDashboardModel } from '@/api-clients/dashboard/public-dashboard/schema/model'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; const STALE_TIME = 1000 * 60 * 5; diff --git a/apps/web/src/services/dashboards/composables/use-dashboard-query.ts b/apps/web/src/services/dashboards/composables/use-dashboard-query.ts index ef6135ba60..8499a61f5f 100644 --- a/apps/web/src/services/dashboards/composables/use-dashboard-query.ts +++ b/apps/web/src/services/dashboards/composables/use-dashboard-query.ts @@ -7,16 +7,17 @@ import type { QueryKey } from '@tanstack/vue-query'; import { ApiQueryHelper } from '@cloudforet/core-lib/space-connector/helper'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import type { ListResponse } from '@/api-clients/_common/schema/api-verbs/list'; import { usePrivateDashboardApi } from '@/api-clients/dashboard/private-dashboard/composables/use-private-dashboard-api'; import type { PrivateDashboardModel } from '@/api-clients/dashboard/private-dashboard/schema/model'; import { usePublicDashboardApi } from '@/api-clients/dashboard/public-dashboard/composables/use-public-dashboard-api'; import type { PublicDashboardModel } from '@/api-clients/dashboard/public-dashboard/schema/model'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; import { useAppContextStore } from '@/store/app-context/app-context-store'; + const DEFAULT_LIST_DATA = { results: [] }; const STALE_TIME = 1000 * 60 * 5; diff --git a/apps/web/src/services/dashboards/composables/use-dashboard-search-query.ts b/apps/web/src/services/dashboards/composables/use-dashboard-search-query.ts index 877f5ff1aa..237b0b8741 100644 --- a/apps/web/src/services/dashboards/composables/use-dashboard-search-query.ts +++ b/apps/web/src/services/dashboards/composables/use-dashboard-search-query.ts @@ -9,13 +9,13 @@ import type { ConsoleFilter } from '@cloudforet/core-lib/query/type'; import { ApiQueryHelper } from '@cloudforet/core-lib/space-connector/helper'; import type { Query } from '@cloudforet/core-lib/space-connector/type'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import type { ListResponse } from '@/api-clients/_common/schema/api-verbs/list'; import { usePrivateDashboardApi } from '@/api-clients/dashboard/private-dashboard/composables/use-private-dashboard-api'; import type { PrivateDashboardModel } from '@/api-clients/dashboard/private-dashboard/schema/model'; import { usePublicDashboardApi } from '@/api-clients/dashboard/public-dashboard/composables/use-public-dashboard-api'; import type { PublicDashboardModel } from '@/api-clients/dashboard/public-dashboard/schema/model'; import { ROLE_TYPE } from '@/api-clients/identity/role/constant'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; import { useAppContextStore } from '@/store/app-context/app-context-store'; diff --git a/apps/web/src/services/dashboards/composables/use-dashboard-widget-list-query.ts b/apps/web/src/services/dashboards/composables/use-dashboard-widget-list-query.ts index 683e6ec0b0..58f964b851 100644 --- a/apps/web/src/services/dashboards/composables/use-dashboard-widget-list-query.ts +++ b/apps/web/src/services/dashboards/composables/use-dashboard-widget-list-query.ts @@ -3,7 +3,6 @@ import { computed } from 'vue'; import type { QueryKey } from '@tanstack/vue-query'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import type { WidgetModel, WidgetUpdateParams } from '@/api-clients/dashboard/_types/widget-type'; import { usePrivateWidgetApi } from '@/api-clients/dashboard/private-widget/composables/use-private-widget-api'; import type { PrivateWidgetListParameters } from '@/api-clients/dashboard/private-widget/schema/api-verbs/list'; @@ -13,6 +12,7 @@ import { usePublicWidgetApi } from '@/api-clients/dashboard/public-widget/compos import type { PublicWidgetListParameters } from '@/api-clients/dashboard/public-widget/schema/api-verbs/list'; import type { PublicWidgetUpdateParameters } from '@/api-clients/dashboard/public-widget/schema/api-verbs/update'; import type { PublicWidgetModel } from '@/api-clients/dashboard/public-widget/schema/model'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; const DEFAULT_LIST_DATA = { results: [] }; diff --git a/apps/web/src/services/project/v2/components/ProjectDetailTab.vue b/apps/web/src/services/project/v2/components/ProjectDetailTab.vue index 7adb277281..0dd147982f 100644 --- a/apps/web/src/services/project/v2/components/ProjectDetailTab.vue +++ b/apps/web/src/services/project/v2/components/ProjectDetailTab.vue @@ -4,11 +4,11 @@ import { computed, ref, watch } from 'vue'; import { PTab } from '@cloudforet/mirinae'; import type { TabItem } from '@cloudforet/mirinae/types/navigation/tabs/tab/type'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import { usePublicDashboardApi } from '@/api-clients/dashboard/public-dashboard/composables/use-public-dashboard-api'; import type { PublicDashboardListParameters } from '@/api-clients/dashboard/public-dashboard/schema/api-verbs/list'; import { usePublicFolderApi } from '@/api-clients/dashboard/public-folder/composables/use-public-folder-api'; import type { PublicFolderListParameters } from '@/api-clients/dashboard/public-folder/schema/api-verbs/list'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; import ProjectDashboard from '@/services/project/v2/components/ProjectDashboard.vue'; From c648d87b2cad81e1c01f3fc343fef2474873a944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9A=A9=ED=83=9C?= Date: Tue, 8 Apr 2025 01:10:23 +0900 Subject: [PATCH 3/9] chore: add test Signed-off-by: piggggggggy --- .../__tests__/use-scoped-query.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 apps/web/src/query/composables/__tests__/use-scoped-query.test.ts diff --git a/apps/web/src/query/composables/__tests__/use-scoped-query.test.ts b/apps/web/src/query/composables/__tests__/use-scoped-query.test.ts new file mode 100644 index 0000000000..de5a7bf79d --- /dev/null +++ b/apps/web/src/query/composables/__tests__/use-scoped-query.test.ts @@ -0,0 +1,75 @@ +// useScopedQuery.spec.ts + +import { computed } from 'vue'; + +import { + describe, it, expect, vi, +} from 'vitest'; + +import { useScopedQuery } from '@/query/composables/use-scoped-query'; + +import { useUserStore } from '@/store/user/user-store'; + +vi.mock('@/store/user/user-store', () => ({ + useUserStore: vi.fn(() => ({ + state: { + currentGrantInfo: { + scope: 'WORKSPACE', + }, + }, + })), +})); + +vi.mock('@/store/app-context/app-context-store', () => ({ + useAppContextStore: () => ({ + getters: { + globalGrantLoading: true, + }, + }), +})); + +vi.mock('@tanstack/vue-query', () => ({ + useQuery: vi.fn((options) => ({ + data: computed(() => 'success'), + isSuccess: computed(() => true), + isLoading: computed(() => false), + enabled: options.enabled, + })), + QueryClient: vi.fn(() => ({ + setDefaultOptions: vi.fn(), + })), +})); + +describe('useScopedQuery', () => { + it('should validate scope correctly', () => { + const queryFn = vi.fn(() => Promise.resolve('success')); + const queryKey = computed(() => ['test']); + + const result = useScopedQuery({ + queryKey, + queryFn, + }, ['WORKSPACE']); + + expect(result.enabled.value).toBe(true); + }); + + it('should disable query for invalid scope', () => { + vi.mocked(useUserStore).mockImplementationOnce(() => ({ + state: { + currentGrantInfo: { + scope: 'DOMAIN', + }, + }, + })); + + const queryFn = vi.fn(() => Promise.resolve('fail')); + const queryKey = computed(() => ['test-invalid']); + + const result = useScopedQuery({ + queryKey, + queryFn, + }, ['WORKSPACE']); + + expect(result.enabled.value).toBe(false); + }); +}); From 965fe09ebd51eff625d29e6e5290399387751b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9A=A9=ED=83=9C?= Date: Tue, 8 Apr 2025 01:12:00 +0900 Subject: [PATCH 4/9] chore: small fix Signed-off-by: piggggggggy --- apps/web/src/query/composables/use-scoped-query.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/query/composables/use-scoped-query.ts b/apps/web/src/query/composables/use-scoped-query.ts index 9d3b767092..e07ed81b1d 100644 --- a/apps/web/src/query/composables/use-scoped-query.ts +++ b/apps/web/src/query/composables/use-scoped-query.ts @@ -96,21 +96,21 @@ const _extractQueryKey = (input: unknown): QueryKeyArray => toValue(input as Com const _warnedKeys = new Set(); -function _warnMissingRequiredScopes(scopes: GrantScope[]) { +const _warnMissingRequiredScopes = (scopes: GrantScope[]) => { if (import.meta.env.DEV && (!scopes || scopes.length === 0)) { console.warn('[useScopedQuery] `requiredScopes` is missing or empty.', { suggestion: 'Pass at least one valid scope like [\'DOMAIN\'], [\'WORKSPACE\'], etc.', }); } -} +}; -function _warnInvalidScopeOnce(params: { +const _warnInvalidScopeOnce = (params: { queryKey: QueryKeyArray; enabled: boolean; currentScope: GrantScope | undefined; requiredScopes: GrantScope[]; isAppReady: boolean; -}) { +}) => { if (!import.meta.env.DEV) return; const { @@ -133,4 +133,4 @@ function _warnInvalidScopeOnce(params: { currentScope, requiredScopes, }); -} +}; From 9279c9994c97c6422ec6815c0d05fa176ac41eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9A=A9=ED=83=9C?= Date: Tue, 8 Apr 2025 01:24:44 +0900 Subject: [PATCH 5/9] chore: small fix Signed-off-by: piggggggggy --- apps/web/src/query/composables/use-scoped-query.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/query/composables/use-scoped-query.ts b/apps/web/src/query/composables/use-scoped-query.ts index e07ed81b1d..db0c0b2f51 100644 --- a/apps/web/src/query/composables/use-scoped-query.ts +++ b/apps/web/src/query/composables/use-scoped-query.ts @@ -94,8 +94,8 @@ export const useScopedQuery = toValue(input as ComputedRef); -const _warnedKeys = new Set(); +// Warns if `requiredScopes` array is missing or empty during development const _warnMissingRequiredScopes = (scopes: GrantScope[]) => { if (import.meta.env.DEV && (!scopes || scopes.length === 0)) { console.warn('[useScopedQuery] `requiredScopes` is missing or empty.', { @@ -104,6 +104,8 @@ const _warnMissingRequiredScopes = (scopes: GrantScope[]) => { } }; +// Logs a warning once per queryKey when the current scope is invalid for this query +const _warnedKeys = new Set(); const _warnInvalidScopeOnce = (params: { queryKey: QueryKeyArray; enabled: boolean; From c79c980311cd6f062ec8e1e40d452710b618f1b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9A=A9=ED=83=9C?= Date: Tue, 8 Apr 2025 09:10:19 +0900 Subject: [PATCH 6/9] chore: small fix Signed-off-by: piggggggggy --- apps/web/src/query/composables/use-scoped-query.ts | 2 +- .../services/dashboards/composables/use-dashboard-query.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/query/composables/use-scoped-query.ts b/apps/web/src/query/composables/use-scoped-query.ts index db0c0b2f51..b5755ebd41 100644 --- a/apps/web/src/query/composables/use-scoped-query.ts +++ b/apps/web/src/query/composables/use-scoped-query.ts @@ -64,7 +64,7 @@ export const useScopedQuery = ( () => userStore.state.currentGrantInfo?.scope, ); - const isAppReady = computed(() => appContextStore.getters.globalGrantLoading); + const isAppReady = computed(() => !appContextStore.getters.globalGrantLoading); const isValidScope = computed(() => currentGrantScope.value !== undefined && requiredScopes.includes(currentGrantScope.value)); diff --git a/apps/web/src/services/dashboards/composables/use-dashboard-query.ts b/apps/web/src/services/dashboards/composables/use-dashboard-query.ts index 8499a61f5f..dab1ca745c 100644 --- a/apps/web/src/services/dashboards/composables/use-dashboard-query.ts +++ b/apps/web/src/services/dashboards/composables/use-dashboard-query.ts @@ -7,7 +7,6 @@ import type { QueryKey } from '@tanstack/vue-query'; import { ApiQueryHelper } from '@cloudforet/core-lib/space-connector/helper'; -import type { ListResponse } from '@/api-clients/_common/schema/api-verbs/list'; import { usePrivateDashboardApi } from '@/api-clients/dashboard/private-dashboard/composables/use-private-dashboard-api'; import type { PrivateDashboardModel } from '@/api-clients/dashboard/private-dashboard/schema/model'; import { usePublicDashboardApi } from '@/api-clients/dashboard/public-dashboard/composables/use-public-dashboard-api'; @@ -68,7 +67,7 @@ export const useDashboardQuery = (): UseDashboardQueryReturn => { }); /* Querys */ - const publicDashboardListQuery = useScopedQuery, unknown, PublicDashboardModel[]>({ + const publicDashboardListQuery = useScopedQuery({ queryKey: publicDashboardListQueryKey, queryFn: () => publicDashboardAPI.list(publicDashboardListParams.value), select: (data) => data?.results ?? [], @@ -77,7 +76,7 @@ export const useDashboardQuery = (): UseDashboardQueryReturn => { staleTime: STALE_TIME, enabled: computed(() => !!_state.publicDashboardListApiQuery?.filter), }, ['DOMAIN', 'WORKSPACE']); - const privateDashboardListQuery = useScopedQuery, unknown, PrivateDashboardModel[]>({ + const privateDashboardListQuery = useScopedQuery({ queryKey: privateDashboardListQueryKey, queryFn: () => privateDashboardAPI.list(privateDashboardListParams.value), select: (data) => data?.results || [], From 0520c37b9d18c905a296271f69b1f6d8e1525457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9A=A9=ED=83=9C?= Date: Tue, 8 Apr 2025 13:28:52 +0900 Subject: [PATCH 7/9] fix(dev-log): apply instance context log caching Signed-off-by: piggggggggy --- .../src/query/composables/use-scoped-query.ts | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/apps/web/src/query/composables/use-scoped-query.ts b/apps/web/src/query/composables/use-scoped-query.ts index b5755ebd41..f075ae990d 100644 --- a/apps/web/src/query/composables/use-scoped-query.ts +++ b/apps/web/src/query/composables/use-scoped-query.ts @@ -33,7 +33,7 @@ import type { MaybeRef } from '@vueuse/core'; import { toValue } from '@vueuse/core'; import type { ComputedRef } from 'vue'; -import { computed } from 'vue'; +import { computed, getCurrentInstance } from 'vue'; import { useQuery, type UseQueryOptions, type UseQueryReturnType, @@ -104,8 +104,17 @@ const _warnMissingRequiredScopes = (scopes: GrantScope[]) => { } }; -// Logs a warning once per queryKey when the current scope is invalid for this query -const _warnedKeys = new Set(); +/** + * Logs a warning when a query is not executed due to invalid scope, + * but only once per queryKey during development. + * + * Conditions to trigger warning: + * - `enabled` is true + * - app is ready (not loading) + * - currentScope is defined + * - currentScope NOT included in requiredScopes + */ +const _warnedKeysPerInstance = new WeakMap>(); const _warnInvalidScopeOnce = (params: { queryKey: QueryKeyArray; enabled: boolean; @@ -115,20 +124,39 @@ const _warnInvalidScopeOnce = (params: { }) => { if (!import.meta.env.DEV) return; + // Get the current Vue component instance (used to scope the warning cache) + const instance = getCurrentInstance(); + if (!instance) return; + const { queryKey, enabled, currentScope, requiredScopes, isAppReady, } = params; - if (!enabled || !isAppReady || !currentScope) return; - if (requiredScopes.includes(currentScope)) return; + if (!isAppReady || !currentScope) return; - const key = Array.isArray(queryKey) - ? queryKey.join(':') - : String(queryKey); + const isValidScope = requiredScopes.includes(currentScope); - if (_warnedKeys.has(key)) return; + if (!enabled && isValidScope) return; - _warnedKeys.add(key); + if (isValidScope) return; + + // Safely serialize the queryKey (even if it contains objects) + const key = (() => { + try { + return JSON.stringify(queryKey); + } catch { + return Array.isArray(queryKey) ? queryKey.join(':') : String(queryKey); + } + })(); + + // Cache per component instance to prevent duplicate logs + let keySet = _warnedKeysPerInstance.get(instance); + if (!keySet) { + keySet = new Set(); + _warnedKeysPerInstance.set(instance, keySet); + } + if (keySet.has(key)) return; + keySet.add(key); console.warn('[useScopedQuery] Query not executed due to invalid grant scope.', { queryKey, From 3de0c5c4431d35d5e23265100cd42a66f9dcb1b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9A=A9=ED=83=9C?= Date: Sun, 13 Apr 2025 02:56:31 +0900 Subject: [PATCH 8/9] fix(use-scoped-query): improve dev-logger with err.stack and queueMicrotask Signed-off-by: piggggggggy --- .../src/query/composables/use-scoped-query.ts | 125 +++++++----------- 1 file changed, 47 insertions(+), 78 deletions(-) diff --git a/apps/web/src/query/composables/use-scoped-query.ts b/apps/web/src/query/composables/use-scoped-query.ts index f075ae990d..895c182156 100644 --- a/apps/web/src/query/composables/use-scoped-query.ts +++ b/apps/web/src/query/composables/use-scoped-query.ts @@ -32,8 +32,7 @@ import type { MaybeRef } from '@vueuse/core'; import { toValue } from '@vueuse/core'; -import type { ComputedRef } from 'vue'; -import { computed, getCurrentInstance } from 'vue'; +import { computed, type ComputedRef } from 'vue'; import { useQuery, type UseQueryOptions, type UseQueryReturnType, @@ -53,9 +52,16 @@ export const useScopedQuery = , requiredScopes: [GrantScope, ...GrantScope[]], ): UseQueryReturnType => { - // Warns if `requiredScopes` array is missing or empty during development - if (import.meta.env.DEV) { - _warnMissingRequiredScopes(requiredScopes); + // [Dev Warning] This query is missing `requiredScopes`. + // All scoped queries must explicitly define at least one valid scope for clarity and safety. + if (import.meta.env.DEV && (!requiredScopes || requiredScopes.length === 0)) { + _warnOncePerTick(() => { + console.warn('[useScopedQuery] `requiredScopes` is missing or empty.', { + queryKey: _extractQueryKey((options as any).queryKey), + suggestion: 'Pass at least one valid scope like [\'DOMAIN\'], [\'WORKSPACE\'], etc.', + }); + return true; + }); } const appContextStore = useAppContextStore(); @@ -75,15 +81,20 @@ export const useScopedQuery = { + console.warn('[useScopedQuery] Invalid requiredScopes for current scope:', { + queryKey: _extractQueryKey((options as any).queryKey), + requiredScopes, + currentScope, + }); + return true; + }); + } } return useQuery({ @@ -95,72 +106,30 @@ export const useScopedQuery = toValue(input as ComputedRef); -// Warns if `requiredScopes` array is missing or empty during development -const _warnMissingRequiredScopes = (scopes: GrantScope[]) => { - if (import.meta.env.DEV && (!scopes || scopes.length === 0)) { - console.warn('[useScopedQuery] `requiredScopes` is missing or empty.', { - suggestion: 'Pass at least one valid scope like [\'DOMAIN\'], [\'WORKSPACE\'], etc.', - }); - } -}; -/** - * Logs a warning when a query is not executed due to invalid scope, - * but only once per queryKey during development. - * - * Conditions to trigger warning: - * - `enabled` is true - * - app is ready (not loading) - * - currentScope is defined - * - currentScope NOT included in requiredScopes - */ -const _warnedKeysPerInstance = new WeakMap>(); -const _warnInvalidScopeOnce = (params: { - queryKey: QueryKeyArray; - enabled: boolean; - currentScope: GrantScope | undefined; - requiredScopes: GrantScope[]; - isAppReady: boolean; -}) => { - if (!import.meta.env.DEV) return; - - // Get the current Vue component instance (used to scope the warning cache) - const instance = getCurrentInstance(); - if (!instance) return; - - const { - queryKey, enabled, currentScope, requiredScopes, isAppReady, - } = params; - - if (!isAppReady || !currentScope) return; - - const isValidScope = requiredScopes.includes(currentScope); - - if (!enabled && isValidScope) return; - - if (isValidScope) return; - - // Safely serialize the queryKey (even if it contains objects) - const key = (() => { - try { - return JSON.stringify(queryKey); - } catch { - return Array.isArray(queryKey) ? queryKey.join(':') : String(queryKey); - } - })(); +/* Warning Logger Utilities */ +const _warnedKeys = new Set(); +const _getCallerKey = (): string => { + try { + const err = new Error(); + const stack = err.stack?.split('\n') || []; - // Cache per component instance to prevent duplicate logs - let keySet = _warnedKeysPerInstance.get(instance); - if (!keySet) { - keySet = new Set(); - _warnedKeysPerInstance.set(instance, keySet); - } - if (keySet.has(key)) return; - keySet.add(key); + const caller = stack.find((line, i) => i > 1 + && (line.includes('.ts') || line.includes('.vue')) + && !line.includes('use-scoped-query')); - console.warn('[useScopedQuery] Query not executed due to invalid grant scope.', { - queryKey, - currentScope, - requiredScopes, - }); + return caller?.trim() ?? 'UNKNOWN_CALLSITE'; + } catch { + return 'UNKNOWN_CALLSITE'; + } +}; +const _warnOncePerTick = (log: () => boolean) => { + const key = _getCallerKey(); + if (_warnedKeys.has(key)) return; + const didLog = log(); + + if (didLog) { + _warnedKeys.add(key); + queueMicrotask(() => _warnedKeys.delete(key)); + } }; From cc6e8ee66bc9b4892a38d8a80fd4876b127d2b3c Mon Sep 17 00:00:00 2001 From: Yongtae Park Date: Mon, 14 Apr 2025 10:52:25 +0900 Subject: [PATCH 9/9] chore: remove test file Signed-off-by: samuel.park --- .../__tests__/use-scoped-query.test.ts | 75 ------------------- 1 file changed, 75 deletions(-) delete mode 100644 apps/web/src/query/composables/__tests__/use-scoped-query.test.ts diff --git a/apps/web/src/query/composables/__tests__/use-scoped-query.test.ts b/apps/web/src/query/composables/__tests__/use-scoped-query.test.ts deleted file mode 100644 index de5a7bf79d..0000000000 --- a/apps/web/src/query/composables/__tests__/use-scoped-query.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -// useScopedQuery.spec.ts - -import { computed } from 'vue'; - -import { - describe, it, expect, vi, -} from 'vitest'; - -import { useScopedQuery } from '@/query/composables/use-scoped-query'; - -import { useUserStore } from '@/store/user/user-store'; - -vi.mock('@/store/user/user-store', () => ({ - useUserStore: vi.fn(() => ({ - state: { - currentGrantInfo: { - scope: 'WORKSPACE', - }, - }, - })), -})); - -vi.mock('@/store/app-context/app-context-store', () => ({ - useAppContextStore: () => ({ - getters: { - globalGrantLoading: true, - }, - }), -})); - -vi.mock('@tanstack/vue-query', () => ({ - useQuery: vi.fn((options) => ({ - data: computed(() => 'success'), - isSuccess: computed(() => true), - isLoading: computed(() => false), - enabled: options.enabled, - })), - QueryClient: vi.fn(() => ({ - setDefaultOptions: vi.fn(), - })), -})); - -describe('useScopedQuery', () => { - it('should validate scope correctly', () => { - const queryFn = vi.fn(() => Promise.resolve('success')); - const queryKey = computed(() => ['test']); - - const result = useScopedQuery({ - queryKey, - queryFn, - }, ['WORKSPACE']); - - expect(result.enabled.value).toBe(true); - }); - - it('should disable query for invalid scope', () => { - vi.mocked(useUserStore).mockImplementationOnce(() => ({ - state: { - currentGrantInfo: { - scope: 'DOMAIN', - }, - }, - })); - - const queryFn = vi.fn(() => Promise.resolve('fail')); - const queryKey = computed(() => ['test-invalid']); - - const result = useScopedQuery({ - queryKey, - queryFn, - }, ['WORKSPACE']); - - expect(result.enabled.value).toBe(false); - }); -});