adorn-api 1.1.2 → 1.1.3

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.
@@ -1,17 +1,31 @@
1
-
1
+ import type { ErrorResponseOptions, FieldOverride } from "../../core/decorators";
2
+ import { Errors } from "../../core/decorators";
3
+ import { registerDto, type FieldMeta } from "../../core/metadata";
4
+ import { t } from "../../core/schema";
2
5
  import type { DtoConstructor } from "../../core/types";
3
6
  import { MetalDto } from "./dto";
7
+ import { createErrorDtoClass } from "./error-dtos";
8
+ import { createPagedResponseDtoClass } from "./paged-dtos";
4
9
  import type {
5
- MetalCrudDtoOptions,
10
+ FilterMapping,
11
+ MetalCrudDtoClassNameKey,
6
12
  MetalCrudDtoClassOptions,
7
- MetalCrudDtoDecorators,
8
13
  MetalCrudDtoClasses,
9
- NestedCreateDtoOptions
14
+ MetalCrudDtoDecorators,
15
+ MetalCrudDtoOptions,
16
+ MetalCrudQueryFilterDef,
17
+ MetalCrudSortableColumns,
18
+ MetalCrudStandardErrorsOptions,
19
+ MetalDtoOptions,
20
+ MetalDtoTarget,
21
+ NestedCreateDtoOptions,
22
+ RouteErrorsDecorator,
23
+ SortDirection
10
24
  } from "./types";
