@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.
- package/CHANGELOG.md +6 -0
- package/README.md +5 -5
- package/dist/discovery/index.d.ts +3 -3
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/index.js +19 -28
- package/dist/discovery/index.js.map +1 -1
- package/dist/generated-api/index.d.ts +10 -0
- package/dist/generated-api/index.d.ts.map +1 -0
- package/dist/generated-api/index.js +272 -0
- package/dist/generated-api/index.js.map +1 -0
- package/dist/graphql/index.d.ts +3 -3
- package/dist/graphql/index.d.ts.map +1 -1
- package/dist/graphql/index.js +24 -60
- package/dist/graphql/index.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.d.ts +2 -2
- package/dist/openapi/index.d.ts.map +1 -1
- package/dist/openapi/index.js +19 -29
- package/dist/openapi/index.js.map +1 -1
- package/dist/rest/index.d.ts +3 -3
- package/dist/rest/index.d.ts.map +1 -1
- package/dist/rest/index.js +51 -51
- package/dist/rest/index.js.map +1 -1
- package/dist/test-runner/index.d.ts.map +1 -1
- package/dist/test-runner/index.js +60 -104
- package/dist/test-runner/index.js.map +1 -1
- package/package.json +2 -2
- package/src/discovery/index.test.ts +25 -101
- package/src/discovery/index.ts +22 -35
- package/src/{managed-api → generated-api}/index.test.ts +59 -67
- package/src/generated-api/index.ts +410 -0
- package/src/graphql/index.test.ts +28 -134
- package/src/graphql/index.ts +29 -89
- package/src/index.test.ts +25 -18
- package/src/index.ts +28 -7
- package/src/openapi/index.test.ts +36 -246
- package/src/openapi/index.ts +22 -60
- package/src/rest/index.test.ts +22 -173
- package/src/rest/index.ts +62 -59
- package/src/test-runner/index.test.ts +82 -150
- package/src/test-runner/index.ts +73 -143
- package/dist/managed-api/index.d.ts +0 -30
- package/dist/managed-api/index.d.ts.map +0 -1
- package/dist/managed-api/index.js +0 -257
- package/dist/managed-api/index.js.map +0 -1
- 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
|
|
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
|
|
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
|
|
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('
|
|
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.
|
|
108
|
-
expect(result.data.
|
|
109
|
-
expect(result.
|
|
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
|
|
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
|
|
126
|
-
? jsonResponse(
|
|
127
|
-
: jsonResponse({
|
|
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
|
-
|
|
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('
|
|
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
|
|
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
|
|
193
|
-
const
|
|
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
|
-
|
|
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(
|
|
207
|
-
expect(
|
|
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
|
});
|
package/src/discovery/index.ts
CHANGED
|
@@ -2,8 +2,8 @@ import type {
|
|
|
2
2
|
CredentialRef,
|
|
3
3
|
DataContractValue,
|
|
4
4
|
DataSourceDiagnostic,
|
|
5
|
-
|
|
6
|
-
|
|
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:
|
|
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:
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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():
|
|
15
|
+
function createPostsApiDefinition(): GeneratedApiDefinition {
|
|
17
16
|
return {
|
|
18
17
|
id: 'posts-api',
|
|
18
|
+
protocol: 'rest',
|
|
19
19
|
name: 'Posts API',
|
|
20
|
-
|
|
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
|
-
|
|
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('
|
|
51
|
+
describe('generated API normalization', () => {
|
|
47
52
|
it('generates deterministic operation IDs', () => {
|
|
48
|
-
expect(
|
|
49
|
-
expect(
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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('
|
|
84
|
-
const source =
|
|
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('
|
|
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
|
|
106
|
-
const result =
|
|
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.
|
|
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
|
|
116
|
-
const postsApi =
|
|
117
|
-
const analyticsApi =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
150
|
-
const emptyResult =
|
|
136
|
+
it('reports missing resources, invalid paths and missing primary keys', () => {
|
|
137
|
+
const emptyResult = createGeneratedApiDataSource({
|
|
151
138
|
id: 'empty-api',
|
|
152
|
-
|
|
139
|
+
protocol: 'rest',
|
|
140
|
+
basePath: 'api',
|
|
141
|
+
database: { id: 'db', kind: 'database' },
|
|
153
142
|
resources: [],
|
|
154
143
|
});
|
|
155
|
-
const missingPrimaryKeyResult =
|
|
144
|
+
const missingPrimaryKeyResult = createGeneratedApiDataSource({
|
|
156
145
|
id: 'broken-api',
|
|
157
|
-
|
|
146
|
+
protocol: 'rest',
|
|
147
|
+
basePath: '/api',
|
|
148
|
+
database: { id: 'db', kind: 'database' },
|
|
158
149
|
resources: [
|
|
159
150
|
{
|
|
160
|
-
|
|
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
|
});
|