@ankhorage/data-sources 0.0.0 → 0.1.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.
@@ -0,0 +1,237 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import {
4
+ createManualRestDataSource,
5
+ extractRestPathParams,
6
+ isManualRestMethod,
7
+ type ManualRestDataSourceDefinition,
8
+ normalizeManualRestDataSource,
9
+ normalizeManualRestMethod,
10
+ } from './index';
11
+
12
+ function assertSerializable<TValue>(value: TValue): void {
13
+ expect(JSON.parse(JSON.stringify(value))).toEqual(value);
14
+ }
15
+
16
+ function createBlogDefinition(): ManualRestDataSourceDefinition {
17
+ return {
18
+ id: 'blog-api',
19
+ name: 'Blog API',
20
+ baseUrl: 'https://api.example.com',
21
+ credential: {
22
+ id: 'blog-api-token',
23
+ kind: 'bearer',
24
+ label: 'Blog API token',
25
+ },
26
+ schemas: {
27
+ post: {
28
+ type: 'object',
29
+ required: ['id', 'title'],
30
+ properties: {
31
+ id: { type: 'string' },
32
+ title: { type: 'string' },
33
+ published: { type: 'boolean' },
34
+ },
35
+ },
36
+ },
37
+ endpoints: [
38
+ {
39
+ id: 'posts',
40
+ path: '/posts',
41
+ operations: [
42
+ {
43
+ id: 'posts.list',
44
+ method: 'GET',
45
+ intent: 'read',
46
+ parameters: [
47
+ {
48
+ name: 'limit',
49
+ location: 'query',
50
+ schema: { type: 'integer', default: 20 },
51
+ },
52
+ ],
53
+ response: {
54
+ status: 200,
55
+ schema: {
56
+ type: 'array',
57
+ items: { ref: { id: 'post' } },
58
+ },
59
+ },
60
+ pagination: {
61
+ kind: 'limit-offset',
62
+ limitParameter: 'limit',
63
+ offsetParameter: 'offset',
64
+ },
65
+ },
66
+ {
67
+ id: 'posts.create',
68
+ method: 'POST',
69
+ intent: 'create',
70
+ request: {
71
+ contentType: 'application/json',
72
+ schemaRef: { id: 'post' },
73
+ },
74
+ response: {
75
+ status: 201,
76
+ schemaRef: { id: 'post' },
77
+ },
78
+ },
79
+ ],
80
+ },
81
+ {
82
+ id: 'post',
83
+ path: '/posts/{postId}',
84
+ operations: [
85
+ {
86
+ id: 'posts.get',
87
+ method: 'get',
88
+ intent: 'read',
89
+ parameters: [
90
+ {
91
+ name: 'postId',
92
+ location: 'path',
93
+ required: true,
94
+ schema: { type: 'string' },
95
+ },
96
+ ],
97
+ response: {
98
+ status: 200,
99
+ schemaRef: { id: 'post' },
100
+ },
101
+ },
102
+ {
103
+ id: 'posts.update',
104
+ method: 'PATCH',
105
+ intent: 'update',
106
+ parameters: [
107
+ {
108
+ name: 'postId',
109
+ location: 'path',
110
+ required: true,
111
+ schema: { type: 'string' },
112
+ },
113
+ ],
114
+ request: {
115
+ contentType: 'application/json',
116
+ schemaRef: { id: 'post' },
117
+ },
118
+ response: {
119
+ status: 200,
120
+ schemaRef: { id: 'post' },
121
+ },
122
+ },
123
+ {
124
+ id: 'posts.delete',
125
+ method: 'DELETE',
126
+ intent: 'delete',
127
+ parameters: [
128
+ {
129
+ name: 'postId',
130
+ location: 'path',
131
+ required: true,
132
+ schema: { type: 'string' },
133
+ },
134
+ ],
135
+ response: {
136
+ status: 204,
137
+ },
138
+ },
139
+ ],
140
+ },
141
+ ],
142
+ };
143
+ }
144
+
145
+ describe('manual REST data-source helpers', () => {
146
+ it('normalizes REST methods', () => {
147
+ expect(normalizeManualRestMethod('get')).toBe('GET');
148
+ expect(isManualRestMethod('GET')).toBe(true);
149
+ expect(isManualRestMethod('TRACE')).toBe(false);
150
+ });
151
+
152
+ it('extracts braced and colon path parameters', () => {
153
+ expect(extractRestPathParams('/posts/{postId}/comments/:commentId')).toEqual([
154
+ 'postId',
155
+ 'commentId',
156
+ ]);
157
+ });
158
+
159
+ it('normalizes list, get, create, update, and delete operations', () => {
160
+ const source = normalizeManualRestDataSource(createBlogDefinition());
161
+
162
+ assertSerializable(source);
163
+ expect(source.kind).toBe('rest');
164
+ expect(source.endpoints.posts?.operations['posts.list']?.intent).toBe('read');
165
+ expect(source.endpoints.post?.operations['posts.get']?.method).toBe('GET');
166
+ expect(source.endpoints.posts?.operations['posts.create']?.intent).toBe('create');
167
+ expect(source.endpoints.post?.operations['posts.update']?.intent).toBe('update');
168
+ expect(source.endpoints.post?.operations['posts.delete']?.intent).toBe('delete');
169
+ });
170
+
171
+ it('returns an ok diagnostic result for valid manual definitions', () => {
172
+ const result = createManualRestDataSource(createBlogDefinition());
173
+
174
+ expect(result.ok).toBe(true);
175
+ if (result.ok) {
176
+ expect(
177
+ result.data.endpoints.post?.operations['posts.get']?.request?.parameters?.[0]?.name,
178
+ ).toBe('postId');
179
+ expect(result.diagnostics).toEqual([]);
180
+ }
181
+ });
182
+
183
+ it('reports missing base URL', () => {
184
+ const result = createManualRestDataSource({
185
+ ...createBlogDefinition(),
186
+ baseUrl: ' ',
187
+ });
188
+
189
+ expect(result.ok).toBe(false);
190
+ if (!result.ok) {
191
+ expect(result.diagnostics.map((diagnostic) => diagnostic.path)).toContain('baseUrl');
192
+ }
193
+ });
194
+
195
+ it('reports invalid methods and path-template mismatches', () => {
196
+ const result = createManualRestDataSource({
197
+ id: 'broken-api',
198
+ baseUrl: 'https://api.example.com',
199
+ endpoints: [
200
+ {
201
+ id: 'broken',
202
+ path: 'broken/{id}',
203
+ operations: [
204
+ {
205
+ id: 'broken.trace',
206
+ method: 'TRACE',
207
+ intent: 'read',
208
+ parameters: [
209
+ {
210
+ name: 'unusedId',
211
+ location: 'path',
212
+ schema: { type: 'string' },
213
+ },
214
+ ],
215
+ },
216
+ ],
217
+ },
218
+ ],
219
+ });
220
+
221
+ expect(result.ok).toBe(false);
222
+ if (!result.ok) {
223
+ const messages = result.diagnostics.map((diagnostic) => diagnostic.message);
224
+
225
+ expect(messages).toContain('Manual REST endpoint paths must start with `/`.');
226
+ expect(messages).toContain(
227
+ 'Manual REST operation method must be one of DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT.',
228
+ );
229
+ expect(messages).toContain(
230
+ "Path template parameter 'id' must have a matching path parameter definition.",
231
+ );
232
+ expect(messages).toContain(
233
+ "Path parameter 'unusedId' is not referenced by the operation path template.",
234
+ );
235
+ }
236
+ });
237
+ });
@@ -0,0 +1,243 @@
1
+ import type {
2
+ CredentialRef,
3
+ DataContractValue,
4
+ DataEndpointConfig,
5
+ DataOperationConfig,
6
+ DataOperationIntent,
7
+ DataOperationMethod,
8
+ DataOperationPagination,
9
+ DataOperationParameter,
10
+ DataOperationRequest,
11
+ DataOperationResponse,
12
+ DataSchemaRegistry,
13
+ DataSourceDiagnostic,
14
+ DataSourceDiagnosticResult,
15
+ EndpointId,
16
+ OperationId,
17
+ RestDataSourceConfig,
18
+ } from '@ankhorage/contracts/data';
19
+
20
+ const REST_METHODS = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'] as const;
21
+
22
+ export type ManualRestMethod = (typeof REST_METHODS)[number];
23
+
24
+ export interface ManualRestOperationDefinition {
25
+ readonly id: OperationId;
26
+ readonly intent: DataOperationIntent;
27
+ readonly method: string;
28
+ readonly name?: string;
29
+ readonly description?: string;
30
+ readonly path?: string;
31
+ readonly credential?: CredentialRef;
32
+ readonly parameters?: readonly DataOperationParameter[];
33
+ readonly request?: DataOperationRequest;
34
+ readonly response?: DataOperationResponse;
35
+ readonly pagination?: DataOperationPagination;
36
+ readonly metadata?: DataContractValue;
37
+ }
38
+
39
+ export interface ManualRestEndpointDefinition {
40
+ readonly id: EndpointId;
41
+ readonly path: string;
42
+ readonly name?: string;
43
+ readonly description?: string;
44
+ readonly credential?: CredentialRef;
45
+ readonly operations: readonly ManualRestOperationDefinition[];
46
+ readonly metadata?: DataContractValue;
47
+ }
48
+
49
+ export interface ManualRestDataSourceDefinition {
50
+ readonly id: string;
51
+ readonly baseUrl: string;
52
+ readonly name?: string;
53
+ readonly description?: string;
54
+ readonly credential?: CredentialRef;
55
+ readonly endpoints: readonly ManualRestEndpointDefinition[];
56
+ readonly schemas?: DataSchemaRegistry;
57
+ readonly metadata?: DataContractValue;
58
+ }
59
+
60
+ export function isManualRestMethod(method: string): method is ManualRestMethod {
61
+ return REST_METHODS.some((restMethod) => restMethod === method);
62
+ }
63
+
64
+ export function normalizeManualRestMethod(method: string): DataOperationMethod {
65
+ return method.toUpperCase();
66
+ }
67
+
68
+ export function extractRestPathParams(path: string): readonly string[] {
69
+ const params = new Set<string>();
70
+ const bracedParamPattern = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
71
+ const colonParamPattern = /(^|\/):([A-Za-z_][A-Za-z0-9_]*)/g;
72
+
73
+ for (const match of path.matchAll(bracedParamPattern)) {
74
+ const [, param] = match;
75
+ if (param !== undefined) params.add(param);
76
+ }
77
+
78
+ for (const match of path.matchAll(colonParamPattern)) {
79
+ const [, , param] = match;
80
+ if (param !== undefined) params.add(param);
81
+ }
82
+
83
+ return [...params];
84
+ }
85
+
86
+ export function validateManualRestDataSource(
87
+ definition: ManualRestDataSourceDefinition,
88
+ ): readonly DataSourceDiagnostic[] {
89
+ const diagnostics: DataSourceDiagnostic[] = [];
90
+
91
+ if (definition.baseUrl.trim().length === 0) {
92
+ diagnostics.push({
93
+ code: 'invalid-config',
94
+ dataSourceId: definition.id,
95
+ message: 'Manual REST data source requires a non-empty baseUrl.',
96
+ path: 'baseUrl',
97
+ severity: 'error',
98
+ });
99
+ }
100
+
101
+ for (const endpoint of definition.endpoints) {
102
+ if (!endpoint.path.startsWith('/')) {
103
+ diagnostics.push({
104
+ code: 'invalid-config',
105
+ dataSourceId: definition.id,
106
+ endpointId: endpoint.id,
107
+ message: 'Manual REST endpoint paths must start with `/`.',
108
+ path: `endpoints.${endpoint.id}.path`,
109
+ severity: 'error',
110
+ });
111
+ }
112
+
113
+ for (const operation of endpoint.operations) {
114
+ const normalizedMethod = normalizeManualRestMethod(operation.method);
115
+ const operationPath = operation.path ?? endpoint.path;
116
+ const templateParams = extractRestPathParams(operationPath);
117
+ const pathParameters =
118
+ operation.parameters?.filter((parameter) => parameter.location === 'path') ?? [];
119
+ const pathParameterNames = new Set(pathParameters.map((parameter) => parameter.name));
120
+
121
+ if (!isManualRestMethod(normalizedMethod)) {
122
+ diagnostics.push({
123
+ code: 'invalid-config',
124
+ dataSourceId: definition.id,
125
+ endpointId: endpoint.id,
126
+ operationId: operation.id,
127
+ message: `Manual REST operation method must be one of ${REST_METHODS.join(', ')}.`,
128
+ path: `endpoints.${endpoint.id}.operations.${operation.id}.method`,
129
+ severity: 'error',
130
+ });
131
+ }
132
+
133
+ for (const templateParam of templateParams) {
134
+ if (!pathParameterNames.has(templateParam)) {
135
+ diagnostics.push({
136
+ code: 'invalid-config',
137
+ dataSourceId: definition.id,
138
+ endpointId: endpoint.id,
139
+ operationId: operation.id,
140
+ message: `Path template parameter '${templateParam}' must have a matching path parameter definition.`,
141
+ path: `endpoints.${endpoint.id}.operations.${operation.id}.parameters`,
142
+ severity: 'error',
143
+ });
144
+ }
145
+ }
146
+
147
+ for (const pathParameter of pathParameters) {
148
+ if (!templateParams.includes(pathParameter.name)) {
149
+ diagnostics.push({
150
+ code: 'invalid-config',
151
+ dataSourceId: definition.id,
152
+ endpointId: endpoint.id,
153
+ operationId: operation.id,
154
+ message: `Path parameter '${pathParameter.name}' is not referenced by the operation path template.`,
155
+ path: `endpoints.${endpoint.id}.operations.${operation.id}.parameters.${pathParameter.name}`,
156
+ severity: 'error',
157
+ });
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ return diagnostics;
164
+ }
165
+
166
+ export function createManualRestDataSource(
167
+ definition: ManualRestDataSourceDefinition,
168
+ ): DataSourceDiagnosticResult<RestDataSourceConfig> {
169
+ const diagnostics = validateManualRestDataSource(definition);
170
+ const hasErrors = diagnostics.some((diagnostic) => diagnostic.severity === 'error');
171
+
172
+ if (hasErrors) {
173
+ return { ok: false, diagnostics };
174
+ }
175
+
176
+ return {
177
+ ok: true,
178
+ data: normalizeManualRestDataSource(definition),
179
+ diagnostics,
180
+ };
181
+ }
182
+
183
+ export function normalizeManualRestDataSource(
184
+ definition: ManualRestDataSourceDefinition,
185
+ ): RestDataSourceConfig {
186
+ const endpoints: Record<EndpointId, DataEndpointConfig> = {};
187
+
188
+ for (const endpoint of definition.endpoints) {
189
+ const operations: Record<OperationId, DataOperationConfig> = {};
190
+
191
+ for (const operation of endpoint.operations) {
192
+ const operationPath = operation.path ?? endpoint.path;
193
+ const { parameters } = operation;
194
+ const request: DataOperationRequest | undefined =
195
+ operation.request === undefined && parameters === undefined
196
+ ? undefined
197
+ : {
198
+ ...operation.request,
199
+ parameters,
200
+ };
201
+
202
+ operations[operation.id] = {
203
+ id: operation.id,
204
+ endpointId: endpoint.id,
205
+ name: operation.name,
206
+ description: operation.description,
207
+ protocol: 'http',
208
+ intent: operation.intent,
209
+ method: normalizeManualRestMethod(operation.method),
210
+ path: operationPath,
211
+ request,
212
+ response: operation.response,
213
+ pagination: operation.pagination,
214
+ credential: operation.credential,
215
+ metadata: operation.metadata,
216
+ };
217
+ }
218
+
219
+ endpoints[endpoint.id] = {
220
+ id: endpoint.id,
221
+ kind: 'http',
222
+ name: endpoint.name,
223
+ description: endpoint.description,
224
+ baseUrl: definition.baseUrl,
225
+ path: endpoint.path,
226
+ credential: endpoint.credential,
227
+ operations,
228
+ metadata: endpoint.metadata,
229
+ };
230
+ }
231
+
232
+ return {
233
+ id: definition.id,
234
+ kind: 'rest',
235
+ name: definition.name,
236
+ description: definition.description,
237
+ baseUrl: definition.baseUrl,
238
+ credential: definition.credential,
239
+ endpoints,
240
+ schemas: definition.schemas,
241
+ metadata: definition.metadata,
242
+ };
243
+ }