mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
[FE-3496] feat(studio): hide unexposed tables from Data API docs (#46508)
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`. <img width="259" height="272" alt="Screenshot 2026-06-01 at 5 48 21 PM" src="https://github.com/user-attachments/assets/d2af86f2-5436-4e94-8295-83ecc74a77d9" /> **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 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
1 parent
ac79b1bc81
commit
ea695fdfa9
13 files changed
+441
-41
No files matched your search
@@ -75,6 +75,7 @@ export const DocsMenu = ({
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{group.footer}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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: (
|
||||
<NotExposedEntitiesIndicator
|
||||
count={excludedTablesCount}
|
||||
entityNoun="table"
|
||||
entityNounPlural="tables"
|
||||
className="pt-1"
|
||||
/>
|
||||
),
|
||||
}
|
||||
}
|
||||
if (group.key === 'functions' && excludedFunctionsCount > 0) {
|
||||
return {
|
||||
...group,
|
||||
footer: (
|
||||
<NotExposedEntitiesIndicator
|
||||
count={excludedFunctionsCount}
|
||||
entityNoun="function"
|
||||
entityNounPlural="functions"
|
||||
className="pt-1"
|
||||
/>
|
||||
),
|
||||
}
|
||||
}
|
||||
return group
|
||||
})
|
||||
}, [
|
||||
projectRef,
|
||||
tableNames,
|
||||
functionNames,
|
||||
authEnabled,
|
||||
docsBasePath,
|
||||
excludedTablesCount,
|
||||
excludedFunctionsCount,
|
||||
])
|
||||
|
||||
if (isConfigLoading) {
|
||||
return (
|
||||
|
||||
@@ -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 && <LoadingIndicator />}
|
||||
{tables.length > 0 && <Separator />}
|
||||
{(tables.length > 0 || excludedCount > 0) && <Separator />}
|
||||
{tables.map((table) => (
|
||||
<button
|
||||
key={table.name}
|
||||
@@ -207,25 +203,26 @@ const TablesSubsections = (): ReactNode => {
|
||||
{table.name}
|
||||
</button>
|
||||
))}
|
||||
<NotExposedEntitiesIndicator
|
||||
count={excludedCount}
|
||||
entityNoun="table"
|
||||
entityNounPlural="tables"
|
||||
onNavigate={() => 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 && <LoadingIndicator />}
|
||||
{functions.length > 0 && <Separator />}
|
||||
{(functions.length > 0 || excludedCount > 0) && <Separator />}
|
||||
{functions.map((fn) => (
|
||||
<button
|
||||
key={fn.name}
|
||||
@@ -237,6 +234,12 @@ const DbFunctionsSubsections = (): ReactNode => {
|
||||
{fn.name}
|
||||
</button>
|
||||
))}
|
||||
<NotExposedEntitiesIndicator
|
||||
count={excludedCount}
|
||||
entityNoun="function"
|
||||
entityNounPlural="functions"
|
||||
onNavigate={() => snap.setShowProjectApiDocs(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<SecondLevelNavLayout
|
||||
@@ -32,10 +27,7 @@ const EntitiesSecondLevelNav = () => {
|
||||
}
|
||||
|
||||
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 (
|
||||
<SecondLevelNavLayout
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
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 { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
const OPEN_API_SPEC_STALE_TIME = 1000 * 60 * 10
|
||||
|
||||
/**
|
||||
* Tables shown in the autogenerated Data API docs, with unexposed (revoked)
|
||||
* tables filtered out and counted.
|
||||
*/
|
||||
export const useApiDocsTables = () => {
|
||||
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]
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
<Link
|
||||
href={`/project/${ref}/integrations/data_api/settings`}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'block text-foreground-lighter transition hover:text-foreground',
|
||||
className ?? 'px-4 pt-2 text-sm'
|
||||
)}
|
||||
>
|
||||
{count} {noun} not exposed
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
export type ExposedEntityStatus = 'granted' | 'revoked' | 'custom'
|
||||
|
||||
type ExposedEntityStatusItem = {
|
||||
name: string
|
||||
status: ExposedEntityStatus
|
||||
}
|
||||
|
||||
export type FilteredDocsEntities<T> = {
|
||||
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<T extends { name: string }>(
|
||||
specEntities: T[],
|
||||
exposedStatuses: ExposedEntityStatusItem[] | undefined
|
||||
): FilteredDocsEntities<T> {
|
||||
if (!exposedStatuses) {
|
||||
return { visibleEntities: specEntities, excludedCount: 0 }
|
||||
}
|
||||
|
||||
const accessibleNames = new Set<string>()
|
||||
const revokedNames = new Set<string>()
|
||||
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 }
|
||||
}
|
||||
@@ -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<ExposedFunctionsAllData> {
|
||||
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 = <TData = ExposedFunctionsAllData>(
|
||||
{ projectRef, connectionString }: ExposedFunctionsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<ExposedFunctionsAllData, ExposedFunctionsError, TData> = {}
|
||||
) =>
|
||||
useQuery<ExposedFunctionsAllData, ExposedFunctionsError, TData>({
|
||||
queryKey: privilegeKeys.exposedFunctionsAll(projectRef),
|
||||
queryFn: ({ signal }) => getAllExposedFunctions({ projectRef, connectionString }, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
...options,
|
||||
})
|
||||
@@ -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<ExposedTablesAllData> {
|
||||
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 = <TData = ExposedTablesAllData>(
|
||||
{ projectRef, connectionString }: ExposedTablesVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<ExposedTablesAllData, ExposedTablesError, TData> = {}
|
||||
) =>
|
||||
useQuery<ExposedTablesAllData, ExposedTablesError, TData>({
|
||||
queryKey: privilegeKeys.exposedTablesAll(projectRef),
|
||||
queryFn: ({ signal }) => getAllExposedTables({ projectRef, connectionString }, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
...options,
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user