@ankhorage/data-sources 1.0.1 → 2.0.1
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 +6 -41
- package/dist/ankh.provider.js +1 -1
- package/dist/ankh.provider.js.map +1 -1
- package/dist/discovery/http.d.ts +10 -0
- package/dist/discovery/http.d.ts.map +1 -0
- package/dist/discovery/http.js +35 -0
- package/dist/discovery/http.js.map +1 -0
- package/dist/discovery/index.d.ts +9 -9
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/index.js +27 -55
- package/dist/discovery/index.js.map +1 -1
- package/dist/graphql/index.d.ts +8 -76
- package/dist/graphql/index.d.ts.map +1 -1
- package/dist/graphql/index.js +17 -197
- package/dist/graphql/index.js.map +1 -1
- package/dist/graphql/introspection.d.ts +5 -0
- package/dist/graphql/introspection.d.ts.map +1 -0
- package/dist/graphql/introspection.js +136 -0
- package/dist/graphql/introspection.js.map +1 -0
- package/dist/graphql/operation.d.ts +5 -0
- package/dist/graphql/operation.d.ts.map +1 -0
- package/dist/graphql/operation.js +39 -0
- package/dist/graphql/operation.js.map +1 -0
- package/dist/graphql/types.d.ts +71 -0
- package/dist/graphql/types.d.ts.map +1 -0
- package/dist/graphql/types.js +2 -0
- package/dist/graphql/types.js.map +1 -0
- package/dist/index.d.ts +1 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -34
- package/dist/index.js.map +1 -1
- package/dist/openapi/endpoints.d.ts +5 -0
- package/dist/openapi/endpoints.d.ts.map +1 -0
- package/dist/openapi/endpoints.js +77 -0
- package/dist/openapi/endpoints.js.map +1 -0
- package/dist/openapi/index.d.ts +7 -87
- package/dist/openapi/index.d.ts.map +1 -1
- package/dist/openapi/index.js +8 -267
- package/dist/openapi/index.js.map +1 -1
- package/dist/openapi/operation.d.ts +5 -0
- package/dist/openapi/operation.d.ts.map +1 -0
- package/dist/openapi/operation.js +141 -0
- package/dist/openapi/operation.js.map +1 -0
- package/dist/openapi/schema.d.ts +5 -0
- package/dist/openapi/schema.d.ts.map +1 -0
- package/dist/openapi/schema.js +84 -0
- package/dist/openapi/schema.js.map +1 -0
- package/dist/openapi/types.d.ts +82 -0
- package/dist/openapi/types.d.ts.map +1 -0
- package/dist/openapi/types.js +2 -0
- package/dist/openapi/types.js.map +1 -0
- package/dist/packageInfo.d.ts +18 -0
- package/dist/packageInfo.d.ts.map +1 -0
- package/dist/packageInfo.js +30 -0
- package/dist/packageInfo.js.map +1 -0
- package/dist/rest/index.d.ts +5 -5
- package/dist/rest/index.d.ts.map +1 -1
- package/dist/rest/index.js +49 -48
- package/dist/rest/index.js.map +1 -1
- package/dist/test-runner/index.d.ts +2 -57
- package/dist/test-runner/index.d.ts.map +1 -1
- package/dist/test-runner/index.js +58 -160
- package/dist/test-runner/index.js.map +1 -1
- package/dist/test-runner/response.d.ts +3 -0
- package/dist/test-runner/response.d.ts.map +1 -0
- package/dist/test-runner/response.js +29 -0
- package/dist/test-runner/response.js.map +1 -0
- package/dist/test-runner/result.d.ts +6 -0
- package/dist/test-runner/result.d.ts.map +1 -0
- package/dist/test-runner/result.js +17 -0
- package/dist/test-runner/result.js.map +1 -0
- package/dist/test-runner/serialization.d.ts +12 -0
- package/dist/test-runner/serialization.d.ts.map +1 -0
- package/dist/test-runner/serialization.js +99 -0
- package/dist/test-runner/serialization.js.map +1 -0
- package/dist/test-runner/types.d.ts +58 -0
- package/dist/test-runner/types.d.ts.map +1 -0
- package/dist/test-runner/types.js +2 -0
- package/dist/test-runner/types.js.map +1 -0
- package/package.json +12 -13
- package/src/ankh.provider.ts +1 -1
- package/src/api-phase1.contract.test.ts +24 -0
- package/src/discovery/http.ts +38 -0
- package/src/discovery/index.test.ts +76 -75
- package/src/discovery/index.ts +49 -72
- package/src/graphql/index.test.ts +10 -11
- package/src/graphql/index.ts +35 -315
- package/src/graphql/introspection.ts +163 -0
- package/src/graphql/operation.ts +60 -0
- package/src/graphql/types.ts +79 -0
- package/src/index.test.ts +21 -13
- package/src/index.ts +1 -54
- package/src/openapi/endpoints.ts +124 -0
- package/src/openapi/index.test.ts +47 -47
- package/src/openapi/index.ts +12 -467
- package/src/openapi/operation.ts +231 -0
- package/src/openapi/schema.ts +127 -0
- package/src/openapi/types.ts +93 -0
- package/src/packageInfo.ts +50 -0
- package/src/rest/index.test.ts +17 -14
- package/src/rest/index.ts +67 -61
- package/src/test-runner/index.test.ts +153 -120
- package/src/test-runner/index.ts +97 -299
- package/src/test-runner/response.ts +32 -0
- package/src/test-runner/result.ts +35 -0
- package/src/test-runner/serialization.ts +151 -0
- package/src/test-runner/types.ts +81 -0
- package/dist/generated-api/index.d.ts +0 -10
- package/dist/generated-api/index.d.ts.map +0 -1
- package/dist/generated-api/index.js +0 -274
- package/dist/generated-api/index.js.map +0 -1
- package/dist/generated-api/validateGeneratedApiCollection.d.ts +0 -3
- package/dist/generated-api/validateGeneratedApiCollection.d.ts.map +0 -1
- package/dist/generated-api/validateGeneratedApiCollection.js +0 -35
- package/dist/generated-api/validateGeneratedApiCollection.js.map +0 -1
- package/src/generated-api/index.test.ts +0 -173
- package/src/generated-api/index.ts +0 -414
- package/src/generated-api/validateGeneratedApiCollection.test.ts +0 -91
- package/src/generated-api/validateGeneratedApiCollection.ts +0 -76
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
import type { GeneratedApiDefinition } from '@ankhorage/contracts/data';
|
|
2
|
-
import { describe, expect, it } from 'bun:test';
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
createGeneratedApiDataSource,
|
|
6
|
-
createGeneratedApiOperationId,
|
|
7
|
-
createGeneratedApiResourceSchema,
|
|
8
|
-
normalizeGeneratedApiDataSource,
|
|
9
|
-
} from './index';
|
|
10
|
-
|
|
11
|
-
function assertSerializable<TValue>(value: TValue): void {
|
|
12
|
-
expect(JSON.parse(JSON.stringify(value))).toEqual(value);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function createPostsApiDefinition(): GeneratedApiDefinition {
|
|
16
|
-
return {
|
|
17
|
-
id: 'posts-api',
|
|
18
|
-
protocol: 'rest',
|
|
19
|
-
name: 'Posts API',
|
|
20
|
-
basePath: '/api',
|
|
21
|
-
database: {
|
|
22
|
-
id: 'primary-db',
|
|
23
|
-
kind: 'database',
|
|
24
|
-
packageName: '@ankhorage/supabase-db',
|
|
25
|
-
},
|
|
26
|
-
resources: [
|
|
27
|
-
{
|
|
28
|
-
id: 'posts',
|
|
29
|
-
name: 'Posts',
|
|
30
|
-
path: '/posts',
|
|
31
|
-
operations: ['list', 'read', 'create', 'update', 'delete'],
|
|
32
|
-
collection: {
|
|
33
|
-
name: 'posts',
|
|
34
|
-
schema: 'public',
|
|
35
|
-
primaryKey: 'id',
|
|
36
|
-
fields: [
|
|
37
|
-
{ name: 'id', type: 'uuid', required: true, unique: true },
|
|
38
|
-
{ name: 'title', type: 'text', required: true },
|
|
39
|
-
{ name: 'published', type: 'boolean', defaultValue: false },
|
|
40
|
-
{ name: 'createdAt', type: 'datetime' },
|
|
41
|
-
{ name: 'metadata', type: 'json' },
|
|
42
|
-
],
|
|
43
|
-
},
|
|
44
|
-
seed: [{ id: 'seed-1', title: 'Hello' }],
|
|
45
|
-
policies: [{ id: 'posts-policy' }],
|
|
46
|
-
},
|
|
47
|
-
],
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
describe('generated API normalization', () => {
|
|
52
|
-
it('generates deterministic operation IDs', () => {
|
|
53
|
-
expect(createGeneratedApiOperationId('posts', 'list')).toBe('posts.list');
|
|
54
|
-
expect(createGeneratedApiOperationId('posts', 'delete')).toBe('posts.delete');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('generates a serializable resource schema from a DB collection', () => {
|
|
58
|
-
const [resource] = createPostsApiDefinition().resources;
|
|
59
|
-
expect(resource).toBeDefined();
|
|
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']);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('normalizes generated REST desired state into canonical API operations', () => {
|
|
72
|
-
const source = normalizeGeneratedApiDataSource(createPostsApiDefinition());
|
|
73
|
-
|
|
74
|
-
assertSerializable(source);
|
|
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');
|
|
79
|
-
expect(source.adapter.id).toBe('primary-db');
|
|
80
|
-
expect(source.endpoints.posts?.kind).toBe('database');
|
|
81
|
-
expect(source.endpoints.posts?.path).toBe('/posts');
|
|
82
|
-
expect(source.endpoints.posts?.operations['posts.list']?.intent).toBe('read');
|
|
83
|
-
expect(source.endpoints.posts?.operations['posts.create']?.intent).toBe('create');
|
|
84
|
-
expect(source.endpoints.posts?.operations['posts.update']?.intent).toBe('update');
|
|
85
|
-
expect(source.endpoints.posts?.operations['posts.delete']?.intent).toBe('delete');
|
|
86
|
-
expect(source.endpoints.posts?.operations['posts.read']?.request?.parameters?.[0]?.name).toBe(
|
|
87
|
-
'id',
|
|
88
|
-
);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('creates a diagnostic result for valid generated APIs', () => {
|
|
92
|
-
const result = createGeneratedApiDataSource(createPostsApiDefinition());
|
|
93
|
-
|
|
94
|
-
expect(result.ok).toBe(true);
|
|
95
|
-
if (result.ok) {
|
|
96
|
-
expect(result.data.endpoints.posts?.operations['posts.list']).toBeDefined();
|
|
97
|
-
expect(result.diagnostics).toEqual([]);
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('supports multiple generated APIs backed by different database adapters', () => {
|
|
102
|
-
const postsApi = normalizeGeneratedApiDataSource(createPostsApiDefinition());
|
|
103
|
-
const analyticsApi = normalizeGeneratedApiDataSource({
|
|
104
|
-
id: 'analytics-api',
|
|
105
|
-
protocol: 'rest',
|
|
106
|
-
basePath: '/analytics',
|
|
107
|
-
database: {
|
|
108
|
-
id: 'analytics-db',
|
|
109
|
-
kind: 'database',
|
|
110
|
-
packageName: '@ankhorage/postgres-db',
|
|
111
|
-
},
|
|
112
|
-
resources: [
|
|
113
|
-
{
|
|
114
|
-
id: 'events',
|
|
115
|
-
path: '/events',
|
|
116
|
-
operations: ['list'],
|
|
117
|
-
collection: {
|
|
118
|
-
name: 'events',
|
|
119
|
-
primaryKey: 'id',
|
|
120
|
-
fields: [
|
|
121
|
-
{ name: 'id', type: 'uuid', required: true, unique: true },
|
|
122
|
-
{ name: 'name', type: 'text', required: true },
|
|
123
|
-
],
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
],
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
assertSerializable([postsApi, analyticsApi]);
|
|
130
|
-
expect(postsApi.adapter.id).toBe('primary-db');
|
|
131
|
-
expect(analyticsApi.adapter.id).toBe('analytics-db');
|
|
132
|
-
expect(analyticsApi.endpoints.events?.operations['events.list']?.intent).toBe('read');
|
|
133
|
-
expect(analyticsApi.endpoints.events?.operations['events.read']).toBeUndefined();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('reports missing resources, invalid paths and missing primary keys', () => {
|
|
137
|
-
const emptyResult = createGeneratedApiDataSource({
|
|
138
|
-
id: 'empty-api',
|
|
139
|
-
protocol: 'rest',
|
|
140
|
-
basePath: 'api',
|
|
141
|
-
database: { id: 'db', kind: 'database' },
|
|
142
|
-
resources: [],
|
|
143
|
-
});
|
|
144
|
-
const missingPrimaryKeyResult = createGeneratedApiDataSource({
|
|
145
|
-
id: 'broken-api',
|
|
146
|
-
protocol: 'rest',
|
|
147
|
-
basePath: '/api',
|
|
148
|
-
database: { id: 'db', kind: 'database' },
|
|
149
|
-
resources: [
|
|
150
|
-
{
|
|
151
|
-
id: 'posts',
|
|
152
|
-
path: 'posts',
|
|
153
|
-
operations: ['read'],
|
|
154
|
-
collection: {
|
|
155
|
-
name: 'posts',
|
|
156
|
-
fields: [{ name: 'title', type: 'text' }],
|
|
157
|
-
},
|
|
158
|
-
},
|
|
159
|
-
],
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
expect(emptyResult.ok).toBe(false);
|
|
163
|
-
expect(missingPrimaryKeyResult.ok).toBe(false);
|
|
164
|
-
if (!missingPrimaryKeyResult.ok) {
|
|
165
|
-
expect(missingPrimaryKeyResult.diagnostics.map((diagnostic) => diagnostic.path)).toContain(
|
|
166
|
-
'resources.posts.collection.primaryKey',
|
|
167
|
-
);
|
|
168
|
-
expect(missingPrimaryKeyResult.diagnostics.map((diagnostic) => diagnostic.path)).toContain(
|
|
169
|
-
'resources.posts.path',
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
});
|
|
@@ -1,414 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
DataContractValue,
|
|
3
|
-
DataEndpointConfig,
|
|
4
|
-
DataOperationConfig,
|
|
5
|
-
DataOperationIntent,
|
|
6
|
-
DataOperationParameter,
|
|
7
|
-
DataSchema,
|
|
8
|
-
DataSourceDiagnostic,
|
|
9
|
-
DataSourceDiagnosticResult,
|
|
10
|
-
GeneratedApiAuthRequirement,
|
|
11
|
-
GeneratedApiCrudOperation,
|
|
12
|
-
GeneratedApiDefinition,
|
|
13
|
-
GeneratedApiOperationPolicyRef,
|
|
14
|
-
GeneratedApiResourceDefinition,
|
|
15
|
-
GeneratedRestApiDataSourceConfig,
|
|
16
|
-
OperationId,
|
|
17
|
-
} from '@ankhorage/contracts/data';
|
|
18
|
-
import type {
|
|
19
|
-
DbCollectionDefinition,
|
|
20
|
-
DbFieldDefinition,
|
|
21
|
-
DbFieldType,
|
|
22
|
-
} from '@ankhorage/contracts/db';
|
|
23
|
-
|
|
24
|
-
import { validateGeneratedApiCollection } from './validateGeneratedApiCollection';
|
|
25
|
-
|
|
26
|
-
export function createGeneratedApiDataSource(
|
|
27
|
-
definition: GeneratedApiDefinition,
|
|
28
|
-
): DataSourceDiagnosticResult<GeneratedRestApiDataSourceConfig> {
|
|
29
|
-
const diagnostics = validateGeneratedApiDefinition(definition);
|
|
30
|
-
if (diagnostics.some((diagnostic) => diagnostic.severity === 'error')) {
|
|
31
|
-
return { ok: false, diagnostics };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
ok: true,
|
|
36
|
-
data: normalizeGeneratedApiDataSource(definition),
|
|
37
|
-
diagnostics,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function validateGeneratedApiDefinition(
|
|
42
|
-
definition: GeneratedApiDefinition,
|
|
43
|
-
): readonly DataSourceDiagnostic[] {
|
|
44
|
-
const diagnostics: DataSourceDiagnostic[] = [];
|
|
45
|
-
const resourceIds = new Set<string>();
|
|
46
|
-
|
|
47
|
-
if (definition.basePath.trim().length === 0 || !definition.basePath.startsWith('/')) {
|
|
48
|
-
diagnostics.push({
|
|
49
|
-
code: 'invalid-config',
|
|
50
|
-
dataSourceId: definition.id,
|
|
51
|
-
message: 'Generated API basePath must be a non-empty absolute path.',
|
|
52
|
-
path: 'basePath',
|
|
53
|
-
severity: 'error',
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (definition.resources.length === 0) {
|
|
58
|
-
diagnostics.push({
|
|
59
|
-
code: 'invalid-config',
|
|
60
|
-
dataSourceId: definition.id,
|
|
61
|
-
message: 'Generated API requires at least one resource.',
|
|
62
|
-
path: 'resources',
|
|
63
|
-
severity: 'error',
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
for (const resource of definition.resources) {
|
|
68
|
-
validateGeneratedApiResource(definition, resource, resourceIds, diagnostics);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return diagnostics;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function normalizeGeneratedApiDataSource(
|
|
75
|
-
definition: GeneratedApiDefinition,
|
|
76
|
-
): GeneratedRestApiDataSourceConfig {
|
|
77
|
-
const endpoints: Record<string, DataEndpointConfig> = {};
|
|
78
|
-
const schemas: Record<string, DataSchema> = {};
|
|
79
|
-
|
|
80
|
-
for (const resource of definition.resources) {
|
|
81
|
-
endpoints[resource.id] = createGeneratedApiEndpoint(definition, resource);
|
|
82
|
-
schemas[resource.id] = createGeneratedApiResourceSchema(resource.collection);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
id: definition.id,
|
|
87
|
-
kind: 'api',
|
|
88
|
-
origin: 'generated',
|
|
89
|
-
protocol: 'rest',
|
|
90
|
-
generatedApiId: definition.id,
|
|
91
|
-
name: definition.name,
|
|
92
|
-
description: definition.description,
|
|
93
|
-
adapter: definition.database,
|
|
94
|
-
endpoints,
|
|
95
|
-
schemas,
|
|
96
|
-
metadata: createGeneratedApiSourceMetadata(definition),
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function createGeneratedApiEndpoint(
|
|
101
|
-
definition: GeneratedApiDefinition,
|
|
102
|
-
resource: GeneratedApiResourceDefinition,
|
|
103
|
-
): DataEndpointConfig {
|
|
104
|
-
const operations: Record<OperationId, DataOperationConfig> = {};
|
|
105
|
-
|
|
106
|
-
for (const operation of resource.operations) {
|
|
107
|
-
const config = createGeneratedApiOperation(definition, resource, operation);
|
|
108
|
-
operations[config.id] = config;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return {
|
|
112
|
-
id: resource.id,
|
|
113
|
-
kind: 'database',
|
|
114
|
-
name: resource.name,
|
|
115
|
-
description: resource.description,
|
|
116
|
-
path: resource.path,
|
|
117
|
-
operations,
|
|
118
|
-
metadata: createGeneratedApiResourceMetadata(definition, resource),
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function createGeneratedApiOperation(
|
|
123
|
-
definition: GeneratedApiDefinition,
|
|
124
|
-
resource: GeneratedApiResourceDefinition,
|
|
125
|
-
operation: GeneratedApiCrudOperation,
|
|
126
|
-
): DataOperationConfig {
|
|
127
|
-
const operationId = createGeneratedApiOperationId(resource.id, operation);
|
|
128
|
-
const primaryKey = resolveGeneratedApiPrimaryKey(resource.collection);
|
|
129
|
-
const primaryKeyParameter =
|
|
130
|
-
primaryKey === undefined ? undefined : createPrimaryKeyParameter(primaryKey);
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
id: operationId,
|
|
134
|
-
endpointId: resource.id,
|
|
135
|
-
name: createGeneratedApiOperationName(resource, operation),
|
|
136
|
-
protocol: 'database',
|
|
137
|
-
intent: mapGeneratedApiOperationIntent(operation),
|
|
138
|
-
path: resource.path,
|
|
139
|
-
request: createGeneratedApiOperationRequest(
|
|
140
|
-
resource.collection,
|
|
141
|
-
operation,
|
|
142
|
-
primaryKeyParameter,
|
|
143
|
-
),
|
|
144
|
-
response: createGeneratedApiOperationResponse(resource.collection, operation),
|
|
145
|
-
metadata: createGeneratedApiOperationMetadata(definition, resource, operation),
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function createGeneratedApiOperationId(
|
|
150
|
-
resourceId: string,
|
|
151
|
-
operation: GeneratedApiCrudOperation,
|
|
152
|
-
): OperationId {
|
|
153
|
-
return `${resourceId}.${operation}`;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function createGeneratedApiResourceSchema(collection: DbCollectionDefinition): DataSchema {
|
|
157
|
-
const properties: Record<string, DataSchema> = {};
|
|
158
|
-
const required: string[] = [];
|
|
159
|
-
|
|
160
|
-
for (const field of collection.fields) {
|
|
161
|
-
properties[field.name] = createGeneratedApiFieldSchema(field);
|
|
162
|
-
if (field.required === true) required.push(field.name);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return {
|
|
166
|
-
type: 'object',
|
|
167
|
-
title: collection.name,
|
|
168
|
-
required: required.length > 0 ? required : undefined,
|
|
169
|
-
properties,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function validateGeneratedApiResource(
|
|
174
|
-
definition: GeneratedApiDefinition,
|
|
175
|
-
resource: GeneratedApiResourceDefinition,
|
|
176
|
-
resourceIds: Set<string>,
|
|
177
|
-
diagnostics: DataSourceDiagnostic[],
|
|
178
|
-
): void {
|
|
179
|
-
if (resource.id.trim().length === 0) {
|
|
180
|
-
diagnostics.push(
|
|
181
|
-
resourceDiagnostic(definition, resource, 'Resource IDs must be non-empty.', 'id'),
|
|
182
|
-
);
|
|
183
|
-
} else if (resourceIds.has(resource.id)) {
|
|
184
|
-
diagnostics.push(
|
|
185
|
-
resourceDiagnostic(definition, resource, `Resource ID '${resource.id}' is duplicated.`, 'id'),
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
resourceIds.add(resource.id);
|
|
189
|
-
|
|
190
|
-
if (!resource.path.startsWith('/')) {
|
|
191
|
-
diagnostics.push(
|
|
192
|
-
resourceDiagnostic(
|
|
193
|
-
definition,
|
|
194
|
-
resource,
|
|
195
|
-
'Generated API resource paths must start with `/`.',
|
|
196
|
-
'path',
|
|
197
|
-
),
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (resource.collection.name.trim().length === 0) {
|
|
202
|
-
diagnostics.push(
|
|
203
|
-
resourceDiagnostic(
|
|
204
|
-
definition,
|
|
205
|
-
resource,
|
|
206
|
-
'Generated API resource collections must have a non-empty name.',
|
|
207
|
-
'collection.name',
|
|
208
|
-
),
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
diagnostics.push(...validateGeneratedApiCollection(definition, resource));
|
|
213
|
-
|
|
214
|
-
if (resource.operations.length === 0) {
|
|
215
|
-
diagnostics.push(
|
|
216
|
-
resourceDiagnostic(
|
|
217
|
-
definition,
|
|
218
|
-
resource,
|
|
219
|
-
'Generated API resources require at least one operation.',
|
|
220
|
-
'operations',
|
|
221
|
-
),
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const primaryKey = resolveGeneratedApiPrimaryKey(resource.collection);
|
|
226
|
-
if (
|
|
227
|
-
primaryKey === undefined &&
|
|
228
|
-
resource.operations.some((operation) => operation !== 'list' && operation !== 'create')
|
|
229
|
-
) {
|
|
230
|
-
diagnostics.push(
|
|
231
|
-
resourceDiagnostic(
|
|
232
|
-
definition,
|
|
233
|
-
resource,
|
|
234
|
-
'Generated API resources need a primaryKey field for read/update/delete operations.',
|
|
235
|
-
'collection.primaryKey',
|
|
236
|
-
),
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function resourceDiagnostic(
|
|
242
|
-
definition: GeneratedApiDefinition,
|
|
243
|
-
resource: GeneratedApiResourceDefinition,
|
|
244
|
-
message: string,
|
|
245
|
-
path: string,
|
|
246
|
-
): DataSourceDiagnostic {
|
|
247
|
-
return {
|
|
248
|
-
code: 'invalid-config',
|
|
249
|
-
dataSourceId: definition.id,
|
|
250
|
-
endpointId: resource.id,
|
|
251
|
-
message,
|
|
252
|
-
path: `resources.${resource.id}.${path}`,
|
|
253
|
-
severity: 'error',
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function createGeneratedApiSourceMetadata(definition: GeneratedApiDefinition): DataContractValue {
|
|
258
|
-
return {
|
|
259
|
-
...readMetadataRecord(definition.metadata),
|
|
260
|
-
basePath: definition.basePath,
|
|
261
|
-
source: 'generated-api',
|
|
262
|
-
auth: createGeneratedApiAuthMetadata(definition.auth),
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function createGeneratedApiResourceMetadata(
|
|
267
|
-
definition: GeneratedApiDefinition,
|
|
268
|
-
resource: GeneratedApiResourceDefinition,
|
|
269
|
-
): DataContractValue {
|
|
270
|
-
return {
|
|
271
|
-
...readMetadataRecord(resource.metadata),
|
|
272
|
-
adapterId: definition.database.id,
|
|
273
|
-
collection: resource.collection.name,
|
|
274
|
-
schema: resource.collection.schema ?? null,
|
|
275
|
-
source: 'generated-api',
|
|
276
|
-
seed: resource.seed ?? [],
|
|
277
|
-
policies: createGeneratedApiPolicyMetadata(resource.policies),
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function createGeneratedApiOperationMetadata(
|
|
282
|
-
definition: GeneratedApiDefinition,
|
|
283
|
-
resource: GeneratedApiResourceDefinition,
|
|
284
|
-
operation: GeneratedApiCrudOperation,
|
|
285
|
-
): DataContractValue {
|
|
286
|
-
return {
|
|
287
|
-
adapterId: definition.database.id,
|
|
288
|
-
collection: resource.collection.name,
|
|
289
|
-
operation,
|
|
290
|
-
schema: resource.collection.schema ?? null,
|
|
291
|
-
source: 'generated-api',
|
|
292
|
-
policies: createGeneratedApiPolicyMetadata(
|
|
293
|
-
resource.policies?.filter(
|
|
294
|
-
(policy) => policy.operation === undefined || policy.operation === operation,
|
|
295
|
-
),
|
|
296
|
-
),
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function createGeneratedApiAuthMetadata(
|
|
301
|
-
auth: GeneratedApiAuthRequirement | undefined,
|
|
302
|
-
): DataContractValue {
|
|
303
|
-
if (auth === undefined) return null;
|
|
304
|
-
|
|
305
|
-
const metadata: Record<string, DataContractValue> = {};
|
|
306
|
-
if (auth.required !== undefined) metadata.required = auth.required;
|
|
307
|
-
if (auth.roles !== undefined) metadata.roles = auth.roles;
|
|
308
|
-
if (auth.permissions !== undefined) metadata.permissions = auth.permissions;
|
|
309
|
-
if (auth.policy !== undefined) metadata.policy = auth.policy;
|
|
310
|
-
if (auth.metadata !== undefined) metadata.metadata = auth.metadata;
|
|
311
|
-
return metadata;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function createGeneratedApiPolicyMetadata(
|
|
315
|
-
policies: readonly GeneratedApiOperationPolicyRef[] | undefined,
|
|
316
|
-
): readonly DataContractValue[] {
|
|
317
|
-
return (policies ?? []).map((policy) => {
|
|
318
|
-
const metadata: Record<string, DataContractValue> = { id: policy.id };
|
|
319
|
-
if (policy.operation !== undefined) metadata.operation = policy.operation;
|
|
320
|
-
return metadata;
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function createGeneratedApiOperationName(
|
|
325
|
-
resource: GeneratedApiResourceDefinition,
|
|
326
|
-
operation: GeneratedApiCrudOperation,
|
|
327
|
-
): string {
|
|
328
|
-
return `${operation} ${resource.name ?? resource.id}`;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function mapGeneratedApiOperationIntent(operation: GeneratedApiCrudOperation): DataOperationIntent {
|
|
332
|
-
if (operation === 'list' || operation === 'read') return 'read';
|
|
333
|
-
if (operation === 'create') return 'create';
|
|
334
|
-
if (operation === 'update') return 'update';
|
|
335
|
-
return 'delete';
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function createGeneratedApiOperationRequest(
|
|
339
|
-
collection: DbCollectionDefinition,
|
|
340
|
-
operation: GeneratedApiCrudOperation,
|
|
341
|
-
primaryKeyParameter: DataOperationParameter | undefined,
|
|
342
|
-
): DataOperationConfig['request'] {
|
|
343
|
-
if (operation === 'list') {
|
|
344
|
-
return {
|
|
345
|
-
parameters: [
|
|
346
|
-
{ name: 'limit', location: 'query', schema: { type: 'integer' } },
|
|
347
|
-
{ name: 'offset', location: 'query', schema: { type: 'integer' } },
|
|
348
|
-
],
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
if (operation === 'read' || operation === 'delete') {
|
|
353
|
-
return primaryKeyParameter === undefined ? undefined : { parameters: [primaryKeyParameter] };
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const parameters =
|
|
357
|
-
operation === 'update' && primaryKeyParameter !== undefined ? [primaryKeyParameter] : undefined;
|
|
358
|
-
|
|
359
|
-
return {
|
|
360
|
-
parameters,
|
|
361
|
-
schema: createGeneratedApiResourceSchema(collection),
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function createGeneratedApiOperationResponse(
|
|
366
|
-
collection: DbCollectionDefinition,
|
|
367
|
-
operation: GeneratedApiCrudOperation,
|
|
368
|
-
): DataOperationConfig['response'] {
|
|
369
|
-
const schema = createGeneratedApiResourceSchema(collection);
|
|
370
|
-
|
|
371
|
-
if (operation === 'list') return { schema: { type: 'array', items: schema } };
|
|
372
|
-
if (operation === 'delete') {
|
|
373
|
-
return { schema: { type: 'object', properties: { deleted: { type: 'boolean' } } } };
|
|
374
|
-
}
|
|
375
|
-
return { schema };
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function createPrimaryKeyParameter(primaryKey: string): DataOperationParameter {
|
|
379
|
-
return {
|
|
380
|
-
name: primaryKey,
|
|
381
|
-
location: 'path',
|
|
382
|
-
required: true,
|
|
383
|
-
schema: { type: 'string' },
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function resolveGeneratedApiPrimaryKey(collection: DbCollectionDefinition): string | undefined {
|
|
388
|
-
return collection.primaryKey ?? collection.fields.find((field) => field.unique === true)?.name;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function createGeneratedApiFieldSchema(field: DbFieldDefinition): DataSchema {
|
|
392
|
-
return {
|
|
393
|
-
...mapDbFieldTypeToDataSchema(field.type),
|
|
394
|
-
default: field.defaultValue,
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function mapDbFieldTypeToDataSchema(type: DbFieldType): DataSchema {
|
|
399
|
-
if (type === 'boolean') return { type: 'boolean' };
|
|
400
|
-
if (type === 'datetime') return { type: 'string', format: 'date-time' };
|
|
401
|
-
if (type === 'json') return { type: 'object' };
|
|
402
|
-
if (type === 'number') return { type: 'number' };
|
|
403
|
-
if (type === 'uuid') return { type: 'string', format: 'uuid' };
|
|
404
|
-
return { type: 'string' };
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function readMetadataRecord(
|
|
408
|
-
value: DataContractValue | undefined,
|
|
409
|
-
): Record<string, DataContractValue> {
|
|
410
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {};
|
|
411
|
-
const metadata: Record<string, DataContractValue> = {};
|
|
412
|
-
for (const [key, item] of Object.entries(value)) metadata[key] = item;
|
|
413
|
-
return metadata;
|
|
414
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import type { GeneratedApiDefinition } from '@ankhorage/contracts/data';
|
|
2
|
-
import { describe, expect, it } from 'bun:test';
|
|
3
|
-
|
|
4
|
-
import { validateGeneratedApiDefinition } from './index';
|
|
5
|
-
|
|
6
|
-
function createDefinition(): GeneratedApiDefinition {
|
|
7
|
-
return {
|
|
8
|
-
id: 'posts-api',
|
|
9
|
-
protocol: 'rest',
|
|
10
|
-
basePath: '/api',
|
|
11
|
-
database: { id: 'primary-db', kind: 'database' },
|
|
12
|
-
resources: [
|
|
13
|
-
{
|
|
14
|
-
id: 'posts',
|
|
15
|
-
path: '/posts',
|
|
16
|
-
operations: ['list', 'read'],
|
|
17
|
-
collection: {
|
|
18
|
-
name: 'posts',
|
|
19
|
-
primaryKey: 'id',
|
|
20
|
-
fields: [
|
|
21
|
-
{ name: 'id', type: 'uuid', required: true, unique: true },
|
|
22
|
-
{ name: 'title', type: 'text' },
|
|
23
|
-
],
|
|
24
|
-
},
|
|
25
|
-
},
|
|
26
|
-
],
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function diagnosticPaths(definition: GeneratedApiDefinition): readonly string[] {
|
|
31
|
-
return validateGeneratedApiDefinition(definition).map((diagnostic) => diagnostic.path ?? '');
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe('generated API collection validation', () => {
|
|
35
|
-
it('rejects resources without fields', () => {
|
|
36
|
-
const definition = createDefinition();
|
|
37
|
-
const [resource] = definition.resources;
|
|
38
|
-
if (!resource) throw new Error('Expected resource fixture.');
|
|
39
|
-
|
|
40
|
-
const paths = diagnosticPaths({
|
|
41
|
-
...definition,
|
|
42
|
-
resources: [{ ...resource, collection: { ...resource.collection, fields: [] } }],
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
expect(paths).toContain('resources.posts.collection.fields');
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('rejects empty and duplicate field names', () => {
|
|
49
|
-
const definition = createDefinition();
|
|
50
|
-
const [resource] = definition.resources;
|
|
51
|
-
if (!resource) throw new Error('Expected resource fixture.');
|
|
52
|
-
|
|
53
|
-
const paths = diagnosticPaths({
|
|
54
|
-
...definition,
|
|
55
|
-
resources: [
|
|
56
|
-
{
|
|
57
|
-
...resource,
|
|
58
|
-
collection: {
|
|
59
|
-
...resource.collection,
|
|
60
|
-
fields: [
|
|
61
|
-
{ name: '', type: 'text' },
|
|
62
|
-
{ name: 'title', type: 'text' },
|
|
63
|
-
{ name: 'title', type: 'text' },
|
|
64
|
-
],
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
],
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
expect(paths).toContain('resources.posts.collection.fields.0.name');
|
|
71
|
-
expect(paths).toContain('resources.posts.collection.fields.2.name');
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('rejects primary keys that do not reference a field', () => {
|
|
75
|
-
const definition = createDefinition();
|
|
76
|
-
const [resource] = definition.resources;
|
|
77
|
-
if (!resource) throw new Error('Expected resource fixture.');
|
|
78
|
-
|
|
79
|
-
const paths = diagnosticPaths({
|
|
80
|
-
...definition,
|
|
81
|
-
resources: [
|
|
82
|
-
{
|
|
83
|
-
...resource,
|
|
84
|
-
collection: { ...resource.collection, primaryKey: 'missing' },
|
|
85
|
-
},
|
|
86
|
-
],
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
expect(paths).toContain('resources.posts.collection.primaryKey');
|
|
90
|
-
});
|
|
91
|
-
});
|