@ankhorage/data-sources 1.0.1 → 2.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 (120) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +6 -41
  3. package/dist/ankh.provider.js +1 -1
  4. package/dist/ankh.provider.js.map +1 -1
  5. package/dist/discovery/http.d.ts +10 -0
  6. package/dist/discovery/http.d.ts.map +1 -0
  7. package/dist/discovery/http.js +35 -0
  8. package/dist/discovery/http.js.map +1 -0
  9. package/dist/discovery/index.d.ts +9 -9
  10. package/dist/discovery/index.d.ts.map +1 -1
  11. package/dist/discovery/index.js +27 -55
  12. package/dist/discovery/index.js.map +1 -1
  13. package/dist/graphql/index.d.ts +8 -76
  14. package/dist/graphql/index.d.ts.map +1 -1
  15. package/dist/graphql/index.js +17 -197
  16. package/dist/graphql/index.js.map +1 -1
  17. package/dist/graphql/introspection.d.ts +5 -0
  18. package/dist/graphql/introspection.d.ts.map +1 -0
  19. package/dist/graphql/introspection.js +136 -0
  20. package/dist/graphql/introspection.js.map +1 -0
  21. package/dist/graphql/operation.d.ts +5 -0
  22. package/dist/graphql/operation.d.ts.map +1 -0
  23. package/dist/graphql/operation.js +39 -0
  24. package/dist/graphql/operation.js.map +1 -0
  25. package/dist/graphql/types.d.ts +71 -0
  26. package/dist/graphql/types.d.ts.map +1 -0
  27. package/dist/graphql/types.js +2 -0
  28. package/dist/graphql/types.js.map +1 -0
  29. package/dist/index.d.ts +1 -17
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -34
  32. package/dist/index.js.map +1 -1
  33. package/dist/openapi/endpoints.d.ts +5 -0
  34. package/dist/openapi/endpoints.d.ts.map +1 -0
  35. package/dist/openapi/endpoints.js +77 -0
  36. package/dist/openapi/endpoints.js.map +1 -0
  37. package/dist/openapi/index.d.ts +7 -87
  38. package/dist/openapi/index.d.ts.map +1 -1
  39. package/dist/openapi/index.js +8 -267
  40. package/dist/openapi/index.js.map +1 -1
  41. package/dist/openapi/operation.d.ts +5 -0
  42. package/dist/openapi/operation.d.ts.map +1 -0
  43. package/dist/openapi/operation.js +141 -0
  44. package/dist/openapi/operation.js.map +1 -0
  45. package/dist/openapi/schema.d.ts +5 -0
  46. package/dist/openapi/schema.d.ts.map +1 -0
  47. package/dist/openapi/schema.js +84 -0
  48. package/dist/openapi/schema.js.map +1 -0
  49. package/dist/openapi/types.d.ts +82 -0
  50. package/dist/openapi/types.d.ts.map +1 -0
  51. package/dist/openapi/types.js +2 -0
  52. package/dist/openapi/types.js.map +1 -0
  53. package/dist/packageInfo.d.ts +18 -0
  54. package/dist/packageInfo.d.ts.map +1 -0
  55. package/dist/packageInfo.js +30 -0
  56. package/dist/packageInfo.js.map +1 -0
  57. package/dist/rest/index.d.ts +5 -5
  58. package/dist/rest/index.d.ts.map +1 -1
  59. package/dist/rest/index.js +49 -48
  60. package/dist/rest/index.js.map +1 -1
  61. package/dist/test-runner/index.d.ts +2 -57
  62. package/dist/test-runner/index.d.ts.map +1 -1
  63. package/dist/test-runner/index.js +58 -160
  64. package/dist/test-runner/index.js.map +1 -1
  65. package/dist/test-runner/response.d.ts +3 -0
  66. package/dist/test-runner/response.d.ts.map +1 -0
  67. package/dist/test-runner/response.js +29 -0
  68. package/dist/test-runner/response.js.map +1 -0
  69. package/dist/test-runner/result.d.ts +6 -0
  70. package/dist/test-runner/result.d.ts.map +1 -0
  71. package/dist/test-runner/result.js +17 -0
  72. package/dist/test-runner/result.js.map +1 -0
  73. package/dist/test-runner/serialization.d.ts +12 -0
  74. package/dist/test-runner/serialization.d.ts.map +1 -0
  75. package/dist/test-runner/serialization.js +99 -0
  76. package/dist/test-runner/serialization.js.map +1 -0
  77. package/dist/test-runner/types.d.ts +58 -0
  78. package/dist/test-runner/types.d.ts.map +1 -0
  79. package/dist/test-runner/types.js +2 -0
  80. package/dist/test-runner/types.js.map +1 -0
  81. package/package.json +5 -5
  82. package/src/ankh.provider.ts +1 -1
  83. package/src/api-phase1.contract.test.ts +24 -0
  84. package/src/discovery/http.ts +38 -0
  85. package/src/discovery/index.test.ts +76 -75
  86. package/src/discovery/index.ts +49 -72
  87. package/src/graphql/index.test.ts +10 -11
  88. package/src/graphql/index.ts +35 -315
  89. package/src/graphql/introspection.ts +163 -0
  90. package/src/graphql/operation.ts +60 -0
  91. package/src/graphql/types.ts +79 -0
  92. package/src/index.test.ts +21 -13
  93. package/src/index.ts +1 -54
  94. package/src/openapi/endpoints.ts +124 -0
  95. package/src/openapi/index.test.ts +47 -47
  96. package/src/openapi/index.ts +12 -467
  97. package/src/openapi/operation.ts +231 -0
  98. package/src/openapi/schema.ts +127 -0
  99. package/src/openapi/types.ts +93 -0
  100. package/src/packageInfo.ts +50 -0
  101. package/src/rest/index.test.ts +17 -14
  102. package/src/rest/index.ts +67 -61
  103. package/src/test-runner/index.test.ts +153 -120
  104. package/src/test-runner/index.ts +97 -299
  105. package/src/test-runner/response.ts +32 -0
  106. package/src/test-runner/result.ts +35 -0
  107. package/src/test-runner/serialization.ts +151 -0
  108. package/src/test-runner/types.ts +81 -0
  109. package/dist/generated-api/index.d.ts +0 -10
  110. package/dist/generated-api/index.d.ts.map +0 -1
  111. package/dist/generated-api/index.js +0 -274
  112. package/dist/generated-api/index.js.map +0 -1
  113. package/dist/generated-api/validateGeneratedApiCollection.d.ts +0 -3
  114. package/dist/generated-api/validateGeneratedApiCollection.d.ts.map +0 -1
  115. package/dist/generated-api/validateGeneratedApiCollection.js +0 -35
  116. package/dist/generated-api/validateGeneratedApiCollection.js.map +0 -1
  117. package/src/generated-api/index.test.ts +0 -173
  118. package/src/generated-api/index.ts +0 -414
  119. package/src/generated-api/validateGeneratedApiCollection.test.ts +0 -91
  120. package/src/generated-api/validateGeneratedApiCollection.ts +0 -76
