From ea695fdfa9895c75eb8499f1f1a6d3db5b899de0 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Tue, 2 Jun 2026 23:36:14 +1000 Subject: [PATCH] [FE-3496] feat(studio): hide unexposed tables from Data API docs (#46508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autogenerated Data API docs listed every table and database function from the PostgREST OpenAPI spec, even ones that aren't actually accessible via the Data API (i.e. with grants revoked). This filters the docs down to only the entities that are exposed, and surfaces a count of the excluded ones with a link to enable them. This applies to **both** autogenerated docs surfaces: - the **API Docs side panel** (the slide-over opened from the API docs button), and - the **full-page Data API docs** at `/integrations/data_api/docs`. Screenshot 2026-06-01 at 5 48 21 PM **Changed:** - Both docs UIs now only list tables and functions that have Data API access (any `anon`/`authenticated`/`service_role` grant). Fully-revoked entities are hidden. - Side panel: both the sidebar list and the drilled-in resource picker are filtered. - Full page: the menu's Tables/Functions groups are filtered, with a footer note under each. **Added:** - A footer under each list — "N table(s)/function(s) not exposed via **Data API**" — linking to Data API settings (`/integrations/data_api/settings`) so the entity can be granted access. - One-shot `useExposedTablesQuery` / `useExposedFunctionsQuery` hooks reusing the same granted/custom/revoked SQL as the Data API settings page (no new SQL). - Pure, unit-tested `partitionExposedDocsEntities()` helper (fails open if grant status hasn't loaded / errors, so docs are never blanked). - Optional `footer` slot on `ProductMenuGroup` (rendered by `DocsMenu`) so the full-page menu can show the not-exposed note under a group. **Note on the "all" queries:** the new `useExposedTablesQuery` / `useExposedFunctionsQuery` fetch the full grant-status list in a single request (rather than paginating like the Data API settings page does). This is deliberate — the docs sections aren't paginated and render every entity from the OpenAPI spec at once, so we need the complete status set to cross-reference against. Ideally we'd refactor the docs to be paginated in future, at which point these queries should move to a paginated approach too; until then, the one-shot "all" fetch is what matches the current (unpaginated) docs behavior. ## To test - On a project, revoke a `public` table's Data API access (Data API settings → uncheck it) - Open the **full-page** docs at `/integrations/data_api/docs`: the table should no longer appear under Tables and Views, and you should see "1 table not exposed via Data API" under that menu group - Open the **API Docs side panel** and expand Tables and Views: same behavior - Click the "Data API" link → goes to Data API settings (closes the side panel if open) - Same for a database function under Functions - Tables/functions that are still granted (or have custom/partial grants) should remain visible ## Summary by CodeRabbit * **New Features** * Data API docs now reflect actual exposure: tables/functions not exposed by permissions are hidden and counted. * Sections display footer indicators with counts of hidden entities and links to Data API settings. * Navigation lists and docs menu updated to show only exposed entities and the new "not exposed" cues. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../Integrations/DataApi/DocsMenu.tsx | 1 + .../Integrations/DataApi/DocsTab.tsx | 98 ++++++++++++++++--- .../ProjectAPIDocs/FirstLevelNav.tsx | 33 ++++--- .../ProjectAPIDocs/SecondLevelNav.tsx | 14 +-- .../ProjectAPIDocs/useApiDocsEntities.ts | 61 ++++++++++++ .../layouts/DocsLayout/DocsLayout.utils.tsx | 2 + .../ui/NotExposedEntitiesIndicator.tsx | 48 +++++++++ .../ui/ProductMenu/ProductMenu.types.ts | 2 + .../privileges/exposed-docs-entities.test.ts | 73 ++++++++++++++ .../data/privileges/exposed-docs-entities.ts | 55 +++++++++++ .../privileges/exposed-functions-query.ts | 46 +++++++++ .../data/privileges/exposed-tables-query.ts | 45 +++++++++ apps/studio/data/privileges/keys.ts | 4 + 13 files changed, 441 insertions(+), 41 deletions(-) create mode 100644 apps/studio/components/interfaces/ProjectAPIDocs/useApiDocsEntities.ts create mode 100644 apps/studio/components/ui/NotExposedEntitiesIndicator.tsx create mode 100644 apps/studio/data/privileges/exposed-docs-entities.test.ts create mode 100644 apps/studio/data/privileges/exposed-docs-entities.ts create mode 100644 apps/studio/data/privileges/exposed-functions-query.ts create mode 100644 apps/studio/data/privileges/exposed-tables-query.ts diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DocsMenu.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DocsMenu.tsx index e514c777660..4de0d5abef3 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/DocsMenu.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/DocsMenu.tsx @@ -75,6 +75,7 @@ export const DocsMenu = ({ ) })} + {group.footer} ))} diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx index 859d733578b..fac8a71615d 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx @@ -9,7 +9,11 @@ import { DocsMenu } from '@/components/interfaces/Integrations/DataApi/DocsMenu' import { DocsMobileNav } from '@/components/interfaces/Integrations/DataApi/DocsMobileNav' import { DocView } from '@/components/interfaces/Integrations/DataApi/DocView' import { generateDocsMenu, getActivePage } from '@/components/layouts/DocsLayout/DocsLayout.utils' +import { NotExposedEntitiesIndicator } from '@/components/ui/NotExposedEntitiesIndicator' import { useOpenAPISpecQuery } from '@/data/open-api/api-spec-query' +import { partitionExposedDocsEntities } from '@/data/privileges/exposed-docs-entities' +import { useExposedFunctionsQuery } from '@/data/privileges/exposed-functions-query' +import { useExposedTablesQuery } from '@/data/privileges/exposed-tables-query' import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -27,21 +31,42 @@ export const DataApiDocsTab = () => { const { isEnabled, isPending: isConfigLoading } = useIsDataApiEnabled({ projectRef }) - const { data: openApiSpec } = useOpenAPISpecQuery( - { projectRef }, - { - enabled: !!projectRef && !isPaused && isEnabled, - } + const dataApiEnabled = !!projectRef && !isPaused && isEnabled + + const { data: openApiSpec } = useOpenAPISpecQuery({ projectRef }, { enabled: dataApiEnabled }) + + // Cross-reference the spec against grant status so tables/functions that exist + // in the spec but aren't actually exposed to the Data API are hidden + counted. + const { data: exposedTables } = useExposedTablesQuery( + { projectRef, connectionString: project?.connectionString }, + { enabled: dataApiEnabled } + ) + const { data: exposedFunctions } = useExposedFunctionsQuery( + { projectRef, connectionString: project?.connectionString }, + { enabled: dataApiEnabled } ) - const tableNames = useMemo( - () => (openApiSpec?.tables ?? []).map((table) => table.name), - [openApiSpec] - ) - const functionNames = useMemo( - () => (openApiSpec?.functions ?? []).map((fn) => fn.name), - [openApiSpec] - ) + const { tableNames, excludedTablesCount } = useMemo(() => { + const { visibleEntities, excludedCount } = partitionExposedDocsEntities( + openApiSpec?.tables ?? [], + exposedTables + ) + return { + tableNames: visibleEntities.map((table) => table.name), + excludedTablesCount: excludedCount, + } + }, [openApiSpec?.tables, exposedTables]) + + const { functionNames, excludedFunctionsCount } = useMemo(() => { + const { visibleEntities, excludedCount } = partitionExposedDocsEntities( + openApiSpec?.functions ?? [], + exposedFunctions + ) + return { + functionNames: visibleEntities.map((fn) => fn.name), + excludedFunctionsCount: excludedCount, + } + }, [openApiSpec?.functions, exposedFunctions]) const activePage = useMemo(() => getActivePage({ page, resource, rpc }), [page, resource, rpc]) @@ -49,8 +74,51 @@ export const DataApiDocsTab = () => { const menu = useMemo(() => { if (!projectRef) return [] - return generateDocsMenu(projectRef, tableNames, functionNames, { authEnabled }, docsBasePath) - }, [projectRef, tableNames, functionNames, authEnabled, docsBasePath]) + const groups = generateDocsMenu( + projectRef, + tableNames, + functionNames, + { authEnabled }, + docsBasePath + ) + return groups.map((group) => { + if (group.key === 'tables' && excludedTablesCount > 0) { + return { + ...group, + footer: ( + + ), + } + } + if (group.key === 'functions' && excludedFunctionsCount > 0) { + return { + ...group, + footer: ( + + ), + } + } + return group + }) + }, [ + projectRef, + tableNames, + functionNames, + authEnabled, + docsBasePath, + excludedTablesCount, + excludedFunctionsCount, + ]) if (isConfigLoading) { return ( diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx index 1ac1f02f5bf..1535382868b 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx @@ -8,9 +8,10 @@ import { ShimmeringLoader } from 'ui-patterns' import { navigateToSection } from './Content/Content.utils' import { API_DOCS_CATEGORIES, DOCS_CONTENT, DOCS_MENU } from './ProjectAPIDocs.constants' +import { useApiDocsFunctions, useApiDocsTables } from './useApiDocsEntities' import { InfiniteListDefault, type RowComponentBaseProps } from '@/components/ui/InfiniteList' +import { NotExposedEntitiesIndicator } from '@/components/ui/NotExposedEntitiesIndicator' import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query' -import { useOpenAPISpecQuery } from '@/data/open-api/api-spec-query' import { usePaginatedBucketsQuery, type Bucket } from '@/data/storage/buckets-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { BASE_PATH, DOCS_URL } from '@/lib/constants' @@ -184,20 +185,15 @@ const Subsections = ({ category }: SubsectionsProps): ReactNode => { } const TablesSubsections = (): ReactNode => { - const { ref } = useParams() const snap = useAppStateSnapshot() - const { data, isLoading } = useOpenAPISpecQuery( - { projectRef: ref }, - { staleTime: 1000 * 60 * 10 } - ) - const tables = data?.tables ?? [] + const { visibleEntities: tables, excludedCount, isLoading } = useApiDocsTables() // TODO: handle infinite loading of tables return ( <> {isLoading && } - {tables.length > 0 && } + {(tables.length > 0 || excludedCount > 0) && } {tables.map((table) => ( ))} + snap.setShowProjectApiDocs(false)} + /> ) } const DbFunctionsSubsections = (): ReactNode => { - const { ref } = useParams() const snap = useAppStateSnapshot() - const { data, isLoading } = useOpenAPISpecQuery( - { projectRef: ref }, - { staleTime: 1000 * 60 * 10 } - ) - const functions = data?.functions ?? [] + const { visibleEntities: functions, excludedCount, isLoading } = useApiDocsFunctions() // TODO: handle virtualization of DB functions return ( <> {isLoading && } - {functions.length > 0 && } + {(functions.length > 0 || excludedCount > 0) && } {functions.map((fn) => ( ))} + snap.setShowProjectApiDocs(false)} + /> ) } diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/SecondLevelNav.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/SecondLevelNav.tsx index e0b3c9d1b7b..2f76da6a8d7 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/SecondLevelNav.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/SecondLevelNav.tsx @@ -5,19 +5,14 @@ import { API_DOCS_CATEGORIES } from './ProjectAPIDocs.constants' import { SecondLevelNavLayout, type MenuItemFilter } from './SecondLevelNav.Layout' import { ResourcePickerList } from './SecondLevelNav.ResourcePicker' import { StorageResourceList } from './SecondLevelNav.StoragePicker' +import { useApiDocsFunctions, useApiDocsTables } from './useApiDocsEntities' import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query' -import { useOpenAPISpecQuery } from '@/data/open-api/api-spec-query' import { useBucketInfoQueryPreferCached } from '@/data/storage/buckets-query' import { DOCS_URL } from '@/lib/constants' import { useAppStateSnapshot } from '@/state/app-state' -const OPEN_API_SPEC_STALE_TIME = 1000 * 60 * 10 - const EntitiesSecondLevelNav = () => { - const { ref } = useParams() - - const { data } = useOpenAPISpecQuery({ projectRef: ref }, { staleTime: OPEN_API_SPEC_STALE_TIME }) - const tables = data?.tables ?? [] + const { visibleEntities: tables } = useApiDocsTables() return ( { } const StoredProceduresSecondLevelNav = () => { - const { ref } = useParams() - - const { data } = useOpenAPISpecQuery({ projectRef: ref }, { staleTime: OPEN_API_SPEC_STALE_TIME }) - const functions = data?.functions ?? [] + const { visibleEntities: functions } = useApiDocsFunctions() return ( { + const { data: project } = useSelectedProjectQuery() + const projectRef = project?.ref + + const { data: spec, isLoading } = useOpenAPISpecQuery( + { projectRef }, + { staleTime: OPEN_API_SPEC_STALE_TIME } + ) + const { data: exposedTables } = useExposedTablesQuery({ + projectRef, + connectionString: project?.connectionString, + }) + + return useMemo( + () => ({ + ...partitionExposedDocsEntities(spec?.tables ?? [], exposedTables), + isLoading, + }), + [spec?.tables, exposedTables, isLoading] + ) +} + +/** + * Database functions shown in the autogenerated Data API docs, with unexposed + * (revoked) functions filtered out and counted. + */ +export const useApiDocsFunctions = () => { + const { data: project } = useSelectedProjectQuery() + const projectRef = project?.ref + + const { data: spec, isLoading } = useOpenAPISpecQuery( + { projectRef }, + { staleTime: OPEN_API_SPEC_STALE_TIME } + ) + const { data: exposedFunctions } = useExposedFunctionsQuery({ + projectRef, + connectionString: project?.connectionString, + }) + + return useMemo( + () => ({ + ...partitionExposedDocsEntities(spec?.functions ?? [], exposedFunctions), + isLoading, + }), + [spec?.functions, exposedFunctions, isLoading] + ) +} diff --git a/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx b/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx index d37fcf7ce12..a9c7448b3c7 100644 --- a/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx +++ b/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx @@ -48,6 +48,7 @@ export const generateDocsMenu = ( }, { title: 'Tables and Views', + key: 'tables', items: [ { name: 'Introduction', @@ -67,6 +68,7 @@ export const generateDocsMenu = ( }, { title: 'Functions', + key: 'functions', items: [ { name: 'Introduction', diff --git a/apps/studio/components/ui/NotExposedEntitiesIndicator.tsx b/apps/studio/components/ui/NotExposedEntitiesIndicator.tsx new file mode 100644 index 00000000000..428aca44a0a --- /dev/null +++ b/apps/studio/components/ui/NotExposedEntitiesIndicator.tsx @@ -0,0 +1,48 @@ +import { useParams } from 'common' +import Link from 'next/link' +import { type ReactNode } from 'react' +import { cn } from 'ui' + +interface NotExposedEntitiesIndicatorProps { + count: number + /** Singular noun, e.g. "table" or "function" */ + entityNoun: string + /** Plural noun, e.g. "tables" or "functions" */ + entityNounPlural: string + /** Optional callback fired when the link is clicked (e.g. to close a panel) */ + onNavigate?: () => void + /** Overrides the default layout classes (padding/size) — color/hover styling is always applied */ + className?: string +} + +/** + * Quiet link shown beneath a docs entity list when some entities are hidden + * because they aren't exposed to the Data API. Styled like a dimmer nav item and + * links to the Data API settings where the user can grant access. + */ +export const NotExposedEntitiesIndicator = ({ + count, + entityNoun, + entityNounPlural, + onNavigate, + className, +}: NotExposedEntitiesIndicatorProps): ReactNode => { + const { ref } = useParams() + + if (count <= 0) return null + + const noun = count === 1 ? entityNoun : entityNounPlural + + return ( + + {count} {noun} not exposed + + ) +} diff --git a/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts b/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts index 21cf7fd36bf..26b84896e6f 100644 --- a/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts +++ b/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts @@ -10,6 +10,8 @@ export interface ProductMenuGroup { name?: string items: ProductMenuGroupItem[] link?: string + /** Optional node rendered after the group's items (e.g. a footer note) */ + footer?: ReactNode } export interface ProductMenuGroupItem { diff --git a/apps/studio/data/privileges/exposed-docs-entities.test.ts b/apps/studio/data/privileges/exposed-docs-entities.test.ts new file mode 100644 index 00000000000..f31c7d9322d --- /dev/null +++ b/apps/studio/data/privileges/exposed-docs-entities.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' + +import { partitionExposedDocsEntities, type ExposedEntityStatus } from './exposed-docs-entities' + +type SpecEntity = { name: string } +type StatusItem = { name: string; status: ExposedEntityStatus } + +const spec = (...names: string[]): SpecEntity[] => names.map((name) => ({ name })) +const status = (name: string, status: ExposedEntityStatus): StatusItem => ({ name, status }) + +describe('partitionExposedDocsEntities', () => { + it('fails open when statuses are undefined (not yet loaded)', () => { + const specEntities = spec('todos', 'profiles') + const result = partitionExposedDocsEntities(specEntities, undefined) + expect(result.visibleEntities).toEqual(specEntities) + expect(result.excludedCount).toBe(0) + }) + + it('keeps all entities when every entity is fully granted', () => { + const result = partitionExposedDocsEntities(spec('todos', 'profiles'), [ + status('todos', 'granted'), + status('profiles', 'granted'), + ]) + expect(result.visibleEntities.map((e) => e.name)).toEqual(['todos', 'profiles']) + expect(result.excludedCount).toBe(0) + }) + + it('hides and counts revoked entities', () => { + const result = partitionExposedDocsEntities(spec('todos', 'secret'), [ + status('todos', 'granted'), + status('secret', 'revoked'), + ]) + expect(result.visibleEntities.map((e) => e.name)).toEqual(['todos']) + expect(result.excludedCount).toBe(1) + }) + + it('treats custom (partial) grants as exposed', () => { + const result = partitionExposedDocsEntities(spec('partial'), [status('partial', 'custom')]) + expect(result.visibleEntities.map((e) => e.name)).toEqual(['partial']) + expect(result.excludedCount).toBe(0) + }) + + it('keeps an entity when a same-named entity in another schema is accessible', () => { + // e.g. public.foo (granted) vs private.foo (revoked) — both surface as `foo` + const result = partitionExposedDocsEntities(spec('foo'), [ + status('foo', 'revoked'), + status('foo', 'granted'), + ]) + expect(result.visibleEntities.map((e) => e.name)).toEqual(['foo']) + expect(result.excludedCount).toBe(0) + }) + + it('fails open per-entity when a spec entity has no matching status', () => { + const result = partitionExposedDocsEntities(spec('unknown'), [status('other', 'revoked')]) + expect(result.visibleEntities.map((e) => e.name)).toEqual(['unknown']) + expect(result.excludedCount).toBe(0) + }) + + it('only counts revoked entities that appear in the spec', () => { + const result = partitionExposedDocsEntities(spec('todos'), [ + status('todos', 'granted'), + status('revoked_not_in_spec', 'revoked'), + ]) + expect(result.visibleEntities.map((e) => e.name)).toEqual(['todos']) + expect(result.excludedCount).toBe(0) + }) + + it('handles an empty spec list', () => { + const result = partitionExposedDocsEntities([], [status('todos', 'revoked')]) + expect(result.visibleEntities).toEqual([]) + expect(result.excludedCount).toBe(0) + }) +}) diff --git a/apps/studio/data/privileges/exposed-docs-entities.ts b/apps/studio/data/privileges/exposed-docs-entities.ts new file mode 100644 index 00000000000..37adbb18791 --- /dev/null +++ b/apps/studio/data/privileges/exposed-docs-entities.ts @@ -0,0 +1,55 @@ +export type ExposedEntityStatus = 'granted' | 'revoked' | 'custom' + +type ExposedEntityStatusItem = { + name: string + status: ExposedEntityStatus +} + +export type FilteredDocsEntities = { + visibleEntities: T[] + excludedCount: number +} + +/** + * Splits the entities listed in the PostgREST OpenAPI spec into those that are + * actually accessible via the Data API and those that are not. + * + * An entity is considered exposed when it has at least one API-role grant + * (`granted` or `custom`). It is excluded only when every entry matching its + * name is `revoked` (no API role has any privilege) — mirroring the + * granted/custom/revoked classification used by the Data API settings page. + * + * Fails open: when the grant statuses haven't loaded (or errored), all spec + * entities are returned so the docs are never blanked out. + */ +export function partitionExposedDocsEntities( + specEntities: T[], + exposedStatuses: ExposedEntityStatusItem[] | undefined +): FilteredDocsEntities { + if (!exposedStatuses) { + return { visibleEntities: specEntities, excludedCount: 0 } + } + + const accessibleNames = new Set() + const revokedNames = new Set() + for (const { name, status } of exposedStatuses) { + if (status === 'revoked') { + revokedNames.add(name) + } else { + accessibleNames.add(name) + } + } + + const visibleEntities: T[] = [] + let excludedCount = 0 + for (const entity of specEntities) { + const isExcluded = revokedNames.has(entity.name) && !accessibleNames.has(entity.name) + if (isExcluded) { + excludedCount += 1 + } else { + visibleEntities.push(entity) + } + } + + return { visibleEntities, excludedCount } +} diff --git a/apps/studio/data/privileges/exposed-functions-query.ts b/apps/studio/data/privileges/exposed-functions-query.ts new file mode 100644 index 00000000000..9b42de932af --- /dev/null +++ b/apps/studio/data/privileges/exposed-functions-query.ts @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query' + +import { + getExposedFunctions, + type ExposedFunction, + type ExposedFunctionsError, + type ExposedFunctionsVariables, +} from './exposed-functions-infinite-query' +import { privilegeKeys } from './keys' +import type { UseCustomQueryOptions } from '@/types' + +// The API Docs panel renders every function without virtualization, so we fetch +// the full grant-status list in a single request rather than paginating. +const ALL_EXPOSED_ENTITIES_LIMIT = 100_000 + +export type ExposedFunctionsAllData = ExposedFunction[] + +async function getAllExposedFunctions( + { projectRef, connectionString }: ExposedFunctionsVariables, + signal?: AbortSignal +): Promise { + const { functions } = await getExposedFunctions( + { projectRef, connectionString, page: 0, limit: ALL_EXPOSED_ENTITIES_LIMIT }, + signal + ) + return functions +} + +/** + * Returns the grant status (`granted` | `custom` | `revoked`) for every database + * function, used to filter unexposed functions out of the autogenerated Data API + * docs. + */ +export const useExposedFunctionsQuery = ( + { projectRef, connectionString }: ExposedFunctionsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: privilegeKeys.exposedFunctionsAll(projectRef), + queryFn: ({ signal }) => getAllExposedFunctions({ projectRef, connectionString }, signal), + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + }) diff --git a/apps/studio/data/privileges/exposed-tables-query.ts b/apps/studio/data/privileges/exposed-tables-query.ts new file mode 100644 index 00000000000..959d77a70b1 --- /dev/null +++ b/apps/studio/data/privileges/exposed-tables-query.ts @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query' + +import { + getExposedTables, + type ExposedTable, + type ExposedTablesError, + type ExposedTablesVariables, +} from './exposed-tables-infinite-query' +import { privilegeKeys } from './keys' +import type { UseCustomQueryOptions } from '@/types' + +// The API Docs panel renders every table without virtualization, so we fetch +// the full grant-status list in a single request rather than paginating. +const ALL_EXPOSED_ENTITIES_LIMIT = 100_000 + +export type ExposedTablesAllData = ExposedTable[] + +async function getAllExposedTables( + { projectRef, connectionString }: ExposedTablesVariables, + signal?: AbortSignal +): Promise { + const { tables } = await getExposedTables( + { projectRef, connectionString, page: 0, limit: ALL_EXPOSED_ENTITIES_LIMIT }, + signal + ) + return tables +} + +/** + * Returns the grant status (`granted` | `custom` | `revoked`) for every table, + * used to filter unexposed tables out of the autogenerated Data API docs. + */ +export const useExposedTablesQuery = ( + { projectRef, connectionString }: ExposedTablesVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: privilegeKeys.exposedTablesAll(projectRef), + queryFn: ({ signal }) => getAllExposedTables({ projectRef, connectionString }, signal), + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + }) diff --git a/apps/studio/data/privileges/keys.ts b/apps/studio/data/privileges/keys.ts index 873cb16e380..da4f5f43084 100644 --- a/apps/studio/data/privileges/keys.ts +++ b/apps/studio/data/privileges/keys.ts @@ -11,6 +11,10 @@ export const privilegeKeys = { 'exposed-tables-infinite', ...(search ? ([{ search }] as const) : []), ] as const, + exposedTablesAll: (projectRef: string | undefined) => + ['projects', projectRef, 'privileges', 'exposed-tables-all'] as const, + exposedFunctionsAll: (projectRef: string | undefined) => + ['projects', projectRef, 'privileges', 'exposed-functions-all'] as const, exposedTableCounts: (projectRef: string | undefined, selectedSchemas?: string[]) => [ 'projects',