├── .npmignore ├── .travis.yml ├── .babelrc ├── commitlint.config.js ├── src ├── __tests__ │ ├── fixtures │ │ ├── base.ts │ │ ├── namespace.ts │ │ ├── company.ts │ │ ├── support.ts │ │ └── image.ts │ ├── translate │ │ ├── global.ts │ │ ├── import.ts │ │ └── support.ts │ ├── translate.test.js │ └── index.test.js ├── translate │ ├── types.ts │ ├── index.ts │ └── definition.ts ├── index.ts └── util.ts ├── tsconfig.json ├── .gitignore ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | .travis.yml 4 | tsconfig.json 5 | commitlint.config.js 6 | .babelrc -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - lts/* 4 | before_script: 5 | - npm install 6 | script: npm test -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", { 4 | "targets": { 5 | "node": "current" 6 | } 7 | }] 8 | ], 9 | "plugins": [ 10 | "@babel/transform-modules-commonjs" 11 | ] 12 | } -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @file .commitlintrc file 4 | * @author cxtom 5 | */ 6 | 7 | module.exports = { 8 | 'extends': ['@commitlint/config-conventional'], 9 | 'rules': { 10 | 'subject-case': [0, 'always'] 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/base.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface GenericDataOuter { 3 | name: string; 4 | data: K; 5 | type: T; 6 | } 7 | 8 | export interface GenericDataReName { 9 | rename: string; 10 | data: K; 11 | type: T; 12 | } 13 | 14 | interface Test { 15 | a: Date; 16 | } 17 | -------------------------------------------------------------------------------- /src/__tests__/translate/global.ts: -------------------------------------------------------------------------------- 1 | 2 | interface GlobalSupport { 3 | a: ValEnum; 4 | b: Foo; 5 | c: Simple; 6 | d: TestNameSpace.Fooz; 7 | e: TestNameSpace.InnerSpace.InnerFooz; 8 | f: DefaultExport; 9 | } 10 | 11 | interface UnSupportType { 12 | a: Date; 13 | b: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/__tests__/translate/import.ts: -------------------------------------------------------------------------------- 1 | import DefaultExport, {ValEnum, Foo, Simple, TestNameSpace} from './support'; 2 | 3 | interface ImportSupport { 4 | a: ValEnum; 5 | b: Foo; 6 | c: Simple; 7 | d: TestNameSpace.Fooz; 8 | e: TestNameSpace.InnerSpace.InnerFooz; 9 | f: DefaultExport; 10 | } 11 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/namespace.ts: -------------------------------------------------------------------------------- 1 | namespace TestNameSpace { 2 | 3 | interface Test { 4 | a: string; 5 | } 6 | 7 | export interface Fooz { 8 | bar: number; 9 | } 10 | 11 | export const a = 1; 12 | 13 | export function test() {} 14 | } 15 | 16 | namespace EmptyNameSpace { 17 | } 18 | 19 | interface TestExt extends TestNameSpace.Fooz { 20 | barzz: string; 21 | } 22 | 23 | interface TestProp { 24 | barzz: TestNameSpace.Fooz; 25 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**.ts"], 3 | "exclude": ["__tests__/**"], 4 | "compilerOptions": { 5 | "target": "es2016", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "moduleResolution": "node", 10 | "module": "CommonJs", 11 | "sourceMap": true, 12 | "declaration": true, 13 | "removeComments": true, 14 | "outDir": "lib", 15 | "baseUrl": "./" 16 | } 17 | } -------------------------------------------------------------------------------- /src/__tests__/fixtures/company.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 数组 3 | * @author cxtom(cxtom2008@gmail.com) 4 | */ 5 | 6 | import { integer } from "../.."; 7 | 8 | type employee = { 9 | 10 | /** 11 | * 雇员名字 12 | * 13 | * @maxLength 50 14 | * @minLength 1 15 | */ 16 | name: string; 17 | 18 | /** 19 | * 雇员年龄 20 | * 21 | * @minimum 18 22 | */ 23 | age: integer; 24 | }; 25 | 26 | interface Department { 27 | 28 | /** 29 | * 是否开始 30 | */ 31 | open: boolean | null; 32 | 33 | /** 34 | * 员工 35 | * 36 | * @maxItems 1000 37 | */ 38 | employee: employee[] 39 | } 40 | 41 | export interface Company { 42 | 43 | /** 44 | * 部门 45 | * 46 | * @minItems 1 47 | */ 48 | departments: Department[] 49 | } 50 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/support.ts: -------------------------------------------------------------------------------- 1 | import {GenericDataOuter, GenericDataReName as GenericDataRename} from './base'; 2 | 3 | interface TestValue { 4 | test: string; 5 | name: number; 6 | bool: boolean; 7 | } 8 | 9 | interface RecordTest { 10 | a: Record; 11 | b: Record; 12 | c: Record<'a' | 'b', string>; 13 | d: Record; 14 | e: Record; 15 | f: Record; 16 | g: Record; 17 | } 18 | 19 | type Hello = "Hello"; 20 | type World = "World"; 21 | type Foo = `${Hello} ${World}!`; 22 | 23 | interface PickOmit { 24 | /** Pick */ 25 | pick: Pick; 26 | /** pickMulti */ 27 | pickMulti: Pick; 28 | omit: Omit; 29 | omitMulti: Omit; 30 | } 31 | 32 | interface GenericData { 33 | data: K; 34 | type: T; 35 | } 36 | 37 | interface GenericTest { 38 | inner: GenericData; 39 | outer: GenericDataOuter<'0' | '1', TestValue>; 40 | outerRename: GenericDataRename<'0' | '1', TestValue>; 41 | } 42 | -------------------------------------------------------------------------------- /src/translate/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file type 3 | * @author xuxiulou 4 | */ 5 | 6 | import { Project, Identifier } from 'ts-morph'; 7 | 8 | import { Schema, PropIterator, PropContext } from '../util'; 9 | 10 | export interface TransOption { 11 | project?: Project; 12 | tsConfigFilePath?: string; 13 | } 14 | 15 | export interface EntryNode { 16 | filePath: string; 17 | nodeName: string; 18 | } 19 | 20 | class GenPlugin { 21 | traverse?: (ctx: PropContext, schema: Schema) => void; 22 | complete?: (schema: Schema) => void; 23 | } 24 | 25 | export interface GenOption { 26 | globalFiles?: string[]; 27 | beforePropMount?: PropIterator; 28 | afterPropMount?: (ctx: PropContext, schema: Schema) => void; 29 | plugins?: GenPlugin[]; 30 | } 31 | 32 | export interface TansNode { 33 | node: Identifier; 34 | root: Schema; 35 | isRef?: boolean; 36 | $ref?: string; 37 | } 38 | 39 | export interface TransResult { 40 | $ref: string; 41 | schema: Schema; 42 | transList?: TansNode[]; 43 | } 44 | 45 | export interface CompositionSchema extends Schema { 46 | $schema?: string; 47 | $id?: string; 48 | definitions?: Schema; 49 | } 50 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/image.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file image 3 | * @author cxtom(cxtom2008@gmail.com) 4 | */ 5 | 6 | import {numberic, integer} from '../../index'; 7 | 8 | /** 9 | * 简单的图片 10 | */ 11 | interface ImageBase { 12 | 13 | type?: "timg" | "online"; 14 | 15 | /** 16 | * 图片链接 17 | * 18 | * @format uri 19 | * @default http://mms-mis.cdn.bcebos.com/graph/2057/star/actor146.jpg 20 | */ 21 | src: string; 22 | } 23 | 24 | /** 25 | * Timg 服务 26 | */ 27 | interface Timg extends ImageBase { 28 | type: "timg"; 29 | /** 拼链接参数 */ 30 | params?: { 31 | /** 32 | * 图片裁剪参数,默认为8 33 | * 34 | * @pattern ^([bpwhfu][\\d_]+|[1-8])$ 35 | * @minimum 1 36 | * @maximum 8 37 | * @default 8 38 | */ 39 | cuttype?: integer | string; 40 | /** 41 | * 用于指定目的图片质量,数值越大,质量越好(最多与原图持平) 42 | * 43 | * @minimum 0 44 | * @maximum 100 45 | * @default 60 46 | */ 47 | size?: integer; 48 | } 49 | } 50 | 51 | /** 52 | * 在线裁剪服务 53 | */ 54 | interface OnlineCut extends ImageBase { 55 | 56 | type: "online"; 57 | 58 | /** 数组 */ 59 | test: Array 60 | } 61 | 62 | export type Image = ImageBase | Timg | OnlineCut; 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Referenced from https://github.com/github/gitignore/blob/master/Node.gitignore 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # Typescript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # next.js build output 63 | .next 64 | 65 | # other stuff 66 | .DS_Store 67 | Thumbs.db 68 | 69 | # IDE configurations 70 | .idea 71 | .vscode 72 | .ts2php 73 | 74 | # build assets 75 | /output 76 | /dist 77 | /dll 78 | coverage 79 | 80 | /example 81 | /docs 82 | /lib 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hoth/typescript-to-json-schema", 3 | "version": "2.1.2", 4 | "description": "typescript to json-schema transpiler", 5 | "keywords": [ 6 | "typescript", 7 | "json-schema" 8 | ], 9 | "author": "cxtom ", 10 | "license": "MIT", 11 | "main": "lib/index.js", 12 | "typings": "lib/index.d.ts", 13 | "files": [ 14 | "lib" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/max-team/typescript-to-json-schema.git" 19 | }, 20 | "scripts": { 21 | "build": "tsc", 22 | "prepublish": "npm run build", 23 | "test": "jest", 24 | "coverage": "jest --coverage" 25 | }, 26 | "dependencies": { 27 | "chalk": "^4.1.1", 28 | "fs-extra": "^8.0.1", 29 | "json-schema-traverse": "^1.0.0", 30 | "lodash": "^4.17.20", 31 | "ts-morph": "^11.0.3", 32 | "typescript": "~3.8.0" 33 | }, 34 | "devDependencies": { 35 | "@babel/core": "^7.4.5", 36 | "@babel/plugin-transform-modules-commonjs": "^7.4.4", 37 | "@babel/preset-env": "^7.4.5", 38 | "@commitlint/cli": "^11.0.0", 39 | "@commitlint/config-conventional": "^7.5.0", 40 | "@types/fs-extra": "^7.0.0", 41 | "@types/jest": "^24.0.13", 42 | "@types/node": "^12.0.12", 43 | "@typescript-eslint/eslint-plugin": "^1.9.0", 44 | "commitizen": "^4.2.1", 45 | "conventional-changelog": "^3.1.3", 46 | "conventional-changelog-cli": "^2.0.12", 47 | "cz-conventional-changelog": "^2.1.0", 48 | "husky": "^1.3.1", 49 | "jest": "^26.4.2", 50 | "jest-config": "^26.4.2", 51 | "ts-jest": "^26.3.0", 52 | "ts-node": "^8.2.0" 53 | }, 54 | "jest": { 55 | "verbose": true, 56 | "preset": "ts-jest/presets/js-with-babel", 57 | "testEnvironment": "node", 58 | "testRegex": "(/__tests__/.*\\.(test|spec))\\.(ts|js)$", 59 | "moduleFileExtensions": [ 60 | "ts", 61 | "js" 62 | ], 63 | "coveragePathIgnorePatterns": [ 64 | "/node_modules/", 65 | "/__tests__/" 66 | ], 67 | "moduleDirectories": [ 68 | "node_modules" 69 | ], 70 | "coverageThreshold": { 71 | "global": { 72 | "branches": 90, 73 | "functions": 95, 74 | "lines": 95, 75 | "statements": 95 76 | } 77 | }, 78 | "collectCoverageFrom": [ 79 | "src/**.ts" 80 | ] 81 | }, 82 | "config": { 83 | "commitizen": { 84 | "path": "cz-conventional-changelog" 85 | } 86 | }, 87 | "husky": { 88 | "hooks": { 89 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/__tests__/translate/support.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum ValEnum { 3 | val1 = 2, 4 | val2 = 3 5 | } 6 | 7 | type Person = { 8 | name: string; 9 | age: string; 10 | }; 11 | 12 | export interface BaseType { 13 | /** 14 | * 字符串 15 | * @minLength 1 16 | * @maxLength 100 17 | * @pattern ^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/)?[^\s]*$ 18 | * @format uri 19 | */ 20 | str: string; 21 | 22 | /** 23 | * 数字 24 | * @minimum 0 25 | * @exclusiveMinimum 0 26 | * @maximum 100 27 | * @exclusiveMaximum 100 28 | * @multipleOf 10 29 | */ 30 | num: number; 31 | 32 | /** 布尔 */ 33 | bool: boolean; 34 | 35 | /** 36 | * 数组 37 | * @minItems 1 38 | * @maxItems 10 39 | * @uniqueItems true 40 | */ 41 | arr: string[]; 42 | /** 数组:Array */ 43 | arrTemp?: Array; 44 | 45 | /** 枚举 */ 46 | enum: ValEnum; 47 | /** 枚举 */ 48 | enumUnion?: 2 | 3; 49 | 50 | /** Index Access */ 51 | indexAccess: Person['age']; 52 | } 53 | 54 | type Hello = "Hello"; 55 | type World = "World"; 56 | export type Foo = `${Hello} ${World}!`; 57 | 58 | interface CompositionType { 59 | /** 模板字符串 */ 60 | tplLiteral: Foo; 61 | } 62 | 63 | export interface Simple { 64 | arr: number[]; 65 | } 66 | 67 | interface RecordSupport { 68 | a: Record; 69 | b: Record; 70 | c: Record<'a' | 'b', string>; 71 | d: Record; 72 | e: Record; 73 | f: Record; 74 | g: Record; 75 | } 76 | 77 | interface PickSupport { 78 | single: Pick; 79 | multi: Pick; 80 | } 81 | 82 | interface OmitSupport { 83 | single: Omit; 84 | multi: Omit; 85 | } 86 | 87 | interface GenericTpl { 88 | data: K; 89 | type: T; 90 | } 91 | interface GenericTest { 92 | a: GenericTpl; 93 | b: GenericTpl<'0' | '1', Simple>; 94 | } 95 | 96 | interface ExtendSupport extends BaseType, CompositionType { 97 | custom: string; 98 | } 99 | interface RefExtendSupprt { 100 | a: ExtendSupport; 101 | } 102 | 103 | interface OneOfSupport { 104 | anyof: BaseType | BaseType[]; 105 | } 106 | 107 | export namespace TestNameSpace { 108 | export interface Fooz { 109 | bar: number; 110 | } 111 | export namespace InnerSpace { 112 | export interface InnerFooz { 113 | bar: string; 114 | } 115 | } 116 | } 117 | interface NameSpaceSupport { 118 | a: TestNameSpace.Fooz; 119 | b: TestNameSpace.InnerSpace.InnerFooz; 120 | } 121 | 122 | export default interface DefaultExport { 123 | c: string; 124 | } 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # typescript-to-json-schema 2 | 3 | ![Language](https://img.shields.io/badge/-TypeScript-blue.svg) 4 | [![npm package](https://img.shields.io/npm/v/@hoth%2ftypescript-to-json-schema.svg)](https://www.npmjs.org/package/@hoth/typescript-to-json-schema) 5 | [![Build Status](https://travis-ci.org/max-team/typescript-to-json-schema.svg?branch=master)](https://travis-ci.org/max-team/typescript-to-json-schema) 6 | 7 | TypeScript to JsonSchema Transpiler 8 | 9 | ## Usage 10 | 11 | ### Programmatic use 12 | 13 | ```typescript 14 | import {resolve} from 'path'; 15 | import {generateSchema} from '@hoth/typescript-to-json-schema'; 16 | 17 | const {schemas} = generateSchema([resolve('demo.ts')]); 18 | ``` 19 | 20 | ### Annotations 21 | 22 | For example 23 | 24 | `company.ts`: 25 | 26 | ```typescript 27 | import { integer } from "@hoth/typescript-to-json-schema"; 28 | 29 | type employee = { 30 | 31 | /** 32 | * 雇员名字 33 | * 34 | * @maxLength 50 35 | * @minLength 1 36 | */ 37 | name: string; 38 | 39 | /** 40 | * 雇员年龄 41 | * 42 | * @minimum 18 43 | */ 44 | age: integer; 45 | }; 46 | 47 | interface Department { 48 | 49 | /** 50 | * 是否开始 51 | */ 52 | open: boolean | null; 53 | 54 | /** 55 | * 员工 56 | * 57 | * @maxItems 1000 58 | */ 59 | employee: employee[] 60 | } 61 | 62 | export interface Company { 63 | 64 | /** 65 | * 部门 66 | * 67 | * @minItems 1 68 | */ 69 | departments: Department[] 70 | } 71 | ``` 72 | 73 | output 74 | 75 | ```json 76 | { 77 | "$schema": "http://json-schema.org/draft-07/schema#", 78 | "$id": "http://www.baidu.com/schemas/company.json", 79 | "$ref": "#/definitions/company", 80 | "definitions": { 81 | "department": { 82 | "type": "object", 83 | "properties": { 84 | "open": { 85 | "oneOf": [ 86 | { 87 | "type": "boolean" 88 | }, 89 | { 90 | "type": "null" 91 | } 92 | ], 93 | "description": "是否开始" 94 | }, 95 | "employee": { 96 | "type": "array", 97 | "items": { 98 | "$ref": "#/definitions/employee" 99 | }, 100 | "maxItems": 1000, 101 | "description": "员工" 102 | } 103 | }, 104 | "required": [ 105 | "open", 106 | "employee" 107 | ] 108 | }, 109 | "company": { 110 | "type": "object", 111 | "properties": { 112 | "departments": { 113 | "type": "array", 114 | "items": { 115 | "$ref": "#/definitions/department" 116 | }, 117 | "minItems": 1, 118 | "description": "部门" 119 | } 120 | }, 121 | "required": [ 122 | "departments" 123 | ] 124 | }, 125 | "employee": { 126 | "type": "object", 127 | "properties": { 128 | "name": { 129 | "type": "string", 130 | "minLength": 1, 131 | "maxLength": 50, 132 | "description": "雇员名字" 133 | }, 134 | "age": { 135 | "type": "integer", 136 | "minimum": 18, 137 | "description": "雇员年龄" 138 | } 139 | }, 140 | "required": [ 141 | "name", 142 | "age" 143 | ] 144 | } 145 | } 146 | } 147 | ``` -------------------------------------------------------------------------------- /src/translate/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Translate 3 | * @author xuxiulou 4 | */ 5 | 6 | import { Project } from 'ts-morph'; 7 | import { set, get, isArray, omit, isPlainObject } from 'lodash'; 8 | import traverse from 'json-schema-traverse'; 9 | 10 | import { mergeSchema } from '../util'; 11 | import { Definition, REF_PREFIX } from './definition'; 12 | import { TransOption, EntryNode, GenOption, TansNode, CompositionSchema } from './types'; 13 | 14 | function mergeAnyOf(schema: CompositionSchema): CompositionSchema { 15 | const ret = {...schema}; 16 | 17 | for (const key in ret) { 18 | if (!ret.hasOwnProperty(key)) { 19 | continue; 20 | } 21 | if (isPlainObject(ret[key])) { 22 | ret[key] = mergeAnyOf(ret[key]); 23 | } 24 | } 25 | 26 | if (isArray(ret.anyOf)) { 27 | let afschema = mergeAnyOf(ret.anyOf[0]); 28 | for (let i = 1; i < ret.anyOf.length; i++) { 29 | afschema = mergeSchema(afschema, mergeAnyOf(ret.anyOf[i])); 30 | } 31 | return Object.assign(omit(ret, 'anyOf'), afschema); 32 | } 33 | 34 | return omit(ret, 'anyOf'); 35 | } 36 | 37 | export class Translate { 38 | 39 | readonly project: Project; 40 | 41 | constructor(opts?: TransOption) { 42 | const { project, tsConfigFilePath } = opts || {}; 43 | 44 | if (project && !project.addSourceFileAtPath) { 45 | throw new Error('The version of ts-morph require 11.0.3+.'); 46 | } 47 | 48 | this.project = project || new Project({ 49 | tsConfigFilePath 50 | }); 51 | } 52 | 53 | getProject() { 54 | return this.project; 55 | } 56 | 57 | generateSchema(entry: EntryNode, opts?: GenOption): CompositionSchema { 58 | const {filePath, nodeName} = entry || {}; 59 | if (!filePath || !nodeName) { 60 | return; 61 | } 62 | const sourceFile = this.project.addSourceFileAtPath(filePath); 63 | const interfaceInst = sourceFile.getInterface(nodeName); 64 | if (!interfaceInst) { 65 | return; 66 | } 67 | 68 | let schema: CompositionSchema = {}; 69 | const definitions = {}; 70 | const definition = new Definition(this.project, opts); 71 | let transCache: TansNode[] = []; 72 | transCache.push({ node: interfaceInst.getNameNode(), root: schema, isRef: false }); 73 | while (transCache.length > 0) { 74 | const { node, root, isRef, $ref: ref } = transCache.shift(); 75 | const { $ref, schema, transList } = definition.generate(node, ref); 76 | if (!schema) { 77 | continue; 78 | } 79 | if (transList) { 80 | transCache = [...transCache, ...transList]; 81 | } 82 | if (isRef && $ref) { 83 | set(definitions, $ref.split(REF_PREFIX)[1].split('/'), schema); 84 | } 85 | else { 86 | Object.assign(root, schema); 87 | delete root.$ref; 88 | } 89 | } 90 | 91 | schema = Object.keys(definitions).length === 0 ? schema : { 92 | ...schema, 93 | definitions 94 | }; 95 | 96 | schema = { 97 | '$schema': 'http://json-schema.org/draft-07/schema#', 98 | ...mergeAnyOf(schema) 99 | }; 100 | 101 | opts?.plugins && opts?.plugins.forEach(plugin => plugin.complete && plugin.complete(schema)); 102 | 103 | return schema; 104 | } 105 | 106 | mergeDefinitions(schema: CompositionSchema): CompositionSchema { 107 | const { definitions } = schema; 108 | if (!definitions) { 109 | return schema; 110 | } 111 | traverse(schema, { 112 | cb: obj => { 113 | const { $ref } = obj; 114 | if (!$ref) { 115 | return; 116 | } 117 | delete obj.$ref; 118 | Object.assign(obj, get(definitions, $ref.split(REF_PREFIX)[1].split('/')) || {}); 119 | } 120 | }); 121 | return omit(schema, 'definitions'); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file ts -> schema generator 3 | * @author cxtom(cxtom2008@gmail.com) 4 | */ 5 | 6 | import { Project, SourceFile, ModuleDeclaration } from 'ts-morph'; 7 | import { basename } from 'path'; 8 | 9 | import { isPlainObject, get, omit, isArray } from 'lodash'; 10 | 11 | import { 12 | processInterface, 13 | processTypeAlias, 14 | processEnum, 15 | mergeSchema, 16 | Schema, 17 | PropIterator, 18 | CompilerState 19 | } from './util'; 20 | 21 | /** 22 | * 内置类型:数字,或者可转为数字的字符串 23 | */ 24 | export type numberic = string | number; 25 | 26 | /** 27 | * 内置类型:整型 28 | */ 29 | export type integer = number; 30 | 31 | 32 | export interface GenerateSchemaOption { 33 | getId(filePath: string): string; 34 | getRootName?: (filePath: string) => string; 35 | beforePropMount?: PropIterator; 36 | tsConfigFilePath?: string; 37 | baseUrl?: string; 38 | } 39 | 40 | function getDefinitions(sourceFile: SourceFile, state: CompilerState, namespace?: ModuleDeclaration) { 41 | const source = namespace || sourceFile; 42 | let definitions: {[name: string]: object} = {}; 43 | 44 | const interfaces = source.getInterfaces(); 45 | definitions = interfaces.reduce( 46 | (prev, node) => node.getTypeParameters().length > 0 ? prev : { 47 | ...prev, 48 | [node.getName().toLowerCase()]: processInterface(node, sourceFile, state) 49 | }, 50 | definitions 51 | ); 52 | 53 | const typeAliases = source.getTypeAliases(); 54 | definitions = typeAliases.reduce( 55 | (prev, node) => ({ 56 | ...prev, 57 | [node.getName().toLowerCase()]: processTypeAlias(node, sourceFile, state) 58 | }), 59 | definitions 60 | ); 61 | 62 | const enums = source.getEnums(); 63 | definitions = enums.reduce( 64 | (prev, node) => ({ 65 | ...prev, 66 | [node.getName().toLowerCase()]: processEnum(node) 67 | }), 68 | definitions 69 | ); 70 | 71 | return definitions; 72 | } 73 | 74 | export function generateSchema(files: string[], options: GenerateSchemaOption): {schemas: {[id: string]: Schema}} { 75 | 76 | const { 77 | getId, 78 | beforePropMount, 79 | getRootName = filePath => basename(filePath, '.ts').toLowerCase(), 80 | tsConfigFilePath, 81 | baseUrl = 'http://www.baidu.com/schemas' 82 | } = options; 83 | 84 | const project = new Project({ 85 | tsConfigFilePath 86 | }); 87 | 88 | const state = { getId, beforePropMount }; 89 | 90 | const sourceFiles = project.addSourceFilesAtPaths(files); 91 | project.resolveSourceFileDependencies(); 92 | 93 | const schemas: {[name: string]: object} = {}; 94 | 95 | for (const sourceFile of sourceFiles) { 96 | 97 | const filePath = sourceFile.getFilePath(); 98 | const rootName = getRootName(filePath); 99 | let definitions: {[name: string]: object} = {}; 100 | 101 | try { 102 | const namespaces = sourceFile.getModules(); 103 | definitions = namespaces.reduce( 104 | (prev, namespace) => ({ 105 | ...prev, 106 | [namespace.getName().toLowerCase()]: getDefinitions(sourceFile, state, namespace) 107 | }), 108 | definitions 109 | ); 110 | 111 | definitions = { 112 | ...definitions, 113 | ...getDefinitions(sourceFile, state) 114 | }; 115 | } 116 | catch (e) { 117 | console.error(`${filePath} generate error! ${e.stack}`); 118 | return { schemas: {} }; 119 | } 120 | 121 | if (Object.keys(definitions).length <= 0) { 122 | continue; 123 | } 124 | 125 | const id = getId(filePath); 126 | 127 | schemas[id] = { 128 | '$schema': 'http://json-schema.org/draft-07/schema#', 129 | '$id': `${baseUrl}${baseUrl && !/\/$/.test(baseUrl) ? '/' : ''}${id}`, 130 | '$ref': definitions[rootName] ? `#/definitions/${rootName}` : undefined, 131 | definitions 132 | }; 133 | } 134 | 135 | return { 136 | schemas 137 | }; 138 | } 139 | 140 | interface SchemaList { 141 | [id: string]: Schema 142 | } 143 | 144 | export function mergeSchemas(schemas: SchemaList, options: { mergeAnyOf?: boolean, mergeAllOf?: boolean }) { 145 | 146 | const { 147 | mergeAnyOf = true, 148 | mergeAllOf = true 149 | } = options; 150 | 151 | function getSchema(ref: string, id: string, schemas: SchemaList) { 152 | let [refId, pointer] = ref.split('#/'); 153 | refId = refId || id; 154 | let ret = walk(get(schemas[refId], pointer.split('/')), refId, schemas); 155 | return ret; 156 | } 157 | 158 | function walk(element: Schema, id: string, schemas: SchemaList) { 159 | if (!element) { 160 | return; 161 | } 162 | let ret = {...element}; 163 | if (element.$ref) { 164 | ret = mergeSchema(getSchema(element.$ref, id, schemas), omit(element, '$ref')); 165 | } 166 | 167 | for (const key in element) { 168 | if (ret.hasOwnProperty(key)) { 169 | if (isPlainObject(element[key])) { 170 | ret[key] = walk(element[key], id, schemas); 171 | } 172 | if (isArray(element[key]) && ['oneOf', 'anyOf', 'allOf', 'items'].includes(key)) { 173 | ret[key] = element[key].map(e => isPlainObject(e) && walk(e, id, schemas)); 174 | } 175 | } 176 | } 177 | 178 | if (element.anyOf && mergeAnyOf) { 179 | ret = mergeSchema(omit(ret, 'anyOf'), walk(element.anyOf[0], id, schemas)); 180 | for (let i = 1; i < element.anyOf.length; i++) { 181 | ret = mergeSchema(ret, walk(element.anyOf[i], id, schemas)) 182 | } 183 | } 184 | 185 | if (element.allOf && mergeAllOf) { 186 | let hasIfThen = true; 187 | for (let i = 0; i < element.allOf.length; i++) { 188 | if (!element.allOf[i].if || !element.allOf[i].then) { 189 | hasIfThen = false; 190 | break; 191 | } 192 | } 193 | if (!hasIfThen) { 194 | const allOfArray = [ ...ret.allOf ]; 195 | ret = omit(ret, 'allOf'); 196 | for (let item of allOfArray) { 197 | item = walk(item, id, schemas); 198 | ret = mergeSchema(ret, item); 199 | } 200 | } 201 | } 202 | 203 | return ret; 204 | } 205 | 206 | const ret = {}; 207 | 208 | for (const id in schemas) { 209 | if (schemas.hasOwnProperty(id)) { 210 | const element = schemas[id]; 211 | try { 212 | ret[id] = walk(schemas[id], id, schemas); 213 | } 214 | catch (e) { 215 | console.error(`merge ${id} failed! ${e.stack}`); 216 | } 217 | if (element.$ref) { 218 | delete ret[id].definitions; 219 | } 220 | } 221 | } 222 | 223 | return ret; 224 | } 225 | 226 | export { Schema, PropContext } from './util'; 227 | export * from './translate/types'; 228 | export { Translate } from './translate'; 229 | -------------------------------------------------------------------------------- /src/__tests__/translate.test.js: -------------------------------------------------------------------------------- 1 | 2 | const { Project } = require('ts-morph'); 3 | const { Translate } = require('../index'); 4 | 5 | describe('tranform', () => { 6 | 7 | it('project: internal', () => { 8 | const translate = new Translate(); 9 | expect(translate.getProject()).toBeDefined(); 10 | }); 11 | 12 | it('project: external', () => { 13 | const project = new Project(); 14 | const translate = new Translate({project}); 15 | expect(translate.getProject()).toBe(project); 16 | }); 17 | 18 | it('project: external incompatible', () => { 19 | expect(() => { 20 | new Translate({project: {}}); 21 | }).toThrow(); 22 | }); 23 | 24 | it('schema: base type', () => { 25 | const translate = new Translate(); 26 | const res = translate.generateSchema({ 27 | filePath: './src/__tests__/translate/support.ts', 28 | nodeName: 'BaseType' 29 | }); 30 | expect(Object.keys(res.properties.str)).toEqual([ 31 | 'type', 'minLength', 'maxLength', 'pattern', 'format', 'description' 32 | ]); 33 | expect(Object.keys(res.properties.num)).toEqual([ 34 | 'type', 'minimum', 'exclusiveMinimum', 'maximum', 'exclusiveMaximum', 'multipleOf', 'description' 35 | ]); 36 | expect(Object.keys(res.properties.bool)).toEqual([ 37 | 'type', 'description' 38 | ]); 39 | expect(Object.keys(res.properties.arr)).toEqual([ 40 | 'type', 'items', 'minItems', 'maxItems', 'uniqueItems', 'description' 41 | ]); 42 | expect(Object.keys(res.properties.enum)).toEqual([ 43 | '$ref', 'description' 44 | ]); 45 | expect(Object.keys(res.properties.enumUnion)).toEqual([ 46 | 'enum', 'description' 47 | ]); 48 | expect(Object.keys(res.properties.indexAccess)).toEqual([ 49 | '$ref', 'description' 50 | ]); 51 | expect(res.definitions).toBeDefined(); 52 | 53 | const schema = translate.mergeDefinitions(res); 54 | expect(schema.properties.enum).toEqual(schema.properties.enumUnion); 55 | }); 56 | 57 | it('schema: template literal', () => { 58 | const translate = new Translate(); 59 | const res = translate.generateSchema({ 60 | filePath: './src/__tests__/translate/support.ts', 61 | nodeName: 'CompositionType' 62 | }); 63 | const schema = translate.mergeDefinitions(res); 64 | expect(schema.properties.tplLiteral).toEqual({ 65 | description: '模板字符串', 66 | const: 'Hello World!' 67 | }); 68 | }); 69 | 70 | it('schema: record', () => { 71 | const translate = new Translate(); 72 | const res = translate.generateSchema({ 73 | filePath: './src/__tests__/translate/support.ts', 74 | nodeName: 'RecordSupport' 75 | }); 76 | const schema = translate.mergeDefinitions(res); 77 | expect(schema.properties.a).toEqual({ 78 | type: 'object', 79 | propertyNames: { type: 'string' }, 80 | additionalProperties: { type: 'string' } 81 | }); 82 | expect(schema.properties.c.propertyNames).toEqual({ 83 | enum: ['a', 'b'] 84 | }); 85 | expect(schema.properties.d.additionalProperties).toEqual({ 86 | type: 'boolean' 87 | }); 88 | expect(schema.properties.e.additionalProperties).toEqual({ 89 | type: 'number' 90 | }); 91 | expect(schema.properties.f.additionalProperties).toEqual({ 92 | type: 'object', 93 | properties: { 94 | arr: { 95 | type: 'array', 96 | items: { type: 'number' } 97 | } 98 | }, 99 | required: ['arr'] 100 | }); 101 | expect(schema.properties.g.additionalProperties).toBeUndefined(); 102 | }); 103 | 104 | it('schema: pick', () => { 105 | const translate = new Translate(); 106 | const res = translate.generateSchema({ 107 | filePath: './src/__tests__/translate/support.ts', 108 | nodeName: 'PickSupport' 109 | }); 110 | const schema = translate.mergeDefinitions(res); 111 | expect(Object.keys(schema.properties.single.properties)).toEqual(['str']); 112 | expect(Object.keys(schema.properties.multi.properties)).toEqual(['str', 'num']); 113 | }); 114 | 115 | it('schema: omit', () => { 116 | const translate = new Translate(); 117 | const res = translate.generateSchema({ 118 | filePath: './src/__tests__/translate/support.ts', 119 | nodeName: 'OmitSupport' 120 | }); 121 | const schema = translate.mergeDefinitions(res); 122 | expect(Object.keys(schema.properties.single.properties)).not.toContain(['str']); 123 | expect(Object.keys(schema.properties.multi.properties)).not.toContain(['str', 'num']); 124 | }); 125 | 126 | it('schema: generic', () => { 127 | const translate = new Translate(); 128 | const res = translate.generateSchema({ 129 | filePath: './src/__tests__/translate/support.ts', 130 | nodeName: 'GenericTest' 131 | }); 132 | const schema = translate.mergeDefinitions(res); 133 | expect(schema.properties.a).toEqual({ 134 | type: 'object', 135 | properties: { 136 | type: { type: 'string' }, 137 | data: { 138 | type: 'object', 139 | properties: { 140 | arr: { 141 | type: 'array', 142 | items: { type: 'number' } 143 | } 144 | }, 145 | required: ['arr'] 146 | } 147 | }, 148 | required: ['data', 'type'] 149 | }); 150 | expect(schema.properties.b).toEqual({ 151 | type: 'object', 152 | properties: { 153 | type: { enum: ['0', '1'] }, 154 | data: { 155 | type: 'object', 156 | properties: { 157 | arr: { 158 | type: 'array', 159 | items: { type: 'number' } 160 | } 161 | }, 162 | required: ['arr'] 163 | } 164 | }, 165 | required: ['data', 'type'] 166 | }); 167 | }); 168 | 169 | it('schema: extends normal', () => { 170 | const translate = new Translate(); 171 | const res = translate.generateSchema({ 172 | filePath: './src/__tests__/translate/support.ts', 173 | nodeName: 'ExtendSupport' 174 | }); 175 | const schema = translate.mergeDefinitions(res); 176 | expect(schema.properties).toHaveProperty('str'); 177 | }); 178 | 179 | it('schema: extends ref', () => { 180 | const translate = new Translate(); 181 | const res = translate.generateSchema({ 182 | filePath: './src/__tests__/translate/support.ts', 183 | nodeName: 'RefExtendSupprt' 184 | }); 185 | const schema = translate.mergeDefinitions(res); 186 | expect(schema.properties.a.properties).toHaveProperty('str'); 187 | }); 188 | 189 | it('schema: oneOf', () => { 190 | const translate = new Translate(); 191 | const res = translate.generateSchema({ 192 | filePath: './src/__tests__/translate/support.ts', 193 | nodeName: 'OneOfSupport' 194 | }); 195 | const schema = translate.mergeDefinitions(res); 196 | expect(schema.properties.anyof).toHaveProperty('oneOf'); 197 | }); 198 | 199 | it('schema: namespace', () => { 200 | const translate = new Translate(); 201 | const res = translate.generateSchema({ 202 | filePath: './src/__tests__/translate/support.ts', 203 | nodeName: 'NameSpaceSupport' 204 | }); 205 | const schema = translate.mergeDefinitions(res); 206 | expect(schema.properties.a.properties.bar).toEqual({ 207 | type: 'number' 208 | }); 209 | expect(schema.properties.b.properties.bar).toEqual({ 210 | type: 'string' 211 | }); 212 | }); 213 | 214 | it('schema: import & global', () => { 215 | const translate = new Translate(); 216 | const res = translate.generateSchema({ 217 | filePath: './src/__tests__/translate/import.ts', 218 | nodeName: 'ImportSupport' 219 | }); 220 | const schema = translate.mergeDefinitions(res); 221 | expect(schema.properties.a).toEqual({ 222 | enum: [2, 3] 223 | }); 224 | expect(schema.properties.b).toEqual({ 225 | const: 'Hello World!' 226 | }); 227 | expect(schema.properties.c).toEqual({ 228 | type: 'object', 229 | properties: { 230 | arr: { 231 | type: 'array', 232 | items: { type: 'number' } 233 | } 234 | }, 235 | required: ['arr'] 236 | }); 237 | 238 | const gtranslate = new Translate(); 239 | const gres = gtranslate.generateSchema({ 240 | filePath: './src/__tests__/translate/global.ts', 241 | nodeName: 'GlobalSupport' 242 | }, { 243 | globalFiles: ['./src/__tests__/translate/support.ts'] 244 | }); 245 | const gschema = gtranslate.mergeDefinitions(gres); 246 | expect(gschema).toEqual(schema); 247 | }); 248 | 249 | it('schema: unsupport type', () => { 250 | const translate = new Translate(); 251 | const res = translate.generateSchema({ 252 | filePath: './src/__tests__/translate/global.ts', 253 | nodeName: 'UnSupportType' 254 | }); 255 | expect(res.properties).not.toHaveProperty('a'); 256 | }); 257 | 258 | }); -------------------------------------------------------------------------------- /src/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file unit test 3 | * @author cxtom(cxtom2008@gmail.com) 4 | */ 5 | 6 | /* eslint-disable fecs-no-require */ 7 | 8 | const { 9 | readdirSync 10 | } = require('fs-extra'); 11 | const { 12 | resolve, 13 | basename 14 | } = require('path'); 15 | const { 16 | generateSchema, 17 | mergeSchemas 18 | } = require('../index'); 19 | 20 | describe('typescript-json-schema', () => { 21 | 22 | const files = readdirSync(resolve(__dirname, 'fixtures')) 23 | .filter(n => n.includes('.ts')) 24 | .map(n => resolve(__dirname, 'fixtures', n)); 25 | 26 | const { 27 | schemas 28 | } = generateSchema(files, { 29 | getId(filePath) { 30 | return basename(filePath, '.ts') + '.json'; 31 | } 32 | }); 33 | 34 | const image = schemas['image.json']; 35 | const company = schemas['company.json']; 36 | const support = schemas['support.json']; 37 | const namespace = schemas['namespace.json']; 38 | 39 | // console.log(JSON.stringify(image, null, 2)); 40 | 41 | it('$schema & $id & $ref', function () { 42 | expect(image.$schema).toBe('http://json-schema.org/draft-07/schema#'); 43 | expect(image.$id).toBe('http://www.baidu.com/schemas/image.json'); 44 | expect(image.$ref).toBe('#/definitions/image'); 45 | }); 46 | 47 | it('definitions', function () { 48 | expect(Object.keys(image.definitions)).toEqual(['imagebase', 'timg', 'onlinecut', 'image']); 49 | }); 50 | 51 | it('boolean & null', function () { 52 | expect(company.definitions.department.properties.open).toEqual({ 53 | oneOf: [{ 54 | type: 'boolean' 55 | }, { 56 | const: null 57 | }], 58 | description: '是否开始' 59 | }); 60 | }); 61 | 62 | // it('oneOf', function () { 63 | // expect(image.definitions.timg.anyOf[1].properties.params.properties.cuttype).toEqual({ 64 | // oneOf: [{ 65 | // type: 'integer', 66 | // minimum: 1, 67 | // maximum: 8, 68 | // default: 8 69 | // }, 70 | // { 71 | // type: 'string', 72 | // pattern: '^([bpwhfu][\\\\d_]+|[1-8])$' 73 | // } 74 | // ], 75 | // description: '图片裁剪参数,默认为8' 76 | // }); 77 | // }); 78 | 79 | it('array', function () { 80 | 81 | expect(company.definitions.department.properties.employee).toEqual({ 82 | type: 'array', 83 | items: { 84 | $ref: '#/definitions/employee' 85 | }, 86 | maxItems: 1000, 87 | description: '员工' 88 | }); 89 | 90 | expect(company.definitions.company.properties.departments.items).toEqual({ 91 | $ref: '#/definitions/department' 92 | }); 93 | 94 | expect(image.definitions.onlinecut.anyOf[1].properties.test).toEqual({ 95 | type: 'array', 96 | items: { 97 | type: 'string' 98 | }, 99 | description: "数组" 100 | }); 101 | }); 102 | 103 | it('literal', function () { 104 | const definitions = support.definitions; 105 | expect(definitions.hello).toEqual({ const: 'Hello' }); 106 | expect(definitions.world).toEqual({ const: 'World' }); 107 | expect(definitions.foo).toEqual({ const: 'Hello World!' }); 108 | }); 109 | 110 | it('record', function () { 111 | const properties = support.definitions.recordtest.properties; 112 | expect(properties.a).toEqual({ 113 | type: 'object', 114 | propertyNames: { 115 | type: 'string' 116 | }, 117 | additionalProperties: { 118 | type: 'string' 119 | } 120 | }); 121 | expect(properties.b.propertyNames).toEqual({ 122 | type: 'number' 123 | }); 124 | expect(properties.c.propertyNames).toEqual({ 125 | enum: ['a', 'b'] 126 | }); 127 | expect(properties.d.additionalProperties).toEqual({ 128 | type: 'boolean' 129 | }); 130 | expect(properties.e.additionalProperties).toEqual({ 131 | type: 'number' 132 | }); 133 | expect(properties.f.additionalProperties).toEqual({ 134 | $ref: '#/definitions/testvalue' 135 | }); 136 | expect(properties.g.additionalProperties).toEqual({}); 137 | }); 138 | 139 | it('pickomit', function () { 140 | const {pick, pickMulti, omit, omitMulti} = support.definitions.pickomit.properties; 141 | expect(pick).toEqual({ 142 | type: 'object', 143 | properties: { 144 | name: { 145 | type: 'number' 146 | } 147 | }, 148 | required: ['name'], 149 | description: 'Pick' 150 | }); 151 | expect(pickMulti).toEqual({ 152 | type: 'object', 153 | properties: { 154 | name: { 155 | type: 'number' 156 | }, 157 | bool: { 158 | type: 'boolean' 159 | } 160 | }, 161 | required: ['name', 'bool'], 162 | description: 'pickMulti' 163 | }); 164 | expect(omit).toEqual({ 165 | type: 'object', 166 | properties: { 167 | test: { 168 | type: 'string' 169 | }, 170 | bool: { 171 | type: 'boolean' 172 | } 173 | }, 174 | required: ['test', 'bool'] 175 | }); 176 | expect(omitMulti).toEqual({ 177 | type: 'object', 178 | properties: { 179 | test: { 180 | type: 'string' 181 | } 182 | }, 183 | required: ['test'] 184 | }); 185 | }); 186 | 187 | it('Generic', function () { 188 | const {inner, outer, outerRename} = support.definitions.generictest.properties; 189 | expect(inner).toEqual({ 190 | type: 'object', 191 | properties: { 192 | data: { 193 | $ref: '#/definitions/testvalue' 194 | }, 195 | type: { 196 | type: 'string' 197 | } 198 | }, 199 | required: ['data', 'type'] 200 | }); 201 | expect(outer).toEqual({ 202 | type: 'object', 203 | properties: { 204 | name: { 205 | type: 'string' 206 | }, 207 | data: { 208 | $ref: '#/definitions/testvalue' 209 | }, 210 | type: { 211 | enum: ['0', '1'] 212 | } 213 | }, 214 | required: ['name', 'data', 'type'] 215 | }); 216 | expect(outerRename).toEqual({ 217 | type: 'object', 218 | properties: { 219 | rename: { 220 | type: 'string' 221 | }, 222 | data: { 223 | $ref: '#/definitions/testvalue' 224 | }, 225 | type: { 226 | enum: ['0', '1'] 227 | } 228 | }, 229 | required: ['rename', 'data', 'type'] 230 | }); 231 | }); 232 | 233 | it('namespace', function () { 234 | const {testnamespace, emptynamespace} = namespace.definitions; 235 | expect(testnamespace).toEqual({ 236 | test: { 237 | type: 'object', 238 | properties: { 239 | a: { 240 | type: 'string' 241 | } 242 | }, 243 | required: ['a'] 244 | }, 245 | fooz: { 246 | type: 'object', 247 | properties: { 248 | bar: { 249 | type: 'number' 250 | } 251 | }, 252 | required: ['bar'] 253 | } 254 | }); 255 | expect(emptynamespace).toEqual({}); 256 | }); 257 | 258 | it('merge allOf', function () { 259 | 260 | const testSchemas = { 261 | a: { 262 | allOf: [ 263 | { 264 | type: 'object', 265 | properties: { 266 | show: { 267 | type: 'boolean' 268 | } 269 | }, 270 | allOf: [ 271 | { 272 | if: { 273 | properties: { 274 | show: { 275 | const: true 276 | } 277 | } 278 | }, 279 | then: { 280 | properties: { 281 | price: { 282 | type: 'string' 283 | } 284 | } 285 | } 286 | } 287 | ] 288 | }, 289 | { 290 | type: 'object', 291 | properties: { 292 | show: { 293 | const: true 294 | } 295 | } 296 | } 297 | ] 298 | } 299 | }; 300 | 301 | const result = mergeSchemas(testSchemas, { 302 | mergeAllOf: true 303 | }); 304 | 305 | expect(result.a.properties.show).toEqual({ 306 | type: 'boolean', 307 | const: true 308 | }); 309 | 310 | expect(result.a.allOf.length).toEqual(1); 311 | 312 | }); 313 | 314 | it('merge Generic', function () { 315 | const result = mergeSchemas({ 316 | test: { 317 | $ref: '#/definitions/generictest', 318 | definitions: support.definitions 319 | } 320 | }, { 321 | mergeAllOf: true 322 | }); 323 | 324 | expect(result.test.properties).toBeDefined(); 325 | }); 326 | }); -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file tool functions 3 | * @author cxtom(cxtom2008@gmail.com) 4 | */ 5 | 6 | import { 7 | JSDocableNode, 8 | InterfaceDeclaration, 9 | TypeLiteralNode, 10 | TypeNode, 11 | ts, 12 | TypeReferenceNode, 13 | Identifier, 14 | UnionTypeNode, 15 | TypeGuards, 16 | SourceFile, 17 | TypeAliasDeclaration, 18 | ArrayTypeNode, 19 | LiteralTypeNode, 20 | IndexedAccessTypeNode, 21 | EnumDeclaration, 22 | PropertySignature, 23 | PropertyAccessExpression, 24 | QualifiedName, 25 | TemplateLiteralTypeNode, 26 | ImportSpecifier 27 | } from "ts-morph"; 28 | 29 | import chalk from 'chalk'; 30 | import { omit, pick, uniq } from 'lodash'; 31 | import traverse from 'json-schema-traverse'; 32 | 33 | const buildTypes = new Set(['integer', 'numberic']); 34 | 35 | export interface PropContext { 36 | property: PropertySignature; 37 | typeNode: TypeNode, 38 | interface: InterfaceDeclaration; 39 | sourceFile: SourceFile; 40 | } 41 | export type PropIterator = (ctx: PropContext, schema: Schema) => {ignore: boolean} | undefined; 42 | 43 | export interface CompilerState { 44 | getId(filePath: string): string; 45 | beforePropMount?: PropIterator; 46 | } 47 | 48 | interface ObjectProperties { 49 | [key: string]: Schema; 50 | } 51 | 52 | export interface Schema { 53 | type?: string; 54 | anyOf?: Schema[]; 55 | allOf?: Schema[]; 56 | oneOf?: Schema[]; 57 | $ref?: string; 58 | properties?: ObjectProperties, 59 | required?: string[]; 60 | const?: string | boolean | number; 61 | format?: string; 62 | enum?: (string | number)[]; 63 | items?: Schema; 64 | if?: Schema, 65 | then?: Schema, 66 | propertyNames?: Schema; 67 | additionalProperties?: Schema; 68 | description?: string; 69 | } 70 | 71 | export function getDescription (node: JSDocableNode) { 72 | const jsdocs = node.getJsDocs(); 73 | if (jsdocs.length > 0) { 74 | const des = jsdocs[0].getCommentText(); 75 | return des && des.trim() ? des.trim() : undefined; 76 | } 77 | } 78 | 79 | export function getRequired (node: InterfaceDeclaration | TypeLiteralNode): string[] | undefined { 80 | const required = node.getProperties() 81 | .filter(n => !n.getQuestionTokenNode() && !('ignore' in getJsDocTags(n))) 82 | .map(n => n.getName()); 83 | if (required.length > 0) { 84 | return required; 85 | } 86 | } 87 | 88 | export const getLiteralTypeValue = (node: LiteralTypeNode) => { 89 | const text = node.getText(); 90 | return JSON.parse(/^'/.test(text) ? text.replace(/"/g, '\\"').replace(/(^'|'$)/g, '"').replace(/\\'/g, '\'') : text); 91 | }; 92 | 93 | export function getTypeNodeSchema (node: TypeNode, sourceFile: SourceFile, state: CompilerState): Schema { 94 | switch (node.getKind()) { 95 | case ts.SyntaxKind.LiteralType: 96 | return { const: getLiteralTypeValue(node as LiteralTypeNode) }; 97 | case ts.SyntaxKind.TemplateLiteralType: 98 | return { const: JSON.parse((node as TemplateLiteralTypeNode).getType().getText()) }; 99 | case ts.SyntaxKind.StringKeyword: 100 | case ts.SyntaxKind.NumberKeyword: 101 | case ts.SyntaxKind.BooleanKeyword: 102 | case ts.SyntaxKind.NullKeyword: 103 | case ts.SyntaxKind.ObjectKeyword: 104 | return { 105 | type: node.getText() 106 | }; 107 | case ts.SyntaxKind.TypeLiteral: 108 | return { 109 | type: 'object', 110 | properties: getProperties(node as TypeLiteralNode, sourceFile, state), 111 | required: getRequired(node as TypeLiteralNode) 112 | }; 113 | case ts.SyntaxKind.TypeReference: 114 | return getTypeReferenceSchema(node as TypeReferenceNode, sourceFile, state); 115 | case ts.SyntaxKind.UnionType: { 116 | const types = (node as UnionTypeNode).getTypeNodes(); 117 | if (types.every(t => TypeGuards.isLiteralTypeNode(t))) { 118 | return { 119 | enum: types.map(getLiteralTypeValue) 120 | }; 121 | } 122 | return { 123 | oneOf: types.map(t => getTypeNodeSchema(t, sourceFile, state)) 124 | }; 125 | } 126 | case ts.SyntaxKind.IntersectionType: { 127 | const types = (node as UnionTypeNode).getTypeNodes(); 128 | return { 129 | allOf: types.map(t => getTypeNodeSchema(t, sourceFile, state)) 130 | }; 131 | } 132 | case ts.SyntaxKind.ArrayType: { 133 | return { 134 | type: 'array', 135 | items: getTypeNodeSchema((node as ArrayTypeNode).getElementTypeNode(), sourceFile, state) 136 | }; 137 | } 138 | case ts.SyntaxKind.IndexedAccessType: { 139 | const objectType = (node as IndexedAccessTypeNode).getObjectTypeNode(); 140 | let accesses = [(node as IndexedAccessTypeNode).getIndexTypeNode()] as LiteralTypeNode[]; 141 | let identifier = objectType 142 | while (TypeGuards.isIndexedAccessTypeNode(objectType)) { 143 | identifier = objectType.getObjectTypeNode(); 144 | accesses.push(objectType.getIndexTypeNode() as LiteralTypeNode) 145 | } 146 | // @ts-ignore 147 | const { $ref } = getTypeNodeSchema(identifier, sourceFile, state); 148 | return { 149 | $ref: `${$ref}${accesses.map(a => '/properties/' + getLiteralTypeValue(a))}` 150 | }; 151 | } 152 | default: 153 | return {}; 154 | } 155 | } 156 | 157 | function getTypeReferenceSchema(node: TypeReferenceNode, sourceFile: SourceFile, state: CompilerState): Schema { 158 | const text = node.getText(); 159 | const name = (node as TypeReferenceNode).getTypeName().getText(); 160 | if (buildTypes.has(text)) { 161 | const isInterger = text === 'integer'; 162 | return { 163 | type: isInterger ? text : 'string', 164 | format: isInterger ? undefined : text 165 | }; 166 | } 167 | if (name === 'Array') { 168 | return { 169 | type: 'array', 170 | items: getTypeNodeSchema((node as TypeReferenceNode).getTypeArguments()[0], sourceFile, state) 171 | }; 172 | } 173 | 174 | const typeArgs = (node as TypeReferenceNode).getTypeArguments(); 175 | if (name === 'Record') { 176 | return { 177 | type: 'object', 178 | propertyNames: getTypeNodeSchema(typeArgs[0], sourceFile, state), 179 | additionalProperties: getTypeNodeSchema(typeArgs[1], sourceFile, state) 180 | }; 181 | } 182 | if (['Pick', 'Omit'].includes(name)) { 183 | const schema = processInterface( 184 | getInterface((typeArgs[0] as TypeReferenceNode).getTypeName() as Identifier), 185 | sourceFile, 186 | state 187 | ) as Schema; 188 | const {const: constVal, enum: enumVal} = getTypeNodeSchema(typeArgs[1], sourceFile, state); 189 | const propNames = constVal ? [constVal] : enumVal; 190 | return { 191 | type: 'object', 192 | properties: (name === 'Pick' ? pick : omit)(schema.properties, propNames), 193 | required: (schema.required || []).filter(itm => { 194 | const res = propNames.includes(itm); 195 | return name === 'Pick' ? res : !res; 196 | }) 197 | } 198 | } 199 | // 视为泛型 200 | if (typeArgs.length > 0) { 201 | const typeMaps = typeArgs.map(typeNode => getTypeNodeSchema(typeNode, sourceFile, state)); 202 | const genDec = getInterface((node as TypeReferenceNode).getTypeName() as Identifier); 203 | const relMap = genDec.getTypeParameters().reduce((prev, typeNode, idx) => { 204 | prev[`#/definitions/${typeNode.getName().toLowerCase()}`] = typeMaps[idx]; 205 | return prev; 206 | }, {}); 207 | const schema = processInterface(genDec, sourceFile, state) as Schema; 208 | traverse(schema, { 209 | cb: obj => { 210 | const {$ref} = obj; 211 | if ($ref && relMap[$ref]) { 212 | delete obj.$ref; 213 | Object.assign(obj, relMap[$ref]); 214 | } 215 | } 216 | }); 217 | return schema; 218 | } 219 | 220 | return getRef((node as TypeReferenceNode).getTypeName() as Identifier, sourceFile, state); 221 | } 222 | 223 | export function getInterface(identifier: Identifier): InterfaceDeclaration { 224 | const nodeDecl = identifier.getSymbol().getDeclarations()[0]; 225 | if (nodeDecl.getKind() === ts.SyntaxKind.ImportSpecifier) { 226 | const sourceFile = (nodeDecl as ImportSpecifier).getImportDeclaration().getModuleSpecifierSourceFile(); 227 | return sourceFile.getInterface((nodeDecl as InterfaceDeclaration).getName()); 228 | } 229 | return nodeDecl as InterfaceDeclaration; 230 | } 231 | 232 | function getTagValue(tag, type: 'string' | 'number' | 'boolean'): boolean | number | string | {$data: string} { 233 | if (/^\$\{(.*)\}$/.test(tag)) { 234 | return { 235 | $data: RegExp.$1 236 | }; 237 | } 238 | if (type === 'number') { 239 | return Number(tag); 240 | } 241 | if (type === 'boolean') { 242 | return tag !== 'false'; 243 | } 244 | return tag; 245 | } 246 | 247 | export function mergeTags (schema?: {[name: string]: any}, tags: {[name: string]: any} = {}): object { 248 | const mergedSchema = { ...schema }; 249 | if (mergedSchema.oneOf) { 250 | mergedSchema.oneOf = mergedSchema.oneOf.map(s => mergeTags(s, tags)); 251 | } 252 | if (mergedSchema.allOf) { 253 | mergedSchema.allOf = mergedSchema.allOf.map(s => mergeTags(s, tags)); 254 | } 255 | const changeAttrs = ['default', 'example']; 256 | const numberAttrs = ['minItems', 'maxItems', 'minimum', 'exclusiveMinimum', 'maximum', 'exclusiveMaximum', 'minLength', 'maxLength', 'multipleOf']; 257 | const booleanAttrs = ['uniqueItems', 'flatten']; 258 | if (['integer', 'number'].indexOf(mergedSchema.type) >= 0) { 259 | changeAttrs.forEach(name => { 260 | if (tags[name] != null) { 261 | mergedSchema[name] = getTagValue(tags[name], 'number'); 262 | name === 'default' && delete tags[name]; 263 | } 264 | }); 265 | } 266 | if (mergedSchema.type === 'string') { 267 | changeAttrs.forEach(name => { 268 | if (tags[name] != null) { 269 | mergedSchema[name] = getTagValue(tags[name], 'string'); 270 | } 271 | }); 272 | } 273 | numberAttrs.forEach(name => { 274 | if (tags[name] != null) { 275 | mergedSchema[name] = getTagValue(tags[name], 'number'); 276 | } 277 | }); 278 | booleanAttrs.forEach(name => { 279 | if (tags[name] != null) { 280 | mergedSchema[name] = getTagValue(tags[name], 'boolean'); 281 | } 282 | }); 283 | const jsonAttrs = ['enumNames', 'enumName', 'dataSchemaRequired', 'opencardDataSchemaRequired']; 284 | for (const attr of jsonAttrs) { 285 | if (tags[attr] != null) { 286 | mergedSchema[attr] = JSON.parse(tags[attr]); 287 | } 288 | } 289 | if (mergedSchema.$ref && !mergedSchema.type) { 290 | return { ...mergedSchema, ...tags } 291 | } 292 | return { 293 | ...mergedSchema, 294 | ...omit(tags, [ 295 | ...jsonAttrs, 296 | ...numberAttrs, 297 | ...booleanAttrs, 298 | ...changeAttrs 299 | ]) 300 | }; 301 | } 302 | 303 | export function getProperties (node: InterfaceDeclaration | TypeLiteralNode, sourceFile: SourceFile, state: CompilerState): ObjectProperties { 304 | return node.getProperties().reduce((prev, property) => { 305 | const name = property.getName(); 306 | const typeNode = property.getTypeNodeOrThrow(); 307 | let tags = getJsDocTags(property); 308 | // 忽略 309 | if ('ignore' in tags) { 310 | return prev; 311 | } 312 | let typeSchema = null; 313 | try { 314 | typeSchema = getTypeNodeSchema(typeNode, sourceFile, state); 315 | } catch(err) { 316 | const {line, column} = sourceFile.getLineAndColumnAtPos(typeNode.getStart()); 317 | const msg = 'Get schema failed! Ignore this property.'; 318 | console.log(`${chalk.yellow('WARNING')} ${sourceFile.getFilePath()}\n${chalk.cyan(`line ${line}, col ${column}`)} ${msg}`); 319 | return prev; 320 | } 321 | const schema = { 322 | ...mergeTags(typeSchema, tags), 323 | description: getDescription(property) 324 | }; 325 | 326 | if (state.beforePropMount) { 327 | const {ignore} = state.beforePropMount({ 328 | property, 329 | typeNode, 330 | interface: node as InterfaceDeclaration, 331 | sourceFile 332 | }, schema) || {}; 333 | if (ignore) { 334 | return prev; 335 | } 336 | } 337 | return { 338 | ...prev, 339 | [name]: schema 340 | }; 341 | }, {}); 342 | } 343 | 344 | export function getRef (identifier: Identifier, sourceFile: SourceFile, state: CompilerState) { 345 | const defArr: Identifier[] = []; 346 | if (identifier.getKind() === ts.SyntaxKind.PropertyAccessExpression) { 347 | const expNode = identifier as unknown as PropertyAccessExpression; 348 | defArr.push(expNode.getExpression() as Identifier); 349 | defArr.push(expNode.getNameNode()); 350 | } 351 | else if (identifier.getKind() === ts.SyntaxKind.QualifiedName) { 352 | const expNode = identifier as unknown as QualifiedName; 353 | defArr.push(expNode.getLeft() as Identifier); 354 | defArr.push(expNode.getRight()); 355 | } 356 | else { 357 | defArr.push(identifier); 358 | } 359 | 360 | let file = null; 361 | const defNames = defArr.reduce((prev, def) => { 362 | const definitions = def.getDefinitions(); 363 | file = definitions[0].getSourceFile(); 364 | prev.push(definitions[0].getName().toLowerCase()); 365 | return prev; 366 | }, []); 367 | let id = ''; 368 | if (!(defArr.length === 1 && identifier.getDefinitions()[0].getKind() === ts.ScriptElementKind.typeParameterElement) 369 | && file.getFilePath() !== sourceFile.getFilePath() 370 | ) { 371 | id = state.getId(file.getFilePath()); 372 | } 373 | return { $ref: `${id}#/definitions/${defNames.join('/')}` }; 374 | } 375 | 376 | export function getJsDocTags(node: JSDocableNode) { 377 | return node.getJsDocs().reduce((prev, jsdoc) => { 378 | return { 379 | ...prev, 380 | ...jsdoc.getTags().reduce((p, v) => (v.getKind() === ts.SyntaxKind.JSDocTag ? { ...p, [v.getTagName()]: v.getComment() } : p), {}) 381 | }; 382 | }, {}); 383 | } 384 | 385 | 386 | /** 387 | * 生成 definitions 388 | * 389 | * @param node 节点 390 | */ 391 | export function processInterface (node: InterfaceDeclaration, sourceFile: SourceFile, state: CompilerState) { 392 | 393 | const exts = node.getExtends().map(e => { 394 | return getRef(e.getExpression() as Identifier, sourceFile, state); 395 | }); 396 | 397 | const tags = getJsDocTags(node); 398 | 399 | const base = mergeTags({ 400 | type: 'object', 401 | properties: getProperties(node, sourceFile, state), 402 | required: getRequired(node), 403 | description: getDescription(node) 404 | }, tags); 405 | 406 | const schema = exts.length > 0 ? { 407 | anyOf: [...exts, base] 408 | } : base; 409 | 410 | return { 411 | ...schema 412 | }; 413 | } 414 | 415 | export function processTypeAlias (node: TypeAliasDeclaration, sourceFile: SourceFile, state: CompilerState) { 416 | const typeNode = node.getTypeNode(); 417 | const tags = getJsDocTags(node); 418 | const schema = mergeTags(getTypeNodeSchema(typeNode, sourceFile, state), tags); 419 | return { 420 | ...schema, 421 | description: getDescription(node) 422 | }; 423 | } 424 | 425 | export function processEnum (node: EnumDeclaration) { 426 | return { 427 | enum: node.getMembers().map(member => member.getValue()), 428 | description: getDescription(node) 429 | }; 430 | } 431 | 432 | export function mergeSchema(a: Schema, b: Schema): Schema { 433 | const ret = {...a, ...b}; 434 | if (!b) { 435 | return ret; 436 | } 437 | if (a.type === 'object' || b.type === 'object') { 438 | ret.properties = { 439 | ...a.properties, 440 | ...b.properties 441 | }; 442 | b.properties && a.properties && Object.keys(a.properties).forEach(key => { 443 | if (b.properties[key]) { 444 | ret.properties[key] = mergeSchema(a.properties[key], b.properties[key]); 445 | } 446 | }); 447 | ret.required = uniq([...(a.required || []), ...(b.required || [])]); 448 | } 449 | return ret; 450 | } 451 | -------------------------------------------------------------------------------- /src/translate/definition.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Definition 3 | * @author xuxiulou 4 | */ 5 | 6 | import crypto from 'crypto'; 7 | import chalk from 'chalk'; 8 | import { set, get, omitBy, isNil, pick, omit, intersection } from 'lodash'; 9 | import traverse from 'json-schema-traverse'; 10 | 11 | import { 12 | Project, 13 | ts, 14 | Identifier, 15 | InterfaceDeclaration, 16 | PropertyAccessExpression, 17 | QualifiedName, 18 | TypeLiteralNode, 19 | TypeNode, 20 | SourceFile, 21 | LiteralTypeNode, 22 | TemplateLiteralTypeNode, 23 | TypeReferenceNode, 24 | UnionTypeNode, 25 | TypeGuards, 26 | ArrayTypeNode, 27 | IndexedAccessTypeNode, 28 | TypeAliasDeclaration, 29 | EnumDeclaration, 30 | ModuleDeclaration 31 | } from 'ts-morph'; 32 | 33 | import { 34 | getJsDocTags, 35 | mergeTags, 36 | getRequired, 37 | getDescription, 38 | getInterface, 39 | getLiteralTypeValue, 40 | Schema 41 | } from '../util'; 42 | 43 | import { TransResult, TansNode, GenOption } from './types'; 44 | 45 | function getId(filePath) { 46 | const md5 = crypto.createHash('md5'); 47 | return md5.update(filePath).digest('hex'); 48 | } 49 | 50 | function isTsLibPath(filePath: string) { 51 | return filePath.includes('/node_modules/typescript/lib/'); 52 | } 53 | 54 | function checkRecursion(node: TypeReferenceNode) { 55 | const name = node.getText(); 56 | const sourceFile = node.getSourceFile(); 57 | let recursion = false; 58 | let tmpNode = node.getParent(); 59 | while (tmpNode) { 60 | if (tmpNode.getKind() !== ts.SyntaxKind.PropertySignature 61 | && tmpNode.getSymbol()?.getName() === name 62 | && tmpNode.getSourceFile() === sourceFile 63 | ) { 64 | recursion = true; 65 | break; 66 | } 67 | tmpNode = tmpNode.getParent(); 68 | } 69 | return recursion; 70 | } 71 | 72 | export const REF_PREFIX = '#/definitions/'; 73 | 74 | export class Definition { 75 | 76 | readonly defCache = {}; 77 | readonly state: GenOption; 78 | 79 | readonly project: Project; 80 | 81 | transList: TansNode[] = []; 82 | 83 | constructor(project: Project, opts?: GenOption) { 84 | this.state = opts || {}; 85 | this.project = project; 86 | } 87 | 88 | generate(identifier: Identifier, ref?: string): TransResult { 89 | const $ref = ref || this.getRef(identifier); 90 | if (!$ref) { 91 | return { $ref, schema: null }; 92 | } 93 | 94 | const path = $ref.split(REF_PREFIX)[1].split('/'); 95 | const cacheSchema = get(this.defCache, path); 96 | if (cacheSchema) { 97 | return { $ref, schema: {...cacheSchema} }; 98 | } 99 | 100 | this.resetTransList(); 101 | const schema = this.getReferenceSchema(identifier); 102 | set(this.defCache, path, schema); 103 | return { $ref, schema, transList: [...this.transList] }; 104 | } 105 | 106 | private getReferenceSchema(identifier: Identifier): Schema { 107 | const declaration = identifier.getSymbol().getDeclarations()[0]; 108 | switch (declaration.getKind()) { 109 | // 外部导入类型 110 | case ts.SyntaxKind.ImportClause: 111 | case ts.SyntaxKind.ImportSpecifier: { 112 | const sourceFile = identifier.getDefinitions()[0].getSourceFile(); 113 | const node = this.getSourceFileNode(sourceFile, identifier.getText()); 114 | if (node) { 115 | return this.getReferenceSchema(node.getNameNode()); 116 | } 117 | return {}; 118 | } 119 | // 内部 Interface 120 | case ts.SyntaxKind.InterfaceDeclaration: { 121 | return this.processInterface(declaration as InterfaceDeclaration); 122 | } 123 | // 内部 TypeAlias 124 | case ts.SyntaxKind.TypeAliasDeclaration: { 125 | const typeNode = (declaration as TypeAliasDeclaration).getTypeNode(); 126 | return this.getTypeNodeSchema(typeNode, typeNode.getSourceFile()); 127 | } 128 | // 内部 Enum 129 | case ts.SyntaxKind.EnumDeclaration: 130 | return { enum: (declaration as EnumDeclaration).getMembers().map(member => member.getValue()) }; 131 | default: 132 | return {}; 133 | } 134 | } 135 | 136 | private processInterface(node: InterfaceDeclaration): Schema { 137 | let exts = []; 138 | if (node.getKind() === ts.SyntaxKind.InterfaceDeclaration) { 139 | exts = node.getExtends().map(e => this.getSchemaAsync(e.getExpression() as Identifier, false)); 140 | } 141 | 142 | const tags = getJsDocTags(node); 143 | const properties = this.getProperties(node, node.getSourceFile()); 144 | const required = getRequired(node); 145 | let base = mergeTags({ 146 | type: 'object', 147 | properties, 148 | required: intersection(required, Object.keys(properties)), 149 | description: getDescription(node) 150 | }, tags); 151 | 152 | base = omitBy(base, isNil); 153 | 154 | return exts.length > 0 ? { 155 | anyOf: [...exts, base] 156 | } : base; 157 | } 158 | 159 | private getProperties(node: InterfaceDeclaration | TypeLiteralNode, sourceFile: SourceFile): Record { 160 | return node.getProperties().reduce((prev, property) => { 161 | const name = property.getName(); 162 | const typeNode = property.getTypeNodeOrThrow(); 163 | let tags = getJsDocTags(property); 164 | // 忽略 165 | if ('ignore' in tags) { 166 | return prev; 167 | } 168 | let typeSchema = null; 169 | try { 170 | typeSchema = this.getTypeNodeSchema(typeNode, sourceFile); 171 | } catch(err) { 172 | const {line, column} = sourceFile.getLineAndColumnAtPos(typeNode.getStart()); 173 | const msg = 'Get schema failed! Ignore this property.'; 174 | console.log(`${ 175 | chalk.yellow('WARNING') 176 | } ${sourceFile.getFilePath()}\n${chalk.cyan(`line ${line}, col ${column}`)} ${msg}`); 177 | return prev; 178 | } 179 | if (!typeSchema) { 180 | return prev; 181 | } 182 | const schema = Object.assign(typeSchema, mergeTags(typeSchema, tags)); 183 | const description = getDescription(property); 184 | if (description) { 185 | Object.assign(typeSchema, { description }); 186 | } 187 | 188 | const {beforePropMount, afterPropMount, plugins} = this.state; 189 | const propContext = { 190 | property, 191 | typeNode, 192 | interface: node as InterfaceDeclaration, 193 | sourceFile 194 | }; 195 | if (beforePropMount) { 196 | const {ignore} = beforePropMount(propContext, schema) || {}; 197 | if (ignore) { 198 | return prev; 199 | } 200 | } 201 | afterPropMount && afterPropMount(propContext, schema); 202 | plugins && plugins.forEach(plugin => plugin.traverse && plugin.traverse(propContext, {...schema})); 203 | 204 | return { 205 | ...prev, 206 | [name]: schema 207 | }; 208 | }, {}); 209 | } 210 | 211 | private getTypeNodeSchema(node: TypeNode, sourceFile: SourceFile): Schema { 212 | switch (node.getKind()) { 213 | case ts.SyntaxKind.LiteralType: 214 | return { const: getLiteralTypeValue(node as LiteralTypeNode) }; 215 | case ts.SyntaxKind.TemplateLiteralType: 216 | return { const: JSON.parse((node as TemplateLiteralTypeNode).getType().getText()) }; 217 | case ts.SyntaxKind.StringKeyword: 218 | case ts.SyntaxKind.NumberKeyword: 219 | case ts.SyntaxKind.BooleanKeyword: 220 | case ts.SyntaxKind.NullKeyword: 221 | case ts.SyntaxKind.ObjectKeyword: 222 | return { 223 | type: node.getText() 224 | }; 225 | case ts.SyntaxKind.TypeLiteral: 226 | return { 227 | type: 'object', 228 | properties: this.getProperties(node as TypeLiteralNode, sourceFile), 229 | required: getRequired(node as TypeLiteralNode) 230 | }; 231 | case ts.SyntaxKind.TypeReference: 232 | return this.getTypeReferenceSchema(node as TypeReferenceNode, sourceFile); 233 | case ts.SyntaxKind.UnionType: { 234 | const types = (node as UnionTypeNode).getTypeNodes(); 235 | if (types.every(t => TypeGuards.isLiteralTypeNode(t))) { 236 | return { 237 | enum: types.map(getLiteralTypeValue) 238 | }; 239 | } 240 | return { 241 | oneOf: types.map(t => this.getTypeNodeSchema(t, sourceFile)) 242 | }; 243 | } 244 | case ts.SyntaxKind.IntersectionType: { 245 | const types = (node as UnionTypeNode).getTypeNodes(); 246 | return { 247 | allOf: types.map(t => this.getTypeNodeSchema(t, sourceFile)) 248 | }; 249 | } 250 | case ts.SyntaxKind.ArrayType: { 251 | return { 252 | type: 'array', 253 | items: this.getTypeNodeSchema((node as ArrayTypeNode).getElementTypeNode(), sourceFile) 254 | }; 255 | } 256 | case ts.SyntaxKind.IndexedAccessType: { 257 | const objectType = (node as IndexedAccessTypeNode).getObjectTypeNode(); 258 | let accesses = [(node as IndexedAccessTypeNode).getIndexTypeNode()] as LiteralTypeNode[]; 259 | let identifier = objectType; 260 | while (TypeGuards.isIndexedAccessTypeNode(objectType)) { 261 | identifier = objectType.getObjectTypeNode(); 262 | accesses.push(objectType.getIndexTypeNode() as LiteralTypeNode) 263 | } 264 | // @ts-ignore 265 | const { $ref } = this.getTypeNodeSchema(identifier, sourceFile); 266 | return { 267 | $ref: `${$ref}${accesses.map(a => '/properties/' + getLiteralTypeValue(a))}` 268 | }; 269 | } 270 | default: 271 | return; 272 | } 273 | } 274 | 275 | private getTypeReferenceSchema(node: TypeReferenceNode, sourceFile: SourceFile): Schema { 276 | const text = node.getText(); 277 | const identifier = node.getTypeName() as Identifier; 278 | const name = identifier.getText(); 279 | if (['integer', 'numberic'].includes(text)) { 280 | const isInterger = text === 'integer'; 281 | return { 282 | type: isInterger ? text : 'string', 283 | format: isInterger ? undefined : text 284 | }; 285 | } 286 | 287 | // 不规范写法兼容 288 | if (name === 'String') { 289 | return { type: 'string' }; 290 | } 291 | if (name === 'Object') { 292 | return { type: 'object' }; 293 | } 294 | 295 | if (name === 'Array') { 296 | return { 297 | type: 'array', 298 | items: this.getTypeNodeSchema(node.getTypeArguments()[0], sourceFile) 299 | }; 300 | } 301 | 302 | const typeArgs = node.getTypeArguments(); 303 | if (name === 'Record') { 304 | return { 305 | type: 'object', 306 | propertyNames: this.getTypeNodeSchema(typeArgs[0], sourceFile), 307 | additionalProperties: this.getTypeNodeSchema(typeArgs[1], sourceFile) 308 | }; 309 | } 310 | if (['Pick', 'Omit'].includes(name)) { 311 | const schema = this.processInterface( 312 | getInterface((typeArgs[0] as TypeReferenceNode).getTypeName() as Identifier) 313 | ); 314 | const {const: constVal, enum: enumVal} = this.getTypeNodeSchema(typeArgs[1], sourceFile); 315 | const propNames = constVal ? [constVal] : enumVal; 316 | return { 317 | type: 'object', 318 | properties: (name === 'Pick' ? pick : omit)(schema.properties, propNames), 319 | required: (schema.required || []).filter(itm => { 320 | const res = propNames.includes(itm); 321 | return name === 'Pick' ? res : !res; 322 | }) 323 | } 324 | } 325 | 326 | // 视为泛型 327 | if (typeArgs.length > 0) { 328 | const typeMaps = typeArgs.map(typeNode => this.getTypeNodeSchema(typeNode, sourceFile)); 329 | const genDec = getInterface(identifier); 330 | const relMap = genDec.getTypeParameters().reduce((prev, typeNode, idx) => { 331 | prev[`${REF_PREFIX}${typeNode.getName().toLowerCase()}`] = typeMaps[idx]; 332 | return prev; 333 | }, {}); 334 | const schema = this.processInterface(getInterface(identifier)); 335 | traverse(schema, { 336 | cb: function (obj, jsonPointer, rootSchema) { 337 | const {$ref} = obj; 338 | if ($ref && relMap[$ref]) { 339 | delete obj.$ref; 340 | set(rootSchema, jsonPointer.slice(1).split('/'), Object.assign(relMap[$ref], obj)); 341 | } 342 | } 343 | }); 344 | return schema; 345 | } 346 | 347 | // 全局声明处理 348 | if (!identifier.getSymbol()) { 349 | const node = this.getNodeFromGlobalFiles(identifier.getText()); 350 | return node ? this.getSchemaAsync(node.getNameNode()) : undefined; 351 | } 352 | 353 | // 泛型参数直接返回 ref 354 | const declaration = identifier.getSymbol().getDeclarations()[0]; 355 | if (declaration.getKind() === ts.SyntaxKind.TypeParameter) { 356 | return { $ref: `${REF_PREFIX}${identifier.getText().toLowerCase()}` }; 357 | } 358 | 359 | // 未支持 TS 类型忽略 360 | if (isTsLibPath(declaration.getSourceFile().getFilePath())) { 361 | return; 362 | } 363 | 364 | // 递归引用忽略 365 | if (checkRecursion(node)) { 366 | return; 367 | } 368 | 369 | return this.getSchemaAsync(identifier); 370 | } 371 | 372 | private getNodeFromGlobalFiles(nodeName: string) { 373 | const { globalFiles } = this.state; 374 | if (!globalFiles || globalFiles.length === 0) { 375 | return; 376 | } 377 | for(let i = globalFiles.length - 1; i >= 0; i--) { 378 | const sourceFile = this.project.addSourceFileAtPath(globalFiles[i]); 379 | const node = this.getSourceFileNode(sourceFile, nodeName); 380 | if (node) { 381 | return node; 382 | } 383 | } 384 | } 385 | 386 | private getSourceFileNode(sourceFile: SourceFile, nodeName: string) { 387 | let target: SourceFile | ModuleDeclaration = sourceFile; 388 | let names = nodeName.split('.'); 389 | while (names.length > 1) { 390 | const spaceName = names.splice(0, 1)[0]; 391 | target = target.getModule(spaceName); 392 | } 393 | const name = names[0]; 394 | return target.getInterface(name) || target.getEnum(name) || target.getTypeAlias(name); 395 | } 396 | 397 | private resetTransList() { 398 | this.transList = []; 399 | } 400 | 401 | private getSchemaAsync(identifier: Identifier, isRef: boolean = true): Schema { 402 | const $ref = this.getRef(identifier); 403 | const schema = { $ref }; 404 | this.transList.push({ node: identifier, root: schema, isRef, $ref }); 405 | return schema; 406 | } 407 | 408 | private getRef(node: Identifier | PropertyAccessExpression | QualifiedName) { 409 | const defArr: Identifier[] = []; 410 | if (node.getKind() === ts.SyntaxKind.PropertyAccessExpression) { 411 | let expNode = node as PropertyAccessExpression; 412 | while(expNode.getKind() === ts.SyntaxKind.PropertyAccessExpression) { 413 | defArr.unshift(expNode.getNameNode()); 414 | expNode = expNode.getExpression() as PropertyAccessExpression; 415 | } 416 | defArr.unshift(expNode as unknown as Identifier); 417 | } 418 | else if (node.getKind() === ts.SyntaxKind.QualifiedName) { 419 | let expNode = node as QualifiedName; 420 | while(expNode.getKind() === ts.SyntaxKind.QualifiedName) { 421 | defArr.unshift(expNode.getRight()); 422 | expNode = expNode.getLeft() as QualifiedName; 423 | } 424 | defArr.unshift(expNode as unknown as Identifier); 425 | } 426 | else { 427 | defArr.push(node as Identifier); 428 | } 429 | 430 | // 全局声明 431 | if (defArr[0].getDefinitions().length === 0) { 432 | const node = this.getNodeFromGlobalFiles(defArr[0].getText()); 433 | if (!node) { 434 | return; 435 | } 436 | const subPath = node.getText().toLowerCase().split('.').join('/'); 437 | return `${REF_PREFIX}${getId(node.getSourceFile().getFilePath())}/${subPath}`; 438 | } 439 | 440 | let file = null; 441 | const defNames = defArr.reduce((prev, def) => { 442 | const definitions = def.getDefinitions(); 443 | file = definitions[0].getSourceFile(); 444 | prev.push(definitions[0].getName().toLowerCase()); 445 | return prev; 446 | }, []); 447 | return `${REF_PREFIX}${getId(file.getFilePath())}/${defNames.join('/')}`; 448 | } 449 | } 450 | --------------------------------------------------------------------------------