@squiz/dx-json-schema-lib 1.85.1 → 1.85.2

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +11 -0
  3. package/jest.integration.config.ts +26 -0
  4. package/jsonCompiler.ts +35 -2
  5. package/lib/JsonSchemaService.js +8 -7
  6. package/lib/JsonSchemaService.js.map +1 -1
  7. package/lib/JsonValidationService.d.ts +3 -2
  8. package/lib/JsonValidationService.js +21 -91
  9. package/lib/JsonValidationService.js.map +1 -1
  10. package/lib/defaultDraftConfig.d.ts +2 -0
  11. package/lib/defaultDraftConfig.js +81 -0
  12. package/lib/defaultDraftConfig.js.map +1 -0
  13. package/lib/formatted-text/v1/formattedTextConstants.d.ts +2 -0
  14. package/lib/formatted-text/v1/formattedTextConstants.js +6 -0
  15. package/lib/formatted-text/v1/formattedTextConstants.js.map +1 -0
  16. package/lib/formatted-text/v1/resolveFormattedTextNodes.d.ts +2 -2
  17. package/lib/formatted-text/v1/resolveFormattedTextNodes.js +3 -2
  18. package/lib/formatted-text/v1/resolveFormattedTextNodes.js.map +1 -1
  19. package/lib/index.d.ts +1 -0
  20. package/lib/index.js +1 -0
  21. package/lib/index.js.map +1 -1
  22. package/lib/manifest/userApi/v1/UserApiManifestV1.d.ts +48 -0
  23. package/lib/manifest/userApi/v1/UserApiManifestV1.js +9 -0
  24. package/lib/manifest/userApi/v1/UserApiManifestV1.js.map +1 -0
  25. package/lib/manifest/userApi/v1/UserApiManifestV1.json +76 -0
  26. package/lib/manifest/userApi/v1/index.d.ts +2 -0
  27. package/lib/manifest/userApi/v1/index.js +8 -0
  28. package/lib/manifest/userApi/v1/index.js.map +1 -0
  29. package/lib/manifest/userApi/v1/integration-fixtures/invalid-endpoint-extra-public.manifest.json +10 -0
  30. package/lib/manifest/userApi/v1/integration-fixtures/invalid-endpoint-handler-hyphen.manifest.json +9 -0
  31. package/lib/manifest/userApi/v1/integration-fixtures/invalid-endpoint-handler-leading-digit.manifest.json +9 -0
  32. package/lib/manifest/userApi/v1/integration-fixtures/invalid-endpoint-method-invalid.manifest.json +9 -0
  33. package/lib/manifest/userApi/v1/integration-fixtures/invalid-endpoint-missing-method.manifest.json +8 -0
  34. package/lib/manifest/userApi/v1/integration-fixtures/invalid-endpoint-missing-path.manifest.json +8 -0
  35. package/lib/manifest/userApi/v1/integration-fixtures/invalid-full-displayName-empty.manifest.json +16 -0
  36. package/lib/manifest/userApi/v1/integration-fixtures/invalid-full-manifest-not-object.manifest.json +8 -0
  37. package/lib/manifest/userApi/v1/integration-fixtures/invalid-full-missing-manifest.manifest.json +7 -0
  38. package/lib/manifest/userApi/v1/integration-fixtures/invalid-full-missing-schema.manifest.json +15 -0
  39. package/lib/manifest/userApi/v1/integration-fixtures/invalid-full-name-empty.manifest.json +16 -0
  40. package/lib/manifest/userApi/v1/integration-fixtures/invalid-full-root-extra-property.manifest.json +17 -0
  41. package/lib/manifest/userApi/v1/integration-fixtures/invalid-nested-manifest-handlerMap.manifest.json +12 -0
  42. package/lib/manifest/userApi/v1/integration-fixtures/invalid-nested-manifest-name.manifest.json +10 -0
  43. package/lib/manifest/userApi/v1/integration-fixtures/invalid-resolved-empty-endpoints.manifest.json +4 -0
  44. package/lib/manifest/userApi/v1/integration-fixtures/invalid-resolved-no-leading-slash.manifest.json +11 -0
  45. package/lib/manifest/userApi/v1/integration-fixtures/valid-full-each-http-method.manifest.json +18 -0
  46. package/lib/manifest/userApi/v1/integration-fixtures/valid-full-no-description.manifest.json +16 -0
  47. package/lib/manifest/userApi/v1/integration-fixtures/valid-full.manifest.json +22 -0
  48. package/lib/manifest/userApi/v1/integration-fixtures/valid-resolved-slash-path.manifest.json +14 -0
  49. package/lib/manifest/userApi/v1/integration-fixtures/valid-resolved.manifest.json +14 -0
  50. package/lib/manifest/userApi/v1/userApiManifest.integration.spec.d.ts +1 -0
  51. package/lib/manifest/userApi/v1/userApiManifest.integration.spec.js +201 -0
  52. package/lib/manifest/userApi/v1/userApiManifest.integration.spec.js.map +1 -0
  53. package/lib/manifest/userApi/v1/userApiManifestValidation.d.ts +16 -0
  54. package/lib/manifest/userApi/v1/userApiManifestValidation.js +88 -0
  55. package/lib/manifest/userApi/v1/userApiManifestValidation.js.map +1 -0
  56. package/lib/manifest/userApi/v1/userApiManifestValidation.spec.d.ts +1 -0
  57. package/lib/manifest/userApi/v1/userApiManifestValidation.spec.js +237 -0
  58. package/lib/manifest/userApi/v1/userApiManifestValidation.spec.js.map +1 -0
  59. package/package.json +11 -1
  60. package/src/JsonSchemaService.ts +2 -1
  61. package/src/JsonValidationService.ts +9 -96
  62. package/src/defaultDraftConfig.ts +89 -0
  63. package/src/formatted-text/v1/formattedTextConstants.ts +2 -0
  64. package/src/formatted-text/v1/resolveFormattedTextNodes.ts +3 -2
  65. package/src/index.ts +2 -0
  66. package/src/manifest/userApi/v1/UserApiManifestV1.json +76 -0
  67. package/src/manifest/userApi/v1/UserApiManifestV1.ts +50 -0
  68. package/src/manifest/userApi/v1/index.ts +6 -0
  69. package/src/manifest/userApi/v1/integration-fixtures/invalid-endpoint-extra-public.manifest.json +10 -0
  70. package/src/manifest/userApi/v1/integration-fixtures/invalid-endpoint-handler-hyphen.manifest.json +9 -0
  71. package/src/manifest/userApi/v1/integration-fixtures/invalid-endpoint-handler-leading-digit.manifest.json +9 -0
  72. package/src/manifest/userApi/v1/integration-fixtures/invalid-endpoint-method-invalid.manifest.json +9 -0
  73. package/src/manifest/userApi/v1/integration-fixtures/invalid-endpoint-missing-method.manifest.json +8 -0
  74. package/src/manifest/userApi/v1/integration-fixtures/invalid-endpoint-missing-path.manifest.json +8 -0
  75. package/src/manifest/userApi/v1/integration-fixtures/invalid-full-displayName-empty.manifest.json +16 -0
  76. package/src/manifest/userApi/v1/integration-fixtures/invalid-full-manifest-not-object.manifest.json +8 -0
  77. package/src/manifest/userApi/v1/integration-fixtures/invalid-full-missing-manifest.manifest.json +7 -0
  78. package/src/manifest/userApi/v1/integration-fixtures/invalid-full-missing-schema.manifest.json +15 -0
  79. package/src/manifest/userApi/v1/integration-fixtures/invalid-full-name-empty.manifest.json +16 -0
  80. package/src/manifest/userApi/v1/integration-fixtures/invalid-full-root-extra-property.manifest.json +17 -0
  81. package/src/manifest/userApi/v1/integration-fixtures/invalid-nested-manifest-handlerMap.manifest.json +12 -0
  82. package/src/manifest/userApi/v1/integration-fixtures/invalid-nested-manifest-name.manifest.json +10 -0
  83. package/src/manifest/userApi/v1/integration-fixtures/invalid-resolved-empty-endpoints.manifest.json +4 -0
  84. package/src/manifest/userApi/v1/integration-fixtures/invalid-resolved-no-leading-slash.manifest.json +11 -0
  85. package/src/manifest/userApi/v1/integration-fixtures/valid-full-each-http-method.manifest.json +18 -0
  86. package/src/manifest/userApi/v1/integration-fixtures/valid-full-no-description.manifest.json +16 -0
  87. package/src/manifest/userApi/v1/integration-fixtures/valid-full.manifest.json +22 -0
  88. package/src/manifest/userApi/v1/integration-fixtures/valid-resolved-slash-path.manifest.json +14 -0
  89. package/src/manifest/userApi/v1/integration-fixtures/valid-resolved.manifest.json +14 -0
  90. package/src/manifest/userApi/v1/userApiManifest.integration.spec.ts +265 -0
  91. package/src/manifest/userApi/v1/userApiManifestValidation.spec.ts +311 -0
  92. package/src/manifest/userApi/v1/userApiManifestValidation.ts +54 -0
  93. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,265 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import type { JSONSchema } from '@squiz/json-schema-library';
