@proofkit/fmodata 0.1.0-alpha.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 +37 -0
- package/dist/esm/client/base-table.d.ts +13 -0
- package/dist/esm/client/base-table.js +19 -0
- package/dist/esm/client/base-table.js.map +1 -0
- package/dist/esm/client/database.d.ts +49 -0
- package/dist/esm/client/database.js +90 -0
- package/dist/esm/client/database.js.map +1 -0
- package/dist/esm/client/delete-builder.d.ts +61 -0
- package/dist/esm/client/delete-builder.js +121 -0
- package/dist/esm/client/delete-builder.js.map +1 -0
- package/dist/esm/client/entity-set.d.ts +43 -0
- package/dist/esm/client/entity-set.js +120 -0
- package/dist/esm/client/entity-set.js.map +1 -0
- package/dist/esm/client/filemaker-odata.d.ts +26 -0
- package/dist/esm/client/filemaker-odata.js +85 -0
- package/dist/esm/client/filemaker-odata.js.map +1 -0
- package/dist/esm/client/insert-builder.d.ts +23 -0
- package/dist/esm/client/insert-builder.js +69 -0
- package/dist/esm/client/insert-builder.js.map +1 -0
- package/dist/esm/client/query-builder.d.ts +94 -0
- package/dist/esm/client/query-builder.js +649 -0
- package/dist/esm/client/query-builder.js.map +1 -0
- package/dist/esm/client/record-builder.d.ts +43 -0
- package/dist/esm/client/record-builder.js +121 -0
- package/dist/esm/client/record-builder.js.map +1 -0
- package/dist/esm/client/table-occurrence.d.ts +25 -0
- package/dist/esm/client/table-occurrence.js +47 -0
- package/dist/esm/client/table-occurrence.js.map +1 -0
- package/dist/esm/client/update-builder.d.ts +69 -0
- package/dist/esm/client/update-builder.js +134 -0
- package/dist/esm/client/update-builder.js.map +1 -0
- package/dist/esm/filter-types.d.ts +76 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/types.d.ts +67 -0
- package/dist/esm/validation.d.ts +41 -0
- package/dist/esm/validation.js +270 -0
- package/dist/esm/validation.js.map +1 -0
- package/package.json +68 -0
- package/src/client/base-table.ts +25 -0
- package/src/client/database.ts +177 -0
- package/src/client/delete-builder.ts +193 -0
- package/src/client/entity-set.ts +310 -0
- package/src/client/filemaker-odata.ts +119 -0
- package/src/client/insert-builder.ts +93 -0
- package/src/client/query-builder.ts +1076 -0
- package/src/client/record-builder.ts +240 -0
- package/src/client/table-occurrence.ts +100 -0
- package/src/client/update-builder.ts +212 -0
- package/src/filter-types.ts +97 -0
- package/src/index.ts +17 -0
- package/src/types.ts +123 -0
- package/src/validation.ts +397 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { type FFetchOptions } from "@fetchkit/ffetch";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
4
|
+
|
|
5
|
+
export type Auth = { username: string; password: string } | { apiKey: string };
|
|
6
|
+
|
|
7
|
+
export interface ExecutableBuilder<T> {
|
|
8
|
+
execute(): Promise<Result<T>>;
|
|
9
|
+
getRequestConfig(): { method: string; url: string; body?: any };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ExecutionContext {
|
|
13
|
+
_makeRequest<T>(
|
|
14
|
+
url: string,
|
|
15
|
+
options?: RequestInit & FFetchOptions,
|
|
16
|
+
): Promise<T>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type InferSchemaType<Schema extends Record<string, StandardSchemaV1>> = {
|
|
20
|
+
[K in keyof Schema]: Schema[K] extends StandardSchemaV1<any, infer Output>
|
|
21
|
+
? Output
|
|
22
|
+
: never;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type WithSystemFields<T> =
|
|
26
|
+
T extends Record<string, any>
|
|
27
|
+
? T & {
|
|
28
|
+
ROWID: number;
|
|
29
|
+
ROWMODID: number;
|
|
30
|
+
}
|
|
31
|
+
: never;
|
|
32
|
+
|
|
33
|
+
// Helper type to exclude system fields from a union of keys
|
|
34
|
+
export type ExcludeSystemFields<T extends keyof any> = Exclude<
|
|
35
|
+
T,
|
|
36
|
+
"ROWID" | "ROWMODID"
|
|
37
|
+
>;
|
|
38
|
+
|
|
39
|
+
// Helper type to omit system fields from an object type
|
|
40
|
+
export type OmitSystemFields<T> = Omit<T, "ROWID" | "ROWMODID">;
|
|
41
|
+
|
|
42
|
+
// OData record metadata fields (present on each record)
|
|
43
|
+
export type ODataRecordMetadata = {
|
|
44
|
+
"@id": string;
|
|
45
|
+
"@editLink": string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// OData response wrapper (top-level, internal use only)
|
|
49
|
+
export type ODataListResponse<T> = {
|
|
50
|
+
"@context": string;
|
|
51
|
+
value: (T & ODataRecordMetadata)[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ODataSingleResponse<T> = T &
|
|
55
|
+
ODataRecordMetadata & {
|
|
56
|
+
"@context": string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// OData response for single field values
|
|
60
|
+
export type ODataFieldResponse<T> = {
|
|
61
|
+
"@context": string;
|
|
62
|
+
value: T;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Result pattern for execute responses
|
|
66
|
+
export type Result<T, E = Error> =
|
|
67
|
+
| { data: T; error: undefined }
|
|
68
|
+
| { data: undefined; error: E };
|
|
69
|
+
|
|
70
|
+
// Make specific keys required, rest optional
|
|
71
|
+
export type MakeFieldsRequired<T, Keys extends keyof T> = Partial<T> &
|
|
72
|
+
Required<Pick<T, Keys>>;
|
|
73
|
+
|
|
74
|
+
// Extract insert data type from BaseTable
|
|
75
|
+
// This type makes the insertRequired fields required while keeping others optional
|
|
76
|
+
export type InsertData<BT> = BT extends import("./client/base-table").BaseTable<
|
|
77
|
+
infer Schema extends Record<string, z.ZodType>,
|
|
78
|
+
any,
|
|
79
|
+
infer InsertRequired extends readonly any[],
|
|
80
|
+
any
|
|
81
|
+
>
|
|
82
|
+
? [InsertRequired[number]] extends [keyof InferSchemaType<Schema>]
|
|
83
|
+
? InsertRequired extends readonly (keyof InferSchemaType<Schema>)[]
|
|
84
|
+
? MakeFieldsRequired<InferSchemaType<Schema>, InsertRequired[number]>
|
|
85
|
+
: Partial<InferSchemaType<Schema>>
|
|
86
|
+
: Partial<InferSchemaType<Schema>>
|
|
87
|
+
: Partial<Record<string, any>>;
|
|
88
|
+
|
|
89
|
+
// Extract update data type from BaseTable
|
|
90
|
+
// This type makes the updateRequired fields required while keeping others optional
|
|
91
|
+
export type UpdateData<BT> = BT extends import("./client/base-table").BaseTable<
|
|
92
|
+
infer Schema extends Record<string, z.ZodType>,
|
|
93
|
+
any,
|
|
94
|
+
any,
|
|
95
|
+
infer UpdateRequired extends readonly any[]
|
|
96
|
+
>
|
|
97
|
+
? [UpdateRequired[number]] extends [keyof InferSchemaType<Schema>]
|
|
98
|
+
? UpdateRequired extends readonly (keyof InferSchemaType<Schema>)[]
|
|
99
|
+
? MakeFieldsRequired<InferSchemaType<Schema>, UpdateRequired[number]>
|
|
100
|
+
: Partial<InferSchemaType<Schema>>
|
|
101
|
+
: Partial<InferSchemaType<Schema>>
|
|
102
|
+
: Partial<Record<string, any>>;
|
|
103
|
+
|
|
104
|
+
export type ExecuteOptions = {
|
|
105
|
+
includeODataAnnotations?: boolean;
|
|
106
|
+
skipValidation?: boolean;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type ConditionallyWithODataAnnotations<T, IncludeODataAnnotations extends boolean> =
|
|
110
|
+
IncludeODataAnnotations extends true
|
|
111
|
+
? T & {
|
|
112
|
+
"@id": string;
|
|
113
|
+
"@editLink": string;
|
|
114
|
+
}
|
|
115
|
+
: T;
|
|
116
|
+
|
|
117
|
+
// Helper type to extract schema from a TableOccurrence
|
|
118
|
+
export type ExtractSchemaFromOccurrence<Occ> =
|
|
119
|
+
Occ extends { baseTable: { schema: infer S } }
|
|
120
|
+
? S extends Record<string, StandardSchemaV1>
|
|
121
|
+
? S
|
|
122
|
+
: Record<string, StandardSchemaV1>
|
|
123
|
+
: Record<string, StandardSchemaV1>;
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import type { ODataRecordMetadata } from "./types";
|
|
2
|
+
import { StandardSchemaV1 } from "@standard-schema/spec";
|
|
3
|
+
import type { TableOccurrence } from "./client/table-occurrence";
|
|
4
|
+
|
|
5
|
+
// Type for expand validation configuration
|
|
6
|
+
export type ExpandValidationConfig = {
|
|
7
|
+
relation: string;
|
|
8
|
+
targetSchema?: Record<string, StandardSchemaV1>;
|
|
9
|
+
targetOccurrence?: TableOccurrence<any, any, any, any>;
|
|
10
|
+
selectedFields?: string[];
|
|
11
|
+
nestedExpands?: ExpandValidationConfig[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validates a single record against a schema, only validating selected fields.
|
|
16
|
+
* Also validates expanded relations if expandConfigs are provided.
|
|
17
|
+
*/
|
|
18
|
+
export async function validateRecord<T extends Record<string, any>>(
|
|
19
|
+
record: any,
|
|
20
|
+
schema: Record<string, StandardSchemaV1> | undefined,
|
|
21
|
+
selectedFields?: (keyof T)[],
|
|
22
|
+
expandConfigs?: ExpandValidationConfig[],
|
|
23
|
+
): Promise<
|
|
24
|
+
| { valid: true; data: T & ODataRecordMetadata }
|
|
25
|
+
| { valid: false; error: Error }
|
|
26
|
+
> {
|
|
27
|
+
// Extract OData metadata fields (don't validate them - include if present)
|
|
28
|
+
const { "@id": id, "@editLink": editLink, ...rest } = record;
|
|
29
|
+
|
|
30
|
+
// Include metadata fields if present (don't validate they exist)
|
|
31
|
+
const metadata: ODataRecordMetadata = {
|
|
32
|
+
"@id": id || "",
|
|
33
|
+
"@editLink": editLink || "",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// If no schema, just return the data with metadata
|
|
37
|
+
if (!schema) {
|
|
38
|
+
return {
|
|
39
|
+
valid: true,
|
|
40
|
+
data: { ...rest, ...metadata } as T & ODataRecordMetadata,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Filter out FileMaker system fields that shouldn't be in responses by default
|
|
45
|
+
const { ROWID, ROWMODID, ...restWithoutSystemFields } = rest;
|
|
46
|
+
|
|
47
|
+
// If selected fields are specified, validate only those fields
|
|
48
|
+
if (selectedFields && selectedFields.length > 0) {
|
|
49
|
+
const validatedRecord: Record<string, any> = {};
|
|
50
|
+
|
|
51
|
+
for (const field of selectedFields) {
|
|
52
|
+
const fieldName = String(field);
|
|
53
|
+
const fieldSchema = schema[fieldName];
|
|
54
|
+
|
|
55
|
+
if (fieldSchema) {
|
|
56
|
+
const input = rest[fieldName];
|
|
57
|
+
let result = fieldSchema["~standard"].validate(input);
|
|
58
|
+
if (result instanceof Promise) result = await result;
|
|
59
|
+
|
|
60
|
+
// if the `issues` field exists, the validation failed
|
|
61
|
+
if (result.issues) {
|
|
62
|
+
return {
|
|
63
|
+
valid: false,
|
|
64
|
+
error: new Error(
|
|
65
|
+
`Validation failed for field '${fieldName}': ${JSON.stringify(result.issues, null, 2)}`,
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
validatedRecord[fieldName] = result.value;
|
|
71
|
+
} else {
|
|
72
|
+
// For fields not in schema (like when explicitly selecting ROWID/ROWMODID)
|
|
73
|
+
// include them from the original response
|
|
74
|
+
validatedRecord[fieldName] = rest[fieldName];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Validate expanded relations
|
|
79
|
+
if (expandConfigs && expandConfigs.length > 0) {
|
|
80
|
+
for (const expandConfig of expandConfigs) {
|
|
81
|
+
const expandValue = rest[expandConfig.relation];
|
|
82
|
+
|
|
83
|
+
// Check if expand field is missing
|
|
84
|
+
if (expandValue === undefined) {
|
|
85
|
+
// Check for inline error array (FileMaker returns errors inline when expand fails)
|
|
86
|
+
if (Array.isArray(rest.error) && rest.error.length > 0) {
|
|
87
|
+
// Extract error message from inline error
|
|
88
|
+
const errorDetail = rest.error[0]?.error;
|
|
89
|
+
if (errorDetail?.message) {
|
|
90
|
+
const errorMessage = errorDetail.message;
|
|
91
|
+
// Check if the error is related to this expand by checking if:
|
|
92
|
+
// 1. The error mentions the relation name, OR
|
|
93
|
+
// 2. The error mentions any of the selected fields
|
|
94
|
+
const isRelatedToExpand =
|
|
95
|
+
errorMessage
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.includes(expandConfig.relation.toLowerCase()) ||
|
|
98
|
+
(expandConfig.selectedFields &&
|
|
99
|
+
expandConfig.selectedFields.some((field) =>
|
|
100
|
+
errorMessage.toLowerCase().includes(field.toLowerCase()),
|
|
101
|
+
));
|
|
102
|
+
|
|
103
|
+
if (isRelatedToExpand) {
|
|
104
|
+
return {
|
|
105
|
+
valid: false,
|
|
106
|
+
error: new Error(
|
|
107
|
+
`Validation failed for expanded relation '${expandConfig.relation}': ${errorMessage}`,
|
|
108
|
+
),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// If no inline error but expand was expected, that's also an issue
|
|
114
|
+
// However, this might be a legitimate case (e.g., no related records)
|
|
115
|
+
// So we'll only fail if there's an explicit error array
|
|
116
|
+
} else {
|
|
117
|
+
// Original validation logic for when expand exists
|
|
118
|
+
if (Array.isArray(expandValue)) {
|
|
119
|
+
// Validate each item in the expanded array
|
|
120
|
+
const validatedExpandedItems: any[] = [];
|
|
121
|
+
for (let i = 0; i < expandValue.length; i++) {
|
|
122
|
+
const item = expandValue[i];
|
|
123
|
+
const itemValidation = await validateRecord(
|
|
124
|
+
item,
|
|
125
|
+
expandConfig.targetSchema,
|
|
126
|
+
expandConfig.selectedFields as string[] | undefined,
|
|
127
|
+
expandConfig.nestedExpands,
|
|
128
|
+
);
|
|
129
|
+
if (!itemValidation.valid) {
|
|
130
|
+
return {
|
|
131
|
+
valid: false,
|
|
132
|
+
error: new Error(
|
|
133
|
+
`Validation failed for expanded relation '${expandConfig.relation}' at index ${i}: ${itemValidation.error.message}`,
|
|
134
|
+
),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
validatedExpandedItems.push(itemValidation.data);
|
|
138
|
+
}
|
|
139
|
+
validatedRecord[expandConfig.relation] = validatedExpandedItems;
|
|
140
|
+
} else {
|
|
141
|
+
// Single expanded item (shouldn't happen in OData, but handle it)
|
|
142
|
+
const itemValidation = await validateRecord(
|
|
143
|
+
expandValue,
|
|
144
|
+
expandConfig.targetSchema,
|
|
145
|
+
expandConfig.selectedFields as string[] | undefined,
|
|
146
|
+
expandConfig.nestedExpands,
|
|
147
|
+
);
|
|
148
|
+
if (!itemValidation.valid) {
|
|
149
|
+
return {
|
|
150
|
+
valid: false,
|
|
151
|
+
error: new Error(
|
|
152
|
+
`Validation failed for expanded relation '${expandConfig.relation}': ${itemValidation.error.message}`,
|
|
153
|
+
),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
validatedRecord[expandConfig.relation] = itemValidation.data;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Merge validated data with metadata
|
|
163
|
+
return {
|
|
164
|
+
valid: true,
|
|
165
|
+
data: { ...validatedRecord, ...metadata } as T & ODataRecordMetadata,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Validate all fields in schema, but exclude ROWID/ROWMODID by default
|
|
170
|
+
const validatedRecord: Record<string, any> = { ...restWithoutSystemFields };
|
|
171
|
+
|
|
172
|
+
for (const [fieldName, fieldSchema] of Object.entries(schema)) {
|
|
173
|
+
const input = rest[fieldName];
|
|
174
|
+
let result = fieldSchema["~standard"].validate(input);
|
|
175
|
+
if (result instanceof Promise) result = await result;
|
|
176
|
+
|
|
177
|
+
// if the `issues` field exists, the validation failed
|
|
178
|
+
if (result.issues) {
|
|
179
|
+
return {
|
|
180
|
+
valid: false,
|
|
181
|
+
error: new Error(
|
|
182
|
+
`Validation failed for field '${fieldName}': ${JSON.stringify(result.issues, null, 2)}`,
|
|
183
|
+
),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
validatedRecord[fieldName] = result.value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Validate expanded relations even when not using selected fields
|
|
191
|
+
if (expandConfigs && expandConfigs.length > 0) {
|
|
192
|
+
for (const expandConfig of expandConfigs) {
|
|
193
|
+
const expandValue = rest[expandConfig.relation];
|
|
194
|
+
|
|
195
|
+
// Check if expand field is missing
|
|
196
|
+
if (expandValue === undefined) {
|
|
197
|
+
// Check for inline error array (FileMaker returns errors inline when expand fails)
|
|
198
|
+
if (Array.isArray(rest.error) && rest.error.length > 0) {
|
|
199
|
+
// Extract error message from inline error
|
|
200
|
+
const errorDetail = rest.error[0]?.error;
|
|
201
|
+
if (errorDetail?.message) {
|
|
202
|
+
const errorMessage = errorDetail.message;
|
|
203
|
+
// Check if the error is related to this expand by checking if:
|
|
204
|
+
// 1. The error mentions the relation name, OR
|
|
205
|
+
// 2. The error mentions any of the selected fields
|
|
206
|
+
const isRelatedToExpand =
|
|
207
|
+
errorMessage
|
|
208
|
+
.toLowerCase()
|
|
209
|
+
.includes(expandConfig.relation.toLowerCase()) ||
|
|
210
|
+
(expandConfig.selectedFields &&
|
|
211
|
+
expandConfig.selectedFields.some((field) =>
|
|
212
|
+
errorMessage.toLowerCase().includes(field.toLowerCase()),
|
|
213
|
+
));
|
|
214
|
+
|
|
215
|
+
if (isRelatedToExpand) {
|
|
216
|
+
return {
|
|
217
|
+
valid: false,
|
|
218
|
+
error: new Error(
|
|
219
|
+
`Validation failed for expanded relation '${expandConfig.relation}': ${errorMessage}`,
|
|
220
|
+
),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// If no inline error but expand was expected, that's also an issue
|
|
226
|
+
// However, this might be a legitimate case (e.g., no related records)
|
|
227
|
+
// So we'll only fail if there's an explicit error array
|
|
228
|
+
} else {
|
|
229
|
+
// Original validation logic for when expand exists
|
|
230
|
+
if (Array.isArray(expandValue)) {
|
|
231
|
+
// Validate each item in the expanded array
|
|
232
|
+
const validatedExpandedItems: any[] = [];
|
|
233
|
+
for (let i = 0; i < expandValue.length; i++) {
|
|
234
|
+
const item = expandValue[i];
|
|
235
|
+
const itemValidation = await validateRecord(
|
|
236
|
+
item,
|
|
237
|
+
expandConfig.targetSchema,
|
|
238
|
+
expandConfig.selectedFields as string[] | undefined,
|
|
239
|
+
expandConfig.nestedExpands,
|
|
240
|
+
);
|
|
241
|
+
if (!itemValidation.valid) {
|
|
242
|
+
return {
|
|
243
|
+
valid: false,
|
|
244
|
+
error: new Error(
|
|
245
|
+
`Validation failed for expanded relation '${expandConfig.relation}' at index ${i}: ${itemValidation.error.message}`,
|
|
246
|
+
),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
validatedExpandedItems.push(itemValidation.data);
|
|
250
|
+
}
|
|
251
|
+
validatedRecord[expandConfig.relation] = validatedExpandedItems;
|
|
252
|
+
} else {
|
|
253
|
+
// Single expanded item (shouldn't happen in OData, but handle it)
|
|
254
|
+
const itemValidation = await validateRecord(
|
|
255
|
+
expandValue,
|
|
256
|
+
expandConfig.targetSchema,
|
|
257
|
+
expandConfig.selectedFields as string[] | undefined,
|
|
258
|
+
expandConfig.nestedExpands,
|
|
259
|
+
);
|
|
260
|
+
if (!itemValidation.valid) {
|
|
261
|
+
return {
|
|
262
|
+
valid: false,
|
|
263
|
+
error: new Error(
|
|
264
|
+
`Validation failed for expanded relation '${expandConfig.relation}': ${itemValidation.error.message}`,
|
|
265
|
+
),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
validatedRecord[expandConfig.relation] = itemValidation.data;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
valid: true,
|
|
276
|
+
data: { ...validatedRecord, ...metadata } as T & ODataRecordMetadata,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Validates a list response against a schema.
|
|
282
|
+
*/
|
|
283
|
+
export async function validateListResponse<T extends Record<string, any>>(
|
|
284
|
+
response: any,
|
|
285
|
+
schema: Record<string, StandardSchemaV1> | undefined,
|
|
286
|
+
selectedFields?: (keyof T)[],
|
|
287
|
+
expandConfigs?: ExpandValidationConfig[],
|
|
288
|
+
): Promise<
|
|
289
|
+
| { valid: true; data: (T & ODataRecordMetadata)[] }
|
|
290
|
+
| { valid: false; error: Error }
|
|
291
|
+
> {
|
|
292
|
+
// Check if response has the expected structure
|
|
293
|
+
if (!response || typeof response !== "object") {
|
|
294
|
+
return {
|
|
295
|
+
valid: false,
|
|
296
|
+
error: new Error("Invalid response: expected an object"),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Extract @context (for internal validation, but we won't return it)
|
|
301
|
+
const { "@context": context, value, ...rest } = response;
|
|
302
|
+
|
|
303
|
+
if (!Array.isArray(value)) {
|
|
304
|
+
return {
|
|
305
|
+
valid: false,
|
|
306
|
+
error: new Error(
|
|
307
|
+
"Invalid response: expected 'value' property to be an array",
|
|
308
|
+
),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Validate each record in the array
|
|
313
|
+
const validatedRecords: (T & ODataRecordMetadata)[] = [];
|
|
314
|
+
|
|
315
|
+
for (let i = 0; i < value.length; i++) {
|
|
316
|
+
const record = value[i];
|
|
317
|
+
const validation = await validateRecord<T>(
|
|
318
|
+
record,
|
|
319
|
+
schema,
|
|
320
|
+
selectedFields,
|
|
321
|
+
expandConfigs,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
if (!validation.valid) {
|
|
325
|
+
return {
|
|
326
|
+
valid: false,
|
|
327
|
+
error: new Error(
|
|
328
|
+
`Validation failed for record at index ${i}: ${validation.error.message}`,
|
|
329
|
+
),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
validatedRecords.push(validation.data);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
valid: true,
|
|
338
|
+
data: validatedRecords,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Validates a single record response against a schema.
|
|
344
|
+
*/
|
|
345
|
+
export async function validateSingleResponse<T extends Record<string, any>>(
|
|
346
|
+
response: any,
|
|
347
|
+
schema: Record<string, StandardSchemaV1> | undefined,
|
|
348
|
+
selectedFields?: (keyof T)[],
|
|
349
|
+
expandConfigs?: ExpandValidationConfig[],
|
|
350
|
+
mode: "exact" | "maybe" = "maybe",
|
|
351
|
+
): Promise<
|
|
352
|
+
| { valid: true; data: (T & ODataRecordMetadata) | null }
|
|
353
|
+
| { valid: false; error: Error }
|
|
354
|
+
> {
|
|
355
|
+
// Check for multiple records (error in both modes)
|
|
356
|
+
if (response.value && Array.isArray(response.value) && response.value.length > 1) {
|
|
357
|
+
return {
|
|
358
|
+
valid: false,
|
|
359
|
+
error: new Error(
|
|
360
|
+
`Expected ${mode === "exact" ? "exactly one" : "at most one"} record, but received ${response.value.length}`
|
|
361
|
+
),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Handle empty responses
|
|
366
|
+
if (!response || (response.value && response.value.length === 0)) {
|
|
367
|
+
if (mode === "exact") {
|
|
368
|
+
return {
|
|
369
|
+
valid: false,
|
|
370
|
+
error: new Error("Expected exactly one record, but received none"),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
// mode === "maybe" - return null for empty
|
|
374
|
+
return {
|
|
375
|
+
valid: true,
|
|
376
|
+
data: null,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Single record validation
|
|
381
|
+
const record = response.value?.[0] ?? response;
|
|
382
|
+
const validation = await validateRecord<T>(
|
|
383
|
+
record,
|
|
384
|
+
schema,
|
|
385
|
+
selectedFields,
|
|
386
|
+
expandConfigs,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
if (!validation.valid) {
|
|
390
|
+
return validation as { valid: false; error: Error };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
valid: true,
|
|
395
|
+
data: validation.data,
|
|
396
|
+
};
|
|
397
|
+
}
|