mirror of
https://github.com/supabase/supabase.git
synced 2026-06-12 08:29:15 +08:00
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>
322 lines
9.8 KiB
TypeScript
322 lines
9.8 KiB
TypeScript
import { useParams } from 'common'
|
|
import { Book, BookOpen } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { Fragment, type ReactNode } from 'react'
|
|
import SVG from 'react-inlinesvg'
|
|
import { Button, cn } from 'ui'
|
|
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 { usePaginatedBucketsQuery, type Bucket } from '@/data/storage/buckets-query'
|
|
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
|
import { BASE_PATH, DOCS_URL } from '@/lib/constants'
|
|
import { useAppStateSnapshot } from '@/state/app-state'
|
|
|
|
type DocsSections = typeof DOCS_MENU
|
|
type DocsSection = DocsSections[number]
|
|
type DocsSectionsSubset = readonly DocsSection[]
|
|
type DocsCategory = DocsSection['key']
|
|
type DocsContentRegistry = typeof DOCS_CONTENT
|
|
type DocsSnippet = DocsContentRegistry[keyof DocsContentRegistry]
|
|
|
|
const Separator = () => <hr className="border-t mt-3! pb-1 mx-3" />
|
|
|
|
const MENU_BUTTON_CLASSES = cn(
|
|
'w-full px-4',
|
|
'text-left text-sm text-foreground-light',
|
|
'transition hover:text-foreground'
|
|
)
|
|
|
|
/**
|
|
* Gets the docs menu items based on feature flags.
|
|
* @returns An array of menu items to be displayed in the docs navigation.
|
|
*/
|
|
const useDocsMenu = (): DocsSectionsSubset => {
|
|
const {
|
|
projectAuthAll: authEnabled,
|
|
projectStorageAll: storageEnabled,
|
|
projectEdgeFunctionAll: edgeFunctionsEnabled,
|
|
realtimeAll: realtimeEnabled,
|
|
} = useIsFeatureEnabled([
|
|
'project_auth:all',
|
|
'project_storage:all',
|
|
'project_edge_function:all',
|
|
'realtime:all',
|
|
])
|
|
|
|
return DOCS_MENU.filter((item) => {
|
|
if (item.key === 'user-management') return authEnabled
|
|
if (item.key === 'storage') return storageEnabled
|
|
if (item.key === 'edge-functions') return edgeFunctionsEnabled
|
|
if (item.key === 'realtime') return realtimeEnabled
|
|
return true
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Gets the content snippets for a given documentation category.
|
|
* @param category - The category of documentation to retrieve snippets for.
|
|
* @returns An array of content snippets belonging to the specified category.
|
|
*/
|
|
const getSectionSnippets = (category: DocsCategory): DocsSnippet[] =>
|
|
Object.values(DOCS_CONTENT).filter((snippet) => snippet.category === category)
|
|
|
|
export const FirstLevelNav = (): ReactNode => {
|
|
const { ref } = useParams()
|
|
|
|
const snap = useAppStateSnapshot()
|
|
const currentSection = snap.activeDocsSection[0]
|
|
|
|
const docsMenu = useDocsMenu()
|
|
|
|
return (
|
|
<>
|
|
<nav aria-labelledby="api-docs-rest-categories" className="px-2 py-4 border-b">
|
|
<h2 id="api-docs-rest-categories" className="sr-only">
|
|
REST API Docs
|
|
</h2>
|
|
{docsMenu.map((item) => {
|
|
const isActive = currentSection === item.key
|
|
|
|
return (
|
|
<Fragment key={item.key}>
|
|
<button
|
|
aria-current={isActive ? 'page' : undefined}
|
|
className={cn(
|
|
'w-full px-3 py-2 rounded-md',
|
|
'text-left text-sm',
|
|
'transition',
|
|
isActive && 'bg-surface-300'
|
|
)}
|
|
onClick={() => snap.setActiveDocsSection([item.key])}
|
|
>
|
|
{item.name}
|
|
</button>
|
|
{isActive && <Subsections category={item.key} />}
|
|
</Fragment>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
<div className="px-2 py-4 border-b">
|
|
<Button
|
|
block
|
|
asChild
|
|
type="text"
|
|
size="small"
|
|
icon={
|
|
<SVG
|
|
src={`${BASE_PATH}/img/graphql.svg`}
|
|
style={{ width: `${16}px`, height: `${16}px` }}
|
|
className="text-foreground"
|
|
preProcessor={(code) => code.replace(/svg/, 'svg class="m-auto text-color-inherit"')}
|
|
/>
|
|
}
|
|
onClick={() => snap.setShowProjectApiDocs(false)}
|
|
>
|
|
<Link className="justify-start!" href={`/project/${ref}/integrations/graphiql`}>
|
|
GraphiQL
|
|
</Link>
|
|
</Button>
|
|
<Button block asChild type="text" size="small" icon={<BookOpen />}>
|
|
<Link
|
|
href={`${DOCS_URL}/guides/graphql`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="justify-start!"
|
|
>
|
|
GraphQL guide
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="px-2 py-4">
|
|
<Button block asChild type="text" size="small" icon={<Book />}>
|
|
<Link href={`${DOCS_URL}`} target="_blank" rel="noreferrer" className="justify-start!">
|
|
Documentation
|
|
</Link>
|
|
</Button>
|
|
<Button block asChild type="text" size="small" icon={<BookOpen />}>
|
|
<Link
|
|
href={`${DOCS_URL}/guides/api`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="justify-start!"
|
|
>
|
|
REST guide
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
type SubsectionsProps = {
|
|
category: DocsCategory
|
|
}
|
|
|
|
const Subsections = ({ category }: SubsectionsProps): ReactNode => {
|
|
const snippets = getSectionSnippets(category)
|
|
|
|
return (
|
|
<div className="space-y-2 py-2">
|
|
{snippets.map((snippet) => (
|
|
<button
|
|
key={snippet.key}
|
|
className={MENU_BUTTON_CLASSES}
|
|
onClick={() => {
|
|
navigateToSection(snippet.key)
|
|
}}
|
|
>
|
|
{snippet.title}
|
|
</button>
|
|
))}
|
|
{category === API_DOCS_CATEGORIES.ENTITIES && <TablesSubsections />}
|
|
{category === API_DOCS_CATEGORIES.STORED_PROCEDURES && <DbFunctionsSubsections />}
|
|
{category === API_DOCS_CATEGORIES.STORAGE && <StorageSubsections />}
|
|
{category === API_DOCS_CATEGORIES.EDGE_FUNCTIONS && <EdgeFunctionsSubsections />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const TablesSubsections = (): ReactNode => {
|
|
const snap = useAppStateSnapshot()
|
|
|
|
const { visibleEntities: tables, excludedCount, isLoading } = useApiDocsTables()
|
|
|
|
// TODO: handle infinite loading of tables
|
|
return (
|
|
<>
|
|
{isLoading && <LoadingIndicator />}
|
|
{(tables.length > 0 || excludedCount > 0) && <Separator />}
|
|
{tables.map((table) => (
|
|
<button
|
|
key={table.name}
|
|
className={MENU_BUTTON_CLASSES}
|
|
onClick={() => snap.setActiveDocsSection([API_DOCS_CATEGORIES.ENTITIES, table.name])}
|
|
>
|
|
{table.name}
|
|
</button>
|
|
))}
|
|
<NotExposedEntitiesIndicator
|
|
count={excludedCount}
|
|
entityNoun="table"
|
|
entityNounPlural="tables"
|
|
onNavigate={() => snap.setShowProjectApiDocs(false)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const DbFunctionsSubsections = (): ReactNode => {
|
|
const snap = useAppStateSnapshot()
|
|
|
|
const { visibleEntities: functions, excludedCount, isLoading } = useApiDocsFunctions()
|
|
|
|
// TODO: handle virtualization of DB functions
|
|
return (
|
|
<>
|
|
{isLoading && <LoadingIndicator />}
|
|
{(functions.length > 0 || excludedCount > 0) && <Separator />}
|
|
{functions.map((fn) => (
|
|
<button
|
|
key={fn.name}
|
|
className={MENU_BUTTON_CLASSES}
|
|
onClick={() =>
|
|
snap.setActiveDocsSection([API_DOCS_CATEGORIES.STORED_PROCEDURES, fn.name])
|
|
}
|
|
>
|
|
{fn.name}
|
|
</button>
|
|
))}
|
|
<NotExposedEntitiesIndicator
|
|
count={excludedCount}
|
|
entityNoun="function"
|
|
entityNounPlural="functions"
|
|
onNavigate={() => snap.setShowProjectApiDocs(false)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const BucketButton = ({ item: bucket, style }: RowComponentBaseProps<Bucket>) => {
|
|
const snap = useAppStateSnapshot()
|
|
|
|
return (
|
|
<button
|
|
key={bucket.name}
|
|
className={cn(MENU_BUTTON_CLASSES, 'py-1')}
|
|
style={style}
|
|
onClick={() => snap.setActiveDocsSection([API_DOCS_CATEGORIES.STORAGE, bucket.name])}
|
|
>
|
|
{bucket.name}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
const StorageSubsections = (): ReactNode => {
|
|
const { ref } = useParams()
|
|
|
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
|
usePaginatedBucketsQuery({
|
|
projectRef: ref,
|
|
})
|
|
const buckets = data?.pages.flatMap((page) => page) ?? []
|
|
|
|
return (
|
|
<>
|
|
{isLoading && <LoadingIndicator />}
|
|
{buckets.length > 0 && <Separator />}
|
|
<InfiniteListDefault
|
|
className="max-h-80"
|
|
items={buckets}
|
|
getItemKey={(idx) => buckets[idx]?.name}
|
|
getItemSize={() => 28}
|
|
hasNextPage={!!hasNextPage}
|
|
isLoadingNextPage={isFetchingNextPage}
|
|
onLoadNextPage={fetchNextPage}
|
|
ItemComponent={BucketButton}
|
|
LoaderComponent={({ style }) => <LoadingIndicator style={{ ...style, width: '75%' }} />}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const EdgeFunctionsSubsections = (): ReactNode => {
|
|
const { ref } = useParams()
|
|
const snap = useAppStateSnapshot()
|
|
|
|
const { data: edgeFunctions, isLoading } = useEdgeFunctionsQuery({ projectRef: ref })
|
|
|
|
// TODO: handle virtualization of edge functions
|
|
return (
|
|
<>
|
|
{isLoading && <LoadingIndicator />}
|
|
{(edgeFunctions ?? []).length > 0 && <Separator />}
|
|
{(edgeFunctions ?? []).map((fn) => (
|
|
<button
|
|
key={fn.name}
|
|
className={MENU_BUTTON_CLASSES}
|
|
onClick={() => snap.setActiveDocsSection([API_DOCS_CATEGORIES.EDGE_FUNCTIONS, fn.name])}
|
|
>
|
|
{fn.name}
|
|
</button>
|
|
))}
|
|
</>
|
|
)
|
|
}
|
|
|
|
type LoadingIndicatorProps = {
|
|
className?: string
|
|
style?: React.CSSProperties
|
|
}
|
|
|
|
const LoadingIndicator = ({ className, style }: LoadingIndicatorProps) => (
|
|
<ShimmeringLoader style={style} className={cn('mx-2', className)} />
|
|
)
|