@ankhorage/data-sources 0.5.5 → 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 +12 -0
  2. package/README.md +12 -5
  3. package/dist/discovery/index.d.ts +64 -0
  4. package/dist/discovery/index.d.ts.map +1 -0
  5. package/dist/discovery/index.js +195 -0
  6. package/dist/discovery/index.js.map +1 -0
  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 +10 -3
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +18 -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 +133 -0
  32. package/src/discovery/index.ts +340 -0
  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 +29 -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,340 @@
1
+ import type {
2
+ CredentialRef,
3
+ DataContractValue,
4
+ DataSourceDiagnostic,
5
+ ExternalGraphQlApiDataSourceConfig,
6
+ ExternalRestApiDataSourceConfig,
7
+ } from '@ankhorage/contracts/data';
8
+
9
+ import {
10
+ createGraphQlDataSource,
11
+ createGraphQlIntrospectionRequest,
12
+ type GraphQlIntrospectionResult,
13
+ } from '../graphql';
14
+ import { importOpenApiDocument, type OpenApiDocumentObject } from '../openapi';
15
+
16
+ export const DEFAULT_OPENAPI_DISCOVERY_PATHS = [
17
+ 'openapi.json',
18
+ 'swagger.json',
19
+ 'api/openapi.json',
20
+ 'v3/api-docs',
21
+ ] as const;
22
+
23
+ export interface ExternalApiFetchInit {
24
+ readonly method: 'GET' | 'POST';
25
+ readonly headers: Readonly<Record<string, string>>;
26
+ readonly body?: string;
27
+ }
28
+
29
+ export interface ExternalApiFetchResponse {
30
+ readonly status: number;
31
+ text(): Promise<string>;
32
+ }
33
+
34
+ export type ExternalApiFetch = (
35
+ url: string,
36
+ init: ExternalApiFetchInit,
37
+ ) => Promise<ExternalApiFetchResponse>;
38
+
39
+ export type OpenApiDiscoveryAttemptOutcome =
40
+ | 'http-error'
41
+ | 'invalid-document'
42
+ | 'matched'
43
+ | 'network-error'
44
+ | 'parse-error'
45
+ | 'unsupported-document';
46
+
47
+ export interface OpenApiDiscoveryAttempt {
48
+ readonly url: string;
49
+ readonly outcome: OpenApiDiscoveryAttemptOutcome;
50
+ readonly status?: number;
51
+ }
52
+
53
+ export interface DiscoverOpenApiDataSourceInput {
54
+ readonly id: string;
55
+ readonly url: string;
56
+ readonly fetch: ExternalApiFetch;
57
+ readonly baseUrl?: string;
58
+ readonly credential?: CredentialRef;
59
+ readonly name?: string;
60
+ readonly description?: string;
61
+ readonly metadata?: DataContractValue;
62
+ readonly conventionalPaths?: readonly string[];
63
+ }
64
+
65
+ export type DiscoverOpenApiDataSourceResult =
66
+ | {
67
+ readonly ok: true;
68
+ readonly data: ExternalRestApiDataSourceConfig;
69
+ readonly documentUrl: string;
70
+ readonly attempts: readonly OpenApiDiscoveryAttempt[];
71
+ readonly diagnostics: readonly DataSourceDiagnostic[];
72
+ }
73
+ | {
74
+ readonly ok: false;
75
+ readonly attempts: readonly OpenApiDiscoveryAttempt[];
76
+ readonly diagnostics: readonly DataSourceDiagnostic[];
77
+ };
78
+
79
+ export interface IntrospectGraphQlDataSourceInput {
80
+ readonly id: string;
81
+ readonly endpointUrl: string;
82
+ readonly fetch: ExternalApiFetch;
83
+ readonly headers?: Readonly<Record<string, string>>;
84
+ readonly credential?: CredentialRef;
85
+ readonly name?: string;
86
+ readonly description?: string;
87
+ readonly schemaVersion?: string;
88
+ readonly metadata?: DataContractValue;
89
+ }
90
+
91
+ export type IntrospectGraphQlDataSourceResult =
92
+ | {
93
+ readonly ok: true;
94
+ readonly data: ExternalGraphQlApiDataSourceConfig;
95
+ readonly diagnostics: readonly DataSourceDiagnostic[];
96
+ }
97
+ | {
98
+ readonly ok: false;
99
+ readonly diagnostics: readonly DataSourceDiagnostic[];
100
+ readonly status?: number;
101
+ };
102
+
103
+ export function createOpenApiDiscoveryCandidates(
104
+ rawUrl: string,
105
+ conventionalPaths: readonly string[] = DEFAULT_OPENAPI_DISCOVERY_PATHS,
106
+ ): readonly string[] {
107
+ const parsed = parseHttpUrl(rawUrl);
108
+ if (parsed === undefined) return [];
109
+
110
+ const exact = normalizeCandidateUrl(parsed);
111
+ const serviceBase = new URL(exact);
112
+ if (!serviceBase.pathname.endsWith('/')) serviceBase.pathname = `${serviceBase.pathname}/`;
113
+
114
+ const candidates = [exact];
115
+ for (const path of conventionalPaths) {
116
+ const normalizedPath = path.replace(/^\/+/, '');
117
+ candidates.push(normalizeCandidateUrl(new URL(normalizedPath, serviceBase)));
118
+ candidates.push(normalizeCandidateUrl(new URL(normalizedPath, `${parsed.origin}/`)));
119
+ }
120
+
121
+ return [...new Set(candidates)];
122
+ }
123
+
124
+ export async function discoverOpenApiDataSource(
125
+ input: DiscoverOpenApiDataSourceInput,
126
+ ): Promise<DiscoverOpenApiDataSourceResult> {
127
+ const candidates = createOpenApiDiscoveryCandidates(input.url, input.conventionalPaths);
128
+ if (candidates.length === 0) {
129
+ return discoveryFailure(input.id, [], 'OpenAPI discovery requires a valid HTTP or HTTPS URL.');
130
+ }
131
+
132
+ const attempts: OpenApiDiscoveryAttempt[] = [];
133
+ for (const candidate of candidates) {
134
+ const probed = await probeOpenApiCandidate(input, candidate);
135
+ attempts.push(probed.attempt);
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 };
146
+ }
147
+
148
+ return discoveryFailure(
149
+ input.id,
150
+ attempts,
151
+ 'No supported OpenAPI document was found at the supplied URL or conventional locations.',
152
+ );
153
+ }
154
+
155
+ export async function introspectGraphQlDataSource(
156
+ input: IntrospectGraphQlDataSourceInput,
157
+ ): Promise<IntrospectGraphQlDataSourceResult> {
158
+ const endpoint = parseHttpUrl(input.endpointUrl);
159
+ if (endpoint === undefined) {
160
+ return graphqlFailure(input.id, 'GraphQL introspection requires a valid HTTP or HTTPS URL.');
161
+ }
162
+
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 {
175
+ return graphqlFailure(input.id, 'GraphQL introspection request failed.', 'network-error');
176
+ }
177
+
178
+ if (!isSuccessfulStatus(response.status)) {
179
+ return {
180
+ ...graphqlFailure(
181
+ input.id,
182
+ `GraphQL introspection returned HTTP status ${response.status}.`,
183
+ 'network-error',
184
+ ),
185
+ status: response.status,
186
+ };
187
+ }
188
+
189
+ const introspection = readGraphQlIntrospection(await parseJsonResponse(response));
190
+ if (introspection === undefined) {
191
+ return graphqlFailure(
192
+ input.id,
193
+ 'GraphQL introspection response did not contain data.__schema.',
194
+ 'parse-error',
195
+ );
196
+ }
197
+
198
+ const result = createGraphQlDataSource({
199
+ id: input.id,
200
+ endpointUrl: normalizeCandidateUrl(endpoint),
201
+ credential: input.credential,
202
+ name: input.name,
203
+ description: input.description,
204
+ introspection,
205
+ introspectionEnabled: true,
206
+ schemaVersion: input.schemaVersion,
207
+ metadata: input.metadata,
208
+ });
209
+
210
+ return result.ok
211
+ ? { ok: true, data: result.data, diagnostics: result.diagnostics ?? [] }
212
+ : { ok: false, diagnostics: result.diagnostics };
213
+ }
214
+
215
+ interface OpenApiProbeResult {
216
+ readonly attempt: OpenApiDiscoveryAttempt;
217
+ readonly result?: ReturnType<typeof importOpenApiDocument>;
218
+ }
219
+
220
+ async function probeOpenApiCandidate(
221
+ input: DiscoverOpenApiDataSourceInput,
222
+ candidate: string,
223
+ ): Promise<OpenApiProbeResult> {
224
+ let response: ExternalApiFetchResponse;
225
+ try {
226
+ response = await input.fetch(candidate, {
227
+ method: 'GET',
228
+ headers: { accept: 'application/json, application/vnd.oai.openapi+json' },
229
+ });
230
+ } catch {
231
+ return { attempt: { url: candidate, outcome: 'network-error' } };
232
+ }
233
+
234
+ if (!isSuccessfulStatus(response.status)) {
235
+ return { attempt: { url: candidate, outcome: 'http-error', status: response.status } };
236
+ }
237
+
238
+ const parsed = await parseJsonResponse(response);
239
+ if (parsed === undefined) {
240
+ return { attempt: { url: candidate, outcome: 'parse-error', status: response.status } };
241
+ }
242
+ if (!isOpenApiDocument(parsed)) {
243
+ return {
244
+ attempt: { url: candidate, outcome: 'unsupported-document', status: response.status },
245
+ };
246
+ }
247
+
248
+ const result = importOpenApiDocument({
249
+ id: input.id,
250
+ document: parsed,
251
+ baseUrl: input.baseUrl,
252
+ credential: input.credential,
253
+ documentUrl: candidate,
254
+ name: input.name,
255
+ description: input.description,
256
+ metadata: input.metadata,
257
+ });
258
+ return {
259
+ attempt: {
260
+ url: candidate,
261
+ outcome: result.ok ? 'matched' : 'invalid-document',
262
+ status: response.status,
263
+ },
264
+ result,
265
+ };
266
+ }
267
+
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
+ function isOpenApiDocument(value: unknown): value is OpenApiDocumentObject {
298
+ const record = readRecord(value);
299
+ return (
300
+ record !== undefined &&
301
+ typeof record.openapi === 'string' &&
302
+ readRecord(record.paths) !== undefined
303
+ );
304
+ }
305
+
306
+ function readGraphQlIntrospection(value: unknown): GraphQlIntrospectionResult | undefined {
307
+ const payload = readRecord(value);
308
+ const data = readRecord(payload?.data);
309
+ if (readRecord(data?.__schema) === undefined) return undefined;
310
+ return data;
311
+ }
312
+
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
+ function discoveryFailure(
320
+ dataSourceId: string,
321
+ attempts: readonly OpenApiDiscoveryAttempt[],
322
+ message: string,
323
+ ): DiscoverOpenApiDataSourceResult {
324
+ return {
325
+ ok: false,
326
+ attempts,
327
+ diagnostics: [{ code: 'missing-schema', dataSourceId, message, severity: 'error' }],
328
+ };
329
+ }
330
+
331
+ function graphqlFailure(
332
+ dataSourceId: string,
333
+ message: string,
334
+ code: DataSourceDiagnostic['code'] = 'invalid-config',
335
+ ): Extract<IntrospectGraphQlDataSourceResult, { readonly ok: false }> {
336
+ return {
337
+ ok: false,
338
+ diagnostics: [{ code, dataSourceId, message, severity: 'error' }],
339
+ };
340
+ }
@@ -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
  });