Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/pr-1561.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- auto-generated -->
---
'@sanity/cli': minor
---

feat: add `sanity api` command for direct HTTP API access
1 change: 1 addition & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
".release-please-manifest.json",
"**/tsconfig.json",
"**/__snapshots__/**",
"**/src/generated/**",
".claude",
".changeset",
"**/dist/**",
Expand Down
88 changes: 88 additions & 0 deletions packages/@sanity/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Code for sanity cli

<!-- commands -->

- [`sanity api ENDPOINT`](#sanity-api-endpoint)
- [`sanity backups disable [DATASET]`](#sanity-backups-disable-dataset)
- [`sanity backups download [DATASET]`](#sanity-backups-download-dataset)
- [`sanity backups enable [DATASET]`](#sanity-backups-enable-dataset)
Expand Down Expand Up @@ -122,6 +123,93 @@ Code for sanity cli
- [`sanity users list`](#sanity-users-list)
- [`sanity versions`](#sanity-versions)

## `sanity api ENDPOINT`

Make an authenticated HTTP request to a Sanity API

```
USAGE
$ sanity api ENDPOINT [-p <id>] [-d <name>] [--api-version <version>] [--global | --project-hosted] [-H
<key:value>...] [-i] [--input <file> | -F <key=value>... | -f <key=value>...] [-X <method>] [--pretty] [-t <token> |
--anonymous]

ARGUMENTS
ENDPOINT API path (eg "projects" or "data/query/{dataset}"), optionally with placeholders, or a full
https://*.api.sanity.io URL

FLAGS
-F, --field=<key=value>... Add a typed parameter (key=value): true/false/null and numbers are converted, @file
reads the value from a file, @- from stdin
-H, --header=<key:value>... Add an HTTP request header (key: value)
-X, --method=<method> HTTP method to use (default GET, or POST when fields or --input are provided)
-f, --raw-field=<key=value>... Add a string parameter (key=value)
-i, --include Include the HTTP response status and headers in the output
-t, --token=<token> API token to authenticate with, instead of the logged-in user token
--anonymous Send the request without an authorization token
--api-version=<version> API version to use (eg v2025-02-19). Defaults to a version embedded in the endpoint
path, or the version from the matching OpenAPI spec
--global Force the request to the global API host (api.sanity.io)
--input=<file> Read the raw request body from a file (use "-" for stdin). Sent without a default
Content-Type - provide one with -H when the API requires it
--pretty Colorize JSON output
--project-hosted Force the request to the project API host (<projectId>.api.sanity.io)

OVERRIDE FLAGS
-d, --dataset=<name> Dataset for {dataset} placeholders (overrides CLI configuration)
-p, --project-id=<id> Project ID for {projectId} placeholders and project-hosted APIs (overrides CLI configuration)

DESCRIPTION
Make an authenticated HTTP request to a Sanity API

The endpoint argument is an API path as documented in the published OpenAPI
specifications - list them with "sanity openapi list" and inspect one with
"sanity openapi get <slug>". Paths can be copied verbatim from the specs:
{projectId} and {dataset} placeholders are filled in from flags or the CLI
configuration, and the API host (api.sanity.io or <projectId>.api.sanity.io)
is chosen based on the specs' routing information.

The default request method is GET, or POST when fields or --input are
provided. For GET/HEAD requests, fields are sent as query parameters;
otherwise they are combined into a JSON request body sent with
"Content-Type: application/json". Raw --input bodies are sent without a
default Content-Type - provide one with -H when the API requires it. The
response body is written to stdout.

Requests are authenticated with the token from "sanity login". To use a
specific token instead - for example in CI or when the CLI is not logged in
- pass --token or set the SANITY_AUTH_TOKEN environment variable. Pass
--anonymous to send no token at all.

EXAMPLES
Get the current user

$ sanity api users/me

Get the current project (placeholder filled from CLI config)

$ sanity api projects/{projectId}

Run a GROQ query against the project host

$ sanity api 'data/query/{dataset}' -f query='*[_type == "movie"][0..2]'

Send a JSON body built from typed fields

$ sanity api projects/{projectId} -X PATCH -F displayName="My project"

Send a raw request body from stdin

echo '{"mutations": []}' | sanity api 'data/mutate/{dataset}' --input - -H 'Content-Type: application/json'

Include the response status and headers, pinning the API version

$ sanity api jobs/123 --include --api-version v2025-02-19

Authenticate with a specific token instead of the logged-in session

SANITY_AUTH_TOKEN=<token> sanity api users/me
Comment thread
jonahsnider marked this conversation as resolved.
```

## `sanity backups disable [DATASET]`

Disable backup for a dataset
Expand Down
2 changes: 2 additions & 0 deletions packages/@sanity/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,13 @@
"access": "public"
},
"scripts": {
"prebuild": "pnpm run generate:api-routes",
"build": "swc --delete-dir-on-start --strip-leading-paths --out-dir dist/ src",
"postbuild": "pnpm run manifest:generate && pnpm run check:topic-aliases",
"build:types": "pkg-utils build --emitDeclarationOnly",
"check:topic-aliases": "tsx scripts/check-topic-aliases.ts",
"check:types": "tsc --noEmit",
"generate:api-routes": "tsx scripts/generate-api-routes.ts",
"lint": "eslint .",
"manifest:generate": "oclif manifest",
"manifest:remove": "rimraf oclif.manifest.json",
Expand Down
137 changes: 137 additions & 0 deletions packages/@sanity/cli/scripts/generate-api-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* API Routing Manifest Generator
*
* Fetches the published OpenAPI specification index (the same source as
* `sanity openapi list|get`) and distills it into the routing manifest used
* by `sanity api` to decide which host serves a request path and which API
* version to default to.
*
* The manifest is generated - not hand-maintained - so the set of APIs
* reachable through `sanity api` follows the published specs. It runs on
* every build (prebuild hook) and fails the build when the specs can't be
* fetched or the distilled manifest would be empty, so a release can never
* ship with a missing or empty endpoint list.
*
* Regenerate: tsx scripts/generate-api-routes.ts
* Verify freshness: tsx scripts/generate-api-routes.ts --check
*/

/* eslint-disable no-console */
import {readFileSync, writeFileSync} from 'node:fs'
import {join} from 'node:path'
import {setTimeout as sleep} from 'node:timers/promises'
import {parseArgs} from 'node:util'

import pMap from 'p-map'

import {OPENAPI_SPEC_INDEX_URL} from '../src/actions/api/constants.ts'
import {distillApiRoutes, type SpecSource} from '../src/actions/api/distillApiRoutes.ts'
import {type ApiRouteEntry, type OpenApiDocument} from '../src/actions/api/types.ts'

const OUTPUT_PATH = join(import.meta.dirname, '..', 'src', 'generated', 'apiRoutes.ts')

const FETCH_TIMEOUT_MS = 30_000
const FETCH_ATTEMPTS = 3
const FETCH_CONCURRENCY = 6

interface SpecIndexEntry {
slug: string
title: string
}

async function fetchJson<T>(url: string): Promise<T> {
let lastError: unknown
for (let attempt = 1; attempt <= FETCH_ATTEMPTS; attempt++) {
try {
const response = await fetch(url, {signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)})
if (!response.ok) {
throw new Error(`GET ${url} responded with HTTP ${response.status}`)
}
return (await response.json()) as T
} catch (error) {
lastError = error
if (attempt < FETCH_ATTEMPTS) {
const delayMs = 1000 * 2 ** (attempt - 1)
console.warn(
`Fetch failed (attempt ${attempt}/${FETCH_ATTEMPTS}), retrying in ${delayMs}ms: ${url}`,
)
await sleep(delayMs)
}
}
}
throw lastError
}

async function fetchSpecSources(): Promise<SpecSource[]> {
const index = await fetchJson<{specs?: SpecIndexEntry[]}>(OPENAPI_SPEC_INDEX_URL)
const specs = index.specs ?? []
if (specs.length === 0) {
throw new Error(`No OpenAPI specs found at ${OPENAPI_SPEC_INDEX_URL}`)
}

return pMap(
specs,
async ({slug, title}): Promise<SpecSource> => {
console.log(`Fetching spec: ${slug}`)
const document = await fetchJson<OpenApiDocument>(
`${OPENAPI_SPEC_INDEX_URL}/${slug}?format=json`,
)
return {document, slug, title}
},
{concurrency: FETCH_CONCURRENCY},
)
}

function renderManifest(routes: ApiRouteEntry[]): string {
const serialized = JSON.stringify(routes, null, 2)
// Match the repo code style (oxfmt): single-quoted strings.
.replaceAll(/"((?:[^"\\]|\\.)*)":/g, (_, key: string) => `${key}:`)
.replaceAll(
/"((?:[^"\\]|\\.)*)"/g,
(_, value: string) => `'${value.replaceAll("'", String.raw`\'`)}'`,
)

return `/**
* GENERATED FILE - DO NOT EDIT
*
* Routing manifest for \`sanity api\`, distilled from the published OpenAPI
* specifications at ${OPENAPI_SPEC_INDEX_URL}
*
* Regenerate with: pnpm generate:api-routes
*/
import {type ApiRouteEntry} from '../actions/api/types.js'

export const apiRoutes: ApiRouteEntry[] = ${serialized}
`
}

const {values: args} = parseArgs({options: {check: {default: false, type: 'boolean'}}})

const sources = await fetchSpecSources()
const routes = distillApiRoutes(sources)
if (routes.length === 0) {
throw new Error(
`Distilled API routing manifest is empty (${sources.length} specs fetched) - refusing to write an empty endpoint list`,
)
}
const rendered = renderManifest(routes)

if (args.check) {
let existing = ''
try {
existing = readFileSync(OUTPUT_PATH, 'utf8')
} catch {
// Missing file is stale by definition
}
if (existing === rendered) {
console.log('API routing manifest is up to date.')
} else {
console.error(
`API routing manifest is stale. Run "pnpm generate:api-routes" and commit the result.`,
)
process.exitCode = 1
}
} else {
writeFileSync(OUTPUT_PATH, rendered)
console.log(`Wrote ${routes.length} route entries to ${OUTPUT_PATH}`)
}
Loading
Loading