4
+ import Draft07Schema from '../../v1/Draft-07.json';
5
+ import { JSONSchemaService, SchemaValidationError, TypeResolverBuilder } from '../../..';
6
+
7
+ /** Must align with `integration-fixtures/valid-full.manifest.json` `$schema`. */
8
+ const FULL_MANIFEST_FIXTURE_SCHEMA =
9
+ 'https://unpkg.com/@squiz/dx-json-schema-lib@latest/lib/manifest/userApiManifest/UserApiManifestV1.json';
10
+
11
+ /** Directory containing `UserApiManifestV1.json` (this spec sits alongside those files). */
12
+ const USER_API_SCHEMA_DIR = __dirname;
13
+
14
+ /** `src/manifest/userApi/v1` → package root (four levels up). */
15
+ const PACKAGE_ROOT = path.join(USER_API_SCHEMA_DIR, '..', '..', '..', '..');
16
+
17
+ const draft07Remotes = {
18
+ 'http://json-schema.org/draft-07/schema': Draft07Schema,
19
+ 'http://json-schema.org/draft-07/schema#': Draft07Schema,
20
+ } as const;
21
+
22
+ function loadJson(relPathUnderSrcDir: string): unknown {
23
+ const abs = path.isAbsolute(relPathUnderSrcDir)
24
+ ? relPathUnderSrcDir
25
+ : path.join(USER_API_SCHEMA_DIR, relPathUnderSrcDir);
26
+ return JSON.parse(fs.readFileSync(abs, 'utf8')) as unknown;
27
+ }
28
+
29
+ function fixture(name: string): unknown {
30
+ return loadJson(path.join('integration-fixtures', name));
31
+ }
32
+
33
+ function fixtureRecord(name: string): Record<string, unknown> {
34
+ return fixture(name) as Record<string, unknown>;
35
+ }
36
+
37
+ /** Standalone resolved manifests: validated only after {@link wrapResolvedFixtureAsFull}. */
38
+ const INVALID_WRAPPED_FIXTURES = [
39
+ 'invalid-nested-manifest-name.manifest.json',
40
+ 'invalid-nested-manifest-handlerMap.manifest.json',
41
+ 'invalid-endpoint-extra-public.manifest.json',
42
+ 'invalid-endpoint-handler-leading-digit.manifest.json',
43
+ 'invalid-endpoint-handler-hyphen.manifest.json',
44
+ 'invalid-endpoint-method-invalid.manifest.json',
45
+ 'invalid-endpoint-missing-method.manifest.json',
46
+ 'invalid-endpoint-missing-path.manifest.json',
47
+ 'invalid-resolved-empty-endpoints.manifest.json',
48
+ 'invalid-resolved-no-leading-slash.manifest.json',
49
+ ] as const;
50
+
51
+ /** Full top-level documents consumed directly by {@link JSONSchemaService.validateInput}. */
52
+ const INVALID_FULL_MANIFEST_FIXTURES = [
53
+ 'invalid-full-root-extra-property.manifest.json',
54
+ 'invalid-full-name-empty.manifest.json',
55
+ 'invalid-full-displayName-empty.manifest.json',
56
+ 'invalid-full-missing-schema.manifest.json',
57
+ 'invalid-full-missing-manifest.manifest.json',
58
+ 'invalid-full-manifest-not-object.manifest.json',
59
+ ] as const;
60
+
61
+ type FixtureEndpoint = {
62
+ path: string;
63
+ method: string;
64
+ handler: string;
65
+ };
66
+
67
+ /** Non-schema regression check: validators must not omit or reorder fixture endpoint fields. */
68
+ function expectFixtureEndpointsUnchanged(validated: unknown) {
69
+ const data = validated as { manifest?: { endpoints: FixtureEndpoint[] }; endpoints?: FixtureEndpoint[] };
70
+ const endpoints = data.manifest?.endpoints ?? data.endpoints;
71
+ expect(endpoints).toHaveLength(2);
72
+
73
+ expect(endpoints!).toContainEqual({
74
+ path: '/integration/hello',
75
+ method: 'GET',
76
+ handler: 'index.helloWorld',
77
+ });
78
+ expect(endpoints!).toContainEqual({
79
+ path: '/integration/items',
80
+ method: 'POST',
81
+ handler: 'handlers.createItem',
82
+ });
83
+ }
84
+
85
+ /** Wrap standalone resolved fixture JSON under a minimal UserApiManifestV1 root (only full manifest is validated publicly). */
86
+ function wrapResolvedFixtureAsFull(resolved: Record<string, unknown>) {
87
+ return {
88
+ $schema: FULL_MANIFEST_FIXTURE_SCHEMA,
89
+ name: typeof resolved.name === 'string' ? resolved.name : 'wrapped-api',
90
+ displayName: 'Integration fixture envelope',
91
+ description: '',
92
+ filesDir: 'src',
93
+ entry: 'index.ts',
94
+ manifest: resolved,
95
+ };
96
+ }
97
+
98
+ function createFullManifestValidator(schemaFromDisk: JSONSchema) {
99
+ const resolver = TypeResolverBuilder.new().build();
100
+ return new JSONSchemaService(resolver, {
101
+ root: schemaFromDisk,
102
+ remotes: draft07Remotes,
103
+ });
104
+ }
105
+
106
+ describe('user-api JSON schemas (integration, filesystem-backed)', () => {
107
+ describe('published schema documents on disk', () => {
108
+ it('parses UserApiManifestV1.json and exposes nested resolved manifest definition', () => {
109
+ const schema = loadJson('UserApiManifestV1.json') as Record<string, unknown>;
110
+ expect(schema.$schema).toBe('http://json-schema.org/draft-07/schema#');
111
+ expect(schema.title).toBe('UserApiManifestV1');
112
+ expect(schema.additionalProperties).toBe(false);
113
+
114
+ expect(schema.required).toEqual(
115
+ expect.arrayContaining(['$schema', 'name', 'displayName', 'filesDir', 'entry', 'manifest']),
116
+ );
117
+
118
+ const { resolvedApiManifest } = schema.definitions as {
119
+ resolvedApiManifest: {
120
+ properties: Record<string, unknown>;
121
+ required: string[];
122
+ };
123
+ };
124
+ expect(resolvedApiManifest.required).toEqual(expect.arrayContaining(['endpoints']));
125
+
126
+ const rootProps = schema.properties as { manifest: { $ref?: string } };
127
+ expect(rootProps.manifest.$ref).toBe('#/definitions/resolvedApiManifest');
128
+
129
+ expect(resolvedApiManifest.properties).toHaveProperty('endpoints');
130
+ expect(Object.keys(resolvedApiManifest.properties ?? {})).toEqual(['endpoints']);
131
+ expect(resolvedApiManifest.required).toEqual(['endpoints']);
132
+ });
133
+
134
+ it('UserApiManifestV1 endpoint items require path, method, handler only and forbid extra endpoint keys', () => {
135
+ const schema = loadJson('UserApiManifestV1.json') as {
136
+ definitions: {
137
+ resolvedApiManifest: {
138
+ properties: {
139
+ endpoints: {
140
+ items: {
141
+ required: string[];
142
+ additionalProperties?: boolean;
143
+ properties?: Record<string, unknown>;
144
+ };
145
+ };
146
+ };
147
+ };
148
+ };
149
+ };
150
+ const items = schema.definitions.resolvedApiManifest.properties.endpoints.items;
151
+ expect(items.required).toEqual(['path', 'method', 'handler']);
152
+ expect(items.additionalProperties).toBe(false);
153
+ expect(items.properties).toMatchObject({
154
+ path: expect.objectContaining({ type: 'string' }),
155
+ method: expect.objectContaining({ type: 'string' }),
156
+ handler: expect.objectContaining({ type: 'string' }),
157
+ });
158
+ expect(Object.keys(items.properties ?? {})).toHaveLength(3);
159
+ });
160
+
161
+ it('UserApiManifestV1 nested resolved manifest forbids arbitrary root keys (endpoints only)', () => {
162
+ const schema = loadJson('UserApiManifestV1.json') as {
163
+ definitions: { resolvedApiManifest: { additionalProperties?: boolean } };
164
+ };
165
+ expect(schema.definitions.resolvedApiManifest.additionalProperties).toBe(false);
166
+ });
167
+ });
168
+
169
+ describe('validation pipeline using schemas read from filesystem (not TS imports)', () => {
170
+ const fullManifestSchemaRoot = loadJson('UserApiManifestV1.json') as JSONSchema;
171
+ const fullValidator = createFullManifestValidator(fullManifestSchemaRoot);
172
+ const validResolved = fixtureRecord('valid-resolved.manifest.json');
173
+
174
+ function expectRejectWhenWrapped(filename: string) {
175
+ expect(() => fullValidator.validateInput(wrapResolvedFixtureAsFull(fixtureRecord(filename)))).toThrow(
176
+ SchemaValidationError,
177
+ );
178
+ }
179
+
180
+ function expectRejectFullDocument(blob: unknown) {
181
+ expect(() => fullValidator.validateInput(blob)).toThrow(SchemaValidationError);
182
+ }
183
+
184
+ it.each(INVALID_WRAPPED_FIXTURES)('rejects wrapped nested manifest from %s', (filename) => {
185
+ expectRejectWhenWrapped(filename);
186
+ });
187
+
188
+ it.each(INVALID_FULL_MANIFEST_FIXTURES)('rejects full manifest document %s', (filename) => {
189
+ expectRejectFullDocument(fixture(filename));
190
+ });
191
+
192
+ it('accepts realistic full manifest fixture aligned with nested endpoint schema', () => {
193
+ const blob = fixture('valid-full.manifest.json');
194
+ expect(fullValidator.validateInput(blob)).toBe(true);
195
+ expectFixtureEndpointsUnchanged(blob);
196
+ });
197
+
198
+ it('accepts valid-resolved fixture when wrapped under a minimal full manifest envelope', () => {
199
+ const wrapped = wrapResolvedFixtureAsFull(validResolved);
200
+ expect(fullValidator.validateInput(wrapped)).toBe(true);
201
+ expectFixtureEndpointsUnchanged(wrapped);
202
+ });
203
+
204
+ it('valid-resolved fixture is endpoints-only at nested root (no manifest.name)', () => {
205
+ expect(Array.isArray(validResolved.endpoints)).toBe(true);
206
+ });
207
+
208
+ it('accepts api-builder-style paths (wildcard and :param) using disk-backed schema', () => {
209
+ const wrapped = wrapResolvedFixtureAsFull({
210
+ endpoints: [
211
+ { path: '/hello/*', method: 'GET', handler: 'hello' },
212
+ { path: '/user/:id/:type', method: 'GET', handler: 'user' },
213
+ ],
214
+ });
215
+ expect(fullValidator.validateInput(wrapped)).toBe(true);
216
+ });
217
+
218
+ it('accepts alternate $schema URL on envelope (filesystem validation path)', () => {
219
+ const blob = {
220
+ $schema: 'https://raw.githubusercontent.com/user/api-builder-prototype/main/schemas/api-builder.schema.json',
221
+ name: 'integration-proto',
222
+ displayName: 'Proto',
223
+ filesDir: 'src',
224
+ entry: 'index.ts',
225
+ manifest: {
226
+ endpoints: [{ path: '/health', method: 'GET', handler: 'health' }],
227
+ },
228
+ };
229
+ expect(fullValidator.validateInput(blob)).toBe(true);
230
+ });
231
+
232
+ it('accepts full manifest when optional description is omitted', () => {
233
+ expect(fullValidator.validateInput(fixture('valid-full-no-description.manifest.json'))).toBe(true);
234
+ });
235
+
236
+ it('accepts one endpoint per allowed HTTP method value', () => {
237
+ const blob = fixture('valid-full-each-http-method.manifest.json') as {
238
+ manifest: { endpoints: { method: string }[] };
239
+ };
240
+ expect(fullValidator.validateInput(blob)).toBe(true);
241
+ expect(blob.manifest.endpoints.map((e) => e.method).sort()).toEqual(
242
+ ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'].sort(),
243
+ );
244
+ });
245
+
246
+ it('accepts minimal path "/" in wrapped resolved fixture alongside another endpoint', () => {
247
+ const wrapped = wrapResolvedFixtureAsFull(fixtureRecord('valid-resolved-slash-path.manifest.json'));
248
+ expect(fullValidator.validateInput(wrapped)).toBe(true);
249
+ const endpoints = (wrapped.manifest as { endpoints: { path: string }[] }).endpoints;
250
+ expect(endpoints.some((e) => e.path === '/')).toBe(true);
251
+ });
252
+ });
253
+
254
+ describe('artifacts under lib/ after compile (when present)', () => {
255
+ const srcFull = path.join(USER_API_SCHEMA_DIR, 'UserApiManifestV1.json');
256
+ const libFull = path.join(PACKAGE_ROOT, 'lib', 'manifest', 'userApi', 'v1', 'UserApiManifestV1.json');
257
+
258
+ const libExists = fs.existsSync(libFull);
259
+ const itOrSkip = libExists ? it : it.skip;
260
+
261
+ itOrSkip('copied lib UserApiManifestV1.json payload matches src', () => {
262
+ expect(loadJson(libFull)).toEqual(loadJson(srcFull));
263
+ });
264
+ });
265
+ });
@@ -0,0 +1,311 @@
1
+ import {
2
+ USER_API_FULL_MANIFEST_SCHEMA,
3
+ USER_API_MANIFEST_SCHEMA_URL,
4
+ validateUserApiFullManifest,
5
+ } from './userApiManifestValidation';
6
+ import { SchemaValidationError } from '../../../errors/SchemaValidationError';
7
+
8
+ describe('user API manifest validation', () => {
9
+ const validResolved = {
10
+ endpoints: [{ path: '/hello', method: 'GET', handler: 'index.hello' }],
11
+ };
12
+
13
+ const validFull = {
14
+ $schema: USER_API_MANIFEST_SCHEMA_URL,
15
+ name: 'api',
16
+ displayName: 'API',
17
+ description: '',
18
+ filesDir: 'src',
19
+ entry: 'index.ts',
20
+ manifest: validResolved,
21
+ };
22
+
23
+ /** Resolves `#/definitions/resolvedApiManifest` via the full-manifest root schema only. */
24
+ const wrapResolved = (manifestNested: Record<string, unknown>) => ({
25
+ ...validFull,
26
+ manifest: manifestNested,
27
+ });
28
+
29
+ const endpoint = (
30
+ overrides: Partial<{
31
+ path: string;
32
+ method: string;
33
+ handler: string;
34
+ }> = {},
35
+ ) => ({
36
+ path: '/r',
37
+ method: 'GET',
38
+ handler: 'handler',
39
+ ...overrides,
40
+ });
41
+
42
+ const expectResolvedNestedThrows = (manifestNested: Record<string, unknown>) => {
43
+ expect(() => validateUserApiFullManifest(wrapResolved(manifestNested))).toThrow(SchemaValidationError);
44
+ };
45
+
46
+ const expectFullThrows = (input: unknown) => {
47
+ expect(() => validateUserApiFullManifest(input)).toThrow(SchemaValidationError);
48
+ };
49
+
50
+ it('surfaces bundled full manifest schema for CDN / tooling', () => {
51
+ expect(USER_API_FULL_MANIFEST_SCHEMA.type).toBe('object');
52
+ expect(typeof USER_API_MANIFEST_SCHEMA_URL).toBe('string');
53
+ expect(USER_API_MANIFEST_SCHEMA_URL).toContain('dx-json-schema-lib');
54
+ expect(USER_API_MANIFEST_SCHEMA_URL).toContain('manifest/userApiManifest');
55
+ });
56
+
57
+ describe('nested resolvedApiManifest rules (validated only through validateUserApiFullManifest)', () => {
58
+ it('accepts canonical resolved manifest when nested under manifest', () => {
59
+ expect(validateUserApiFullManifest(wrapResolved(validResolved))).toBe(true);
60
+ });
61
+
62
+ it.each(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const)(
63
+ 'accepts HTTP method %s',
64
+ (method) => {
65
+ expect(
66
+ validateUserApiFullManifest(
67
+ wrapResolved({
68
+ endpoints: [endpoint({ path: `/m-${method}`, method })],
69
+ }),
70
+ ),
71
+ ).toBe(true);
72
+ },
73
+ );
74
+
75
+ it('accepts multiple endpoints without extra endpoint fields', () => {
76
+ expect(
77
+ validateUserApiFullManifest(
78
+ wrapResolved({
79
+ endpoints: [
80
+ { path: '/a', method: 'GET', handler: 'a' },
81
+ { path: '/b', method: 'POST', handler: 'mod.b' },
82
+ ],
83
+ }),
84
+ ),
85
+ ).toBe(true);
86
+ });
87
+
88
+ it('rejects handlerMap on nested manifest (endpoints only)', () => {
89
+ expectResolvedNestedThrows({
90
+ endpoints: [endpoint()],
91
+ handlerMap: { 'index.hello': './handlers/hello.ts' },
92
+ });
93
+ });
94
+
95
+ it('accepts handlers using $ and nested module paths', () => {
96
+ expect(
97
+ validateUserApiFullManifest(
98
+ wrapResolved({
99
+ endpoints: [
100
+ endpoint({ handler: '$default' }),
101
+ endpoint({ handler: 'ns.deep_export' }),
102
+ endpoint({ handler: '_private' }),
103
+ ],
104
+ }),
105
+ ),
106
+ ).toBe(true);
107
+ });
108
+
109
+ it('accepts path with only slash', () => {
110
+ expect(validateUserApiFullManifest(wrapResolved({ endpoints: [endpoint({ path: '/' })] }))).toBe(true);
111
+ });
112
+
113
+ it('accepts api-builder prototype document (alternate $schema, endpoints-only nested manifest)', () => {
114
+ expect(
115
+ validateUserApiFullManifest({
116
+ $schema: 'https://raw.githubusercontent.com/user/api-builder-prototype/main/schemas/api-builder.schema.json',
117
+ name: 'test-api',
118
+ displayName: 'Test Api',
119
+ description: 'An API to test the proxy endpoint',
120
+ entry: 'index.ts',
121
+ filesDir: 'src',
122
+ manifest: {
123
+ endpoints: [
124
+ { path: '/hello/*', method: 'GET', handler: 'hello' },
125
+ { path: '/add', method: 'GET', handler: 'add' },
126
+ { path: '/user/:id/:type', method: 'GET', handler: 'user' },
127
+ { path: '/proxy', method: 'GET', handler: 'proxy' },
128
+ { path: '/health', method: 'GET', handler: 'health' },
129
+ ],
130
+ },
131
+ }),
132
+ ).toBe(true);
133
+ });
134
+
135
+ it('rejects empty endpoints array', () => {
136
+ expectResolvedNestedThrows({ endpoints: [] });
137
+ expectFullThrows({
138
+ ...validFull,
139
+ manifest: { endpoints: [] },
140
+ });
141
+ });
142
+
143
+ it('accepts nested manifest with only endpoints (no nested name)', () => {
144
+ expect(
145
+ validateUserApiFullManifest(
146
+ wrapResolved({
147
+ endpoints: [endpoint()],
148
+ }),
149
+ ),
150
+ ).toBe(true);
151
+ });
152
+
153
+ it('rejects nested manifest name property (nested root allows only endpoints)', () => {
154
+ expectResolvedNestedThrows({ name: 'nested-id', endpoints: [endpoint()] });
155
+ expectResolvedNestedThrows({ name: '', endpoints: [endpoint()] });
156
+ });
157
+
158
+ it('rejects missing endpoints on nested manifest', () => {
159
+ expectResolvedNestedThrows({ name: 'only-name' });
160
+ });
161
+
162
+ it('rejects non-array endpoints on nested manifest', () => {
163
+ expectResolvedNestedThrows({ endpoints: {} });
164
+ });
165
+
166
+ it('rejects nested manifest root additionalProperties', () => {
167
+ expectResolvedNestedThrows({ ...validResolved, extraKey: true });
168
+ });
169
+
170
+ it('rejects extra properties on an endpoint object', () => {
171
+ expectResolvedNestedThrows({
172
+ endpoints: [{ ...endpoint(), unknownFlag: true }],
173
+ });
174
+ });
175
+
176
+ it.each(['path', 'method', 'handler'] as const)('rejects endpoint missing required field %s', (field) => {
177
+ const ep = endpoint();
178
+ const partial = Object.fromEntries(Object.entries(ep).filter(([k]) => k !== field)) as Record<string, unknown>;
179
+ expectResolvedNestedThrows({
180
+ endpoints: [partial],
181
+ });
182
+ });
183
+
184
+ it('accepts minimal endpoint objects (only path/method/handler)', () => {
185
+ expect(
186
+ validateUserApiFullManifest(
187
+ wrapResolved({
188
+ endpoints: [{ path: '/x', method: 'GET', handler: 'h' }],
189
+ }),
190
+ ),
191
+ ).toBe(true);
192
+ });
193
+
194
+ it('rejects paths without a leading slash', () => {
195
+ expectResolvedNestedThrows({
196
+ endpoints: [endpoint({ path: 'no-leading-slash' })],
197
+ });
198
+ });
199
+
200
+ it('rejects invalid HTTP methods', () => {
201
+ expectResolvedNestedThrows({
202
+ endpoints: [endpoint({ method: 'TRACE' })],
203
+ });
204
+ expectResolvedNestedThrows({
205
+ endpoints: [endpoint({ method: 'get' })],
206
+ });
207
+ });
208
+
209
+ it.each([
210
+ { handler: '', reason: 'empty handler' },
211
+ { handler: '9start', reason: 'leading digit' },
212
+ { handler: 'bad-handler', reason: 'hyphen' },
213
+ { handler: 'a b', reason: 'space' },
214
+ { handler: '😀', reason: 'non-ASCII' },
215
+ ])('rejects invalid handler ($reason)', ({ handler }) => {
216
+ expectResolvedNestedThrows({
217
+ endpoints: [endpoint({ handler })],
218
+ });
219
+ });
220
+
221
+ it('rejects invalid nested manifest object shapes', () => {
222
+ expectFullThrows(wrapResolved({} as Record<string, unknown>));
223
+ expectFullThrows({
224
+ ...validFull,
225
+ manifest: [] as unknown,
226
+ });
227
+ expectFullThrows({
228
+ ...validFull,
229
+ manifest: 'nested' as unknown,
230
+ });
231
+ });
232
+ });
233
+
234
+ describe('validateUserApiFullManifest (root document)', () => {
235
+ it('accepts canonical full manifest', () => {
236
+ expect(validateUserApiFullManifest(validFull)).toBe(true);
237
+ });
238
+
239
+ it('accepts omission of optional description', () => {
240
+ const { description: _d, ...noDesc } = validFull;
241
+ expect(validateUserApiFullManifest(noDesc)).toBe(true);
242
+ });
243
+
244
+ it('accepts endpoints-only nested manifest when root declares name', () => {
245
+ expect(
246
+ validateUserApiFullManifest({
247
+ ...validFull,
248
+ name: 'root-name',
249
+ manifest: {
250
+ endpoints: [{ path: '/e', method: 'GET', handler: 'eh' }],
251
+ },
252
+ }),
253
+ ).toBe(true);
254
+ });
255
+
256
+ it('rejects missing root $schema', () => {
257
+ const { $schema: _drop, ...withoutSchema } = validFull;
258
+ expectFullThrows(withoutSchema);
259
+ });
260
+
261
+ it('rejects empty $schema string', () => {
262
+ expectFullThrows({ ...validFull, $schema: '' });
263
+ });
264
+
265
+ it.each([['name'], ['displayName'], ['filesDir'], ['entry'], ['manifest']] as const)(
266
+ 'rejects full manifest missing required field %s',
267
+ (field) => {
268
+ const { [field]: _removed, ...rest } = validFull as Record<string, unknown>;
269
+ expectFullThrows(rest);
270
+ },
271
+ );
272
+
273
+ it.each([
274
+ ['name', ''],
275
+ ['displayName', ''],
276
+ ['filesDir', ''],
277
+ ['entry', ''],
278
+ ] as const)('rejects empty string for %s (minLength 1)', (field, value) => {
279
+ expectFullThrows({
280
+ ...validFull,
281
+ [field]: value,
282
+ });
283
+ });
284
+
285
+ it('rejects root additionalProperties on full manifest', () => {
286
+ expectFullThrows({ ...validFull, version: '1' });
287
+ });
288
+
289
+ it('rejects nested manifest validation failures (delegates to resolved definition)', () => {
290
+ expectFullThrows({
291
+ ...validFull,
292
+ manifest: {
293
+ endpoints: [endpoint(), endpoint({ handler: '$default', path: '/other' })],
294
+ badRootKey: true,
295
+ },
296
+ });
297
+ expectFullThrows({
298
+ ...validFull,
299
+ manifest: { endpoints: [] },
300
+ });
301
+ });
302
+
303
+ it('rejects primitives instead of manifest object root', () => {
304
+ expectFullThrows(null);
305
+ expectFullThrows(undefined);
306
+ expectFullThrows([]);
307
+ expectFullThrows('manifest');
308
+ expectFullThrows({});
309
+ });
310
+ });
311
+ });
@@ -0,0 +1,54 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import Draft07Schema from '../../v1/Draft-07.json';
4
+ import UserApiManifestV1 from './UserApiManifestV1.json';
5
+ import { JSONSchemaService } from '../../../JsonSchemaService';
6
+ import { TypeResolverBuilder } from '../../../jsonTypeResolution/TypeResolverBuilder';
7
+
8
+ const pkgVersion = ((): string | undefined => {
9
+ try {
10
+ const pkgPath = path.join(__dirname, '..', '..', '..', '..', 'package.json');
11
+ const raw = fs.readFileSync(pkgPath, 'utf8');
12
+ return (JSON.parse(raw) as { version?: string }).version;
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ })();
17
+
18
+ const draft07Remotes = {
19
+ 'http://json-schema.org/draft-07/schema': Draft07Schema,
20
+ 'http://json-schema.org/draft-07/schema#': Draft07Schema,
21
+ } as const;
22
+
23
+ const fullManifestMetaSchema = {
24
+ root: UserApiManifestV1,
25
+ remotes: draft07Remotes,
26
+ } as const;
27
+
28
+ const resolver = TypeResolverBuilder.new().build();
29
+
30
+ const fullManifestValidator = new JSONSchemaService(resolver, fullManifestMetaSchema);
31
+
32
+ /**
33
+ * Recommended `$schema` URL for the top-level DXP User API `manifest.json`.
34
+ *
35
+ * Published at {@link USER_API_FULL_MANIFEST_SCHEMA} under `lib/manifest/userApiManifest/UserApiManifestV1.json`.
36
+ */
37
+ export const USER_API_MANIFEST_SCHEMA_URL =
38
+ typeof pkgVersion === 'string'
39
+ ? `https://unpkg.com/@squiz/dx-json-schema-lib@${pkgVersion}/lib/manifest/userApiManifest/UserApiManifestV1.json`
40
+ : 'https://unpkg.com/@squiz/dx-json-schema-lib@latest/lib/manifest/userApiManifest/UserApiManifestV1.json';
41
+
42
+ /**
43
+ * JSON Schema bundle validating the full on-disk manifest (CLI / zip upload).
44
+ */
45
+ export { default as USER_API_FULL_MANIFEST_SCHEMA } from './UserApiManifestV1.json';
46
+
47
+ /**
48
+ * Validate the User API manifest before upload.
49
+ *
50
+ * @throws {SchemaValidationError} when validation fails.
51
+ */
52
+ export function validateUserApiFullManifest(input: unknown): true {
53
+ return fullManifestValidator.validateInput(input);
54
+ }