11
-
12
- export function createMetalCrudDtos(
13
- target: any,
14
- options: MetalCrudDtoOptions = {}
25
+
26
+ export function createMetalCrudDtos<TEntity extends Record<string, unknown>>(
27
+ target: MetalDtoTarget,
28
+ options: MetalCrudDtoOptions<TEntity> = {}
15
29
  ): MetalCrudDtoDecorators {
16
30
  const mutationExclude = options.mutationExclude;
17
31
  const immutable = options.immutable;
@@ -45,101 +59,357 @@ export function createMetalCrudDtos(
45
59
  params: MetalDto(target, params)
46
60
  };
47
61
  }
48
-
49
- export function createMetalCrudDtoClasses(
50
- target: any,
51
- options: MetalCrudDtoClassOptions = {}
52
- ): MetalCrudDtoClasses {
53
- const { baseName, names, ...crudOptions } = options;
54
- const decorators = createMetalCrudDtos(target, crudOptions);
55
- const entityName = baseName ?? getTargetName(target);
56
- const defaultNames: Record<keyof MetalCrudDtoDecorators, string> = {
57
- response: `${entityName}Dto`,
58
- create: `Create${entityName}Dto`,
59
- replace: `Replace${entityName}Dto`,
60
- update: `Update${entityName}Dto`,
61
- params: `${entityName}ParamsDto`
62
- };
63
-
64
- const classes: Partial<MetalCrudDtoClasses> = {};
65
- for (const key of Object.keys(decorators) as Array<keyof MetalCrudDtoDecorators>) {
66
- const name = names?.[key] ?? defaultNames[key];
67
- classes[key] = buildDtoClass(name, decorators[key]);
68
- }
69
- return classes as MetalCrudDtoClasses;
70
- }
71
-
72
- export function createNestedCreateDtoClass(
73
- target: any,
74
- overrides: Record<string, any>,
75
- options: NestedCreateDtoOptions
76
- ): DtoConstructor {
77
- const { additionalExclude, name, parentEntity: _parentEntity, ...metalDtoOptions } = options;
78
-
79
- const allExcludes = mergeStringArrays(
80
- ["id", "createdAt"],
81
- additionalExclude,
82
- metalDtoOptions.exclude
83
- );
84
-
85
- @MetalDto(target, {
86
- ...metalDtoOptions,
87
- mode: "create",
88
- exclude: allExcludes,
89
- overrides
90
- })
91
- class NestedCreateDto {}
92
-
93
- Object.defineProperty(NestedCreateDto, "name", { value: name, configurable: true });
94
-
95
- return NestedCreateDto;
96
- }
97
-
98
- function buildDtoClass(name: string, decorator: (target: DtoConstructor) => void): DtoConstructor {
99
- const DtoClass = class {};
100
- Object.defineProperty(DtoClass, "name", { value: name, configurable: true });
101
- decorator(DtoClass);
102
- return DtoClass;
103
- }
104
-
105
- function getTargetName(target: any): string {
106
- if (typeof target === "function" && target.name) {
107
- return target.name;
108
- }
109
- return "Entity";
110
- }
111
-
112
- function mergeOverrides(
113
- base?: Record<string, any>,
114
- override?: Record<string, any>
115
- ): Record<string, any> | undefined {
116
- if (!base && !override) {
117
- return undefined;
118
- }
119
- return { ...(base ?? {}), ...(override ?? {}) };
120
- }
121
-
122
- function mergeStringArrays(
123
- ...entries: Array<string[] | undefined>
124
- ): string[] | undefined {
125
- const merged = new Set<string>();
126
- for (const entry of entries) {
127
- for (const value of entry ?? []) {
128
- merged.add(value);
129
- }
130
- }
131
- return merged.size ? Array.from(merged) : undefined;
132
- }
133
-
134
- function buildCrudOptions(
135
- base: any | undefined,
136
- overrides: Record<string, any> | undefined,
137
- extra: any = {}
138
- ): any {
139
- const mergedOverrides = mergeOverrides(overrides, base?.overrides);
140
- const output: any = { ...base, ...extra };
141
- if (mergedOverrides) {
142
- output.overrides = mergedOverrides;
143
- }
144
- return output;
145
- }
62
+
63
+ export function createMetalCrudDtoClasses<TEntity extends Record<string, unknown>>(
64
+ target: MetalDtoTarget,
65
+ options: MetalCrudDtoClassOptions<TEntity> = {}
66
+ ): MetalCrudDtoClasses<TEntity> {
67
+ const { baseName, names, query, errors: errorOptions, ...crudOptions } = options;
68
+ const decorators = createMetalCrudDtos(target, crudOptions);
69
+ const entityName = baseName ?? getTargetName(target);
70
+
71
+ const defaultNames: Record<MetalCrudDtoClassNameKey, string> = {
72
+ response: `${entityName}Dto`,
73
+ create: `Create${entityName}Dto`,
74
+ replace: `Replace${entityName}Dto`,
75
+ update: `Update${entityName}Dto`,
76
+ params: `${entityName}ParamsDto`,
77
+ queryDto: `${entityName}QueryDto`,
78
+ optionsQueryDto: `${entityName}OptionsQueryDto`,
79
+ pagedResponseDto: `${entityName}PagedResponseDto`,
80
+ optionDto: `${entityName}OptionDto`,
81
+ optionsDto: `${entityName}OptionsDto`
82
+ };
83
+
84
+ const crudClasses: Partial<MetalCrudDtoClasses<TEntity>> = {};
85
+ for (const key of Object.keys(decorators) as Array<keyof MetalCrudDtoDecorators>) {
86
+ const name = names?.[key] ?? defaultNames[key];
87
+ crudClasses[key] = buildDtoClass(name, decorators[key]);
88
+ }
89
+
90
+ const strict = options.strict ?? false;
91
+ const queryOptions = query ?? {};
92
+ const sortByKey = queryOptions.sortByKey ?? "sortBy";
93
+ const sortDirectionKey = queryOptions.sortDirectionKey ?? "sortDirection";
94
+ const defaultPageSize = queryOptions.defaultPageSize ?? 25;
95
+ const maxPageSize = queryOptions.maxPageSize ?? 100;
96
+ const filters = queryOptions.filters ?? {};
97
+ const sortableColumns = cloneSortableColumns(queryOptions.sortableColumns ?? {});
98
+ const filterMappings = buildFilterMappings(filters);
99
+
100
+ const optionsQueryConfig = queryOptions.options ?? {};
101
+ const optionsEnabled = optionsQueryConfig.enabled ?? true;
102
+ const optionsSearchKey = optionsQueryConfig.searchKey ?? "search";
103
+ const optionsLabelField = (optionsQueryConfig.labelField ?? "nome") as keyof TEntity & string;
104
+ const optionsValueField = (optionsQueryConfig.valueField ?? "id") as keyof TEntity & string;
105
+ const optionsSearchOperator = optionsQueryConfig.searchOperator ?? "contains";
106
+ const optionsDefaultPageSize = optionsQueryConfig.defaultPageSize ?? defaultPageSize;
107
+ const optionsMaxPageSize = optionsQueryConfig.maxPageSize ?? maxPageSize;
108
+
109
+ if (optionsEnabled && !(optionsSearchKey in filterMappings)) {
110
+ filterMappings[optionsSearchKey] = {
111
+ field: optionsLabelField,
112
+ operator: optionsSearchOperator
113
+ };
114
+ }
115
+
116
+ const queryDtoName = names?.queryDto ?? defaultNames.queryDto;
117
+ const optionsQueryDtoName = names?.optionsQueryDto ?? defaultNames.optionsQueryDto;
118
+ const pagedResponseDtoName = names?.pagedResponseDto ?? defaultNames.pagedResponseDto;
119
+ const optionDtoName = names?.optionDto ?? defaultNames.optionDto;
120
+ const optionsDtoName = names?.optionsDto ?? defaultNames.optionsDto;
121
+
122
+ const queryDto = createQueryDtoClass({
123
+ name: queryDtoName,
124
+ filters,
125
+ sortableColumns,
126
+ sortByKey,
127
+ sortDirectionKey,
128
+ defaultSortBy: queryOptions.defaultSortBy,
129
+ defaultSortDirection: queryOptions.defaultSortDirection ?? "asc",
130
+ defaultPageSize,
131
+ maxPageSize
132
+ });
133
+
134
+ const optionsQueryDto = createOptionsQueryDtoClass({
135
+ name: optionsQueryDtoName,
136
+ filters,
137
+ sortableColumns,
138
+ sortByKey,
139
+ sortDirectionKey,
140
+ defaultSortBy: queryOptions.defaultSortBy,
141
+ defaultSortDirection: queryOptions.defaultSortDirection ?? "asc",
142
+ defaultPageSize: optionsDefaultPageSize,
143
+ maxPageSize: optionsMaxPageSize,
144
+ searchKey: optionsEnabled ? optionsSearchKey : undefined
145
+ });
146
+
147
+ const optionDto = buildDtoClass(
148
+ optionDtoName,
149
+ MetalDto(target, {
150
+ include: Array.from(new Set([optionsValueField, optionsLabelField])),
151
+ strict
152
+ })
153
+ );
154
+
155
+ const responseDto = crudClasses.response as DtoConstructor;
156
+ const pagedResponseDto = createPagedResponseDtoClass({
157
+ name: pagedResponseDtoName,
158
+ itemDto: responseDto,
159
+ description: `Paged ${entityName} response.`
160
+ });
161
+ const optionsDto = createPagedResponseDtoClass({
162
+ name: optionsDtoName,
163
+ itemDto: optionDto,
164
+ description: `${entityName} options response.`
165
+ });
166
+
167
+ const errors = buildStandardCrudErrors(entityName, errorOptions);
168
+
169
+ return {
170
+ response: crudClasses.response as DtoConstructor,
171
+ create: crudClasses.create as DtoConstructor,
172
+ replace: crudClasses.replace as DtoConstructor,
173
+ update: crudClasses.update as DtoConstructor,
174
+ params: crudClasses.params as DtoConstructor,
175
+ queryDto,
176
+ optionsQueryDto,
177
+ pagedResponseDto,
178
+ optionDto,
179
+ optionsDto,
180
+ errors,
181
+ filterMappings,
182
+ sortableColumns
183
+ };
184
+ }
185
+
186
+ export function createNestedCreateDtoClass(
187
+ target: MetalDtoTarget,
188
+ overrides: Record<string, FieldOverride>,
189
+ options: NestedCreateDtoOptions
190
+ ): DtoConstructor {
191
+ const { additionalExclude, name, parentEntity: _parentEntity, ...metalDtoOptions } = options;
192
+
193
+ const allExcludes = mergeStringArrays(
194
+ ["id", "createdAt"],
195
+ additionalExclude,
196
+ metalDtoOptions.exclude
197
+ );
198
+
199
+ @MetalDto(target, {
200
+ ...metalDtoOptions,
201
+ mode: "create",
202
+ exclude: allExcludes,
203
+ overrides
204
+ })
205
+ class NestedCreateDto {}
206
+
207
+ Object.defineProperty(NestedCreateDto, "name", { value: name, configurable: true });
208
+
209
+ return NestedCreateDto;
210
+ }
211
+
212
+ interface CreateQueryDtoClassOptions<TEntity extends Record<string, unknown>> {
213
+ name: string;
214
+ filters: Record<string, MetalCrudQueryFilterDef<TEntity>>;
215
+ sortableColumns: MetalCrudSortableColumns<TEntity>;
216
+ sortByKey: string;
217
+ sortDirectionKey: string;
218
+ defaultSortBy?: string;
219
+ defaultSortDirection: SortDirection;
220
+ defaultPageSize: number;
221
+ maxPageSize: number;
222
+ }
223
+
224
+ interface CreateOptionsQueryDtoClassOptions<TEntity extends Record<string, unknown>>
225
+ extends CreateQueryDtoClassOptions<TEntity> {
226
+ searchKey?: string;
227
+ }
228
+
229
+ function createQueryDtoClass<TEntity extends Record<string, unknown>>(
230
+ options: CreateQueryDtoClassOptions<TEntity>
231
+ ): DtoConstructor {
232
+ const fields = buildQueryFields(options);
233
+ return createRegisteredDtoClass(options.name, fields);
234
+ }
235
+
236
+ function createOptionsQueryDtoClass<TEntity extends Record<string, unknown>>(
237
+ options: CreateOptionsQueryDtoClassOptions<TEntity>
238
+ ): DtoConstructor {
239
+ const fields = buildQueryFields(options);
240
+ if (options.searchKey) {
241
+ fields[options.searchKey] = {
242
+ schema: t.optional(t.string({ minLength: 1 })),
243
+ optional: true
244
+ };
245
+ }
246
+ return createRegisteredDtoClass(options.name, fields);
247
+ }
248
+
249
+ function buildQueryFields<TEntity extends Record<string, unknown>>(
250
+ options: CreateQueryDtoClassOptions<TEntity>
251
+ ): Record<string, FieldMeta> {
252
+ const fields: Record<string, FieldMeta> = {
253
+ page: {
254
+ schema: t.optional(t.integer({ minimum: 1, default: 1 })),
255
+ optional: true
256
+ },
257
+ pageSize: {
258
+ schema: t.optional(
259
+ t.integer({
260
+ minimum: 1,
261
+ maximum: options.maxPageSize,
262
+ default: options.defaultPageSize
263
+ })
264
+ ),
265
+ optional: true
266
+ }
267
+ };
268
+
269
+ for (const [queryKey, def] of Object.entries(options.filters)) {
270
+ fields[queryKey] = { schema: t.optional(def.schema), optional: true };
271
+ }
272
+
273
+ const sortableKeys = Object.keys(options.sortableColumns);
274
+ if (sortableKeys.length > 0) {
275
+ const sortByOptions = options.defaultSortBy
276
+ ? { default: options.defaultSortBy }
277
+ : {};
278
+ fields[options.sortByKey] = {
279
+ schema: t.optional(t.enum(sortableKeys, sortByOptions)),
280
+ optional: true
281
+ };
282
+ fields[options.sortDirectionKey] = {
283
+ schema: t.optional(
284
+ t.enum(["asc", "desc"], { default: options.defaultSortDirection })
285
+ ),
286
+ optional: true
287
+ };
288
+ }
289
+
290
+ return fields;
291
+ }
292
+
293
+ function createRegisteredDtoClass(
294
+ name: string,
295
+ fields: Record<string, FieldMeta>
296
+ ): DtoConstructor {
297
+ const DtoClass = class {};
298
+ Object.defineProperty(DtoClass, "name", { value: name, configurable: true });
299
+ registerDto(DtoClass, { name, fields });
300
+ return DtoClass as DtoConstructor;
301
+ }
302
+
303
+ function buildFilterMappings<TEntity extends Record<string, unknown>>(
304
+ filters: Record<string, MetalCrudQueryFilterDef<TEntity>>
305
+ ): Record<string, FilterMapping<TEntity>> {
306
+ const mappings: Record<string, FilterMapping<TEntity>> = {};
307
+ for (const [queryKey, def] of Object.entries(filters)) {
308
+ mappings[queryKey] = {
309
+ field: def.field,
310
+ operator: def.operator ?? "equals"
311
+ };
312
+ }
313
+ return mappings;
314
+ }
315
+
316
+ function cloneSortableColumns<TEntity extends Record<string, unknown>>(
317
+ sortableColumns: MetalCrudSortableColumns<TEntity>
318
+ ): MetalCrudSortableColumns<TEntity> {
319
+ return Object.fromEntries(
320
+ Object.entries(sortableColumns).map(([key, value]) => [key, value])
321
+ ) as MetalCrudSortableColumns<TEntity>;
322
+ }
323
+
324
+ function buildStandardCrudErrors(
325
+ entityName: string,
326
+ options: boolean | MetalCrudStandardErrorsOptions | undefined
327
+ ): RouteErrorsDecorator | undefined {
328
+ if (!options) {
329
+ return undefined;
330
+ }
331
+
332
+ const config: MetalCrudStandardErrorsOptions =
333
+ typeof options === "boolean" ? {} : options;
334
+ if (config.enabled === false) {
335
+ return undefined;
336
+ }
337
+
338
+ const responses: ErrorResponseOptions[] = [];
339
+ const invalidId = config.invalidId;
340
+ const notFound = config.notFound;
341
+
342
+ if (invalidId !== false) {
343
+ responses.push({
344
+ status: 400,
345
+ description: invalidId?.description ?? `Invalid ${entityName} id.`,
346
+ contentType: invalidId?.contentType
347
+ });
348
+ }
349
+ if (notFound !== false) {
350
+ responses.push({
351
+ status: 404,
352
+ description: notFound?.description ?? `${entityName} not found.`,
353
+ contentType: notFound?.contentType
354
+ });
355
+ }
356
+
357
+ if (!responses.length) {
358
+ return undefined;
359
+ }
360
+
361
+ const schema = config.schema ?? createErrorDtoClass({
362
+ withDetails: config.withDetails ?? false,
363
+ includeTraceId: config.includeTraceId ?? true
364
+ });
365
+ return Errors(schema, responses);
366
+ }
367
+
368
+ function buildDtoClass(name: string, decorator: (target: DtoConstructor) => void): DtoConstructor {
369
+ const DtoClass = class {};
370
+ Object.defineProperty(DtoClass, "name", { value: name, configurable: true });
371
+ decorator(DtoClass);
372
+ return DtoClass as DtoConstructor;
373
+ }
374
+
375
+ function getTargetName(target: unknown): string {
376
+ if (typeof target === "function" && target.name) {
377
+ return target.name;
378
+ }
379
+ return "Entity";
380
+ }
381
+
382
+ function mergeOverrides(
383
+ base?: Record<string, FieldOverride>,
384
+ override?: Record<string, FieldOverride>
385
+ ): Record<string, FieldOverride> | undefined {
386
+ if (!base && !override) {
387
+ return undefined;
388
+ }
389
+ return { ...(base ?? {}), ...(override ?? {}) };
390
+ }
391
+
392
+ function mergeStringArrays(
393
+ ...entries: Array<string[] | undefined>
394
+ ): string[] | undefined {
395
+ const merged = new Set<string>();
396
+ for (const entry of entries) {
397
+ for (const value of entry ?? []) {
398
+ merged.add(value);
399
+ }
400
+ }
401
+ return merged.size ? Array.from(merged) : undefined;
402
+ }
403
+
404
+ function buildCrudOptions(
405
+ base: MetalDtoOptions | undefined,
406
+ overrides: Record<string, FieldOverride> | undefined,
407
+ extra: Partial<MetalDtoOptions> = {}
408
+ ): MetalDtoOptions {
409
+ const mergedOverrides = mergeOverrides(overrides, base?.overrides);
410
+ const output: MetalDtoOptions = { ...(base ?? {}), ...extra };
411
+ if (mergedOverrides) {
412
+ output.overrides = mergedOverrides;
413
+ }
414
+ return output;
415
+ }
@@ -12,10 +12,14 @@ export {
12
12
  parsePagination
13
13
  } from "./pagination";
14
14
 
15
- export {
16
- parseFilter,
17
- createFilterMappings
18
- } from "./filters";
15
+ export {
16
+ parseFilter,
17
+ createFilterMappings
18
+ } from "./filters";
19
+
20
+ export {
21
+ parseSort
22
+ } from "./sort";
19
23
 
20
24
  export {
21
25
  createPagedQueryDtoClass,
@@ -73,15 +77,25 @@ export type {
73
77
  FilterFieldInput,
74
78
  RelationQuantifier,
75
79
  ParseFilterOptions,
76
- PagedQueryDtoOptions,
77
- PagedResponseDtoOptions,
78
- PagedFilterQueryDtoOptions,
79
- FilterFieldDef,
80
- MetalCrudDtoOptions,
81
- MetalCrudDtoClassOptions,
82
- MetalCrudDtoDecorators,
80
+ ParseSortOptions,
81
+ ParsedSort,
82
+ SortDirection,
83
+ PagedQueryDtoOptions,
84
+ PagedResponseDtoOptions,
85
+ PagedFilterQueryDtoOptions,
86
+ FilterFieldDef,
87
+ MetalCrudQueryFilterDef,
88
+ MetalCrudSortableColumns,
89
+ MetalCrudOptionsQueryOptions,
90
+ MetalCrudQueryOptions,
91
+ MetalCrudStandardErrorsOptions,
92
+ MetalCrudDtoOptions,
93
+ MetalCrudDtoClassOptions,
94
+ MetalCrudDtoDecorators,
83
95
  MetalCrudDtoClasses,
96
+ MetalCrudDtoClassNameKey,
84
97
  MetalCrudDtoClassNames,
98
+ RouteErrorsDecorator,
85
99
  NestedCreateDtoOptions,
86
100
  MetalTreeDtoClassOptions,
87
101
  MetalTreeDtoClasses,
@@ -0,0 +1,82 @@
1
+ import type { FilterFieldInput, ParseSortOptions, ParsedSort, SortDirection } from "./types";
2
+
3
+ /**
4
+ * Parses sort parameters from query params using an allowed sortable column map.
5
+ * Returns undefined when no valid sort column is selected.
6
+ */
7
+ export function parseSort<T>(
8
+ query: Record<string, unknown> | undefined,
9
+ sortableColumns: Record<string, FilterFieldInput<T>>,
10
+ options?: Omit<ParseSortOptions<T>, "query" | "sortableColumns">
11
+ ): ParsedSort<T> | undefined;
12
+ export function parseSort<T>(options: ParseSortOptions<T>): ParsedSort<T> | undefined;
13
+ export function parseSort<T>(
14
+ queryOrOptions: Record<string, unknown> | ParseSortOptions<T> | undefined,
15
+ sortableColumns?: Record<string, FilterFieldInput<T>>,
16
+ options?: Omit<ParseSortOptions<T>, "query" | "sortableColumns">
17
+ ): ParsedSort<T> | undefined {
18
+ const resolved = sortableColumns
19
+ ? {
20
+ query: queryOrOptions as Record<string, unknown> | undefined,
21
+ sortableColumns,
22
+ ...(options ?? {})
23
+ }
24
+ : (queryOrOptions as ParseSortOptions<T> | undefined);
25
+
26
+ const query = resolved?.query;
27
+ const allowed = resolved?.sortableColumns;
28
+ if (!query || !allowed || Object.keys(allowed).length === 0) {
29
+ return undefined;
30
+ }
31
+
32
+ const sortByKey = resolved.sortByKey ?? "sortBy";
33
+ const sortDirectionKey = resolved.sortDirectionKey ?? "sortDirection";
34
+ const defaultSortBy = resolved.defaultSortBy;
35
+ const defaultDirection = resolved.defaultSortDirection ?? "asc";
36
+
37
+ const requestedSortBy = toTrimmedString(query[sortByKey]);
38
+ const selectedSortBy = selectSortBy(requestedSortBy, defaultSortBy, allowed);
39
+ if (!selectedSortBy) {
40
+ return undefined;
41
+ }
42
+
43
+ const requestedDirection = toTrimmedString(query[sortDirectionKey]);
44
+ const sortDirection = normalizeDirection(requestedDirection, defaultDirection);
45
+
46
+ return {
47
+ sortBy: selectedSortBy,
48
+ sortDirection,
49
+ field: allowed[selectedSortBy]
50
+ };
51
+ }
52
+
53
+ function toTrimmedString(value: unknown): string | undefined {
54
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
55
+ }
56
+
57
+ function selectSortBy<T>(
58
+ requestedSortBy: string | undefined,
59
+ defaultSortBy: string | undefined,
60
+ allowed: Record<string, FilterFieldInput<T>>
61
+ ): string | undefined {
62
+ if (requestedSortBy && requestedSortBy in allowed) {
63
+ return requestedSortBy;
64
+ }
65
+ if (defaultSortBy && defaultSortBy in allowed) {
66
+ return defaultSortBy;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ function normalizeDirection(
72
+ raw: string | undefined,
73
+ fallback: SortDirection
74
+ ): SortDirection {
75
+ if (raw === "desc") {
76
+ return "desc";
77
+ }
78
+ if (raw === "asc") {
79
+ return "asc";
80
+ }
81
+ return fallback;
82
+ }