@squiz/dx-json-schema-lib 1.12.0-alpha.46 → 1.12.0-alpha.48

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. package/.npm/_logs/{2023-02-24T01_14_03_380Z-debug-0.log → 2023-02-24T06_22_01_362Z-debug-0.log} +12 -12
  2. package/lib/JsonValidationService.d.ts +33 -0
  3. package/lib/JsonValidationService.js +101 -1
  4. package/lib/JsonValidationService.js.map +1 -1
  5. package/lib/JsonValidationService.spec.js +168 -0
  6. package/lib/JsonValidationService.spec.js.map +1 -1
  7. package/lib/errors/JsonResolutionError.d.ts +5 -0
  8. package/lib/errors/JsonResolutionError.js +12 -0
  9. package/lib/errors/JsonResolutionError.js.map +1 -0
  10. package/lib/index.d.ts +2 -0
  11. package/lib/index.js +2 -0
  12. package/lib/index.js.map +1 -1
  13. package/lib/jsonTypeResolution/arbitraryTypeResolution.d.ts +61 -0
  14. package/lib/jsonTypeResolution/arbitraryTypeResolution.js +61 -0
  15. package/lib/jsonTypeResolution/arbitraryTypeResolution.js.map +1 -0
  16. package/lib/jsonTypeResolution/arbitraryTypeResolution.spec.d.ts +1 -0
  17. package/lib/jsonTypeResolution/arbitraryTypeResolution.spec.js +100 -0
  18. package/lib/jsonTypeResolution/arbitraryTypeResolution.spec.js.map +1 -0
  19. package/lib/jsonTypeResolution/index.d.ts +76 -0
  20. package/lib/jsonTypeResolution/index.js +35 -0
  21. package/lib/jsonTypeResolution/index.js.map +1 -0
  22. package/lib/jsonTypeResolution/primitiveTypes.d.ts +10 -0
  23. package/lib/jsonTypeResolution/primitiveTypes.js +27 -0
  24. package/lib/jsonTypeResolution/primitiveTypes.js.map +1 -0
  25. package/lib/jsonTypeResolution/resolvableTypes.d.ts +12 -0
  26. package/lib/jsonTypeResolution/resolvableTypes.js +30 -0
  27. package/lib/jsonTypeResolution/resolvableTypes.js.map +1 -0
  28. package/package.json +4 -3
  29. package/src/JsonValidationService.spec.ts +280 -1
  30. package/src/JsonValidationService.ts +127 -1
  31. package/src/errors/JsonResolutionError.ts +5 -0
  32. package/src/index.ts +3 -0
  33. package/src/jsonTypeResolution/arbitraryTypeResolution.spec.ts +134 -0
  34. package/src/jsonTypeResolution/arbitraryTypeResolution.ts +100 -0
  35. package/src/jsonTypeResolution/index.ts +16 -0
  36. package/src/jsonTypeResolution/primitiveTypes.ts +32 -0
  37. package/src/jsonTypeResolution/resolvableTypes.ts +37 -0
  38. package/tsconfig.tsbuildinfo +1 -1
@@ -1,9 +1,11 @@
1
- import { JsonValidationService } from './JsonValidationService';
1
+ import { ComponentInputMetaSchema, JSONSchemaService, JsonValidationService } from './JsonValidationService';
2
2
  import { SchemaValidationError } from './errors/SchemaValidationError';
3
3
 
4
4
  import validManifest from './manifest/v1/__test__/schemas/validComponent.json';
5
5
  import validManifestJson from './manifest/v1/__test__/schemas/validComponentJson.json';
6
6
  import { FormattedText } from './formatted-text/v1/formattedText';
7
+ import { JSONSchema } from 'json-schema-library';
8
+ import { PrimitiveType, ResolvableType, TypeResolver } from './jsonTypeResolution/arbitraryTypeResolution';
7
9
 
8
10
  // eslint-disable-next-line @typescript-eslint/ban-types
9
11
  function expectToThrowErrorMatchingTypeAndMessage(received: Function, errorType: Function, message: string) {
@@ -656,3 +658,280 @@ describe('JsonValidationService', () => {
656
658
  });
657
659
  });
