@svadmin/elysia 0.10.7 → 0.11.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/package.json +8 -4
- package/src/data-provider.ts +289 -62
- package/src/index.ts +7 -1
- package/src/types.ts +42 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/elysia",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Elysia DataProvider for svadmin — CRUD convention + InferResourceMap type utility",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
|
-
"@svadmin/core": "^0.
|
|
24
|
-
"elysia": ">=1.
|
|
25
|
-
"@elysiajs/eden": ">=1.
|
|
23
|
+
"@svadmin/core": "^0.35.0",
|
|
24
|
+
"elysia": ">=1.4.29",
|
|
25
|
+
"@elysiajs/eden": ">=1.4.9"
|
|
26
26
|
},
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"author": "zuohuadong",
|
|
@@ -41,5 +41,9 @@
|
|
|
41
41
|
"@elysiajs/eden": {
|
|
42
42
|
"optional": true
|
|
43
43
|
}
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"elysia": ">=1.4.29",
|
|
47
|
+
"@elysiajs/eden": ">=1.4.9"
|
|
44
48
|
}
|
|
45
49
|
}
|
package/src/data-provider.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
1
|
// Elysia DataProvider — CRUD convention compatible
|
|
3
2
|
// Expects backend routes following: GET /resource, GET /resource/:id, POST /resource, PATCH /resource/:id, DELETE /resource/:id
|
|
4
3
|
// Response format for lists: { items: T[], total: number } (also supports raw arrays)
|
|
@@ -8,9 +7,44 @@ import type {
|
|
|
8
7
|
CreateParams, CreateResult, UpdateParams, UpdateResult, DeleteParams, DeleteResult,
|
|
9
8
|
GetManyParams, GetManyResult, CreateManyParams, CreateManyResult,
|
|
10
9
|
UpdateManyParams, UpdateManyResult, DeleteManyParams, DeleteManyResult,
|
|
11
|
-
CustomParams, CustomResult, BaseRecord,
|
|
10
|
+
CustomParams, CustomResult, BaseRecord, FieldFilter, Filter, Sort, Pagination,
|
|
12
11
|
} from '@svadmin/core';
|
|
13
12
|
|
|
13
|
+
export interface ElysiaResourceContext {
|
|
14
|
+
apiUrl: string;
|
|
15
|
+
resource: string;
|
|
16
|
+
meta?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ElysiaListContext extends ElysiaResourceContext {
|
|
20
|
+
pagination: {
|
|
21
|
+
current: number;
|
|
22
|
+
pageSize: number;
|
|
23
|
+
mode?: Pagination['mode'];
|
|
24
|
+
};
|
|
25
|
+
sorters: Sort[];
|
|
26
|
+
filters: Filter[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ElysiaResourceMatcher = string | RegExp | ((resource: string) => boolean);
|
|
30
|
+
|
|
31
|
+
/** Per-resource transport overrides for APIs with mixed URL/query/envelope dialects. */
|
|
32
|
+
export interface ElysiaResourceAdapter {
|
|
33
|
+
match: ElysiaResourceMatcher;
|
|
34
|
+
/**
|
|
35
|
+
* Path appended to `apiUrl`. Encode dynamic path segments inside the resolver;
|
|
36
|
+
* the provider intentionally does not encode the complete resource path.
|
|
37
|
+
*/
|
|
38
|
+
resourcePath?: string | ((context: ElysiaResourceContext) => string);
|
|
39
|
+
/** Build the complete query string for list requests. */
|
|
40
|
+
buildListSearchParams?: (context: ElysiaListContext) => URLSearchParams;
|
|
41
|
+
/** Normalize a resource-specific list envelope while retaining extra result metadata. */
|
|
42
|
+
parseListResponse?: <TData extends BaseRecord = BaseRecord>(
|
|
43
|
+
json: unknown,
|
|
44
|
+
context: ElysiaListContext,
|
|
45
|
+
) => GetListResult<TData>;
|
|
46
|
+
}
|
|
47
|
+
|
|
14
48
|
export interface ElysiaDataProviderOptions {
|
|
15
49
|
/** Base API URL, e.g. 'http://localhost:3000' */
|
|
16
50
|
apiUrl: string;
|
|
@@ -41,22 +75,195 @@ export interface ElysiaDataProviderOptions {
|
|
|
41
75
|
*
|
|
42
76
|
* @default Handles `{ items, total }` and raw arrays automatically
|
|
43
77
|
*/
|
|
44
|
-
parseListResponse?: <
|
|
78
|
+
parseListResponse?: <TData extends BaseRecord = BaseRecord>(
|
|
79
|
+
json: unknown,
|
|
80
|
+
resource: string,
|
|
81
|
+
) => GetListResult<TData>;
|
|
82
|
+
/**
|
|
83
|
+
* Ordered per-resource transport overrides. The first matching adapter wins.
|
|
84
|
+
* Existing global options remain the fallback for unmatched resources.
|
|
85
|
+
*/
|
|
86
|
+
resourceAdapters?: readonly ElysiaResourceAdapter[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const DEFAULT_JSON_HEADERS: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
90
|
+
|
|
91
|
+
type RequestOptions = Omit<RequestInit, 'headers'> & {
|
|
92
|
+
headers?: Record<string, string>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
function mergeHeaders(...sources: Array<Record<string, string> | undefined>): Record<string, string> {
|
|
96
|
+
const merged = new Map<string, readonly [name: string, value: string]>();
|
|
97
|
+
|
|
98
|
+
for (const source of sources) {
|
|
99
|
+
if (!source) continue;
|
|
100
|
+
for (const [name, value] of Object.entries(source)) {
|
|
101
|
+
merged.set(name.toLowerCase(), [name, value]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return Object.fromEntries(merged.values());
|
|
45
106
|
}
|
|
46
107
|
|
|
47
108
|
function resolveHeaders(opts: ElysiaDataProviderOptions): Record<string, string> {
|
|
48
|
-
const base: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
49
109
|
const extra = typeof opts.headers === 'function' ? opts.headers() : (opts.headers ?? {});
|
|
50
|
-
return
|
|
110
|
+
return mergeHeaders(DEFAULT_JSON_HEADERS, extra);
|
|
51
111
|
}
|
|
52
112
|
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
return
|
|
113
|
+
function matchesResource(matcher: ElysiaResourceMatcher, resource: string): boolean {
|
|
114
|
+
if (typeof matcher === 'string') return matcher === resource;
|
|
115
|
+
if (typeof matcher === 'function') return matcher(resource);
|
|
116
|
+
matcher.lastIndex = 0;
|
|
117
|
+
return matcher.test(resource);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveResourceAdapter(
|
|
121
|
+
opts: ElysiaDataProviderOptions,
|
|
122
|
+
resource: string,
|
|
123
|
+
): ElysiaResourceAdapter | undefined {
|
|
124
|
+
return opts.resourceAdapters?.find(adapter => matchesResource(adapter.match, resource));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resolveResourceUrlWithAdapter(
|
|
128
|
+
opts: ElysiaDataProviderOptions,
|
|
129
|
+
context: ElysiaResourceContext,
|
|
130
|
+
adapter: ElysiaResourceAdapter | undefined,
|
|
131
|
+
): string {
|
|
132
|
+
const configuredPath = typeof adapter?.resourcePath === 'function'
|
|
133
|
+
? adapter.resourcePath(context)
|
|
134
|
+
: adapter?.resourcePath;
|
|
135
|
+
const path = configuredPath ?? opts.resourceUrlMap?.[context.resource] ?? context.resource;
|
|
136
|
+
return `${opts.apiUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveResourceUrl(
|
|
140
|
+
opts: ElysiaDataProviderOptions,
|
|
141
|
+
resource: string,
|
|
142
|
+
meta?: Record<string, unknown>,
|
|
143
|
+
): string {
|
|
144
|
+
const context: ElysiaResourceContext = { apiUrl: opts.apiUrl, resource, meta };
|
|
145
|
+
return resolveResourceUrlWithAdapter(opts, context, resolveResourceAdapter(opts, resource));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function encodeIdPathSegment(id: string | number): string {
|
|
149
|
+
return encodeURIComponent(String(id));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isSameOrigin(apiUrl: string, targetUrl: string): boolean {
|
|
153
|
+
try {
|
|
154
|
+
const api = new URL(apiUrl);
|
|
155
|
+
const target = new URL(targetUrl, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
|
|
156
|
+
return api.origin === target.origin;
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function serializeQueryValue(value: unknown): string {
|
|
163
|
+
if (value === null) return 'null';
|
|
164
|
+
if (typeof value === 'string') return value;
|
|
165
|
+
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
|
166
|
+
return String(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const serialized = JSON.stringify(value);
|
|
170
|
+
return serialized === undefined ? String(value) : serialized;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function appendQuery(params: URLSearchParams, query?: Record<string, unknown>): void {
|
|
174
|
+
if (!query) return;
|
|
175
|
+
|
|
176
|
+
for (const [name, value] of Object.entries(query)) {
|
|
177
|
+
if (value === undefined) continue;
|
|
178
|
+
if (Array.isArray(value)) {
|
|
179
|
+
for (const item of value) params.append(name, serializeQueryValue(item));
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
params.set(name, serializeQueryValue(value));
|
|
183
|
+
}
|
|
56
184
|
}
|
|
57
185
|
|
|
58
|
-
|
|
59
|
-
|
|
186
|
+
function appendSorters(params: URLSearchParams, sorters?: Sort[]): void {
|
|
187
|
+
if (!sorters?.length) return;
|
|
188
|
+
params.set('_sort', sorters.map(sorter => sorter.field).join(','));
|
|
189
|
+
params.set('_order', sorters.map(sorter => sorter.order).join(','));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function filterParamName(filter: FieldFilter): string {
|
|
193
|
+
if (filter.operator === 'eq') return filter.field;
|
|
194
|
+
if (filter.operator === 'contains') return `${filter.field}_like`;
|
|
195
|
+
return `${filter.field}_${filter.operator}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function filterParamValue(filter: FieldFilter): string {
|
|
199
|
+
if (filter.operator === 'null' || filter.operator === 'nnull') return 'true';
|
|
200
|
+
if (Array.isArray(filter.value)) {
|
|
201
|
+
return filter.value.map(serializeQueryValue).join(',');
|
|
202
|
+
}
|
|
203
|
+
return serializeQueryValue(filter.value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function appendFilters(params: URLSearchParams, filters?: Filter[]): void {
|
|
207
|
+
if (!filters?.length) return;
|
|
208
|
+
|
|
209
|
+
let hasLogicalFilter = false;
|
|
210
|
+
for (const filter of filters) {
|
|
211
|
+
if ('field' in filter) {
|
|
212
|
+
params.append(filterParamName(filter), filterParamValue(filter));
|
|
213
|
+
} else {
|
|
214
|
+
hasLogicalFilter = true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Flat filters keep the established field_operator convention. A canonical
|
|
219
|
+
// JSON copy is added only when a logical group is present, because OR/AND
|
|
220
|
+
// cannot be represented without losing nesting in flat query parameters.
|
|
221
|
+
if (hasLogicalFilter) params.set('_filters', JSON.stringify(filters));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function buildDefaultListSearchParams(context: ElysiaListContext): URLSearchParams {
|
|
225
|
+
const params = new URLSearchParams();
|
|
226
|
+
params.set('_page', String(context.pagination.current));
|
|
227
|
+
params.set('_limit', String(context.pagination.pageSize));
|
|
228
|
+
appendSorters(params, context.sorters);
|
|
229
|
+
appendFilters(params, context.filters);
|
|
230
|
+
return params;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function buildCustomUrl(
|
|
234
|
+
url: string,
|
|
235
|
+
apiUrl: string,
|
|
236
|
+
query?: Record<string, unknown>,
|
|
237
|
+
sorters?: Sort[],
|
|
238
|
+
filters?: Filter[],
|
|
239
|
+
): string {
|
|
240
|
+
const parsed = new URL(url, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
|
|
241
|
+
appendQuery(parsed.searchParams, query);
|
|
242
|
+
appendSorters(parsed.searchParams, sorters);
|
|
243
|
+
appendFilters(parsed.searchParams, filters);
|
|
244
|
+
return parsed.toString();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function parseResponse<T>(response: Response): Promise<T> {
|
|
248
|
+
if (response.status === 204 || response.status === 205) {
|
|
249
|
+
return undefined as unknown as T;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const contentLength = response.headers?.get('content-length');
|
|
253
|
+
if (contentLength?.trim() === '0') {
|
|
254
|
+
return undefined as unknown as T;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const body = await response.text();
|
|
258
|
+
if (!body || body.trim() === '') {
|
|
259
|
+
return undefined as unknown as T;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return JSON.parse(body) as T;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function request<T>(url: string, headers: Record<string, string>, init?: RequestOptions, withCredentials?: boolean): Promise<T> {
|
|
266
|
+
const fetchInit: RequestInit = { ...init, headers: mergeHeaders(headers, init?.headers) };
|
|
60
267
|
if (withCredentials) {
|
|
61
268
|
fetchInit.credentials = 'include';
|
|
62
269
|
}
|
|
@@ -65,7 +272,7 @@ async function request<T>(url: string, headers: Record<string, string>, init?: R
|
|
|
65
272
|
const body = await response.text().catch(() => '');
|
|
66
273
|
throw new Error(`HTTP ${response.status}: ${response.statusText}${body ? ` — ${body}` : ''}`);
|
|
67
274
|
}
|
|
68
|
-
return response
|
|
275
|
+
return parseResponse<T>(response);
|
|
69
276
|
}
|
|
70
277
|
|
|
71
278
|
/**
|
|
@@ -75,16 +282,29 @@ async function request<T>(url: string, headers: Record<string, string>, init?: R
|
|
|
75
282
|
* - `{ data: T[], total: number }` (common alternative)
|
|
76
283
|
* - `T[]` (raw array — total is inferred from array length)
|
|
77
284
|
*/
|
|
78
|
-
function
|
|
285
|
+
function normalizeObjectListResponse<TData extends BaseRecord>(
|
|
286
|
+
response: Record<string, unknown>,
|
|
287
|
+
recordsKey: 'items' | 'data',
|
|
288
|
+
): GetListResult<TData> {
|
|
289
|
+
const { [recordsKey]: rawRecords, ...metadata } = response;
|
|
290
|
+
const records = rawRecords as TData[];
|
|
291
|
+
return {
|
|
292
|
+
...metadata,
|
|
293
|
+
data: records,
|
|
294
|
+
total: response.total !== undefined ? Number(response.total) : records.length,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function defaultParseListResponse<TData extends BaseRecord>(json: unknown): GetListResult<TData> {
|
|
79
299
|
if (Array.isArray(json)) {
|
|
80
|
-
return { data: json as
|
|
300
|
+
return { data: json as TData[], total: json.length };
|
|
81
301
|
}
|
|
82
302
|
const obj = json as Record<string, unknown>;
|
|
83
303
|
if (Array.isArray(obj.items)) {
|
|
84
|
-
return
|
|
304
|
+
return normalizeObjectListResponse<TData>(obj, 'items');
|
|
85
305
|
}
|
|
86
306
|
if (Array.isArray(obj.data)) {
|
|
87
|
-
return
|
|
307
|
+
return normalizeObjectListResponse<TData>(obj, 'data');
|
|
88
308
|
}
|
|
89
309
|
throw new Error('Unrecognized list response format. Expected { items, total }, { data, total }, or an array.');
|
|
90
310
|
}
|
|
@@ -115,44 +335,41 @@ export function createElysiaDataProvider(opts: ElysiaDataProviderOptions): DataP
|
|
|
115
335
|
return {
|
|
116
336
|
getApiUrl: () => apiUrl,
|
|
117
337
|
|
|
118
|
-
async getList<TData extends BaseRecord = BaseRecord>({ resource, pagination, sorters, filters }: GetListParams): Promise<GetListResult<TData>> {
|
|
119
|
-
const params = new URLSearchParams();
|
|
338
|
+
async getList<TData extends BaseRecord = BaseRecord>({ resource, pagination, sorters, filters, meta }: GetListParams): Promise<GetListResult<TData>> {
|
|
120
339
|
const { current = 1, pageSize = 10 } = pagination ?? {};
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const baseUrl = resolveResourceUrl(opts, resource);
|
|
138
|
-
const url = `${baseUrl}?${params.toString()}`;
|
|
340
|
+
const context: ElysiaListContext = {
|
|
341
|
+
apiUrl,
|
|
342
|
+
resource,
|
|
343
|
+
meta,
|
|
344
|
+
pagination: { current, pageSize, mode: pagination?.mode },
|
|
345
|
+
sorters: sorters ?? [],
|
|
346
|
+
filters: filters ?? [],
|
|
347
|
+
};
|
|
348
|
+
const adapter = resolveResourceAdapter(opts, resource);
|
|
349
|
+
const params = adapter?.buildListSearchParams?.(context) ?? buildDefaultListSearchParams(context);
|
|
350
|
+
const baseUrl = resolveResourceUrlWithAdapter(opts, context, adapter);
|
|
351
|
+
const query = params.toString();
|
|
352
|
+
const url = query ? `${baseUrl}?${query}` : baseUrl;
|
|
139
353
|
const headers = resolveHeaders(opts);
|
|
140
354
|
const json = await request<unknown>(url, headers, undefined, withCredentials);
|
|
141
355
|
|
|
356
|
+
if (adapter?.parseListResponse) {
|
|
357
|
+
return adapter.parseListResponse<TData>(json, context);
|
|
358
|
+
}
|
|
142
359
|
if (opts.parseListResponse) {
|
|
143
360
|
return opts.parseListResponse<TData>(json, resource);
|
|
144
361
|
}
|
|
145
362
|
return defaultParseListResponse<TData>(json);
|
|
146
363
|
},
|
|
147
364
|
|
|
148
|
-
async getOne<TData extends BaseRecord = BaseRecord>({ resource, id }: GetOneParams): Promise<GetOneResult<TData>> {
|
|
149
|
-
const baseUrl = resolveResourceUrl(opts, resource);
|
|
150
|
-
const data = await request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), undefined, withCredentials);
|
|
365
|
+
async getOne<TData extends BaseRecord = BaseRecord>({ resource, id, meta }: GetOneParams): Promise<GetOneResult<TData>> {
|
|
366
|
+
const baseUrl = resolveResourceUrl(opts, resource, meta);
|
|
367
|
+
const data = await request<TData>(`${baseUrl}/${encodeIdPathSegment(id)}`, resolveHeaders(opts), undefined, withCredentials);
|
|
151
368
|
return { data };
|
|
152
369
|
},
|
|
153
370
|
|
|
154
|
-
async create<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, variables }: CreateParams<TVariables>): Promise<CreateResult<TData>> {
|
|
155
|
-
const baseUrl = resolveResourceUrl(opts, resource);
|
|
371
|
+
async create<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, variables, meta }: CreateParams<TVariables>): Promise<CreateResult<TData>> {
|
|
372
|
+
const baseUrl = resolveResourceUrl(opts, resource, meta);
|
|
156
373
|
const data = await request<TData>(baseUrl, resolveHeaders(opts), {
|
|
157
374
|
method: 'POST',
|
|
158
375
|
body: JSON.stringify(variables),
|
|
@@ -160,32 +377,32 @@ export function createElysiaDataProvider(opts: ElysiaDataProviderOptions): DataP
|
|
|
160
377
|
return { data };
|
|
161
378
|
},
|
|
162
379
|
|
|
163
|
-
async update<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, id, variables }: UpdateParams<TVariables>): Promise<UpdateResult<TData>> {
|
|
164
|
-
const baseUrl = resolveResourceUrl(opts, resource);
|
|
165
|
-
const data = await request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), {
|
|
380
|
+
async update<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, id, variables, meta }: UpdateParams<TVariables>): Promise<UpdateResult<TData>> {
|
|
381
|
+
const baseUrl = resolveResourceUrl(opts, resource, meta);
|
|
382
|
+
const data = await request<TData>(`${baseUrl}/${encodeIdPathSegment(id)}`, resolveHeaders(opts), {
|
|
166
383
|
method: updateMethod,
|
|
167
384
|
body: JSON.stringify(variables),
|
|
168
385
|
}, withCredentials);
|
|
169
386
|
return { data };
|
|
170
387
|
},
|
|
171
388
|
|
|
172
|
-
async deleteOne<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, id }: DeleteParams<TVariables>): Promise<DeleteResult<TData>> {
|
|
173
|
-
const baseUrl = resolveResourceUrl(opts, resource);
|
|
174
|
-
const data = await request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), {
|
|
389
|
+
async deleteOne<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, id, meta }: DeleteParams<TVariables>): Promise<DeleteResult<TData>> {
|
|
390
|
+
const baseUrl = resolveResourceUrl(opts, resource, meta);
|
|
391
|
+
const data = await request<TData | undefined>(`${baseUrl}/${encodeIdPathSegment(id)}`, resolveHeaders(opts), {
|
|
175
392
|
method: 'DELETE',
|
|
176
393
|
}, withCredentials);
|
|
177
|
-
return { data };
|
|
394
|
+
return { data: data === undefined ? { id } as unknown as TData : data };
|
|
178
395
|
},
|
|
179
396
|
|
|
180
|
-
async getMany<TData extends BaseRecord = BaseRecord>({ resource, ids }: GetManyParams): Promise<GetManyResult<TData>> {
|
|
181
|
-
const baseUrl = resolveResourceUrl(opts, resource);
|
|
397
|
+
async getMany<TData extends BaseRecord = BaseRecord>({ resource, ids, meta }: GetManyParams): Promise<GetManyResult<TData>> {
|
|
398
|
+
const baseUrl = resolveResourceUrl(opts, resource, meta);
|
|
182
399
|
const params = ids.map(id => `id=${encodeURIComponent(String(id))}`).join('&');
|
|
183
400
|
const data = await request<TData[]>(`${baseUrl}?${params}`, resolveHeaders(opts), undefined, withCredentials);
|
|
184
401
|
return { data };
|
|
185
402
|
},
|
|
186
403
|
|
|
187
|
-
async createMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, variables }: CreateManyParams<TVariables>): Promise<CreateManyResult<TData>> {
|
|
188
|
-
const baseUrl = resolveResourceUrl(opts, resource);
|
|
404
|
+
async createMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, variables, meta }: CreateManyParams<TVariables>): Promise<CreateManyResult<TData>> {
|
|
405
|
+
const baseUrl = resolveResourceUrl(opts, resource, meta);
|
|
189
406
|
const results = await Promise.all(
|
|
190
407
|
variables.map(vars =>
|
|
191
408
|
request<TData>(baseUrl, resolveHeaders(opts), {
|
|
@@ -197,11 +414,11 @@ export function createElysiaDataProvider(opts: ElysiaDataProviderOptions): DataP
|
|
|
197
414
|
return { data: results };
|
|
198
415
|
},
|
|
199
416
|
|
|
200
|
-
async updateMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, ids, variables }: UpdateManyParams<TVariables>): Promise<UpdateManyResult<TData>> {
|
|
201
|
-
const baseUrl = resolveResourceUrl(opts, resource);
|
|
417
|
+
async updateMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, ids, variables, meta }: UpdateManyParams<TVariables>): Promise<UpdateManyResult<TData>> {
|
|
418
|
+
const baseUrl = resolveResourceUrl(opts, resource, meta);
|
|
202
419
|
const results = await Promise.all(
|
|
203
420
|
ids.map(id =>
|
|
204
|
-
request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), {
|
|
421
|
+
request<TData>(`${baseUrl}/${encodeIdPathSegment(id)}`, resolveHeaders(opts), {
|
|
205
422
|
method: updateMethod,
|
|
206
423
|
body: JSON.stringify(variables),
|
|
207
424
|
}, withCredentials)
|
|
@@ -210,23 +427,33 @@ export function createElysiaDataProvider(opts: ElysiaDataProviderOptions): DataP
|
|
|
210
427
|
return { data: results };
|
|
211
428
|
},
|
|
212
429
|
|
|
213
|
-
async deleteMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, ids }: DeleteManyParams<TVariables>): Promise<DeleteManyResult<TData>> {
|
|
214
|
-
const baseUrl = resolveResourceUrl(opts, resource);
|
|
430
|
+
async deleteMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>({ resource, ids, meta }: DeleteManyParams<TVariables>): Promise<DeleteManyResult<TData>> {
|
|
431
|
+
const baseUrl = resolveResourceUrl(opts, resource, meta);
|
|
215
432
|
const results = await Promise.all(
|
|
216
433
|
ids.map(id =>
|
|
217
|
-
request<TData>(`${baseUrl}/${id}`, resolveHeaders(opts), {
|
|
434
|
+
request<TData | undefined>(`${baseUrl}/${encodeIdPathSegment(id)}`, resolveHeaders(opts), {
|
|
218
435
|
method: 'DELETE',
|
|
219
|
-
}, withCredentials)
|
|
436
|
+
}, withCredentials).then(data => data === undefined ? { id } as unknown as TData : data)
|
|
220
437
|
)
|
|
221
438
|
);
|
|
222
439
|
return { data: results };
|
|
223
440
|
},
|
|
224
441
|
|
|
225
|
-
async custom<TData = unknown, TVariables = unknown>({ url, method, payload, headers }: CustomParams<TVariables>): Promise<CustomResult<TData>> {
|
|
226
|
-
const
|
|
442
|
+
async custom<TData = unknown, TVariables = unknown>({ url, method, payload, query, headers, sorters, filters }: CustomParams<TVariables>): Promise<CustomResult<TData>> {
|
|
443
|
+
const requestUrl = buildCustomUrl(url, apiUrl, query, sorters, filters);
|
|
444
|
+
const sameOrigin = isSameOrigin(apiUrl, requestUrl);
|
|
445
|
+
const providerHeaders = resolveHeaders(opts);
|
|
446
|
+
const requestHeaders = mergeHeaders(
|
|
447
|
+
// Provider-level headers often contain credentials under application-specific
|
|
448
|
+
// names. Never inherit them across origins; callers must opt in explicitly via
|
|
449
|
+
// custom.headers for the target service.
|
|
450
|
+
sameOrigin ? providerHeaders : DEFAULT_JSON_HEADERS,
|
|
451
|
+
headers,
|
|
452
|
+
);
|
|
453
|
+
const data = await request<TData>(requestUrl, requestHeaders, {
|
|
227
454
|
method: method.toUpperCase(),
|
|
228
|
-
body: payload ? JSON.stringify(payload)
|
|
229
|
-
}, withCredentials);
|
|
455
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
456
|
+
}, withCredentials && sameOrigin);
|
|
230
457
|
return { data };
|
|
231
458
|
},
|
|
232
459
|
};
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// @svadmin/elysia — Elysia DataProvider + type utilities
|
|
2
2
|
|
|
3
3
|
export { createElysiaDataProvider } from './data-provider';
|
|
4
|
-
export type {
|
|
4
|
+
export type {
|
|
5
|
+
ElysiaDataProviderOptions,
|
|
6
|
+
ElysiaListContext,
|
|
7
|
+
ElysiaResourceAdapter,
|
|
8
|
+
ElysiaResourceContext,
|
|
9
|
+
ElysiaResourceMatcher,
|
|
10
|
+
} from './data-provider';
|
|
5
11
|
export type { InferResourceMap } from './types';
|
package/src/types.ts
CHANGED
|
@@ -39,15 +39,46 @@
|
|
|
39
39
|
* }
|
|
40
40
|
* ```
|
|
41
41
|
*/
|
|
42
|
-
|
|
43
|
-
?
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
42
|
+
type SuccessfulResponse<Response> = Response extends { 200: infer Payload }
|
|
43
|
+
? Payload
|
|
44
|
+
: Response extends { '200': infer Payload }
|
|
45
|
+
? Payload
|
|
46
|
+
: Response;
|
|
47
|
+
|
|
48
|
+
type ResourceItem<Response> = SuccessfulResponse<Response> extends infer Payload
|
|
49
|
+
? Payload extends { items: readonly (infer Item)[] }
|
|
50
|
+
? Item
|
|
51
|
+
: Payload extends { data: readonly (infer Item)[] }
|
|
52
|
+
? Item
|
|
53
|
+
: Payload extends readonly (infer Item)[]
|
|
54
|
+
? Item
|
|
51
55
|
: never
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
: never;
|
|
57
|
+
|
|
58
|
+
type RouteResource<Route> = Route extends { get: { response: infer Response } }
|
|
59
|
+
? ResourceItem<Response>
|
|
60
|
+
: never;
|
|
61
|
+
|
|
62
|
+
type InferEdenResourceMap<Routes> = {
|
|
63
|
+
[Resource in keyof Routes as Resource extends string
|
|
64
|
+
? Routes[Resource] extends { get: { response: unknown } }
|
|
65
|
+
? [RouteResource<Routes[Resource]>] extends [never] ? never : Resource
|
|
66
|
+
: never
|
|
67
|
+
: never
|
|
68
|
+
]: RouteResource<Routes[Resource]>
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type InferLegacyResourceMap<Routes> = {
|
|
72
|
+
[Path in keyof Routes as Path extends `/${infer Resource}`
|
|
73
|
+
? Resource extends `${string}/${string}`
|
|
74
|
+
? never
|
|
75
|
+
: [RouteResource<Routes[Path]>] extends [never] ? never : Resource
|
|
76
|
+
: never
|
|
77
|
+
]: RouteResource<Routes[Path]>
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type InferResourceMap<App> = App extends { '~Routes': infer Routes }
|
|
81
|
+
? InferEdenResourceMap<Routes>
|
|
82
|
+
: App extends { _routes: infer Routes }
|
|
83
|
+
? InferLegacyResourceMap<Routes>
|
|
84
|
+
: Record<string, never>;
|