@jaypie/fabric 0.1.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 +677 -0
- package/dist/cjs/commander/FabricCommander.d.ts +94 -0
- package/dist/cjs/commander/createCommanderOptions.d.ts +25 -0
- package/dist/cjs/commander/fabricCommand.d.ts +43 -0
- package/dist/cjs/commander/index.cjs +1487 -0
- package/dist/cjs/commander/index.cjs.map +1 -0
- package/dist/cjs/commander/index.d.ts +6 -0
- package/dist/cjs/commander/parseCommanderOptions.d.ts +32 -0
- package/dist/cjs/commander/registerServiceCommand.d.ts +43 -0
- package/dist/cjs/commander/types.d.ts +107 -0
- package/dist/cjs/constants.d.ts +12 -0
- package/dist/cjs/convert-date.d.ts +47 -0
- package/dist/cjs/convert.d.ts +69 -0
- package/dist/cjs/data/FabricData.d.ts +42 -0
- package/dist/cjs/data/index.cjs +1575 -0
- package/dist/cjs/data/index.cjs.map +1 -0
- package/dist/cjs/data/index.d.ts +5 -0
- package/dist/cjs/data/services/archive.d.ts +8 -0
- package/dist/cjs/data/services/create.d.ts +8 -0
- package/dist/cjs/data/services/delete.d.ts +8 -0
- package/dist/cjs/data/services/execute.d.ts +8 -0
- package/dist/cjs/data/services/index.d.ts +7 -0
- package/dist/cjs/data/services/list.d.ts +8 -0
- package/dist/cjs/data/services/read.d.ts +8 -0
- package/dist/cjs/data/services/update.d.ts +8 -0
- package/dist/cjs/data/transforms.d.ts +80 -0
- package/dist/cjs/data/types.d.ts +190 -0
- package/dist/cjs/express/FabricRouter.d.ts +29 -0
- package/dist/cjs/express/fabricExpress.d.ts +16 -0
- package/dist/cjs/express/index.cjs +505 -0
- package/dist/cjs/express/index.cjs.map +1 -0
- package/dist/cjs/express/index.d.ts +3 -0
- package/dist/cjs/express/types.d.ts +51 -0
- package/dist/cjs/helpers/fallback.d.ts +21 -0
- package/dist/cjs/helpers/index.d.ts +3 -0
- package/dist/cjs/helpers/resolvedName.d.ts +24 -0
- package/dist/cjs/http/FabricHttpServer.d.ts +31 -0
- package/dist/cjs/http/authorization.d.ts +30 -0
- package/dist/cjs/http/cors.d.ts +40 -0
- package/dist/cjs/http/fabricHttp.d.ts +28 -0
- package/dist/cjs/http/httpTransform.d.ts +36 -0
- package/dist/cjs/http/index.cjs +1820 -0
- package/dist/cjs/http/index.cjs.map +1 -0
- package/dist/cjs/http/index.d.ts +10 -0
- package/dist/cjs/http/stream.d.ts +185 -0
- package/dist/cjs/http/types.d.ts +343 -0
- package/dist/cjs/index/index.d.ts +8 -0
- package/dist/cjs/index/keyBuilder.d.ts +81 -0
- package/dist/cjs/index/registry.d.ts +56 -0
- package/dist/cjs/index/types.d.ts +54 -0
- package/dist/cjs/index.cjs +1674 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/cjs/index.d.ts +18 -0
- package/dist/cjs/lambda/createLambdaService.d.ts +33 -0
- package/dist/cjs/lambda/fabricLambda.d.ts +36 -0
- package/dist/cjs/lambda/index.cjs +967 -0
- package/dist/cjs/lambda/index.cjs.map +1 -0
- package/dist/cjs/lambda/index.d.ts +2 -0
- package/dist/cjs/lambda/types.d.ts +68 -0
- package/dist/cjs/llm/createLlmTool.d.ts +40 -0
- package/dist/cjs/llm/fabricTool.d.ts +40 -0
- package/dist/cjs/llm/index.cjs +1107 -0
- package/dist/cjs/llm/index.cjs.map +1 -0
- package/dist/cjs/llm/index.d.ts +3 -0
- package/dist/cjs/llm/inputToJsonSchema.d.ts +32 -0
- package/dist/cjs/llm/types.d.ts +61 -0
- package/dist/cjs/mcp/fabricMcp.d.ts +38 -0
- package/dist/cjs/mcp/index.cjs +938 -0
- package/dist/cjs/mcp/index.cjs.map +1 -0
- package/dist/cjs/mcp/index.d.ts +2 -0
- package/dist/cjs/mcp/registerMcpTool.d.ts +38 -0
- package/dist/cjs/mcp/types.d.ts +60 -0
- package/dist/cjs/models/base.d.ts +209 -0
- package/dist/cjs/resolve-date.d.ts +47 -0
- package/dist/cjs/resolve.d.ts +69 -0
- package/dist/cjs/resolveService.d.ts +49 -0
- package/dist/cjs/service.d.ts +13 -0
- package/dist/cjs/status.d.ts +30 -0
- package/dist/cjs/types/elementaryTypes.d.ts +84 -0
- package/dist/cjs/types/fieldCategory.d.ts +20 -0
- package/dist/cjs/types/fieldDefinition.d.ts +46 -0
- package/dist/cjs/types/index.d.ts +4 -0
- package/dist/cjs/types.d.ts +56 -0
- package/dist/esm/commander/FabricCommander.d.ts +94 -0
- package/dist/esm/commander/createCommanderOptions.d.ts +25 -0
- package/dist/esm/commander/fabricCommand.d.ts +43 -0
- package/dist/esm/commander/index.d.ts +6 -0
- package/dist/esm/commander/index.js +1482 -0
- package/dist/esm/commander/index.js.map +1 -0
- package/dist/esm/commander/parseCommanderOptions.d.ts +32 -0
- package/dist/esm/commander/registerServiceCommand.d.ts +43 -0
- package/dist/esm/commander/types.d.ts +107 -0
- package/dist/esm/constants.d.ts +12 -0
- package/dist/esm/convert-date.d.ts +47 -0
- package/dist/esm/convert.d.ts +69 -0
- package/dist/esm/data/FabricData.d.ts +42 -0
- package/dist/esm/data/index.d.ts +5 -0
- package/dist/esm/data/index.js +1548 -0
- package/dist/esm/data/index.js.map +1 -0
- package/dist/esm/data/services/archive.d.ts +8 -0
- package/dist/esm/data/services/create.d.ts +8 -0
- package/dist/esm/data/services/delete.d.ts +8 -0
- package/dist/esm/data/services/execute.d.ts +8 -0
- package/dist/esm/data/services/index.d.ts +7 -0
- package/dist/esm/data/services/list.d.ts +8 -0
- package/dist/esm/data/services/read.d.ts +8 -0
- package/dist/esm/data/services/update.d.ts +8 -0
- package/dist/esm/data/transforms.d.ts +80 -0
- package/dist/esm/data/types.d.ts +190 -0
- package/dist/esm/express/FabricRouter.d.ts +29 -0
- package/dist/esm/express/fabricExpress.d.ts +16 -0
- package/dist/esm/express/index.d.ts +3 -0
- package/dist/esm/express/index.js +500 -0
- package/dist/esm/express/index.js.map +1 -0
- package/dist/esm/express/types.d.ts +51 -0
- package/dist/esm/helpers/fallback.d.ts +21 -0
- package/dist/esm/helpers/index.d.ts +3 -0
- package/dist/esm/helpers/resolvedName.d.ts +24 -0
- package/dist/esm/http/FabricHttpServer.d.ts +31 -0
- package/dist/esm/http/authorization.d.ts +30 -0
- package/dist/esm/http/cors.d.ts +40 -0
- package/dist/esm/http/fabricHttp.d.ts +28 -0
- package/dist/esm/http/httpTransform.d.ts +36 -0
- package/dist/esm/http/index.d.ts +10 -0
- package/dist/esm/http/index.js +1775 -0
- package/dist/esm/http/index.js.map +1 -0
- package/dist/esm/http/stream.d.ts +185 -0
- package/dist/esm/http/types.d.ts +343 -0
- package/dist/esm/index/index.d.ts +8 -0
- package/dist/esm/index/keyBuilder.d.ts +81 -0
- package/dist/esm/index/registry.d.ts +56 -0
- package/dist/esm/index/types.d.ts +54 -0
- package/dist/esm/index.d.ts +18 -0
- package/dist/esm/index.js +1606 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/lambda/createLambdaService.d.ts +33 -0
- package/dist/esm/lambda/fabricLambda.d.ts +36 -0
- package/dist/esm/lambda/index.d.ts +2 -0
- package/dist/esm/lambda/index.js +965 -0
- package/dist/esm/lambda/index.js.map +1 -0
- package/dist/esm/lambda/types.d.ts +68 -0
- package/dist/esm/llm/createLlmTool.d.ts +40 -0
- package/dist/esm/llm/fabricTool.d.ts +40 -0
- package/dist/esm/llm/index.d.ts +3 -0
- package/dist/esm/llm/index.js +1104 -0
- package/dist/esm/llm/index.js.map +1 -0
- package/dist/esm/llm/inputToJsonSchema.d.ts +32 -0
- package/dist/esm/llm/types.d.ts +61 -0
- package/dist/esm/mcp/fabricMcp.d.ts +38 -0
- package/dist/esm/mcp/index.d.ts +2 -0
- package/dist/esm/mcp/index.js +936 -0
- package/dist/esm/mcp/index.js.map +1 -0
- package/dist/esm/mcp/registerMcpTool.d.ts +38 -0
- package/dist/esm/mcp/types.d.ts +60 -0
- package/dist/esm/models/base.d.ts +209 -0
- package/dist/esm/resolve-date.d.ts +47 -0
- package/dist/esm/resolve.d.ts +69 -0
- package/dist/esm/resolveService.d.ts +49 -0
- package/dist/esm/service.d.ts +13 -0
- package/dist/esm/status.d.ts +30 -0
- package/dist/esm/types/elementaryTypes.d.ts +84 -0
- package/dist/esm/types/fieldCategory.d.ts +20 -0
- package/dist/esm/types/fieldDefinition.d.ts +46 -0
- package/dist/esm/types/index.d.ts +4 -0
- package/dist/esm/types.d.ts +56 -0
- package/package.json +122 -0
|
@@ -0,0 +1,1575 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var errors = require('@jaypie/errors');
|
|
4
|
+
|
|
5
|
+
// #region Constants
|
|
6
|
+
/**
|
|
7
|
+
* Default pagination limit for list operations
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_LIMIT = 20;
|
|
10
|
+
/**
|
|
11
|
+
* Maximum pagination limit
|
|
12
|
+
*/
|
|
13
|
+
const MAX_LIMIT = 100;
|
|
14
|
+
// #endregion
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default scope value (APEX)
|
|
18
|
+
*/
|
|
19
|
+
const APEX = "@";
|
|
20
|
+
/**
|
|
21
|
+
* Extract ID from path parameters
|
|
22
|
+
*/
|
|
23
|
+
function extractId(context) {
|
|
24
|
+
return context.params.id;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Extract scope context from HTTP context
|
|
28
|
+
*/
|
|
29
|
+
function extractScopeContext(context) {
|
|
30
|
+
return {
|
|
31
|
+
body: context.body,
|
|
32
|
+
params: context.params,
|
|
33
|
+
query: context.query,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Calculate scope from scope configuration
|
|
38
|
+
*/
|
|
39
|
+
async function calculateScopeFromConfig(scopeConfig, context) {
|
|
40
|
+
if (scopeConfig === undefined) {
|
|
41
|
+
return APEX;
|
|
42
|
+
}
|
|
43
|
+
if (typeof scopeConfig === "string") {
|
|
44
|
+
return scopeConfig;
|
|
45
|
+
}
|
|
46
|
+
const scopeContext = extractScopeContext(context);
|
|
47
|
+
return scopeConfig(scopeContext);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Transform HTTP context to create operation input
|
|
51
|
+
* Extracts body fields for entity creation
|
|
52
|
+
*/
|
|
53
|
+
function transformCreate(context) {
|
|
54
|
+
const body = context.body;
|
|
55
|
+
return { ...body };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Transform HTTP context to read operation input
|
|
59
|
+
* Extracts ID from path parameters
|
|
60
|
+
*/
|
|
61
|
+
function transformRead(context) {
|
|
62
|
+
const id = extractId(context);
|
|
63
|
+
if (!id) {
|
|
64
|
+
throw new Error("Missing id parameter");
|
|
65
|
+
}
|
|
66
|
+
return { id };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Transform HTTP context to update operation input
|
|
70
|
+
* Extracts ID from path and merges with body
|
|
71
|
+
*/
|
|
72
|
+
function transformUpdate(context) {
|
|
73
|
+
const id = extractId(context);
|
|
74
|
+
if (!id) {
|
|
75
|
+
throw new Error("Missing id parameter");
|
|
76
|
+
}
|
|
77
|
+
const body = context.body;
|
|
78
|
+
return { id, ...body };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Transform HTTP context to delete operation input
|
|
82
|
+
* Extracts ID from path parameters
|
|
83
|
+
*/
|
|
84
|
+
function transformDelete(context) {
|
|
85
|
+
const id = extractId(context);
|
|
86
|
+
if (!id) {
|
|
87
|
+
throw new Error("Missing id parameter");
|
|
88
|
+
}
|
|
89
|
+
return { id };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Transform HTTP context to archive operation input
|
|
93
|
+
* Extracts ID from path parameters
|
|
94
|
+
*/
|
|
95
|
+
function transformArchive(context) {
|
|
96
|
+
const id = extractId(context);
|
|
97
|
+
if (!id) {
|
|
98
|
+
throw new Error("Missing id parameter");
|
|
99
|
+
}
|
|
100
|
+
return { id };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Transform HTTP context to list operation input
|
|
104
|
+
* Extracts pagination options from query string
|
|
105
|
+
*/
|
|
106
|
+
function transformList(context, defaultLimit = DEFAULT_LIMIT, maxLimit = MAX_LIMIT) {
|
|
107
|
+
const query = context.query;
|
|
108
|
+
// Parse limit with bounds
|
|
109
|
+
let limit = defaultLimit;
|
|
110
|
+
const limitParam = query.get("limit");
|
|
111
|
+
if (limitParam) {
|
|
112
|
+
const parsed = parseInt(limitParam, 10);
|
|
113
|
+
if (!isNaN(parsed) && parsed > 0) {
|
|
114
|
+
limit = Math.min(parsed, maxLimit);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Parse cursor
|
|
118
|
+
const startKey = query.get("cursor") ?? query.get("startKey") ?? undefined;
|
|
119
|
+
// Parse sort order
|
|
120
|
+
const ascending = query.get("ascending") === "true" || query.get("sort") === "asc";
|
|
121
|
+
// Parse archived/deleted flags
|
|
122
|
+
const archived = query.get("archived") === "true";
|
|
123
|
+
const deleted = query.get("deleted") === "true";
|
|
124
|
+
return {
|
|
125
|
+
archived,
|
|
126
|
+
ascending,
|
|
127
|
+
deleted,
|
|
128
|
+
limit,
|
|
129
|
+
startKey,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Transform HTTP context to execute operation input
|
|
134
|
+
* Extracts ID from path and merges with body
|
|
135
|
+
*/
|
|
136
|
+
function transformExecute(context) {
|
|
137
|
+
const id = extractId(context);
|
|
138
|
+
if (!id) {
|
|
139
|
+
throw new Error("Missing id parameter");
|
|
140
|
+
}
|
|
141
|
+
const body = context.body;
|
|
142
|
+
return { id, ...body };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Pluralize a model alias for route paths
|
|
146
|
+
* Simple pluralization: adds 's' unless already ends in 's'
|
|
147
|
+
*/
|
|
148
|
+
function pluralize(alias) {
|
|
149
|
+
if (alias.endsWith("s")) {
|
|
150
|
+
return alias;
|
|
151
|
+
}
|
|
152
|
+
// Handle common irregular plurals
|
|
153
|
+
if (alias.endsWith("y")) {
|
|
154
|
+
return alias.slice(0, -1) + "ies";
|
|
155
|
+
}
|
|
156
|
+
if (alias.endsWith("x") || alias.endsWith("ch") || alias.endsWith("sh")) {
|
|
157
|
+
return alias + "es";
|
|
158
|
+
}
|
|
159
|
+
return alias + "s";
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Capitalize first letter of string
|
|
163
|
+
*/
|
|
164
|
+
function capitalize(str) {
|
|
165
|
+
if (!str)
|
|
166
|
+
return str;
|
|
167
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Encode pagination cursor for client response
|
|
171
|
+
*/
|
|
172
|
+
function encodeCursor(lastEvaluatedKey) {
|
|
173
|
+
if (!lastEvaluatedKey) {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
return Buffer.from(JSON.stringify(lastEvaluatedKey)).toString("base64");
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Decode pagination cursor from client request
|
|
180
|
+
*/
|
|
181
|
+
function decodeCursor(cursor) {
|
|
182
|
+
if (!cursor) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const decoded = Buffer.from(cursor, "base64").toString("utf-8");
|
|
187
|
+
return JSON.parse(decoded);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Meta-modeling Constants
|
|
196
|
+
*/
|
|
197
|
+
// =============================================================================
|
|
198
|
+
// Constants
|
|
199
|
+
// =============================================================================
|
|
200
|
+
/** Root organizational unit */
|
|
201
|
+
/** Fabric version - used to identify pre-instantiated Services */
|
|
202
|
+
const FABRIC_VERSION = "0.1.0";
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Date Type Conversion for @jaypie/fabric
|
|
206
|
+
*
|
|
207
|
+
* Adds Date as a supported type in the fabric type system.
|
|
208
|
+
* Follows the same conversion patterns as String, Number, Boolean.
|
|
209
|
+
*/
|
|
210
|
+
/**
|
|
211
|
+
* Convert a value to a Date
|
|
212
|
+
*
|
|
213
|
+
* Supported inputs:
|
|
214
|
+
* - Date: returned as-is (validated)
|
|
215
|
+
* - Number: treated as Unix timestamp (milliseconds)
|
|
216
|
+
* - String: parsed via Date constructor (ISO 8601, etc.)
|
|
217
|
+
* - Object with value property: unwrapped and converted
|
|
218
|
+
*
|
|
219
|
+
* @throws BadRequestError if value cannot be converted to valid Date
|
|
220
|
+
*/
|
|
221
|
+
function fabricDate(value) {
|
|
222
|
+
// Already a Date
|
|
223
|
+
if (value instanceof Date) {
|
|
224
|
+
if (Number.isNaN(value.getTime())) {
|
|
225
|
+
throw new errors.BadRequestError("Invalid Date value");
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
// Null/undefined
|
|
230
|
+
if (value === null || value === undefined) {
|
|
231
|
+
throw new errors.BadRequestError("Cannot convert null or undefined to Date");
|
|
232
|
+
}
|
|
233
|
+
// Object with value property (fabric pattern)
|
|
234
|
+
if (typeof value === "object" && value !== null && "value" in value) {
|
|
235
|
+
return fabricDate(value.value);
|
|
236
|
+
}
|
|
237
|
+
// Number (timestamp in milliseconds)
|
|
238
|
+
if (typeof value === "number") {
|
|
239
|
+
if (Number.isNaN(value)) {
|
|
240
|
+
throw new errors.BadRequestError("Cannot convert NaN to Date");
|
|
241
|
+
}
|
|
242
|
+
const date = new Date(value);
|
|
243
|
+
if (Number.isNaN(date.getTime())) {
|
|
244
|
+
throw new errors.BadRequestError(`Cannot convert ${value} to Date`);
|
|
245
|
+
}
|
|
246
|
+
return date;
|
|
247
|
+
}
|
|
248
|
+
// String (ISO 8601 or parseable format)
|
|
249
|
+
if (typeof value === "string") {
|
|
250
|
+
// Empty string is invalid
|
|
251
|
+
if (value.trim() === "") {
|
|
252
|
+
throw new errors.BadRequestError("Cannot convert empty string to Date");
|
|
253
|
+
}
|
|
254
|
+
const date = new Date(value);
|
|
255
|
+
if (Number.isNaN(date.getTime())) {
|
|
256
|
+
throw new errors.BadRequestError(`Cannot convert "${value}" to Date`);
|
|
257
|
+
}
|
|
258
|
+
return date;
|
|
259
|
+
}
|
|
260
|
+
// Boolean cannot be converted to Date
|
|
261
|
+
if (typeof value === "boolean") {
|
|
262
|
+
throw new errors.BadRequestError("Cannot convert boolean to Date");
|
|
263
|
+
}
|
|
264
|
+
// Arrays - attempt single element extraction
|
|
265
|
+
if (Array.isArray(value)) {
|
|
266
|
+
if (value.length === 1) {
|
|
267
|
+
return fabricDate(value[0]);
|
|
268
|
+
}
|
|
269
|
+
throw new errors.BadRequestError(`Cannot convert array with ${value.length} elements to Date`);
|
|
270
|
+
}
|
|
271
|
+
throw new errors.BadRequestError(`Cannot convert ${typeof value} to Date`);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Type guard for Date type in schema definitions
|
|
275
|
+
*/
|
|
276
|
+
function isDateType(type) {
|
|
277
|
+
return type === Date;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Fabric functions for @jaypie/fabric
|
|
281
|
+
/**
|
|
282
|
+
* Try to parse a string as JSON if it looks like JSON
|
|
283
|
+
* Returns the parsed value or the original string if not JSON
|
|
284
|
+
*/
|
|
285
|
+
function tryParseJson(value) {
|
|
286
|
+
const trimmed = value.trim();
|
|
287
|
+
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
288
|
+
(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
289
|
+
try {
|
|
290
|
+
return JSON.parse(trimmed);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Not valid JSON, return original
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return value;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Unwrap arrays and objects to get to the scalar value
|
|
301
|
+
* - Single-element arrays unwrap to their element
|
|
302
|
+
* - Objects with value property unwrap to that value
|
|
303
|
+
* - Recursively unwraps nested structures
|
|
304
|
+
*/
|
|
305
|
+
function unwrapToScalar(value) {
|
|
306
|
+
if (value === undefined || value === null) {
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
// Unwrap single-element arrays
|
|
310
|
+
if (Array.isArray(value)) {
|
|
311
|
+
if (value.length === 0) {
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
if (value.length === 1) {
|
|
315
|
+
return unwrapToScalar(value[0]);
|
|
316
|
+
}
|
|
317
|
+
throw new errors.BadRequestError("Cannot convert multi-value array to scalar");
|
|
318
|
+
}
|
|
319
|
+
// Unwrap objects with value property
|
|
320
|
+
if (typeof value === "object") {
|
|
321
|
+
const obj = value;
|
|
322
|
+
if ("value" in obj) {
|
|
323
|
+
return unwrapToScalar(obj.value);
|
|
324
|
+
}
|
|
325
|
+
throw new errors.BadRequestError("Object must have a value attribute");
|
|
326
|
+
}
|
|
327
|
+
return value;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Prepare a value for scalar conversion by parsing JSON strings and unwrapping
|
|
331
|
+
*/
|
|
332
|
+
function prepareForScalarConversion(value) {
|
|
333
|
+
if (value === undefined || value === null) {
|
|
334
|
+
return value;
|
|
335
|
+
}
|
|
336
|
+
// Try to parse JSON strings
|
|
337
|
+
if (typeof value === "string") {
|
|
338
|
+
const parsed = tryParseJson(value);
|
|
339
|
+
if (parsed !== value) {
|
|
340
|
+
// Successfully parsed, unwrap the result
|
|
341
|
+
return unwrapToScalar(parsed);
|
|
342
|
+
}
|
|
343
|
+
return value;
|
|
344
|
+
}
|
|
345
|
+
// Unwrap arrays and objects
|
|
346
|
+
if (Array.isArray(value) || typeof value === "object") {
|
|
347
|
+
return unwrapToScalar(value);
|
|
348
|
+
}
|
|
349
|
+
return value;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Convert a value to a boolean
|
|
353
|
+
* - Arrays, objects, and JSON strings are unwrapped first
|
|
354
|
+
* - String "true" becomes true
|
|
355
|
+
* - String "false" becomes false
|
|
356
|
+
* - Strings that parse to numbers: positive = true, zero or negative = false
|
|
357
|
+
* - Numbers: positive = true, zero or negative = false
|
|
358
|
+
* - Boolean passes through
|
|
359
|
+
*/
|
|
360
|
+
function fabricBoolean(value) {
|
|
361
|
+
// Prepare value by parsing JSON and unwrapping arrays/objects
|
|
362
|
+
const prepared = prepareForScalarConversion(value);
|
|
363
|
+
if (prepared === undefined || prepared === null) {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
if (typeof prepared === "boolean") {
|
|
367
|
+
return prepared;
|
|
368
|
+
}
|
|
369
|
+
if (typeof prepared === "string") {
|
|
370
|
+
if (prepared === "") {
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
const lower = prepared.toLowerCase();
|
|
374
|
+
if (lower === "true") {
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
if (lower === "false") {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
// Try to parse as number
|
|
381
|
+
const num = parseFloat(prepared);
|
|
382
|
+
if (isNaN(num)) {
|
|
383
|
+
throw new errors.BadRequestError(`Cannot convert "${prepared}" to Boolean`);
|
|
384
|
+
}
|
|
385
|
+
return num > 0;
|
|
386
|
+
}
|
|
387
|
+
if (typeof prepared === "number") {
|
|
388
|
+
if (isNaN(prepared)) {
|
|
389
|
+
throw new errors.BadRequestError("Cannot convert NaN to Boolean");
|
|
390
|
+
}
|
|
391
|
+
return prepared > 0;
|
|
392
|
+
}
|
|
393
|
+
throw new errors.BadRequestError(`Cannot convert ${typeof prepared} to Boolean`);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Convert a value to a number
|
|
397
|
+
* - Arrays, objects, and JSON strings are unwrapped first
|
|
398
|
+
* - String "" becomes undefined
|
|
399
|
+
* - String "true" becomes 1
|
|
400
|
+
* - String "false" becomes 0
|
|
401
|
+
* - Strings that parse to numbers use those values
|
|
402
|
+
* - Strings that parse to NaN throw BadRequestError
|
|
403
|
+
* - Boolean true becomes 1, false becomes 0
|
|
404
|
+
* - Number passes through
|
|
405
|
+
*/
|
|
406
|
+
function fabricNumber(value) {
|
|
407
|
+
// Prepare value by parsing JSON and unwrapping arrays/objects
|
|
408
|
+
const prepared = prepareForScalarConversion(value);
|
|
409
|
+
if (prepared === undefined || prepared === null) {
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
if (typeof prepared === "number") {
|
|
413
|
+
if (isNaN(prepared)) {
|
|
414
|
+
throw new errors.BadRequestError("Cannot convert NaN to Number");
|
|
415
|
+
}
|
|
416
|
+
return prepared;
|
|
417
|
+
}
|
|
418
|
+
if (typeof prepared === "boolean") {
|
|
419
|
+
return prepared ? 1 : 0;
|
|
420
|
+
}
|
|
421
|
+
if (typeof prepared === "string") {
|
|
422
|
+
if (prepared === "") {
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
const lower = prepared.toLowerCase();
|
|
426
|
+
if (lower === "true") {
|
|
427
|
+
return 1;
|
|
428
|
+
}
|
|
429
|
+
if (lower === "false") {
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
const num = parseFloat(prepared);
|
|
433
|
+
if (isNaN(num)) {
|
|
434
|
+
throw new errors.BadRequestError(`Cannot convert "${prepared}" to Number`);
|
|
435
|
+
}
|
|
436
|
+
return num;
|
|
437
|
+
}
|
|
438
|
+
throw new errors.BadRequestError(`Cannot convert ${typeof prepared} to Number`);
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Convert a value to a string
|
|
442
|
+
* - Arrays, objects, and JSON strings are unwrapped first
|
|
443
|
+
* - String "" becomes undefined
|
|
444
|
+
* - Boolean true becomes "true", false becomes "false"
|
|
445
|
+
* - Number converts to string representation
|
|
446
|
+
* - String passes through
|
|
447
|
+
*/
|
|
448
|
+
function fabricString(value) {
|
|
449
|
+
// Prepare value by parsing JSON and unwrapping arrays/objects
|
|
450
|
+
const prepared = prepareForScalarConversion(value);
|
|
451
|
+
if (prepared === undefined || prepared === null) {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
if (typeof prepared === "string") {
|
|
455
|
+
if (prepared === "") {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
return prepared;
|
|
459
|
+
}
|
|
460
|
+
if (typeof prepared === "boolean") {
|
|
461
|
+
return prepared ? "true" : "false";
|
|
462
|
+
}
|
|
463
|
+
if (typeof prepared === "number") {
|
|
464
|
+
if (isNaN(prepared)) {
|
|
465
|
+
throw new errors.BadRequestError("Cannot convert NaN to String");
|
|
466
|
+
}
|
|
467
|
+
return String(prepared);
|
|
468
|
+
}
|
|
469
|
+
throw new errors.BadRequestError(`Cannot convert ${typeof prepared} to String`);
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Convert a value to an array
|
|
473
|
+
* - Non-arrays become arrays containing that value
|
|
474
|
+
* - Arrays of a single value become that value (unwrapped)
|
|
475
|
+
* - Multi-value arrays throw BadRequestError
|
|
476
|
+
* - undefined/null become undefined
|
|
477
|
+
*/
|
|
478
|
+
function fabricArray(value) {
|
|
479
|
+
if (value === undefined || value === null) {
|
|
480
|
+
return undefined;
|
|
481
|
+
}
|
|
482
|
+
if (Array.isArray(value)) {
|
|
483
|
+
// Arrays pass through (single-element unwrapping happens when converting FROM array)
|
|
484
|
+
return value;
|
|
485
|
+
}
|
|
486
|
+
// Non-arrays become single-element arrays
|
|
487
|
+
return [value];
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Convert a value to an object with a value property
|
|
491
|
+
* - Scalars become { value: scalar }
|
|
492
|
+
* - Arrays become { value: array }
|
|
493
|
+
* - Objects with a value attribute pass through
|
|
494
|
+
* - Objects without a value attribute throw BadRequestError
|
|
495
|
+
* - undefined/null become undefined
|
|
496
|
+
*/
|
|
497
|
+
function fabricObject(value) {
|
|
498
|
+
if (value === undefined || value === null) {
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
// Check if already an object (but not an array)
|
|
502
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
503
|
+
const obj = value;
|
|
504
|
+
if ("value" in obj) {
|
|
505
|
+
return obj;
|
|
506
|
+
}
|
|
507
|
+
throw new errors.BadRequestError("Object must have a value attribute");
|
|
508
|
+
}
|
|
509
|
+
// Scalars and arrays become { value: ... }
|
|
510
|
+
return { value };
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Check if a type is a typed array (e.g., [String], [Number], [], etc.)
|
|
514
|
+
*/
|
|
515
|
+
function isTypedArrayType(type) {
|
|
516
|
+
return Array.isArray(type);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Split a string on comma or tab delimiters for typed array conversion.
|
|
520
|
+
* Only splits if the string contains commas or tabs.
|
|
521
|
+
* Returns the original value if not a string or no delimiters found.
|
|
522
|
+
*/
|
|
523
|
+
function splitStringForArray(value) {
|
|
524
|
+
if (typeof value !== "string") {
|
|
525
|
+
return value;
|
|
526
|
+
}
|
|
527
|
+
// Check for comma or tab delimiters
|
|
528
|
+
if (value.includes(",")) {
|
|
529
|
+
return value.split(",").map((s) => s.trim());
|
|
530
|
+
}
|
|
531
|
+
if (value.includes("\t")) {
|
|
532
|
+
return value.split("\t").map((s) => s.trim());
|
|
533
|
+
}
|
|
534
|
+
return value;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Try to parse a string as JSON for array context.
|
|
538
|
+
* Returns parsed value if it's an array, otherwise returns original.
|
|
539
|
+
*/
|
|
540
|
+
function tryParseJsonArray(value) {
|
|
541
|
+
if (typeof value !== "string") {
|
|
542
|
+
return value;
|
|
543
|
+
}
|
|
544
|
+
const trimmed = value.trim();
|
|
545
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
546
|
+
try {
|
|
547
|
+
const parsed = JSON.parse(trimmed);
|
|
548
|
+
if (Array.isArray(parsed)) {
|
|
549
|
+
return parsed;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
// Not valid JSON, fall through
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return value;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Get the element type from a typed array type
|
|
560
|
+
* Returns undefined for untyped arrays ([])
|
|
561
|
+
*/
|
|
562
|
+
function getArrayElementType(type) {
|
|
563
|
+
if (type.length === 0) {
|
|
564
|
+
return undefined; // Untyped array
|
|
565
|
+
}
|
|
566
|
+
const elementType = type[0];
|
|
567
|
+
// Handle constructor types
|
|
568
|
+
if (elementType === Boolean)
|
|
569
|
+
return "boolean";
|
|
570
|
+
if (elementType === Number)
|
|
571
|
+
return "number";
|
|
572
|
+
if (elementType === String)
|
|
573
|
+
return "string";
|
|
574
|
+
if (elementType === Object)
|
|
575
|
+
return "object";
|
|
576
|
+
// Handle string types
|
|
577
|
+
if (elementType === "boolean")
|
|
578
|
+
return "boolean";
|
|
579
|
+
if (elementType === "number")
|
|
580
|
+
return "number";
|
|
581
|
+
if (elementType === "string")
|
|
582
|
+
return "string";
|
|
583
|
+
if (elementType === "object")
|
|
584
|
+
return "object";
|
|
585
|
+
// Handle shorthand types
|
|
586
|
+
if (elementType === "")
|
|
587
|
+
return "string"; // "" shorthand for String
|
|
588
|
+
if (typeof elementType === "object" &&
|
|
589
|
+
elementType !== null &&
|
|
590
|
+
Object.keys(elementType).length === 0) {
|
|
591
|
+
return "object"; // {} shorthand for Object
|
|
592
|
+
}
|
|
593
|
+
throw new errors.BadRequestError(`Unknown array element type: ${String(elementType)}`);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Convert a value to a typed array
|
|
597
|
+
* - Tries to parse JSON arrays first
|
|
598
|
+
* - Splits strings on comma/tab if present
|
|
599
|
+
* - Wraps non-arrays in an array
|
|
600
|
+
* - Converts each element to the specified element type
|
|
601
|
+
*/
|
|
602
|
+
function fabricTypedArray(value, elementType) {
|
|
603
|
+
// Try to parse JSON array first
|
|
604
|
+
let processed = tryParseJsonArray(value);
|
|
605
|
+
// If still a string, try to split on comma/tab
|
|
606
|
+
processed = splitStringForArray(processed);
|
|
607
|
+
// Convert to array (wraps non-arrays)
|
|
608
|
+
const array = fabricArray(processed);
|
|
609
|
+
if (array === undefined) {
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
// If no element type specified, return as-is
|
|
613
|
+
if (elementType === undefined) {
|
|
614
|
+
return array;
|
|
615
|
+
}
|
|
616
|
+
// Convert each element to the element type
|
|
617
|
+
return array.map((element, index) => {
|
|
618
|
+
try {
|
|
619
|
+
switch (elementType) {
|
|
620
|
+
case "boolean":
|
|
621
|
+
return fabricBoolean(element);
|
|
622
|
+
case "number":
|
|
623
|
+
return fabricNumber(element);
|
|
624
|
+
case "object":
|
|
625
|
+
return fabricObject(element);
|
|
626
|
+
case "string":
|
|
627
|
+
return fabricString(element);
|
|
628
|
+
default:
|
|
629
|
+
throw new errors.BadRequestError(`Unknown element type: ${elementType}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
catch (error) {
|
|
633
|
+
if (error instanceof errors.BadRequestError) {
|
|
634
|
+
throw new errors.BadRequestError(`Cannot convert array element at index ${index}: ${error.message}`);
|
|
635
|
+
}
|
|
636
|
+
throw error;
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Fabric a value to the specified type
|
|
642
|
+
*/
|
|
643
|
+
function fabric(value, type) {
|
|
644
|
+
// Check for Date type first
|
|
645
|
+
if (isDateType(type)) {
|
|
646
|
+
return fabricDate(value);
|
|
647
|
+
}
|
|
648
|
+
// Check for typed array types
|
|
649
|
+
if (isTypedArrayType(type)) {
|
|
650
|
+
const elementType = getArrayElementType(type);
|
|
651
|
+
return fabricTypedArray(value, elementType);
|
|
652
|
+
}
|
|
653
|
+
const normalizedType = normalizeType(type);
|
|
654
|
+
switch (normalizedType) {
|
|
655
|
+
case "array":
|
|
656
|
+
return fabricArray(value);
|
|
657
|
+
case "boolean":
|
|
658
|
+
return fabricBoolean(value);
|
|
659
|
+
case "number":
|
|
660
|
+
return fabricNumber(value);
|
|
661
|
+
case "object":
|
|
662
|
+
return fabricObject(value);
|
|
663
|
+
case "string":
|
|
664
|
+
return fabricString(value);
|
|
665
|
+
default:
|
|
666
|
+
throw new errors.BadRequestError(`Unknown type: ${String(type)}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Normalize type to string representation
|
|
671
|
+
*/
|
|
672
|
+
function normalizeType(type) {
|
|
673
|
+
if (type === Array || type === "array") {
|
|
674
|
+
return "array";
|
|
675
|
+
}
|
|
676
|
+
if (type === Boolean || type === "boolean") {
|
|
677
|
+
return "boolean";
|
|
678
|
+
}
|
|
679
|
+
if (type === Number || type === "number") {
|
|
680
|
+
return "number";
|
|
681
|
+
}
|
|
682
|
+
if (type === Object || type === "object") {
|
|
683
|
+
return "object";
|
|
684
|
+
}
|
|
685
|
+
if (type === String || type === "string") {
|
|
686
|
+
return "string";
|
|
687
|
+
}
|
|
688
|
+
throw new errors.BadRequestError(`Unknown type: ${String(type)}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Service for @jaypie/fabric
|
|
692
|
+
/**
|
|
693
|
+
* Check if a single-element array is a typed array type constructor.
|
|
694
|
+
*/
|
|
695
|
+
function isTypedArrayConstructor(element) {
|
|
696
|
+
return (element === Boolean ||
|
|
697
|
+
element === Number ||
|
|
698
|
+
element === String ||
|
|
699
|
+
element === Object ||
|
|
700
|
+
element === "boolean" ||
|
|
701
|
+
element === "number" ||
|
|
702
|
+
element === "string" ||
|
|
703
|
+
element === "object" ||
|
|
704
|
+
element === "" ||
|
|
705
|
+
(typeof element === "object" &&
|
|
706
|
+
element !== null &&
|
|
707
|
+
!(element instanceof RegExp) &&
|
|
708
|
+
Object.keys(element).length === 0));
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Check if a type is a validated string type (array of string literals and/or RegExp).
|
|
712
|
+
* Distinguishes from typed arrays like [String], [Number], etc.
|
|
713
|
+
*/
|
|
714
|
+
function isValidatedStringType(type) {
|
|
715
|
+
if (!Array.isArray(type)) {
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
// Empty array is untyped array, not validated string
|
|
719
|
+
if (type.length === 0) {
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
// Single-element arrays with type constructors are typed arrays
|
|
723
|
+
if (type.length === 1 && isTypedArrayConstructor(type[0])) {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
// Check that all elements are strings or RegExp
|
|
727
|
+
return type.every((item) => typeof item === "string" || item instanceof RegExp);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Check if a type is a validated number type (array of number literals).
|
|
731
|
+
* Distinguishes from typed arrays like [Number], etc.
|
|
732
|
+
*/
|
|
733
|
+
function isValidatedNumberType(type) {
|
|
734
|
+
if (!Array.isArray(type)) {
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
// Empty array is untyped array, not validated number
|
|
738
|
+
if (type.length === 0) {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
// Single-element arrays with type constructors are typed arrays
|
|
742
|
+
if (type.length === 1 && isTypedArrayConstructor(type[0])) {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
// Check that all elements are numbers
|
|
746
|
+
return type.every((item) => typeof item === "number");
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Parse input string as JSON if it's a string
|
|
750
|
+
*/
|
|
751
|
+
function parseInput(input) {
|
|
752
|
+
if (input === undefined || input === null) {
|
|
753
|
+
return {};
|
|
754
|
+
}
|
|
755
|
+
if (typeof input === "string") {
|
|
756
|
+
if (input === "") {
|
|
757
|
+
return {};
|
|
758
|
+
}
|
|
759
|
+
try {
|
|
760
|
+
const parsed = JSON.parse(input);
|
|
761
|
+
if (typeof parsed !== "object" ||
|
|
762
|
+
parsed === null ||
|
|
763
|
+
Array.isArray(parsed)) {
|
|
764
|
+
throw new errors.BadRequestError("Input must be an object");
|
|
765
|
+
}
|
|
766
|
+
return parsed;
|
|
767
|
+
}
|
|
768
|
+
catch (error) {
|
|
769
|
+
if (error instanceof errors.BadRequestError) {
|
|
770
|
+
throw error;
|
|
771
|
+
}
|
|
772
|
+
throw new errors.BadRequestError("Invalid JSON input");
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (typeof input === "object" && !Array.isArray(input)) {
|
|
776
|
+
return input;
|
|
777
|
+
}
|
|
778
|
+
throw new errors.BadRequestError("Input must be an object or JSON string");
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Run validation on a value (supports async validators)
|
|
782
|
+
*/
|
|
783
|
+
async function runValidation(value, validate, fieldName) {
|
|
784
|
+
if (typeof validate === "function") {
|
|
785
|
+
const result = await validate(value);
|
|
786
|
+
if (result === false) {
|
|
787
|
+
throw new errors.BadRequestError(`Validation failed for field "${fieldName}"`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
else if (validate instanceof RegExp) {
|
|
791
|
+
if (typeof value !== "string" || !validate.test(value)) {
|
|
792
|
+
throw new errors.BadRequestError(`Validation failed for field "${fieldName}"`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
else if (Array.isArray(validate)) {
|
|
796
|
+
// Check if value matches any item in the array
|
|
797
|
+
for (const item of validate) {
|
|
798
|
+
if (item instanceof RegExp) {
|
|
799
|
+
if (typeof value === "string" && item.test(value)) {
|
|
800
|
+
return; // Match found
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
else if (typeof item === "function") {
|
|
804
|
+
try {
|
|
805
|
+
const result = await item(value);
|
|
806
|
+
if (result !== false) {
|
|
807
|
+
return; // Match found
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
// Continue to next item
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
else if (value === item) {
|
|
815
|
+
return; // Scalar match found
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
throw new errors.BadRequestError(`Validation failed for field "${fieldName}"`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Check if a field is required
|
|
823
|
+
* A field is required unless it has a default OR required is explicitly false
|
|
824
|
+
*/
|
|
825
|
+
function isFieldRequired(definition) {
|
|
826
|
+
if (definition.required === false) {
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
if (definition.default !== undefined) {
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Process a single field through conversion and validation
|
|
836
|
+
*/
|
|
837
|
+
async function processField(fieldName, value, definition) {
|
|
838
|
+
// Apply default if value is undefined
|
|
839
|
+
let processedValue = value;
|
|
840
|
+
if (processedValue === undefined && definition.default !== undefined) {
|
|
841
|
+
processedValue = definition.default;
|
|
842
|
+
}
|
|
843
|
+
// Determine actual type and validation
|
|
844
|
+
let actualType = definition.type;
|
|
845
|
+
let validation = definition.validate;
|
|
846
|
+
// Handle bare RegExp shorthand: /regex/
|
|
847
|
+
if (definition.type instanceof RegExp) {
|
|
848
|
+
actualType = String;
|
|
849
|
+
validation = definition.type; // The RegExp becomes the validation
|
|
850
|
+
}
|
|
851
|
+
// Handle validated string shorthand: ["value1", "value2"] or [/regex/]
|
|
852
|
+
else if (isValidatedStringType(definition.type)) {
|
|
853
|
+
actualType = String;
|
|
854
|
+
validation = definition.type; // The array becomes the validation
|
|
855
|
+
}
|
|
856
|
+
// Handle validated number shorthand: [1, 2, 3]
|
|
857
|
+
else if (isValidatedNumberType(definition.type)) {
|
|
858
|
+
actualType = Number;
|
|
859
|
+
validation = definition.type; // The array becomes the validation
|
|
860
|
+
}
|
|
861
|
+
// Fabric to target type
|
|
862
|
+
const convertedValue = fabric(processedValue, actualType);
|
|
863
|
+
// Check if required field is missing
|
|
864
|
+
if (convertedValue === undefined && isFieldRequired(definition)) {
|
|
865
|
+
throw new errors.BadRequestError(`Missing required field "${fieldName}"`);
|
|
866
|
+
}
|
|
867
|
+
// Run validation if provided
|
|
868
|
+
if (validation !== undefined && convertedValue !== undefined) {
|
|
869
|
+
await runValidation(convertedValue, validation, fieldName);
|
|
870
|
+
}
|
|
871
|
+
return convertedValue;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Fabric a service function
|
|
875
|
+
*
|
|
876
|
+
* Service builds a function that initiates a "controller" step that:
|
|
877
|
+
* - Parses the input if it is a string to object
|
|
878
|
+
* - Fabrics each input field to its type
|
|
879
|
+
* - Calls the validation function or regular expression or checks the array
|
|
880
|
+
* - Calls the service function and returns the response
|
|
881
|
+
*
|
|
882
|
+
* The returned function has config properties for introspection.
|
|
883
|
+
*/
|
|
884
|
+
function fabricService(config) {
|
|
885
|
+
const { input: inputDefinitions, service } = config;
|
|
886
|
+
const handler = async (rawInput, context) => {
|
|
887
|
+
// Parse input (handles string JSON)
|
|
888
|
+
const parsedInput = parseInput(rawInput);
|
|
889
|
+
// If no input definitions, pass through to service or return parsed input
|
|
890
|
+
if (!inputDefinitions) {
|
|
891
|
+
if (service) {
|
|
892
|
+
return service(parsedInput, context);
|
|
893
|
+
}
|
|
894
|
+
return parsedInput;
|
|
895
|
+
}
|
|
896
|
+
// Process all fields in parallel
|
|
897
|
+
const entries = Object.entries(inputDefinitions);
|
|
898
|
+
const processedValues = await Promise.all(entries.map(([fieldName, definition]) => processField(fieldName, parsedInput[fieldName], definition)));
|
|
899
|
+
// Build processed input object
|
|
900
|
+
const processedInput = {};
|
|
901
|
+
entries.forEach(([fieldName], index) => {
|
|
902
|
+
processedInput[fieldName] = processedValues[index];
|
|
903
|
+
});
|
|
904
|
+
// Return processed input if no service, otherwise call service
|
|
905
|
+
if (service) {
|
|
906
|
+
return service(processedInput, context);
|
|
907
|
+
}
|
|
908
|
+
return processedInput;
|
|
909
|
+
};
|
|
910
|
+
// Attach config properties directly to handler for flat access
|
|
911
|
+
const typedHandler = handler;
|
|
912
|
+
typedHandler.$fabric = FABRIC_VERSION;
|
|
913
|
+
if (config.alias !== undefined)
|
|
914
|
+
typedHandler.alias = config.alias;
|
|
915
|
+
if (config.description !== undefined)
|
|
916
|
+
typedHandler.description = config.description;
|
|
917
|
+
if (config.input !== undefined)
|
|
918
|
+
typedHandler.input = config.input;
|
|
919
|
+
if (config.service !== undefined)
|
|
920
|
+
typedHandler.service = config.service;
|
|
921
|
+
return typedHandler;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Extract token from Authorization header
|
|
926
|
+
* Removes "Bearer " prefix (case insensitive) and strips whitespace
|
|
927
|
+
*
|
|
928
|
+
* Examples:
|
|
929
|
+
* - "Bearer eyJhbGc..." → "eyJhbGc..."
|
|
930
|
+
* - "bearer eyJhbGc..." → "eyJhbGc..."
|
|
931
|
+
* - "BEARER eyJhbGc..." → "eyJhbGc..."
|
|
932
|
+
* - "eyJhbGc..." → "eyJhbGc..."
|
|
933
|
+
* - " eyJhbGc... " → "eyJhbGc..."
|
|
934
|
+
*/
|
|
935
|
+
function extractToken(authHeader) {
|
|
936
|
+
if (!authHeader) {
|
|
937
|
+
return "";
|
|
938
|
+
}
|
|
939
|
+
let token = authHeader.trim();
|
|
940
|
+
// Remove "Bearer " prefix (case insensitive)
|
|
941
|
+
const bearerRegex = /^bearer\s+/i;
|
|
942
|
+
if (bearerRegex.test(token)) {
|
|
943
|
+
token = token.replace(bearerRegex, "");
|
|
944
|
+
}
|
|
945
|
+
return token.trim();
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Get authorization header from Headers object
|
|
949
|
+
*/
|
|
950
|
+
function getAuthHeader(headers) {
|
|
951
|
+
return headers.get("authorization");
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Validate authorization and return auth context
|
|
955
|
+
*
|
|
956
|
+
* @param headers - Request headers
|
|
957
|
+
* @param config - Authorization configuration (function or false)
|
|
958
|
+
* @returns Auth context from the authorization function, or undefined if public
|
|
959
|
+
* @throws UnauthorizedError if authorization fails
|
|
960
|
+
*/
|
|
961
|
+
async function validateAuthorization(headers, config) {
|
|
962
|
+
// Public endpoint - no authorization required
|
|
963
|
+
if (config === false) {
|
|
964
|
+
return undefined;
|
|
965
|
+
}
|
|
966
|
+
const authHeader = getAuthHeader(headers);
|
|
967
|
+
const token = extractToken(authHeader);
|
|
968
|
+
// If authorization is required but no token provided
|
|
969
|
+
if (!token) {
|
|
970
|
+
throw new errors.UnauthorizedError("Authorization header required");
|
|
971
|
+
}
|
|
972
|
+
// Call the authorization function
|
|
973
|
+
const authFunction = config;
|
|
974
|
+
return authFunction(token);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Default HTTP transformation function
|
|
979
|
+
* Merges query parameters with body (body takes precedence)
|
|
980
|
+
*/
|
|
981
|
+
const defaultHttpTransform = ({ body, query, }) => {
|
|
982
|
+
const queryObject = Object.fromEntries(query.entries());
|
|
983
|
+
const bodyObject = typeof body === "object" && body !== null ? body : {};
|
|
984
|
+
return {
|
|
985
|
+
...queryObject,
|
|
986
|
+
...bodyObject,
|
|
987
|
+
};
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Check if a value is a fabricService (has $fabric property)
|
|
992
|
+
*/
|
|
993
|
+
function isFabricService(value) {
|
|
994
|
+
return (typeof value === "function" &&
|
|
995
|
+
"$fabric" in value &&
|
|
996
|
+
typeof value.$fabric === "string");
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Create an HTTP-aware fabric service
|
|
1000
|
+
*
|
|
1001
|
+
* Extends fabricService with:
|
|
1002
|
+
* - HTTP context transformation (body, headers, method, path, query, params)
|
|
1003
|
+
* - Authorization handling (token extraction from Authorization header)
|
|
1004
|
+
* - CORS configuration (enabled by default)
|
|
1005
|
+
*
|
|
1006
|
+
* Accepts either:
|
|
1007
|
+
* - Inline service definition (with `service` function)
|
|
1008
|
+
* - Pre-built `fabricService` instance (via `service` property)
|
|
1009
|
+
*/
|
|
1010
|
+
function fabricHttp(config) {
|
|
1011
|
+
const { authorization = false, cors = true, http = defaultHttpTransform, service: serviceConfig, stream = false, ...baseConfig } = config;
|
|
1012
|
+
// Resolve the underlying service
|
|
1013
|
+
let underlyingService;
|
|
1014
|
+
if (isFabricService(serviceConfig)) {
|
|
1015
|
+
// Pre-built fabricService - merge configs
|
|
1016
|
+
underlyingService = serviceConfig;
|
|
1017
|
+
// Merge base config properties from the pre-built service
|
|
1018
|
+
if (baseConfig.alias === undefined &&
|
|
1019
|
+
underlyingService.alias !== undefined) {
|
|
1020
|
+
baseConfig.alias = underlyingService.alias;
|
|
1021
|
+
}
|
|
1022
|
+
if (baseConfig.description === undefined &&
|
|
1023
|
+
underlyingService.description !== undefined) {
|
|
1024
|
+
baseConfig.description = underlyingService.description;
|
|
1025
|
+
}
|
|
1026
|
+
if (baseConfig.input === undefined &&
|
|
1027
|
+
underlyingService.input !== undefined) {
|
|
1028
|
+
baseConfig.input = underlyingService.input;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
else {
|
|
1032
|
+
// Inline service definition or plain function
|
|
1033
|
+
const serviceFunction = serviceConfig;
|
|
1034
|
+
underlyingService = fabricService({
|
|
1035
|
+
...baseConfig,
|
|
1036
|
+
service: serviceFunction,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
// Create the HTTP handler that processes HTTP context
|
|
1040
|
+
const httpHandler = async (input, context) => {
|
|
1041
|
+
// If context has HTTP info, process authorization
|
|
1042
|
+
// (HTTP context is added by the adapter layer like fabricExpress)
|
|
1043
|
+
if (context?.http && authorization !== false) {
|
|
1044
|
+
const authResult = await validateAuthorization(context.http.headers, authorization);
|
|
1045
|
+
// Add auth result to context
|
|
1046
|
+
context.auth = authResult;
|
|
1047
|
+
}
|
|
1048
|
+
// Call the underlying service
|
|
1049
|
+
return underlyingService(input, context);
|
|
1050
|
+
};
|
|
1051
|
+
// Create the HTTP service with all properties
|
|
1052
|
+
const httpService = httpHandler;
|
|
1053
|
+
// Copy properties from config (which may have been merged with underlying service)
|
|
1054
|
+
httpService.$fabric = underlyingService.$fabric;
|
|
1055
|
+
// Use baseConfig values (which include overrides) or fall back to underlying service
|
|
1056
|
+
const resolvedAlias = baseConfig.alias ?? underlyingService.alias;
|
|
1057
|
+
const resolvedDescription = baseConfig.description ?? underlyingService.description;
|
|
1058
|
+
const resolvedInput = baseConfig.input ?? underlyingService.input;
|
|
1059
|
+
if (resolvedAlias !== undefined) {
|
|
1060
|
+
httpService.alias = resolvedAlias;
|
|
1061
|
+
}
|
|
1062
|
+
if (resolvedDescription !== undefined) {
|
|
1063
|
+
httpService.description = resolvedDescription;
|
|
1064
|
+
}
|
|
1065
|
+
if (resolvedInput !== undefined) {
|
|
1066
|
+
httpService.input = resolvedInput;
|
|
1067
|
+
}
|
|
1068
|
+
if (underlyingService.service !== undefined) {
|
|
1069
|
+
httpService.service = underlyingService.service;
|
|
1070
|
+
}
|
|
1071
|
+
// Add HTTP-specific properties
|
|
1072
|
+
httpService.authorization = authorization;
|
|
1073
|
+
httpService.cors = cors;
|
|
1074
|
+
httpService.http = http;
|
|
1075
|
+
httpService.stream = stream;
|
|
1076
|
+
return httpService;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Create the "archive" service for a FabricData endpoint
|
|
1081
|
+
* POST /{model}/:id/archive - Archive an entity
|
|
1082
|
+
*/
|
|
1083
|
+
function createArchiveService(modelConfig, operationConfig, globalConfig) {
|
|
1084
|
+
const { alias, name } = modelConfig;
|
|
1085
|
+
return fabricHttp({
|
|
1086
|
+
alias: `archive-${alias}`,
|
|
1087
|
+
description: `Archive a ${name}`,
|
|
1088
|
+
input: {
|
|
1089
|
+
id: { type: String, description: `${name} ID` },
|
|
1090
|
+
},
|
|
1091
|
+
authorization: operationConfig.authorization ?? globalConfig.authorization,
|
|
1092
|
+
cors: globalConfig.cors,
|
|
1093
|
+
http: operationConfig.http ?? transformArchive,
|
|
1094
|
+
service: async (input) => {
|
|
1095
|
+
// Dynamically import DynamoDB utilities
|
|
1096
|
+
const { archiveEntity, getEntity } = await import('@jaypie/dynamodb');
|
|
1097
|
+
const id = input.id;
|
|
1098
|
+
if (!id) {
|
|
1099
|
+
throw new errors.BadRequestError("ID is required");
|
|
1100
|
+
}
|
|
1101
|
+
// Check if entity exists
|
|
1102
|
+
const existing = await getEntity({ id, model: alias });
|
|
1103
|
+
if (!existing) {
|
|
1104
|
+
throw new errors.NotFoundError(`${name} not found`);
|
|
1105
|
+
}
|
|
1106
|
+
// Archive the entity
|
|
1107
|
+
const archived = await archiveEntity({ id, model: alias });
|
|
1108
|
+
if (!archived) {
|
|
1109
|
+
throw new errors.NotFoundError(`${name} not found`);
|
|
1110
|
+
}
|
|
1111
|
+
// Fetch the updated entity to return
|
|
1112
|
+
const updated = await getEntity({ id, model: alias });
|
|
1113
|
+
return updated;
|
|
1114
|
+
},
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Create the "create" service for a FabricData endpoint
|
|
1120
|
+
* POST /{model} - Create a new entity
|
|
1121
|
+
*/
|
|
1122
|
+
function createCreateService(modelConfig, operationConfig, globalConfig) {
|
|
1123
|
+
const { alias, name } = modelConfig;
|
|
1124
|
+
return fabricHttp({
|
|
1125
|
+
alias: `create-${alias}`,
|
|
1126
|
+
description: `Create a new ${name}`,
|
|
1127
|
+
authorization: operationConfig.authorization ?? globalConfig.authorization,
|
|
1128
|
+
cors: globalConfig.cors,
|
|
1129
|
+
http: operationConfig.http ?? transformCreate,
|
|
1130
|
+
service: async (input, context) => {
|
|
1131
|
+
// Dynamically import DynamoDB utilities
|
|
1132
|
+
const { putEntity } = await import('@jaypie/dynamodb');
|
|
1133
|
+
// Calculate scope
|
|
1134
|
+
const scopeConfig = globalConfig.scope;
|
|
1135
|
+
const httpContext = context?.http;
|
|
1136
|
+
const scope = httpContext
|
|
1137
|
+
? await calculateScopeFromConfig(scopeConfig, httpContext)
|
|
1138
|
+
: "@";
|
|
1139
|
+
// Validate required fields
|
|
1140
|
+
if (!input || typeof input !== "object") {
|
|
1141
|
+
throw new errors.BadRequestError("Request body is required");
|
|
1142
|
+
}
|
|
1143
|
+
// Apply transform if configured
|
|
1144
|
+
let entityInput = input;
|
|
1145
|
+
if (operationConfig.transform) {
|
|
1146
|
+
entityInput = {
|
|
1147
|
+
...entityInput,
|
|
1148
|
+
...operationConfig.transform(entityInput, null),
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
// Build the entity
|
|
1152
|
+
const now = new Date().toISOString();
|
|
1153
|
+
const entity = {
|
|
1154
|
+
...entityInput,
|
|
1155
|
+
createdAt: now,
|
|
1156
|
+
id: crypto.randomUUID(),
|
|
1157
|
+
model: alias,
|
|
1158
|
+
name: entityInput.name ?? name,
|
|
1159
|
+
scope,
|
|
1160
|
+
sequence: Date.now(),
|
|
1161
|
+
updatedAt: now,
|
|
1162
|
+
};
|
|
1163
|
+
// Create the entity
|
|
1164
|
+
const created = await putEntity({ entity });
|
|
1165
|
+
return created;
|
|
1166
|
+
},
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Create the "delete" service for a FabricData endpoint
|
|
1172
|
+
* DELETE /{model}/:id - Soft delete an entity
|
|
1173
|
+
*/
|
|
1174
|
+
function createDeleteService(modelConfig, operationConfig, globalConfig) {
|
|
1175
|
+
const { alias, name } = modelConfig;
|
|
1176
|
+
return fabricHttp({
|
|
1177
|
+
alias: `delete-${alias}`,
|
|
1178
|
+
description: `Delete a ${name}`,
|
|
1179
|
+
input: {
|
|
1180
|
+
id: { type: String, description: `${name} ID` },
|
|
1181
|
+
},
|
|
1182
|
+
authorization: operationConfig.authorization ?? globalConfig.authorization,
|
|
1183
|
+
cors: globalConfig.cors,
|
|
1184
|
+
http: operationConfig.http ?? transformDelete,
|
|
1185
|
+
service: async (input) => {
|
|
1186
|
+
// Dynamically import DynamoDB utilities
|
|
1187
|
+
const { deleteEntity, getEntity } = await import('@jaypie/dynamodb');
|
|
1188
|
+
const id = input.id;
|
|
1189
|
+
if (!id) {
|
|
1190
|
+
throw new errors.BadRequestError("ID is required");
|
|
1191
|
+
}
|
|
1192
|
+
// Check if entity exists
|
|
1193
|
+
const existing = await getEntity({ id, model: alias });
|
|
1194
|
+
if (!existing) {
|
|
1195
|
+
throw new errors.NotFoundError(`${name} not found`);
|
|
1196
|
+
}
|
|
1197
|
+
// Soft delete the entity
|
|
1198
|
+
const deleted = await deleteEntity({ id, model: alias });
|
|
1199
|
+
if (!deleted) {
|
|
1200
|
+
throw new errors.NotFoundError(`${name} not found`);
|
|
1201
|
+
}
|
|
1202
|
+
// Return success (no content)
|
|
1203
|
+
return null;
|
|
1204
|
+
},
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Create an "execute" service for a custom action
|
|
1210
|
+
* POST /{model}/:id/{alias} - Execute a custom action on an entity
|
|
1211
|
+
*/
|
|
1212
|
+
function createExecuteService(modelConfig, executeConfig, globalConfig) {
|
|
1213
|
+
const { alias: modelAlias, name: modelName } = modelConfig;
|
|
1214
|
+
const { alias: actionAlias, authorization, description, input: inputDefinition, service: actionService, } = executeConfig;
|
|
1215
|
+
return fabricHttp({
|
|
1216
|
+
alias: `${modelAlias}-${actionAlias}`,
|
|
1217
|
+
description: description ?? `${actionAlias} action on ${modelName}`,
|
|
1218
|
+
input: {
|
|
1219
|
+
id: { type: String, description: `${modelName} ID` },
|
|
1220
|
+
...inputDefinition,
|
|
1221
|
+
},
|
|
1222
|
+
authorization: authorization ?? globalConfig.authorization,
|
|
1223
|
+
cors: globalConfig.cors,
|
|
1224
|
+
http: transformExecute,
|
|
1225
|
+
service: async (input) => {
|
|
1226
|
+
// Dynamically import DynamoDB utilities
|
|
1227
|
+
const { getEntity } = await import('@jaypie/dynamodb');
|
|
1228
|
+
const id = input.id;
|
|
1229
|
+
const { id: _id, ...actionInput } = input;
|
|
1230
|
+
if (!id) {
|
|
1231
|
+
throw new errors.BadRequestError("ID is required");
|
|
1232
|
+
}
|
|
1233
|
+
// Fetch the entity
|
|
1234
|
+
const entity = await getEntity({ id, model: modelAlias });
|
|
1235
|
+
if (!entity) {
|
|
1236
|
+
throw new errors.NotFoundError(`${modelName} not found`);
|
|
1237
|
+
}
|
|
1238
|
+
// Execute the action
|
|
1239
|
+
const result = await actionService(entity, actionInput);
|
|
1240
|
+
return result;
|
|
1241
|
+
},
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Create the "list" service for a FabricData endpoint
|
|
1247
|
+
* GET /{model} - List entities with pagination
|
|
1248
|
+
*/
|
|
1249
|
+
function createListService(modelConfig, operationConfig, globalConfig) {
|
|
1250
|
+
const { alias, name, pluralAlias } = modelConfig;
|
|
1251
|
+
const defaultLimit = globalConfig.defaultLimit ?? DEFAULT_LIMIT;
|
|
1252
|
+
const maxLimit = globalConfig.maxLimit ?? MAX_LIMIT;
|
|
1253
|
+
// Build the HTTP transform function
|
|
1254
|
+
const httpTransform = operationConfig.http ??
|
|
1255
|
+
((context) => transformList(context, defaultLimit, maxLimit));
|
|
1256
|
+
return fabricHttp({
|
|
1257
|
+
alias: `list-${pluralAlias}`,
|
|
1258
|
+
description: `List ${name} entities`,
|
|
1259
|
+
input: {
|
|
1260
|
+
archived: {
|
|
1261
|
+
type: Boolean,
|
|
1262
|
+
default: false,
|
|
1263
|
+
required: false,
|
|
1264
|
+
description: "Include archived entities",
|
|
1265
|
+
},
|
|
1266
|
+
ascending: {
|
|
1267
|
+
type: Boolean,
|
|
1268
|
+
default: false,
|
|
1269
|
+
required: false,
|
|
1270
|
+
description: "Sort ascending by sequence",
|
|
1271
|
+
},
|
|
1272
|
+
cursor: {
|
|
1273
|
+
type: String,
|
|
1274
|
+
required: false,
|
|
1275
|
+
description: "Pagination cursor",
|
|
1276
|
+
},
|
|
1277
|
+
deleted: {
|
|
1278
|
+
type: Boolean,
|
|
1279
|
+
default: false,
|
|
1280
|
+
required: false,
|
|
1281
|
+
description: "Include deleted entities",
|
|
1282
|
+
},
|
|
1283
|
+
limit: {
|
|
1284
|
+
type: Number,
|
|
1285
|
+
default: defaultLimit,
|
|
1286
|
+
required: false,
|
|
1287
|
+
description: `Number of items per page (max: ${maxLimit})`,
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
authorization: operationConfig.authorization ?? globalConfig.authorization,
|
|
1291
|
+
cors: globalConfig.cors,
|
|
1292
|
+
http: httpTransform,
|
|
1293
|
+
service: async (input, context) => {
|
|
1294
|
+
// Dynamically import DynamoDB utilities
|
|
1295
|
+
const { queryByScope } = await import('@jaypie/dynamodb');
|
|
1296
|
+
// Calculate scope
|
|
1297
|
+
const scopeConfig = globalConfig.scope;
|
|
1298
|
+
const httpContext = context?.http;
|
|
1299
|
+
const scope = httpContext
|
|
1300
|
+
? await calculateScopeFromConfig(scopeConfig, httpContext)
|
|
1301
|
+
: "@";
|
|
1302
|
+
// Parse input with defaults
|
|
1303
|
+
const archived = input.archived ?? false;
|
|
1304
|
+
const ascending = input.ascending ?? false;
|
|
1305
|
+
const deleted = input.deleted ?? false;
|
|
1306
|
+
const limit = Math.min(input.limit ?? defaultLimit, maxLimit);
|
|
1307
|
+
const startKey = decodeCursor(input.cursor ?? input.startKey);
|
|
1308
|
+
// Query entities
|
|
1309
|
+
const result = await queryByScope({
|
|
1310
|
+
archived,
|
|
1311
|
+
ascending,
|
|
1312
|
+
deleted,
|
|
1313
|
+
limit,
|
|
1314
|
+
model: alias,
|
|
1315
|
+
scope,
|
|
1316
|
+
startKey,
|
|
1317
|
+
});
|
|
1318
|
+
// Build response
|
|
1319
|
+
const response = {
|
|
1320
|
+
items: result.items,
|
|
1321
|
+
nextKey: encodeCursor(result.lastEvaluatedKey),
|
|
1322
|
+
};
|
|
1323
|
+
return response;
|
|
1324
|
+
},
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* Create the "read" service for a FabricData endpoint
|
|
1330
|
+
* GET /{model}/:id - Get a single entity by ID
|
|
1331
|
+
*/
|
|
1332
|
+
function createReadService(modelConfig, operationConfig, globalConfig) {
|
|
1333
|
+
const { alias, name } = modelConfig;
|
|
1334
|
+
return fabricHttp({
|
|
1335
|
+
alias: `read-${alias}`,
|
|
1336
|
+
description: `Get a ${name} by ID`,
|
|
1337
|
+
input: {
|
|
1338
|
+
id: { type: String, description: `${name} ID` },
|
|
1339
|
+
},
|
|
1340
|
+
authorization: operationConfig.authorization ?? globalConfig.authorization,
|
|
1341
|
+
cors: globalConfig.cors,
|
|
1342
|
+
http: operationConfig.http ?? transformRead,
|
|
1343
|
+
service: async (input) => {
|
|
1344
|
+
// Dynamically import DynamoDB utilities
|
|
1345
|
+
const { getEntity } = await import('@jaypie/dynamodb');
|
|
1346
|
+
const id = input.id;
|
|
1347
|
+
if (!id) {
|
|
1348
|
+
throw new errors.BadRequestError("ID is required");
|
|
1349
|
+
}
|
|
1350
|
+
// Fetch the entity
|
|
1351
|
+
const entity = await getEntity({ id, model: alias });
|
|
1352
|
+
if (!entity) {
|
|
1353
|
+
throw new errors.NotFoundError(`${name} not found`);
|
|
1354
|
+
}
|
|
1355
|
+
return entity;
|
|
1356
|
+
},
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Create the "update" service for a FabricData endpoint
|
|
1362
|
+
* POST /{model}/:id - Update an existing entity
|
|
1363
|
+
*/
|
|
1364
|
+
function createUpdateService(modelConfig, operationConfig, globalConfig) {
|
|
1365
|
+
const { alias, name } = modelConfig;
|
|
1366
|
+
return fabricHttp({
|
|
1367
|
+
alias: `update-${alias}`,
|
|
1368
|
+
description: `Update a ${name}`,
|
|
1369
|
+
input: {
|
|
1370
|
+
id: { type: String, description: `${name} ID` },
|
|
1371
|
+
},
|
|
1372
|
+
authorization: operationConfig.authorization ?? globalConfig.authorization,
|
|
1373
|
+
cors: globalConfig.cors,
|
|
1374
|
+
http: operationConfig.http ?? transformUpdate,
|
|
1375
|
+
service: async (input) => {
|
|
1376
|
+
// Dynamically import DynamoDB utilities
|
|
1377
|
+
const { getEntity, updateEntity } = await import('@jaypie/dynamodb');
|
|
1378
|
+
const id = input.id;
|
|
1379
|
+
const { id: _id, ...updateData } = input;
|
|
1380
|
+
if (!id) {
|
|
1381
|
+
throw new errors.BadRequestError("ID is required");
|
|
1382
|
+
}
|
|
1383
|
+
// Fetch existing entity
|
|
1384
|
+
const existing = await getEntity({ id, model: alias });
|
|
1385
|
+
if (!existing) {
|
|
1386
|
+
throw new errors.NotFoundError(`${name} not found`);
|
|
1387
|
+
}
|
|
1388
|
+
// Apply transform if configured
|
|
1389
|
+
let entityUpdate = updateData;
|
|
1390
|
+
if (operationConfig.transform) {
|
|
1391
|
+
entityUpdate = {
|
|
1392
|
+
...entityUpdate,
|
|
1393
|
+
...operationConfig.transform(updateData, existing),
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
// Build the updated entity
|
|
1397
|
+
const entity = {
|
|
1398
|
+
...existing,
|
|
1399
|
+
...entityUpdate,
|
|
1400
|
+
// Preserve immutable fields
|
|
1401
|
+
createdAt: existing.createdAt,
|
|
1402
|
+
id: existing.id,
|
|
1403
|
+
model: existing.model,
|
|
1404
|
+
scope: existing.scope,
|
|
1405
|
+
sequence: existing.sequence,
|
|
1406
|
+
};
|
|
1407
|
+
// Update the entity
|
|
1408
|
+
const updated = await updateEntity({ entity });
|
|
1409
|
+
return updated;
|
|
1410
|
+
},
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Resolve model configuration from string or object
|
|
1416
|
+
*/
|
|
1417
|
+
function resolveModelConfig(model) {
|
|
1418
|
+
if (typeof model === "string") {
|
|
1419
|
+
return {
|
|
1420
|
+
alias: model,
|
|
1421
|
+
description: undefined,
|
|
1422
|
+
name: capitalize(model),
|
|
1423
|
+
pluralAlias: pluralize(model),
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
return {
|
|
1427
|
+
alias: model.alias,
|
|
1428
|
+
description: model.description,
|
|
1429
|
+
name: model.name ?? capitalize(model.alias),
|
|
1430
|
+
pluralAlias: pluralize(model.alias),
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Resolve operation configuration from boolean or object
|
|
1435
|
+
*/
|
|
1436
|
+
function resolveOperationConfig(option, defaultEnabled = true) {
|
|
1437
|
+
// Undefined means use defaults (enabled)
|
|
1438
|
+
if (option === undefined) {
|
|
1439
|
+
return { enabled: defaultEnabled };
|
|
1440
|
+
}
|
|
1441
|
+
// Boolean means enabled/disabled with defaults
|
|
1442
|
+
if (typeof option === "boolean") {
|
|
1443
|
+
return { enabled: option };
|
|
1444
|
+
}
|
|
1445
|
+
// Object config
|
|
1446
|
+
return {
|
|
1447
|
+
authorization: option.authorization,
|
|
1448
|
+
enabled: option.enabled !== false,
|
|
1449
|
+
http: option.http,
|
|
1450
|
+
transform: option.transform,
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Create CRUD HTTP services for a Jaypie model backed by DynamoDB
|
|
1455
|
+
*
|
|
1456
|
+
* Generates standard operations:
|
|
1457
|
+
* - POST /{model} - Create
|
|
1458
|
+
* - GET /{model} - List
|
|
1459
|
+
* - GET /{model}/:id - Read
|
|
1460
|
+
* - POST /{model}/:id - Update
|
|
1461
|
+
* - DELETE /{model}/:id - Delete
|
|
1462
|
+
* - POST /{model}/:id/archive - Archive
|
|
1463
|
+
* - POST /{model}/:id/{action} - Custom execute actions
|
|
1464
|
+
*
|
|
1465
|
+
* @example
|
|
1466
|
+
* ```typescript
|
|
1467
|
+
* // Basic usage
|
|
1468
|
+
* const recordServices = FabricData({ model: "record" });
|
|
1469
|
+
*
|
|
1470
|
+
* // With authorization
|
|
1471
|
+
* const recordServices = FabricData({
|
|
1472
|
+
* model: "record",
|
|
1473
|
+
* authorization: validateToken,
|
|
1474
|
+
* operations: {
|
|
1475
|
+
* read: { authorization: false }, // Public read
|
|
1476
|
+
* delete: { authorization: requireAdmin },
|
|
1477
|
+
* archive: false, // Disabled
|
|
1478
|
+
* },
|
|
1479
|
+
* });
|
|
1480
|
+
*
|
|
1481
|
+
* // Use with FabricHttpServer
|
|
1482
|
+
* const server = new FabricHttpServer({
|
|
1483
|
+
* services: recordServices.services,
|
|
1484
|
+
* prefix: "/api",
|
|
1485
|
+
* });
|
|
1486
|
+
* ```
|
|
1487
|
+
*/
|
|
1488
|
+
function FabricData(config) {
|
|
1489
|
+
const modelConfig = resolveModelConfig(config.model);
|
|
1490
|
+
const { alias, pluralAlias } = modelConfig;
|
|
1491
|
+
const services = [];
|
|
1492
|
+
// Resolve operation configs
|
|
1493
|
+
const operations = config.operations ?? {};
|
|
1494
|
+
// Create operation
|
|
1495
|
+
const createOp = resolveOperationConfig(operations.create);
|
|
1496
|
+
if (createOp.enabled) {
|
|
1497
|
+
services.push(createCreateService(modelConfig, createOp, config));
|
|
1498
|
+
}
|
|
1499
|
+
// List operation
|
|
1500
|
+
const listOp = resolveOperationConfig(operations.list);
|
|
1501
|
+
if (listOp.enabled) {
|
|
1502
|
+
services.push(createListService(modelConfig, listOp, config));
|
|
1503
|
+
}
|
|
1504
|
+
// Read operation
|
|
1505
|
+
const readOp = resolveOperationConfig(operations.read);
|
|
1506
|
+
if (readOp.enabled) {
|
|
1507
|
+
services.push(createReadService(modelConfig, readOp, config));
|
|
1508
|
+
}
|
|
1509
|
+
// Update operation
|
|
1510
|
+
const updateOp = resolveOperationConfig(operations.update);
|
|
1511
|
+
if (updateOp.enabled) {
|
|
1512
|
+
services.push(createUpdateService(modelConfig, updateOp, config));
|
|
1513
|
+
}
|
|
1514
|
+
// Delete operation
|
|
1515
|
+
const deleteOp = resolveOperationConfig(operations.delete);
|
|
1516
|
+
if (deleteOp.enabled) {
|
|
1517
|
+
services.push(createDeleteService(modelConfig, deleteOp, config));
|
|
1518
|
+
}
|
|
1519
|
+
// Archive operation
|
|
1520
|
+
const archiveOp = resolveOperationConfig(operations.archive);
|
|
1521
|
+
if (archiveOp.enabled) {
|
|
1522
|
+
services.push(createArchiveService(modelConfig, archiveOp, config));
|
|
1523
|
+
}
|
|
1524
|
+
// Execute actions
|
|
1525
|
+
if (config.execute) {
|
|
1526
|
+
for (const executeConfig of config.execute) {
|
|
1527
|
+
services.push(createExecuteService(modelConfig, executeConfig, config));
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
return {
|
|
1531
|
+
model: alias,
|
|
1532
|
+
prefix: `/${pluralAlias}`,
|
|
1533
|
+
services,
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Check if a value is a FabricDataResult
|
|
1538
|
+
*/
|
|
1539
|
+
function isFabricDataResult(value) {
|
|
1540
|
+
if (typeof value !== "object" || value === null) {
|
|
1541
|
+
return false;
|
|
1542
|
+
}
|
|
1543
|
+
const obj = value;
|
|
1544
|
+
return (typeof obj.model === "string" &&
|
|
1545
|
+
typeof obj.prefix === "string" &&
|
|
1546
|
+
Array.isArray(obj.services));
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
exports.APEX = APEX;
|
|
1550
|
+
exports.DEFAULT_LIMIT = DEFAULT_LIMIT;
|
|
1551
|
+
exports.FabricData = FabricData;
|
|
1552
|
+
exports.MAX_LIMIT = MAX_LIMIT;
|
|
1553
|
+
exports.calculateScopeFromConfig = calculateScopeFromConfig;
|
|
1554
|
+
exports.capitalize = capitalize;
|
|
1555
|
+
exports.createArchiveService = createArchiveService;
|
|
1556
|
+
exports.createCreateService = createCreateService;
|
|
1557
|
+
exports.createDeleteService = createDeleteService;
|
|
1558
|
+
exports.createExecuteService = createExecuteService;
|
|
1559
|
+
exports.createListService = createListService;
|
|
1560
|
+
exports.createReadService = createReadService;
|
|
1561
|
+
exports.createUpdateService = createUpdateService;
|
|
1562
|
+
exports.decodeCursor = decodeCursor;
|
|
1563
|
+
exports.encodeCursor = encodeCursor;
|
|
1564
|
+
exports.extractId = extractId;
|
|
1565
|
+
exports.extractScopeContext = extractScopeContext;
|
|
1566
|
+
exports.isFabricDataResult = isFabricDataResult;
|
|
1567
|
+
exports.pluralize = pluralize;
|
|
1568
|
+
exports.transformArchive = transformArchive;
|
|
1569
|
+
exports.transformCreate = transformCreate;
|
|
1570
|
+
exports.transformDelete = transformDelete;
|
|
1571
|
+
exports.transformExecute = transformExecute;
|
|
1572
|
+
exports.transformList = transformList;
|
|
1573
|
+
exports.transformRead = transformRead;
|
|
1574
|
+
exports.transformUpdate = transformUpdate;
|
|
1575
|
+
//# sourceMappingURL=index.cjs.map
|