@ankhorage/contracts 7.9.0 → 8.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/dist/appManifest/apis.d.ts +3 -0
- package/dist/appManifest/apis.d.ts.map +1 -0
- package/dist/appManifest/apis.js +81 -0
- package/dist/appManifest/apis.js.map +1 -0
- package/dist/appManifest/bindings.js +1 -1
- package/dist/appManifest/bindings.js.map +1 -1
- package/dist/appManifest/data.d.ts +5 -0
- package/dist/appManifest/data.d.ts.map +1 -0
- package/dist/appManifest/data.js +140 -0
- package/dist/appManifest/data.js.map +1 -0
- package/dist/appManifest/dataSources.d.ts.map +1 -1
- package/dist/appManifest/dataSources.js +23 -120
- package/dist/appManifest/dataSources.js.map +1 -1
- package/dist/appManifest/infra.d.ts.map +1 -1
- package/dist/appManifest/infra.js +2 -0
- package/dist/appManifest/infra.js.map +1 -1
- package/dist/appManifest.d.ts.map +1 -1
- package/dist/appManifest.js +4 -6
- package/dist/appManifest.js.map +1 -1
- package/dist/bindings.d.ts +2 -2
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js.map +1 -1
- package/dist/data/apis.d.ts +42 -38
- package/dist/data/apis.d.ts.map +1 -1
- package/dist/data/apis.js +1 -7
- package/dist/data/apis.js.map +1 -1
- package/dist/data/diagnostics.d.ts +3 -2
- package/dist/data/diagnostics.d.ts.map +1 -1
- package/dist/data/diagnostics.js.map +1 -1
- package/dist/data/ids.d.ts +1 -0
- package/dist/data/ids.d.ts.map +1 -1
- package/dist/data/ids.js.map +1 -1
- package/dist/data/sources.d.ts +5 -42
- package/dist/data/sources.d.ts.map +1 -1
- package/dist/data/sources.js.map +1 -1
- package/dist/types.d.ts +2 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/appManifest/apis.ts +98 -0
- package/src/appManifest/bindings.ts +1 -1
- package/src/appManifest/data.ts +179 -0
- package/src/appManifest/dataSources.ts +30 -157
- package/src/appManifest/infra.ts +2 -0
- package/src/appManifest.test.ts +95 -39
- package/src/appManifest.ts +4 -6
- package/src/bindings.test.ts +36 -43
- package/src/bindings.ts +2 -2
- package/src/data/apis.test.ts +55 -45
- package/src/data/apis.ts +49 -46
- package/src/data/data.test.ts +19 -160
- package/src/data/diagnostics.ts +3 -1
- package/src/data/ids.ts +1 -0
- package/src/data/sources.ts +5 -53
- package/src/types.ts +2 -3
- package/dist/appManifest/generatedApis.d.ts +0 -2
- package/dist/appManifest/generatedApis.d.ts.map +0 -1
- package/dist/appManifest/generatedApis.js +0 -87
- package/dist/appManifest/generatedApis.js.map +0 -1
- package/src/appManifest/generatedApis.ts +0 -122
package/src/appManifest.test.ts
CHANGED
|
@@ -43,6 +43,41 @@ function createManifest(): Record<string, unknown> {
|
|
|
43
43
|
storage: { provider: 'auto', buckets: ['media'] },
|
|
44
44
|
state: { provider: 'legend', persistence: 'local' },
|
|
45
45
|
networking: { domain: 'example.test', cdn: false },
|
|
46
|
+
apis: [
|
|
47
|
+
{
|
|
48
|
+
id: 'nutrition',
|
|
49
|
+
origin: 'external',
|
|
50
|
+
protocol: 'rest',
|
|
51
|
+
baseUrl: 'https://api.ankhorage.com/v1/nutrition',
|
|
52
|
+
schemas: {
|
|
53
|
+
Product: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
required: ['id', 'name'],
|
|
56
|
+
properties: {
|
|
57
|
+
id: { type: 'string', format: 'uuid' },
|
|
58
|
+
name: { type: 'string' },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
endpoints: {
|
|
63
|
+
products: {
|
|
64
|
+
id: 'products',
|
|
65
|
+
kind: 'http',
|
|
66
|
+
path: '/products',
|
|
67
|
+
operations: {
|
|
68
|
+
'products.list': {
|
|
69
|
+
id: 'products.list',
|
|
70
|
+
endpointId: 'products',
|
|
71
|
+
protocol: 'http',
|
|
72
|
+
intent: 'read',
|
|
73
|
+
method: 'GET',
|
|
74
|
+
path: '/products',
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
],
|
|
46
81
|
modules: ['expo-localization'],
|
|
47
82
|
modulesConfig: { localization: { defaultLocale: 'en' } },
|
|
48
83
|
},
|
|
@@ -76,8 +111,8 @@ function createManifest(): Record<string, unknown> {
|
|
|
76
111
|
dataLoaders: [
|
|
77
112
|
{
|
|
78
113
|
kind: 'operation',
|
|
79
|
-
id: 'load-
|
|
80
|
-
operation: {
|
|
114
|
+
id: 'load-products',
|
|
115
|
+
operation: { apiId: 'nutrition', endpointId: 'products', operationId: 'products.list' },
|
|
81
116
|
},
|
|
82
117
|
],
|
|
83
118
|
requires: {
|
|
@@ -86,42 +121,12 @@ function createManifest(): Record<string, unknown> {
|
|
|
86
121
|
},
|
|
87
122
|
},
|
|
88
123
|
},
|
|
89
|
-
generatedApis: {
|
|
90
|
-
users: {
|
|
91
|
-
id: 'users',
|
|
92
|
-
protocol: 'rest',
|
|
93
|
-
basePath: '/users',
|
|
94
|
-
database: { id: 'primary', kind: 'database' },
|
|
95
|
-
resources: [
|
|
96
|
-
{
|
|
97
|
-
id: 'users',
|
|
98
|
-
path: '/users',
|
|
99
|
-
collection: {
|
|
100
|
-
name: 'users',
|
|
101
|
-
fields: [{ name: 'id', type: 'uuid', required: true }],
|
|
102
|
-
primaryKey: 'id',
|
|
103
|
-
},
|
|
104
|
-
operations: ['list', 'read'],
|
|
105
|
-
},
|
|
106
|
-
],
|
|
107
|
-
},
|
|
108
|
-
},
|
|
109
124
|
dataSources: {
|
|
110
|
-
|
|
111
|
-
id: '
|
|
112
|
-
kind: '
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
baseUrl: 'https://example.test',
|
|
116
|
-
endpoints: {
|
|
117
|
-
main: {
|
|
118
|
-
id: 'main',
|
|
119
|
-
kind: 'http',
|
|
120
|
-
operations: {
|
|
121
|
-
list: { id: 'list', protocol: 'rest', intent: 'read', path: '/users' },
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
|
+
primary: {
|
|
126
|
+
id: 'primary',
|
|
127
|
+
kind: 'database',
|
|
128
|
+
adapter: { id: 'primary-db', kind: 'database' },
|
|
129
|
+
endpoints: {},
|
|
125
130
|
},
|
|
126
131
|
},
|
|
127
132
|
dataBindings: {
|
|
@@ -134,14 +139,13 @@ function createManifest(): Record<string, unknown> {
|
|
|
134
139
|
},
|
|
135
140
|
},
|
|
136
141
|
settings: {
|
|
137
|
-
apiBaseUrl: 'https://example.test/api',
|
|
138
142
|
localization: { defaultLocale: 'en', locales: ['en', 'de'] },
|
|
139
143
|
},
|
|
140
144
|
};
|
|
141
145
|
}
|
|
142
146
|
|
|
143
147
|
describe('AppManifest runtime parsing', () => {
|
|
144
|
-
it('accepts the canonical manifest including
|
|
148
|
+
it('accepts the canonical manifest including infra APIs', () => {
|
|
145
149
|
const manifest = createManifest();
|
|
146
150
|
|
|
147
151
|
expect(isAppManifest(manifest)).toBe(true);
|
|
@@ -167,6 +171,58 @@ describe('AppManifest runtime parsing', () => {
|
|
|
167
171
|
});
|
|
168
172
|
});
|
|
169
173
|
|
|
174
|
+
it('rejects duplicate canonical API ids', () => {
|
|
175
|
+
const manifest = createManifest();
|
|
176
|
+
const infra = manifest.infra as Record<string, unknown>;
|
|
177
|
+
const apis = infra.apis as Record<string, unknown>[];
|
|
178
|
+
apis.push(structuredClone(apis[0]));
|
|
179
|
+
|
|
180
|
+
expect(isAppManifest(manifest)).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('rejects generatedApis as removed parallel API state', () => {
|
|
184
|
+
const manifest = createManifest();
|
|
185
|
+
manifest.generatedApis = {};
|
|
186
|
+
|
|
187
|
+
expect(isAppManifest(manifest)).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('rejects API-flavoured dataSources', () => {
|
|
191
|
+
const manifest = createManifest();
|
|
192
|
+
manifest.dataSources = {
|
|
193
|
+
external: {
|
|
194
|
+
id: 'external',
|
|
195
|
+
kind: 'api',
|
|
196
|
+
origin: 'external',
|
|
197
|
+
protocol: 'rest',
|
|
198
|
+
baseUrl: 'https://example.test',
|
|
199
|
+
endpoints: {},
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
expect(isAppManifest(manifest)).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('rejects old dataSourceId API binding references', () => {
|
|
207
|
+
const manifest = createManifest();
|
|
208
|
+
const screens = manifest.screens as Record<string, Record<string, unknown>>;
|
|
209
|
+
const loaders = screens.home?.dataLoaders as Record<string, unknown>[];
|
|
210
|
+
loaders[0] = {
|
|
211
|
+
kind: 'operation',
|
|
212
|
+
operation: { dataSourceId: 'nutrition', operationId: 'products.list' },
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
expect(isAppManifest(manifest)).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('rejects settings.apiBaseUrl as removed parallel API state', () => {
|
|
219
|
+
const manifest = createManifest();
|
|
220
|
+
const settings = manifest.settings as Record<string, unknown>;
|
|
221
|
+
settings.apiBaseUrl = 'https://example.test/api';
|
|
222
|
+
|
|
223
|
+
expect(isAppManifest(manifest)).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
170
226
|
it('rejects transient media URLs at the manifest boundary', () => {
|
|
171
227
|
const manifest = createManifest();
|
|
172
228
|
const media = manifest.media as Record<string, Record<string, Record<string, unknown>>>;
|
package/src/appManifest.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { isComponentDataBindingRegistry } from './appManifest/bindings';
|
|
2
2
|
import { isDataSourceRegistry } from './appManifest/dataSources';
|
|
3
3
|
import { isAppDeployManifest } from './appManifest/deploy';
|
|
4
|
-
import { isGeneratedApiRegistry } from './appManifest/generatedApis';
|
|
5
4
|
import { isInfraManifest } from './appManifest/infra';
|
|
6
5
|
import { isMediaManifest } from './appManifest/media';
|
|
7
6
|
import {
|
|
@@ -11,7 +10,7 @@ import {
|
|
|
11
10
|
isSplashScreenSpec,
|
|
12
11
|
isThemeConfig,
|
|
13
12
|
} from './appManifest/screens';
|
|
14
|
-
import {
|
|
13
|
+
import { isRecord, isStringArray } from './appManifest/shared';
|
|
15
14
|
import type { AppManifest } from './types';
|
|
16
15
|
|
|
17
16
|
export type AppManifestParseResult =
|
|
@@ -29,7 +28,6 @@ const APP_MANIFEST_KEY_POLICY = {
|
|
|
29
28
|
infra: 'required',
|
|
30
29
|
navigator: 'required',
|
|
31
30
|
screens: 'required',
|
|
32
|
-
generatedApis: 'optional',
|
|
33
31
|
dataSources: 'optional',
|
|
34
32
|
dataBindings: 'optional',
|
|
35
33
|
settings: 'required',
|
|
@@ -53,6 +51,7 @@ export function isAppManifest(value: unknown): value is AppManifest {
|
|
|
53
51
|
return (
|
|
54
52
|
isRecord(value) &&
|
|
55
53
|
hasRequiredManifestKeys(value) &&
|
|
54
|
+
!('generatedApis' in value) &&
|
|
56
55
|
isManifestMetadata(value.metadata) &&
|
|
57
56
|
Array.isArray(value.themes) &&
|
|
58
57
|
value.themes.every(isThemeConfig) &&
|
|
@@ -64,7 +63,6 @@ export function isAppManifest(value: unknown): value is AppManifest {
|
|
|
64
63
|
isInfraManifest(value.infra) &&
|
|
65
64
|
isNavigatorSpec(value.navigator) &&
|
|
66
65
|
isScreenRegistry(value.screens) &&
|
|
67
|
-
(value.generatedApis === undefined || isGeneratedApiRegistry(value.generatedApis)) &&
|
|
68
66
|
(value.dataSources === undefined || isDataSourceRegistry(value.dataSources)) &&
|
|
69
67
|
(value.dataBindings === undefined || isComponentDataBindingRegistry(value.dataBindings)) &&
|
|
70
68
|
isAppSettings(value.settings)
|
|
@@ -84,9 +82,9 @@ function isActiveThemeMode(value: unknown): boolean {
|
|
|
84
82
|
function isAppSettings(value: unknown): boolean {
|
|
85
83
|
return (
|
|
86
84
|
isRecord(value) &&
|
|
85
|
+
!('apiBaseUrl' in value) &&
|
|
87
86
|
isRecord(value.localization) &&
|
|
88
87
|
typeof value.localization.defaultLocale === 'string' &&
|
|
89
|
-
isStringArray(value.localization.locales)
|
|
90
|
-
isOptionalString(value.apiBaseUrl)
|
|
88
|
+
isStringArray(value.localization.locales)
|
|
91
89
|
);
|
|
92
90
|
}
|
package/src/bindings.test.ts
CHANGED
|
@@ -26,7 +26,7 @@ describe('component data-binding contracts', () => {
|
|
|
26
26
|
source: {
|
|
27
27
|
kind: 'operation',
|
|
28
28
|
operation: {
|
|
29
|
-
|
|
29
|
+
apiId: 'cms',
|
|
30
30
|
endpointId: 'pages',
|
|
31
31
|
operationId: 'pages.getHome',
|
|
32
32
|
},
|
|
@@ -66,7 +66,7 @@ describe('component data-binding contracts', () => {
|
|
|
66
66
|
{
|
|
67
67
|
kind: 'operation',
|
|
68
68
|
operation: {
|
|
69
|
-
|
|
69
|
+
apiId: 'search-api',
|
|
70
70
|
operationId: 'search.query',
|
|
71
71
|
},
|
|
72
72
|
path: '$.results',
|
|
@@ -83,7 +83,7 @@ describe('component data-binding contracts', () => {
|
|
|
83
83
|
]);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
it('serializes an event binding that executes
|
|
86
|
+
it('serializes an event binding that executes an API operation', () => {
|
|
87
87
|
const input: BindingInputMap = {
|
|
88
88
|
email: {
|
|
89
89
|
kind: 'source',
|
|
@@ -122,7 +122,7 @@ describe('component data-binding contracts', () => {
|
|
|
122
122
|
target: {
|
|
123
123
|
kind: 'operation',
|
|
124
124
|
operation: {
|
|
125
|
-
|
|
125
|
+
apiId: 'contact-api',
|
|
126
126
|
endpointId: 'messages',
|
|
127
127
|
operationId: 'messages.create',
|
|
128
128
|
},
|
|
@@ -165,7 +165,7 @@ describe('component data-binding contracts', () => {
|
|
|
165
165
|
kind: 'operation',
|
|
166
166
|
id: 'product-detail',
|
|
167
167
|
operation: {
|
|
168
|
-
|
|
168
|
+
apiId: 'nutrition',
|
|
169
169
|
endpointId: 'products',
|
|
170
170
|
operationId: 'nutrition.products.getById',
|
|
171
171
|
},
|
|
@@ -186,7 +186,7 @@ describe('component data-binding contracts', () => {
|
|
|
186
186
|
|
|
187
187
|
it('serializes scanner lookup and conditional navigation intent using existing generic bindings', () => {
|
|
188
188
|
const productLookupOperation = {
|
|
189
|
-
|
|
189
|
+
apiId: 'nutrition',
|
|
190
190
|
endpointId: 'products',
|
|
191
191
|
operationId: 'products.lookupByBarcode',
|
|
192
192
|
} as const;
|
|
@@ -308,7 +308,7 @@ describe('component data-binding contracts', () => {
|
|
|
308
308
|
|
|
309
309
|
it('serializes repeated container nodes with generic item-context bindings', () => {
|
|
310
310
|
const productsListOperation = {
|
|
311
|
-
|
|
311
|
+
apiId: 'catalog-api',
|
|
312
312
|
endpointId: 'products',
|
|
313
313
|
operationId: 'products.list',
|
|
314
314
|
} as const;
|
|
@@ -439,7 +439,7 @@ describe('component data-binding contracts', () => {
|
|
|
439
439
|
target: {
|
|
440
440
|
kind: 'operation',
|
|
441
441
|
operation: {
|
|
442
|
-
|
|
442
|
+
apiId: 'contact-api',
|
|
443
443
|
operationId: 'messages.create',
|
|
444
444
|
},
|
|
445
445
|
},
|
|
@@ -453,12 +453,12 @@ describe('component data-binding contracts', () => {
|
|
|
453
453
|
expect(bindings['submit-button']?.events?.press?.[0]?.target.kind).toBe('operation');
|
|
454
454
|
});
|
|
455
455
|
|
|
456
|
-
it('serializes app-level
|
|
456
|
+
it('serializes app-level APIs and dataBindings on the manifest', () => {
|
|
457
457
|
const propBinding: PropBinding = {
|
|
458
458
|
source: {
|
|
459
459
|
kind: 'operation',
|
|
460
460
|
operation: {
|
|
461
|
-
|
|
461
|
+
apiId: 'cms',
|
|
462
462
|
endpointId: 'pages',
|
|
463
463
|
operationId: 'pages.getHome',
|
|
464
464
|
},
|
|
@@ -490,6 +490,31 @@ describe('component data-binding contracts', () => {
|
|
|
490
490
|
],
|
|
491
491
|
activeThemeId: 'default',
|
|
492
492
|
infra: {
|
|
493
|
+
apis: [
|
|
494
|
+
{
|
|
495
|
+
id: 'cms',
|
|
496
|
+
origin: 'external',
|
|
497
|
+
protocol: 'rest',
|
|
498
|
+
baseUrl: 'https://cms.example.com',
|
|
499
|
+
endpoints: {
|
|
500
|
+
pages: {
|
|
501
|
+
id: 'pages',
|
|
502
|
+
kind: 'http',
|
|
503
|
+
path: '/pages/home',
|
|
504
|
+
operations: {
|
|
505
|
+
'pages.getHome': {
|
|
506
|
+
id: 'pages.getHome',
|
|
507
|
+
endpointId: 'pages',
|
|
508
|
+
protocol: 'http',
|
|
509
|
+
intent: 'read',
|
|
510
|
+
method: 'GET',
|
|
511
|
+
path: '/pages/home',
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
],
|
|
493
518
|
modules: [],
|
|
494
519
|
},
|
|
495
520
|
navigator: {
|
|
@@ -517,30 +542,6 @@ describe('component data-binding contracts', () => {
|
|
|
517
542
|
},
|
|
518
543
|
},
|
|
519
544
|
},
|
|
520
|
-
dataSources: {
|
|
521
|
-
cms: {
|
|
522
|
-
id: 'cms',
|
|
523
|
-
kind: 'rest',
|
|
524
|
-
baseUrl: 'https://cms.example.com',
|
|
525
|
-
endpoints: {
|
|
526
|
-
pages: {
|
|
527
|
-
id: 'pages',
|
|
528
|
-
kind: 'http',
|
|
529
|
-
path: '/pages/home',
|
|
530
|
-
operations: {
|
|
531
|
-
'pages.getHome': {
|
|
532
|
-
id: 'pages.getHome',
|
|
533
|
-
endpointId: 'pages',
|
|
534
|
-
protocol: 'http',
|
|
535
|
-
intent: 'read',
|
|
536
|
-
method: 'GET',
|
|
537
|
-
path: '/pages/home',
|
|
538
|
-
},
|
|
539
|
-
},
|
|
540
|
-
},
|
|
541
|
-
},
|
|
542
|
-
},
|
|
543
|
-
},
|
|
544
545
|
dataBindings: {
|
|
545
546
|
'hero-title': {
|
|
546
547
|
componentId: 'hero-title',
|
|
@@ -555,19 +556,11 @@ describe('component data-binding contracts', () => {
|
|
|
555
556
|
defaultLocale: 'en',
|
|
556
557
|
locales: ['en'],
|
|
557
558
|
},
|
|
558
|
-
authFlow: {
|
|
559
|
-
signInRoute: '/sign-in',
|
|
560
|
-
signUpRoute: '/sign-up',
|
|
561
|
-
signOutRoute: '/sign-out',
|
|
562
|
-
forgotPasswordRoute: '/forgot-password',
|
|
563
|
-
postSignInRoute: '/',
|
|
564
|
-
unauthorizedRoute: '/sign-in',
|
|
565
|
-
},
|
|
566
559
|
},
|
|
567
560
|
};
|
|
568
561
|
|
|
569
562
|
assertSerializable(manifest);
|
|
570
563
|
expect(manifest.dataBindings?.['hero-title']?.props?.children?.source.kind).toBe('operation');
|
|
571
|
-
expect(manifest.
|
|
564
|
+
expect(manifest.infra.apis?.[0]?.id).toBe('cms');
|
|
572
565
|
});
|
|
573
566
|
});
|
package/src/bindings.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ApiId, EndpointId, OperationId } from './data';
|
|
2
2
|
|
|
3
3
|
export type ComponentInstanceId = string;
|
|
4
4
|
export type ComponentTypeId = string;
|
|
@@ -18,7 +18,7 @@ export type BindingDataPath = string;
|
|
|
18
18
|
export type BindingValueTransform = 'lowercase' | 'trim' | 'uppercase';
|
|
19
19
|
|
|
20
20
|
export interface BindingOperationRef {
|
|
21
|
-
readonly
|
|
21
|
+
readonly apiId: ApiId;
|
|
22
22
|
readonly operationId: OperationId;
|
|
23
23
|
readonly endpointId?: EndpointId;
|
|
24
24
|
}
|
package/src/data/apis.test.ts
CHANGED
|
@@ -1,69 +1,79 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
|
|
3
|
+
import type {
|
|
4
|
+
ApiDefinitionList,
|
|
5
|
+
ExternalRestApiDefinition,
|
|
6
|
+
InternalRestApiDefinition,
|
|
7
|
+
} from './apis';
|
|
5
8
|
|
|
6
9
|
function assertSerializable<TValue>(value: TValue): void {
|
|
7
10
|
expect(JSON.parse(JSON.stringify(value))).toEqual(value);
|
|
8
11
|
}
|
|
9
12
|
|
|
10
|
-
function
|
|
13
|
+
function createNutritionApi(): ExternalRestApiDefinition {
|
|
11
14
|
return {
|
|
12
|
-
id: '
|
|
15
|
+
id: 'nutrition',
|
|
16
|
+
origin: 'external',
|
|
13
17
|
protocol: 'rest',
|
|
14
|
-
name: '
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
name: 'Nutrition API',
|
|
19
|
+
baseUrl: 'https://api.ankhorage.com/v1/nutrition',
|
|
20
|
+
schemas: {
|
|
21
|
+
Product: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
required: ['id', 'name'],
|
|
24
|
+
properties: {
|
|
25
|
+
id: { type: 'string', format: 'uuid' },
|
|
26
|
+
name: { type: 'string' },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
25
29
|
},
|
|
26
|
-
|
|
27
|
-
{
|
|
30
|
+
endpoints: {
|
|
31
|
+
products: {
|
|
28
32
|
id: 'products',
|
|
29
|
-
|
|
33
|
+
kind: 'http',
|
|
30
34
|
path: '/products',
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
operations: {
|
|
36
|
+
'products.list': {
|
|
37
|
+
id: 'products.list',
|
|
38
|
+
endpointId: 'products',
|
|
39
|
+
protocol: 'http',
|
|
40
|
+
intent: 'read',
|
|
41
|
+
method: 'GET',
|
|
42
|
+
path: '/products',
|
|
43
|
+
},
|
|
40
44
|
},
|
|
41
|
-
operations: GENERATED_API_CRUD_OPERATIONS,
|
|
42
|
-
seed: [{ id: 'product-1', name: 'Keyboard', price: 120 }],
|
|
43
|
-
policies: [
|
|
44
|
-
{ id: 'catalog.read', operation: 'list' },
|
|
45
|
-
{ id: 'catalog.write', operation: 'create' },
|
|
46
|
-
],
|
|
47
45
|
},
|
|
48
|
-
|
|
46
|
+
},
|
|
49
47
|
};
|
|
50
48
|
}
|
|
51
49
|
|
|
52
|
-
describe('
|
|
53
|
-
it('serializes
|
|
54
|
-
const api =
|
|
50
|
+
describe('canonical API contracts', () => {
|
|
51
|
+
it('serializes external REST schemas, endpoints, and operations', () => {
|
|
52
|
+
const api = createNutritionApi();
|
|
55
53
|
|
|
56
54
|
assertSerializable(api);
|
|
57
|
-
expect(api.
|
|
58
|
-
expect(api.
|
|
59
|
-
expect(api.resources[0]?.seed?.[0]?.name).toBe('Keyboard');
|
|
55
|
+
expect(api.baseUrl).toBe('https://api.ankhorage.com/v1/nutrition');
|
|
56
|
+
expect(api.endpoints.products?.operations['products.list']?.method).toBe('GET');
|
|
60
57
|
});
|
|
61
58
|
|
|
62
|
-
it('stores
|
|
63
|
-
const
|
|
64
|
-
const registry: GeneratedApiRegistry = { [api.id]: api };
|
|
59
|
+
it('stores canonical APIs as an ordered infra list', () => {
|
|
60
|
+
const apis: ApiDefinitionList = [createNutritionApi()];
|
|
65
61
|
|
|
66
|
-
assertSerializable(
|
|
67
|
-
expect(
|
|
62
|
+
assertSerializable(apis);
|
|
63
|
+
expect(apis[0]?.id).toBe('nutrition');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('reserves an internal REST API identity without persistence implementation', () => {
|
|
67
|
+
const api: InternalRestApiDefinition = {
|
|
68
|
+
id: 'future-internal-api',
|
|
69
|
+
origin: 'internal',
|
|
70
|
+
protocol: 'rest',
|
|
71
|
+
basePath: '/v1/internal',
|
|
72
|
+
endpoints: {},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
assertSerializable(api);
|
|
76
|
+
expect('database' in api).toBe(false);
|
|
77
|
+
expect('adapter' in api).toBe(false);
|
|
68
78
|
});
|
|
69
79
|
});
|
package/src/data/apis.ts
CHANGED
|
@@ -1,62 +1,65 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { DataEndpointRegistry } from './endpoints';
|
|
2
|
+
import type { ApiId } from './ids';
|
|
3
|
+
import type { CredentialRef } from './refs';
|
|
4
|
+
import type { DataSchemaRegistry } from './schemas';
|
|
3
5
|
import type { DataContractValue } from './values';
|
|
4
6
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
export interface GeneratedApiAuthRequirement {
|
|
19
|
-
readonly required?: boolean;
|
|
20
|
-
readonly roles?: readonly string[];
|
|
21
|
-
readonly permissions?: readonly string[];
|
|
22
|
-
readonly policy?: string;
|
|
7
|
+
export type ApiOrigin = 'external' | 'internal';
|
|
8
|
+
export type ApiProtocol = 'graphql' | 'rest';
|
|
9
|
+
|
|
10
|
+
export interface ApiBaseDefinition {
|
|
11
|
+
readonly id: ApiId;
|
|
12
|
+
readonly origin: ApiOrigin;
|
|
13
|
+
readonly protocol: ApiProtocol;
|
|
14
|
+
readonly name?: string;
|
|
15
|
+
readonly description?: string;
|
|
16
|
+
readonly credential?: CredentialRef;
|
|
17
|
+
readonly endpoints: DataEndpointRegistry;
|
|
18
|
+
readonly schemas?: DataSchemaRegistry;
|
|
23
19
|
readonly metadata?: DataContractValue;
|
|
24
20
|
}
|
|
25
21
|
|
|
26
|
-
export interface
|
|
27
|
-
readonly
|
|
28
|
-
readonly
|
|
22
|
+
export interface OpenApiDocumentRef {
|
|
23
|
+
readonly url?: string;
|
|
24
|
+
readonly documentId?: string;
|
|
25
|
+
readonly version?: string;
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
export interface
|
|
32
|
-
readonly
|
|
33
|
-
readonly
|
|
34
|
-
readonly
|
|
35
|
-
readonly
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
readonly
|
|
40
|
-
readonly
|
|
28
|
+
export interface ExternalRestApiDefinition extends ApiBaseDefinition {
|
|
29
|
+
readonly origin: 'external';
|
|
30
|
+
readonly protocol: 'rest';
|
|
31
|
+
readonly baseUrl: string;
|
|
32
|
+
readonly openApi?: OpenApiDocumentRef;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GraphQlIntrospectionConfig {
|
|
36
|
+
readonly enabled: boolean;
|
|
37
|
+
readonly schemaVersion?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ExternalGraphQlApiDefinition extends ApiBaseDefinition {
|
|
41
|
+
readonly origin: 'external';
|
|
42
|
+
readonly protocol: 'graphql';
|
|
43
|
+
readonly endpointUrl: string;
|
|
44
|
+
readonly introspection?: GraphQlIntrospectionConfig;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
/**
|
|
44
|
-
*
|
|
48
|
+
* Consumer-facing identity for an Ankhorage-owned API.
|
|
45
49
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
50
|
+
* Internal API generation/execution is intentionally not part of the current
|
|
51
|
+
* contract. `basePath` reserves the portable API-facing location only; later
|
|
52
|
+
* infrastructure/runtime packages may implement it without changing bindings.
|
|
49
53
|
*/
|
|
50
|
-
export interface
|
|
51
|
-
readonly
|
|
54
|
+
export interface InternalRestApiDefinition extends ApiBaseDefinition {
|
|
55
|
+
readonly origin: 'internal';
|
|
52
56
|
readonly protocol: 'rest';
|
|
53
|
-
readonly name?: string;
|
|
54
|
-
readonly description?: string;
|
|
55
57
|
readonly basePath: string;
|
|
56
|
-
readonly database: DatabaseAdapterRef;
|
|
57
|
-
readonly resources: readonly GeneratedApiResourceDefinition[];
|
|
58
|
-
readonly auth?: GeneratedApiAuthRequirement;
|
|
59
|
-
readonly metadata?: DataContractValue;
|
|
60
58
|
}
|
|
61
59
|
|
|
62
|
-
export type
|
|
60
|
+
export type ApiDefinition =
|
|
61
|
+
| ExternalGraphQlApiDefinition
|
|
62
|
+
| ExternalRestApiDefinition
|
|
63
|
+
| InternalRestApiDefinition;
|
|
64
|
+
|
|
65
|
+
export type ApiDefinitionList = readonly ApiDefinition[];
|