658
660
  });
661
+
662
+ const defaultSchema: JSONSchema = {
663
+ type: 'object',
664
+ properties: {
665
+ myProperty: {
666
+ type: 'string',
667
+ },
668
+ },
669
+ required: ['myProperty'],
670
+ };
671
+ function primitiveTypeFixture<T extends string>(title: T, schema: JSONSchema = defaultSchema) {
672
+ return PrimitiveType({
673
+ ...schema,
674
+ title,
675
+ });
676
+ }
677
+
678
+ function resolvableTypeFixture<T extends string>(title: T, schema: JSONSchema = defaultSchema) {
679
+ return ResolvableType({
680
+ ...schema,
681
+ title,
682
+ });
683
+ }
684
+
685
+ describe('JsonSchemaService', () => {
686
+ describe('validateInput', () => {
687
+ it.each([String('123'), Number(123), [123]])(
688
+ 'should validate any primitive type with its resolvable type %s',
689
+ (propertyValue) => {
690
+ const primitiveSchema = primitiveTypeFixture('MyPrimitive', { type: 'string' });
691
+ const jsonSchemaService = new JSONSchemaService(
692
+ new TypeResolver(
693
+ {
694
+ MyPrimitive: primitiveSchema,
695
+ },
696
+ {
697
+ MyResolvableNumber: resolvableTypeFixture('MyResolvableNumber', { type: 'number' }),
698
+ MyResolvableArray: resolvableTypeFixture('MyResolvableArray', { type: 'array' }),
699
+ },
700
+ {
701
+ MyPrimitive: {
702
+ MyResolvableNumber: (value: number) => value.toString(),
703
+ MyResolvableArray: (value: any[]) => value.join(''),
704
+ },
705
+ },
706
+ ),
707
+ ComponentInputMetaSchema,
708
+ );
709
+
710
+ expect(
711
+ jsonSchemaService.validateInput(
712
+ { myProperty: propertyValue },
713
+ { type: 'object', properties: { myProperty: { type: 'MyPrimitive' } } },
714
+ ),
715
+ ).toEqual(true);
716
+ },
717
+ );
718
+
719
+ it('should error when a primitive type is provided a value that cannot be resolved by its resolvable types', () => {
720
+ const primitiveSchema = primitiveTypeFixture('MyPrimitive', { type: 'string' });
721
+ const jsonSchemaService = new JSONSchemaService(
722
+ new TypeResolver(
723
+ {
724
+ MyPrimitive: primitiveSchema,
725
+ },
726
+ {
727
+ MyResolvableNumber: resolvableTypeFixture('MyResolvableNumber', { type: 'number' }),
728
+ MyResolvableArray: resolvableTypeFixture('MyResolvableArray', { type: 'array' }),
729
+ },
730
+ {
731
+ MyPrimitive: {
732
+ MyResolvableNumber: (value: number) => value.toString(),
733
+ MyResolvableArray: (value: any[]) => value.join(''),
734
+ },
735
+ },
736
+ ),
737
+ ComponentInputMetaSchema,
738
+ );
739
+
740
+ expect(() => {
741
+ jsonSchemaService.validateInput(
742
+ { myProperty: true },
743
+ { type: 'object', properties: { myProperty: { type: 'MyPrimitive' } } },
744
+ );
745
+ }).toThrowError();
746
+ });
747
+
748
+ it.each([String('123'), Number(123), [123]])(
749
+ 'should validate a primitive type when defined as a ref with resolvable value %s',
750
+ (propertyValue) => {
751
+ const primitiveSchema = primitiveTypeFixture('MyPrimitive', { type: 'string' });
752
+ const jsonSchemaService = new JSONSchemaService(
753
+ new TypeResolver(
754
+ {
755
+ MyPrimitive: primitiveSchema,
756
+ },
757
+ {
758
+ MyResolvableNumber: resolvableTypeFixture('MyResolvableNumber', { type: 'number' }),
759
+ MyResolvableArray: resolvableTypeFixture('MyResolvableArray', { type: 'array' }),
760
+ },
761
+ {
762
+ MyPrimitive: {
763
+ MyResolvableNumber: (value: number) => value.toString(),
764
+ MyResolvableArray: (value: any[]) => value.join(''),
765
+ },
766
+ },
767
+ ),
768
+ ComponentInputMetaSchema,
769
+ );
770
+
771
+ expect(
772
+ jsonSchemaService.validateInput(
773
+ { myProperty: propertyValue },
774
+ {
775
+ type: 'object',
776
+ properties: { myProperty: { $ref: '#/definitions/Ref' } },
777
+ definitions: { Ref: { type: 'MyPrimitive' } },
778
+ },
779
+ ),
780
+ ).toEqual(true);
781
+ },
782
+ );
783
+
784
+ it('should not validate on a primitive type against a resolvable type when a resolver is not defined', () => {
785
+ const primitiveSchema = primitiveTypeFixture('MyPrimitive', { type: 'string' });
786
+ const jsonSchemaService = new JSONSchemaService(
787
+ new TypeResolver(
788
+ {
789
+ MyPrimitive: primitiveSchema,
790
+ },
791
+ {
792
+ MyResolvableNumber: resolvableTypeFixture('MyResolvableNumber', { type: 'number' }),
793
+ MyResolvableArray: resolvableTypeFixture('MyResolvableArray', { type: 'array' }),
794
+ },
795
+ {
796
+ MyPrimitive: {
797
+ MyResolvableNumber: (value: number) => value.toString(),
798
+ },
799
+ },
800
+ ),
801
+ ComponentInputMetaSchema,
802
+ );
803
+
804
+ expect(() => {
805
+ jsonSchemaService.validateInput(
806
+ { myProperty: [123] },
807
+ { type: 'object', properties: { myProperty: { type: 'MyPrimitive' } } },
808
+ );
809
+ }).toThrowError();
810
+ });
811
+
812
+ it('should validate a primitive type against similar but different resolvable types', () => {
813
+ const primitiveSchema = primitiveTypeFixture('MyPrimitive', { type: 'string' });
814
+ const jsonSchemaService = new JSONSchemaService(
815
+ new TypeResolver(
816
+ {
817
+ MyPrimitive: primitiveSchema,
818
+ },
819
+ {
820
+ MyResolvableSrcNumber: resolvableTypeFixture('MyResolvableSrcNumber', {
821
+ type: 'object',
822
+ properties: {
823
+ src: { type: 'number' },
824
+ },
825
+ }),
826
+ MyResolvableSrcString: resolvableTypeFixture('MyResolvableSrcString', {
827
+ type: 'object',
828
+ properties: {
829
+ src: { type: 'string' },
830
+ },
831
+ }),
832
+ },
833
+ {
834
+ MyPrimitive: {
835
+ MyResolvableSrcNumber: (value: { src: number }) => value.src.toString(),
836
+ MyResolvableSrcString: (value: { src: string }) => value.src,
837
+ },
838
+ },
839
+ ),
840
+ ComponentInputMetaSchema,
841
+ );
842
+
843
+ expect(
844
+ jsonSchemaService.validateInput(
845
+ {
846
+ myProperty: {
847
+ src: 123,
848
+ },
849
+ },
850
+ { type: 'object', properties: { myProperty: { type: 'MyPrimitive' } } },
851
+ ),
852
+ ).toEqual(true);
853
+ expect(
854
+ jsonSchemaService.validateInput(
855
+ {
856
+ myProperty: {
857
+ src: '123',
858
+ },
859
+ },
860
+ { type: 'object', properties: { myProperty: { type: 'MyPrimitive' } } },
861
+ ),
862
+ ).toEqual(true);
863
+ });
864
+ });
865
+
866
+ describe('resolveInput', () => {
867
+ it.each([String('123'), Number(123), [123]])(
868
+ 'should resolve a primitive type from its resolvable type %s',
869
+ async (resolvableValue) => {
870
+ const primitiveSchema = primitiveTypeFixture('MyPrimitive', { type: 'string' });
871
+ const jsonSchemaService = new JSONSchemaService(
872
+ new TypeResolver(
873
+ {
874
+ MyPrimitive: primitiveSchema,
875
+ },
876
+ {
877
+ MyResolvableNumber: resolvableTypeFixture('MyResolvableNumber', { type: 'number' }),
878
+ MyResolvableArray: resolvableTypeFixture('MyResolvableArray', { type: 'array' }),
879
+ },
880
+ {
881
+ MyPrimitive: {
882
+ MyResolvableNumber: (value: number) => value.toString(),
883
+ MyResolvableArray: (value: any[]) => value.join(''),
884
+ },
885
+ },
886
+ ),
887
+ ComponentInputMetaSchema,
888
+ );
889
+
890
+ await expect(
891
+ jsonSchemaService.resolveInput(
892
+ { myProperty: resolvableValue },
893
+ { type: 'object', properties: { myProperty: { type: 'MyPrimitive' } } },
894
+ ),
895
+ ).resolves.toEqual({ myProperty: '123' });
896
+ },
897
+ );
898
+
899
+ it.each([
900
+ [{ src: 'MyString' }, 'MyString'],
901
+ [{ src: 1132 }, '1132'],
902
+ ])('should resolve a resolvable type %s against the correct resolver to %s', async (resolvableValue, output) => {
903
+ const primitiveSchema = primitiveTypeFixture('MyPrimitive', { type: 'string' });
904
+ const jsonSchemaService = new JSONSchemaService(
905
+ new TypeResolver(
906
+ {
907
+ MyPrimitive: primitiveSchema,
908
+ },
909
+ {
910
+ MyResolvableSrcString: resolvableTypeFixture('MyResolvableSrcString', {
911
+ type: 'object',
912
+ properties: { src: { type: 'string' } },
913
+ }),
914
+ MyResolvableSrcNumber: resolvableTypeFixture('MyResolvableSrcNumber', {
915
+ type: 'object',
916
+ properties: { src: { type: 'number' } },
917
+ }),
918
+ },
919
+ {
920
+ MyPrimitive: {
921
+ MyResolvableSrcNumber: (value: { src: number }) => value.src.toString(),
922
+ MyResolvableSrcString: (value: { src: string }) => value.src,
923
+ },
924
+ },
925
+ ),
926
+ ComponentInputMetaSchema,
927
+ );
928
+
929
+ await expect(
930
+ jsonSchemaService.resolveInput(
931
+ { myProperty: resolvableValue },
932
+ { type: 'object', properties: { myProperty: { type: 'MyPrimitive' } } },
933
+ ),
934
+ ).resolves.toEqual({ myProperty: output });
935
+ });
936
+ });
937
+ });
@@ -1,3 +1,5 @@
1
+ import JSONQuery, { Input } from '@sagold/json-query';
2
+
1
3
  import DxComponentInputSchema from './manifest/v1/DxComponentInputSchema.json';
