@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
@@ -4,44 +4,28 @@ import {
4
4
  createOpenApiDiscoveryCandidates,
5
5
  discoverOpenApiDataSource,
6
6
  type ExternalApiFetch,
7
- type ExternalApiFetchInit,
8
7
  type ExternalApiFetchResponse,
9
8
  introspectGraphQlDataSource,
10
9
  } from './index';
11
10
 
12
11
  function jsonResponse(value: unknown, status = 200): ExternalApiFetchResponse {
13
- return {
14
- status,
15
- text: () => Promise.resolve(JSON.stringify(value)),
16
- };
12
+ return { status, text: () => Promise.resolve(JSON.stringify(value)) };
17
13
  }
18
14
 
19
- function createOpenApiDocument() {
15
+ function openApiDocument() {
20
16
  return {
21
17
  openapi: '3.1.0',
22
18
  info: { title: 'Inventory API', version: '1.0.0' },
23
19
  servers: [{ url: 'https://api.example.com' }],
24
20
  paths: {
25
21
  '/items': {
26
- get: {
27
- operationId: 'listItems',
28
- responses: {
29
- '200': {
30
- description: 'Items',
31
- content: {
32
- 'application/json': {
33
- schema: { type: 'array', items: { type: 'string' } },
34
- },
35
- },
36
- },
37
- },
38
- },
22
+ get: { operationId: 'listItems', responses: { '200': { description: 'Items' } } },
39
23
  },
40
24
  },
41
25
  } as const;
42
26
  }
43
27
 
44
- function createGraphQlPayload() {
28
+ function graphQlPayload() {
45
29
  return {
46
30
  data: {
47
31
  __schema: {
@@ -56,11 +40,7 @@ function createGraphQlPayload() {
56
40
  {
57
41
  name: 'items',
58
42
  args: [],
59
- type: {
60
- kind: 'LIST',
61
- name: null,
62
- ofType: { kind: 'SCALAR', name: 'String' },
63
- },
43
+ type: { kind: 'LIST', ofType: { kind: 'SCALAR', name: 'String' } },
64
44
  },
65
45
  ],
66
46
  },
@@ -71,139 +51,83 @@ function createGraphQlPayload() {
71
51
  }
72
52
 
73
53
  describe('external API discovery', () => {
74
- it('creates direct, service-relative, and origin-relative OpenAPI candidates', () => {
54
+ it('creates direct and conventional candidates and rejects unsafe URLs', () => {
75
55
  const candidates = createOpenApiDiscoveryCandidates('https://api.example.com/service');
76
-
77
56
  expect(candidates).toContain('https://api.example.com/service');
78
57
  expect(candidates).toContain('https://api.example.com/service/openapi.json');
79
58
  expect(candidates).toContain('https://api.example.com/openapi.json');
80
- expect(candidates).toContain('https://api.example.com/service/v3/api-docs');
81
- expect(candidates).toContain('https://api.example.com/v3/api-docs');
82
- });
83
-
84
- it('rejects non-http URLs and URLs containing inline credentials', () => {
85
59
  expect(createOpenApiDiscoveryCandidates('file:///tmp/openapi.json')).toEqual([]);
86
60
  expect(
87
61
  createOpenApiDiscoveryCandidates('https://user:secret@example.com/openapi.json'),
88
62
  ).toEqual([]);
89
63
  });
90
64
 
91
- it('imports a directly supplied OpenAPI document URL', async () => {
92
- const calls: string[] = [];
93
- const fetch: ExternalApiFetch = (url) => {
94
- calls.push(url);
95
- return Promise.resolve(jsonResponse(createOpenApiDocument()));
96
- };
97
-
65
+ it('discovers OpenAPI into an external REST source', async () => {
98
66
  const result = await discoverOpenApiDataSource({
99
67
  id: 'inventory',
100
68
  url: 'https://api.example.com/openapi.json',
101
- fetch,
69
+ fetch: () => Promise.resolve(jsonResponse(openApiDocument())),
102
70
  });
103
-
104
71
  expect(result.ok).toBe(true);
105
- expect(calls).toEqual(['https://api.example.com/openapi.json']);
106
72
  if (result.ok) {
107
- expect(result.documentUrl).toBe('https://api.example.com/openapi.json');
108
- expect(result.data.kind).toBe('openapi');
109
- expect(result.data.endpoints.items?.operations.listitems?.intent).toBe('read');
110
- expect(result.attempts).toEqual([
111
- {
112
- url: 'https://api.example.com/openapi.json',
113
- outcome: 'matched',
114
- status: 200,
115
- },
116
- ]);
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');
117
76
  }
118
77
  });
119
78
 
120
- it('falls back to a conventional OpenAPI location', async () => {
79
+ it('falls back to conventional locations and keeps diagnostics safe', async () => {
121
80
  const calls: string[] = [];
122
81
  const fetch: ExternalApiFetch = (url) => {
123
82
  calls.push(url);
124
83
  return Promise.resolve(
125
- url === 'https://api.example.com/service/openapi.json'
126
- ? jsonResponse(createOpenApiDocument())
127
- : jsonResponse({ message: 'not a schema' }, 404),
84
+ url.endsWith('/service/openapi.json')
85
+ ? jsonResponse(openApiDocument())
86
+ : jsonResponse({ private: 'hidden' }, 404),
128
87
  );
129
88
  };
130
-
131
89
  const result = await discoverOpenApiDataSource({
132
90
  id: 'inventory',
133
91
  url: 'https://api.example.com/service',
134
92
  conventionalPaths: ['openapi.json'],
135
93
  fetch,
136
94
  });
137
-
138
95
  expect(result.ok).toBe(true);
139
96
  expect(calls).toEqual([
140
97
  'https://api.example.com/service',
141
98
  'https://api.example.com/service/openapi.json',
142
99
  ]);
143
- if (result.ok) {
144
- expect(result.documentUrl).toBe('https://api.example.com/service/openapi.json');
145
- expect(result.attempts.map((attempt) => attempt.outcome)).toEqual(['http-error', 'matched']);
146
- }
147
- });
148
-
149
- it('returns safe diagnostics when no OpenAPI document can be discovered', async () => {
150
- const fetch: ExternalApiFetch = () =>
151
- Promise.resolve(jsonResponse({ error: 'private detail' }, 404));
152
- const result = await discoverOpenApiDataSource({
153
- id: 'missing',
154
- url: 'https://api.example.com',
155
- conventionalPaths: [],
156
- fetch,
157
- });
158
-
159
- expect(result.ok).toBe(false);
160
- expect(result.diagnostics[0]).toMatchObject({
161
- code: 'missing-schema',
162
- dataSourceId: 'missing',
163
- severity: 'error',
164
- });
165
- expect(JSON.stringify(result)).not.toContain('private detail');
100
+ expect(JSON.stringify(result)).not.toContain('hidden');
166
101
  });
167
102
 
168
- it('executes GraphQL introspection and normalizes discovered operations', async () => {
169
- let capturedInit: ExternalApiFetchInit | undefined;
170
- const fetch: ExternalApiFetch = (_url, init) => {
171
- capturedInit = init;
172
- return Promise.resolve(jsonResponse(createGraphQlPayload()));
173
- };
174
-
103
+ it('introspects GraphQL without echoing trusted headers', async () => {
175
104
  const result = await introspectGraphQlDataSource({
176
105
  id: 'catalog',
177
106
  endpointUrl: 'https://api.example.com/graphql',
178
107
  headers: { authorization: 'Bearer server-only' },
179
- fetch,
108
+ fetch: () => Promise.resolve(jsonResponse(graphQlPayload())),
180
109
  });
181
-
182
110
  expect(result.ok).toBe(true);
183
- expect(capturedInit?.method).toBe('POST');
184
- expect(capturedInit?.body).toContain('AnkhorageGraphQlIntrospection');
185
111
  if (result.ok) {
186
- expect(result.data.kind).toBe('graphql');
112
+ expect(result.data).toMatchObject({ kind: 'api', origin: 'external', protocol: 'graphql' });
187
113
  expect(result.data.endpoints.graphql?.operations['query.items']).toBeDefined();
188
114
  expect(JSON.stringify(result)).not.toContain('server-only');
189
115
  }
190
116
  });
191
117
 
192
- it('reports GraphQL transport and response-shape failures without response bodies', async () => {
193
- const httpFailure = await introspectGraphQlDataSource({
118
+ it('reports transport and response-shape failures safely', async () => {
119
+ const http = await introspectGraphQlDataSource({
194
120
  id: 'catalog',
195
121
  endpointUrl: 'https://api.example.com/graphql',
196
122
  fetch: () => Promise.resolve(jsonResponse({ secret: 'hidden' }, 403)),
197
123
  });
198
- expect(httpFailure).toMatchObject({ ok: false, status: 403 });
199
- expect(JSON.stringify(httpFailure)).not.toContain('hidden');
200
-
201
- const shapeFailure = await introspectGraphQlDataSource({
124
+ const shape = await introspectGraphQlDataSource({
202
125
  id: 'catalog',
203
126
  endpointUrl: 'https://api.example.com/graphql',
204
127
  fetch: () => Promise.resolve(jsonResponse({ data: {} })),
205
128
  });
206
- expect(shapeFailure.ok).toBe(false);
207
- expect(shapeFailure.diagnostics[0]?.code).toBe('parse-error');
129
+ expect(http).toMatchObject({ ok: false, status: 403 });
130
+ expect(JSON.stringify(http)).not.toContain('hidden');
131
+ expect(shape.ok).toBe(false);
208
132
  });
209
133
  });
@@ -2,8 +2,8 @@ import type {
2
2
  CredentialRef,
3
3
  DataContractValue,
4
4
  DataSourceDiagnostic,
5
- GraphQlDataSourceConfig,
6
- OpenApiDataSourceConfig,
5
+ ExternalGraphQlApiDataSourceConfig,
6
+ ExternalRestApiDataSourceConfig,
7
7
  } from '@ankhorage/contracts/data';
8
8
 
9
9
  import {
@@ -65,7 +65,7 @@ export interface DiscoverOpenApiDataSourceInput {
65
65
  export type DiscoverOpenApiDataSourceResult =
66
66
  | {
67
67
  readonly ok: true;
68
- readonly data: OpenApiDataSourceConfig;
68
+ readonly data: ExternalRestApiDataSourceConfig;
69
69
  readonly documentUrl: string;
70
70
  readonly attempts: readonly OpenApiDiscoveryAttempt[];
71
71
  readonly diagnostics: readonly DataSourceDiagnostic[];
@@ -91,7 +91,7 @@ export interface IntrospectGraphQlDataSourceInput {
91
91
  export type IntrospectGraphQlDataSourceResult =
92
92
  | {
93
93
  readonly ok: true;
94
- readonly data: GraphQlDataSourceConfig;
94
+ readonly data: ExternalGraphQlApiDataSourceConfig;
95
95
  readonly diagnostics: readonly DataSourceDiagnostic[];
96
96
  }
97
97
  | {
@@ -133,17 +133,16 @@ export async function discoverOpenApiDataSource(
133
133
  for (const candidate of candidates) {
134
134
  const probed = await probeOpenApiCandidate(input, candidate);
135
135
  attempts.push(probed.attempt);
136
- if (probed.result !== undefined) {
137
- return probed.result.ok
138
- ? {
139
- ok: true,
140
- data: probed.result.data,
141
- documentUrl: candidate,
142
- attempts,
143
- diagnostics: probed.result.diagnostics ?? [],
144
- }
145
- : { ok: false, attempts, diagnostics: probed.result.diagnostics };
146
- }
136
+ if (probed.result === undefined) continue;
137
+ return probed.result.ok
138
+ ? {
139
+ ok: true,
140
+ data: probed.result.data,
141
+ documentUrl: candidate,
142
+ attempts,
143
+ diagnostics: probed.result.diagnostics ?? [],
144
+ }
145
+ : { ok: false, attempts, diagnostics: probed.result.diagnostics };
147
146
  }
148
147
 
149
148
  return discoveryFailure(
@@ -161,7 +160,6 @@ export async function introspectGraphQlDataSource(
161
160
  return graphqlFailure(input.id, 'GraphQL introspection requires a valid HTTP or HTTPS URL.');
162
161
  }
163
162
 
164
- const request = createGraphQlIntrospectionRequest();
165
163
  let response: ExternalApiFetchResponse;
166
164
  try {
167
165
  response = await input.fetch(normalizeCandidateUrl(endpoint), {
@@ -171,7 +169,7 @@ export async function introspectGraphQlDataSource(
171
169
  'content-type': 'application/json',
172
170
  ...(input.headers ?? {}),
173
171
  },
174
- body: JSON.stringify(request),
172
+ body: JSON.stringify(createGraphQlIntrospectionRequest()),
175
173
  });
176
174
  } catch {
177
175
  return graphqlFailure(input.id, 'GraphQL introspection request failed.', 'network-error');
@@ -188,8 +186,7 @@ export async function introspectGraphQlDataSource(
188
186
  };
189
187
  }
190
188
 
191
- const parsed = await parseJsonResponse(response);
192
- const introspection = readGraphQlIntrospection(parsed);
189
+ const introspection = readGraphQlIntrospection(await parseJsonResponse(response));
193
190
  if (introspection === undefined) {
194
191
  return graphqlFailure(
195
192
  input.id,
@@ -228,27 +225,20 @@ async function probeOpenApiCandidate(
228
225
  try {
229
226
  response = await input.fetch(candidate, {
230
227
  method: 'GET',
231
- headers: {
232
- accept: 'application/json, application/vnd.oai.openapi+json',
233
- },
228
+ headers: { accept: 'application/json, application/vnd.oai.openapi+json' },
234
229
  });
235
230
  } catch {
236
231
  return { attempt: { url: candidate, outcome: 'network-error' } };
237
232
  }
238
233
 
239
234
  if (!isSuccessfulStatus(response.status)) {
240
- return {
241
- attempt: { url: candidate, outcome: 'http-error', status: response.status },
242
- };
235
+ return { attempt: { url: candidate, outcome: 'http-error', status: response.status } };
243
236
  }
244
237
 
245
238
  const parsed = await parseJsonResponse(response);
246
239
  if (parsed === undefined) {
247
- return {
248
- attempt: { url: candidate, outcome: 'parse-error', status: response.status },
249
- };
240
+ return { attempt: { url: candidate, outcome: 'parse-error', status: response.status } };
250
241
  }
251
-
252
242
  if (!isOpenApiDocument(parsed)) {
253
243
  return {
254
244
  attempt: { url: candidate, outcome: 'unsupported-document', status: response.status },
@@ -265,7 +255,6 @@ async function probeOpenApiCandidate(
265
255
  description: input.description,
266
256
  metadata: input.metadata,
267
257
  });
268
-
269
258
  return {
270
259
  attempt: {
271
260
  url: candidate,
@@ -321,12 +310,10 @@ function readGraphQlIntrospection(value: unknown): GraphQlIntrospectionResult |
321
310
  return data;
322
311
  }
323
312
 
324
- function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
325
- return typeof value === 'object' && value !== null && !Array.isArray(value);
326
- }
327
-
328
313
  function readRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
329
- return isRecord(value) ? value : undefined;
314
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
315
+ ? (value as Readonly<Record<string, unknown>>)
316
+ : undefined;
330
317
  }
331
318
 
332
319
  function discoveryFailure(
@@ -1,30 +1,34 @@
1
+ import type { GeneratedApiDefinition } from '@ankhorage/contracts/data';
1
2
  import { describe, expect, it } from 'bun:test';
2
3
 
3
4
  import {
4
- createManagedApiDataSource,
5
- createManagedApiOperationId,
6
- createManagedApiResourceSchema,
7
- getManagedApiOperations,
8
- type ManagedApiGenerationDefinition,
9
- normalizeManagedApiDataSource,
5
+ createGeneratedApiDataSource,
6
+ createGeneratedApiOperationId,
7
+ createGeneratedApiResourceSchema,
8
+ normalizeGeneratedApiDataSource,
10
9
  } from './index';
11
10
 
12
11
  function assertSerializable<TValue>(value: TValue): void {
13
12
  expect(JSON.parse(JSON.stringify(value))).toEqual(value);
14
13
  }
15
14
 
16
- function createPostsApiDefinition(): ManagedApiGenerationDefinition {
15
+ function createPostsApiDefinition(): GeneratedApiDefinition {
17
16
  return {
18
17
  id: 'posts-api',
18
+ protocol: 'rest',
19
19
  name: 'Posts API',
20
- adapter: {
20
+ basePath: '/api',
21
+ database: {
21
22
  id: 'primary-db',
22
23
  kind: 'database',
23
24
  packageName: '@ankhorage/supabase-db',
24
25
  },
25
26
  resources: [
26
27
  {
27
- name: 'posts',
28
+ id: 'posts',
29
+ name: 'Posts',
30
+ path: '/posts',
31
+ operations: ['list', 'read', 'create', 'update', 'delete'],
28
32
  collection: {
29
33
  name: 'posts',
30
34
  schema: 'public',
@@ -37,93 +41,78 @@ function createPostsApiDefinition(): ManagedApiGenerationDefinition {
37
41
  { name: 'metadata', type: 'json' },
38
42
  ],
39
43
  },
44
+ seed: [{ id: 'seed-1', title: 'Hello' }],
40
45
  policies: [{ id: 'posts-policy' }],
41
46
  },
42
47
  ],
43
48
  };
44
49
  }
45
50
 
46
- describe('managed API generation helpers', () => {
51
+ describe('generated API normalization', () => {
47
52
  it('generates deterministic operation IDs', () => {
48
- expect(createManagedApiOperationId('posts', 'list')).toBe('posts.list');
49
- expect(createManagedApiOperationId('posts', 'delete')).toBe('posts.delete');
50
- });
51
-
52
- it('uses all CRUD operations by default', () => {
53
- const [resource] = createPostsApiDefinition().resources;
54
-
55
- expect(resource).toBeDefined();
56
- if (resource !== undefined) {
57
- expect(getManagedApiOperations(resource)).toEqual([
58
- 'list',
59
- 'read',
60
- 'create',
61
- 'update',
62
- 'delete',
63
- ]);
64
- }
53
+ expect(createGeneratedApiOperationId('posts', 'list')).toBe('posts.list');
54
+ expect(createGeneratedApiOperationId('posts', 'delete')).toBe('posts.delete');
65
55
  });
66
56
 
67
57
  it('generates a serializable resource schema from a DB collection', () => {
68
58
  const [resource] = createPostsApiDefinition().resources;
69
-
70
59
  expect(resource).toBeDefined();
71
- if (resource !== undefined) {
72
- const schema = createManagedApiResourceSchema(resource.collection);
73
-
74
- assertSerializable(schema);
75
- expect(schema.type).toBe('object');
76
- expect(schema.properties?.id?.format).toBe('uuid');
77
- expect(schema.properties?.createdAt?.format).toBe('date-time');
78
- expect(schema.properties?.metadata?.type).toBe('object');
79
- expect(schema.required).toEqual(['id', 'title']);
80
- }
60
+ if (resource === undefined) return;
61
+
62
+ const schema = createGeneratedApiResourceSchema(resource.collection);
63
+ assertSerializable(schema);
64
+ expect(schema.type).toBe('object');
65
+ expect(schema.properties?.id?.format).toBe('uuid');
66
+ expect(schema.properties?.createdAt?.format).toBe('date-time');
67
+ expect(schema.properties?.metadata?.type).toBe('object');
68
+ expect(schema.required).toEqual(['id', 'title']);
81
69
  });
82
70
 
83
- it('generates list/read/create/update/delete endpoint configs', () => {
84
- const source = normalizeManagedApiDataSource(createPostsApiDefinition());
71
+ it('normalizes generated REST desired state into canonical API operations', () => {
72
+ const source = normalizeGeneratedApiDataSource(createPostsApiDefinition());
85
73
 
86
74
  assertSerializable(source);
87
- expect(source.kind).toBe('managed-api');
75
+ expect(source.kind).toBe('api');
76
+ expect(source.origin).toBe('generated');
77
+ expect(source.protocol).toBe('rest');
78
+ expect(source.generatedApiId).toBe('posts-api');
88
79
  expect(source.adapter.id).toBe('primary-db');
89
80
  expect(source.endpoints.posts?.kind).toBe('database');
81
+ expect(source.endpoints.posts?.path).toBe('/posts');
90
82
  expect(source.endpoints.posts?.operations['posts.list']?.intent).toBe('read');
91
- expect(source.endpoints.posts?.operations['posts.read']?.intent).toBe('read');
92
83
  expect(source.endpoints.posts?.operations['posts.create']?.intent).toBe('create');
93
84
  expect(source.endpoints.posts?.operations['posts.update']?.intent).toBe('update');
94
85
  expect(source.endpoints.posts?.operations['posts.delete']?.intent).toBe('delete');
95
86
  expect(source.endpoints.posts?.operations['posts.read']?.request?.parameters?.[0]?.name).toBe(
96
87
  'id',
97
88
  );
98
- expect(
99
- source.endpoints.posts?.operations['posts.list']?.request?.parameters?.map(
100
- (parameter) => parameter.name,
101
- ),
102
- ).toEqual(['limit', 'offset']);
103
89
  });
104
90
 
105
- it('creates diagnostic result for valid managed APIs', () => {
106
- const result = createManagedApiDataSource(createPostsApiDefinition());
91
+ it('creates a diagnostic result for valid generated APIs', () => {
92
+ const result = createGeneratedApiDataSource(createPostsApiDefinition());
107
93
 
108
94
  expect(result.ok).toBe(true);
109
95
  if (result.ok) {
110
- expect(result.data.resources[0]?.name).toBe('posts');
96
+ expect(result.data.endpoints.posts?.operations['posts.list']).toBeDefined();
111
97
  expect(result.diagnostics).toEqual([]);
112
98
  }
113
99
  });
114
100
 
115
- it('supports multiple managed APIs backed by different adapter refs', () => {
116
- const postsApi = normalizeManagedApiDataSource(createPostsApiDefinition());
117
- const analyticsApi = normalizeManagedApiDataSource({
101
+ it('supports multiple generated APIs backed by different database adapters', () => {
102
+ const postsApi = normalizeGeneratedApiDataSource(createPostsApiDefinition());
103
+ const analyticsApi = normalizeGeneratedApiDataSource({
118
104
  id: 'analytics-api',
119
- adapter: {
105
+ protocol: 'rest',
106
+ basePath: '/analytics',
107
+ database: {
120
108
  id: 'analytics-db',
121
109
  kind: 'database',
122
110
  packageName: '@ankhorage/postgres-db',
123
111
  },
124
112
  resources: [
125
113
  {
126
- name: 'events',
114
+ id: 'events',
115
+ path: '/events',
127
116
  operations: ['list'],
128
117
  collection: {
129
118
  name: 'events',
@@ -138,26 +127,30 @@ describe('managed API generation helpers', () => {
138
127
  });
139
128
 
140
129
  assertSerializable([postsApi, analyticsApi]);
141
- expect(postsApi.id).toBe('posts-api');
142
- expect(analyticsApi.id).toBe('analytics-api');
143
130
  expect(postsApi.adapter.id).toBe('primary-db');
144
131
  expect(analyticsApi.adapter.id).toBe('analytics-db');
145
132
  expect(analyticsApi.endpoints.events?.operations['events.list']?.intent).toBe('read');
146
133
  expect(analyticsApi.endpoints.events?.operations['events.read']).toBeUndefined();
147
134
  });
148
135
 
149
- it('reports missing resources and missing primary key diagnostics', () => {
150
- const emptyResult = createManagedApiDataSource({
136
+ it('reports missing resources, invalid paths and missing primary keys', () => {
137
+ const emptyResult = createGeneratedApiDataSource({
151
138
  id: 'empty-api',
152
- adapter: { id: 'db', kind: 'database' },
139
+ protocol: 'rest',
140
+ basePath: 'api',
141
+ database: { id: 'db', kind: 'database' },
153
142
  resources: [],
154
143
  });
155
- const missingPrimaryKeyResult = createManagedApiDataSource({
144
+ const missingPrimaryKeyResult = createGeneratedApiDataSource({
156
145
  id: 'broken-api',
157
- adapter: { id: 'db', kind: 'database' },
146
+ protocol: 'rest',
147
+ basePath: '/api',
148
+ database: { id: 'db', kind: 'database' },
158
149
  resources: [
159
150
  {
160
- name: 'posts',
151
+ id: 'posts',
152
+ path: 'posts',
153
+ operations: ['read'],
161
154
  collection: {
162
155
  name: 'posts',
163
156
  fields: [{ name: 'title', type: 'text' }],
@@ -167,15 +160,14 @@ describe('managed API generation helpers', () => {
167
160
  });
168
161
 
169
162
  expect(emptyResult.ok).toBe(false);
170
- if (!emptyResult.ok) {
171
- expect(emptyResult.diagnostics.map((diagnostic) => diagnostic.path)).toContain('resources');
172
- }
173
-
174
163
  expect(missingPrimaryKeyResult.ok).toBe(false);
175
164
  if (!missingPrimaryKeyResult.ok) {
176
165
  expect(missingPrimaryKeyResult.diagnostics.map((diagnostic) => diagnostic.path)).toContain(
177
166
  'resources.posts.collection.primaryKey',
178
167
  );
168
+ expect(missingPrimaryKeyResult.diagnostics.map((diagnostic) => diagnostic.path)).toContain(
169
+ 'resources.posts.path',
170
+ );
179
171
  }
180
172
  });
181
173
  });