@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.
- package/CHANGELOG.md +12 -0
- package/README.md +12 -5
- package/dist/discovery/index.d.ts +64 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +195 -0
- package/dist/discovery/index.js.map +1 -0
- 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 +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -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 +133 -0
- package/src/discovery/index.ts +340 -0
- 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 +29 -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
|
@@ -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
|
-
|
|
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
|
});
|