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