@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +5 -5
  3. package/dist/discovery/index.d.ts +3 -3
  4. package/dist/discovery/index.d.ts.map +1 -1
  5. package/dist/discovery/index.js +19 -28
  6. package/dist/discovery/index.js.map +1 -1
  7. package/dist/generated-api/index.d.ts +10 -0
  8. package/dist/generated-api/index.d.ts.map +1 -0
  9. package/dist/generated-api/index.js +272 -0
  10. package/dist/generated-api/index.js.map +1 -0
  11. package/dist/graphql/index.d.ts +3 -3
  12. package/dist/graphql/index.d.ts.map +1 -1
  13. package/dist/graphql/index.js +24 -60
  14. package/dist/graphql/index.js.map +1 -1
  15. package/dist/index.d.ts +9 -3
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +17 -4
  18. package/dist/index.js.map +1 -1
  19. package/dist/openapi/index.d.ts +2 -2
  20. package/dist/openapi/index.d.ts.map +1 -1
  21. package/dist/openapi/index.js +19 -29
  22. package/dist/openapi/index.js.map +1 -1
  23. package/dist/rest/index.d.ts +3 -3
  24. package/dist/rest/index.d.ts.map +1 -1
  25. package/dist/rest/index.js +51 -51
  26. package/dist/rest/index.js.map +1 -1
  27. package/dist/test-runner/index.d.ts.map +1 -1
  28. package/dist/test-runner/index.js +60 -104
  29. package/dist/test-runner/index.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/discovery/index.test.ts +25 -101
  32. package/src/discovery/index.ts +22 -35
  33. package/src/{managed-api → generated-api}/index.test.ts +59 -67
  34. package/src/generated-api/index.ts +410 -0
  35. package/src/graphql/index.test.ts +28 -134
  36. package/src/graphql/index.ts +29 -89
  37. package/src/index.test.ts +25 -18
  38. package/src/index.ts +28 -7
  39. package/src/openapi/index.test.ts +36 -246
  40. package/src/openapi/index.ts +22 -60
  41. package/src/rest/index.test.ts +22 -173
  42. package/src/rest/index.ts +62 -59
  43. package/src/test-runner/index.test.ts +82 -150
  44. package/src/test-runner/index.ts +73 -143
  45. package/dist/managed-api/index.d.ts +0 -30
  46. package/dist/managed-api/index.d.ts.map +0 -1
  47. package/dist/managed-api/index.js +0 -257
  48. package/dist/managed-api/index.js.map +0 -1
  49. 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 assertSerializable<TValue>(value: TValue): void {
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
- description: 'List posts.',
29
- args: [
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 introspection normalization', () => {
79
- it('creates the standard introspection request', () => {
80
- const request = createGraphQlIntrospectionRequest();
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 from introspection results', () => {
92
- const schemas = normalizeGraphQlIntrospectionSchemas(createIntrospection());
93
-
94
- assertSerializable(schemas);
95
- expect(schemas?.Post?.properties?.id?.type).toBe('string');
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 a serializable GraphQL data source from introspection', () => {
62
+ it('creates an external GraphQL API source', () => {
112
63
  const result = createGraphQlDataSource({
113
- id: 'content-graphql',
114
- endpointUrl: 'https://content.example.com/graphql',
115
- introspection: createIntrospection(),
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
- assertSerializable(result.data);
126
- expect(result.data.kind).toBe('graphql');
127
- expect(result.data.endpointUrl).toBe('https://content.example.com/graphql');
128
- expect(result.data.introspection?.enabled).toBe(true);
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('creates a data source with manual GraphQL operations when introspection is unavailable', () => {
140
- const result = createGraphQlDataSource({
141
- id: 'manual-graphql',
142
- endpointUrl: 'https://manual.example.com/graphql',
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(result.ok).toBe(true);
163
- if (result.ok) {
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
  });