@amritk/generate-parsers 0.3.1 → 0.5.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/dist/generators/build-schema.d.ts +7 -1
- package/dist/generators/generate-files.d.ts +12 -0
- package/dist/helpers/collect-helpers.d.ts +9 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +172 -45
- package/package.json +4 -4
- package/src/generators/build-schema.test.ts +24 -0
- package/src/generators/build-schema.ts +12 -0
- package/src/generators/generate-files.ts +14 -2
- package/src/generators/generate-schema-checks.test.ts +8 -0
- package/src/generators/generate-schema-checks.ts +13 -0
- package/src/generators/generate-strict-assertion.test.ts +67 -0
- package/src/generators/generate-strict-assertion.ts +39 -0
- package/src/generators/generate-validation-expression.test.ts +37 -0
- package/src/generators/generate-validation-expression.ts +40 -0
- package/src/helpers/collect-helpers.test.ts +12 -0
- package/src/helpers/collect-helpers.ts +27 -12
- package/src/helpers/get-default-value.test.ts +4 -0
- package/src/helpers/get-default-value.ts +6 -0
- package/src/index.ts +1 -1
|
@@ -34,6 +34,12 @@ export type GeneratedFile = {
|
|
|
34
34
|
* @param helpersMode - `'package'` (default) emits `import ... from '@amritk/helpers/...'`.
|
|
35
35
|
* `'embedded'` emits `import ... from './_helpers/...'` and appends the helper sources
|
|
36
36
|
* as additional `GeneratedFile` entries so the output directory is self-contained.
|
|
37
|
+
* @param helpersImportPrefix - Relative path prefix to the `_helpers/` directory in
|
|
38
|
+
* embedded mode. Defaults to `'./'`. The recursive multi-schema build passes `'../'`,
|
|
39
|
+
* `'../../'`, etc. so nested parsers can import from a single shared `_helpers/`
|
|
40
|
+
* directory while the helper sources are emitted once at the output root.
|
|
41
|
+
* @param readonly - When true, every property, array, and record in the generated type
|
|
42
|
+
* definitions is emitted as `readonly`.
|
|
37
43
|
* @returns An array of generated TypeScript files
|
|
38
44
|
*
|
|
39
45
|
* @example
|
|
@@ -66,4 +72,4 @@ export type GeneratedFile = {
|
|
|
66
72
|
* });
|
|
67
73
|
* ```
|
|
68
74
|
*/
|
|
69
|
-
export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode) => Promise<GeneratedFile[]>;
|
|
75
|
+
export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean) => Promise<GeneratedFile[]>;
|
|
@@ -37,6 +37,18 @@ type GenerateFileOptions = {
|
|
|
37
37
|
* be shipped alongside the generated files.
|
|
38
38
|
*/
|
|
39
39
|
readonly helpersMode?: HelpersMode;
|
|
40
|
+
/**
|
|
41
|
+
* Relative path prefix to the shared `_helpers/` directory in embedded mode.
|
|
42
|
+
* Defaults to `'./'`. The recursive multi-schema build passes `'../'`,
|
|
43
|
+
* `'../../'`, etc. so a nested parser can reach a single `_helpers/` directory
|
|
44
|
+
* at the output root.
|
|
45
|
+
*/
|
|
46
|
+
readonly helpersImportPrefix?: string;
|
|
47
|
+
/**
|
|
48
|
+
* When true, every property, array, and record in the generated type
|
|
49
|
+
* definitions is emitted as `readonly`.
|
|
50
|
+
*/
|
|
51
|
+
readonly readonly?: boolean;
|
|
40
52
|
};
|
|
41
53
|
/** Result of generating a single parser file. */
|
|
42
54
|
export type GeneratedFileContent = {
|
|
@@ -12,5 +12,12 @@ export type CollectedHelpers = {
|
|
|
12
12
|
*/
|
|
13
13
|
readonly used: Set<RuntimeHelperName>;
|
|
14
14
|
};
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Detects which runtime helpers a generated parser body references.
|
|
17
|
+
*
|
|
18
|
+
* @param helpersImportPrefix - Relative path prefix to the shared `_helpers/`
|
|
19
|
+
* directory in embedded mode. Defaults to `'./'`. The recursive multi-schema
|
|
20
|
+
* build passes `'../'`, `'../../'`, etc. so nested parsers can reach a single
|
|
21
|
+
* `_helpers/` directory at the output root.
|
|
22
|
+
*/
|
|
23
|
+
export declare const collectHelpers: (parserFunction: string, mode: HelpersMode, helpersImportPrefix?: string) => CollectedHelpers;
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { buildSchema } from './generators/build-schema';
|
|
1
|
+
export { buildSchema, type GeneratedFile } from './generators/build-schema';
|
package/dist/index.js
CHANGED
|
@@ -375,6 +375,31 @@ var isSchemaObject2 = (schema) => {
|
|
|
375
375
|
var isObjectSchema = (schema) => {
|
|
376
376
|
return isSchemaObject2(schema) && (("type" in schema) && schema.type === "object" || ("properties" in schema));
|
|
377
377
|
};
|
|
378
|
+
var MJST_EXTENSION_KEY = "x-mjst";
|
|
379
|
+
var IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
380
|
+
var SUPPORTED_PRIMITIVES = new Set(["bigint"]);
|
|
381
|
+
var SAFE_BRAND = /^[\w$ -]+$/;
|
|
382
|
+
var readExtensionString = (schema, field) => {
|
|
383
|
+
if (!isSchemaObject2(schema))
|
|
384
|
+
return;
|
|
385
|
+
const extension = schema[MJST_EXTENSION_KEY];
|
|
386
|
+
if (typeof extension !== "object" || extension === null)
|
|
387
|
+
return;
|
|
388
|
+
const value = extension[field];
|
|
389
|
+
return typeof value === "string" ? value : undefined;
|
|
390
|
+
};
|
|
391
|
+
var getMjstInstanceOf = (schema) => {
|
|
392
|
+
const instanceOf = readExtensionString(schema, "instanceOf");
|
|
393
|
+
return instanceOf !== undefined && IDENTIFIER.test(instanceOf) ? instanceOf : undefined;
|
|
394
|
+
};
|
|
395
|
+
var getMjstPrimitive = (schema) => {
|
|
396
|
+
const primitive = readExtensionString(schema, "primitive");
|
|
397
|
+
return primitive !== undefined && SUPPORTED_PRIMITIVES.has(primitive) ? primitive : undefined;
|
|
398
|
+
};
|
|
399
|
+
var getMjstBrand = (schema) => {
|
|
400
|
+
const brand = readExtensionString(schema, "brand");
|
|
401
|
+
return brand !== undefined && SAFE_BRAND.test(brand) ? brand : undefined;
|
|
402
|
+
};
|
|
378
403
|
var toKebabCase4 = (value) => value.replace(/OAuth/g, "Oauth").replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").replace(/([a-z\d])([A-Z])/g, "$1-$2").toLowerCase();
|
|
379
404
|
var uriRefToFilename4 = (uri) => {
|
|
380
405
|
const hashIndex = uri.indexOf("#");
|
|
@@ -515,13 +540,27 @@ var buildJsDocBlock = (title, description, commentUrl) => {
|
|
|
515
540
|
`;
|
|
516
541
|
return block;
|
|
517
542
|
};
|
|
518
|
-
var getTypeScriptType = (schema) => {
|
|
543
|
+
var getTypeScriptType = (schema, options = {}) => {
|
|
544
|
+
const base = getUnbrandedType(schema, options);
|
|
545
|
+
const brand = getMjstBrand(schema);
|
|
546
|
+
return brand ? `(${base} & { readonly __brand: '${brand}' })` : base;
|
|
547
|
+
};
|
|
548
|
+
var recordType = (keyType, valueType, options) => options.readonly ? `Readonly<Record<${keyType}, ${valueType}>>` : `Record<${keyType}, ${valueType}>`;
|
|
549
|
+
var getUnbrandedType = (schema, options = {}) => {
|
|
519
550
|
if (typeof schema === "boolean") {
|
|
520
551
|
return getBooleanSubSchemaType(schema);
|
|
521
552
|
}
|
|
522
553
|
if (typeof schema !== "object" || schema === null) {
|
|
523
554
|
return "unknown";
|
|
524
555
|
}
|
|
556
|
+
const instanceOf = getMjstInstanceOf(schema);
|
|
557
|
+
if (instanceOf) {
|
|
558
|
+
return instanceOf;
|
|
559
|
+
}
|
|
560
|
+
const primitive = getMjstPrimitive(schema);
|
|
561
|
+
if (primitive) {
|
|
562
|
+
return primitive;
|
|
563
|
+
}
|
|
525
564
|
if (schema.$ref) {
|
|
526
565
|
if (!schema.$ref.startsWith("#")) {
|
|
527
566
|
return "unknown";
|
|
@@ -555,29 +594,29 @@ var getTypeScriptType = (schema) => {
|
|
|
555
594
|
return multiEnumUnion;
|
|
556
595
|
}
|
|
557
596
|
if (schema.oneOf && Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
|
|
558
|
-
let oneOfUnion = getTypeScriptType(schema.oneOf[0]);
|
|
597
|
+
let oneOfUnion = getTypeScriptType(schema.oneOf[0], options);
|
|
559
598
|
for (let i = 1;i < schema.oneOf.length; i++) {
|
|
560
|
-
oneOfUnion += " | " + getTypeScriptType(schema.oneOf[i]);
|
|
599
|
+
oneOfUnion += " | " + getTypeScriptType(schema.oneOf[i], options);
|
|
561
600
|
}
|
|
562
601
|
return oneOfUnion;
|
|
563
602
|
}
|
|
564
603
|
if (schema.anyOf && Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
|
565
|
-
let anyOfUnion = getTypeScriptType(schema.anyOf[0]);
|
|
604
|
+
let anyOfUnion = getTypeScriptType(schema.anyOf[0], options);
|
|
566
605
|
for (let i = 1;i < schema.anyOf.length; i++) {
|
|
567
|
-
anyOfUnion += " | " + getTypeScriptType(schema.anyOf[i]);
|
|
606
|
+
anyOfUnion += " | " + getTypeScriptType(schema.anyOf[i], options);
|
|
568
607
|
}
|
|
569
608
|
return anyOfUnion;
|
|
570
609
|
}
|
|
571
610
|
if (schema.allOf && Array.isArray(schema.allOf) && schema.allOf.length > 0) {
|
|
572
|
-
let intersectionTypes = getTypeScriptType(schema.allOf[0]);
|
|
611
|
+
let intersectionTypes = getTypeScriptType(schema.allOf[0], options);
|
|
573
612
|
for (let i = 1;i < schema.allOf.length; i++) {
|
|
574
|
-
intersectionTypes += " & " + getTypeScriptType(schema.allOf[i]);
|
|
613
|
+
intersectionTypes += " & " + getTypeScriptType(schema.allOf[i], options);
|
|
575
614
|
}
|
|
576
615
|
return intersectionTypes;
|
|
577
616
|
}
|
|
578
617
|
const conditionalResult = getConditionalObjectSchema(schema);
|
|
579
618
|
if (conditionalResult) {
|
|
580
|
-
const baseType = getTypeScriptType(conditionalResult.schema);
|
|
619
|
+
const baseType = getTypeScriptType(conditionalResult.schema, options);
|
|
581
620
|
if (conditionalResult.thenRef) {
|
|
582
621
|
return `(${baseType}) & ${refToName2(conditionalResult.thenRef)}`;
|
|
583
622
|
}
|
|
@@ -586,20 +625,20 @@ var getTypeScriptType = (schema) => {
|
|
|
586
625
|
if (!schema.type) {
|
|
587
626
|
if (schema.additionalProperties !== undefined) {
|
|
588
627
|
if (typeof schema.additionalProperties === "boolean") {
|
|
589
|
-
return
|
|
628
|
+
return recordType("string", getBooleanSubSchemaType(schema.additionalProperties), options);
|
|
590
629
|
}
|
|
591
|
-
return
|
|
630
|
+
return recordType("string", getTypeScriptType(schema.additionalProperties, options), options);
|
|
592
631
|
}
|
|
593
632
|
if (schema.patternProperties && typeof schema.patternProperties === "object") {
|
|
594
633
|
const firstEntry = Object.entries(schema.patternProperties)[0];
|
|
595
634
|
if (firstEntry) {
|
|
596
635
|
const [pattern, value] = firstEntry;
|
|
597
636
|
if (value !== undefined) {
|
|
598
|
-
const valueType = typeof value === "boolean" ? getBooleanSubSchemaType(value) : getTypeScriptType(value);
|
|
637
|
+
const valueType = typeof value === "boolean" ? getBooleanSubSchemaType(value) : getTypeScriptType(value, options);
|
|
599
638
|
if (pattern === "^x-") {
|
|
600
|
-
return `
|
|
639
|
+
return recordType("`x-${string}`", valueType, options);
|
|
601
640
|
}
|
|
602
|
-
return
|
|
641
|
+
return recordType("string", valueType, options);
|
|
603
642
|
}
|
|
604
643
|
}
|
|
605
644
|
}
|
|
@@ -652,41 +691,42 @@ var getTypeScriptType = (schema) => {
|
|
|
652
691
|
return "boolean";
|
|
653
692
|
case "array":
|
|
654
693
|
if (schema.items) {
|
|
655
|
-
const itemType = getTypeScriptType(schema.items);
|
|
694
|
+
const itemType = getTypeScriptType(schema.items, options);
|
|
656
695
|
const wrappedItemType = itemType.includes(" | ") ? `(${itemType})` : itemType;
|
|
657
|
-
return `${wrappedItemType}[]`;
|
|
696
|
+
return options.readonly ? `readonly ${wrappedItemType}[]` : `${wrappedItemType}[]`;
|
|
658
697
|
}
|
|
659
|
-
return "unknown[]";
|
|
698
|
+
return options.readonly ? "readonly unknown[]" : "unknown[]";
|
|
660
699
|
case "object":
|
|
661
700
|
if (schema.properties) {
|
|
701
|
+
const readonlyPrefix = options.readonly ? "readonly " : "";
|
|
662
702
|
let properties = "";
|
|
663
703
|
let first = true;
|
|
664
704
|
for (const key in schema.properties) {
|
|
665
705
|
const propSchema = schema.properties[key];
|
|
666
706
|
const isRequired = schema.required?.includes(key) ?? false;
|
|
667
707
|
const optional = isRequired ? "" : "?";
|
|
668
|
-
const propType = getTypeScriptType(propSchema);
|
|
708
|
+
const propType = getTypeScriptType(propSchema, options);
|
|
669
709
|
if (!first)
|
|
670
710
|
properties += "; ";
|
|
671
|
-
properties += safeKey(key) + optional + ": " + propType;
|
|
711
|
+
properties += readonlyPrefix + safeKey(key) + optional + ": " + propType;
|
|
672
712
|
first = false;
|
|
673
713
|
}
|
|
674
714
|
return "{ " + properties + " }";
|
|
675
715
|
}
|
|
676
716
|
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
677
|
-
const additionalPropType = getTypeScriptType(schema.additionalProperties);
|
|
678
|
-
return
|
|
717
|
+
const additionalPropType = getTypeScriptType(schema.additionalProperties, options);
|
|
718
|
+
return recordType("string", additionalPropType, options);
|
|
679
719
|
}
|
|
680
720
|
if (schema.patternProperties && typeof schema.patternProperties === "object") {
|
|
681
721
|
const firstEntry = Object.entries(schema.patternProperties)[0];
|
|
682
722
|
if (firstEntry) {
|
|
683
723
|
const [pattern, patternVal] = firstEntry;
|
|
684
724
|
if (patternVal) {
|
|
685
|
-
const valueType = getTypeScriptType(patternVal);
|
|
725
|
+
const valueType = getTypeScriptType(patternVal, options);
|
|
686
726
|
if (pattern === "^x-") {
|
|
687
|
-
return `
|
|
727
|
+
return recordType("`x-${string}`", valueType, options);
|
|
688
728
|
}
|
|
689
|
-
return
|
|
729
|
+
return recordType("string", valueType, options);
|
|
690
730
|
}
|
|
691
731
|
}
|
|
692
732
|
}
|
|
@@ -695,9 +735,10 @@ var getTypeScriptType = (schema) => {
|
|
|
695
735
|
return "unknown";
|
|
696
736
|
}
|
|
697
737
|
};
|
|
698
|
-
var generateTypeDefinition = (schema, typeName) => {
|
|
738
|
+
var generateTypeDefinition = (schema, typeName, options = {}) => {
|
|
739
|
+
const readonlyPrefix = options.readonly ? "readonly " : "";
|
|
699
740
|
if (!isObjectLikeSchema(schema)) {
|
|
700
|
-
const tsType = getTypeScriptType(schema);
|
|
741
|
+
const tsType = getTypeScriptType(schema, options);
|
|
701
742
|
let result = "";
|
|
702
743
|
if (isSchemaObject2(schema) && schema.$comment && typeof schema.$comment === "string") {
|
|
703
744
|
result += buildJsDocBlock(typeName, schema.$comment);
|
|
@@ -725,23 +766,23 @@ var generateTypeDefinition = (schema, typeName) => {
|
|
|
725
766
|
if (firstPatternProperty === undefined) {
|
|
726
767
|
return `export type ${typeName} = Record<string, unknown>;`;
|
|
727
768
|
}
|
|
728
|
-
const patternPropType = typeof firstPatternProperty === "boolean" ? getBooleanSubSchemaType(firstPatternProperty) : getTypeScriptType(firstPatternProperty);
|
|
769
|
+
const patternPropType = typeof firstPatternProperty === "boolean" ? getBooleanSubSchemaType(firstPatternProperty) : getTypeScriptType(firstPatternProperty, options);
|
|
729
770
|
const keyType = firstPattern === "^x-" ? "`x-${string}`" : "string";
|
|
730
771
|
let result2 = "";
|
|
731
772
|
if (jsDocTitle && jsDocDescription) {
|
|
732
773
|
result2 += buildJsDocBlock(jsDocTitle, jsDocDescription);
|
|
733
774
|
}
|
|
734
|
-
result2 += `export type ${typeName} =
|
|
775
|
+
result2 += `export type ${typeName} = ${recordType(keyType, patternPropType, options)};`;
|
|
735
776
|
return result2;
|
|
736
777
|
}
|
|
737
778
|
if (!hasProperties2 && hasAdditionalProperties2 && normalizedSchema.additionalProperties) {
|
|
738
|
-
const additionalPropType = getTypeScriptType(normalizedSchema.additionalProperties);
|
|
779
|
+
const additionalPropType = getTypeScriptType(normalizedSchema.additionalProperties, options);
|
|
739
780
|
let result2 = "";
|
|
740
781
|
if (jsDocTitle && jsDocDescription) {
|
|
741
782
|
result2 += buildJsDocBlock(jsDocTitle, jsDocDescription);
|
|
742
783
|
}
|
|
743
784
|
result2 += `export type ${typeName} = {
|
|
744
|
-
[key: string]: ${additionalPropType};
|
|
785
|
+
${readonlyPrefix}[key: string]: ${additionalPropType};
|
|
745
786
|
};`;
|
|
746
787
|
return result2;
|
|
747
788
|
}
|
|
@@ -752,8 +793,8 @@ var generateTypeDefinition = (schema, typeName) => {
|
|
|
752
793
|
const propSchema = schemaProps[key];
|
|
753
794
|
const isRequired = normalizedSchema.required?.includes(key) ?? false;
|
|
754
795
|
const optional = isRequired ? "" : "?";
|
|
755
|
-
const propType = getTypeScriptType(propSchema);
|
|
756
|
-
const quotedKey = safeKey(key);
|
|
796
|
+
const propType = getTypeScriptType(propSchema, options);
|
|
797
|
+
const quotedKey = readonlyPrefix + safeKey(key);
|
|
757
798
|
if (!isFirstProp)
|
|
758
799
|
properties += `
|
|
759
800
|
`;
|
|
@@ -803,31 +844,32 @@ var PACKAGE_IMPORTS = {
|
|
|
803
844
|
"validate-record": "import { validateRecord } from '@amritk/helpers/validate-record';",
|
|
804
845
|
"has-ref": "import { hasRef } from '@amritk/helpers/schema-guards';"
|
|
805
846
|
};
|
|
806
|
-
var
|
|
807
|
-
"is-object": "
|
|
808
|
-
"validate-array": "
|
|
809
|
-
"validate-record": "
|
|
810
|
-
"has-ref": "
|
|
847
|
+
var EMBEDDED_NAMED_EXPORTS = {
|
|
848
|
+
"is-object": "isObject",
|
|
849
|
+
"validate-array": "validateArray",
|
|
850
|
+
"validate-record": "validateRecord",
|
|
851
|
+
"has-ref": "hasRef"
|
|
811
852
|
};
|
|
812
|
-
var
|
|
853
|
+
var embeddedImport = (helper, prefix) => `import { ${EMBEDDED_NAMED_EXPORTS[helper]} } from '${prefix}_helpers/${helper}';`;
|
|
854
|
+
var collectHelpers = (parserFunction, mode, helpersImportPrefix = "./") => {
|
|
813
855
|
const imports = [];
|
|
814
856
|
const used = new Set;
|
|
815
|
-
const
|
|
857
|
+
const importFor = (helper) => mode === "embedded" ? embeddedImport(helper, helpersImportPrefix) : PACKAGE_IMPORTS[helper];
|
|
816
858
|
if (parserFunction.includes("validateArray")) {
|
|
817
|
-
imports.push(
|
|
859
|
+
imports.push(importFor("validate-array"));
|
|
818
860
|
used.add("validate-array");
|
|
819
861
|
}
|
|
820
862
|
if (parserFunction.includes("validateRecord")) {
|
|
821
|
-
imports.push(
|
|
863
|
+
imports.push(importFor("validate-record"));
|
|
822
864
|
used.add("validate-record");
|
|
823
865
|
used.add("is-object");
|
|
824
866
|
}
|
|
825
867
|
if (parserFunction.includes("isObject")) {
|
|
826
|
-
imports.push(
|
|
868
|
+
imports.push(importFor("is-object"));
|
|
827
869
|
used.add("is-object");
|
|
828
870
|
}
|
|
829
871
|
if (parserFunction.includes("hasRef(")) {
|
|
830
|
-
imports.push(
|
|
872
|
+
imports.push(importFor("has-ref"));
|
|
831
873
|
used.add("has-ref");
|
|
832
874
|
}
|
|
833
875
|
return { imports, used };
|
|
@@ -1082,6 +1124,31 @@ var safeKey2 = (key) => {
|
|
|
1082
1124
|
return `'${key}'`;
|
|
1083
1125
|
};
|
|
1084
1126
|
|
|
1127
|
+
// ../helpers/dist/mjst-extension.js
|
|
1128
|
+
var isSchemaObject4 = (schema) => {
|
|
1129
|
+
return typeof schema === "object" && schema !== null && typeof schema !== "boolean";
|
|
1130
|
+
};
|
|
1131
|
+
var MJST_EXTENSION_KEY2 = "x-mjst";
|
|
1132
|
+
var IDENTIFIER2 = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
1133
|
+
var SUPPORTED_PRIMITIVES2 = new Set(["bigint"]);
|
|
1134
|
+
var readExtensionString2 = (schema, field) => {
|
|
1135
|
+
if (!isSchemaObject4(schema))
|
|
1136
|
+
return;
|
|
1137
|
+
const extension = schema[MJST_EXTENSION_KEY2];
|
|
1138
|
+
if (typeof extension !== "object" || extension === null)
|
|
1139
|
+
return;
|
|
1140
|
+
const value = extension[field];
|
|
1141
|
+
return typeof value === "string" ? value : undefined;
|
|
1142
|
+
};
|
|
1143
|
+
var getMjstInstanceOf2 = (schema) => {
|
|
1144
|
+
const instanceOf = readExtensionString2(schema, "instanceOf");
|
|
1145
|
+
return instanceOf !== undefined && IDENTIFIER2.test(instanceOf) ? instanceOf : undefined;
|
|
1146
|
+
};
|
|
1147
|
+
var getMjstPrimitive2 = (schema) => {
|
|
1148
|
+
const primitive = readExtensionString2(schema, "primitive");
|
|
1149
|
+
return primitive !== undefined && SUPPORTED_PRIMITIVES2.has(primitive) ? primitive : undefined;
|
|
1150
|
+
};
|
|
1151
|
+
|
|
1085
1152
|
// src/generators/generate-default-from-pattern.ts
|
|
1086
1153
|
var generateDefaultFromPattern = (pattern) => {
|
|
1087
1154
|
if (pattern.includes("@") || pattern.includes("[a-zA-Z0-9]") && pattern.includes("\\.")) {
|
|
@@ -1139,6 +1206,9 @@ var getDefaultValue = (schema) => {
|
|
|
1139
1206
|
return getDefaultValue(firstSchema);
|
|
1140
1207
|
}
|
|
1141
1208
|
}
|
|
1209
|
+
if (getMjstPrimitive2(schema) === "bigint") {
|
|
1210
|
+
return "0n";
|
|
1211
|
+
}
|
|
1142
1212
|
if (!hasType(schema)) {
|
|
1143
1213
|
return "undefined";
|
|
1144
1214
|
}
|
|
@@ -1234,6 +1304,24 @@ var generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
|
|
|
1234
1304
|
return lines;
|
|
1235
1305
|
if (hasRef(propSchema))
|
|
1236
1306
|
return lines;
|
|
1307
|
+
const instanceOf = getMjstInstanceOf2(propSchema);
|
|
1308
|
+
if (instanceOf) {
|
|
1309
|
+
if (isRequired) {
|
|
1310
|
+
lines.push(` if (!(${acc} instanceof ${instanceOf})) throw new Error('[${typeName}] field "${key}" must be ${instanceOf}');`);
|
|
1311
|
+
} else {
|
|
1312
|
+
lines.push(` if (${acc} !== undefined && !(${acc} instanceof ${instanceOf})) throw new Error('[${typeName}] field "${key}" must be ${instanceOf}');`);
|
|
1313
|
+
}
|
|
1314
|
+
return lines;
|
|
1315
|
+
}
|
|
1316
|
+
const primitive = getMjstPrimitive2(propSchema);
|
|
1317
|
+
if (primitive) {
|
|
1318
|
+
if (isRequired) {
|
|
1319
|
+
lines.push(` if (typeof ${acc} !== "${primitive}") throw new Error('[${typeName}] field "${key}" must be ${primitive}');`);
|
|
1320
|
+
} else {
|
|
1321
|
+
lines.push(` if (${acc} !== undefined && typeof ${acc} !== "${primitive}") throw new Error('[${typeName}] field "${key}" must be ${primitive}');`);
|
|
1322
|
+
}
|
|
1323
|
+
return lines;
|
|
1324
|
+
}
|
|
1237
1325
|
if (hasEnum(propSchema)) {
|
|
1238
1326
|
const allowed = JSON.stringify(propSchema.enum);
|
|
1239
1327
|
const label = propSchema.enum.map((v) => JSON.stringify(v)).join(", ");
|
|
@@ -1270,6 +1358,14 @@ var generateObjectStrictAssertion = (schema, typeName) => {
|
|
|
1270
1358
|
return lines;
|
|
1271
1359
|
};
|
|
1272
1360
|
var generateScalarStrictAssertion = (schema, typeName) => {
|
|
1361
|
+
const instanceOf = getMjstInstanceOf2(schema);
|
|
1362
|
+
if (instanceOf) {
|
|
1363
|
+
return ` if (!(input instanceof ${instanceOf})) throw new Error(\`[${typeName}] expected ${instanceOf}, got \${input === null ? "null" : typeof input}\`);`;
|
|
1364
|
+
}
|
|
1365
|
+
const primitive = getMjstPrimitive2(schema);
|
|
1366
|
+
if (primitive) {
|
|
1367
|
+
return ` if (typeof input !== "${primitive}") throw new Error(\`[${typeName}] expected ${primitive}, got \${input === null ? "null" : typeof input}\`);`;
|
|
1368
|
+
}
|
|
1273
1369
|
if (!isSchemaObject3(schema) || !hasType(schema))
|
|
1274
1370
|
return null;
|
|
1275
1371
|
const t = schema.type;
|
|
@@ -1329,6 +1425,14 @@ var generateEnumCheck = (accessor, enumValues) => {
|
|
|
1329
1425
|
// src/generators/generate-schema-checks.ts
|
|
1330
1426
|
var generateSchemaChecks = (accessor, schema) => {
|
|
1331
1427
|
const checks = [];
|
|
1428
|
+
const instanceOf = getMjstInstanceOf2(schema);
|
|
1429
|
+
if (instanceOf) {
|
|
1430
|
+
return [`${accessor} instanceof ${instanceOf}`];
|
|
1431
|
+
}
|
|
1432
|
+
const primitive = getMjstPrimitive2(schema);
|
|
1433
|
+
if (primitive) {
|
|
1434
|
+
return [`typeof ${accessor} === "${primitive}"`];
|
|
1435
|
+
}
|
|
1332
1436
|
if (!hasType(schema)) {
|
|
1333
1437
|
return checks;
|
|
1334
1438
|
}
|
|
@@ -1461,6 +1565,11 @@ var generateNonDiscriminatedUnionValidation = (accessor, schemas, defaultValue,
|
|
|
1461
1565
|
};
|
|
1462
1566
|
|
|
1463
1567
|
// src/generators/generate-validation-expression.ts
|
|
1568
|
+
var getInstanceCoercion = (accessor, instanceOf) => {
|
|
1569
|
+
if (instanceOf === "Date")
|
|
1570
|
+
return `new Date(${accessor} as string | number | Date)`;
|
|
1571
|
+
return null;
|
|
1572
|
+
};
|
|
1464
1573
|
var getTypeCoercion = (accessor, schema, defaultValue) => {
|
|
1465
1574
|
if (!hasType(schema)) {
|
|
1466
1575
|
return null;
|
|
@@ -1489,6 +1598,20 @@ var generateValidationExpression = (key, schema, defaultValue, isRequired, rootS
|
|
|
1489
1598
|
if (!isSchemaObject3(schema)) {
|
|
1490
1599
|
return isRequired ? `${accessor} ?? ${defaultValue}` : accessor;
|
|
1491
1600
|
}
|
|
1601
|
+
const instanceOf = getMjstInstanceOf2(schema);
|
|
1602
|
+
if (instanceOf) {
|
|
1603
|
+
const valid = `${accessor} instanceof ${instanceOf}`;
|
|
1604
|
+
const coercion = getInstanceCoercion(accessor, instanceOf);
|
|
1605
|
+
if (isRequired) {
|
|
1606
|
+
return coercion ? `${valid} ? ${accessor} : (${accessor} !== undefined ? ${coercion} : ${defaultValue})` : `${valid} ? ${accessor} : ${defaultValue}`;
|
|
1607
|
+
}
|
|
1608
|
+
return coercion ? `${valid} ? ${accessor} : (${accessor} !== undefined ? ${coercion} : undefined)` : `${valid} ? ${accessor} : undefined`;
|
|
1609
|
+
}
|
|
1610
|
+
const primitive = getMjstPrimitive2(schema);
|
|
1611
|
+
if (primitive) {
|
|
1612
|
+
const valid = `typeof ${accessor} === "${primitive}"`;
|
|
1613
|
+
return isRequired ? `${valid} ? ${accessor} : ${defaultValue}` : `${valid} ? ${accessor} : undefined`;
|
|
1614
|
+
}
|
|
1492
1615
|
if (hasRef(schema) && rootSchema) {
|
|
1493
1616
|
const ref = schema.$ref;
|
|
1494
1617
|
if (visitedRefs?.has(ref)) {
|
|
@@ -2443,7 +2566,7 @@ var generateFile = (schema, typeName, options) => {
|
|
|
2443
2566
|
const selfRef = options?.selfRef;
|
|
2444
2567
|
const rootSchema = options?.rootSchema;
|
|
2445
2568
|
const helpersMode = options?.helpersMode ?? "package";
|
|
2446
|
-
const typeDefinition = generateTypeDefinition(schema, typeName);
|
|
2569
|
+
const typeDefinition = generateTypeDefinition(schema, typeName, { readonly: options?.readonly ?? false });
|
|
2447
2570
|
if (typesOnly) {
|
|
2448
2571
|
const imports2 = collectImports(schema, { typesOnly: true, selfRef, rootSchema });
|
|
2449
2572
|
let result2 = "";
|
|
@@ -2468,7 +2591,7 @@ var generateFile = (schema, typeName, options) => {
|
|
|
2468
2591
|
const combinedFunctions = `${shapeValidator}
|
|
2469
2592
|
|
|
2470
2593
|
${parserFunction}`;
|
|
2471
|
-
const helpers = collectHelpers(combinedFunctions, helpersMode);
|
|
2594
|
+
const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix);
|
|
2472
2595
|
const imports = [...collectImports(schema, { selfRef, rootSchema }), ...helpers.imports];
|
|
2473
2596
|
let result = "";
|
|
2474
2597
|
if (imports.length > 0) {
|
|
@@ -2512,7 +2635,7 @@ var extractDynamicAnchorDefs = (schema) => {
|
|
|
2512
2635
|
}
|
|
2513
2636
|
return refs;
|
|
2514
2637
|
};
|
|
2515
|
-
var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = "package") => {
|
|
2638
|
+
var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = "package", helpersImportPrefix = "./", readonly = false) => {
|
|
2516
2639
|
rootSchema = upgradeDraft07Schema(rootSchema);
|
|
2517
2640
|
const files = [];
|
|
2518
2641
|
const processedRefs = new Set;
|
|
@@ -2526,6 +2649,8 @@ var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWar
|
|
|
2526
2649
|
typesOnly: typesOnly ?? false,
|
|
2527
2650
|
rootSchema,
|
|
2528
2651
|
helpersMode,
|
|
2652
|
+
helpersImportPrefix,
|
|
2653
|
+
readonly,
|
|
2529
2654
|
...logWarnings !== undefined ? { logWarnings } : {},
|
|
2530
2655
|
...strict !== undefined ? { strict } : {}
|
|
2531
2656
|
});
|
|
@@ -2561,6 +2686,8 @@ var buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWar
|
|
|
2561
2686
|
selfRef: ref,
|
|
2562
2687
|
rootSchema,
|
|
2563
2688
|
helpersMode,
|
|
2689
|
+
helpersImportPrefix,
|
|
2690
|
+
readonly,
|
|
2564
2691
|
...logWarnings !== undefined ? { logWarnings } : {},
|
|
2565
2692
|
...strict !== undefined ? { strict } : {}
|
|
2566
2693
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-parsers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"json-schema-typed": "
|
|
52
|
-
"@amritk/helpers": "
|
|
53
|
-
"@amritk/generate-markdown": "
|
|
51
|
+
"json-schema-typed": "^8.0.1",
|
|
52
|
+
"@amritk/helpers": "0.5.0",
|
|
53
|
+
"@amritk/generate-markdown": "0.2.1"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -681,6 +681,30 @@ describe('build-schema', () => {
|
|
|
681
681
|
expect(filenames).toContain('_helpers/is-object.ts')
|
|
682
682
|
})
|
|
683
683
|
|
|
684
|
+
it('honours a custom helpersImportPrefix so nested parsers import from a shared root _helpers/', async () => {
|
|
685
|
+
const schema: JSONSchema = {
|
|
686
|
+
type: 'object',
|
|
687
|
+
properties: { name: { type: 'string' } },
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const result = await buildSchema(
|
|
691
|
+
schema,
|
|
692
|
+
'Document',
|
|
693
|
+
undefined,
|
|
694
|
+
undefined,
|
|
695
|
+
undefined,
|
|
696
|
+
undefined,
|
|
697
|
+
'embedded',
|
|
698
|
+
'../../',
|
|
699
|
+
)
|
|
700
|
+
const root = result.find((f) => f.filename === 'document.ts')
|
|
701
|
+
const filenames = result.map((f) => f.filename)
|
|
702
|
+
|
|
703
|
+
expect(root?.content).toContain("import { isObject } from '../../_helpers/is-object';")
|
|
704
|
+
// Helper sources keep their canonical filenames; the orchestrator relocates them to the root.
|
|
705
|
+
expect(filenames).toContain('_helpers/is-object.ts')
|
|
706
|
+
})
|
|
707
|
+
|
|
684
708
|
it('ships validate-array.ts (plus its transitive is-object.ts) for array schemas', async () => {
|
|
685
709
|
const schema: JSONSchema = {
|
|
686
710
|
type: 'object',
|
|
@@ -90,6 +90,12 @@ const extractDynamicAnchorDefs = (schema: JSONSchema): string[] => {
|
|
|
90
90
|
* @param helpersMode - `'package'` (default) emits `import ... from '@amritk/helpers/...'`.
|
|
91
91
|
* `'embedded'` emits `import ... from './_helpers/...'` and appends the helper sources
|
|
92
92
|
* as additional `GeneratedFile` entries so the output directory is self-contained.
|
|
93
|
+
* @param helpersImportPrefix - Relative path prefix to the `_helpers/` directory in
|
|
94
|
+
* embedded mode. Defaults to `'./'`. The recursive multi-schema build passes `'../'`,
|
|
95
|
+
* `'../../'`, etc. so nested parsers can import from a single shared `_helpers/`
|
|
96
|
+
* directory while the helper sources are emitted once at the output root.
|
|
97
|
+
* @param readonly - When true, every property, array, and record in the generated type
|
|
98
|
+
* definitions is emitted as `readonly`.
|
|
93
99
|
* @returns An array of generated TypeScript files
|
|
94
100
|
*
|
|
95
101
|
* @example
|
|
@@ -130,6 +136,8 @@ export const buildSchema = async (
|
|
|
130
136
|
logWarnings?: boolean,
|
|
131
137
|
strict?: boolean,
|
|
132
138
|
helpersMode: HelpersMode = 'package',
|
|
139
|
+
helpersImportPrefix = './',
|
|
140
|
+
readonly = false,
|
|
133
141
|
): Promise<GeneratedFile[]> => {
|
|
134
142
|
// Upgrade draft-07 schemas to 2020-12 conventions before processing.
|
|
135
143
|
// This renames `definitions` → `$defs` recursively so the rest of the
|
|
@@ -155,6 +163,8 @@ export const buildSchema = async (
|
|
|
155
163
|
typesOnly: typesOnly ?? false,
|
|
156
164
|
rootSchema: rootSchema as Record<string, unknown>,
|
|
157
165
|
helpersMode,
|
|
166
|
+
helpersImportPrefix,
|
|
167
|
+
readonly,
|
|
158
168
|
...(logWarnings !== undefined ? { logWarnings } : {}),
|
|
159
169
|
...(strict !== undefined ? { strict } : {}),
|
|
160
170
|
})
|
|
@@ -205,6 +215,8 @@ export const buildSchema = async (
|
|
|
205
215
|
selfRef: ref,
|
|
206
216
|
rootSchema: rootSchema as Record<string, unknown>,
|
|
207
217
|
helpersMode,
|
|
218
|
+
helpersImportPrefix,
|
|
219
|
+
readonly,
|
|
208
220
|
...(logWarnings !== undefined ? { logWarnings } : {}),
|
|
209
221
|
...(strict !== undefined ? { strict } : {}),
|
|
210
222
|
})
|
|
@@ -47,6 +47,18 @@ type GenerateFileOptions = {
|
|
|
47
47
|
* be shipped alongside the generated files.
|
|
48
48
|
*/
|
|
49
49
|
readonly helpersMode?: HelpersMode
|
|
50
|
+
/**
|
|
51
|
+
* Relative path prefix to the shared `_helpers/` directory in embedded mode.
|
|
52
|
+
* Defaults to `'./'`. The recursive multi-schema build passes `'../'`,
|
|
53
|
+
* `'../../'`, etc. so a nested parser can reach a single `_helpers/` directory
|
|
54
|
+
* at the output root.
|
|
55
|
+
*/
|
|
56
|
+
readonly helpersImportPrefix?: string
|
|
57
|
+
/**
|
|
58
|
+
* When true, every property, array, and record in the generated type
|
|
59
|
+
* definitions is emitted as `readonly`.
|
|
60
|
+
*/
|
|
61
|
+
readonly readonly?: boolean
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
/** Result of generating a single parser file. */
|
|
@@ -93,7 +105,7 @@ export const generateFile = (
|
|
|
93
105
|
const selfRef = options?.selfRef
|
|
94
106
|
const rootSchema = options?.rootSchema
|
|
95
107
|
const helpersMode: HelpersMode = options?.helpersMode ?? 'package'
|
|
96
|
-
const typeDefinition = generateTypeDefinition(schema, typeName)
|
|
108
|
+
const typeDefinition = generateTypeDefinition(schema, typeName, { readonly: options?.readonly ?? false })
|
|
97
109
|
|
|
98
110
|
if (typesOnly) {
|
|
99
111
|
// In types-only mode, skip the parser function and use type-only imports
|
|
@@ -118,7 +130,7 @@ export const generateFile = (
|
|
|
118
130
|
})
|
|
119
131
|
const shapeValidator = generateShapeValidator(schema, typeName, true)
|
|
120
132
|
const combinedFunctions = `${shapeValidator}\n\n${parserFunction}`
|
|
121
|
-
const helpers: CollectedHelpers = collectHelpers(combinedFunctions, helpersMode)
|
|
133
|
+
const helpers: CollectedHelpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix)
|
|
122
134
|
const imports = [...collectImports(schema, { selfRef, rootSchema }), ...helpers.imports]
|
|
123
135
|
|
|
124
136
|
// Build file output using string concatenation instead of array join for performance
|
|
@@ -319,4 +319,12 @@ describe('generate-schema-checks', () => {
|
|
|
319
319
|
const result = generateSchemaChecks('input?.name', { type: 'string', minLength: 1 })
|
|
320
320
|
expect(result).toEqual(['typeof input?.name === "string"', 'input?.name.length >= 1'])
|
|
321
321
|
})
|
|
322
|
+
|
|
323
|
+
it('returns an instanceof check for an x-mjst instanceOf node', () => {
|
|
324
|
+
expect(generateSchemaChecks('value', { 'x-mjst': { instanceOf: 'Date' } })).toEqual(['value instanceof Date'])
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('returns a typeof check for an x-mjst bigint node', () => {
|
|
328
|
+
expect(generateSchemaChecks('value', { 'x-mjst': { primitive: 'bigint' } })).toEqual(['typeof value === "bigint"'])
|
|
329
|
+
})
|
|
322
330
|
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension'
|
|
1
2
|
import {
|
|
2
3
|
hasAdditionalProperties,
|
|
3
4
|
hasEnum,
|
|
@@ -28,6 +29,18 @@ import { generateEnumCheck } from './generate-enum-check'
|
|
|
28
29
|
export const generateSchemaChecks = (accessor: string, schema: JSONSchema): string[] => {
|
|
29
30
|
const checks: string[] = []
|
|
30
31
|
|
|
32
|
+
// x-mjst instanceOf nodes validate purely by instance check (no native type).
|
|
33
|
+
const instanceOf = getMjstInstanceOf(schema)
|
|
34
|
+
if (instanceOf) {
|
|
35
|
+
return [`${accessor} instanceof ${instanceOf}`]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// x-mjst primitive nodes (e.g. bigint) validate purely by typeof check.
|
|
39
|
+
const primitive = getMjstPrimitive(schema)
|
|
40
|
+
if (primitive) {
|
|
41
|
+
return [`typeof ${accessor} === "${primitive}"`]
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
if (!hasType(schema)) {
|
|
32
45
|
return checks
|
|
33
46
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion'
|
|
4
|
+
|
|
5
|
+
describe('generate-strict-assertion x-mjst instanceOf', () => {
|
|
6
|
+
it('asserts instanceof for a required Date property', () => {
|
|
7
|
+
const schema = {
|
|
8
|
+
type: 'object' as const,
|
|
9
|
+
properties: { createdAt: { 'x-mjst': { instanceOf: 'Date' } } },
|
|
10
|
+
required: ['createdAt'],
|
|
11
|
+
}
|
|
12
|
+
const lines = generateObjectStrictAssertion(schema, 'Event').join('\n')
|
|
13
|
+
|
|
14
|
+
expect(lines).toContain('missing required property "createdAt"')
|
|
15
|
+
expect(lines).toContain('!(input.createdAt instanceof Date)')
|
|
16
|
+
expect(lines).toContain('field "createdAt" must be Date')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('guards undefined before asserting instanceof for an optional Date property', () => {
|
|
20
|
+
const schema = {
|
|
21
|
+
type: 'object' as const,
|
|
22
|
+
properties: { createdAt: { 'x-mjst': { instanceOf: 'Date' } } },
|
|
23
|
+
}
|
|
24
|
+
const lines = generateObjectStrictAssertion(schema, 'Event').join('\n')
|
|
25
|
+
|
|
26
|
+
expect(lines).toContain('input.createdAt !== undefined && !(input.createdAt instanceof Date)')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('asserts instanceof for a top-level Date schema', () => {
|
|
30
|
+
const line = generateScalarStrictAssertion({ 'x-mjst': { instanceOf: 'Date' } }, 'When')
|
|
31
|
+
|
|
32
|
+
expect(line).toContain('!(input instanceof Date)')
|
|
33
|
+
expect(line).toContain('expected Date')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('generate-strict-assertion x-mjst primitive', () => {
|
|
38
|
+
it('asserts typeof bigint for a required property', () => {
|
|
39
|
+
const schema = {
|
|
40
|
+
type: 'object' as const,
|
|
41
|
+
properties: { balance: { 'x-mjst': { primitive: 'bigint' } } },
|
|
42
|
+
required: ['balance'],
|
|
43
|
+
}
|
|
44
|
+
const lines = generateObjectStrictAssertion(schema, 'Account').join('\n')
|
|
45
|
+
|
|
46
|
+
expect(lines).toContain('missing required property "balance"')
|
|
47
|
+
expect(lines).toContain('typeof input.balance !== "bigint"')
|
|
48
|
+
expect(lines).toContain('field "balance" must be bigint')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('guards undefined before asserting typeof for an optional bigint property', () => {
|
|
52
|
+
const schema = {
|
|
53
|
+
type: 'object' as const,
|
|
54
|
+
properties: { balance: { 'x-mjst': { primitive: 'bigint' } } },
|
|
55
|
+
}
|
|
56
|
+
const lines = generateObjectStrictAssertion(schema, 'Account').join('\n')
|
|
57
|
+
|
|
58
|
+
expect(lines).toContain('input.balance !== undefined && typeof input.balance !== "bigint"')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('asserts typeof for a top-level bigint schema', () => {
|
|
62
|
+
const line = generateScalarStrictAssertion({ 'x-mjst': { primitive: 'bigint' } }, 'Big')
|
|
63
|
+
|
|
64
|
+
expect(line).toContain('typeof input !== "bigint"')
|
|
65
|
+
expect(line).toContain('expected bigint')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension'
|
|
1
2
|
import { safeAccessor } from '@amritk/helpers/safe-accessor'
|
|
2
3
|
import {
|
|
3
4
|
hasEnum,
|
|
@@ -134,6 +135,34 @@ const generatePropertyAssertion = (
|
|
|
134
135
|
if (!isSchemaObject(propSchema)) return lines
|
|
135
136
|
if (hasRef(propSchema)) return lines
|
|
136
137
|
|
|
138
|
+
const instanceOf = getMjstInstanceOf(propSchema)
|
|
139
|
+
if (instanceOf) {
|
|
140
|
+
if (isRequired) {
|
|
141
|
+
lines.push(
|
|
142
|
+
` if (!(${acc} instanceof ${instanceOf})) throw new Error('[${typeName}] field "${key}" must be ${instanceOf}');`,
|
|
143
|
+
)
|
|
144
|
+
} else {
|
|
145
|
+
lines.push(
|
|
146
|
+
` if (${acc} !== undefined && !(${acc} instanceof ${instanceOf})) throw new Error('[${typeName}] field "${key}" must be ${instanceOf}');`,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
return lines
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const primitive = getMjstPrimitive(propSchema)
|
|
153
|
+
if (primitive) {
|
|
154
|
+
if (isRequired) {
|
|
155
|
+
lines.push(
|
|
156
|
+
` if (typeof ${acc} !== "${primitive}") throw new Error('[${typeName}] field "${key}" must be ${primitive}');`,
|
|
157
|
+
)
|
|
158
|
+
} else {
|
|
159
|
+
lines.push(
|
|
160
|
+
` if (${acc} !== undefined && typeof ${acc} !== "${primitive}") throw new Error('[${typeName}] field "${key}" must be ${primitive}');`,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
return lines
|
|
164
|
+
}
|
|
165
|
+
|
|
137
166
|
if (hasEnum(propSchema)) {
|
|
138
167
|
const allowed = JSON.stringify(propSchema.enum)
|
|
139
168
|
const label = (propSchema.enum as unknown[]).map((v) => JSON.stringify(v)).join(', ')
|
|
@@ -202,6 +231,16 @@ export const generateObjectStrictAssertion = (schema: JSONSchema, typeName: stri
|
|
|
202
231
|
* Returns null when the schema has no type information to assert on.
|
|
203
232
|
*/
|
|
204
233
|
export const generateScalarStrictAssertion = (schema: JSONSchema, typeName: string): string | null => {
|
|
234
|
+
const instanceOf = getMjstInstanceOf(schema)
|
|
235
|
+
if (instanceOf) {
|
|
236
|
+
return ` if (!(input instanceof ${instanceOf})) throw new Error(\`[${typeName}] expected ${instanceOf}, got \${input === null ? "null" : typeof input}\`);`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const primitive = getMjstPrimitive(schema)
|
|
240
|
+
if (primitive) {
|
|
241
|
+
return ` if (typeof input !== "${primitive}") throw new Error(\`[${typeName}] expected ${primitive}, got \${input === null ? "null" : typeof input}\`);`
|
|
242
|
+
}
|
|
243
|
+
|
|
205
244
|
if (!isSchemaObject(schema) || !hasType(schema)) return null
|
|
206
245
|
const t = schema.type as string
|
|
207
246
|
const wrongType = wrongTypeCondition('input', t)
|
|
@@ -791,4 +791,41 @@ describe('generate-validation-expression', () => {
|
|
|
791
791
|
|
|
792
792
|
expect(result).toContain('? input?.status : undefined')
|
|
793
793
|
})
|
|
794
|
+
|
|
795
|
+
it('checks instanceof and coerces a required x-mjst Date field', () => {
|
|
796
|
+
const schema = { 'x-mjst': { instanceOf: 'Date' } }
|
|
797
|
+
const result = generateValidationExpression('createdAt', schema, '{}', true)
|
|
798
|
+
|
|
799
|
+
expect(result).toContain('input?.createdAt instanceof Date')
|
|
800
|
+
expect(result).toContain('new Date(input?.createdAt as string | number | Date)')
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
it('checks instanceof and coerces an optional x-mjst Date field', () => {
|
|
804
|
+
const schema = { 'x-mjst': { instanceOf: 'Date' } }
|
|
805
|
+
const result = generateValidationExpression('createdAt', schema, 'undefined', false)
|
|
806
|
+
|
|
807
|
+
expect(result).toContain('input?.createdAt instanceof Date')
|
|
808
|
+
expect(result).toContain('? input?.createdAt : (input?.createdAt !== undefined ? new Date')
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
it('falls back to the default for an unknown instanceOf with no coercion', () => {
|
|
812
|
+
const schema = { 'x-mjst': { instanceOf: 'CustomThing' } }
|
|
813
|
+
const result = generateValidationExpression('value', schema, '{}', true)
|
|
814
|
+
|
|
815
|
+
expect(result).toBe('input?.value instanceof CustomThing ? input?.value : {}')
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
it('checks typeof bigint for a required x-mjst primitive field', () => {
|
|
819
|
+
const schema = { 'x-mjst': { primitive: 'bigint' } }
|
|
820
|
+
const result = generateValidationExpression('balance', schema, '0n', true)
|
|
821
|
+
|
|
822
|
+
expect(result).toBe('typeof input?.balance === "bigint" ? input?.balance : 0n')
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
it('checks typeof bigint for an optional x-mjst primitive field', () => {
|
|
826
|
+
const schema = { 'x-mjst': { primitive: 'bigint' } }
|
|
827
|
+
const result = generateValidationExpression('balance', schema, '0n', false)
|
|
828
|
+
|
|
829
|
+
expect(result).toBe('typeof input?.balance === "bigint" ? input?.balance : undefined')
|
|
830
|
+
})
|
|
794
831
|
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension'
|
|
1
2
|
import { resolveRef } from '@amritk/helpers/resolve-ref'
|
|
2
3
|
import { safeAccessor } from '@amritk/helpers/safe-accessor'
|
|
3
4
|
import {
|
|
@@ -34,6 +35,17 @@ import { generateEnumCheck } from './generate-enum-check'
|
|
|
34
35
|
import { generateNonDiscriminatedUnionValidation } from './generate-non-discriminated-union-validation'
|
|
35
36
|
import { generateSchemaChecks } from './generate-schema-checks'
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Coercion expression for an `x-mjst` instanceOf value, when one is known.
|
|
40
|
+
* `Date` can be reconstructed from the common JSON encodings (ISO string or
|
|
41
|
+
* epoch number); other classes have no safe generic coercion, so we return null
|
|
42
|
+
* and the caller falls back to the default.
|
|
43
|
+
*/
|
|
44
|
+
const getInstanceCoercion = (accessor: string, instanceOf: string): string | null => {
|
|
45
|
+
if (instanceOf === 'Date') return `new Date(${accessor} as string | number | Date)`
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
/**
|
|
38
50
|
* Generates a type coercion expression for converting a value to the expected type.
|
|
39
51
|
*/
|
|
@@ -85,6 +97,34 @@ export const generateValidationExpression = (
|
|
|
85
97
|
return isRequired ? `${accessor} ?? ${defaultValue}` : accessor
|
|
86
98
|
}
|
|
87
99
|
|
|
100
|
+
// Handle x-mjst instanceOf (e.g. Date): the value must be an instance of the
|
|
101
|
+
// named class. Invalid values are coerced when a coercer is known, otherwise
|
|
102
|
+
// they fall back to the default (required) or undefined (optional).
|
|
103
|
+
const instanceOf = getMjstInstanceOf(schema)
|
|
104
|
+
if (instanceOf) {
|
|
105
|
+
const valid = `${accessor} instanceof ${instanceOf}`
|
|
106
|
+
const coercion = getInstanceCoercion(accessor, instanceOf)
|
|
107
|
+
|
|
108
|
+
if (isRequired) {
|
|
109
|
+
return coercion
|
|
110
|
+
? `${valid} ? ${accessor} : (${accessor} !== undefined ? ${coercion} : ${defaultValue})`
|
|
111
|
+
: `${valid} ? ${accessor} : ${defaultValue}`
|
|
112
|
+
}
|
|
113
|
+
return coercion
|
|
114
|
+
? `${valid} ? ${accessor} : (${accessor} !== undefined ? ${coercion} : undefined)`
|
|
115
|
+
: `${valid} ? ${accessor} : undefined`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Handle x-mjst primitive (e.g. bigint): validate with typeof. We do not
|
|
119
|
+
// coerce — BigInt() throws on invalid input, which a pure coercion expression
|
|
120
|
+
// cannot guard — so invalid values fall back to the default (required) or
|
|
121
|
+
// undefined (optional).
|
|
122
|
+
const primitive = getMjstPrimitive(schema)
|
|
123
|
+
if (primitive) {
|
|
124
|
+
const valid = `typeof ${accessor} === "${primitive}"`
|
|
125
|
+
return isRequired ? `${valid} ? ${accessor} : ${defaultValue}` : `${valid} ? ${accessor} : undefined`
|
|
126
|
+
}
|
|
127
|
+
|
|
88
128
|
// Handle $ref - resolve and validate against the referenced schema
|
|
89
129
|
if (hasRef(schema) && rootSchema) {
|
|
90
130
|
const ref = schema.$ref
|
|
@@ -99,5 +99,17 @@ describe('collect-helpers', () => {
|
|
|
99
99
|
expect(result.imports).toContain("import { validateRecord } from './_helpers/validate-record';")
|
|
100
100
|
expect(result.imports).toContain("import { isObject } from './_helpers/is-object';")
|
|
101
101
|
})
|
|
102
|
+
|
|
103
|
+
it('applies a custom import prefix so nested parsers can reach a shared _helpers/ at the root', () => {
|
|
104
|
+
const parser = 'if (isObject(input)) { const arr = validateArray(input.items, parseItem) }'
|
|
105
|
+
const result = collectHelpers(parser, 'embedded', '../../')
|
|
106
|
+
expect(result.imports).toContain("import { isObject } from '../../_helpers/is-object';")
|
|
107
|
+
expect(result.imports).toContain("import { validateArray } from '../../_helpers/validate-array';")
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('ignores the import prefix in package mode', () => {
|
|
111
|
+
const result = collectHelpers('if (isObject(input)) {', 'package', '../')
|
|
112
|
+
expect(result.imports).toEqual(["import { isObject } from '@amritk/helpers/is-object';"])
|
|
113
|
+
})
|
|
102
114
|
})
|
|
103
115
|
})
|
|
@@ -23,26 +23,41 @@ const PACKAGE_IMPORTS: Record<RuntimeHelperName, string> = {
|
|
|
23
23
|
'has-ref': "import { hasRef } from '@amritk/helpers/schema-guards';",
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const
|
|
27
|
-
'is-object':
|
|
28
|
-
'validate-array':
|
|
29
|
-
'validate-record':
|
|
30
|
-
'has-ref':
|
|
26
|
+
const EMBEDDED_NAMED_EXPORTS: Record<RuntimeHelperName, string> = {
|
|
27
|
+
'is-object': 'isObject',
|
|
28
|
+
'validate-array': 'validateArray',
|
|
29
|
+
'validate-record': 'validateRecord',
|
|
30
|
+
'has-ref': 'hasRef',
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const embeddedImport = (helper: RuntimeHelperName, prefix: string): string =>
|
|
34
|
+
`import { ${EMBEDDED_NAMED_EXPORTS[helper]} } from '${prefix}_helpers/${helper}';`
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Detects which runtime helpers a generated parser body references.
|
|
38
|
+
*
|
|
39
|
+
* @param helpersImportPrefix - Relative path prefix to the shared `_helpers/`
|
|
40
|
+
* directory in embedded mode. Defaults to `'./'`. The recursive multi-schema
|
|
41
|
+
* build passes `'../'`, `'../../'`, etc. so nested parsers can reach a single
|
|
42
|
+
* `_helpers/` directory at the output root.
|
|
43
|
+
*/
|
|
44
|
+
export const collectHelpers = (
|
|
45
|
+
parserFunction: string,
|
|
46
|
+
mode: HelpersMode,
|
|
47
|
+
helpersImportPrefix = './',
|
|
48
|
+
): CollectedHelpers => {
|
|
35
49
|
const imports: string[] = []
|
|
36
50
|
const used = new Set<RuntimeHelperName>()
|
|
37
|
-
const
|
|
51
|
+
const importFor = (helper: RuntimeHelperName): string =>
|
|
52
|
+
mode === 'embedded' ? embeddedImport(helper, helpersImportPrefix) : PACKAGE_IMPORTS[helper]
|
|
38
53
|
|
|
39
54
|
if (parserFunction.includes('validateArray')) {
|
|
40
|
-
imports.push(
|
|
55
|
+
imports.push(importFor('validate-array'))
|
|
41
56
|
used.add('validate-array')
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
if (parserFunction.includes('validateRecord')) {
|
|
45
|
-
imports.push(
|
|
60
|
+
imports.push(importFor('validate-record'))
|
|
46
61
|
used.add('validate-record')
|
|
47
62
|
// The embedded validate-record.ts imports is-object, so it must be shipped too —
|
|
48
63
|
// but only the `validate-record` line is needed in the parser file unless the
|
|
@@ -51,12 +66,12 @@ export const collectHelpers = (parserFunction: string, mode: HelpersMode): Colle
|
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
if (parserFunction.includes('isObject')) {
|
|
54
|
-
imports.push(
|
|
69
|
+
imports.push(importFor('is-object'))
|
|
55
70
|
used.add('is-object')
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
if (parserFunction.includes('hasRef(')) {
|
|
59
|
-
imports.push(
|
|
74
|
+
imports.push(importFor('has-ref'))
|
|
60
75
|
used.add('has-ref')
|
|
61
76
|
}
|
|
62
77
|
|
|
@@ -132,4 +132,8 @@ describe('get-default-value', () => {
|
|
|
132
132
|
it('handles empty oneOf array and falls back', () => {
|
|
133
133
|
expect(getDefaultValue({ oneOf: [], type: 'boolean' })).toBe('false')
|
|
134
134
|
})
|
|
135
|
+
|
|
136
|
+
it('returns 0n for an x-mjst bigint schema', () => {
|
|
137
|
+
expect(getDefaultValue({ 'x-mjst': { primitive: 'bigint' } })).toBe('0n')
|
|
138
|
+
})
|
|
135
139
|
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getMjstPrimitive } from '@amritk/helpers/mjst-extension'
|
|
1
2
|
import {
|
|
2
3
|
hasAllOf,
|
|
3
4
|
hasAnyOf,
|
|
@@ -60,6 +61,11 @@ export const getDefaultValue = (schema: JSONSchema): string => {
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
// x-mjst bigint has no native JSON type but a sensible zero default.
|
|
65
|
+
if (getMjstPrimitive(schema) === 'bigint') {
|
|
66
|
+
return '0n'
|
|
67
|
+
}
|
|
68
|
+
|
|
63
69
|
if (!hasType(schema)) {
|
|
64
70
|
return 'undefined'
|
|
65
71
|
}
|
package/src/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { buildSchema } from './generators/build-schema'
|
|
1
|
+
export { buildSchema, type GeneratedFile } from './generators/build-schema'
|