-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathodata.zod.ts
More file actions
475 lines (428 loc) · 13.3 KB
/
odata.zod.ts
File metadata and controls
475 lines (428 loc) · 13.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { z } from 'zod';
/**
* OData v4 Protocol Support
*
* Open Data Protocol (OData) v4 is an industry-standard protocol for building
* and consuming RESTful APIs. It provides a uniform way to expose, structure,
* query, and manipulate data.
*
* ## Overview
*
* OData v4 provides standardized URL conventions for querying data including:
* - $select: Choose which fields to return
* - $filter: Filter results with complex expressions
* - $orderby: Sort results
* - $top/$skip: Pagination
* - $expand: Include related entities
* - $count: Get total count
*
* ## Use Cases
*
* 1. **Enterprise Integration**
* - Integrate with Microsoft Dynamics 365
* - Connect to SharePoint Online
* - SAP OData services
*
* 2. **API Standardization**
* - Provide consistent query interface
* - Standard pagination and filtering
* - Industry-recognized protocol
*
* 3. **External Data Sources**
* - Connect to OData-compliant systems
* - Federated queries
* - Data virtualization
*
* @see https://www.odata.org/documentation/
* @see https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html
*
* @example OData Query
* ```
* GET /api/odata/customers?
* $select=name,email&
* $filter=country eq 'US' and revenue gt 100000&
* $orderby=revenue desc&
* $top=10&
* $skip=20&
* $expand=orders&
* $count=true
* ```
*
* @example Programmatic Use
* ```typescript
* const query: ODataQuery = {
* select: ['name', 'email'],
* filter: "country eq 'US' and revenue gt 100000",
* orderby: 'revenue desc',
* top: 10,
* skip: 20,
* expand: ['orders'],
* count: true
* }
* ```
*/
/**
* OData Query Options Schema
*
* System query options defined by OData v4 specification.
* These are URL query parameters that control the query execution.
*
* @see https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_SystemQueryOptions
*/
export const ODataQuerySchema = z.object({
/**
* $select - Select specific fields to return
*
* Comma-separated list of field names to include in the response.
* Reduces payload size and improves performance.
*
* @example "name,email,phone"
* @example "id,customer/name" - With navigation path
*/
$select: z.union([
z.string(), // "name,email"
z.array(z.string()), // ["name", "email"]
]).optional().describe('Fields to select'),
/**
* $filter - Filter results with conditions
*
* OData filter expression using comparison operators, logical operators,
* and functions.
*
* Comparison: eq, ne, lt, le, gt, ge
* Logical: and, or, not
* Functions: contains, startswith, endswith, length, indexof, substring, etc.
*
* @example "age gt 18"
* @example "country eq 'US' and revenue gt 100000"
* @example "contains(name, 'Smith')"
* @example "startswith(email, 'admin') and isActive eq true"
*/
$filter: z.string().optional().describe('Filter expression (OData filter syntax)'),
/**
* $orderby - Sort results
*
* Comma-separated list of fields with optional asc/desc.
* Default is ascending.
*
* @example "name"
* @example "revenue desc"
* @example "country asc, revenue desc"
*/
$orderby: z.union([
z.string(), // "name desc"
z.array(z.string()), // ["name desc", "email asc"]
]).optional().describe('Sort order'),
/**
* $top - Limit number of results
*
* Maximum number of results to return.
* Equivalent to SQL LIMIT or FETCH FIRST.
*
* @example 10
* @example 100
*/
$top: z.number().int().min(0).optional().describe('Max results to return'),
/**
* $skip - Skip results for pagination
*
* Number of results to skip before returning results.
* Equivalent to SQL OFFSET.
*
* @example 20
* @example 100
*/
$skip: z.number().int().min(0).optional().describe('Results to skip'),
/**
* $expand - Include related entities (lookup/master_detail fields)
*
* Comma-separated list of navigation properties (relationship field names) to expand.
* Loads related data in the same request by resolving lookup and master_detail fields.
* The engine replaces foreign key IDs with full related objects via batch $in queries.
* Supports nested expand via OData parenthetical syntax.
*
* Behavior:
* - Only fields with `type: 'lookup'` or `type: 'master_detail'` and a valid `reference` are expanded.
* - Fields without a schema or reference definition are silently skipped (ID returned as-is).
* - Maximum expand depth defaults to 3 (configurable via QueryAdapterConfig).
*
* @example "orders"
* @example "customer,products"
* @example "orders($select=id,total)" - With nested query options
*/
$expand: z.union([
z.string(), // "orders"
z.array(z.string()), // ["orders", "customer"]
]).optional().describe('Navigation properties to expand (lookup/master_detail fields)'),
/**
* $count - Include total count
*
* When true, includes totalResults count in response.
* Useful for pagination UI.
*
* @example true
*/
$count: z.boolean().optional().describe('Include total count'),
/**
* $search - Full-text search
*
* Free-text search expression.
* Search implementation is service-specific.
*
* @example "John Smith"
* @example "urgent AND support"
*/
$search: z.string().optional().describe('Search expression'),
/**
* $format - Response format
*
* Preferred response format.
*
* @example "json"
* @example "xml"
*/
$format: z.enum(['json', 'xml', 'atom']).optional().describe('Response format'),
/**
* $apply - Data aggregation
*
* Aggregation transformations (groupby, aggregate, etc.)
* Part of OData aggregation extension.
*
* @example "groupby((country),aggregate(revenue with sum as totalRevenue))"
*/
$apply: z.string().optional().describe('Aggregation expression'),
});
export type ODataQuery = z.infer<typeof ODataQuerySchema>;
/**
* OData Filter Operator
*
* Standard comparison and logical operators in OData filter expressions.
*/
export const ODataFilterOperatorSchema = z.enum([
// Comparison Operators
'eq', // Equal to
'ne', // Not equal to
'lt', // Less than
'le', // Less than or equal to
'gt', // Greater than
'ge', // Greater than or equal to
// Logical Operators
'and', // Logical AND
'or', // Logical OR
'not', // Logical NOT
// Grouping
'(', // Left parenthesis
')', // Right parenthesis
// Other
'in', // Value in list
'has', // Has flag (for enum flags)
]);
export type ODataFilterOperator = z.infer<typeof ODataFilterOperatorSchema>;
/**
* OData Filter Function
*
* Standard functions available in OData filter expressions.
*/
export const ODataFilterFunctionSchema = z.enum([
// String Functions
'contains', // contains(field, 'value')
'startswith', // startswith(field, 'value')
'endswith', // endswith(field, 'value')
'length', // length(field)
'indexof', // indexof(field, 'substring')
'substring', // substring(field, start, length)
'tolower', // tolower(field)
'toupper', // toupper(field)
'trim', // trim(field)
'concat', // concat(field1, field2)
// Date/Time Functions
'year', // year(dateField)
'month', // month(dateField)
'day', // day(dateField)
'hour', // hour(datetimeField)
'minute', // minute(datetimeField)
'second', // second(datetimeField)
'date', // date(datetimeField)
'time', // time(datetimeField)
'now', // now()
'maxdatetime', // maxdatetime()
'mindatetime', // mindatetime()
// Math Functions
'round', // round(numField)
'floor', // floor(numField)
'ceiling', // ceiling(numField)
// Type Functions
'cast', // cast(field, 'Edm.String')
'isof', // isof(field, 'Type')
// Collection Functions
'any', // collection/any(d:d/prop eq value)
'all', // collection/all(d:d/prop eq value)
]);
export type ODataFilterFunction = z.infer<typeof ODataFilterFunctionSchema>;
/**
* OData Response Schema
*
* Standard OData JSON response format.
*/
export const ODataResponseSchema = z.object({
/**
* OData context URL
* Describes the payload structure
*/
'@odata.context': z.string().url().optional().describe('Metadata context URL'),
/**
* Total count (when $count=true)
*/
'@odata.count': z.number().int().optional().describe('Total results count'),
/**
* Next link for pagination
*/
'@odata.nextLink': z.string().url().optional().describe('Next page URL'),
/**
* Result array
*/
value: z.array(z.record(z.string(), z.unknown())).describe('Results array'),
});
export type ODataResponse = z.infer<typeof ODataResponseSchema>;
/**
* OData Error Response Schema
*
* Standard OData error format.
*/
export const ODataErrorSchema = z.object({
error: z.object({
/**
* Error code
*/
code: z.string().describe('Error code'),
/**
* Error message
*/
message: z.string().describe('Error message'),
/**
* Target of the error (field name, etc.)
*/
target: z.string().optional().describe('Error target'),
/**
* Additional error details
*/
details: z.array(z.object({
code: z.string(),
message: z.string(),
target: z.string().optional(),
})).optional().describe('Error details'),
/**
* Inner error for debugging
*/
innererror: z.record(z.string(), z.unknown()).optional().describe('Inner error details'),
}),
});
export type ODataError = z.infer<typeof ODataErrorSchema>;
/**
* OData Metadata Configuration
*
* Configuration for OData metadata endpoint ($metadata).
*/
export const ODataMetadataSchema = z.object({
/**
* Service namespace
*/
namespace: z.string().describe('Service namespace'),
/**
* Entity types to expose
*/
entityTypes: z.array(z.object({
name: z.string().describe('Entity type name'),
key: z.array(z.string()).describe('Key fields'),
properties: z.array(z.object({
name: z.string(),
type: z.string().describe('OData type (Edm.String, Edm.Int32, etc.)'),
nullable: z.boolean().default(true),
})),
navigationProperties: z.array(z.object({
name: z.string(),
type: z.string(),
partner: z.string().optional(),
})).optional(),
})).describe('Entity types'),
/**
* Entity sets
*/
entitySets: z.array(z.object({
name: z.string().describe('Entity set name'),
entityType: z.string().describe('Entity type'),
})).describe('Entity sets'),
});
export type ODataMetadata = z.infer<typeof ODataMetadataSchema>;
/**
* Helper functions for OData operations
*/
export const OData = {
/**
* Build OData query URL
*/
buildUrl: (baseUrl: string, query: ODataQuery): string => {
const params = new URLSearchParams();
if (query.$select) {
params.append('$select', Array.isArray(query.$select) ? query.$select.join(',') : query.$select);
}
if (query.$filter) {
params.append('$filter', query.$filter);
}
if (query.$orderby) {
params.append('$orderby', Array.isArray(query.$orderby) ? query.$orderby.join(',') : query.$orderby);
}
if (query.$top !== undefined) {
params.append('$top', query.$top.toString());
}
if (query.$skip !== undefined) {
params.append('$skip', query.$skip.toString());
}
if (query.$expand) {
params.append('$expand', Array.isArray(query.$expand) ? query.$expand.join(',') : query.$expand);
}
if (query.$count !== undefined) {
params.append('$count', query.$count.toString());
}
if (query.$search) {
params.append('$search', query.$search);
}
if (query.$format) {
params.append('$format', query.$format);
}
if (query.$apply) {
params.append('$apply', query.$apply);
}
const queryString = params.toString();
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
},
/**
* Create a simple filter expression
*/
filter: {
eq: (field: string, value: string | number | boolean) =>
`${field} eq ${typeof value === 'string' ? `'${value}'` : value}`,
ne: (field: string, value: string | number | boolean) =>
`${field} ne ${typeof value === 'string' ? `'${value}'` : value}`,
gt: (field: string, value: number) => `${field} gt ${value}`,
lt: (field: string, value: number) => `${field} lt ${value}`,
contains: (field: string, value: string) => `contains(${field}, '${value}')`,
and: (...expressions: string[]) => expressions.join(' and '),
or: (...expressions: string[]) => expressions.join(' or '),
},
} as const;
/**
* OData Configuration Schema
*
* Configuration for enabling OData v4 API endpoint.
*/
export const ODataConfigSchema = z.object({
/** Enable OData endpoint */
enabled: z.boolean().default(true).describe('Enable OData API'),
/** OData endpoint path */
path: z.string().default('/odata').describe('OData endpoint path'),
/** Metadata configuration */
metadata: ODataMetadataSchema.optional().describe('OData metadata configuration'),
}).passthrough(); // Allow additional properties for flexibility
export type ODataConfig = z.infer<typeof ODataConfigSchema>;