@ankhorage/data-sources 0.2.0 → 0.4.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 +12 -0
- package/README.md +7 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/managed-api/index.d.ts +30 -0
- package/dist/managed-api/index.d.ts.map +1 -0
- package/dist/managed-api/index.js +257 -0
- package/dist/managed-api/index.js.map +1 -0
- package/dist/test-runner/index.d.ts +60 -0
- package/dist/test-runner/index.d.ts.map +1 -0
- package/dist/test-runner/index.js +307 -0
- package/dist/test-runner/index.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/managed-api/index.test.ts +181 -0
- package/src/managed-api/index.ts +364 -0
- package/src/test-runner/index.test.ts +266 -0
- package/src/test-runner/index.ts +533 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdapterRef,
|
|
3
|
+
DataContractValue,
|
|
4
|
+
DataEndpointConfig,
|
|
5
|
+
DataOperationConfig,
|
|
6
|
+
DataOperationIntent,
|
|
7
|
+
DataOperationParameter,
|
|
8
|
+
DataSchema,
|
|
9
|
+
DataSourceDiagnostic,
|
|
10
|
+
DataSourceDiagnosticResult,
|
|
11
|
+
ManagedApiDataSourceConfig,
|
|
12
|
+
ManagedApiResourceConfig,
|
|
13
|
+
OperationId,
|
|
14
|
+
} from '@ankhorage/contracts/data';
|
|
15
|
+
import type {
|
|
16
|
+
DbCollectionDefinition,
|
|
17
|
+
DbFieldDefinition,
|
|
18
|
+
DbFieldType,
|
|
19
|
+
} from '@ankhorage/contracts/db';
|
|
20
|
+
|
|
21
|
+
export const MANAGED_API_CRUD_OPERATIONS = ['list', 'read', 'create', 'update', 'delete'] as const;
|
|
22
|
+
|
|
23
|
+
export type ManagedApiCrudOperation = (typeof MANAGED_API_CRUD_OPERATIONS)[number];
|
|
24
|
+
|
|
25
|
+
export interface ManagedApiOperationPolicyRef {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly operation?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ManagedApiGenerationResourceDefinition {
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly collection: DbCollectionDefinition;
|
|
33
|
+
readonly operations?: readonly ManagedApiCrudOperation[];
|
|
34
|
+
readonly policies?: readonly ManagedApiOperationPolicyRef[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ManagedApiGenerationDefinition {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
readonly adapter: AdapterRef;
|
|
40
|
+
readonly name?: string;
|
|
41
|
+
readonly description?: string;
|
|
42
|
+
readonly resources: readonly ManagedApiGenerationResourceDefinition[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createManagedApiDataSource(
|
|
46
|
+
definition: ManagedApiGenerationDefinition,
|
|
47
|
+
): DataSourceDiagnosticResult<ManagedApiDataSourceConfig> {
|
|
48
|
+
const diagnostics = validateManagedApiDefinition(definition);
|
|
49
|
+
const hasErrors = diagnostics.some((diagnostic) => diagnostic.severity === 'error');
|
|
50
|
+
|
|
51
|
+
if (hasErrors) return { ok: false, diagnostics };
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
ok: true,
|
|
55
|
+
data: normalizeManagedApiDataSource(definition),
|
|
56
|
+
diagnostics,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function validateManagedApiDefinition(
|
|
61
|
+
definition: ManagedApiGenerationDefinition,
|
|
62
|
+
): readonly DataSourceDiagnostic[] {
|
|
63
|
+
const diagnostics: DataSourceDiagnostic[] = [];
|
|
64
|
+
|
|
65
|
+
if (definition.resources.length === 0) {
|
|
66
|
+
diagnostics.push({
|
|
67
|
+
code: 'invalid-config',
|
|
68
|
+
dataSourceId: definition.id,
|
|
69
|
+
message: 'Managed API generation requires at least one resource.',
|
|
70
|
+
path: 'resources',
|
|
71
|
+
severity: 'error',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const resource of definition.resources) {
|
|
76
|
+
if (resource.name.trim().length === 0) {
|
|
77
|
+
diagnostics.push({
|
|
78
|
+
code: 'invalid-config',
|
|
79
|
+
dataSourceId: definition.id,
|
|
80
|
+
endpointId: resource.name,
|
|
81
|
+
message: 'Managed API resource names must be non-empty.',
|
|
82
|
+
path: 'resources.name',
|
|
83
|
+
severity: 'error',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (resource.collection.name.trim().length === 0) {
|
|
88
|
+
diagnostics.push({
|
|
89
|
+
code: 'invalid-config',
|
|
90
|
+
dataSourceId: definition.id,
|
|
91
|
+
endpointId: resource.name,
|
|
92
|
+
message: 'Managed API resource collections must have a non-empty name.',
|
|
93
|
+
path: `resources.${resource.name}.collection.name`,
|
|
94
|
+
severity: 'error',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const primaryKey = resolveManagedApiPrimaryKey(resource.collection);
|
|
99
|
+
if (
|
|
100
|
+
primaryKey === undefined &&
|
|
101
|
+
getManagedApiOperations(resource).some((operation) => operation !== 'list')
|
|
102
|
+
) {
|
|
103
|
+
diagnostics.push({
|
|
104
|
+
code: 'invalid-config',
|
|
105
|
+
dataSourceId: definition.id,
|
|
106
|
+
endpointId: resource.name,
|
|
107
|
+
message:
|
|
108
|
+
'Managed API resources need a primaryKey field when generating read/create/update/delete operations.',
|
|
109
|
+
path: `resources.${resource.name}.collection.primaryKey`,
|
|
110
|
+
severity: 'error',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return diagnostics;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function normalizeManagedApiDataSource(
|
|
119
|
+
definition: ManagedApiGenerationDefinition,
|
|
120
|
+
): ManagedApiDataSourceConfig {
|
|
121
|
+
const resources: ManagedApiResourceConfig[] = [];
|
|
122
|
+
const endpoints: Record<string, DataEndpointConfig> = {};
|
|
123
|
+
const schemas: Record<string, DataSchema> = {};
|
|
124
|
+
|
|
125
|
+
for (const resource of definition.resources) {
|
|
126
|
+
const operations = getManagedApiOperations(resource);
|
|
127
|
+
const resourceConfig: ManagedApiResourceConfig = {
|
|
128
|
+
name: resource.name,
|
|
129
|
+
collection: resource.collection,
|
|
130
|
+
operations,
|
|
131
|
+
metadata: createManagedApiResourceMetadata(resource),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
resources.push(resourceConfig);
|
|
135
|
+
endpoints[resource.name] = createManagedApiEndpoint(definition, resource, operations);
|
|
136
|
+
schemas[resource.name] = createManagedApiResourceSchema(resource.collection);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
id: definition.id,
|
|
141
|
+
kind: 'managed-api',
|
|
142
|
+
name: definition.name,
|
|
143
|
+
description: definition.description,
|
|
144
|
+
adapter: definition.adapter,
|
|
145
|
+
resources,
|
|
146
|
+
endpoints,
|
|
147
|
+
schemas,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createManagedApiEndpoint(
|
|
152
|
+
definition: ManagedApiGenerationDefinition,
|
|
153
|
+
resource: ManagedApiGenerationResourceDefinition,
|
|
154
|
+
operations: readonly ManagedApiCrudOperation[] = getManagedApiOperations(resource),
|
|
155
|
+
): DataEndpointConfig {
|
|
156
|
+
const operationConfigs: Record<OperationId, DataOperationConfig> = {};
|
|
157
|
+
|
|
158
|
+
for (const operation of operations) {
|
|
159
|
+
const operationConfig = createManagedApiOperation(definition, resource, operation);
|
|
160
|
+
operationConfigs[operationConfig.id] = operationConfig;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
id: resource.name,
|
|
165
|
+
kind: 'database',
|
|
166
|
+
operations: operationConfigs,
|
|
167
|
+
metadata: {
|
|
168
|
+
adapterId: definition.adapter.id,
|
|
169
|
+
collection: resource.collection.name,
|
|
170
|
+
schema: resource.collection.schema ?? null,
|
|
171
|
+
source: 'managed-api',
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createManagedApiOperation(
|
|
177
|
+
definition: ManagedApiGenerationDefinition,
|
|
178
|
+
resource: ManagedApiGenerationResourceDefinition,
|
|
179
|
+
operation: ManagedApiCrudOperation,
|
|
180
|
+
): DataOperationConfig {
|
|
181
|
+
const operationId = createManagedApiOperationId(resource.name, operation);
|
|
182
|
+
const primaryKey = resolveManagedApiPrimaryKey(resource.collection);
|
|
183
|
+
const primaryKeyParameter =
|
|
184
|
+
primaryKey === undefined ? undefined : createPrimaryKeyParameter(primaryKey);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
id: operationId,
|
|
188
|
+
endpointId: resource.name,
|
|
189
|
+
name: createManagedApiOperationName(resource.name, operation),
|
|
190
|
+
protocol: 'database',
|
|
191
|
+
intent: mapManagedApiOperationIntent(operation),
|
|
192
|
+
request: createManagedApiOperationRequest(resource.collection, operation, primaryKeyParameter),
|
|
193
|
+
response: createManagedApiOperationResponse(resource.collection, operation),
|
|
194
|
+
metadata: {
|
|
195
|
+
adapterId: definition.adapter.id,
|
|
196
|
+
collection: resource.collection.name,
|
|
197
|
+
operation,
|
|
198
|
+
schema: resource.collection.schema ?? null,
|
|
199
|
+
source: 'managed-api',
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function getManagedApiOperations(
|
|
205
|
+
resource: ManagedApiGenerationResourceDefinition,
|
|
206
|
+
): readonly ManagedApiCrudOperation[] {
|
|
207
|
+
return resource.operations ?? MANAGED_API_CRUD_OPERATIONS;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function createManagedApiOperationId(
|
|
211
|
+
resourceName: string,
|
|
212
|
+
operation: ManagedApiCrudOperation,
|
|
213
|
+
): OperationId {
|
|
214
|
+
return `${resourceName}.${operation}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function createManagedApiResourceSchema(collection: DbCollectionDefinition): DataSchema {
|
|
218
|
+
const properties: Record<string, DataSchema> = {};
|
|
219
|
+
const required: string[] = [];
|
|
220
|
+
|
|
221
|
+
for (const field of collection.fields) {
|
|
222
|
+
properties[field.name] = createManagedApiFieldSchema(field);
|
|
223
|
+
if (field.required === true) required.push(field.name);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
type: 'object',
|
|
228
|
+
title: collection.name,
|
|
229
|
+
required: required.length > 0 ? required : undefined,
|
|
230
|
+
properties,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function createManagedApiResourceMetadata(
|
|
235
|
+
resource: ManagedApiGenerationResourceDefinition,
|
|
236
|
+
): DataContractValue | undefined {
|
|
237
|
+
if (resource.policies === undefined) return undefined;
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
policies: resource.policies.map((policy) => {
|
|
241
|
+
const metadata: Record<string, DataContractValue> = {
|
|
242
|
+
id: policy.id,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
if (policy.operation !== undefined) {
|
|
246
|
+
metadata.operation = policy.operation;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return metadata;
|
|
250
|
+
}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function createManagedApiOperationName(
|
|
255
|
+
resourceName: string,
|
|
256
|
+
operation: ManagedApiCrudOperation,
|
|
257
|
+
): string {
|
|
258
|
+
return `${operation} ${resourceName}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function mapManagedApiOperationIntent(operation: ManagedApiCrudOperation): DataOperationIntent {
|
|
262
|
+
if (operation === 'list' || operation === 'read') return 'read';
|
|
263
|
+
if (operation === 'create') return 'create';
|
|
264
|
+
if (operation === 'update') return 'update';
|
|
265
|
+
return 'delete';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function createManagedApiOperationRequest(
|
|
269
|
+
collection: DbCollectionDefinition,
|
|
270
|
+
operation: ManagedApiCrudOperation,
|
|
271
|
+
primaryKeyParameter: DataOperationParameter | undefined,
|
|
272
|
+
): DataOperationConfig['request'] {
|
|
273
|
+
if (operation === 'list') {
|
|
274
|
+
return {
|
|
275
|
+
parameters: [
|
|
276
|
+
{
|
|
277
|
+
name: 'limit',
|
|
278
|
+
location: 'query',
|
|
279
|
+
schema: { type: 'integer' },
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: 'offset',
|
|
283
|
+
location: 'query',
|
|
284
|
+
schema: { type: 'integer' },
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (operation === 'read' || operation === 'delete') {
|
|
291
|
+
return primaryKeyParameter === undefined ? undefined : { parameters: [primaryKeyParameter] };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const parameters =
|
|
295
|
+
operation === 'update' && primaryKeyParameter !== undefined ? [primaryKeyParameter] : undefined;
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
parameters,
|
|
299
|
+
schema: createManagedApiResourceSchema(collection),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function createManagedApiOperationResponse(
|
|
304
|
+
collection: DbCollectionDefinition,
|
|
305
|
+
operation: ManagedApiCrudOperation,
|
|
306
|
+
): DataOperationConfig['response'] {
|
|
307
|
+
const resourceSchema = createManagedApiResourceSchema(collection);
|
|
308
|
+
|
|
309
|
+
if (operation === 'list') {
|
|
310
|
+
return {
|
|
311
|
+
schema: {
|
|
312
|
+
type: 'array',
|
|
313
|
+
items: resourceSchema,
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (operation === 'delete') {
|
|
319
|
+
return {
|
|
320
|
+
schema: {
|
|
321
|
+
type: 'object',
|
|
322
|
+
properties: {
|
|
323
|
+
deleted: { type: 'boolean' },
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { schema: resourceSchema };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function createPrimaryKeyParameter(primaryKey: string): DataOperationParameter {
|
|
333
|
+
return {
|
|
334
|
+
name: primaryKey,
|
|
335
|
+
location: 'path',
|
|
336
|
+
required: true,
|
|
337
|
+
schema: {
|
|
338
|
+
type: 'string',
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function resolveManagedApiPrimaryKey(collection: DbCollectionDefinition): string | undefined {
|
|
344
|
+
if (collection.primaryKey !== undefined) return collection.primaryKey;
|
|
345
|
+
return collection.fields.find((field) => field.unique === true)?.name;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function createManagedApiFieldSchema(field: DbFieldDefinition): DataSchema {
|
|
349
|
+
const schema: DataSchema = mapDbFieldTypeToDataSchema(field.type);
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
...schema,
|
|
353
|
+
default: field.defaultValue,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function mapDbFieldTypeToDataSchema(type: DbFieldType): DataSchema {
|
|
358
|
+
if (type === 'boolean') return { type: 'boolean' };
|
|
359
|
+
if (type === 'datetime') return { type: 'string', format: 'date-time' };
|
|
360
|
+
if (type === 'json') return { type: 'object' };
|
|
361
|
+
if (type === 'number') return { type: 'number' };
|
|
362
|
+
if (type === 'uuid') return { type: 'string', format: 'uuid' };
|
|
363
|
+
return { type: 'string' };
|
|
364
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { DataSourceConfig } from '@ankhorage/contracts/data';
|
|
2
|
+
import { describe, expect, it } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { buildEndpointTestRequest, type EndpointTestFetch, testEndpoint } from './index';
|
|
5
|
+
|
|
6
|
+
function createRestDataSource(): DataSourceConfig {
|
|
7
|
+
return {
|
|
8
|
+
id: 'cms',
|
|
9
|
+
kind: 'rest',
|
|
10
|
+
baseUrl: 'https://cms.example.com',
|
|
11
|
+
credential: { id: 'cms-token', kind: 'bearer' },
|
|
12
|
+
endpoints: {
|
|
13
|
+
posts: {
|
|
14
|
+
id: 'posts',
|
|
15
|
+
kind: 'http',
|
|
16
|
+
path: '/posts/{postId}',
|
|
17
|
+
operations: {
|
|
18
|
+
'posts.get': {
|
|
19
|
+
id: 'posts.get',
|
|
20
|
+
endpointId: 'posts',
|
|
21
|
+
protocol: 'http',
|
|
22
|
+
intent: 'read',
|
|
23
|
+
method: 'GET',
|
|
24
|
+
path: '/posts/{postId}',
|
|
25
|
+
request: {
|
|
26
|
+
parameters: [
|
|
27
|
+
{
|
|
28
|
+
name: 'postId',
|
|
29
|
+
location: 'path',
|
|
30
|
+
required: true,
|
|
31
|
+
schema: { type: 'string' },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'preview',
|
|
35
|
+
location: 'query',
|
|
36
|
+
schema: { type: 'boolean' },
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
response: {
|
|
41
|
+
status: 200,
|
|
42
|
+
schema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
'posts.create': {
|
|
48
|
+
id: 'posts.create',
|
|
49
|
+
endpointId: 'posts',
|
|
50
|
+
protocol: 'http',
|
|
51
|
+
intent: 'create',
|
|
52
|
+
method: 'POST',
|
|
53
|
+
path: '/posts',
|
|
54
|
+
request: {
|
|
55
|
+
contentType: 'application/json',
|
|
56
|
+
schema: { type: 'object' },
|
|
57
|
+
},
|
|
58
|
+
response: {
|
|
59
|
+
status: 201,
|
|
60
|
+
schema: { type: 'object' },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createGraphQlDataSource(): DataSourceConfig {
|
|
70
|
+
return {
|
|
71
|
+
id: 'content',
|
|
72
|
+
kind: 'graphql',
|
|
73
|
+
endpointUrl: 'https://content.example.com/graphql',
|
|
74
|
+
endpoints: {
|
|
75
|
+
graphql: {
|
|
76
|
+
id: 'graphql',
|
|
77
|
+
kind: 'graphql',
|
|
78
|
+
operations: {
|
|
79
|
+
'query.viewer': {
|
|
80
|
+
id: 'query.viewer',
|
|
81
|
+
endpointId: 'graphql',
|
|
82
|
+
protocol: 'graphql',
|
|
83
|
+
intent: 'read',
|
|
84
|
+
request: { schema: { type: 'object' } },
|
|
85
|
+
response: { schema: { type: 'object' } },
|
|
86
|
+
metadata: {
|
|
87
|
+
document: 'query Viewer($id: ID!) { viewer(id: $id) { id } }',
|
|
88
|
+
selectionPath: '$.data.viewer',
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('endpoint test runner', () => {
|
|
98
|
+
it('builds a dry-run REST request with path, query, and credential headers', async () => {
|
|
99
|
+
const result = await buildEndpointTestRequest({
|
|
100
|
+
dataSource: createRestDataSource(),
|
|
101
|
+
endpointId: 'posts',
|
|
102
|
+
operationId: 'posts.get',
|
|
103
|
+
dryRun: true,
|
|
104
|
+
values: {
|
|
105
|
+
postId: 'post 1',
|
|
106
|
+
preview: true,
|
|
107
|
+
},
|
|
108
|
+
credentialResolver: () => ({
|
|
109
|
+
headers: { authorization: 'Bearer token' },
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.ok).toBe(true);
|
|
114
|
+
if (result.ok) {
|
|
115
|
+
expect(result.request.url).toBe('https://cms.example.com/posts/post%201?preview=true');
|
|
116
|
+
expect(result.request.method).toBe('GET');
|
|
117
|
+
expect(result.request.headers.authorization).toBe('Bearer token');
|
|
118
|
+
expect(result.request.dryRun).toBe(true);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('executes a successful REST request and parses JSON responses', async () => {
|
|
123
|
+
const fetch: EndpointTestFetch = (url, init) => {
|
|
124
|
+
expect(url).toBe('https://cms.example.com/posts/post-1');
|
|
125
|
+
expect(init.method).toBe('GET');
|
|
126
|
+
return Promise.resolve({
|
|
127
|
+
status: 200,
|
|
128
|
+
headers: { 'content-type': 'application/json' },
|
|
129
|
+
text: () => Promise.resolve('{"id":"post-1"}'),
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const result = await testEndpoint({
|
|
134
|
+
dataSource: createRestDataSource(),
|
|
135
|
+
endpointId: 'posts',
|
|
136
|
+
operationId: 'posts.get',
|
|
137
|
+
values: { postId: 'post-1' },
|
|
138
|
+
credentialResolver: () => ({ headers: { authorization: 'Bearer token' } }),
|
|
139
|
+
fetch,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(result.ok).toBe(true);
|
|
143
|
+
if (result.ok) {
|
|
144
|
+
expect(result.response?.status).toBe(200);
|
|
145
|
+
expect(result.data).toEqual({ id: 'post-1' });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns structured diagnostics for HTTP errors', async () => {
|
|
150
|
+
const fetch: EndpointTestFetch = () =>
|
|
151
|
+
Promise.resolve({
|
|
152
|
+
status: 404,
|
|
153
|
+
text: () => Promise.resolve('{"error":"Not found"}'),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const result = await testEndpoint({
|
|
157
|
+
dataSource: createRestDataSource(),
|
|
158
|
+
endpointId: 'posts',
|
|
159
|
+
operationId: 'posts.get',
|
|
160
|
+
values: { postId: 'missing' },
|
|
161
|
+
credentialResolver: () => ({ headers: { authorization: 'Bearer token' } }),
|
|
162
|
+
fetch,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(result.ok).toBe(false);
|
|
166
|
+
if (!result.ok) {
|
|
167
|
+
expect(result.response?.status).toBe(404);
|
|
168
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain('http-error');
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('returns structured diagnostics for network errors', async () => {
|
|
173
|
+
const fetch: EndpointTestFetch = () => Promise.reject(new Error('Connection failed'));
|
|
174
|
+
|
|
175
|
+
const result = await testEndpoint({
|
|
176
|
+
dataSource: createRestDataSource(),
|
|
177
|
+
endpointId: 'posts',
|
|
178
|
+
operationId: 'posts.get',
|
|
179
|
+
values: { postId: 'post-1' },
|
|
180
|
+
credentialResolver: () => ({ headers: { authorization: 'Bearer token' } }),
|
|
181
|
+
fetch,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(result.ok).toBe(false);
|
|
185
|
+
if (!result.ok) {
|
|
186
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain('network-error');
|
|
187
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.message)).toContain(
|
|
188
|
+
'Connection failed',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('returns structured diagnostics for missing credentials', async () => {
|
|
194
|
+
const result = await buildEndpointTestRequest({
|
|
195
|
+
dataSource: createRestDataSource(),
|
|
196
|
+
endpointId: 'posts',
|
|
197
|
+
operationId: 'posts.get',
|
|
198
|
+
values: { postId: 'post-1' },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(result.ok).toBe(false);
|
|
202
|
+
if (!result.ok) {
|
|
203
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain(
|
|
204
|
+
'missing-credential',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('returns structured diagnostics for invalid input', async () => {
|
|
210
|
+
const result = await buildEndpointTestRequest({
|
|
211
|
+
dataSource: createRestDataSource(),
|
|
212
|
+
endpointId: 'posts',
|
|
213
|
+
operationId: 'posts.get',
|
|
214
|
+
credentialResolver: () => ({ headers: { authorization: 'Bearer token' } }),
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(result.ok).toBe(false);
|
|
218
|
+
if (!result.ok) {
|
|
219
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.message)).toContain(
|
|
220
|
+
"Missing required path parameter 'postId'.",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('builds POST request bodies from input body values', async () => {
|
|
226
|
+
const result = await buildEndpointTestRequest({
|
|
227
|
+
dataSource: createRestDataSource(),
|
|
228
|
+
endpointId: 'posts',
|
|
229
|
+
operationId: 'posts.create',
|
|
230
|
+
dryRun: true,
|
|
231
|
+
values: {
|
|
232
|
+
body: { title: 'Hello' },
|
|
233
|
+
},
|
|
234
|
+
credentialResolver: () => ({ headers: { authorization: 'Bearer token' } }),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(result.ok).toBe(true);
|
|
238
|
+
if (result.ok) {
|
|
239
|
+
expect(result.request.method).toBe('POST');
|
|
240
|
+
expect(result.request.body).toBe('{"title":"Hello"}');
|
|
241
|
+
expect(result.request.headers['content-type']).toBe('application/json');
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('builds dry-run GraphQL requests from operation metadata', async () => {
|
|
246
|
+
const result = await buildEndpointTestRequest({
|
|
247
|
+
dataSource: createGraphQlDataSource(),
|
|
248
|
+
endpointId: 'graphql',
|
|
249
|
+
operationId: 'query.viewer',
|
|
250
|
+
dryRun: true,
|
|
251
|
+
values: {
|
|
252
|
+
variables: { id: 'viewer-1' },
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(result.ok).toBe(true);
|
|
257
|
+
if (result.ok) {
|
|
258
|
+
expect(result.request.url).toBe('https://content.example.com/graphql');
|
|
259
|
+
expect(result.request.method).toBe('POST');
|
|
260
|
+
expect(result.request.headers['content-type']).toBe('application/json');
|
|
261
|
+
expect(result.request.body).toBe(
|
|
262
|
+
'{"query":"query Viewer($id: ID!) { viewer(id: $id) { id } }","variables":{"id":"viewer-1"}}',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|