2
4
  import DxComponentIcons from './manifest/v1/DxComponentIcons.json';
3
5
  import DxContentMetaSchema from './manifest/v1/DxContentMetaSchema.json';
@@ -7,10 +9,12 @@ import FormattedText from './formatted-text/v1/formattedText.json';
7
9
 
8
10
  import v1 from './manifest/v1/v1.json';
9
11
  import { SchemaValidationError } from './errors/SchemaValidationError';
10
- import { Draft07, JSONError, JSONSchema, Draft, DraftConfig } from 'json-schema-library';
12
+ import { Draft07, JSONError, JSONSchema, Draft, DraftConfig, isJSONError } from 'json-schema-library';
11
13
 
12
14
  import { draft07Config } from 'json-schema-library';
13
15
  import { MANIFEST_MODELS } from '.';
16
+ import { AnyPrimitiveType, AnyResolvableType, TypeResolver } from './jsonTypeResolution/arbitraryTypeResolution';
17
+ import { JsonResolutionError } from './errors/JsonResolutionError';
14
18
 
15
19
  const defaultConfig: DraftConfig = {
16
20
  ...draft07Config,
@@ -71,6 +75,7 @@ export const ComponentInputSchema = new Draft(
71
75
  if (!resolvedSchema) {
72
76
  return resolvedSchema;
73
77
  }
78
+
74
79
  if (resolvedSchema.type === 'FormattedText') {
75
80
  return FTSchema.rootSchema;
76
81
  } else if (Array.isArray(resolvedSchema.type) && resolvedSchema.type.includes('FormattedText')) {
@@ -117,6 +122,127 @@ v1Schema.addRemoteSchema('/DxComponentIcons.json', DxComponentIcons);
117
122
  v1Schema.addRemoteSchema('http://json-schema.org/draft-07/schema', Draft07Schema);
118
123
  v1Schema.addRemoteSchema('http://json-schema.org/draft-07/schema#', Draft07Schema);
119
124
 
125
+ export const ComponentInputMetaSchema: MetaSchemaInput = {
126
+ root: DxComponentInputSchema,
127
+ remotes: {
128
+ 'DxComponentInputSchema.json/DxContentMetaSchema.json': DxContentMetaSchema,
129
+ },
130
+ };
131
+
132
+ export const RenderInputMetaSchema: MetaSchemaInput = {
133
+ root: Draft07Schema,
134
+ };
135
+
136
+ export const ManifestV1MetaSchema: MetaSchemaInput = {
137
+ root: v1,
138
+ remotes: {
139
+ 'DxComponentInputSchema.json/DxContentMetaSchema.json': DxContentMetaSchema,
140
+ '/DxComponentInputSchema.json': DxComponentInputSchema,
141
+ '/DxComponentIcons.json': DxComponentIcons,
142
+ 'http://json-schema.org/draft-07/schema': Draft07Schema,
143
+ 'http://json-schema.org/draft-07/schema#': Draft07Schema,
144
+ },
145
+ };
146
+
147
+ interface MetaSchemaInput {
148
+ root: JSONSchema;
149
+ remotes?: Record<string, JSONSchema>;
150
+ }
151
+ /**
152
+ * A service that can be used to validate and resolve JSON against a schema.
153
+ */
154
+ export class JSONSchemaService<P extends AnyPrimitiveType, R extends AnyResolvableType> {
155
+ schema: Draft;
156
+ constructor(private typeResolver: TypeResolver<P, R>, metaSchema: MetaSchemaInput) {
157
+ this.schema = new Draft(
158
+ {
159
+ ...defaultConfig,
160
+ resolveRef: (schema, rootSchema) => this.doResolveRef(schema, rootSchema),
161
+ },
162
+ metaSchema.root,
163
+ );
164
+
165
+ for (const [key, value] of Object.entries(metaSchema.remotes || {})) {
166
+ this.schema.addRemoteSchema(key, value);
167
+ }
168
+ }
169
+
170
+ private doResolveRef(schema: JSONSchema, rootSchema: JSONSchema): JSONSchema {
171
+ const initialRef = draft07Config.resolveRef(schema, rootSchema);
172
+
173
+ if (!initialRef) return initialRef;
174
+ if (!this.typeResolver.isPrimitiveType(initialRef.type)) return initialRef;
175
+
176
+ return this.typeResolver.getValidationSchemaForPrimitive(initialRef.type);
177
+ }
178
+
179
+ /**
180
+ * Validate an input value against a specified schema
181
+ * @throws {SchemaValidationError} if the input is invalid
182
+ * @returns true if the input is valid
183
+ */
184
+ public validateInput(input: unknown, inputSchema: JSONSchema = this.schema.rootSchema): true | never {
185
+ inputSchema = this.schema.compileSchema(inputSchema);
186
+ const errors = this.schema.validate(input, inputSchema);
187
+ return this.processValidationResult(errors);
188
+ }
189
+
190
+ private processValidationResult(errors: JSONError[]): true {
191
+ if (errors.length > 0) {
192
+ throw new SchemaValidationError(errors.map((a) => a.message).join(',\n'));
193
+ }
194
+
195
+ return true;
196
+ }
197
+
198
+ /**
199
+ * Resolve an input object by replacing all resolvable shapes with their resolved values
200
+ * @param input any input object which matches the input schema
201
+ * @param inputSchema a JSONSchema which provides type information about the input object
202
+ * @returns the input object with all resolvable shapes resolved
203
+ */
204
+ public async resolveInput(input: Input, inputSchema: JSONSchema) {
205
+ const setters: Array<Promise<(input: Input) => Input>> = [];
206
+ this.schema.each(
207
+ input,
208
+ (schema, value, pointer) => {
209
+ // First we check for if value is a resolvable shape
210
+ if (!this.typeResolver.isResolvableSchema(schema)) return;
211
+ // If its a resolvable schema, it should exist in a oneOf array with other schemas
212
+ // Including a primitive schema
213
+ const allPossibleSchemas: Array<JSONSchema> = schema.oneOfSchema.oneOf;
214
+ if (isJSONError(allPossibleSchemas)) return;
215
+
216
+ const primitiveSchema = allPossibleSchemas.find((schema): schema is P =>
217
+ this.typeResolver.isPrimitiveSchema(schema),
218
+ );
219
+ if (!primitiveSchema) return;
220
+
221
+ const resolver = this.typeResolver.tryGetResolver(primitiveSchema, schema);
222
+ if (!resolver) return;
223
+ const setResolvedData = Promise.resolve()
224
+ .then(() => resolver(value))
225
+ .then((resolvedData) => (item: typeof input) => JSONQuery.set(item, pointer, resolvedData))
226
+ .catch((e) => Promise.reject(new JsonResolutionError(e, pointer, value)));
227
+ setters.push(setResolvedData);
228
+ },
229
+ inputSchema,
230
+ );
231
+
232
+ const potentialResolutionErrors = [];
233
+ for (const resolveResult of await Promise.allSettled(setters)) {
234
+ if (resolveResult.status === 'rejected') {
235
+ potentialResolutionErrors.push(resolveResult.reason);
236
+ continue;
237
+ }
238
+
239
+ input = resolveResult.value(input);
240
+ }
241
+
242
+ return input;
243
+ }
244
+ }
245
+
120
246
  export class JsonValidationService {
121
247
  validateManifest(manifest: unknown, version: 'v1') {
122
248
  switch (version) {
@@ -0,0 +1,5 @@
1
+ export class JsonResolutionError extends Error {
2
+ constructor(error: Error, public pointer: string, public value: any) {
3
+ super(`Error resolving JSON at ${pointer}: ${error.message}`);
4
+ }
5
+ }
package/src/index.ts CHANGED
@@ -9,3 +9,6 @@ export * as SUB_SCHEMAS from './manifest/v1/subSchemas';
9
9
 
10
10
  export * from './JsonValidationService';
11
11
  export * from './errors/SchemaValidationError';
12
+ export * from './errors/JsonResolutionError';
13
+
14
+ export * from './jsonTypeResolution';
@@ -0,0 +1,134 @@
1
+ import { JSONSchema } from 'json-schema-library';
2
+ import { PrimitiveType, ResolvableType, TypeResolver } from './arbitraryTypeResolution';
3
+
4
+ const defaultSchema: JSONSchema = {
5
+ type: 'object',
6
+ properties: {
7
+ myProperty: {
8
+ type: 'string',
9
+ },
10
+ },
11
+ required: ['myProperty'],
12
+ };
13
+ function primitiveTypeFixture<T extends string>(title: T, schema: JSONSchema = defaultSchema) {
14
+ return PrimitiveType({
15
+ ...schema,
16
+ title,
17
+ });
18
+ }
19
+
20
+ function resolvableTypeFixture<T extends string>(title: T, schema: JSONSchema = defaultSchema) {
21
+ return ResolvableType({
22
+ ...schema,
23
+ title,
24
+ });
25
+ }
26
+
27
+ describe('getValidationSchemaForPrimitive', () => {
28
+ it('should return only the primitive schema when no resolvers are defined', () => {
29
+ const primitiveType = primitiveTypeFixture('MyPrimitive');
30
+ const resolvableType = resolvableTypeFixture('MyResolvable');
31
+ const resolver = new TypeResolver(
32
+ {
33
+ MyPrimitive: primitiveType,
34
+ },
35
+ {
36
+ MyResolvable: resolvableType,
37
+ },
38
+ {},
39
+ );
40
+
41
+ expect(resolver.getValidationSchemaForPrimitive('MyPrimitive')).toEqual({
42
+ oneOf: [primitiveType],
43
+ });
44
+ });
45
+
46
+ it('should return the primitive schema and the resolvable schema when a resolver is defined', () => {
47
+ const primitiveType = primitiveTypeFixture('MyPrimitive');
48
+ const resolvableType = resolvableTypeFixture('MyResolvable');
49
+ const resolver = new TypeResolver(
50
+ {
51
+ MyPrimitive: primitiveType,
52
+ },
53
+ {
54
+ MyResolvable: resolvableType,
55
+ },
56
+ {
57
+ MyPrimitive: {
58
+ MyResolvable: () => null,
59
+ },
60
+ },
61
+ );
62
+
63
+ expect(resolver.getValidationSchemaForPrimitive('MyPrimitive')).toEqual({
64
+ oneOf: [primitiveType, resolvableType],
65
+ });
66
+ });
67
+
68
+ it('should return the primitive schema and the resolvable schema when a resolver is defined for a different primitive', () => {
69
+ const primitiveType = primitiveTypeFixture('MyPrimitive');
70
+ const resolvableType = resolvableTypeFixture('MyResolvable');
71
+ const resolver = new TypeResolver(
72
+ {
73
+ MyPrimitive: primitiveType,
74
+ MyOtherPrimitive: primitiveTypeFixture('MyOtherPrimitive'),
75
+ },
76
+ {
77
+ MyResolvable: resolvableType,
78
+ },
79
+ {
80
+ MyOtherPrimitive: {
81
+ MyResolvable: () => null,
82
+ },
83
+ },
84
+ );
85
+
86
+ expect(resolver.getValidationSchemaForPrimitive('MyPrimitive')).toEqual({
87
+ oneOf: [primitiveType],
88
+ });
89
+ });
90
+
91
+ it('should error when resolver map contains a key not listed in resolver schemas', () => {
92
+ const primitiveType = primitiveTypeFixture('MyPrimitive');
93
+ const resolvableType = resolvableTypeFixture('MyResolvable');
94
+ expect(
95
+ () =>
96
+ new TypeResolver<typeof primitiveType, typeof resolvableType>(
97
+ {
98
+ MyPrimitive: primitiveType,
99
+ },
100
+ {
101
+ MyResolvable: resolvableType,
102
+ },
103
+ {
104
+ MyPrimitive: {
105
+ // @ts-expect-error - this is not a valid resolvable type
106
+ MyOtherResolvable: () => null,
107
+ },
108
+ },
109
+ ),
110
+ ).toThrowError();
111
+ });
112
+
113
+ it('should error when resolver map contains a key not listed in primitive schemas', () => {
114
+ const primitiveType = primitiveTypeFixture('MyPrimitive');
115
+ const resolvableType = resolvableTypeFixture('MyResolvable');
116
+ expect(
117
+ () =>
118
+ new TypeResolver<typeof primitiveType, typeof resolvableType>(
119
+ {
120
+ MyPrimitive: primitiveType,
121
+ },
122
+ {
123
+ MyResolvable: resolvableType,
124
+ },
125
+ {
126
+ // @ts-expect-error - this is not a valid primitive type
127
+ MyOtherPrimitive: {
128
+ MyResolvable: () => null,
129
+ },
130
+ },
131
+ ),
132
+ ).toThrowError();
133
+ });
134
+ });
@@ -0,0 +1,100 @@
1
+ import type { JSONSchema } from 'json-schema-library';
2
+ import * as t from 'ts-brand';
3
+
4
+ type MaybePromise<T> = T | Promise<T>;
5
+
6
+ type JsonResolutionSchema<TITLE extends string> = JSONSchema & { title: TITLE };
7
+ /**
8
+ * This type allows the TypeScript type to be encoded onto the JSON schema object
9
+ */
10
+ type SchemaWithShape<TITLE extends string, SHAPE> = JsonResolutionSchema<TITLE> & { __shape__: SHAPE };
11
+
12
+ /**
13
+ * A JSON schema which represents a primitive type which can be a resolve target
14
+ *
15
+ * The brand ensures that TypeScript can differentiate between a primitive type and a resolvable type
16
+ */
17
+ export type PrimitiveType<TITLE extends string, SHAPE> = t.Brand<SchemaWithShape<TITLE, SHAPE>, 'primitive'>;
18
+ export function PrimitiveType<SHAPE, TITLE extends string>(
19
+ jsonSchema: JsonResolutionSchema<TITLE>,
20
+ ): PrimitiveType<TITLE, SHAPE> {
21
+ return jsonSchema as PrimitiveType<TITLE, SHAPE>;
22
+ }
23
+ export type AnyPrimitiveType = PrimitiveType<string, any>;
24
+
25
+ /**
26
+ * A JSON schema which represents a type which can be resolved into a primitive type
27
+ *
28
+ * The brand ensures that TypeScript can differentiate between a primitive type and a resolvable type
29
+ */
30
+ export type ResolvableType<TITLE extends string, SHAPE> = t.Brand<SchemaWithShape<TITLE, SHAPE>, 'resolvable'>;
31
+ export function ResolvableType<SHAPE, TITLE extends string>(
32
+ jsonSchema: JsonResolutionSchema<TITLE>,
33
+ ): ResolvableType<TITLE, SHAPE> {
34
+ return jsonSchema as ResolvableType<TITLE, SHAPE>;
35
+ }
36
+ export type AnyResolvableType = ResolvableType<string, any>;
37
+
38
+ type Resolver<INPUT, OUTPUT> = (input: INPUT) => MaybePromise<OUTPUT>;
39
+
40
+ /**
41
+ * A JSON Type Resolver class which stores the primitive and resolvable JSON Schema types and their resolvers
42
+ *
43
+ * No serious logic is required here. The class should only provide data access methods and type safety
44
+ */
45
+ export class TypeResolver<P extends AnyPrimitiveType, R extends AnyResolvableType> {
46
+ constructor(
47
+ private primitives: { [K in P as P['title']]: K },
48
+ private resolvables: { [K in R as R['title']]: K },
49
+ public resolvers: {
50
+ [PT in P as PT['title']]?: {
51
+ [RT in R as RT['title']]?: Resolver<RT['__shape__'], PT['__shape__']>;
52
+ };
53
+ },
54
+ ) {
55
+ for (const [primitiveKey, primitiveResolvers] of Object.entries(resolvers) as [string, Record<string, any>][]) {
56
+ if (!(primitiveKey in primitives)) {
57
+ throw new Error('Resolver keys must match a primitive schema');
58
+ }
59
+ if (!Object.keys(primitiveResolvers).every((k) => k in resolvables)) {
60
+ throw new Error('Primitive resolvers keys must match a resolvable schema');
61
+ }
62
+ }
63
+ }
64
+
65
+ isPrimitiveType(type: string): type is P['title'] {
66
+ return type in this.primitives;
67
+ }
68
+
69
+ isPrimitiveSchema(schema: JSONSchema): schema is P {
70
+ return this.isPrimitiveType(schema.title);
71
+ }
72
+
73
+ isResolvableSchema(schema: JSONSchema): schema is R {
74
+ return schema.title in this.resolvables;
75
+ }
76
+
77
+ getValidationSchemaForPrimitive(type: keyof typeof this.primitives) {
78
+ const primitiveSchema = this.primitives[type];
79
+ const validSchemas = [primitiveSchema, ...this.fetchResolvableSchemasForPrimitive(type)];
80
+
81
+ return {
82
+ oneOf: validSchemas,
83
+ };
84
+ }
85
+
86
+ private *fetchResolvableSchemasForPrimitive(type: keyof typeof this.primitives) {
87
+ for (const resolverKey in this.resolvers[type]) {
88
+ yield this.resolvables[resolverKey];
89
+ }
90
+ }
91
+
92
+ tryGetResolver<PS extends P, RS extends R>(
93
+ primitiveSchema: PS,
94
+ resolvableSchema: RS,
95
+ ): Resolver<RS['__shape__'], PS['__shape__']> | undefined {
96
+ if (!(primitiveSchema.title in this.resolvers)) return;
97
+ // Sometimes typescript can be insanely annoying
98
+ return (this.resolvers[primitiveSchema.title as keyof typeof this.resolvers] as any)?.[resolvableSchema.title];
99
+ }
100
+ }