@sanity/cli 7.12.1 → 7.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -0
- package/dist/actions/api/constants.js +13 -0
- package/dist/actions/api/constants.js.map +1 -0
- package/dist/actions/api/distillApiRoutes.js +106 -0
- package/dist/actions/api/distillApiRoutes.js.map +1 -0
- package/dist/actions/api/errors.js +18 -0
- package/dist/actions/api/errors.js.map +1 -0
- package/dist/actions/api/parseFields.js +183 -0
- package/dist/actions/api/parseFields.js.map +1 -0
- package/dist/actions/api/resolveEndpoint.js +159 -0
- package/dist/actions/api/resolveEndpoint.js.map +1 -0
- package/dist/actions/api/types.js +10 -0
- package/dist/actions/api/types.js.map +1 -0
- package/dist/actions/auth/getProviderName.js +13 -0
- package/dist/actions/auth/getProviderName.js.map +1 -1
- package/dist/actions/build/buildApp.js +3 -1
- package/dist/actions/build/buildApp.js.map +1 -1
- package/dist/actions/build/buildStudio.js +104 -13
- package/dist/actions/build/buildStudio.js.map +1 -1
- package/dist/actions/build/eventListenerFactory.js +70 -0
- package/dist/actions/build/eventListenerFactory.js.map +1 -0
- package/dist/actions/debug/types.js.map +1 -1
- package/dist/actions/deploy/deployApp.js +1 -0
- package/dist/actions/deploy/deployApp.js.map +1 -1
- package/dist/actions/deploy/deployChecks.js +25 -4
- package/dist/actions/deploy/deployChecks.js.map +1 -1
- package/dist/actions/deploy/resolveDeployTarget.js +23 -4
- package/dist/actions/deploy/resolveDeployTarget.js.map +1 -1
- package/dist/actions/dev/servers/startStudioDevServer.js +5 -1
- package/dist/actions/dev/servers/startStudioDevServer.js.map +1 -1
- package/dist/actions/init/initAction.js +3 -3
- package/dist/actions/init/initAction.js.map +1 -1
- package/dist/commands/api.js +294 -0
- package/dist/commands/api.js.map +1 -0
- package/dist/commands/cors/add.js +13 -7
- package/dist/commands/cors/add.js.map +1 -1
- package/dist/commands/datasets/copy.js +6 -3
- package/dist/commands/datasets/copy.js.map +1 -1
- package/dist/commands/debug.js +1 -1
- package/dist/commands/debug.js.map +1 -1
- package/dist/exports/invokeSanityCli/commandPolicies/index.js +6 -0
- package/dist/exports/invokeSanityCli/commandPolicies/index.js.map +1 -0
- package/dist/exports/invokeSanityCli/commandPolicies/mcpPolicy.js +167 -0
- package/dist/exports/invokeSanityCli/commandPolicies/mcpPolicy.js.map +1 -0
- package/dist/exports/invokeSanityCli/commandPolicies/policy.js +46 -0
- package/dist/exports/invokeSanityCli/commandPolicies/policy.js.map +1 -0
- package/dist/exports/invokeSanityCli/help.js +146 -0
- package/dist/exports/invokeSanityCli/help.js.map +1 -0
- package/dist/exports/invokeSanityCli/index.d.ts +60 -0
- package/dist/exports/invokeSanityCli/index.js +177 -0
- package/dist/exports/invokeSanityCli/index.js.map +1 -0
- package/dist/generated/apiRoutes.js +401 -0
- package/dist/generated/apiRoutes.js.map +1 -0
- package/dist/server/devServer.js +16 -2
- package/dist/server/devServer.js.map +1 -1
- package/dist/services/api.js +116 -0
- package/dist/services/api.js.map +1 -0
- package/dist/util/getProjectDefaults.js +4 -1
- package/dist/util/getProjectDefaults.js.map +1 -1
- package/dist/util/packageManager/preferredPm.js +45 -6
- package/dist/util/packageManager/preferredPm.js.map +1 -1
- package/oclif.manifest.json +709 -528
- package/package.json +14 -7
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ Code for sanity cli
|
|
|
13
13
|
|
|
14
14
|
<!-- commands -->
|
|
15
15
|
|
|
16
|
+
- [`sanity api ENDPOINT`](#sanity-api-endpoint)
|
|
16
17
|
- [`sanity backups disable [DATASET]`](#sanity-backups-disable-dataset)
|
|
17
18
|
- [`sanity backups download [DATASET]`](#sanity-backups-download-dataset)
|
|
18
19
|
- [`sanity backups enable [DATASET]`](#sanity-backups-enable-dataset)
|
|
@@ -122,6 +123,93 @@ Code for sanity cli
|
|
|
122
123
|
- [`sanity users list`](#sanity-users-list)
|
|
123
124
|
- [`sanity versions`](#sanity-versions)
|
|
124
125
|
|
|
126
|
+
## `sanity api ENDPOINT`
|
|
127
|
+
|
|
128
|
+
Make an authenticated HTTP request to a Sanity API
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
USAGE
|
|
132
|
+
$ sanity api ENDPOINT [-p <id>] [-d <name>] [--api-version <version>] [--global | --project-hosted] [-H
|
|
133
|
+
<key:value>...] [-i] [--input <file> | -F <key=value>... | -f <key=value>...] [-X <method>] [--pretty] [-t <token> |
|
|
134
|
+
--anonymous]
|
|
135
|
+
|
|
136
|
+
ARGUMENTS
|
|
137
|
+
ENDPOINT API path (eg "projects" or "data/query/{dataset}"), optionally with placeholders, or a full
|
|
138
|
+
https://*.api.sanity.io URL
|
|
139
|
+
|
|
140
|
+
FLAGS
|
|
141
|
+
-F, --field=<key=value>... Add a typed parameter (key=value): true/false/null and numbers are converted, @file
|
|
142
|
+
reads the value from a file, @- from stdin
|
|
143
|
+
-H, --header=<key:value>... Add an HTTP request header (key: value)
|
|
144
|
+
-X, --method=<method> HTTP method to use (default GET, or POST when fields or --input are provided)
|
|
145
|
+
-f, --raw-field=<key=value>... Add a string parameter (key=value)
|
|
146
|
+
-i, --include Include the HTTP response status and headers in the output
|
|
147
|
+
-t, --token=<token> API token to authenticate with, instead of the logged-in user token
|
|
148
|
+
--anonymous Send the request without an authorization token
|
|
149
|
+
--api-version=<version> API version to use (eg v2025-02-19). Defaults to a version embedded in the endpoint
|
|
150
|
+
path, or the version from the matching OpenAPI spec
|
|
151
|
+
--global Force the request to the global API host (api.sanity.io)
|
|
152
|
+
--input=<file> Read the raw request body from a file (use "-" for stdin). Sent without a default
|
|
153
|
+
Content-Type - provide one with -H when the API requires it
|
|
154
|
+
--pretty Colorize JSON output
|
|
155
|
+
--project-hosted Force the request to the project API host (<projectId>.api.sanity.io)
|
|
156
|
+
|
|
157
|
+
OVERRIDE FLAGS
|
|
158
|
+
-d, --dataset=<name> Dataset for {dataset} placeholders (overrides CLI configuration)
|
|
159
|
+
-p, --project-id=<id> Project ID for {projectId} placeholders and project-hosted APIs (overrides CLI configuration)
|
|
160
|
+
|
|
161
|
+
DESCRIPTION
|
|
162
|
+
Make an authenticated HTTP request to a Sanity API
|
|
163
|
+
|
|
164
|
+
The endpoint argument is an API path as documented in the published OpenAPI
|
|
165
|
+
specifications - list them with "sanity openapi list" and inspect one with
|
|
166
|
+
"sanity openapi get <slug>". Paths can be copied verbatim from the specs:
|
|
167
|
+
{projectId} and {dataset} placeholders are filled in from flags or the CLI
|
|
168
|
+
configuration, and the API host (api.sanity.io or <projectId>.api.sanity.io)
|
|
169
|
+
is chosen based on the specs' routing information.
|
|
170
|
+
|
|
171
|
+
The default request method is GET, or POST when fields or --input are
|
|
172
|
+
provided. For GET/HEAD requests, fields are sent as query parameters;
|
|
173
|
+
otherwise they are combined into a JSON request body sent with
|
|
174
|
+
"Content-Type: application/json". Raw --input bodies are sent without a
|
|
175
|
+
default Content-Type - provide one with -H when the API requires it. The
|
|
176
|
+
response body is written to stdout.
|
|
177
|
+
|
|
178
|
+
Requests are authenticated with the token from "sanity login". To use a
|
|
179
|
+
specific token instead - for example in CI or when the CLI is not logged in
|
|
180
|
+
- pass --token or set the SANITY_AUTH_TOKEN environment variable. Pass
|
|
181
|
+
--anonymous to send no token at all.
|
|
182
|
+
|
|
183
|
+
EXAMPLES
|
|
184
|
+
Get the current user
|
|
185
|
+
|
|
186
|
+
$ sanity api users/me
|
|
187
|
+
|
|
188
|
+
Get the current project (placeholder filled from CLI config)
|
|
189
|
+
|
|
190
|
+
$ sanity api projects/{projectId}
|
|
191
|
+
|
|
192
|
+
Run a GROQ query against the project host
|
|
193
|
+
|
|
194
|
+
$ sanity api 'data/query/{dataset}' -f query='*[_type == "movie"][0..2]'
|
|
195
|
+
|
|
196
|
+
Send a JSON body built from typed fields
|
|
197
|
+
|
|
198
|
+
$ sanity api projects/{projectId} -X PATCH -F displayName="My project"
|
|
199
|
+
|
|
200
|
+
Send a raw request body from stdin
|
|
201
|
+
|
|
202
|
+
echo '{"mutations": []}' | sanity api 'data/mutate/{dataset}' --input - -H 'Content-Type: application/json'
|
|
203
|
+
|
|
204
|
+
Include the response status and headers, pinning the API version
|
|
205
|
+
|
|
206
|
+
$ sanity api jobs/123 --include --api-version v2025-02-19
|
|
207
|
+
|
|
208
|
+
Authenticate with a specific token instead of the logged-in session
|
|
209
|
+
|
|
210
|
+
SANITY_AUTH_TOKEN=<token> sanity api users/me
|
|
211
|
+
```
|
|
212
|
+
|
|
125
213
|
## `sanity backups disable [DATASET]`
|
|
126
214
|
|
|
127
215
|
Disable backup for a dataset
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback API version used when neither the endpoint path, the `--api-version`
|
|
3
|
+
* flag, nor the matched OpenAPI spec provides one.
|
|
4
|
+
*/ export const API_DEFAULT_VERSION = 'v2025-02-19';
|
|
5
|
+
/**
|
|
6
|
+
* Base URL for the published OpenAPI specification index.
|
|
7
|
+
* Also used by the `sanity openapi` commands.
|
|
8
|
+
*/ export const OPENAPI_SPEC_INDEX_URL = 'https://www.sanity.io/docs/api/openapi';
|
|
9
|
+
/**
|
|
10
|
+
* Matches an API version path segment: `v1`, `vX` or `v<YYYY-MM-DD>`.
|
|
11
|
+
*/ export const API_VERSION_SEGMENT_RE = /^v(?:\d{4}-\d{2}-\d{2}|X|\d+)$/;
|
|
12
|
+
|
|
13
|
+
//# sourceMappingURL=constants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/actions/api/constants.ts"],"sourcesContent":["/**\n * Fallback API version used when neither the endpoint path, the `--api-version`\n * flag, nor the matched OpenAPI spec provides one.\n */\nexport const API_DEFAULT_VERSION = 'v2025-02-19'\n\n/**\n * Base URL for the published OpenAPI specification index.\n * Also used by the `sanity openapi` commands.\n */\nexport const OPENAPI_SPEC_INDEX_URL = 'https://www.sanity.io/docs/api/openapi'\n\n/**\n * Matches an API version path segment: `v1`, `vX` or `v<YYYY-MM-DD>`.\n */\nexport const API_VERSION_SEGMENT_RE = /^v(?:\\d{4}-\\d{2}-\\d{2}|X|\\d+)$/\n"],"names":["API_DEFAULT_VERSION","OPENAPI_SPEC_INDEX_URL","API_VERSION_SEGMENT_RE"],"mappings":"AAAA;;;CAGC,GACD,OAAO,MAAMA,sBAAsB,cAAa;AAEhD;;;CAGC,GACD,OAAO,MAAMC,yBAAyB,yCAAwC;AAE9E;;CAEC,GACD,OAAO,MAAMC,yBAAyB,iCAAgC"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { API_VERSION_SEGMENT_RE } from './constants.js';
|
|
2
|
+
/**
|
|
3
|
+
* Distill fetched OpenAPI specifications into the routing manifest used by
|
|
4
|
+
* `sanity api` to decide which host serves a request path and which API
|
|
5
|
+
* version to default to.
|
|
6
|
+
*
|
|
7
|
+
* The output is deterministic (sorted) so the generated manifest can be
|
|
8
|
+
* compared against a fresh distillation to detect drift.
|
|
9
|
+
*/ export function distillApiRoutes(specs) {
|
|
10
|
+
const entries = [];
|
|
11
|
+
for (const { document, slug, title } of specs){
|
|
12
|
+
const servers = document.servers ?? [];
|
|
13
|
+
const paths = Object.keys(document.paths ?? {});
|
|
14
|
+
if (servers.length === 0 || paths.length === 0) continue;
|
|
15
|
+
// A spec may be served on both hosts (eg the Applications API). Emit one
|
|
16
|
+
// entry per host, each carrying the full set of path patterns.
|
|
17
|
+
const byHost = new Map();
|
|
18
|
+
for (const server of servers){
|
|
19
|
+
const base = parseServerUrl(server.url, server.variables);
|
|
20
|
+
if (!base) continue;
|
|
21
|
+
if (!byHost.has(base.host)) byHost.set(base.host, base);
|
|
22
|
+
}
|
|
23
|
+
for (const base of byHost.values()){
|
|
24
|
+
const patterns = new Set();
|
|
25
|
+
let defaultApiVersion = base.defaultApiVersion;
|
|
26
|
+
for (const path of paths){
|
|
27
|
+
const { segments, version } = normalizePathSegments(path);
|
|
28
|
+
defaultApiVersion = defaultApiVersion ?? version;
|
|
29
|
+
const pattern = [
|
|
30
|
+
...base.segments,
|
|
31
|
+
...segments
|
|
32
|
+
].join('/');
|
|
33
|
+
if (pattern !== '') patterns.add(pattern);
|
|
34
|
+
}
|
|
35
|
+
if (patterns.size === 0) continue;
|
|
36
|
+
entries.push({
|
|
37
|
+
...defaultApiVersion === undefined ? {} : {
|
|
38
|
+
defaultApiVersion
|
|
39
|
+
},
|
|
40
|
+
host: base.host,
|
|
41
|
+
pathPatterns: [
|
|
42
|
+
...patterns
|
|
43
|
+
].toSorted(),
|
|
44
|
+
slug,
|
|
45
|
+
title
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return entries.toSorted((a, b)=>a.slug.localeCompare(b.slug) || a.host.localeCompare(b.host));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parse an OpenAPI server URL template (eg
|
|
53
|
+
* `https://{projectId}.api.sanity.io/{apiVersion}/agent/action`) into a host
|
|
54
|
+
* classification, base path segments and a default API version.
|
|
55
|
+
*
|
|
56
|
+
* Returns `undefined` for URLs that don't look like a Sanity API host.
|
|
57
|
+
*/ function parseServerUrl(url, variables) {
|
|
58
|
+
const withoutScheme = url.replace(/^https?:\/\//, '');
|
|
59
|
+
const [hostname = '', ...pathSegments] = withoutScheme.split('/');
|
|
60
|
+
if (!hostname.includes('api.sanity.io') && !hostname.includes('api.sanity.work')) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const host = hostname.includes('{projectId}') ? 'project' : 'global';
|
|
64
|
+
let defaultApiVersion = normalizeVersion(variables?.apiVersion?.default);
|
|
65
|
+
const segments = [];
|
|
66
|
+
for (const segment of pathSegments){
|
|
67
|
+
if (segment === '') continue;
|
|
68
|
+
if (segment === '{apiVersion}') continue;
|
|
69
|
+
if (API_VERSION_SEGMENT_RE.test(segment)) {
|
|
70
|
+
defaultApiVersion = defaultApiVersion ?? segment;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
segments.push(segment);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
defaultApiVersion,
|
|
77
|
+
host,
|
|
78
|
+
segments
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Normalize an OpenAPI path template into version-less path segments, plus the
|
|
83
|
+
* API version if one was embedded in the path (eg `/v2024-07-01/access/...`).
|
|
84
|
+
*
|
|
85
|
+
* Some specs carry a stray query marker in the path key (eg `/foo/{id}?`) -
|
|
86
|
+
* anything from `?` on is not part of the path and is dropped.
|
|
87
|
+
*/ function normalizePathSegments(path) {
|
|
88
|
+
const [pathPart = ''] = path.split('?');
|
|
89
|
+
const segments = pathPart.split('/').filter((segment)=>segment !== '');
|
|
90
|
+
let version;
|
|
91
|
+
if (segments[0] === '{apiVersion}') {
|
|
92
|
+
segments.shift();
|
|
93
|
+
} else if (segments.length > 0 && API_VERSION_SEGMENT_RE.test(segments[0])) {
|
|
94
|
+
version = segments.shift();
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
segments,
|
|
98
|
+
version
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function normalizeVersion(version) {
|
|
102
|
+
if (!version) return undefined;
|
|
103
|
+
return API_VERSION_SEGMENT_RE.test(version) ? version : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
//# sourceMappingURL=distillApiRoutes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/actions/api/distillApiRoutes.ts"],"sourcesContent":["import {API_VERSION_SEGMENT_RE} from './constants.js'\nimport {type ApiHost, type ApiRouteEntry, type OpenApiDocument} from './types.js'\n\n/**\n * A fetched OpenAPI specification, ready for distillation.\n */\nexport interface SpecSource {\n document: OpenApiDocument\n slug: string\n title: string\n}\n\ninterface ServerBase {\n host: ApiHost\n segments: string[]\n\n defaultApiVersion?: string\n}\n\n/**\n * Distill fetched OpenAPI specifications into the routing manifest used by\n * `sanity api` to decide which host serves a request path and which API\n * version to default to.\n *\n * The output is deterministic (sorted) so the generated manifest can be\n * compared against a fresh distillation to detect drift.\n */\nexport function distillApiRoutes(specs: SpecSource[]): ApiRouteEntry[] {\n const entries: ApiRouteEntry[] = []\n\n for (const {document, slug, title} of specs) {\n const servers = document.servers ?? []\n const paths = Object.keys(document.paths ?? {})\n if (servers.length === 0 || paths.length === 0) continue\n\n // A spec may be served on both hosts (eg the Applications API). Emit one\n // entry per host, each carrying the full set of path patterns.\n const byHost = new Map<ApiHost, ServerBase>()\n for (const server of servers) {\n const base = parseServerUrl(server.url, server.variables)\n if (!base) continue\n if (!byHost.has(base.host)) byHost.set(base.host, base)\n }\n\n for (const base of byHost.values()) {\n const patterns = new Set<string>()\n let defaultApiVersion = base.defaultApiVersion\n\n for (const path of paths) {\n const {segments, version} = normalizePathSegments(path)\n defaultApiVersion = defaultApiVersion ?? version\n const pattern = [...base.segments, ...segments].join('/')\n if (pattern !== '') patterns.add(pattern)\n }\n\n if (patterns.size === 0) continue\n\n entries.push({\n ...(defaultApiVersion === undefined ? {} : {defaultApiVersion}),\n host: base.host,\n pathPatterns: [...patterns].toSorted(),\n slug,\n title,\n })\n }\n }\n\n return entries.toSorted((a, b) => a.slug.localeCompare(b.slug) || a.host.localeCompare(b.host))\n}\n\n/**\n * Parse an OpenAPI server URL template (eg\n * `https://{projectId}.api.sanity.io/{apiVersion}/agent/action`) into a host\n * classification, base path segments and a default API version.\n *\n * Returns `undefined` for URLs that don't look like a Sanity API host.\n */\nfunction parseServerUrl(\n url: string,\n variables?: Record<string, {default?: string}>,\n): ServerBase | undefined {\n const withoutScheme = url.replace(/^https?:\\/\\//, '')\n const [hostname = '', ...pathSegments] = withoutScheme.split('/')\n\n if (!hostname.includes('api.sanity.io') && !hostname.includes('api.sanity.work')) {\n return undefined\n }\n\n const host: ApiHost = hostname.includes('{projectId}') ? 'project' : 'global'\n\n let defaultApiVersion = normalizeVersion(variables?.apiVersion?.default)\n const segments: string[] = []\n for (const segment of pathSegments) {\n if (segment === '') continue\n if (segment === '{apiVersion}') continue\n if (API_VERSION_SEGMENT_RE.test(segment)) {\n defaultApiVersion = defaultApiVersion ?? segment\n continue\n }\n segments.push(segment)\n }\n\n return {defaultApiVersion, host, segments}\n}\n\n/**\n * Normalize an OpenAPI path template into version-less path segments, plus the\n * API version if one was embedded in the path (eg `/v2024-07-01/access/...`).\n *\n * Some specs carry a stray query marker in the path key (eg `/foo/{id}?`) -\n * anything from `?` on is not part of the path and is dropped.\n */\nfunction normalizePathSegments(path: string): {segments: string[]; version?: string} {\n const [pathPart = ''] = path.split('?')\n const segments = pathPart.split('/').filter((segment) => segment !== '')\n let version: string | undefined\n\n if (segments[0] === '{apiVersion}') {\n segments.shift()\n } else if (segments.length > 0 && API_VERSION_SEGMENT_RE.test(segments[0])) {\n version = segments.shift()\n }\n\n return {segments, version}\n}\n\nfunction normalizeVersion(version: string | undefined): string | undefined {\n if (!version) return undefined\n return API_VERSION_SEGMENT_RE.test(version) ? version : undefined\n}\n"],"names":["API_VERSION_SEGMENT_RE","distillApiRoutes","specs","entries","document","slug","title","servers","paths","Object","keys","length","byHost","Map","server","base","parseServerUrl","url","variables","has","host","set","values","patterns","Set","defaultApiVersion","path","segments","version","normalizePathSegments","pattern","join","add","size","push","undefined","pathPatterns","toSorted","a","b","localeCompare","withoutScheme","replace","hostname","pathSegments","split","includes","normalizeVersion","apiVersion","default","segment","test","pathPart","filter","shift"],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user provided invalid input to `sanity api` (bad field syntax, missing
|
|
3
|
+
* placeholder values, unsupported host, ...). Mapped to exit code 2 by the
|
|
4
|
+
* command, per the CLI exit code convention.
|
|
5
|
+
*/ export class ApiUsageError extends Error {
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The request targets the project API host (or the path contains a
|
|
9
|
+
* `{projectId}` placeholder) but no project ID has been resolved yet. The
|
|
10
|
+
* command catches this to trigger project resolution (flag, CLI config or
|
|
11
|
+
* interactive prompt) and retries.
|
|
12
|
+
*/ export class ProjectIdRequiredError extends Error {
|
|
13
|
+
constructor(){
|
|
14
|
+
super('A project ID is required for this request');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/actions/api/errors.ts"],"sourcesContent":["/**\n * The user provided invalid input to `sanity api` (bad field syntax, missing\n * placeholder values, unsupported host, ...). Mapped to exit code 2 by the\n * command, per the CLI exit code convention.\n */\nexport class ApiUsageError extends Error {}\n\n/**\n * The request targets the project API host (or the path contains a\n * `{projectId}` placeholder) but no project ID has been resolved yet. The\n * command catches this to trigger project resolution (flag, CLI config or\n * interactive prompt) and retries.\n */\nexport class ProjectIdRequiredError extends Error {\n constructor() {\n super('A project ID is required for this request')\n }\n}\n"],"names":["ApiUsageError","Error","ProjectIdRequiredError"],"mappings":"AAAA;;;;CAIC,GACD,OAAO,MAAMA,sBAAsBC;AAAO;AAE1C;;;;;CAKC,GACD,OAAO,MAAMC,+BAA+BD;IAC1C,aAAc;QACZ,KAAK,CAAC;IACR;AACF"}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { ApiUsageError } from './errors.js';
|
|
2
|
+
const KEY_RE = /^([^=[\]]+)((?:\[[^[\]]*\])*)$/;
|
|
3
|
+
/**
|
|
4
|
+
* Convert parsed fields to query parameters, for request methods without a
|
|
5
|
+
* body (GET/HEAD). Only scalar values (and arrays of scalars) are supported.
|
|
6
|
+
*/ export function fieldsToQuery(fields) {
|
|
7
|
+
// Null-prototype for the same reason as in parseFields.
|
|
8
|
+
const query = Object.create(null);
|
|
9
|
+
for (const [key, value] of Object.entries(fields)){
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
if (value.some((item)=>typeof item === 'object' && item !== null)) {
|
|
12
|
+
throw new ApiUsageError(`Cannot use nested field "${key}" as a query parameter`);
|
|
13
|
+
}
|
|
14
|
+
query[key] = value.map(String);
|
|
15
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
16
|
+
throw new ApiUsageError(`Cannot use nested field "${key}" as a query parameter`);
|
|
17
|
+
} else {
|
|
18
|
+
query[key] = String(value);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return query;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse `gh api`-style field flags into a JSON-compatible object.
|
|
25
|
+
*
|
|
26
|
+
* Supports nested keys with bracket syntax: `a[b]=1` produces `{a: {b: 1}}`,
|
|
27
|
+
* `a[]=1` appends to an array and a bare `a[]` declares an empty one.
|
|
28
|
+
* Raw fields (`-f`) keep values as strings; typed fields (`-F`) convert
|
|
29
|
+
* `true`/`false`/`null` and numbers, and expand `@<file>` / `@-` to file or
|
|
30
|
+
* stdin contents.
|
|
31
|
+
*/ export function parseFields(options) {
|
|
32
|
+
const { fields = [], rawFields = [], readFile, stdin } = options;
|
|
33
|
+
// Null-prototype containers keep user-supplied keys like `__proto__` or
|
|
34
|
+
// `toString` ordinary own properties: nothing to pollute, nothing inherited.
|
|
35
|
+
const result = Object.create(null);
|
|
36
|
+
for (const raw of rawFields){
|
|
37
|
+
const { key, value } = splitField(raw, '--raw-field');
|
|
38
|
+
setField(result, key, value);
|
|
39
|
+
}
|
|
40
|
+
for (const field of fields){
|
|
41
|
+
const { key, value } = splitField(field, '--field');
|
|
42
|
+
setField(result, key, value === undefined ? undefined : coerceValue(value, {
|
|
43
|
+
flag: field,
|
|
44
|
+
readFile,
|
|
45
|
+
stdin
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
function splitField(input, flagName) {
|
|
51
|
+
const separatorIndex = input.indexOf('=');
|
|
52
|
+
// A bare `key[]` without a value declares an empty array (gh api parity)
|
|
53
|
+
if (separatorIndex === -1 && input.endsWith('[]')) {
|
|
54
|
+
return {
|
|
55
|
+
key: input
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (separatorIndex < 1) {
|
|
59
|
+
throw new ApiUsageError(`Invalid ${flagName} "${input}": expected key=value format`);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
key: input.slice(0, separatorIndex),
|
|
63
|
+
value: input.slice(separatorIndex + 1)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function coerceValue(value, context) {
|
|
67
|
+
if (value === 'true') return true;
|
|
68
|
+
if (value === 'false') return false;
|
|
69
|
+
if (value === 'null') return null;
|
|
70
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
|
|
71
|
+
if (value.startsWith('@')) {
|
|
72
|
+
const source = value.slice(1);
|
|
73
|
+
if (source === '-') {
|
|
74
|
+
if (context.stdin === undefined) {
|
|
75
|
+
throw new ApiUsageError(`Unable to read stdin for field "${context.flag}"`);
|
|
76
|
+
}
|
|
77
|
+
return context.stdin;
|
|
78
|
+
}
|
|
79
|
+
if (!context.readFile) {
|
|
80
|
+
throw new ApiUsageError(`Unable to read file for field "${context.flag}"`);
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return context.readFile(source);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
86
|
+
throw new ApiUsageError(`Failed to read "${source}" for field "${context.flag}": ${message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Set a (possibly nested) field on the target object, `gh api`-style:
|
|
93
|
+
* `a=1` sets a top-level key, `a[b]=1` a nested object key, `a[]=1` appends
|
|
94
|
+
* to an array and a bare `a[]` declares an empty one (`value` is `undefined`).
|
|
95
|
+
*
|
|
96
|
+
* Array elements accumulate the way they do in gh: consecutive fields keep
|
|
97
|
+
* filling the current (last) element - `a[][k]=1 a[][j]=2` builds one object
|
|
98
|
+
* with both keys - until a key repeats, which starts the next element.
|
|
99
|
+
*/ function setField(target, key, value) {
|
|
100
|
+
const match = KEY_RE.exec(key);
|
|
101
|
+
if (!match) {
|
|
102
|
+
throw new ApiUsageError(`Invalid field key "${key}"`);
|
|
103
|
+
}
|
|
104
|
+
const [, head, brackets] = match;
|
|
105
|
+
const path = [
|
|
106
|
+
head
|
|
107
|
+
];
|
|
108
|
+
if (brackets) {
|
|
109
|
+
for (const part of brackets.matchAll(/\[([^[\]]*)\]/g)){
|
|
110
|
+
path.push(part[1]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
let container = target;
|
|
114
|
+
let subkey = '';
|
|
115
|
+
let inArray = false;
|
|
116
|
+
for (const segment of path){
|
|
117
|
+
if (segment === '') {
|
|
118
|
+
inArray = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (subkey !== '') {
|
|
122
|
+
container = inArray ? descendIntoArray(container, subkey, segment, key) : descendIntoObject(container, subkey, key);
|
|
123
|
+
inArray = false;
|
|
124
|
+
}
|
|
125
|
+
subkey = segment;
|
|
126
|
+
}
|
|
127
|
+
if (inArray) {
|
|
128
|
+
const existing = Object.hasOwn(container, subkey) ? container[subkey] : undefined;
|
|
129
|
+
if (existing !== undefined && !Array.isArray(existing)) {
|
|
130
|
+
throw new ApiUsageError(`Field "${key}" conflicts with an earlier field`);
|
|
131
|
+
}
|
|
132
|
+
const values = Array.isArray(existing) ? existing : [];
|
|
133
|
+
if (existing === undefined) {
|
|
134
|
+
container[subkey] = values;
|
|
135
|
+
}
|
|
136
|
+
if (value !== undefined) {
|
|
137
|
+
values.push(value);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (Object.hasOwn(container, subkey)) {
|
|
142
|
+
throw new ApiUsageError(`Field "${key}" conflicts with an earlier field`);
|
|
143
|
+
}
|
|
144
|
+
// A `value` of undefined only occurs for keys ending in `[]`, which take
|
|
145
|
+
// the array branch above.
|
|
146
|
+
container[subkey] = value;
|
|
147
|
+
}
|
|
148
|
+
/** Descend into (creating if needed) the object at `container[segment]`. */ function descendIntoObject(container, segment, key) {
|
|
149
|
+
const existing = Object.hasOwn(container, segment) ? container[segment] : undefined;
|
|
150
|
+
if (existing === undefined) {
|
|
151
|
+
const next = Object.create(null);
|
|
152
|
+
container[segment] = next;
|
|
153
|
+
return next;
|
|
154
|
+
}
|
|
155
|
+
if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) {
|
|
156
|
+
throw new ApiUsageError(`Field "${key}" conflicts with an earlier field`);
|
|
157
|
+
}
|
|
158
|
+
return existing;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Descend into the array at `container[segment]`, returning the object
|
|
162
|
+
* element the field at hand should land in: the current (last) element while
|
|
163
|
+
* `nextKey` is new to it or accumulating a nested array, or a freshly
|
|
164
|
+
* appended element once `nextKey` repeats (gh api semantics).
|
|
165
|
+
*/ function descendIntoArray(container, segment, nextKey, key) {
|
|
166
|
+
const existing = Object.hasOwn(container, segment) ? container[segment] : undefined;
|
|
167
|
+
if (existing !== undefined && !Array.isArray(existing)) {
|
|
168
|
+
throw new ApiUsageError(`Field "${key}" conflicts with an earlier field`);
|
|
169
|
+
}
|
|
170
|
+
const values = Array.isArray(existing) ? existing : [];
|
|
171
|
+
if (existing === undefined) {
|
|
172
|
+
container[segment] = values;
|
|
173
|
+
}
|
|
174
|
+
const last = values.at(-1);
|
|
175
|
+
if (typeof last === 'object' && last !== null && !Array.isArray(last) && (!Object.hasOwn(last, nextKey) || Array.isArray(last[nextKey]))) {
|
|
176
|
+
return last;
|
|
177
|
+
}
|
|
178
|
+
const next = Object.create(null);
|
|
179
|
+
values.push(next);
|
|
180
|
+
return next;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
//# sourceMappingURL=parseFields.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/actions/api/parseFields.ts"],"sourcesContent":["import {ApiUsageError} from './errors.js'\n\n/**\n * A JSON-compatible value produced by field parsing.\n */\nexport type FieldValue =\n | boolean\n | FieldValue[]\n | number\n | string\n | {[key: string]: FieldValue}\n | null\n\n/**\n * Options for {@link parseFields}.\n */\nexport interface ParseFieldsOptions {\n /**\n * Typed fields (`-F/--field`): `true`, `false`, `null` and numbers are\n * converted; `@<file>` reads the value from a file, `@-` from stdin.\n */\n fields?: string[]\n\n /** Raw string fields (`-f/--raw-field`): the value is used verbatim. */\n rawFields?: string[]\n\n /** Reads a file referenced via `@<file>`. */\n readFile?: (path: string) => string\n\n /** Content of stdin, for `@-` values. */\n stdin?: string\n}\n\ntype FieldContainer = Record<string, FieldValue>\n\nconst KEY_RE = /^([^=[\\]]+)((?:\\[[^[\\]]*\\])*)$/\n\n/**\n * Convert parsed fields to query parameters, for request methods without a\n * body (GET/HEAD). Only scalar values (and arrays of scalars) are supported.\n */\nexport function fieldsToQuery(\n fields: Record<string, FieldValue>,\n): Record<string, string | string[]> {\n // Null-prototype for the same reason as in parseFields.\n const query: Record<string, string | string[]> = Object.create(null)\n\n for (const [key, value] of Object.entries(fields)) {\n if (Array.isArray(value)) {\n if (value.some((item) => typeof item === 'object' && item !== null)) {\n throw new ApiUsageError(`Cannot use nested field \"${key}\" as a query parameter`)\n }\n query[key] = value.map(String)\n } else if (typeof value === 'object' && value !== null) {\n throw new ApiUsageError(`Cannot use nested field \"${key}\" as a query parameter`)\n } else {\n query[key] = String(value)\n }\n }\n\n return query\n}\n\n/**\n * Parse `gh api`-style field flags into a JSON-compatible object.\n *\n * Supports nested keys with bracket syntax: `a[b]=1` produces `{a: {b: 1}}`,\n * `a[]=1` appends to an array and a bare `a[]` declares an empty one.\n * Raw fields (`-f`) keep values as strings; typed fields (`-F`) convert\n * `true`/`false`/`null` and numbers, and expand `@<file>` / `@-` to file or\n * stdin contents.\n */\nexport function parseFields(options: ParseFieldsOptions): Record<string, FieldValue> {\n const {fields = [], rawFields = [], readFile, stdin} = options\n // Null-prototype containers keep user-supplied keys like `__proto__` or\n // `toString` ordinary own properties: nothing to pollute, nothing inherited.\n const result: FieldContainer = Object.create(null)\n\n for (const raw of rawFields) {\n const {key, value} = splitField(raw, '--raw-field')\n setField(result, key, value)\n }\n\n for (const field of fields) {\n const {key, value} = splitField(field, '--field')\n setField(\n result,\n key,\n value === undefined ? undefined : coerceValue(value, {flag: field, readFile, stdin}),\n )\n }\n\n return result\n}\n\nfunction splitField(input: string, flagName: string): {key: string; value?: string} {\n const separatorIndex = input.indexOf('=')\n // A bare `key[]` without a value declares an empty array (gh api parity)\n if (separatorIndex === -1 && input.endsWith('[]')) {\n return {key: input}\n }\n if (separatorIndex < 1) {\n throw new ApiUsageError(`Invalid ${flagName} \"${input}\": expected key=value format`)\n }\n return {key: input.slice(0, separatorIndex), value: input.slice(separatorIndex + 1)}\n}\n\nfunction coerceValue(\n value: string,\n context: {flag: string; readFile?: (path: string) => string; stdin?: string},\n): FieldValue {\n if (value === 'true') return true\n if (value === 'false') return false\n if (value === 'null') return null\n if (/^-?\\d+(\\.\\d+)?$/.test(value)) return Number(value)\n\n if (value.startsWith('@')) {\n const source = value.slice(1)\n if (source === '-') {\n if (context.stdin === undefined) {\n throw new ApiUsageError(`Unable to read stdin for field \"${context.flag}\"`)\n }\n return context.stdin\n }\n if (!context.readFile) {\n throw new ApiUsageError(`Unable to read file for field \"${context.flag}\"`)\n }\n try {\n return context.readFile(source)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n throw new ApiUsageError(`Failed to read \"${source}\" for field \"${context.flag}\": ${message}`)\n }\n }\n\n return value\n}\n\n/**\n * Set a (possibly nested) field on the target object, `gh api`-style:\n * `a=1` sets a top-level key, `a[b]=1` a nested object key, `a[]=1` appends\n * to an array and a bare `a[]` declares an empty one (`value` is `undefined`).\n *\n * Array elements accumulate the way they do in gh: consecutive fields keep\n * filling the current (last) element - `a[][k]=1 a[][j]=2` builds one object\n * with both keys - until a key repeats, which starts the next element.\n */\nfunction setField(target: FieldContainer, key: string, value: FieldValue | undefined): void {\n const match = KEY_RE.exec(key)\n if (!match) {\n throw new ApiUsageError(`Invalid field key \"${key}\"`)\n }\n\n const [, head, brackets] = match\n const path: string[] = [head]\n if (brackets) {\n for (const part of brackets.matchAll(/\\[([^[\\]]*)\\]/g)) {\n path.push(part[1])\n }\n }\n\n let container = target\n let subkey = ''\n let inArray = false\n for (const segment of path) {\n if (segment === '') {\n inArray = true\n continue\n }\n if (subkey !== '') {\n container = inArray\n ? descendIntoArray(container, subkey, segment, key)\n : descendIntoObject(container, subkey, key)\n inArray = false\n }\n subkey = segment\n }\n\n if (inArray) {\n const existing = Object.hasOwn(container, subkey) ? container[subkey] : undefined\n if (existing !== undefined && !Array.isArray(existing)) {\n throw new ApiUsageError(`Field \"${key}\" conflicts with an earlier field`)\n }\n const values = Array.isArray(existing) ? existing : []\n if (existing === undefined) {\n container[subkey] = values\n }\n if (value !== undefined) {\n values.push(value)\n }\n return\n }\n\n if (Object.hasOwn(container, subkey)) {\n throw new ApiUsageError(`Field \"${key}\" conflicts with an earlier field`)\n }\n // A `value` of undefined only occurs for keys ending in `[]`, which take\n // the array branch above.\n container[subkey] = value as FieldValue\n}\n\n/** Descend into (creating if needed) the object at `container[segment]`. */\nfunction descendIntoObject(\n container: FieldContainer,\n segment: string,\n key: string,\n): FieldContainer {\n const existing = Object.hasOwn(container, segment) ? container[segment] : undefined\n if (existing === undefined) {\n const next: FieldContainer = Object.create(null)\n container[segment] = next\n return next\n }\n if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) {\n throw new ApiUsageError(`Field \"${key}\" conflicts with an earlier field`)\n }\n return existing\n}\n\n/**\n * Descend into the array at `container[segment]`, returning the object\n * element the field at hand should land in: the current (last) element while\n * `nextKey` is new to it or accumulating a nested array, or a freshly\n * appended element once `nextKey` repeats (gh api semantics).\n */\nfunction descendIntoArray(\n container: FieldContainer,\n segment: string,\n nextKey: string,\n key: string,\n): FieldContainer {\n const existing = Object.hasOwn(container, segment) ? container[segment] : undefined\n if (existing !== undefined && !Array.isArray(existing)) {\n throw new ApiUsageError(`Field \"${key}\" conflicts with an earlier field`)\n }\n const values = Array.isArray(existing) ? existing : []\n if (existing === undefined) {\n container[segment] = values\n }\n\n const last = values.at(-1)\n if (\n typeof last === 'object' &&\n last !== null &&\n !Array.isArray(last) &&\n (!Object.hasOwn(last, nextKey) || Array.isArray(last[nextKey]))\n ) {\n return last\n }\n\n const next: FieldContainer = Object.create(null)\n values.push(next)\n return next\n}\n"],"names":["ApiUsageError","KEY_RE","fieldsToQuery","fields","query","Object","create","key","value","entries","Array","isArray","some","item","map","String","parseFields","options","rawFields","readFile","stdin","result","raw","splitField","setField","field","undefined","coerceValue","flag","input","flagName","separatorIndex","indexOf","endsWith","slice","context","test","Number","startsWith","source","error","message","Error","target","match","exec","head","brackets","path","part","matchAll","push","container","subkey","inArray","segment","descendIntoArray","descendIntoObject","existing","hasOwn","values","next","nextKey","last","at"],"mappings":"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"}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { parse } from 'node:querystring';
|
|
2
|
+
import { API_DEFAULT_VERSION, API_VERSION_SEGMENT_RE } from './constants.js';
|
|
3
|
+
import { ApiUsageError, ProjectIdRequiredError } from './errors.js';
|
|
4
|
+
const PLACEHOLDER_RE = /^\{([^{}]+)\}$/;
|
|
5
|
+
const ALLOWED_URL_HOST_RE = /(?:^|\.)api\.sanity\.(?:io|work)$/;
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the endpoint argument of `sanity api` into a concrete request
|
|
8
|
+
* target: substitute placeholders, split off the query string, determine the
|
|
9
|
+
* host family (via the generated OpenAPI routing manifest) and the API
|
|
10
|
+
* version.
|
|
11
|
+
*
|
|
12
|
+
* Throws {@link ProjectIdRequiredError} when the request needs a project ID
|
|
13
|
+
* that hasn't been provided - the command catches this to resolve one (flags,
|
|
14
|
+
* CLI config or interactive prompt) and retries.
|
|
15
|
+
*/ export function resolveEndpoint(options) {
|
|
16
|
+
const { apiVersion, dataset, endpoint, forceHost, projectId, routes } = options;
|
|
17
|
+
if (/^https?:\/\//.test(endpoint)) {
|
|
18
|
+
return resolveUrlEndpoint(endpoint);
|
|
19
|
+
}
|
|
20
|
+
const [pathPart = '', ...queryParts] = endpoint.split('?');
|
|
21
|
+
const query = parseQueryString(queryParts.join('?'));
|
|
22
|
+
const segments = pathPart.split('/').filter((segment)=>segment !== '');
|
|
23
|
+
// An API version embedded in the path (eg `v2021-06-07/projects`) is peeled
|
|
24
|
+
// off and re-applied as the version segment of the final URL.
|
|
25
|
+
let embeddedVersion;
|
|
26
|
+
if (segments[0] === '{apiVersion}') {
|
|
27
|
+
segments.shift();
|
|
28
|
+
} else if (segments.length > 0 && API_VERSION_SEGMENT_RE.test(segments[0])) {
|
|
29
|
+
embeddedVersion = segments.shift();
|
|
30
|
+
}
|
|
31
|
+
// Checked after version peeling so a bare version (eg `v2025-02-19`) is
|
|
32
|
+
// also rejected as an empty path.
|
|
33
|
+
if (segments.length === 0) {
|
|
34
|
+
throw new ApiUsageError('Endpoint path is empty');
|
|
35
|
+
}
|
|
36
|
+
const unresolved = [];
|
|
37
|
+
const substituted = segments.map((segment, index)=>{
|
|
38
|
+
const placeholder = PLACEHOLDER_RE.exec(segment)?.[1];
|
|
39
|
+
if (!placeholder) return segment;
|
|
40
|
+
if (placeholder === 'projectId') {
|
|
41
|
+
if (!projectId) throw new ProjectIdRequiredError();
|
|
42
|
+
return projectId;
|
|
43
|
+
}
|
|
44
|
+
// The projects spec names the dataset placeholder `{name}` (eg
|
|
45
|
+
// `projects/{projectId}/datasets/{name}`) - only that context is a
|
|
46
|
+
// dataset; `{name}` elsewhere (eg video renditions) stays unresolved.
|
|
47
|
+
const isDatasetName = placeholder === 'name' && segments[index - 1] === 'datasets';
|
|
48
|
+
if (placeholder === 'dataset' || placeholder === 'datasetName' || isDatasetName) {
|
|
49
|
+
if (!dataset) {
|
|
50
|
+
throw new ApiUsageError(`Unable to resolve {${placeholder}} - provide a dataset with --dataset or configure one in sanity.cli.ts`);
|
|
51
|
+
}
|
|
52
|
+
return dataset;
|
|
53
|
+
}
|
|
54
|
+
unresolved.push(segment);
|
|
55
|
+
return segment;
|
|
56
|
+
});
|
|
57
|
+
if (unresolved.length > 0) {
|
|
58
|
+
throw new ApiUsageError(`Unable to resolve placeholder(s) ${unresolved.join(', ')} - replace them with actual values`);
|
|
59
|
+
}
|
|
60
|
+
// Forcing a host restricts matching to specs served on that host, so the
|
|
61
|
+
// matched spec's default API version still applies when one exists there.
|
|
62
|
+
const candidates = forceHost ? routes.filter((entry)=>entry.host === forceHost) : routes;
|
|
63
|
+
const match = matchRoutes(substituted, candidates);
|
|
64
|
+
const host = forceHost ?? match?.host ?? 'global';
|
|
65
|
+
if (host === 'project' && !projectId) {
|
|
66
|
+
throw new ProjectIdRequiredError();
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
apiVersion: apiVersion ?? embeddedVersion ?? match?.defaultApiVersion ?? API_DEFAULT_VERSION,
|
|
70
|
+
host,
|
|
71
|
+
kind: 'path',
|
|
72
|
+
matchedSlug: match?.slug,
|
|
73
|
+
path: substituted.join('/'),
|
|
74
|
+
...host === 'project' ? {
|
|
75
|
+
projectId
|
|
76
|
+
} : {},
|
|
77
|
+
query
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function resolveUrlEndpoint(endpoint) {
|
|
81
|
+
let url;
|
|
82
|
+
try {
|
|
83
|
+
url = new URL(endpoint);
|
|
84
|
+
} catch {
|
|
85
|
+
throw new ApiUsageError(`Invalid URL "${endpoint}"`);
|
|
86
|
+
}
|
|
87
|
+
// Never put the token on the wire unencrypted
|
|
88
|
+
if (url.protocol !== 'https:') {
|
|
89
|
+
throw new ApiUsageError(`Refusing to send requests over "${url.protocol}//" - only https:// URLs are supported`);
|
|
90
|
+
}
|
|
91
|
+
if (!ALLOWED_URL_HOST_RE.test(url.hostname)) {
|
|
92
|
+
throw new ApiUsageError(`Refusing to send authenticated requests to "${url.hostname}" - only *.api.sanity.io hosts are supported`);
|
|
93
|
+
}
|
|
94
|
+
const query = parseQueryString(url.search.replace(/^\?/, ''));
|
|
95
|
+
url.search = '';
|
|
96
|
+
return {
|
|
97
|
+
kind: 'url',
|
|
98
|
+
query,
|
|
99
|
+
url: url.toString()
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Find the route entry whose path pattern best matches the request segments.
|
|
104
|
+
*
|
|
105
|
+
* A pattern only matches when the request path consumes it completely, and
|
|
106
|
+
* segments are compared pairwise: literal matches score higher than
|
|
107
|
+
* placeholder matches, and a literal mismatch disqualifies the pattern. On
|
|
108
|
+
* equal scores across hosts the global host wins (APIs served on both hosts
|
|
109
|
+
* don't need a project ID there); on equal scores within the same host the
|
|
110
|
+
* first entry wins, keeping the result independent of later entries.
|
|
111
|
+
*/ function matchRoutes(segments, routes) {
|
|
112
|
+
let best;
|
|
113
|
+
for (const entry of routes){
|
|
114
|
+
for (const pattern of entry.pathPatterns){
|
|
115
|
+
const score = scorePattern(segments, pattern.split('/'));
|
|
116
|
+
if (score === 0) continue;
|
|
117
|
+
const isBetter = !best || score > best.score || score === best.score && best.host === 'project' && entry.host === 'global';
|
|
118
|
+
if (isBetter) {
|
|
119
|
+
best = {
|
|
120
|
+
defaultApiVersion: entry.defaultApiVersion,
|
|
121
|
+
host: entry.host,
|
|
122
|
+
score,
|
|
123
|
+
slug: entry.slug
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return best;
|
|
129
|
+
}
|
|
130
|
+
function scorePattern(segments, patternSegments) {
|
|
131
|
+
// A pattern only matches when the request path consumes it completely - a
|
|
132
|
+
// request that is a mere prefix of a longer pattern (eg `data` vs
|
|
133
|
+
// `data/query/{dataset}`) must not inherit that route's host or version.
|
|
134
|
+
if (patternSegments.length > segments.length) return 0;
|
|
135
|
+
let score = 0;
|
|
136
|
+
let literalMatches = 0;
|
|
137
|
+
for (const [index, patternSegment] of patternSegments.entries()){
|
|
138
|
+
if (PLACEHOLDER_RE.test(patternSegment)) {
|
|
139
|
+
score += 1;
|
|
140
|
+
} else if (patternSegment === segments[index]) {
|
|
141
|
+
score += 2;
|
|
142
|
+
literalMatches += 1;
|
|
143
|
+
} else {
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Require at least one literal segment match so a placeholder-only pattern
|
|
148
|
+
// like `{resourceType}/{resourceId}` can't capture arbitrary paths.
|
|
149
|
+
return literalMatches === 0 ? 0 : score;
|
|
150
|
+
}
|
|
151
|
+
function parseQueryString(queryString) {
|
|
152
|
+
// node:querystring collects repeated keys into arrays and returns a
|
|
153
|
+
// null-prototype object, so user-supplied keys like `__proto__` stay
|
|
154
|
+
// ordinary own properties. Parsed values are never `undefined`, hence the
|
|
155
|
+
// narrowing cast.
|
|
156
|
+
return parse(queryString);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
//# sourceMappingURL=resolveEndpoint.js.map
|