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

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 (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
+ }