@svadmin/sanity 0.9.4 → 0.9.8
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/package.json +8 -4
- package/src/data-provider.ts +231 -35
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/sanity",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
4
4
|
"description": "Sanity DataProvider for svadmin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"files": [
|
|
8
|
-
"src
|
|
8
|
+
"src/**/*.ts",
|
|
9
|
+
"!src/**/*.test.*",
|
|
10
|
+
"!src/**/*.spec.*",
|
|
11
|
+
"!src/**/vitest.config.*",
|
|
12
|
+
"!src/**/setupTest.*"
|
|
9
13
|
],
|
|
10
14
|
"main": "src/index.ts",
|
|
11
15
|
"types": "src/index.ts",
|
|
@@ -16,12 +20,12 @@
|
|
|
16
20
|
}
|
|
17
21
|
},
|
|
18
22
|
"peerDependencies": {
|
|
19
|
-
"@svadmin/core": "^0.
|
|
23
|
+
"@svadmin/core": "^0.41.1"
|
|
20
24
|
},
|
|
21
25
|
"license": "MIT",
|
|
22
26
|
"repository": {
|
|
23
27
|
"type": "git",
|
|
24
|
-
"url": "https://github.com/
|
|
28
|
+
"url": "https://github.com/vibeunion/svadmin.git",
|
|
25
29
|
"directory": "packages/sanity"
|
|
26
30
|
},
|
|
27
31
|
"peerDependenciesMeta": {
|
package/src/data-provider.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
39
|
-
if (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
|
|
67
|
-
const typeFilter =
|
|
68
|
-
const where =
|
|
69
|
-
const orderStr =
|
|
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(
|
|
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>({
|
|
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>({
|
|
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(
|
|
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,
|
|
107
|
-
const
|
|
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:
|
|
305
|
+
return { data: await parseResponse<T>(res) };
|
|
110
306
|
},
|
|
111
307
|
};
|
|
112
308
|
}
|