@ankhorage/data-sources 0.6.0 → 1.0.1
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/CHANGELOG.md +12 -0
- package/README.md +5 -5
- package/dist/discovery/index.d.ts +3 -3
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/index.js +19 -28
- package/dist/discovery/index.js.map +1 -1
- package/dist/generated-api/index.d.ts +10 -0
- package/dist/generated-api/index.d.ts.map +1 -0
- package/dist/generated-api/index.js +274 -0
- package/dist/generated-api/index.js.map +1 -0
- package/dist/generated-api/validateGeneratedApiCollection.d.ts +3 -0
- package/dist/generated-api/validateGeneratedApiCollection.d.ts.map +1 -0
- package/dist/generated-api/validateGeneratedApiCollection.js +35 -0
- package/dist/generated-api/validateGeneratedApiCollection.js.map +1 -0
- package/dist/graphql/index.d.ts +3 -3
- package/dist/graphql/index.d.ts.map +1 -1
- package/dist/graphql/index.js +24 -60
- package/dist/graphql/index.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.d.ts +2 -2
- package/dist/openapi/index.d.ts.map +1 -1
- package/dist/openapi/index.js +19 -29
- package/dist/openapi/index.js.map +1 -1
- package/dist/rest/index.d.ts +3 -3
- package/dist/rest/index.d.ts.map +1 -1
- package/dist/rest/index.js +51 -51
- package/dist/rest/index.js.map +1 -1
- package/dist/test-runner/index.d.ts.map +1 -1
- package/dist/test-runner/index.js +60 -104
- package/dist/test-runner/index.js.map +1 -1
- package/package.json +2 -2
- package/src/discovery/index.test.ts +25 -101
- package/src/discovery/index.ts +22 -35
- package/src/{managed-api → generated-api}/index.test.ts +59 -67
- package/src/generated-api/index.ts +414 -0
- package/src/generated-api/validateGeneratedApiCollection.test.ts +91 -0
- package/src/generated-api/validateGeneratedApiCollection.ts +76 -0
- package/src/graphql/index.test.ts +28 -134
- package/src/graphql/index.ts +29 -89
- package/src/index.test.ts +25 -18
- package/src/index.ts +28 -7
- package/src/openapi/index.test.ts +36 -246
- package/src/openapi/index.ts +22 -60
- package/src/rest/index.test.ts +22 -173
- package/src/rest/index.ts +62 -59
- package/src/test-runner/index.test.ts +82 -150
- package/src/test-runner/index.ts +73 -143
- package/dist/managed-api/index.d.ts +0 -30
- package/dist/managed-api/index.d.ts.map +0 -1
- package/dist/managed-api/index.js +0 -257
- package/dist/managed-api/index.js.map +0 -1
- package/src/managed-api/index.ts +0 -364
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DataContractValue,
|
|
3
|
+
DataEndpointConfig,
|
|
4
|
+
DataOperationConfig,
|
|
5
|
+
DataOperationIntent,
|
|
6
|
+
DataOperationParameter,
|
|
7
|
+
DataSchema,
|
|
8
|
+
DataSourceDiagnostic,
|
|
9
|
+
DataSourceDiagnosticResult,
|
|
10
|
+
GeneratedApiAuthRequirement,
|
|
11
|
+
GeneratedApiCrudOperation,
|
|
12
|
+
GeneratedApiDefinition,
|
|
13
|
+
GeneratedApiOperationPolicyRef,
|
|
14
|
+
GeneratedApiResourceDefinition,
|
|
15
|
+
GeneratedRestApiDataSourceConfig,
|
|
16
|
+
OperationId,
|
|
17
|
+
} from '@ankhorage/contracts/data';
|
|
18
|
+
import type {
|
|
19
|
+
DbCollectionDefinition,
|
|
20
|
+
DbFieldDefinition,
|
|
21
|
+
DbFieldType,
|
|
22
|
+
} from '@ankhorage/contracts/db';
|
|
23
|
+
|
|
24
|
+
import { validateGeneratedApiCollection } from './validateGeneratedApiCollection';
|
|
25
|
+
|
|
26
|
+
export function createGeneratedApiDataSource(
|
|
27
|
+
definition: GeneratedApiDefinition,
|
|
28
|
+
): DataSourceDiagnosticResult<GeneratedRestApiDataSourceConfig> {
|
|
29
|
+
const diagnostics = validateGeneratedApiDefinition(definition);
|
|
30
|
+
if (diagnostics.some((diagnostic) => diagnostic.severity === 'error')) {
|
|
31
|
+
return { ok: false, diagnostics };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
data: normalizeGeneratedApiDataSource(definition),
|
|
37
|
+
diagnostics,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function validateGeneratedApiDefinition(
|
|
42
|
+
definition: GeneratedApiDefinition,
|
|
43
|
+
): readonly DataSourceDiagnostic[] {
|
|
44
|
+
const diagnostics: DataSourceDiagnostic[] = [];
|
|
45
|
+
const resourceIds = new Set<string>();
|
|
46
|
+
|
|
47
|
+
if (definition.basePath.trim().length === 0 || !definition.basePath.startsWith('/')) {
|
|
48
|
+
diagnostics.push({
|
|
49
|
+
code: 'invalid-config',
|
|
50
|
+
dataSourceId: definition.id,
|
|
51
|
+
message: 'Generated API basePath must be a non-empty absolute path.',
|
|
52
|
+
path: 'basePath',
|
|
53
|
+
severity: 'error',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (definition.resources.length === 0) {
|
|
58
|
+
diagnostics.push({
|
|
59
|
+
code: 'invalid-config',
|
|
60
|
+
dataSourceId: definition.id,
|
|
61
|
+
message: 'Generated API requires at least one resource.',
|
|
62
|
+
path: 'resources',
|
|
63
|
+
severity: 'error',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const resource of definition.resources) {
|
|
68
|
+
validateGeneratedApiResource(definition, resource, resourceIds, diagnostics);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return diagnostics;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function normalizeGeneratedApiDataSource(
|
|
75
|
+
definition: GeneratedApiDefinition,
|
|
76
|
+
): GeneratedRestApiDataSourceConfig {
|
|
77
|
+
const endpoints: Record<string, DataEndpointConfig> = {};
|
|
78
|
+
const schemas: Record<string, DataSchema> = {};
|
|
79
|
+
|
|
80
|
+
for (const resource of definition.resources) {
|
|
81
|
+
endpoints[resource.id] = createGeneratedApiEndpoint(definition, resource);
|
|
82
|
+
schemas[resource.id] = createGeneratedApiResourceSchema(resource.collection);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
id: definition.id,
|
|
87
|
+
kind: 'api',
|
|
88
|
+
origin: 'generated',
|
|
89
|
+
protocol: 'rest',
|
|
90
|
+
generatedApiId: definition.id,
|
|
91
|
+
name: definition.name,
|
|
92
|
+
description: definition.description,
|
|
93
|
+
adapter: definition.database,
|
|
94
|
+
endpoints,
|
|
95
|
+
schemas,
|
|
96
|
+
metadata: createGeneratedApiSourceMetadata(definition),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createGeneratedApiEndpoint(
|
|
101
|
+
definition: GeneratedApiDefinition,
|
|
102
|
+
resource: GeneratedApiResourceDefinition,
|
|
103
|
+
): DataEndpointConfig {
|
|
104
|
+
const operations: Record<OperationId, DataOperationConfig> = {};
|
|
105
|
+
|
|
106
|
+
for (const operation of resource.operations) {
|
|
107
|
+
const config = createGeneratedApiOperation(definition, resource, operation);
|
|
108
|
+
operations[config.id] = config;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
id: resource.id,
|
|
113
|
+
kind: 'database',
|
|
114
|
+
name: resource.name,
|
|
115
|
+
description: resource.description,
|
|
116
|
+
path: resource.path,
|
|
117
|
+
operations,
|
|
118
|
+
metadata: createGeneratedApiResourceMetadata(definition, resource),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createGeneratedApiOperation(
|
|
123
|
+
definition: GeneratedApiDefinition,
|
|
124
|
+
resource: GeneratedApiResourceDefinition,
|
|
125
|
+
operation: GeneratedApiCrudOperation,
|
|
126
|
+
): DataOperationConfig {
|
|
127
|
+
const operationId = createGeneratedApiOperationId(resource.id, operation);
|
|
128
|
+
const primaryKey = resolveGeneratedApiPrimaryKey(resource.collection);
|
|
129
|
+
const primaryKeyParameter =
|
|
130
|
+
primaryKey === undefined ? undefined : createPrimaryKeyParameter(primaryKey);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
id: operationId,
|
|
134
|
+
endpointId: resource.id,
|
|
135
|
+
name: createGeneratedApiOperationName(resource, operation),
|
|
136
|
+
protocol: 'database',
|
|
137
|
+
intent: mapGeneratedApiOperationIntent(operation),
|
|
138
|
+
path: resource.path,
|
|
139
|
+
request: createGeneratedApiOperationRequest(
|
|
140
|
+
resource.collection,
|
|
141
|
+
operation,
|
|
142
|
+
primaryKeyParameter,
|
|
143
|
+
),
|
|
144
|
+
response: createGeneratedApiOperationResponse(resource.collection, operation),
|
|
145
|
+
metadata: createGeneratedApiOperationMetadata(definition, resource, operation),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function createGeneratedApiOperationId(
|
|
150
|
+
resourceId: string,
|
|
151
|
+
operation: GeneratedApiCrudOperation,
|
|
152
|
+
): OperationId {
|
|
153
|
+
return `${resourceId}.${operation}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createGeneratedApiResourceSchema(collection: DbCollectionDefinition): DataSchema {
|
|
157
|
+
const properties: Record<string, DataSchema> = {};
|
|
158
|
+
const required: string[] = [];
|
|
159
|
+
|
|
160
|
+
for (const field of collection.fields) {
|
|
161
|
+
properties[field.name] = createGeneratedApiFieldSchema(field);
|
|
162
|
+
if (field.required === true) required.push(field.name);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
type: 'object',
|
|
167
|
+
title: collection.name,
|
|
168
|
+
required: required.length > 0 ? required : undefined,
|
|
169
|
+
properties,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function validateGeneratedApiResource(
|
|
174
|
+
definition: GeneratedApiDefinition,
|
|
175
|
+
resource: GeneratedApiResourceDefinition,
|
|
176
|
+
resourceIds: Set<string>,
|
|
177
|
+
diagnostics: DataSourceDiagnostic[],
|
|
178
|
+
): void {
|
|
179
|
+
if (resource.id.trim().length === 0) {
|
|
180
|
+
diagnostics.push(
|
|
181
|
+
resourceDiagnostic(definition, resource, 'Resource IDs must be non-empty.', 'id'),
|
|
182
|
+
);
|
|
183
|
+
} else if (resourceIds.has(resource.id)) {
|
|
184
|
+
diagnostics.push(
|
|
185
|
+
resourceDiagnostic(definition, resource, `Resource ID '${resource.id}' is duplicated.`, 'id'),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
resourceIds.add(resource.id);
|
|
189
|
+
|
|
190
|
+
if (!resource.path.startsWith('/')) {
|
|
191
|
+
diagnostics.push(
|
|
192
|
+
resourceDiagnostic(
|
|
193
|
+
definition,
|
|
194
|
+
resource,
|
|
195
|
+
'Generated API resource paths must start with `/`.',
|
|
196
|
+
'path',
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (resource.collection.name.trim().length === 0) {
|
|
202
|
+
diagnostics.push(
|
|
203
|
+
resourceDiagnostic(
|
|
204
|
+
definition,
|
|
205
|
+
resource,
|
|
206
|
+
'Generated API resource collections must have a non-empty name.',
|
|
207
|
+
'collection.name',
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
diagnostics.push(...validateGeneratedApiCollection(definition, resource));
|
|
213
|
+
|
|
214
|
+
if (resource.operations.length === 0) {
|
|
215
|
+
diagnostics.push(
|
|
216
|
+
resourceDiagnostic(
|
|
217
|
+
definition,
|
|
218
|
+
resource,
|
|
219
|
+
'Generated API resources require at least one operation.',
|
|
220
|
+
'operations',
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const primaryKey = resolveGeneratedApiPrimaryKey(resource.collection);
|
|
226
|
+
if (
|
|
227
|
+
primaryKey === undefined &&
|
|
228
|
+
resource.operations.some((operation) => operation !== 'list' && operation !== 'create')
|
|
229
|
+
) {
|
|
230
|
+
diagnostics.push(
|
|
231
|
+
resourceDiagnostic(
|
|
232
|
+
definition,
|
|
233
|
+
resource,
|
|
234
|
+
'Generated API resources need a primaryKey field for read/update/delete operations.',
|
|
235
|
+
'collection.primaryKey',
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function resourceDiagnostic(
|
|
242
|
+
definition: GeneratedApiDefinition,
|
|
243
|
+
resource: GeneratedApiResourceDefinition,
|
|
244
|
+
message: string,
|
|
245
|
+
path: string,
|
|
246
|
+
): DataSourceDiagnostic {
|
|
247
|
+
return {
|
|
248
|
+
code: 'invalid-config',
|
|
249
|
+
dataSourceId: definition.id,
|
|
250
|
+
endpointId: resource.id,
|
|
251
|
+
message,
|
|
252
|
+
path: `resources.${resource.id}.${path}`,
|
|
253
|
+
severity: 'error',
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function createGeneratedApiSourceMetadata(definition: GeneratedApiDefinition): DataContractValue {
|
|
258
|
+
return {
|
|
259
|
+
...readMetadataRecord(definition.metadata),
|
|
260
|
+
basePath: definition.basePath,
|
|
261
|
+
source: 'generated-api',
|
|
262
|
+
auth: createGeneratedApiAuthMetadata(definition.auth),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function createGeneratedApiResourceMetadata(
|
|
267
|
+
definition: GeneratedApiDefinition,
|
|
268
|
+
resource: GeneratedApiResourceDefinition,
|
|
269
|
+
): DataContractValue {
|
|
270
|
+
return {
|
|
271
|
+
...readMetadataRecord(resource.metadata),
|
|
272
|
+
adapterId: definition.database.id,
|
|
273
|
+
collection: resource.collection.name,
|
|
274
|
+
schema: resource.collection.schema ?? null,
|
|
275
|
+
source: 'generated-api',
|
|
276
|
+
seed: resource.seed ?? [],
|
|
277
|
+
policies: createGeneratedApiPolicyMetadata(resource.policies),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function createGeneratedApiOperationMetadata(
|
|
282
|
+
definition: GeneratedApiDefinition,
|
|
283
|
+
resource: GeneratedApiResourceDefinition,
|
|
284
|
+
operation: GeneratedApiCrudOperation,
|
|
285
|
+
): DataContractValue {
|
|
286
|
+
return {
|
|
287
|
+
adapterId: definition.database.id,
|
|
288
|
+
collection: resource.collection.name,
|
|
289
|
+
operation,
|
|
290
|
+
schema: resource.collection.schema ?? null,
|
|
291
|
+
source: 'generated-api',
|
|
292
|
+
policies: createGeneratedApiPolicyMetadata(
|
|
293
|
+
resource.policies?.filter(
|
|
294
|
+
(policy) => policy.operation === undefined || policy.operation === operation,
|
|
295
|
+
),
|
|
296
|
+
),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function createGeneratedApiAuthMetadata(
|
|
301
|
+
auth: GeneratedApiAuthRequirement | undefined,
|
|
302
|
+
): DataContractValue {
|
|
303
|
+
if (auth === undefined) return null;
|
|
304
|
+
|
|
305
|
+
const metadata: Record<string, DataContractValue> = {};
|
|
306
|
+
if (auth.required !== undefined) metadata.required = auth.required;
|
|
307
|
+
if (auth.roles !== undefined) metadata.roles = auth.roles;
|
|
308
|
+
if (auth.permissions !== undefined) metadata.permissions = auth.permissions;
|
|
309
|
+
if (auth.policy !== undefined) metadata.policy = auth.policy;
|
|
310
|
+
if (auth.metadata !== undefined) metadata.metadata = auth.metadata;
|
|
311
|
+
return metadata;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function createGeneratedApiPolicyMetadata(
|
|
315
|
+
policies: readonly GeneratedApiOperationPolicyRef[] | undefined,
|
|
316
|
+
): readonly DataContractValue[] {
|
|
317
|
+
return (policies ?? []).map((policy) => {
|
|
318
|
+
const metadata: Record<string, DataContractValue> = { id: policy.id };
|
|
319
|
+
if (policy.operation !== undefined) metadata.operation = policy.operation;
|
|
320
|
+
return metadata;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function createGeneratedApiOperationName(
|
|
325
|
+
resource: GeneratedApiResourceDefinition,
|
|
326
|
+
operation: GeneratedApiCrudOperation,
|
|
327
|
+
): string {
|
|
328
|
+
return `${operation} ${resource.name ?? resource.id}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function mapGeneratedApiOperationIntent(operation: GeneratedApiCrudOperation): DataOperationIntent {
|
|
332
|
+
if (operation === 'list' || operation === 'read') return 'read';
|
|
333
|
+
if (operation === 'create') return 'create';
|
|
334
|
+
if (operation === 'update') return 'update';
|
|
335
|
+
return 'delete';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function createGeneratedApiOperationRequest(
|
|
339
|
+
collection: DbCollectionDefinition,
|
|
340
|
+
operation: GeneratedApiCrudOperation,
|
|
341
|
+
primaryKeyParameter: DataOperationParameter | undefined,
|
|
342
|
+
): DataOperationConfig['request'] {
|
|
343
|
+
if (operation === 'list') {
|
|
344
|
+
return {
|
|
345
|
+
parameters: [
|
|
346
|
+
{ name: 'limit', location: 'query', schema: { type: 'integer' } },
|
|
347
|
+
{ name: 'offset', location: 'query', schema: { type: 'integer' } },
|
|
348
|
+
],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (operation === 'read' || operation === 'delete') {
|
|
353
|
+
return primaryKeyParameter === undefined ? undefined : { parameters: [primaryKeyParameter] };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const parameters =
|
|
357
|
+
operation === 'update' && primaryKeyParameter !== undefined ? [primaryKeyParameter] : undefined;
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
parameters,
|
|
361
|
+
schema: createGeneratedApiResourceSchema(collection),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function createGeneratedApiOperationResponse(
|
|
366
|
+
collection: DbCollectionDefinition,
|
|
367
|
+
operation: GeneratedApiCrudOperation,
|
|
368
|
+
): DataOperationConfig['response'] {
|
|
369
|
+
const schema = createGeneratedApiResourceSchema(collection);
|
|
370
|
+
|
|
371
|
+
if (operation === 'list') return { schema: { type: 'array', items: schema } };
|
|
372
|
+
if (operation === 'delete') {
|
|
373
|
+
return { schema: { type: 'object', properties: { deleted: { type: 'boolean' } } } };
|
|
374
|
+
}
|
|
375
|
+
return { schema };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function createPrimaryKeyParameter(primaryKey: string): DataOperationParameter {
|
|
379
|
+
return {
|
|
380
|
+
name: primaryKey,
|
|
381
|
+
location: 'path',
|
|
382
|
+
required: true,
|
|
383
|
+
schema: { type: 'string' },
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function resolveGeneratedApiPrimaryKey(collection: DbCollectionDefinition): string | undefined {
|
|
388
|
+
return collection.primaryKey ?? collection.fields.find((field) => field.unique === true)?.name;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function createGeneratedApiFieldSchema(field: DbFieldDefinition): DataSchema {
|
|
392
|
+
return {
|
|
393
|
+
...mapDbFieldTypeToDataSchema(field.type),
|
|
394
|
+
default: field.defaultValue,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function mapDbFieldTypeToDataSchema(type: DbFieldType): DataSchema {
|
|
399
|
+
if (type === 'boolean') return { type: 'boolean' };
|
|
400
|
+
if (type === 'datetime') return { type: 'string', format: 'date-time' };
|
|
401
|
+
if (type === 'json') return { type: 'object' };
|
|
402
|
+
if (type === 'number') return { type: 'number' };
|
|
403
|
+
if (type === 'uuid') return { type: 'string', format: 'uuid' };
|
|
404
|
+
return { type: 'string' };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function readMetadataRecord(
|
|
408
|
+
value: DataContractValue | undefined,
|
|
409
|
+
): Record<string, DataContractValue> {
|
|
410
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {};
|
|
411
|
+
const metadata: Record<string, DataContractValue> = {};
|
|
412
|
+
for (const [key, item] of Object.entries(value)) metadata[key] = item;
|
|
413
|
+
return metadata;
|
|
414
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { GeneratedApiDefinition } from '@ankhorage/contracts/data';
|
|
2
|
+
import { describe, expect, it } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { validateGeneratedApiDefinition } from './index';
|
|
5
|
+
|
|
6
|
+
function createDefinition(): GeneratedApiDefinition {
|
|
7
|
+
return {
|
|
8
|
+
id: 'posts-api',
|
|
9
|
+
protocol: 'rest',
|
|
10
|
+
basePath: '/api',
|
|
11
|
+
database: { id: 'primary-db', kind: 'database' },
|
|
12
|
+
resources: [
|
|
13
|
+
{
|
|
14
|
+
id: 'posts',
|
|
15
|
+
path: '/posts',
|
|
16
|
+
operations: ['list', 'read'],
|
|
17
|
+
collection: {
|
|
18
|
+
name: 'posts',
|
|
19
|
+
primaryKey: 'id',
|
|
20
|
+
fields: [
|
|
21
|
+
{ name: 'id', type: 'uuid', required: true, unique: true },
|
|
22
|
+
{ name: 'title', type: 'text' },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function diagnosticPaths(definition: GeneratedApiDefinition): readonly string[] {
|
|
31
|
+
return validateGeneratedApiDefinition(definition).map((diagnostic) => diagnostic.path ?? '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('generated API collection validation', () => {
|
|
35
|
+
it('rejects resources without fields', () => {
|
|
36
|
+
const definition = createDefinition();
|
|
37
|
+
const [resource] = definition.resources;
|
|
38
|
+
if (!resource) throw new Error('Expected resource fixture.');
|
|
39
|
+
|
|
40
|
+
const paths = diagnosticPaths({
|
|
41
|
+
...definition,
|
|
42
|
+
resources: [{ ...resource, collection: { ...resource.collection, fields: [] } }],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(paths).toContain('resources.posts.collection.fields');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects empty and duplicate field names', () => {
|
|
49
|
+
const definition = createDefinition();
|
|
50
|
+
const [resource] = definition.resources;
|
|
51
|
+
if (!resource) throw new Error('Expected resource fixture.');
|
|
52
|
+
|
|
53
|
+
const paths = diagnosticPaths({
|
|
54
|
+
...definition,
|
|
55
|
+
resources: [
|
|
56
|
+
{
|
|
57
|
+
...resource,
|
|
58
|
+
collection: {
|
|
59
|
+
...resource.collection,
|
|
60
|
+
fields: [
|
|
61
|
+
{ name: '', type: 'text' },
|
|
62
|
+
{ name: 'title', type: 'text' },
|
|
63
|
+
{ name: 'title', type: 'text' },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(paths).toContain('resources.posts.collection.fields.0.name');
|
|
71
|
+
expect(paths).toContain('resources.posts.collection.fields.2.name');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects primary keys that do not reference a field', () => {
|
|
75
|
+
const definition = createDefinition();
|
|
76
|
+
const [resource] = definition.resources;
|
|
77
|
+
if (!resource) throw new Error('Expected resource fixture.');
|
|
78
|
+
|
|
79
|
+
const paths = diagnosticPaths({
|
|
80
|
+
...definition,
|
|
81
|
+
resources: [
|
|
82
|
+
{
|
|
83
|
+
...resource,
|
|
84
|
+
collection: { ...resource.collection, primaryKey: 'missing' },
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(paths).toContain('resources.posts.collection.primaryKey');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DataSourceDiagnostic,
|
|
3
|
+
GeneratedApiDefinition,
|
|
4
|
+
GeneratedApiResourceDefinition,
|
|
5
|
+
} from '@ankhorage/contracts/data';
|
|
6
|
+
|
|
7
|
+
export function validateGeneratedApiCollection(
|
|
8
|
+
definition: GeneratedApiDefinition,
|
|
9
|
+
resource: GeneratedApiResourceDefinition,
|
|
10
|
+
): readonly DataSourceDiagnostic[] {
|
|
11
|
+
const diagnostics: DataSourceDiagnostic[] = [];
|
|
12
|
+
const { fields, primaryKey } = resource.collection;
|
|
13
|
+
const fieldNames = new Set<string>();
|
|
14
|
+
|
|
15
|
+
if (fields.length === 0) {
|
|
16
|
+
diagnostics.push(
|
|
17
|
+
diagnostic(definition, resource, 'Generated API resources require fields.', 'fields'),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fields.forEach((field, index) => {
|
|
22
|
+
const name = field.name.trim();
|
|
23
|
+
if (!name) {
|
|
24
|
+
diagnostics.push(
|
|
25
|
+
diagnostic(
|
|
26
|
+
definition,
|
|
27
|
+
resource,
|
|
28
|
+
'Generated API field names must be non-empty.',
|
|
29
|
+
`fields.${index}.name`,
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (fieldNames.has(name)) {
|
|
35
|
+
diagnostics.push(
|
|
36
|
+
diagnostic(
|
|
37
|
+
definition,
|
|
38
|
+
resource,
|
|
39
|
+
`Generated API field '${name}' is duplicated.`,
|
|
40
|
+
`fields.${index}.name`,
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
fieldNames.add(name);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (primaryKey !== undefined && !fields.some((field) => field.name === primaryKey)) {
|
|
49
|
+
diagnostics.push(
|
|
50
|
+
diagnostic(
|
|
51
|
+
definition,
|
|
52
|
+
resource,
|
|
53
|
+
`Primary key '${primaryKey}' does not match a collection field.`,
|
|
54
|
+
'primaryKey',
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return diagnostics;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function diagnostic(
|
|
63
|
+
definition: GeneratedApiDefinition,
|
|
64
|
+
resource: GeneratedApiResourceDefinition,
|
|
65
|
+
message: string,
|
|
66
|
+
path: string,
|
|
67
|
+
): DataSourceDiagnostic {
|
|
68
|
+
return {
|
|
69
|
+
code: 'invalid-config',
|
|
70
|
+
dataSourceId: definition.id,
|
|
71
|
+
endpointId: resource.id,
|
|
72
|
+
message,
|
|
73
|
+
path: `resources.${resource.id}.collection.${path}`,
|
|
74
|
+
severity: 'error',
|
|
75
|
+
};
|
|
76
|
+
}
|