@@ -1,11 +1,11 @@
1
- import { describe, expect, it } from 'bun:test';
1
+ import { expect, it } from 'bun:test';
2
2
 
3
3
  import {
4
4
  createOpenApiDiscoveryCandidates,
5
- discoverOpenApiDataSource,
5
+ discoverOpenApi,
6
6
  type ExternalApiFetch,
7
7
  type ExternalApiFetchResponse,
8
- introspectGraphQlDataSource,
8
+ introspectGraphQlApi,
9
9
  } from './index';
10
10
 
11
11
  function jsonResponse(value: unknown, status = 200): ExternalApiFetchResponse {
@@ -50,84 +50,85 @@ function graphQlPayload() {
50
50
  } as const;
51
51
  }
52
52
 
53
- describe('external API discovery', () => {
54
- it('creates direct and conventional candidates and rejects unsafe URLs', () => {
55
- const candidates = createOpenApiDiscoveryCandidates('https://api.example.com/service');
56
- expect(candidates).toContain('https://api.example.com/service');
57
- expect(candidates).toContain('https://api.example.com/service/openapi.json');
58
- expect(candidates).toContain('https://api.example.com/openapi.json');
59
- expect(createOpenApiDiscoveryCandidates('file:///tmp/openapi.json')).toEqual([]);
60
- expect(
61
- createOpenApiDiscoveryCandidates('https://user:secret@example.com/openapi.json'),
62
- ).toEqual([]);
63
- });
53
+ it('creates OpenAPI discovery candidates and rejects unsafe URLs', () => {
54
+ const candidates = createOpenApiDiscoveryCandidates('https://api.example.com/service');
55
+ expect(candidates).toContain('https://api.example.com/service');
56
+ expect(candidates).toContain('https://api.example.com/service/openapi.json');
57
+ expect(candidates).toContain('https://api.example.com/openapi.json');
58
+ expect(createOpenApiDiscoveryCandidates('file:///tmp/openapi.json')).toEqual([]);
59
+ expect(createOpenApiDiscoveryCandidates('https://user:secret@example.com/openapi.json')).toEqual(
60
+ [],
61
+ );
62
+ });
64
63
 
65
- it('discovers OpenAPI into an external REST source', async () => {
66
- const result = await discoverOpenApiDataSource({
67
- id: 'inventory',
68
- url: 'https://api.example.com/openapi.json',
69
- fetch: () => Promise.resolve(jsonResponse(openApiDocument())),
70
- });
71
- expect(result.ok).toBe(true);
72
- if (result.ok) {
73
- expect(result.data).toMatchObject({ kind: 'api', origin: 'external', protocol: 'rest' });
74
- expect(result.data.openApi?.url).toBe('https://api.example.com/openapi.json');
75
- expect(result.attempts[0]?.outcome).toBe('matched');
76
- }
64
+ it('discovers an external REST API', async () => {
65
+ const result = await discoverOpenApi({
66
+ id: 'inventory',
67
+ url: 'https://api.example.com/openapi.json',
68
+ fetch: () => Promise.resolve(jsonResponse(openApiDocument())),
77
69
  });
70
+ expect(result.ok).toBe(true);
71
+ if (result.ok) {
72
+ expect(result.data).toMatchObject({ origin: 'external', protocol: 'rest' });
73
+ expect('kind' in result.data).toBe(false);
74
+ expect(result.data.openApi?.url).toBe('https://api.example.com/openapi.json');
75
+ expect(result.attempts[0]?.outcome).toBe('matched');
76
+ }
77
+ });
78
78
 
79
- it('falls back to conventional locations and keeps diagnostics safe', async () => {
80
- const calls: string[] = [];
81
- const fetch: ExternalApiFetch = (url) => {
82
- calls.push(url);
83
- return Promise.resolve(
84
- url.endsWith('/service/openapi.json')
85
- ? jsonResponse(openApiDocument())
86
- : jsonResponse({ private: 'hidden' }, 404),
87
- );
88
- };
89
- const result = await discoverOpenApiDataSource({
90
- id: 'inventory',
91
- url: 'https://api.example.com/service',
92
- conventionalPaths: ['openapi.json'],
93
- fetch,
94
- });
95
- expect(result.ok).toBe(true);
96
- expect(calls).toEqual([
97
- 'https://api.example.com/service',
98
- 'https://api.example.com/service/openapi.json',
99
- ]);
100
- expect(JSON.stringify(result)).not.toContain('hidden');
79
+ it('falls back to OpenAPI conventional locations without leaking response data', async () => {
80
+ const calls: string[] = [];
81
+ const fetch: ExternalApiFetch = (url) => {
82
+ calls.push(url);
83
+ return Promise.resolve(
84
+ url.endsWith('/service/openapi.json')
85
+ ? jsonResponse(openApiDocument())
86
+ : jsonResponse({ private: 'hidden' }, 404),
87
+ );
88
+ };
89
+ const result = await discoverOpenApi({
90
+ id: 'inventory',
91
+ url: 'https://api.example.com/service',
92
+ conventionalPaths: ['openapi.json'],
93
+ fetch,
101
94
  });
95
+ expect(result.ok).toBe(true);
96
+ expect(calls).toEqual([
97
+ 'https://api.example.com/service',
98
+ 'https://api.example.com/service/openapi.json',
99
+ ]);
100
+ expect(JSON.stringify(result)).not.toContain('hidden');
101
+ });
102
102
 
103
- it('introspects GraphQL without echoing trusted headers', async () => {
104
- const result = await introspectGraphQlDataSource({
105
- id: 'catalog',
106
- endpointUrl: 'https://api.example.com/graphql',
107
- headers: { authorization: 'Bearer server-only' },
108
- fetch: () => Promise.resolve(jsonResponse(graphQlPayload())),
109
- });
110
- expect(result.ok).toBe(true);
111
- if (result.ok) {
112
- expect(result.data).toMatchObject({ kind: 'api', origin: 'external', protocol: 'graphql' });
113
- expect(result.data.endpoints.graphql?.operations['query.items']).toBeDefined();
114
- expect(JSON.stringify(result)).not.toContain('server-only');
115
- }
103
+ it('introspects GraphQL without echoing trusted headers', async () => {
104
+ const result = await introspectGraphQlApi({
105
+ id: 'catalog',
106
+ endpointUrl: 'https://api.example.com/graphql',
107
+ headers: { authorization: 'Bearer server-only' },
108
+ fetch: () => Promise.resolve(jsonResponse(graphQlPayload())),
116
109
  });
110
+ expect(result.ok).toBe(true);
111
+ if (result.ok) {
112
+ expect(result.data).toMatchObject({ origin: 'external', protocol: 'graphql' });
113
+ expect('kind' in result.data).toBe(false);
114
+ expect(result.data.endpoints.graphql?.operations['query.items']).toBeDefined();
115
+ expect(JSON.stringify(result)).not.toContain('server-only');
116
+ }
117
+ });
117
118
 
118
- it('reports transport and response-shape failures safely', async () => {
119
- const http = await introspectGraphQlDataSource({
120
- id: 'catalog',
121
- endpointUrl: 'https://api.example.com/graphql',
122
- fetch: () => Promise.resolve(jsonResponse({ secret: 'hidden' }, 403)),
123
- });
124
- const shape = await introspectGraphQlDataSource({
125
- id: 'catalog',
126
- endpointUrl: 'https://api.example.com/graphql',
127
- fetch: () => Promise.resolve(jsonResponse({ data: {} })),
128
- });
129
- expect(http).toMatchObject({ ok: false, status: 403 });
130
- expect(JSON.stringify(http)).not.toContain('hidden');
131
- expect(shape.ok).toBe(false);
119
+ it('reports GraphQL transport and response-shape failures safely', async () => {
120
+ const http = await introspectGraphQlApi({
121
+ id: 'catalog',
122
+ endpointUrl: 'https://api.example.com/graphql',
123
+ fetch: () => Promise.resolve(jsonResponse({ secret: 'hidden' }, 403)),
124
+ });
125
+ const shape = await introspectGraphQlApi({
126
+ id: 'catalog',
127
+ endpointUrl: 'https://api.example.com/graphql',
128
+ fetch: () => Promise.resolve(jsonResponse({ data: {} })),
132
129
  });
130
+ expect(http).toMatchObject({ ok: false, status: 403 });
131
+ expect(JSON.stringify(http)).not.toContain('hidden');
132
+ expect(shape.ok).toBe(false);
133
+ if (!shape.ok) expect(shape.diagnostics[0]?.apiId).toBe('catalog');
133
134
  });
@@ -2,16 +2,23 @@ import type {
2
2
  CredentialRef,
3
3
  DataContractValue,
4
4
  DataSourceDiagnostic,
5
- ExternalGraphQlApiDataSourceConfig,
6
- ExternalRestApiDataSourceConfig,
5
+ ExternalGraphQlApiDefinition,
6
+ ExternalRestApiDefinition,
7
7
  } from '@ankhorage/contracts/data';
8
8
 
9
9
  import {
10
- createGraphQlDataSource,
10
+ createGraphQlApi,
11
11
  createGraphQlIntrospectionRequest,
12
12
  type GraphQlIntrospectionResult,
13
13
  } from '../graphql';
14
14
  import { importOpenApiDocument, type OpenApiDocumentObject } from '../openapi';
15
+ import {
16
+ isSuccessfulStatus,
17
+ normalizeCandidateUrl,
18
+ parseHttpUrl,
19
+ parseJsonResponse,
20
+ readRecord,
21
+ } from './http';
15
22
 
16
23
  export const DEFAULT_OPENAPI_DISCOVERY_PATHS = [
17
24
  'openapi.json',
@@ -50,7 +57,7 @@ export interface OpenApiDiscoveryAttempt {
50
57
  readonly status?: number;
51
58
  }
52
59
 
53
- export interface DiscoverOpenApiDataSourceInput {
60
+ export interface DiscoverOpenApiInput {
54
61
  readonly id: string;
55
62
  readonly url: string;
56
63
  readonly fetch: ExternalApiFetch;
@@ -62,10 +69,10 @@ export interface DiscoverOpenApiDataSourceInput {
62
69
  readonly conventionalPaths?: readonly string[];
63
70
  }
64
71
 
65
- export type DiscoverOpenApiDataSourceResult =
72
+ export type DiscoverOpenApiResult =
66
73
  | {
67
74
  readonly ok: true;
68
- readonly data: ExternalRestApiDataSourceConfig;
75
+ readonly data: ExternalRestApiDefinition;
69
76
  readonly documentUrl: string;
70
77
  readonly attempts: readonly OpenApiDiscoveryAttempt[];
71
78
  readonly diagnostics: readonly DataSourceDiagnostic[];
@@ -76,7 +83,7 @@ export type DiscoverOpenApiDataSourceResult =
76
83
  readonly diagnostics: readonly DataSourceDiagnostic[];
77
84
  };
78
85
 
79
- export interface IntrospectGraphQlDataSourceInput {
86
+ export interface IntrospectGraphQlApiInput {
80
87
  readonly id: string;
81
88
  readonly endpointUrl: string;
82
89
  readonly fetch: ExternalApiFetch;
@@ -88,10 +95,10 @@ export interface IntrospectGraphQlDataSourceInput {
88
95
  readonly metadata?: DataContractValue;
89
96
  }
90
97
 
91
- export type IntrospectGraphQlDataSourceResult =
98
+ export type IntrospectGraphQlApiResult =
92
99
  | {
93
100
  readonly ok: true;
94
- readonly data: ExternalGraphQlApiDataSourceConfig;
101
+ readonly data: ExternalGraphQlApiDefinition;
95
102
  readonly diagnostics: readonly DataSourceDiagnostic[];
96
103
  }
97
104
  | {
@@ -121,9 +128,7 @@ export function createOpenApiDiscoveryCandidates(
121
128
  return [...new Set(candidates)];
122
129
  }
123
130
 
124
- export async function discoverOpenApiDataSource(
125
- input: DiscoverOpenApiDataSourceInput,
126
- ): Promise<DiscoverOpenApiDataSourceResult> {
131
+ export async function discoverOpenApi(input: DiscoverOpenApiInput): Promise<DiscoverOpenApiResult> {
127
132
  const candidates = createOpenApiDiscoveryCandidates(input.url, input.conventionalPaths);
128
133
  if (candidates.length === 0) {
129
134
  return discoveryFailure(input.id, [], 'OpenAPI discovery requires a valid HTTP or HTTPS URL.');
@@ -152,29 +157,18 @@ export async function discoverOpenApiDataSource(
152
157
  );
153
158
  }
154
159
 
155
- export async function introspectGraphQlDataSource(
156
- input: IntrospectGraphQlDataSourceInput,
157
- ): Promise<IntrospectGraphQlDataSourceResult> {
160
+ export async function introspectGraphQlApi(
161
+ input: IntrospectGraphQlApiInput,
162
+ ): Promise<IntrospectGraphQlApiResult> {
158
163
  const endpoint = parseHttpUrl(input.endpointUrl);
159
164
  if (endpoint === undefined) {
160
165
  return graphqlFailure(input.id, 'GraphQL introspection requires a valid HTTP or HTTPS URL.');
161
166
  }
162
167
 
163
- let response: ExternalApiFetchResponse;
164
- try {
165
- response = await input.fetch(normalizeCandidateUrl(endpoint), {
166
- method: 'POST',
167
- headers: {
168
- accept: 'application/json',
169
- 'content-type': 'application/json',
170
- ...(input.headers ?? {}),
171
- },
172
- body: JSON.stringify(createGraphQlIntrospectionRequest()),
173
- });
174
- } catch {
168
+ const response = await fetchGraphQlIntrospection(input, endpoint);
169
+ if (response === undefined) {
175
170
  return graphqlFailure(input.id, 'GraphQL introspection request failed.', 'network-error');
176
171
  }
177
-
178
172
  if (!isSuccessfulStatus(response.status)) {
179
173
  return {
180
174
  ...graphqlFailure(
@@ -195,7 +189,7 @@ export async function introspectGraphQlDataSource(
195
189
  );
196
190
  }
197
191
 
198
- const result = createGraphQlDataSource({
192
+ const result = createGraphQlApi({
199
193
  id: input.id,
200
194
  endpointUrl: normalizeCandidateUrl(endpoint),
201
195
  credential: input.credential,
@@ -206,19 +200,37 @@ export async function introspectGraphQlDataSource(
206
200
  schemaVersion: input.schemaVersion,
207
201
  metadata: input.metadata,
208
202
  });
209
-
210
203
  return result.ok
211
204
  ? { ok: true, data: result.data, diagnostics: result.diagnostics ?? [] }
212
205
  : { ok: false, diagnostics: result.diagnostics };
213
206
  }
214
207
 
208
+ async function fetchGraphQlIntrospection(
209
+ input: IntrospectGraphQlApiInput,
210
+ endpoint: URL,
211
+ ): Promise<ExternalApiFetchResponse | undefined> {
212
+ try {
213
+ return await input.fetch(normalizeCandidateUrl(endpoint), {
214
+ method: 'POST',
215
+ headers: {
216
+ accept: 'application/json',
217
+ 'content-type': 'application/json',
218
+ ...(input.headers ?? {}),
219
+ },
220
+ body: JSON.stringify(createGraphQlIntrospectionRequest()),
221
+ });
222
+ } catch {
223
+ return undefined;
224
+ }
225
+ }
226
+
215
227
  interface OpenApiProbeResult {
216
228
  readonly attempt: OpenApiDiscoveryAttempt;
217
229
  readonly result?: ReturnType<typeof importOpenApiDocument>;
218
230
  }
219
231
 
220
232
  async function probeOpenApiCandidate(
221
- input: DiscoverOpenApiDataSourceInput,
233
+ input: DiscoverOpenApiInput,
222
234
  candidate: string,
223
235
  ): Promise<OpenApiProbeResult> {
224
236
  let response: ExternalApiFetchResponse;
@@ -265,35 +277,6 @@ async function probeOpenApiCandidate(
265
277
  };
266
278
  }
267
279
 
268
- function parseHttpUrl(rawUrl: string): URL | undefined {
269
- try {
270
- const parsed = new URL(rawUrl.trim());
271
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
272
- if (parsed.username.length > 0 || parsed.password.length > 0) return undefined;
273
- return parsed;
274
- } catch {
275
- return undefined;
276
- }
277
- }
278
-
279
- function normalizeCandidateUrl(url: URL): string {
280
- const normalized = new URL(url.toString());
281
- normalized.hash = '';
282
- return normalized.toString();
283
- }
284
-
285
- function isSuccessfulStatus(status: number): boolean {
286
- return status >= 200 && status < 300;
287
- }
288
-
289
- async function parseJsonResponse(response: ExternalApiFetchResponse): Promise<unknown> {
290
- try {
291
- return JSON.parse(await response.text()) as unknown;
292
- } catch {
293
- return undefined;
294
- }
295
- }
296
-
297
280
  function isOpenApiDocument(value: unknown): value is OpenApiDocumentObject {
298
281
  const record = readRecord(value);
299
282
  return (
@@ -310,31 +293,25 @@ function readGraphQlIntrospection(value: unknown): GraphQlIntrospectionResult |
310
293
  return data;
311
294
  }
312
295
 
313
- function readRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
314
- return typeof value === 'object' && value !== null && !Array.isArray(value)
315
- ? (value as Readonly<Record<string, unknown>>)
316
- : undefined;
317
- }
318
-
319
296
  function discoveryFailure(
320
- dataSourceId: string,
297
+ apiId: string,
321
298
  attempts: readonly OpenApiDiscoveryAttempt[],
322
299
  message: string,
323
- ): DiscoverOpenApiDataSourceResult {
300
+ ): DiscoverOpenApiResult {
324
301
  return {
325
302
  ok: false,
326
303
  attempts,
327
- diagnostics: [{ code: 'missing-schema', dataSourceId, message, severity: 'error' }],
304
+ diagnostics: [{ code: 'missing-schema', apiId, message, severity: 'error' }],
328
305
  };
329
306
  }
330
307
 
331
308
  function graphqlFailure(
332
- dataSourceId: string,
309
+ apiId: string,
333
310
  message: string,
334
311
  code: DataSourceDiagnostic['code'] = 'invalid-config',
335
- ): Extract<IntrospectGraphQlDataSourceResult, { readonly ok: false }> {
312
+ ): Extract<IntrospectGraphQlApiResult, { readonly ok: false }> {
336
313
  return {
337
314
  ok: false,
338
- diagnostics: [{ code, dataSourceId, message, severity: 'error' }],
315
+ diagnostics: [{ code, apiId, message, severity: 'error' }],
339
316
  };
340
317
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
2
 
3
3
  import {
4
- createGraphQlDataSource,
4
+ createGraphQlApi,
5
5
  createGraphQlIntrospectionRequest,
6
6
  type GraphQlIntrospectionResult,
7
7
  normalizeGraphQlIntrospectionOperations,
@@ -46,7 +46,7 @@ function introspection(): GraphQlIntrospectionResult {
46
46
  };
47
47
  }
48
48
 
49
- describe('GraphQL normalization', () => {
49
+ describe('GraphQL API normalization', () => {
50
50
  it('creates the standard introspection request and stable IDs', () => {
51
51
  expect(createGraphQlIntrospectionRequest().query).toContain('__schema');
52
52
  expect(normalizeGraphQlOperationId('query', 'Posts')).toBe('query.posts');
@@ -59,25 +59,23 @@ describe('GraphQL normalization', () => {
59
59
  );
60
60
  });
61
61
 
62
- it('creates an external GraphQL API source', () => {
63
- const result = createGraphQlDataSource({
62
+ it('creates a canonical external GraphQL API', () => {
63
+ const result = createGraphQlApi({
64
64
  id: 'content',
65
65
  endpointUrl: 'https://example.com/graphql',
66
66
  introspection: introspection(),
67
67
  });
68
68
  expect(result.ok).toBe(true);
69
69
  if (result.ok) {
70
- expect(result.data).toMatchObject({
71
- kind: 'api',
72
- origin: 'external',
73
- protocol: 'graphql',
74
- });
70
+ expect(result.data).toMatchObject({ origin: 'external', protocol: 'graphql' });
71
+ expect('kind' in result.data).toBe(false);
72
+ expect(result.data.endpoints.graphql?.baseUrl).toBeUndefined();
75
73
  expect(result.data.endpoints.graphql?.operations['query.posts']?.intent).toBe('read');
76
74
  }
77
75
  });
78
76
 
79
77
  it('supports manual operations and validates endpoint URLs', () => {
80
- const manual = createGraphQlDataSource({
78
+ const manual = createGraphQlApi({
81
79
  id: 'manual',
82
80
  endpointUrl: 'https://example.com/graphql',
83
81
  introspectionEnabled: false,
@@ -89,8 +87,9 @@ describe('GraphQL normalization', () => {
89
87
  },
90
88
  ],
91
89
  });
92
- const invalid = createGraphQlDataSource({ id: 'broken', endpointUrl: ' ' });
90
+ const invalid = createGraphQlApi({ id: 'broken', endpointUrl: ' ' });
93
91
  expect(manual.ok).toBe(true);
94
92
  expect(invalid.ok).toBe(false);
93
+ if (!invalid.ok) expect(invalid.diagnostics[0]?.apiId).toBe('broken');
95
94
  });
96
95
  });