Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support key remapping via as #1174

Merged
merged 3 commits into from Mar 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions .editorconfig
Expand Up @@ -14,6 +14,10 @@ indent_size = 4
indent_style = space
indent_size = 4

[test/**/schema.json]
indent_style = space
indent_size = 2

[{package.json,azure-pipelines.yml}]
indent_style = space
indent_size = 2
27 changes: 27 additions & 0 deletions .vscode/launch.json
Expand Up @@ -4,6 +4,33 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
//
// Helps with debugging parsing.
// - Set break points in the code
// - Open the file you want to parse: test/**/main.ts
// - F5 to run the debugger.
"name": "Debug Test Case",
"type": "node",
"request": "launch",
"runtimeExecutable": "node",
"runtimeArgs": [
"--nolazy",
"-r",
"ts-node/register"
],
"args": [
"ts-json-schema-generator.ts",
"-p",
"${file}"
],
"cwd": "${workspaceFolder}",
"internalConsoleOptions": "openOnSessionStart",
"skipFiles": [
"<node_internals>/**",
"node_modules/**"
]
},
{
"type": "node",
"request": "attach",
Expand Down
2 changes: 2 additions & 0 deletions factory/parser.ts
Expand Up @@ -24,6 +24,7 @@ import { HiddenNodeParser } from "../src/NodeParser/HiddenTypeNodeParser";
import { IndexedAccessTypeNodeParser } from "../src/NodeParser/IndexedAccessTypeNodeParser";
import { InterfaceAndClassNodeParser } from "../src/NodeParser/InterfaceAndClassNodeParser";
import { IntersectionNodeParser } from "../src/NodeParser/IntersectionNodeParser";
import { IntrinsicNodeParser } from "../src/NodeParser/IntrinsicNodeParser";
import { LiteralNodeParser } from "../src/NodeParser/LiteralNodeParser";
import { MappedTypeNodeParser } from "../src/NodeParser/MappedTypeNodeParser";
import { NeverTypeNodeParser } from "../src/NodeParser/NeverTypeNodeParser";
Expand Down Expand Up @@ -104,6 +105,7 @@ export function createParser(program: ts.Program, config: Config, augmentor?: Pa
.addNodeParser(withJsDoc(new ParameterParser(chainNodeParser)))
.addNodeParser(new StringLiteralNodeParser())
.addNodeParser(new StringTemplateLiteralNodeParser(chainNodeParser))
.addNodeParser(new IntrinsicNodeParser())
.addNodeParser(new NumberLiteralNodeParser())
.addNodeParser(new BooleanLiteralNodeParser())
.addNodeParser(new NullLiteralNodeParser())
Expand Down
39 changes: 39 additions & 0 deletions src/NodeParser/IntrinsicNodeParser.ts
@@ -0,0 +1,39 @@
import ts from "typescript";
import { Context } from "../NodeParser";
import { SubNodeParser } from "../SubNodeParser";
import { BaseType } from "../Type/BaseType";
import { LiteralType } from "../Type/LiteralType";
import { UnionType } from "../Type/UnionType";
import assert from "../Utils/assert";
import { extractLiterals } from "../Utils/extractLiterals";

export const intrinsicMethods: Record<string, ((v: string) => string) | undefined> = {
Uppercase: (v) => v.toUpperCase(),
Lowercase: (v) => v.toLowerCase(),
Capitalize: (v) => v[0].toUpperCase() + v.slice(1),
Uncapitalize: (v) => v[0].toLowerCase() + v.slice(1),
};

export class IntrinsicNodeParser implements SubNodeParser {
public supportsNode(node: ts.KeywordTypeNode): boolean {
return node.kind === ts.SyntaxKind.IntrinsicKeyword;
}
public createType(node: ts.KeywordTypeNode, context: Context): BaseType | undefined {
const methodName = getParentName(node);
const method = intrinsicMethods[methodName];
assert(method, `Unknown intrinsic method: ${methodName}`);
const literals = extractLiterals(context.getArguments()[0])
.map(method)
.map((literal) => new LiteralType(literal));
if (literals.length === 1) {
return literals[0];
}
return new UnionType(literals);
}
}

function getParentName(node: ts.KeywordTypeNode): string {
const parent = node.parent;
assert(ts.isTypeAliasDeclaration(parent), "Only intrinsics part of a TypeAliasDeclaration are supported.");
return parent.name.text;
}
19 changes: 16 additions & 3 deletions src/NodeParser/MappedTypeNodeParser.ts
Expand Up @@ -10,13 +10,14 @@ import { LiteralType } from "../Type/LiteralType";
import { NumberType } from "../Type/NumberType";
import { ObjectProperty, ObjectType } from "../Type/ObjectType";
import { StringType } from "../Type/StringType";
import { SymbolType } from "../Type/SymbolType";
import { UnionType } from "../Type/UnionType";
import assert from "../Utils/assert";
import { derefAnnotatedType, derefType } from "../Utils/derefType";
import { getKey } from "../Utils/nodeKey";
import { notUndefined } from "../Utils/notUndefined";
import { preserveAnnotation } from "../Utils/preserveAnnotation";
import { removeUndefined } from "../Utils/removeUndefined";
import { notUndefined } from "../Utils/notUndefined";
import { SymbolType } from "../Type/SymbolType";

export class MappedTypeNodeParser implements SubNodeParser {
public constructor(protected childNodeParser: NodeParser, protected readonly additionalProperties: boolean) {}
Expand Down Expand Up @@ -67,11 +68,23 @@ export class MappedTypeNodeParser implements SubNodeParser {
}
}

protected mapKey(node: ts.MappedTypeNode, rawKey: LiteralType, context: Context): LiteralType {
if (!node.nameType) {
return rawKey;
}
const key = derefType(
this.childNodeParser.createType(node.nameType, this.createSubContext(node, rawKey, context))
);
assert(key instanceof LiteralType, "Must resolve to Literal");
return key;
}

protected getProperties(node: ts.MappedTypeNode, keyListType: UnionType, context: Context): ObjectProperty[] {
return keyListType
.getTypes()
.filter((type) => type instanceof LiteralType)
.reduce((result: ObjectProperty[], key: LiteralType) => {
const namedKey = this.mapKey(node, key, context);
const propertyType = this.childNodeParser.createType(
node.type!,
this.createSubContext(node, key, context)
Expand All @@ -90,7 +103,7 @@ export class MappedTypeNodeParser implements SubNodeParser {
}

const objectProperty = new ObjectProperty(
key.getValue().toString(),
namedKey.getValue().toString(),
preserveAnnotation(propertyType, newType),
!node.questionToken && !hasUndefined
);
Expand Down
25 changes: 2 additions & 23 deletions src/NodeParser/StringTemplateLiteralNodeParser.ts
@@ -1,11 +1,10 @@
import ts from "typescript";
import { UnknownTypeError } from "../Error/UnknownTypeError";
import { Context, NodeParser } from "../NodeParser";
import { SubNodeParser } from "../SubNodeParser";
import { AliasType } from "../Type/AliasType";
import { BaseType } from "../Type/BaseType";
import { LiteralType } from "../Type/LiteralType";
import { UnionType } from "../Type/UnionType";
import { extractLiterals } from "../Utils/extractLiterals";

export class StringTemplateLiteralNodeParser implements SubNodeParser {
public constructor(protected childNodeParser: NodeParser) {}
Expand All @@ -24,7 +23,7 @@ export class StringTemplateLiteralNodeParser implements SubNodeParser {
node.templateSpans.map((span) => {
const suffix = span.literal.text;
const type = this.childNodeParser.createType(span.type, context);
return [...extractLiterals(type)].map((value) => value + suffix);
return extractLiterals(type).map((value) => value + suffix);
})
);

Expand All @@ -49,23 +48,3 @@ function expand(matrix: string[][]): string[] {
const combined = head.map((prefix) => nested.map((suffix) => prefix + suffix));
return ([] as string[]).concat(...combined);
}

function* extractLiterals(type: BaseType | undefined): Iterable<string> {
if (!type) return;
if (type instanceof LiteralType) {
yield type.getValue().toString();
return;
}
if (type instanceof UnionType) {
for (const t of type.getTypes()) {
yield* extractLiterals(t);
}
return;
}
if (type instanceof AliasType) {
yield* extractLiterals(type.getType());
return;
}

throw new UnknownTypeError(type);
}
7 changes: 7 additions & 0 deletions src/Utils/assert.ts
@@ -0,0 +1,7 @@
import { LogicError } from "../Error/LogicError";

export default function assert(value: unknown, message: string): asserts value {
if (!value) {
throw new LogicError(message);
}
}
31 changes: 31 additions & 0 deletions src/Utils/extractLiterals.ts
@@ -0,0 +1,31 @@
import { UnknownTypeError } from "../Error/UnknownTypeError";
import { AliasType } from "../Type/AliasType";
import { BaseType } from "../Type/BaseType";
import { LiteralType } from "../Type/LiteralType";
import { UnionType } from "../Type/UnionType";

function* _extractLiterals(type: BaseType | undefined): Iterable<string> {
if (!type) {
return;
}
if (type instanceof LiteralType) {
yield type.getValue().toString();
return;
}
if (type instanceof UnionType) {
for (const t of type.getTypes()) {
yield* _extractLiterals(t);
}
return;
}
if (type instanceof AliasType) {
yield* _extractLiterals(type.getType());
return;
}

throw new UnknownTypeError(type);
}

export function extractLiterals(type: BaseType | undefined): string[] {
return [..._extractLiterals(type)];
}
25 changes: 25 additions & 0 deletions test/unit/assert.test.ts
@@ -0,0 +1,25 @@
import { LogicError } from "../../src/Error/LogicError";
import assert from "../../src/Utils/assert";

describe("validate assert", () => {
it.each`
value
${"hello"}
${1}
${true}
${{}}
`("success $value", ({ value }) => {
expect(() => assert(value, "message")).not.toThrow();
});

it.each`
value
${""}
${0}
${false}
${undefined}
${null}
`("fail $value", ({ value }) => {
expect(() => assert(value, "failed to be true")).toThrowError(LogicError);
});
});
1 change: 1 addition & 0 deletions test/valid-data-other.test.ts
Expand Up @@ -36,6 +36,7 @@ describe("valid-data-other", () => {

it("string-literals", assertValidSchema("string-literals", "MyObject"));
it("string-literals-inline", assertValidSchema("string-literals-inline", "MyObject"));
it("string-literals-intrinsic", assertValidSchema("string-literals-intrinsic", "MyObject"));
it("string-literals-null", assertValidSchema("string-literals-null", "MyObject"));
it("string-template-literals", assertValidSchema("string-template-literals", "MyObject"));
it("string-template-expression-literals", assertValidSchema("string-template-expression-literals", "MyObject"));
Expand Down
2 changes: 2 additions & 0 deletions test/valid-data-type.test.ts
Expand Up @@ -76,6 +76,8 @@ describe("valid-data-type", () => {
it("type-keyof-object-function", assertValidSchema("type-keyof-object-function", "MyType"));
it("type-mapped-simple", assertValidSchema("type-mapped-simple", "MyObject"));
it("type-mapped-index", assertValidSchema("type-mapped-index", "MyObject"));
it("type-mapped-index-as", assertValidSchema("type-mapped-index-as", "MyObject"));
it("type-mapped-index-as-template", assertValidSchema("type-mapped-index-as-template", "MyObject"));
it("type-mapped-literal", assertValidSchema("type-mapped-literal", "MyObject"));
it("type-mapped-generic", assertValidSchema("type-mapped-generic", "MyObject"));
it("type-mapped-native", assertValidSchema("type-mapped-native", "MyObject"));
Expand Down
14 changes: 14 additions & 0 deletions test/valid-data/string-literals-intrinsic/main.ts
@@ -0,0 +1,14 @@
type Abort = "abort";
type Result = "ok" | "fail" | Uppercase<Abort> | "Success";
type ResultUpper = Uppercase<Result>;
type ResultLower = Lowercase<ResultUpper>;
type ResultCapitalize = Capitalize<Result>;
type ResultUncapitalize = Uncapitalize<ResultCapitalize>;

export interface MyObject {
result: Result;
resultUpper: ResultUpper;
resultLower: ResultLower;
resultCapitalize: ResultCapitalize;
resultUncapitalize: ResultUncapitalize;
}
64 changes: 64 additions & 0 deletions test/valid-data/string-literals-intrinsic/schema.json
@@ -0,0 +1,64 @@
{
"$ref": "#/definitions/MyObject",
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"MyObject": {
"additionalProperties": false,
"properties": {
"result": {
"enum": [
"ok",
"fail",
"ABORT",
"Success"
],
"type": "string"
},
"resultCapitalize": {
"enum": [
"Ok",
"Fail",
"ABORT",
"Success"
],
"type": "string"
},
"resultLower": {
"enum": [
"ok",
"fail",
"abort",
"success"
],
"type": "string"
},
"resultUncapitalize": {
"enum": [
"ok",
"fail",
"aBORT",
"success"
],
"type": "string"
},
"resultUpper": {
"enum": [
"OK",
"FAIL",
"ABORT",
"SUCCESS"
],
"type": "string"
}
},
"required": [
"result",
"resultUpper",
"resultLower",
"resultCapitalize",
"resultUncapitalize"
],
"type": "object"
}
}
}
9 changes: 9 additions & 0 deletions test/valid-data/type-mapped-index-as-template/main.ts
@@ -0,0 +1,9 @@
interface Message {
id: number;
name: string;
title: string;
}

export type MyObject = {
[K in keyof Message as `message${Capitalize<K>}`]: Message[K];
};
26 changes: 26 additions & 0 deletions test/valid-data/type-mapped-index-as-template/schema.json
@@ -0,0 +1,26 @@
{
"$ref": "#/definitions/MyObject",
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"MyObject": {
"additionalProperties": false,
"properties": {
"messageId": {
"type": "number"
},
"messageName": {
"type": "string"
},
"messageTitle": {
"type": "string"
}
},
"required": [
"messageId",
"messageName",
"messageTitle"
],
"type": "object"
}
}
}