@svadmin/sanity 0.9.5 → 0.9.9

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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/data-provider.ts +231 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/sanity",
3
- "version": "0.9.5",
3
+ "version": "0.9.9",
4
4
  "description": "Sanity DataProvider for svadmin",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -20,12 +20,12 @@
20
20
  }
21
21
  },
22
22
  "peerDependencies": {
23
- "@svadmin/core": "^0.25.3"
23
+ "@svadmin/core": "^0.42.0"
24
24
  },
25
25
  "license": "MIT",
26
26
  "repository": {
27
27
  "type": "git",
28
- "url": "https://github.com/zuohuadong/svadmin.git",
28
+ "url": "https://github.com/vibeunion/svadmin.git",
29
29
  "directory": "packages/sanity"
30
30
  },
31
31
  "peerDependenciesMeta": {
@@ -1,7 +1,8 @@
1
1
  import type {
2
2
  DataProvider, GetListParams, GetListResult, GetOneParams, GetOneResult,
3
3
  CreateParams, CreateResult, UpdateParams, UpdateResult, DeleteParams, DeleteResult,
4
- GetManyParams, GetManyResult, CustomParams, CustomResult, Filter, Sort, BaseRecord
4
+ GetManyParams, GetManyResult, CustomParams, CustomResult, CrudOperator, FieldFilter,
5
+ Filter, Sort, BaseRecord
5
6
  } from '@svadmin/core';
6
7
 
7
8
  interface SanityDoc {
@@ -14,35 +15,220 @@ interface SanityMutationResult {
14
15
  results?: Array<{ id: string }>;
15
16
  }
16
17
 
17
- // Sanity.io uses GROQ query language
18
- function buildGroqFilter(filters?: Filter[]): string {
19
- if (!filters?.length) return '';
20
- return filters
21
- .filter((f): f is Filter & { field: string } => 'field' in f)
22
- .map((f) => {
23
- switch (f.operator) {
24
- case 'eq': return `${f.field} == "${f.value}"`;
25
- case 'ne': return `${f.field} != "${f.value}"`;
26
- case 'lt': return `${f.field} < ${f.value}`;
27
- case 'gt': return `${f.field} > ${f.value}`;
28
- case 'contains': return `${f.field} match "*${f.value}*"`;
29
- case 'in': return `${f.field} in [${(f.value as string[]).map((v: string) => `"${v}"`).join(',')}]`;
30
- default: return `${f.field} == "${f.value}"`;
31
- }
32
- })
33
- .join(' && ');
18
+ const DEFAULT_JSON_HEADERS: Record<string, string> = { 'Content-Type': 'application/json' };
19
+
20
+ interface GroqFilterResult {
21
+ expression: string;
22
+ params: Record<string, unknown>;
23
+ }
24
+
25
+ interface GroqFilterContext {
26
+ nextParam: number;
27
+ params: Record<string, unknown>;
28
+ }
29
+
30
+ const GROQ_PATH_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
31
+
32
+ function assertGroqPath(path: string, kind: 'field' | 'sort'): void {
33
+ if (!GROQ_PATH_PATTERN.test(path)) {
34
+ throw new Error(`Invalid GROQ ${kind} path: ${path}`);
35
+ }
36
+ }
37
+
38
+ function addGroqParam(context: GroqFilterContext, value: unknown): string {
39
+ const name = `filter${context.nextParam++}`;
40
+ context.params[name] = value;
41
+ return `$${name}`;
42
+ }
43
+
44
+ function assertArrayOperatorValue(operator: CrudOperator, value: unknown): asserts value is unknown[] {
45
+ if (!Array.isArray(value)) {
46
+ throw new Error(`GROQ filter operator "${operator}" requires an array value`);
47
+ }
48
+ }
49
+
50
+ function assertRangeOperatorValue(operator: CrudOperator, value: unknown): asserts value is [unknown, unknown] {
51
+ if (!Array.isArray(value) || value.length !== 2) {
52
+ throw new Error(`GROQ filter operator "${operator}" requires a two-item array value`);
53
+ }
54
+ }
55
+
56
+ function buildGroqFieldFilter(filter: FieldFilter, context: GroqFilterContext): string {
57
+ assertGroqPath(filter.field, 'field');
58
+
59
+ switch (filter.operator) {
60
+ case 'eq':
61
+ return `${filter.field} == ${addGroqParam(context, filter.value)}`;
62
+ case 'ne':
63
+ return `${filter.field} != ${addGroqParam(context, filter.value)}`;
64
+ case 'lt':
65
+ return `${filter.field} < ${addGroqParam(context, filter.value)}`;
66
+ case 'gt':
67
+ return `${filter.field} > ${addGroqParam(context, filter.value)}`;
68
+ case 'lte':
69
+ return `${filter.field} <= ${addGroqParam(context, filter.value)}`;
70
+ case 'gte':
71
+ return `${filter.field} >= ${addGroqParam(context, filter.value)}`;
72
+ case 'contains':
73
+ return `${filter.field} match ${addGroqParam(context, `*${String(filter.value)}*`)}`;
74
+ case 'ncontains':
75
+ return `!(${filter.field} match ${addGroqParam(context, `*${String(filter.value)}*`)})`;
76
+ case 'startswith':
77
+ return `${filter.field} match ${addGroqParam(context, `${String(filter.value)}*`)}`;
78
+ case 'endswith':
79
+ return `${filter.field} match ${addGroqParam(context, `*${String(filter.value)}`)}`;
80
+ case 'in':
81
+ assertArrayOperatorValue(filter.operator, filter.value);
82
+ return `${filter.field} in ${addGroqParam(context, filter.value)}`;
83
+ case 'nin':
84
+ assertArrayOperatorValue(filter.operator, filter.value);
85
+ return `!(${filter.field} in ${addGroqParam(context, filter.value)})`;
86
+ case 'null':
87
+ return `!defined(${filter.field})`;
88
+ case 'nnull':
89
+ return `defined(${filter.field})`;
90
+ case 'between': {
91
+ assertRangeOperatorValue(filter.operator, filter.value);
92
+ const lower = addGroqParam(context, filter.value[0]);
93
+ const upper = addGroqParam(context, filter.value[1]);
94
+ return `${filter.field} >= ${lower} && ${filter.field} <= ${upper}`;
95
+ }
96
+ case 'nbetween': {
97
+ assertRangeOperatorValue(filter.operator, filter.value);
98
+ const lower = addGroqParam(context, filter.value[0]);
99
+ const upper = addGroqParam(context, filter.value[1]);
100
+ return `!(${filter.field} >= ${lower} && ${filter.field} <= ${upper})`;
101
+ }
102
+ default:
103
+ throw new Error(`Unsupported GROQ filter operator: ${String(filter.operator)}`);
104
+ }
105
+ }
106
+
107
+ function buildGroqFilterNode(filter: Filter, context: GroqFilterContext): string {
108
+ if (typeof filter !== 'object' || filter === null) {
109
+ throw new Error('Unsupported GROQ filter: expected an object');
110
+ }
111
+
112
+ if ('field' in filter) {
113
+ return buildGroqFieldFilter(filter, context);
114
+ }
115
+
116
+ if (filter.operator !== 'and' && filter.operator !== 'or') {
117
+ throw new Error(`Unsupported GROQ logical operator: ${String(filter.operator)}`);
118
+ }
119
+ if (!Array.isArray(filter.value) || filter.value.length === 0) {
120
+ throw new Error(`GROQ logical operator "${filter.operator}" requires at least one filter`);
121
+ }
122
+
123
+ const joiner = filter.operator === 'and' ? ' && ' : ' || ';
124
+ return `(${filter.value.map(child => buildGroqFilterNode(child, context)).join(joiner)})`;
125
+ }
126
+
127
+ function buildGroqFilters(filters?: Filter[]): GroqFilterResult {
128
+ if (!filters?.length) return { expression: '', params: {} };
129
+ const context: GroqFilterContext = { nextParam: 0, params: {} };
130
+ const expression = filters.map(filter => buildGroqFilterNode(filter, context)).join(' && ');
131
+ return { expression, params: context.params };
132
+ }
133
+
134
+ function buildGroqOrder(sorters?: Sort[]): string {
135
+ if (!sorters?.length) return '';
136
+ return ` | order(${sorters.map(sorter => {
137
+ assertGroqPath(sorter.field, 'sort');
138
+ if (sorter.order !== 'asc' && sorter.order !== 'desc') {
139
+ throw new Error(`Invalid GROQ sort order: ${String(sorter.order)}`);
140
+ }
141
+ return `${sorter.field} ${sorter.order}`;
142
+ }).join(', ')})`;
143
+ }
144
+
145
+ function mergeHeaders(...sources: Array<Record<string, string> | undefined>): Record<string, string> {
146
+ const merged = new Map<string, readonly [name: string, value: string]>();
147
+
148
+ for (const source of sources) {
149
+ if (!source) continue;
150
+ for (const [name, value] of Object.entries(source)) {
151
+ merged.set(name.toLowerCase(), [name, value]);
152
+ }
153
+ }
154
+
155
+ return Object.fromEntries(merged.values());
156
+ }
157
+
158
+ function isSameOrigin(apiUrl: string, targetUrl: string): boolean {
159
+ try {
160
+ return new URL(apiUrl).origin === new URL(targetUrl, `${apiUrl}/`).origin;
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ function serializeQueryValue(value: unknown): string {
167
+ if (value === null) return 'null';
168
+ if (typeof value === 'string') return value;
169
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
170
+ return String(value);
171
+ }
172
+
173
+ const serialized = JSON.stringify(value);
174
+ return serialized === undefined ? String(value) : serialized;
175
+ }
176
+
177
+ function appendCustomQuery(params: URLSearchParams, query?: Record<string, unknown>): void {
178
+ if (!query) return;
179
+
180
+ for (const [name, value] of Object.entries(query)) {
181
+ if (value === undefined) continue;
182
+ if (Array.isArray(value)) {
183
+ for (const item of value) params.append(name, serializeQueryValue(item));
184
+ continue;
185
+ }
186
+ params.set(name, serializeQueryValue(value));
187
+ }
188
+ }
189
+
190
+ function buildCustomUrl(
191
+ url: string,
192
+ apiUrl: string,
193
+ query?: Record<string, unknown>,
194
+ sorters?: Sort[],
195
+ filters?: Filter[],
196
+ ): string {
197
+ const parsed = new URL(url, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
198
+ appendCustomQuery(parsed.searchParams, query);
199
+ if (sorters?.length) parsed.searchParams.set('_sorters', JSON.stringify(sorters));
200
+ if (filters?.length) parsed.searchParams.set('_filters', JSON.stringify(filters));
201
+ return parsed.toString();
202
+ }
203
+
204
+ async function parseResponse<TData>(response: Response): Promise<TData> {
205
+ if (response.status === 204 || response.status === 205) {
206
+ return undefined as unknown as TData;
207
+ }
208
+
209
+ const contentLength = response.headers?.get('content-length');
210
+ if (contentLength?.trim() === '0') {
211
+ return undefined as unknown as TData;
212
+ }
213
+
214
+ const body = await response.text();
215
+ if (!body || body.trim() === '') {
216
+ return undefined as unknown as TData;
217
+ }
218
+
219
+ return JSON.parse(body) as TData;
34
220
  }
35
221
 
36
222
  export function createSanityDataProvider(projectId: string, dataset: string, token?: string, apiVersion = '2023-05-03'): DataProvider {
37
223
  const apiUrl = `https://${projectId}.api.sanity.io/v${apiVersion}`;
38
- const headers: Record<string, string> = { 'Content-Type': 'application/json' };
39
- if (token) headers['Authorization'] = `Bearer ${token}`;
224
+ const providerHeaders: Record<string, string> = { ...DEFAULT_JSON_HEADERS };
225
+ if (token) providerHeaders.Authorization = `Bearer ${token}`;
40
226
 
41
227
  async function query(groq: string, params: Record<string, unknown> = {}): Promise<unknown> {
42
228
  const url = new URL(`${apiUrl}/data/query/${dataset}`);
43
229
  url.searchParams.set('query', groq);
44
230
  for (const [k, v] of Object.entries(params)) url.searchParams.set(`$${k}`, JSON.stringify(v));
45
- const res = await fetch(url.toString(), { headers });
231
+ const res = await fetch(url.toString(), { headers: providerHeaders });
46
232
  if (!res.ok) throw new Error(`Sanity error: ${res.status}`);
47
233
  const data = await res.json();
48
234
  return data.result;
@@ -50,7 +236,7 @@ export function createSanityDataProvider(projectId: string, dataset: string, tok
50
236
 
51
237
  async function mutate(mutations: Record<string, unknown>[]): Promise<SanityMutationResult> {
52
238
  const res = await fetch(`${apiUrl}/data/mutate/${dataset}`, {
53
- method: 'POST', headers, body: JSON.stringify({ mutations }),
239
+ method: 'POST', headers: providerHeaders, body: JSON.stringify({ mutations }),
54
240
  });
55
241
  if (!res.ok) throw new Error(`Sanity mutation error: ${res.status}`);
56
242
  return res.json();
@@ -63,20 +249,21 @@ export function createSanityDataProvider(projectId: string, dataset: string, tok
63
249
  const { current = 1, pageSize = 10 } = pagination ?? {};
64
250
  const start = (current - 1) * pageSize;
65
251
  const end = start + pageSize;
66
- const filterStr = buildGroqFilter(filters);
67
- const typeFilter = `_type == "${resource}"`;
68
- const where = filterStr ? `${typeFilter} && ${filterStr}` : typeFilter;
69
- const orderStr = sorters?.length ? ` | order(${sorters.map((s: Sort) => `${s.field} ${s.order}`).join(', ')})` : '';
252
+ const filter = buildGroqFilters(filters);
253
+ const typeFilter = '_type == $resource';
254
+ const where = filter.expression ? `${typeFilter} && ${filter.expression}` : typeFilter;
255
+ const orderStr = buildGroqOrder(sorters);
256
+ const params = { resource, ...filter.params };
70
257
 
71
258
  const [data, total] = await Promise.all([
72
- query(`*[${where}]${orderStr}[${start}...${end}]`),
73
- query(`count(*[${where}])`),
259
+ query(`*[${where}]${orderStr}[${start}...${end}]`, params),
260
+ query(`count(*[${where}])`, params),
74
261
  ]);
75
262
  return { data: ((data as SanityDoc[]) ?? []).map((d) => ({ ...d, id: d._id })) as unknown as unknown as T[], total: (total as number) ?? 0 };
76
263
  },
77
264
 
78
265
  async getOne<T extends BaseRecord = BaseRecord>({ resource, id }: GetOneParams): Promise<GetOneResult<T>> {
79
- const raw = await query(`*[_type == "${resource}" && _id == $id][0]`, { id });
266
+ const raw = await query('*[_type == $resource && _id == $id][0]', { resource, id });
80
267
  const data = raw as SanityDoc;
81
268
  return { data: { ...data, id: data._id } as unknown as unknown as T };
82
269
  },
@@ -88,25 +275,34 @@ export function createSanityDataProvider(projectId: string, dataset: string, tok
88
275
  return { data: { ...(variables as Record<string, unknown>), id, _id: id } as unknown as unknown as T };
89
276
  },
90
277
 
91
- async update<T extends BaseRecord = BaseRecord>({ resource, id, variables }: UpdateParams): Promise<UpdateResult<T>> {
278
+ async update<T extends BaseRecord = BaseRecord>({ id, variables }: UpdateParams): Promise<UpdateResult<T>> {
92
279
  await mutate([{ patch: { id, set: variables } }]);
93
280
  return { data: { ...(variables as Record<string, unknown>), id, _id: id } as unknown as unknown as T };
94
281
  },
95
282
 
96
- async deleteOne<T extends BaseRecord = BaseRecord>({ resource, id }: DeleteParams): Promise<DeleteResult<T>> {
283
+ async deleteOne<T extends BaseRecord = BaseRecord>({ id }: DeleteParams): Promise<DeleteResult<T>> {
97
284
  await mutate([{ delete: { id } }]);
98
285
  return { data: { id } as unknown as unknown as T };
99
286
  },
100
287
 
101
288
  async getMany<T extends BaseRecord = BaseRecord>({ resource, ids }: GetManyParams): Promise<GetManyResult<T>> {
102
- const data = await query(`*[_type == "${resource}" && _id in $ids]`, { ids });
289
+ const data = await query('*[_type == $resource && _id in $ids]', { resource, ids });
103
290
  return { data: ((data as SanityDoc[]) ?? []).map((d) => ({ ...d, id: d._id })) as unknown as unknown as T[] };
104
291
  },
105
292
 
106
- async custom<T = unknown>({ url, method, payload, headers: h }: CustomParams): Promise<CustomResult<T>> {
107
- const res = await fetch(url, { method: method.toUpperCase(), headers: { ...headers, ...h }, body: payload ? JSON.stringify(payload) : undefined });
293
+ async custom<T = unknown>({ url, method, payload, query: customQuery, headers, sorters, filters }: CustomParams): Promise<CustomResult<T>> {
294
+ const requestUrl = buildCustomUrl(url, apiUrl, customQuery, sorters, filters);
295
+ const requestHeaders = mergeHeaders(
296
+ isSameOrigin(apiUrl, requestUrl) ? providerHeaders : DEFAULT_JSON_HEADERS,
297
+ headers,
298
+ );
299
+ const res = await fetch(requestUrl, {
300
+ method: method.toUpperCase(),
301
+ headers: requestHeaders,
302
+ body: payload === undefined ? undefined : JSON.stringify(payload),
303
+ });
108
304
  if (!res.ok) throw new Error(`Custom request failed: ${res.status}`);
109
- return { data: (await res.json()) as unknown as T };
305
+ return { data: await parseResponse<T>(res) };
110
306
  },
111
307
  };
112
308
  }