├── .eslintignore
├── .eslintrc.json
├── .github
├── CODEOWNERS
├── dependabot.yml
└── workflows
│ ├── publish.yml
│ └── pull_request.yml
├── .gitignore
├── .idea
├── .gitignore
├── api-schema-typescript-generator.iml
├── codeStyles
│ └── codeStyleConfig.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── misc.xml
├── modules.xml
└── vcs.xml
├── .npmignore
├── .prettierrc.js
├── .yarnrc
├── LICENSE
├── README.md
├── bin
└── vk-api-schema-typescript-generator.js
├── jest.config.js
├── package.json
├── src
├── cli.ts
├── constants.ts
├── generator.ts
├── generators
│ ├── APITypingsGenerator.ts
│ ├── BaseCodeBlock.ts
│ ├── CommentCodeBlock.ts
│ ├── SchemaObject.ts
│ ├── TypeCodeBlock.ts
│ ├── enums.ts
│ ├── methods.ts
│ ├── typeString.ts
│ └── utils
│ │ └── mergeImports.ts
├── helpers.test.ts
├── helpers.ts
├── index.ts
├── log.ts
├── types.ts
├── types
│ └── schema.ts
└── utils.ts
├── tsconfig.json
└── yarn.lock
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@vkontakte/eslint-config/typescript"],
3 | "parserOptions": {
4 | "project": "./tsconfig.json",
5 | "ecmaVersion": 2018,
6 | "sourceType": "module",
7 | "ecmaFeatures": {
8 | "jsx": true,
9 | "restParams": true,
10 | "spread": true
11 | }
12 | },
13 | "globals": {
14 | "Element": true,
15 | "Promise": true
16 | },
17 | "env": {
18 | "browser": true,
19 | "node": true
20 | },
21 | "rules": {
22 | "@typescript-eslint/explicit-member-accessibility": "off",
23 | "@typescript-eslint/no-unnecessary-condition": "off",
24 | "@typescript-eslint/no-magic-numbers": "off",
25 | "@typescript-eslint/no-extra-parens": "off",
26 |
27 | "@typescript-eslint/quotes": "off",
28 | "@typescript-eslint/indent": "off",
29 |
30 | "no-shadow": "off"
31 | },
32 | "overrides": [
33 | {
34 | "files": ["**/*.ts"],
35 | "rules": {
36 | "no-undef": "off"
37 | }
38 | }
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | .github/ @VKCOM/vk-sec
2 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: 'npm'
4 | directory: '/'
5 | schedule:
6 | interval: 'daily'
7 | allow:
8 | - dependency-type: 'direct'
9 | reviewers:
10 | - 'VKCOM/vk-sec'
11 |
12 | - package-ecosystem: 'github-actions'
13 | directory: '/'
14 | schedule:
15 | interval: 'daily'
16 | reviewers:
17 | - 'VKCOM/vk-sec'
18 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: 'Publish'
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | version:
7 | description: 'version'
8 | required: true
9 |
10 | jobs:
11 | publish:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v4
15 | with:
16 | token: ${{ secrets.DEVTOOLS_GITHUB_TOKEN }}
17 |
18 | - uses: actions/setup-node@v4
19 | with:
20 | node-version: 18
21 | cache: 'yarn'
22 | always-auth: true
23 | registry-url: 'https://registry.npmjs.org'
24 |
25 | - run: yarn install --frozen-lockfile
26 |
27 | - run: yarn test
28 |
29 | - run: yarn build
30 |
31 | - name: Set Git credentials
32 | run: |
33 | git config --local user.email "actions@github.com"
34 | git config --local user.name "GitHub Action"
35 |
36 | - run: yarn version --new-version ${{ github.event.inputs.version }} --no-commit-hooks
37 |
38 | - name: Pushing changes
39 | uses: ad-m/github-push-action@master
40 | with:
41 | github_token: ${{ secrets.DEVTOOLS_GITHUB_TOKEN }}
42 | branch: ${{ github.ref }}
43 |
44 | - name: Publushing release
45 | run: yarn publish --non-interactive
46 | env:
47 | NODE_AUTH_TOKEN: ${{ secrets.NPMJS_PUBLISH_TOKEN }}
48 |
--------------------------------------------------------------------------------
/.github/workflows/pull_request.yml:
--------------------------------------------------------------------------------
1 | name: 'Pull Request'
2 |
3 | on: ['pull_request']
4 |
5 | jobs:
6 | test:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 |
11 | - uses: actions/setup-node@v4
12 | with:
13 | node-version: 18
14 | cache: 'yarn'
15 |
16 | - run: yarn install --frozen-lockfile
17 |
18 | - run: yarn test
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 |
--------------------------------------------------------------------------------
/.idea/api-schema-typescript-generator.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .idea
3 | .DS_Store
4 | .gitignore
5 | yarn.lock
6 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | printWidth: 100,
3 | bracketSpacing: true,
4 | bracketSameLine: false,
5 | singleQuote: true,
6 | trailingComma: 'all',
7 | arrowParens: 'always',
8 | semi: true,
9 | quoteProps: 'consistent',
10 | };
11 |
--------------------------------------------------------------------------------
/.yarnrc:
--------------------------------------------------------------------------------
1 | frozen-lockfile true
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 V Kontakte, LLC.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # VK API Schema Typescript Generator
2 |
3 | [](https://www.npmjs.com/package/@vkontakte/api-schema-typescript-generator)
4 |
5 | This package generates TypeScript typings based on the [VK API JSON schema](https://github.com/VKCOM/vk-api-schema).
6 |
7 | ## Usage
8 |
9 | #### Via npx:
10 |
11 | ```shell script
12 | npx @vkontakte/api-schema-typescript-generator --schemaDir ./schema --outDir ./types/api --methods '*'
13 | ```
14 |
15 | #### Via installed dependency:
16 |
17 | ```shell script
18 | yarn add @vkontakte/api-schema-typescript-generator
19 |
20 | vk-api-schema-typescript-generator --schemaDir ./schema --outDir ./types/api --methods 'messages.*'
21 | ```
22 |
23 | ### Options
24 |
25 | #### --help
26 |
27 | Shows help.
28 |
29 | #### --schemaDir
30 |
31 | The relative path to directory with `methods.json`, `objects.json` and `responses.json` files.
32 |
33 | #### --outDir
34 |
35 | The directory where the files will be generated.
36 |
37 | If you skip this param, script will work in linter mode without emitting files to file system.
38 |
39 | > **Please note** that this folder will be cleared after starting the generation.
40 |
41 | #### --methods
42 |
43 | List of methods to generate responses and all needed objects.
44 |
45 | Examples:
46 |
47 | - `'*'` – to generate all responses and objects.
48 | - `'messages.*, users.get, groups.isMember'` - to generate all methods from messages section, users.get and groups.isMember.
49 |
--------------------------------------------------------------------------------
/bin/vk-api-schema-typescript-generator.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const { main } = require('../dist/index');
3 | main();
4 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
2 | module.exports = {
3 | preset: 'ts-jest',
4 | testEnvironment: 'node',
5 | };
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@vkontakte/api-schema-typescript-generator",
3 | "version": "0.16.0",
4 | "license": "MIT",
5 | "description": "VK API TypeScript generator",
6 | "author": {
7 | "name": "VK",
8 | "url": "https://vk.com"
9 | },
10 | "keywords": [
11 | "VK",
12 | "VK API",
13 | "JSON Schema",
14 | "TypeScript",
15 | "generator"
16 | ],
17 | "contributors": [
18 | {
19 | "name": "Igor Fedorov",
20 | "email": "ig.fedorov@corp.vk.com",
21 | "url": "https://vk.com/xyz"
22 | }
23 | ],
24 | "repository": "https://github.com/VKCOM/api-schema-typescript-generator",
25 | "engines": {
26 | "node": ">=12.0.0",
27 | "yarn": "^1.21.1"
28 | },
29 | "bin": {
30 | "vk-api-schema-typescript-generator": "./bin/vk-api-schema-typescript-generator.js"
31 | },
32 | "scripts": {
33 | "clear": "rimraf dist/*",
34 | "build": "yarn clear && tsc",
35 | "watch": "yarn clear && tsc --watch",
36 | "prettier": "prettier --write \"src/**/*.ts\"",
37 | "test": "jest && tsc --noEmit && eslint src --ext .ts && yarn prettier"
38 | },
39 | "pre-commit": [
40 | "test"
41 | ],
42 | "dependencies": {
43 | "arg": "^4.1.3",
44 | "chalk": "4.1.0",
45 | "prettier": "^2.7.1"
46 | },
47 | "devDependencies": {
48 | "@types/jest": "^28.1.5",
49 | "@types/node": "^22.0.0",
50 | "@typescript-eslint/eslint-plugin": "5.62.0",
51 | "@typescript-eslint/parser": "5.62.0",
52 | "@vkontakte/eslint-config": "3.1.0",
53 | "eslint": "8.57.1",
54 | "eslint-plugin-react": "7.37.5",
55 | "eslint-plugin-react-hooks": "5.0.0",
56 | "jest": "28.1.3",
57 | "pre-commit": "1.2.2",
58 | "rimraf": "^3.0.2",
59 | "ts-jest": "^28.0.6",
60 | "typescript": "^5.1.6"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
1 | import arg from 'arg';
2 | import { isString, trimArray } from './utils';
3 |
4 | export function parseArguments() {
5 | const args = arg(
6 | {
7 | '--help': Boolean,
8 | '--schemaDir': String,
9 | '--outDir': String,
10 | '--methods': [String],
11 | '-h': '--help',
12 | },
13 | {
14 | argv: process.argv.slice(2),
15 | permissive: true,
16 | },
17 | );
18 |
19 | const schemaDir = args['--schemaDir'];
20 | const outDir = args['--outDir'];
21 |
22 | return {
23 | help: args['--help'] || false,
24 | schemaDir: isString(schemaDir) ? schemaDir.trim() : null,
25 | outDir: isString(outDir) ? outDir.trim() : null,
26 | methods: trimArray(args['--methods'] || []),
27 | };
28 | }
29 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | import { EOL } from 'os';
2 | import { Dictionary } from './types';
3 |
4 | export const DEFAULT_API_VERSION = '5.131';
5 |
6 | export const PropertyType = {
7 | INTEGER: 'integer',
8 | BOOLEAN: 'boolean',
9 | NUMBER: 'number',
10 | STRING: 'string',
11 | ARRAY: 'array',
12 | OBJECT: 'object',
13 | MIXED: 'mixed',
14 | } as const;
15 |
16 | export const scalarTypes: Dictionary = {
17 | integer: 'number',
18 | boolean: 'boolean',
19 | number: 'number',
20 | string: 'string',
21 | };
22 |
23 | export const primitiveTypes: Dictionary = {
24 | ...scalarTypes,
25 | array: 'any[]',
26 | object: '{ [key: string]: unknown }',
27 | mixed: 'any /* mixed primitive */',
28 | };
29 |
30 | export const spaceChar = ' ';
31 | export const tabChar = spaceChar.repeat(2);
32 | export const newLineChar = EOL;
33 |
34 | export const baseBoolIntRef = 'base_bool_int';
35 | export const baseOkResponseRef = 'base_ok_response';
36 | export const basePropertyExistsRef = 'base_property_exists';
37 |
38 | export const baseAPIParamsInterfaceName = 'BaseAPIParams';
39 |
--------------------------------------------------------------------------------
/src/generator.ts:
--------------------------------------------------------------------------------
1 | import { newLineChar } from './constants';
2 | import { getInterfaceName, getSectionFromObjectName } from './helpers';
3 | import { Dictionary, ObjectType, RefsDictionary, RefsDictionaryType } from './types';
4 | import { sortArrayAlphabetically, uniqueArray } from './utils';
5 |
6 | export function generateImportsBlock(
7 | refs: RefsDictionary,
8 | section: string | null,
9 | type?: ObjectType,
10 | ): string {
11 | let importRefs = Object.entries(refs)
12 | .filter(([, type]) => type === RefsDictionaryType.GenerateAndImport)
13 | .map(([key]) => key);
14 |
15 | importRefs = uniqueArray(importRefs);
16 |
17 | const paths: Dictionary = {};
18 | importRefs.forEach((objectName) => {
19 | const importSection = getSectionFromObjectName(objectName);
20 | const interfaceName = getInterfaceName(objectName);
21 | let path;
22 |
23 | if (type === ObjectType.Object) {
24 | if (section === importSection) {
25 | path = `./${interfaceName}`;
26 | } else {
27 | path = `../${importSection}/${interfaceName}`;
28 | }
29 | } else {
30 | path = `../objects/${importSection}/${interfaceName}`;
31 | }
32 |
33 | if (!paths[path]) {
34 | paths[path] = [];
35 | }
36 | paths[path].push(interfaceName);
37 | });
38 |
39 | const importLines: string[] = [];
40 |
41 | sortArrayAlphabetically(Object.keys(paths)).forEach((path) => {
42 | const interfaces = sortArrayAlphabetically(paths[path]).join(', ');
43 | importLines.push(`import { ${interfaces} } from '${path}';`);
44 | });
45 |
46 | return importLines.join(newLineChar);
47 | }
48 |
--------------------------------------------------------------------------------
/src/generators/APITypingsGenerator.ts:
--------------------------------------------------------------------------------
1 | import * as Schema from '../types/schema';
2 | import { Dictionary, ObjectType, RefsDictionary } from '../types';
3 | import { generateEnumAsUnionType } from './enums';
4 | import { normalizeMethodInfo } from './methods';
5 | import { SchemaObject } from './SchemaObject';
6 | import {
7 | getInterfaceName,
8 | getMethodSection,
9 | getObjectNameByRef,
10 | getSectionFromObjectName,
11 | isMethodNeeded,
12 | isPatternProperty,
13 | prepareBuildDirectory,
14 | prepareMethodsPattern,
15 | writeFile,
16 | } from '../helpers';
17 | import { CodeBlocksArray, GeneratorResultInterface } from './BaseCodeBlock';
18 | import { TypeCodeBlock, TypeScriptCodeTypes } from './TypeCodeBlock';
19 | import { isObject, sortArrayAlphabetically, uniqueArray } from '../utils';
20 | import {
21 | baseAPIParamsInterfaceName,
22 | baseBoolIntRef,
23 | baseOkResponseRef,
24 | basePropertyExistsRef,
25 | DEFAULT_API_VERSION,
26 | newLineChar,
27 | } from '../constants';
28 | import path from 'path';
29 | import { CommentCodeBlock } from './CommentCodeBlock';
30 | import { consoleLogError, consoleLogErrorAndExit, consoleLogInfo } from '../log';
31 | import { generateImportsBlock } from '../generator';
32 | import { generateTypeString } from './typeString';
33 | import { ErrorInterface } from '../types/schema';
34 | import { mergeImports } from './utils/mergeImports';
35 |
36 | interface APITypingsGeneratorOptions {
37 | needEmit: boolean;
38 |
39 | /**
40 | * Path for generated typings
41 | */
42 | outDirPath: string;
43 | /**
44 | * List of methods to generate responses and all needed objects
45 | * "*" to generate all responses and objects
46 | *
47 | * For example: "messages.*, users.get, groups.isMember"
48 | */
49 | methodsPattern: string;
50 |
51 | methodsDefinitions: Schema.API;
52 | objects: Dictionary;
53 | responses: Dictionary;
54 | errors: Dictionary;
55 | }
56 |
57 | export class APITypingsGenerator {
58 | constructor(options: APITypingsGeneratorOptions) {
59 | this.needEmit = options.needEmit;
60 | this.outDirPath = options.outDirPath;
61 | this.methodsPattern = prepareMethodsPattern(options.methodsPattern);
62 |
63 | this.methodsDefinitions = options.methodsDefinitions;
64 | this.methodsList = options.methodsDefinitions.methods || [];
65 | this.objects = this.convertJSONSchemaDictionary(options.objects);
66 | this.responses = this.convertJSONSchemaDictionary(options.responses);
67 | this.errors = options.errors;
68 |
69 | this.visitedRefs = {};
70 | this.generatedObjects = {};
71 |
72 | this.methodFilesMap = {};
73 | this.exports = {};
74 |
75 | this.ignoredResponses = {
76 | 'storage.get': {
77 | keysResponse: true,
78 | },
79 | };
80 |
81 | this.resultFiles = {};
82 | }
83 |
84 | needEmit!: APITypingsGeneratorOptions['needEmit'];
85 | outDirPath!: APITypingsGeneratorOptions['outDirPath'];
86 | methodsPattern!: Dictionary;
87 |
88 | methodsDefinitions!: Schema.API;
89 | methodsList!: NonNullable;
90 | objects!: Dictionary;
91 | responses!: Dictionary;
92 | errors!: Dictionary;
93 |
94 | visitedRefs!: Dictionary;
95 | generatedObjects!: Dictionary;
96 |
97 | methodFilesMap!: Dictionary>;
98 | exports!: Dictionary>;
99 |
100 | ignoredResponses!: Dictionary>;
101 |
102 | resultFiles!: Dictionary;
103 |
104 | private convertJSONSchemaDictionary(objects: any) {
105 | const dictionary: Dictionary = {};
106 | Object.keys(objects).forEach((name: string) => {
107 | dictionary[name] = new SchemaObject(name, objects[name]);
108 | });
109 | return dictionary;
110 | }
111 |
112 | private registerExport(path: string, name: string) {
113 | const pathExports: Dictionary = this.exports[path] || {};
114 |
115 | this.exports = {
116 | ...this.exports,
117 | [path]: {
118 | ...pathExports,
119 | [name]: true,
120 | },
121 | };
122 | }
123 |
124 | private registerResultFile(path: string, content: string) {
125 | this.resultFiles[path] = content;
126 | }
127 |
128 | private appendToFileMap(section: string, imports: RefsDictionary, codeBlocks: CodeBlocksArray) {
129 | const methodFile = this.methodFilesMap[section] || {
130 | imports: {},
131 | codeBlocks: [],
132 | };
133 |
134 | this.methodFilesMap[section] = {
135 | imports: mergeImports(methodFile.imports, imports),
136 | codeBlocks: [...methodFile.codeBlocks, ...codeBlocks],
137 | };
138 | }
139 |
140 | collectAllOf(object: SchemaObject, deep = 0): SchemaObject[] {
141 | if (!object.allOf) {
142 | return [];
143 | }
144 |
145 | if (deep === 0) {
146 | this.visitedRefs = {};
147 | }
148 |
149 | let allOf: SchemaObject[] = [];
150 |
151 | object.allOf.forEach((allOfItem) => {
152 | if (allOfItem.ref && !this.visitedRefs[allOfItem.ref]) {
153 | this.visitedRefs[allOfItem.ref] = true;
154 | const refName = allOfItem.ref;
155 |
156 | const tempAllOfItem = this.getObjectByRef(refName);
157 | if (tempAllOfItem) {
158 | allOfItem = tempAllOfItem;
159 | } else {
160 | consoleLogErrorAndExit(`${refName} ref not found`);
161 | }
162 | }
163 |
164 | if (allOfItem.allOf) {
165 | allOf = [...allOf, ...this.collectAllOf(allOfItem, deep + 1)];
166 | } else {
167 | allOf.push(allOfItem);
168 | }
169 | });
170 |
171 | return allOf;
172 | }
173 |
174 | getObjectProperties(object: SchemaObject, deep = 0): SchemaObject[] {
175 | let properties = object.properties || [];
176 |
177 | if (object.allOf) {
178 | this.collectAllOf(object).forEach((allOfItem) => {
179 | object.required = uniqueArray([...object.required, ...allOfItem.required]);
180 |
181 | let additionalProperties: SchemaObject[] = [];
182 |
183 | if (allOfItem.properties) {
184 | additionalProperties = allOfItem.properties;
185 | } else if (allOfItem.ref) {
186 | const refObject = this.getObjectByRef(allOfItem.ref);
187 | if (!refObject) {
188 | consoleLogErrorAndExit(`${object.name} ref object in allOf is not found`);
189 | return;
190 | }
191 |
192 | additionalProperties = this.getObjectProperties(refObject, deep + 1);
193 | }
194 |
195 | if (additionalProperties.length) {
196 | properties = [...properties, ...additionalProperties];
197 | }
198 | });
199 | }
200 |
201 | if (deep === 0) {
202 | return this.filterObjectProperties(properties);
203 | } else {
204 | return properties;
205 | }
206 | }
207 |
208 | /**
209 | * Filter properties with same name
210 | * If an object uses allOf, some nested objects may have the same properties
211 | */
212 | private filterObjectProperties(properties: SchemaObject[]): SchemaObject[] {
213 | const propertyNames: Dictionary = {};
214 |
215 | return properties.filter((property) => {
216 | if (propertyNames[property.name]) {
217 | return false;
218 | } else {
219 | propertyNames[property.name] = true;
220 | return true;
221 | }
222 | });
223 | }
224 |
225 | private getObjectInterfaceCode(object: SchemaObject): GeneratorResultInterface | false {
226 | let imports: RefsDictionary = {};
227 | let codeBlocks: CodeBlocksArray = [];
228 |
229 | const properties = this.getObjectProperties(object);
230 |
231 | const codeBlock = new TypeCodeBlock({
232 | type: TypeScriptCodeTypes.Interface,
233 | refName: object.name,
234 | interfaceName: getInterfaceName(object.name),
235 | needExport: true,
236 | description: object.oneOf ? 'Object has oneOf' : '',
237 | properties: [],
238 | });
239 |
240 | if (object.oneOf) {
241 | return this.getPrimitiveInterfaceCode(object);
242 | }
243 |
244 | properties.forEach((property) => {
245 | const {
246 | imports: newImports,
247 | value,
248 | codeBlocks: newCodeBlocks,
249 | description,
250 | } = generateTypeString(property, this.objects, {
251 | objectParentName: object.name,
252 | });
253 |
254 | imports = mergeImports(imports, newImports);
255 | codeBlocks = [...codeBlocks, ...newCodeBlocks];
256 |
257 | codeBlock.addProperty({
258 | name: property.name,
259 | description: [property.description, description].join(newLineChar),
260 | value,
261 | isRequired: isPatternProperty(property.name) || property.isRequired,
262 | });
263 | });
264 |
265 | return {
266 | codeBlocks: [...codeBlocks, codeBlock],
267 | imports,
268 | value: '',
269 | };
270 | }
271 |
272 | private getPrimitiveInterfaceCode(object: SchemaObject): GeneratorResultInterface | false {
273 | if (object.type === 'array' || object.oneOf) {
274 | return this.getObjectCodeBlockAsType(object);
275 | }
276 |
277 | if (object.enum) {
278 | const { codeBlocks } = generateEnumAsUnionType(object);
279 |
280 | return {
281 | codeBlocks: codeBlocks,
282 | imports: {},
283 | value: '',
284 | };
285 | } else {
286 | return this.getObjectCodeBlockAsType(object);
287 | }
288 | }
289 |
290 | private generateObject(object: SchemaObject) {
291 | if (this.generatedObjects[object.name]) {
292 | return;
293 | }
294 | this.generatedObjects[object.name] = true;
295 |
296 | let result: GeneratorResultInterface | false = false;
297 |
298 | if (object.ref && object.type === 'object') {
299 | result = this.getPrimitiveInterfaceCode(object);
300 | } else {
301 | switch (object.type) {
302 | case 'object':
303 | result = this.getObjectInterfaceCode(object);
304 | break;
305 |
306 | case 'string':
307 | case 'number':
308 | case 'integer':
309 | case 'array':
310 | case 'boolean':
311 | result = this.getPrimitiveInterfaceCode(object);
312 | break;
313 |
314 | default:
315 | if (!result) {
316 | consoleLogErrorAndExit(getInterfaceName(object.name), 'Unknown type of object', object);
317 | }
318 | }
319 | }
320 |
321 | if (!result) {
322 | consoleLogErrorAndExit('empty object result', object);
323 | return;
324 | }
325 |
326 | const { codeBlocks, imports } = result;
327 | const stringCodeBlocks = codeBlocks.map((codeBlock) => codeBlock.toString());
328 |
329 | const section = getSectionFromObjectName(object.name);
330 |
331 | delete imports[object.name];
332 | stringCodeBlocks.unshift(generateImportsBlock(imports, section, ObjectType.Object));
333 |
334 | if (stringCodeBlocks.length > 0) {
335 | const code = stringCodeBlocks.join(newLineChar.repeat(2));
336 | this.registerResultFile(
337 | path.join('objects', section, `${getInterfaceName(object.name)}.ts`),
338 | code,
339 | );
340 | }
341 |
342 | codeBlocks.forEach((codeBlock) => {
343 | if (codeBlock instanceof TypeCodeBlock && codeBlock.needExport && codeBlock.interfaceName) {
344 | this.registerExport(
345 | `./objects/${section}/${getInterfaceName(object.name)}.ts`,
346 | codeBlock.interfaceName,
347 | );
348 | }
349 | });
350 |
351 | this.generateObjectsFromImports(imports);
352 | }
353 |
354 | private getObjectByRef(ref: string): SchemaObject | undefined {
355 | const refName = getObjectNameByRef(ref);
356 | return this.objects[refName];
357 | }
358 |
359 | private generateObjectsFromRefs(refs: RefsDictionary): void {
360 | Object.keys(refs).forEach((ref) => {
361 | const refObject = this.getObjectByRef(ref);
362 | if (!refObject) {
363 | consoleLogInfo(`"${ref}" ref is not found`);
364 | return;
365 | }
366 |
367 | this.generateObject(refObject);
368 | });
369 | }
370 |
371 | private generateObjectsFromImports(imports: RefsDictionary) {
372 | Object.keys(imports).forEach((ref) => {
373 | const refObject = this.getObjectByRef(ref);
374 | if (!refObject) {
375 | consoleLogInfo(`"${ref}" ref is not found`);
376 | return;
377 | }
378 |
379 | this.generateObject(refObject);
380 | });
381 | }
382 |
383 | private generateMethodParams(methodInfo: SchemaObject) {
384 | const section = getMethodSection(methodInfo.name);
385 | const interfaceName = `${methodInfo.name} params`;
386 |
387 | let imports: RefsDictionary = {};
388 | let codeBlocks: CodeBlocksArray = [];
389 |
390 | const codeBlock = new TypeCodeBlock({
391 | type: TypeScriptCodeTypes.Interface,
392 | interfaceName: getInterfaceName(interfaceName),
393 | needExport: true,
394 | allowEmptyInterface: true,
395 | properties: [],
396 | });
397 |
398 | methodInfo.parameters.forEach((property) => {
399 | const {
400 | imports: newImports,
401 | value,
402 | codeBlocks: newCodeBlocks,
403 | } = generateTypeString(property, this.objects, {
404 | needEnumNamesConstant: false,
405 | });
406 |
407 | imports = mergeImports(imports, newImports);
408 | codeBlocks = [...codeBlocks, ...newCodeBlocks];
409 |
410 | codeBlock.addProperty({
411 | name: property.name,
412 | description: property.description,
413 | value,
414 | isRequired: property.isRequired,
415 | });
416 | });
417 |
418 | this.appendToFileMap(section, imports, [...codeBlocks, codeBlock]);
419 | this.generateObjectsFromImports(imports);
420 | }
421 |
422 | private getResponseObjectRef(ref: string): SchemaObject | undefined {
423 | const objectName = getObjectNameByRef(ref);
424 |
425 | if (this.responses[objectName]) {
426 | return this.responses[objectName];
427 | }
428 |
429 | return this.getObjectByRef(ref);
430 | }
431 |
432 | private getObjectCodeBlockAsType(object: SchemaObject): GeneratorResultInterface | false {
433 | let codeBlocks: CodeBlocksArray = [];
434 | let imports: RefsDictionary = {};
435 |
436 | if (object.enum) {
437 | const { codeBlocks: newCodeBlocks } = generateEnumAsUnionType(object);
438 | codeBlocks = [...newCodeBlocks];
439 | } else {
440 | const {
441 | imports: newImports,
442 | value,
443 | codeBlocks: newCodeBlocks,
444 | } = generateTypeString(object, this.objects);
445 | const codeBlock = new TypeCodeBlock({
446 | type: TypeScriptCodeTypes.Type,
447 | refName: object.name,
448 | interfaceName: getInterfaceName(object.name),
449 | description: object.description,
450 | needExport: true,
451 | properties: [],
452 | value,
453 | });
454 |
455 | imports = newImports;
456 | codeBlocks = [...codeBlocks, ...newCodeBlocks, codeBlock];
457 | }
458 |
459 | return {
460 | codeBlocks,
461 | imports,
462 | value: '',
463 | };
464 | }
465 |
466 | private getResponseCodeBlockAsType(
467 | object: SchemaObject,
468 | response: SchemaObject,
469 | ): GeneratorResultInterface | false {
470 | const { imports, value, codeBlocks, description } = generateTypeString(response, this.objects, {
471 | objectParentName: ' ', // TODO: Refactor
472 | });
473 |
474 | const codeBlock = new TypeCodeBlock({
475 | type: TypeScriptCodeTypes.Type,
476 | refName: object.name,
477 | interfaceName: getInterfaceName(object.name),
478 | description: [object.description, description || ''].join(newLineChar),
479 | needExport: true,
480 | properties: [],
481 | value,
482 | });
483 |
484 | return {
485 | codeBlocks: [...codeBlocks, codeBlock],
486 | imports,
487 | value: '',
488 | description,
489 | };
490 | }
491 |
492 | private getResponseCodeBlock(object: SchemaObject): GeneratorResultInterface | false {
493 | if (!object.ref) {
494 | consoleLogError(`response schema object "${object.name}" has no ref`, object);
495 | return false;
496 | }
497 |
498 | const nonBuildableRefs: Dictionary = {
499 | [baseBoolIntRef]: true,
500 | [baseOkResponseRef]: true,
501 | [basePropertyExistsRef]: true,
502 | };
503 |
504 | const objectName = getObjectNameByRef(object.ref);
505 | if (nonBuildableRefs[objectName]) {
506 | return this.getObjectCodeBlockAsType(object);
507 | }
508 |
509 | let response = this.getResponseObjectRef(object.ref);
510 | if (!response) {
511 | consoleLogError(`response schema object "${object.name}" has no response`, object);
512 | return false;
513 | }
514 |
515 | // VK API JSON Schema specific heuristic
516 | if (response.properties.length === 1 && response.properties[0].name === 'response') {
517 | response = response.properties[0];
518 | }
519 |
520 | if (response.ref) {
521 | return this.getResponseCodeBlockAsType(object, response);
522 | }
523 |
524 | response = response.clone();
525 | response.setName(object.name);
526 |
527 | switch (response.type) {
528 | case 'object':
529 | return this.getObjectInterfaceCode(response);
530 |
531 | case 'integer':
532 | case 'string':
533 | case 'boolean':
534 | case 'array':
535 | return this.getResponseCodeBlockAsType(object, response);
536 |
537 | default:
538 | consoleLogErrorAndExit(response.name, 'unknown type', response.type);
539 | return false;
540 | }
541 | }
542 |
543 | public generateResponse(section: string, response: SchemaObject) {
544 | const result = this.getResponseCodeBlock(response);
545 | if (!result) {
546 | return;
547 | }
548 |
549 | const { codeBlocks, imports } = result;
550 |
551 | this.appendToFileMap(section, imports, codeBlocks);
552 | this.generateObjectsFromImports(imports);
553 | }
554 |
555 | private generateMethodParamsAndResponses(method: Schema.Method) {
556 | const { name: methodName } = method;
557 | const section = getMethodSection(methodName);
558 |
559 | if (!isObject(method.responses)) {
560 | consoleLogErrorAndExit(`"${methodName}" "responses" field is not an object.`);
561 | return;
562 | }
563 |
564 | if (Object.keys(method.responses).length === 0) {
565 | consoleLogErrorAndExit(`"${methodName}" "responses" field is empty.`);
566 | return;
567 | }
568 |
569 | // Comment with method name for visual sections in file
570 | const methodNameComment = new CommentCodeBlock([methodName]);
571 | if (method.description) {
572 | methodNameComment.appendLines(['', method.description]);
573 | }
574 | this.appendToFileMap(section, {}, [methodNameComment]);
575 |
576 | const { method: normalizedMethod, parameterRefs } = normalizeMethodInfo(method);
577 |
578 | method = normalizedMethod;
579 | this.generateObjectsFromRefs(parameterRefs);
580 |
581 | this.generateMethodParams(new SchemaObject(method.name, method));
582 |
583 | Object.entries(method.responses).forEach(([responseName, responseObject]) => {
584 | if (this.ignoredResponses[methodName] && this.ignoredResponses[methodName][responseName]) {
585 | return;
586 | }
587 |
588 | const name = `${methodName}_${responseName}`;
589 | this.generateResponse(section, new SchemaObject(name, responseObject));
590 | });
591 | }
592 |
593 | private generateMethods() {
594 | consoleLogInfo('creating method params and responses...');
595 |
596 | this.methodsList.forEach((methodInfo) => {
597 | if (isMethodNeeded(this.methodsPattern, methodInfo.name)) {
598 | this.generateMethodParamsAndResponses(methodInfo);
599 | }
600 | });
601 |
602 | Object.keys(this.methodFilesMap).forEach((section) => {
603 | const { imports, codeBlocks } = this.methodFilesMap[section];
604 | codeBlocks.forEach((codeBlock) => {
605 | if (codeBlock instanceof TypeCodeBlock && codeBlock.needExport && codeBlock.interfaceName) {
606 | this.registerExport(`./methods/${section}`, codeBlock.interfaceName);
607 | }
608 | });
609 | const code = [generateImportsBlock(imports, null), ...codeBlocks];
610 |
611 | this.registerResultFile(
612 | path.join('methods', `${section}.ts`),
613 | code.join(newLineChar.repeat(2)),
614 | );
615 | });
616 | }
617 |
618 | private generateErrors() {
619 | consoleLogInfo('creating errors...');
620 |
621 | const code: string[] = [];
622 |
623 | Object.entries(this.errors)
624 | .reduce>((acc, [name, error]) => {
625 | acc.push({ name, ...error });
626 | return acc;
627 | }, [])
628 | .sort((errorA, errorB) => {
629 | return errorA.code - errorB.code;
630 | })
631 | .forEach((error) => {
632 | const errorConstantName = error.name.toUpperCase();
633 |
634 | code.push(
635 | new TypeCodeBlock({
636 | type: TypeScriptCodeTypes.Const,
637 | interfaceName: errorConstantName,
638 | needExport: true,
639 | value: String(error.code),
640 | properties: [],
641 | description: [error.description, error.$comment || ''].join(newLineChar.repeat(2)),
642 | }).toString(),
643 | );
644 |
645 | this.registerExport('./common/errors', errorConstantName);
646 | });
647 |
648 | this.registerResultFile(path.join('common', 'errors.ts'), code.join(newLineChar.repeat(2)));
649 | }
650 |
651 | private createCommonTypes() {
652 | consoleLogInfo('creating common types...');
653 | const code: string[] = [];
654 |
655 | const apiVersion = this.methodsDefinitions.version || DEFAULT_API_VERSION;
656 | code.push(`export const API_VERSION = '${apiVersion}'`);
657 |
658 | code.push('export type ValueOf = T[keyof T];');
659 |
660 | code.push(
661 | new TypeCodeBlock({
662 | type: TypeScriptCodeTypes.Interface,
663 | interfaceName: getInterfaceName(baseAPIParamsInterfaceName),
664 | needExport: true,
665 | properties: [
666 | {
667 | name: 'v',
668 | value: 'string',
669 | isRequired: true,
670 | },
671 | {
672 | name: 'access_token',
673 | value: 'string',
674 | isRequired: true,
675 | },
676 | {
677 | name: 'lang',
678 | value: 'number',
679 | },
680 | {
681 | name: 'device_id',
682 | value: 'string',
683 | },
684 | ],
685 | }).toString(),
686 | );
687 |
688 | this.registerExport('./common/common', 'API_VERSION');
689 | this.registerExport('./common/common', getInterfaceName(baseAPIParamsInterfaceName));
690 | this.registerResultFile(path.join('common', 'common.ts'), code.join(newLineChar.repeat(2)));
691 | }
692 |
693 | /**
694 | * This method creates index.ts file with exports of all generated params, responses and objects
695 | */
696 | private createIndexExports() {
697 | consoleLogInfo('creating index.ts exports...');
698 |
699 | const blocks: string[] = [];
700 | let exportedObjects: Dictionary = {};
701 |
702 | sortArrayAlphabetically(Object.keys(this.exports)).forEach((path) => {
703 | const objects = Object.keys(this.exports[path]);
704 | if (!objects.length) {
705 | return;
706 | }
707 |
708 | const blockLines: string[] = [];
709 |
710 | blockLines.push('export {');
711 | sortArrayAlphabetically(objects).forEach((object) => {
712 | if (exportedObjects[object]) {
713 | return;
714 | }
715 | blockLines.push(` ${object},`);
716 | exportedObjects[object] = true;
717 | });
718 | blockLines.push(`} from '${path.replace('.ts', '')}';`);
719 |
720 | blocks.push(blockLines.join(newLineChar));
721 | });
722 |
723 | this.registerResultFile('index.ts', blocks.join(newLineChar.repeat(2)));
724 | consoleLogInfo(`${Object.keys(exportedObjects).length} objects successfully generated`);
725 | }
726 |
727 | public generate() {
728 | consoleLogInfo('generate');
729 |
730 | this.generateMethods();
731 | this.generateErrors();
732 |
733 | if (this.needEmit) {
734 | this.createCommonTypes();
735 | this.createIndexExports();
736 |
737 | consoleLogInfo('prepare out directory');
738 | prepareBuildDirectory(this.outDirPath);
739 |
740 | consoleLogInfo('write files');
741 | Object.keys(this.resultFiles).forEach((filePath) => {
742 | const fileContent = this.resultFiles[filePath];
743 | writeFile(path.join(this.outDirPath, filePath), fileContent);
744 | });
745 | }
746 | }
747 | }
748 |
--------------------------------------------------------------------------------
/src/generators/BaseCodeBlock.ts:
--------------------------------------------------------------------------------
1 | import { RefsDictionary } from '../types';
2 |
3 | export abstract class BaseCodeBlock {
4 | toString(): string {
5 | return '';
6 | }
7 | }
8 |
9 | export type CodeBlocksArray = BaseCodeBlock[];
10 |
11 | export interface GeneratorResultInterface {
12 | codeBlocks: CodeBlocksArray;
13 | imports: RefsDictionary;
14 | value: string;
15 | description?: string;
16 | }
17 |
--------------------------------------------------------------------------------
/src/generators/CommentCodeBlock.ts:
--------------------------------------------------------------------------------
1 | import { BaseCodeBlock } from './BaseCodeBlock';
2 | import { newLineChar, spaceChar } from '../constants';
3 |
4 | export class CommentCodeBlock extends BaseCodeBlock {
5 | constructor(lines: string[] = []) {
6 | super();
7 | this.lines = lines;
8 | }
9 |
10 | lines: string[];
11 |
12 | appendLines(lines: string[]) {
13 | this.lines = [...this.lines, ...lines];
14 | }
15 |
16 | toString(): string {
17 | const inner = this.lines.map((line) => spaceChar + `* ${line}`.trim());
18 |
19 | return ['/**', ...inner, ' */'].join(newLineChar);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/generators/SchemaObject.ts:
--------------------------------------------------------------------------------
1 | import { EnumLikeArray } from '../types';
2 | import { isObject, isString } from '../utils';
3 | import { transformPatternPropertyName } from '../helpers';
4 | import { consoleLogErrorAndExit } from '../log';
5 |
6 | export class SchemaObject {
7 | constructor(name: string, object: any, parentName?: string) {
8 | if (!isObject(object)) {
9 | consoleLogErrorAndExit(`[SchemaObject] "${name}" is not an object.`, {
10 | name,
11 | object,
12 | parentName,
13 | });
14 | return;
15 | }
16 |
17 | this.name = name;
18 |
19 | if (parentName) {
20 | this.parentObjectName = parentName;
21 | }
22 |
23 | if (isString(object.type)) {
24 | this.type = object.type;
25 | } else if (Array.isArray(object.type)) {
26 | this.type = object.type;
27 | }
28 |
29 | if (isString(object.description)) {
30 | this.description = object.description;
31 | }
32 |
33 | if (isString(object.$ref)) {
34 | this.ref = object.$ref;
35 | }
36 |
37 | if (Array.isArray(object.enum)) {
38 | this.enum = object.enum;
39 | }
40 |
41 | if (Array.isArray(object.enumNames)) {
42 | this.enumNames = object.enumNames;
43 | }
44 |
45 | if (Array.isArray(object.required)) {
46 | this.required = object.required;
47 | } else {
48 | this.required = [];
49 | }
50 |
51 | if (typeof object.required === 'boolean') {
52 | this.isRequired = object.required;
53 | }
54 |
55 | this.properties = [];
56 |
57 | if (object.properties) {
58 | Object.entries(object.properties).forEach(([propertyName, property]: [string, any]) => {
59 | this.properties.push(new SchemaObject(propertyName, property, name));
60 | });
61 | }
62 |
63 | this.parameters = [];
64 |
65 | if (Array.isArray(object.parameters)) {
66 | object.parameters.forEach((parameter: any) => {
67 | this.parameters.push(new SchemaObject(parameter.name, parameter, `${name} param`));
68 | });
69 | }
70 |
71 | if (object.patternProperties) {
72 | Object.entries(object.patternProperties).forEach(
73 | ([propertyName, property]: [string, any]) => {
74 | this.properties.push(
75 | new SchemaObject(transformPatternPropertyName(propertyName), property, name),
76 | );
77 | },
78 | );
79 | }
80 |
81 | if (isObject(object.items)) {
82 | this.items = new SchemaObject(name + '_items', object.items, this.name);
83 | }
84 |
85 | if (Array.isArray(object.oneOf) && object.oneOf.length > 0) {
86 | this.oneOf = object.oneOf.map((item: any) => new SchemaObject(name, item));
87 | }
88 |
89 | if (Array.isArray(object.allOf) && object.allOf.length > 0) {
90 | this.allOf = object.allOf.map((item: any) => new SchemaObject(name, item));
91 | }
92 | }
93 |
94 | name!: string;
95 | parentObjectName!: string;
96 |
97 | type!: string | string[];
98 | description!: string;
99 | ref!: string;
100 | required!: string[];
101 | isRequired!: boolean;
102 | readonly enum!: EnumLikeArray;
103 | readonly enumNames!: EnumLikeArray;
104 | properties!: SchemaObject[];
105 | parameters!: SchemaObject[];
106 | readonly items!: SchemaObject;
107 | readonly oneOf!: SchemaObject[];
108 | readonly allOf!: SchemaObject[];
109 |
110 | public setName(name: string) {
111 | this.name = name;
112 |
113 | if (Array.isArray(this.properties)) {
114 | this.properties.forEach((property) => {
115 | property.parentObjectName = name;
116 | });
117 | }
118 | }
119 |
120 | public clone() {
121 | return Object.assign(
122 | Object.create(Object.getPrototypeOf(this)),
123 | this,
124 | ) as NonNullable;
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/generators/TypeCodeBlock.ts:
--------------------------------------------------------------------------------
1 | import { BaseCodeBlock } from './BaseCodeBlock';
2 | import { newLineChar } from '../constants';
3 | import { areQuotesNeededForProperty, joinCommentLines } from '../helpers';
4 | import { consoleLogErrorAndExit } from '../log';
5 | import { quoteJavaScriptValue, trimStringDoubleSpaces } from '../utils';
6 |
7 | export enum TypeScriptCodeTypes {
8 | Interface = 'interface',
9 | Enum = 'enum',
10 | ConstantObject = 'constant_object',
11 | Type = 'type',
12 | Const = 'const',
13 | }
14 |
15 | export interface TypeCodeBlockProperty {
16 | name: string;
17 | value: string | number;
18 | description?: string;
19 | isRequired?: boolean;
20 | wrapValue?: boolean;
21 | }
22 |
23 | export interface TypeCodeBlockOptions {
24 | type: TypeScriptCodeTypes;
25 | refName?: string;
26 | interfaceName: string;
27 | extendsInterfaces?: string[];
28 | allowEmptyInterface?: boolean;
29 | description?: string;
30 | properties: TypeCodeBlockProperty[];
31 | value?: string;
32 | needExport?: boolean;
33 | }
34 |
35 | export class TypeCodeBlock extends BaseCodeBlock {
36 | constructor(options: TypeCodeBlockOptions) {
37 | super();
38 |
39 | this.options = options;
40 |
41 | this.type = options.type;
42 | this.refName = options.refName;
43 | this.interfaceName = options.interfaceName;
44 | this.extendsInterfaces = options.extendsInterfaces;
45 | this.description = options.description;
46 | this.properties = options.properties;
47 | this.value = options.value;
48 | this.needExport = options.needExport;
49 | }
50 |
51 | readonly options!: TypeCodeBlockOptions;
52 |
53 | readonly type!: TypeCodeBlockOptions['type'];
54 | readonly refName!: TypeCodeBlockOptions['refName'];
55 | readonly interfaceName!: TypeCodeBlockOptions['interfaceName'];
56 | readonly extendsInterfaces!: TypeCodeBlockOptions['extendsInterfaces'];
57 | readonly description!: TypeCodeBlockOptions['description'];
58 | readonly properties: TypeCodeBlockOptions['properties'];
59 | readonly value!: TypeCodeBlockOptions['value'];
60 | readonly needExport!: TypeCodeBlockOptions['needExport'];
61 |
62 | addProperty(property: TypeCodeBlockProperty) {
63 | this.properties.push(property);
64 | }
65 |
66 | private getPropertiesCode() {
67 | const quoteChar = this.properties.some((property) => areQuotesNeededForProperty(property.name))
68 | ? "'"
69 | : '';
70 |
71 | return this.properties
72 | .map((property) => {
73 | let divider = '';
74 | let lineEnd = '';
75 |
76 | switch (this.type) {
77 | case TypeScriptCodeTypes.Interface:
78 | divider = property.isRequired ? ':' : '?:';
79 | lineEnd = ';';
80 | break;
81 | case TypeScriptCodeTypes.ConstantObject:
82 | divider = ':';
83 | lineEnd = ',';
84 | break;
85 | case TypeScriptCodeTypes.Enum:
86 | divider = ' =';
87 | lineEnd = ',';
88 | break;
89 | }
90 |
91 | let value = property.wrapValue ? quoteJavaScriptValue(property.value) : property.value;
92 | let propertyCode = [
93 | ` ${quoteChar}${property.name}${quoteChar}${divider} ${value}${lineEnd}`,
94 | ];
95 |
96 | if (property.description) {
97 | const commentLines = joinCommentLines(2, property.description);
98 | if (commentLines.length) {
99 | propertyCode.unshift(commentLines.join(newLineChar));
100 | }
101 | }
102 |
103 | return propertyCode.join(newLineChar);
104 | })
105 | .join(newLineChar);
106 | }
107 |
108 | toString(): string {
109 | const hasProperties = this.properties.length > 0;
110 | const exportKeyword = this.needExport ? 'export' : '';
111 |
112 | let propertiesCode = this.getPropertiesCode();
113 | let before: string[] = [];
114 | let code = '';
115 |
116 | if (this.refName) {
117 | before.push(`// ${this.refName}`);
118 | }
119 |
120 | if (this.description) {
121 | before = [...before, ...joinCommentLines(0, this.description)];
122 | }
123 |
124 | switch (this.type) {
125 | case TypeScriptCodeTypes.Interface: {
126 | if (!hasProperties) {
127 | if (this.options.allowEmptyInterface) {
128 | propertiesCode = '';
129 | } else {
130 | propertiesCode = [' // empty interface', ' [key: string]: any;'].join(newLineChar);
131 | }
132 | }
133 |
134 | const extendsInterfaces =
135 | Array.isArray(this.extendsInterfaces) && this.extendsInterfaces.length
136 | ? this.extendsInterfaces.join(', ')
137 | : '';
138 |
139 | code = [
140 | trimStringDoubleSpaces(
141 | `${exportKeyword} interface ${this.interfaceName} ${extendsInterfaces} {`,
142 | ),
143 | propertiesCode,
144 | '}',
145 | ].join(propertiesCode.length ? newLineChar : '');
146 | break;
147 | }
148 |
149 | case TypeScriptCodeTypes.Enum:
150 | code = [
151 | trimStringDoubleSpaces(`${exportKeyword} enum ${this.interfaceName} {`),
152 | propertiesCode,
153 | '}',
154 | ].join(newLineChar);
155 | break;
156 |
157 | case TypeScriptCodeTypes.ConstantObject:
158 | code = [
159 | trimStringDoubleSpaces(`${exportKeyword} const ${this.interfaceName} = {`),
160 | propertiesCode,
161 | '} as const;',
162 | ].join(newLineChar);
163 | break;
164 |
165 | case TypeScriptCodeTypes.Type:
166 | if (!this.value) {
167 | consoleLogErrorAndExit(`"${this.interfaceName}" type has empty value`);
168 | }
169 |
170 | code = [
171 | trimStringDoubleSpaces(`${exportKeyword} type ${this.interfaceName} = ${this.value};`),
172 | ].join(newLineChar);
173 | break;
174 |
175 | case TypeScriptCodeTypes.Const:
176 | if (!this.value) {
177 | consoleLogErrorAndExit(`"${this.interfaceName}" type has empty value`);
178 | }
179 |
180 | code = [
181 | trimStringDoubleSpaces(`${exportKeyword} const ${this.interfaceName} = ${this.value};`),
182 | ].join(newLineChar);
183 | break;
184 | }
185 |
186 | return [before.join(newLineChar), code].join(newLineChar).trim();
187 | }
188 | }
189 |
--------------------------------------------------------------------------------
/src/generators/enums.ts:
--------------------------------------------------------------------------------
1 | import { newLineChar } from '../constants';
2 | import { getEnumPropertyName, getInterfaceName, joinOneOfValues } from '../helpers';
3 | import { RefsDictionaryType } from '../types';
4 | import { quoteJavaScriptValue } from '../utils';
5 | import { CodeBlocksArray, GeneratorResultInterface } from './BaseCodeBlock';
6 | import { SchemaObject } from './SchemaObject';
7 | import { TypeCodeBlock, TypeScriptCodeTypes } from './TypeCodeBlock';
8 |
9 | export function isNumericEnum(object: SchemaObject): boolean {
10 | return object.enum.some((value) => !!+value);
11 | }
12 |
13 | export function getEnumNamesIdentifier(name: string) {
14 | if (!name) {
15 | throw new Error('[getEnumNamesIdentifier] empty name');
16 | }
17 |
18 | return `${name} enumNames`.trim();
19 | }
20 |
21 | export function generateEnumConstantObject(
22 | object: SchemaObject,
23 | objectName: string,
24 | enumNames: Array,
25 | ) {
26 | const enumInterfaceName = getInterfaceName(objectName);
27 |
28 | const codeBlock = new TypeCodeBlock({
29 | type: TypeScriptCodeTypes.ConstantObject,
30 | refName: objectName,
31 | interfaceName: enumInterfaceName,
32 | needExport: true,
33 | properties: [],
34 | });
35 |
36 | enumNames.forEach((name, index) => {
37 | codeBlock.addProperty({
38 | name: getEnumPropertyName(name.toString()),
39 | value: object.enum[index],
40 | wrapValue: true,
41 | });
42 | });
43 |
44 | return codeBlock;
45 | }
46 |
47 | /**
48 | * Generates enum as union type with constant object if necessary
49 | */
50 | export function generateEnumAsUnionType(object: SchemaObject): GeneratorResultInterface {
51 | const { codeBlocks, value, description } = generateInlineEnum(object, {
52 | refName: getEnumNamesIdentifier(object.name),
53 | });
54 |
55 | const unionType = new TypeCodeBlock({
56 | type: TypeScriptCodeTypes.Type,
57 | refName: object.name,
58 | interfaceName: getInterfaceName(object.name),
59 | description: [object.description, description].join(newLineChar),
60 | needExport: true,
61 | properties: [],
62 | value,
63 | });
64 |
65 | codeBlocks.push(unionType);
66 |
67 | return {
68 | codeBlocks,
69 | imports: {},
70 | value: '',
71 | };
72 | }
73 |
74 | function getEnumNames(object: SchemaObject) {
75 | let { enumNames } = object;
76 |
77 | const isNumeric = isNumericEnum(object);
78 | const needEnumNamesDescription = !!enumNames;
79 |
80 | if (!enumNames) {
81 | const canUseEnumNames = !isNumeric;
82 | if (canUseEnumNames) {
83 | enumNames = [...object.enum];
84 | }
85 | }
86 |
87 | return {
88 | isNumericEnum: isNumeric,
89 | needEnumNamesDescription,
90 | enumNames: Array.isArray(enumNames) && enumNames.length ? enumNames : undefined,
91 | };
92 | }
93 |
94 | interface GenerateInlineEnumOptions {
95 | objectParentName?: string;
96 | needEnumNamesConstant?: boolean;
97 | refType?: RefsDictionaryType.Generate;
98 | refName?: string;
99 | }
100 |
101 | export function generateInlineEnum(
102 | object: SchemaObject,
103 | options: GenerateInlineEnumOptions = {},
104 | ): GeneratorResultInterface {
105 | const { isNumericEnum, enumNames, needEnumNamesDescription } = getEnumNames(object);
106 |
107 | options = {
108 | needEnumNamesConstant: isNumericEnum,
109 | ...options,
110 | };
111 |
112 | const codeBlocks: CodeBlocksArray = [];
113 | let descriptionLines: string[] = [];
114 |
115 | if (enumNames) {
116 | if (needEnumNamesDescription) {
117 | if (isNumericEnum && options.refName) {
118 | descriptionLines.push('');
119 | descriptionLines.push('@note This enum have auto-generated constant with keys and values');
120 | descriptionLines.push(`@see ${getInterfaceName(options.refName)}`);
121 | }
122 |
123 | descriptionLines.push('');
124 |
125 | enumNames.forEach((name, index) => {
126 | const value = object.enum[index];
127 |
128 | if (needEnumNamesDescription) {
129 | descriptionLines.push(`\`${value}\` — ${name}`);
130 | }
131 | });
132 | }
133 |
134 | if (isNumericEnum && options.needEnumNamesConstant) {
135 | const enumName = getEnumNamesIdentifier(`${options.objectParentName || ''} ${object.name}`);
136 |
137 | const codeBlock = generateEnumConstantObject(object, enumName, enumNames);
138 | codeBlocks.push(codeBlock);
139 | }
140 | }
141 |
142 | const values = object.enum.map((value) => quoteJavaScriptValue(value));
143 |
144 | return {
145 | codeBlocks,
146 | imports: {},
147 | value: joinOneOfValues(values, true),
148 | description: descriptionLines.join(newLineChar),
149 | };
150 | }
151 |
--------------------------------------------------------------------------------
/src/generators/methods.ts:
--------------------------------------------------------------------------------
1 | import { baseBoolIntRef, newLineChar } from '../constants';
2 | import { getInterfaceName, getObjectNameByRef } from '../helpers';
3 | import { RefsDictionary, RefsDictionaryType } from '../types';
4 | import * as Schema from '../types/schema';
5 |
6 | interface NormalizeMethodInfoResult {
7 | method: Schema.Method;
8 | parameterRefs: RefsDictionary;
9 | }
10 |
11 | /**
12 | * Patches for method definition
13 | */
14 | export function normalizeMethodInfo(method: Schema.Method): NormalizeMethodInfoResult {
15 | const parameterRefs: RefsDictionary = {};
16 |
17 | method.parameters?.forEach((parameter) => {
18 | // For method params "boolean" type means 1 or 0
19 | // Real "false" boolean value will be detected by API as true
20 | if (parameter.type === 'boolean') {
21 | // @ts-expect-error
22 | delete parameter.type;
23 | parameter.$ref = baseBoolIntRef;
24 | }
25 |
26 | // For parameters of the "array" type, VK API still accepts only a comma-separated string
27 | // This may change in the future when the VK API starts accepting a json body
28 | if (parameter.type === 'array') {
29 | parameter.type = 'string';
30 | }
31 |
32 | if (!parameter.description) {
33 | parameter.description = '';
34 | }
35 |
36 | if (parameter.items && parameter.items.$ref) {
37 | const ref = parameter.items?.$ref;
38 | parameterRefs[ref] = RefsDictionaryType.Generate;
39 |
40 | parameter.description +=
41 | newLineChar.repeat(2) +
42 | [`@see ${getInterfaceName(getObjectNameByRef(ref))} (${ref})`].join(newLineChar);
43 | }
44 | });
45 |
46 | return {
47 | method,
48 | parameterRefs,
49 | };
50 | }
51 |
--------------------------------------------------------------------------------
/src/generators/typeString.ts:
--------------------------------------------------------------------------------
1 | import {
2 | baseBoolIntRef,
3 | baseOkResponseRef,
4 | basePropertyExistsRef,
5 | primitiveTypes,
6 | PropertyType,
7 | scalarTypes,
8 | } from '../constants';
9 | import { generateInlineEnum } from './enums';
10 | import {
11 | formatArrayDepth,
12 | getInterfaceName,
13 | getObjectNameByRef,
14 | joinOneOfValues,
15 | resolvePrimitiveTypesArray,
16 | } from '../helpers';
17 | import { consoleLogErrorAndExit } from '../log';
18 | import { Dictionary, RefsDictionary, RefsDictionaryType } from '../types';
19 | import { isString } from '../utils';
20 | import { CodeBlocksArray, GeneratorResultInterface } from './BaseCodeBlock';
21 | import { SchemaObject } from './SchemaObject';
22 | import { mergeImports } from './utils/mergeImports';
23 |
24 | interface GenerateTypeStringOptions {
25 | objectParentName?: string;
26 | /**
27 | * Determines whether enums will be inline to type value or them will be as separate interface block
28 | */
29 | needEnumNamesConstant?: boolean;
30 | }
31 |
32 | function generateBaseType(
33 | object: SchemaObject,
34 | options: GenerateTypeStringOptions,
35 | ): GeneratorResultInterface {
36 | let codeBlocks: CodeBlocksArray = [];
37 | let typeString = 'any /* default type */';
38 | let imports: RefsDictionary = {};
39 | let description: string | undefined = '';
40 |
41 | if (object.enum) {
42 | const {
43 | value,
44 | codeBlocks: newCodeBlocks,
45 | description: newDescription,
46 | } = generateInlineEnum(object, {
47 | // TODO: Refactor
48 | // section_object_name -> property_name -> items => section_object_name_property_name_items enumNames
49 | objectParentName: options.objectParentName || object.parentObjectName,
50 | needEnumNamesConstant: options.needEnumNamesConstant,
51 | });
52 |
53 | typeString = value;
54 | codeBlocks = newCodeBlocks;
55 | description = newDescription;
56 | } else if (isString(object.type)) {
57 | const primitive = primitiveTypes[object.type];
58 | if (!primitive) {
59 | consoleLogErrorAndExit(object.name, `Error, type "${object.type}" is not declared type`);
60 | }
61 |
62 | typeString = primitive;
63 | } else if (Array.isArray(object.type)) {
64 | const primitivesTypesArray = resolvePrimitiveTypesArray(object.type);
65 | if (primitivesTypesArray !== null) {
66 | typeString = primitivesTypesArray;
67 | }
68 | }
69 |
70 | return {
71 | codeBlocks,
72 | imports,
73 | value: typeString,
74 | description,
75 | };
76 | }
77 |
78 | export function generateTypeString(
79 | object: SchemaObject,
80 | objects: Dictionary,
81 | options: GenerateTypeStringOptions = {},
82 | ): GeneratorResultInterface {
83 | let codeBlocks: CodeBlocksArray = [];
84 | let typeString = 'any /* default type */';
85 | let imports: RefsDictionary = {};
86 | let description: string | undefined = '';
87 |
88 | options = {
89 | needEnumNamesConstant: true,
90 | ...options,
91 | };
92 |
93 | if (object.oneOf) {
94 | const values = object.oneOf.map((oneOfObject) => {
95 | const { value, imports: newImports } = generateTypeString(oneOfObject, objects);
96 | imports = mergeImports(imports, newImports);
97 | return value;
98 | });
99 |
100 | typeString = joinOneOfValues(values);
101 | } else if (object.type === PropertyType.ARRAY && object.items) {
102 | let depth = 1;
103 | let items = object.items;
104 |
105 | // Nested arrays
106 | while (true) {
107 | if (items.items) {
108 | items = items.items;
109 | depth++;
110 | } else {
111 | break;
112 | }
113 | }
114 |
115 | if (items.ref) {
116 | const refName = getObjectNameByRef(items.ref);
117 | const refObject = objects[refName];
118 | if (!refObject) {
119 | consoleLogErrorAndExit(`Error, object for "${refName}" ref is not found.`);
120 | }
121 |
122 | imports[refName] = RefsDictionaryType.GenerateAndImport;
123 | typeString = formatArrayDepth(getInterfaceName(refName), depth);
124 | } else {
125 | const {
126 | value,
127 | description: newDescription,
128 | imports: newImports,
129 | codeBlocks: newCodeBlocks,
130 | } = generateBaseType(items, {
131 | ...options,
132 | // TODO: Refactor
133 | objectParentName: object.parentObjectName,
134 | });
135 |
136 | typeString = formatArrayDepth(value, depth);
137 | description = newDescription;
138 | imports = mergeImports(imports, newImports);
139 | codeBlocks = [...codeBlocks, ...newCodeBlocks];
140 | }
141 | } else if (object.ref) {
142 | const refName = getObjectNameByRef(object.ref);
143 |
144 | switch (refName) {
145 | case baseOkResponseRef:
146 | case basePropertyExistsRef:
147 | typeString = '1';
148 | break;
149 |
150 | case baseBoolIntRef:
151 | typeString = '0 | 1';
152 | break;
153 |
154 | default: {
155 | const refObject = objects[refName];
156 | if (!refObject) {
157 | consoleLogErrorAndExit(`Error, object for "${refName}" ref is not found.`);
158 | }
159 |
160 | if (refObject.enum) {
161 | imports[refName] = RefsDictionaryType.GenerateAndImport;
162 | typeString = getInterfaceName(refName);
163 | } else if (refObject.oneOf) {
164 | const values = refObject.oneOf.map((oneOfObject) => {
165 | const { value, imports: newImports } = generateTypeString(oneOfObject, objects);
166 | imports = mergeImports(imports, newImports);
167 | return value;
168 | });
169 |
170 | typeString = joinOneOfValues(values);
171 | } else if (isString(refObject.type) && scalarTypes[refObject.type] && !refObject.ref) {
172 | typeString = scalarTypes[refObject.type];
173 | } else if (object.type === PropertyType.STRING) {
174 | imports[refName] = RefsDictionaryType.Generate;
175 | typeString = scalarTypes.string;
176 | } else {
177 | imports[refName] = RefsDictionaryType.GenerateAndImport;
178 | typeString = getInterfaceName(refName);
179 | }
180 | }
181 | }
182 | } else if (object.type) {
183 | return generateBaseType(object, options);
184 | }
185 |
186 | return {
187 | codeBlocks,
188 | imports,
189 | value: typeString,
190 | description,
191 | };
192 | }
193 |
--------------------------------------------------------------------------------
/src/generators/utils/mergeImports.ts:
--------------------------------------------------------------------------------
1 | import { RefsDictionary, RefsDictionaryType } from '../../types';
2 |
3 | export function mergeImports(oldImports: RefsDictionary, newImports: RefsDictionary) {
4 | const result = { ...oldImports };
5 |
6 | Object.entries(newImports).forEach(([name, newImportValue]) => {
7 | const oldImportValue = oldImports[name];
8 |
9 | if (oldImportValue === RefsDictionaryType.GenerateAndImport) {
10 | return;
11 | }
12 |
13 | result[name] = newImportValue;
14 | });
15 |
16 | return result;
17 | }
18 |
--------------------------------------------------------------------------------
/src/helpers.test.ts:
--------------------------------------------------------------------------------
1 | import { areQuotesNeededForProperty } from './helpers';
2 |
3 | test('areQuotesNeededForProperty', () => {
4 | expect(areQuotesNeededForProperty('user_id')).toBe(false);
5 | expect(areQuotesNeededForProperty('uuid4')).toBe(false);
6 | expect(areQuotesNeededForProperty('_foo')).toBe(false);
7 |
8 | expect(areQuotesNeededForProperty('4uuid')).toBe(true);
9 | expect(areQuotesNeededForProperty('user-id')).toBe(true);
10 | expect(areQuotesNeededForProperty('user&id')).toBe(true);
11 | expect(areQuotesNeededForProperty('идентификатор')).toBe(true);
12 | });
13 |
--------------------------------------------------------------------------------
/src/helpers.ts:
--------------------------------------------------------------------------------
1 | import fs, { promises as fsPromises } from 'fs';
2 | import path from 'path';
3 | import { capitalizeFirstLetter, trimArray } from './utils';
4 | import { newLineChar, primitiveTypes, spaceChar } from './constants';
5 | import { Dictionary } from './types';
6 | import { consoleLogErrorAndExit } from './log';
7 | import prettier from 'prettier';
8 |
9 | export async function readJSONFile(path: string): Promise {
10 | const content = await fsPromises.readFile(path, 'utf-8');
11 | return JSON.parse(content);
12 | }
13 |
14 | function deleteDirectoryRecursive(directoryPath: string) {
15 | if (fs.existsSync(directoryPath)) {
16 | fs.readdirSync(directoryPath).forEach((file) => {
17 | const currentPath = path.join(directoryPath, file);
18 | if (fs.lstatSync(currentPath).isDirectory()) {
19 | deleteDirectoryRecursive(currentPath);
20 | } else {
21 | fs.unlinkSync(currentPath);
22 | }
23 | });
24 | fs.rmdirSync(directoryPath);
25 | }
26 | }
27 |
28 | export function prepareBuildDirectory(directoryPath: string) {
29 | deleteDirectoryRecursive(directoryPath);
30 | fs.mkdirSync(directoryPath, { recursive: true });
31 | }
32 |
33 | export function writeFile(filePath: string, code: string, insertAutoGeneratedNote = true) {
34 | if (insertAutoGeneratedNote) {
35 | code =
36 | [
37 | '/**',
38 | " * This is auto-generated file, don't modify this file manually",
39 | ' */',
40 | // '/* eslint-disable max-len */',
41 | // '/* eslint-disable @typescript-eslint/no-empty-interface */',
42 | ].join(newLineChar) +
43 | newLineChar.repeat(2) +
44 | code.trim();
45 | }
46 |
47 | code = prettier.format(code, {
48 | semi: true,
49 | singleQuote: true,
50 | trailingComma: 'all',
51 | quoteProps: 'consistent',
52 | parser: 'typescript',
53 | });
54 |
55 | fs.mkdirSync(filePath.replace(path.basename(filePath), ''), {
56 | recursive: true,
57 | });
58 | fs.writeFileSync(filePath, code.trim() + newLineChar);
59 | }
60 |
61 | export function prepareMethodsPattern(methodsPattern: string): Dictionary {
62 | if (!methodsPattern) {
63 | consoleLogErrorAndExit('methodsPattern is empty. Pass "*" to generate all methods');
64 | }
65 |
66 | return methodsPattern
67 | .replace(/\s+/g, '')
68 | .split(',')
69 | .reduce>((acc, pattern) => {
70 | acc[pattern] = true;
71 | return acc;
72 | }, {});
73 | }
74 |
75 | export function isMethodNeeded(methodsPattern: Dictionary, method: string): boolean {
76 | const [methodSection, methodName] = method.split('.');
77 |
78 | return Object.keys(methodsPattern).some((pattern) => {
79 | const [patternSection, patternMethod] = pattern.split('.');
80 | if (patternSection === '*') {
81 | return true;
82 | }
83 |
84 | if (patternSection === methodSection) {
85 | return patternMethod === '*' || patternMethod === methodName;
86 | }
87 |
88 | return false;
89 | });
90 | }
91 |
92 | export function getMethodSection(methodName: string): string {
93 | return methodName.split('.')[0];
94 | }
95 |
96 | export function getInterfaceName(name: string): string {
97 | name = name
98 | .replace(/\.|(\s+)|_/g, ' ')
99 | .split(' ')
100 | .map((v) => capitalizeFirstLetter(v))
101 | .join('');
102 |
103 | return capitalizeFirstLetter(name);
104 | }
105 |
106 | export function getEnumPropertyName(name: string): string {
107 | return name.toUpperCase().replace(/\s+/g, '_').replace(/-/g, '_').replace(/\./g, '_');
108 | }
109 |
110 | export function getObjectNameByRef(ref: string): string {
111 | const parts = ref.split('/');
112 | return parts[parts.length - 1];
113 | }
114 |
115 | export function getSectionFromObjectName(name: string): string {
116 | return name.split('_')[0];
117 | }
118 |
119 | export function isPatternProperty(name: string): boolean {
120 | return name.startsWith('[key: ');
121 | }
122 |
123 | export function areQuotesNeededForProperty(name: string | number): boolean {
124 | name = String(name);
125 |
126 | if (isPatternProperty(name)) {
127 | return false;
128 | }
129 |
130 | if (/[&-]/.test(name)) {
131 | return true;
132 | }
133 |
134 | return !(/^[a-z_]([a-z0-9_])+$/i.test(name) || /^[a-z_]/i.test(name));
135 | }
136 |
137 | export function transformPatternPropertyName(name: string): string {
138 | if (name === '^[0-9]+$') {
139 | return '[key: number]';
140 | }
141 |
142 | return '[key: string] /* default pattern property name */';
143 | }
144 |
145 | export function joinCommentLines(indent = 2, ...description: Array): string[] {
146 | let descriptionLines: string[] = [];
147 |
148 | description.forEach((entry) => {
149 | if (typeof entry === 'string') {
150 | descriptionLines = [
151 | ...descriptionLines,
152 | ...trimArray((entry || '').trim().split(newLineChar)),
153 | ];
154 | } else if (Array.isArray(entry)) {
155 | descriptionLines = [...descriptionLines, ...entry];
156 | }
157 | });
158 |
159 | descriptionLines = trimArray(descriptionLines);
160 | if (!descriptionLines.length) {
161 | return [];
162 | }
163 |
164 | const indentSpaces = spaceChar.repeat(indent);
165 |
166 | return [
167 | `${indentSpaces}/**`,
168 | ...descriptionLines.map((line) => {
169 | return indentSpaces + ' ' + `* ${line}`.trim();
170 | }),
171 | `${indentSpaces} */`,
172 | ];
173 | }
174 |
175 | export function joinOneOfValues(values: Array, primitive?: boolean) {
176 | const joined = values.join(' | ');
177 |
178 | if (joined.length > 120) {
179 | const spacesCount = primitive ? 2 : 4;
180 | return values.join(` |${newLineChar}${spaceChar.repeat(spacesCount)}`);
181 | } else {
182 | return joined;
183 | }
184 | }
185 |
186 | export function formatArrayDepth(value: string, depth: number) {
187 | if (value.endsWith("'") || value.includes('|')) {
188 | return `Array<${value}>` + '[]'.repeat(depth - 1); // Need decrement depth value because of Array has its own depth
189 | } else {
190 | return value + '[]'.repeat(depth);
191 | }
192 | }
193 |
194 | export function resolvePrimitiveTypesArray(types: string[]): string | null {
195 | const isEveryTypePrimitive = types.every((type) => !!primitiveTypes[type]);
196 | if (isEveryTypePrimitive) {
197 | return types.map((type) => primitiveTypes[type]).join(' | ');
198 | }
199 |
200 | return null;
201 | }
202 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import chalk from 'chalk';
3 | import { performance } from 'perf_hooks';
4 | import { parseArguments } from './cli';
5 | import { consoleLog, consoleLogErrorAndExit, consoleLogInfo } from './log';
6 | import { APITypingsGenerator } from './generators/APITypingsGenerator';
7 | import { readJSONFile } from './helpers';
8 |
9 | const helpMessage = `
10 | Options:
11 |
12 | ${chalk.greenBright('--help')} Shows this help.
13 |
14 | ${chalk.greenBright('--schemaDir')} The relative path to directory with ${chalk.bold(
15 | 'methods.json',
16 | )}, ${chalk.bold('objects.json')} and ${chalk.bold('responses.json')} files.
17 |
18 | ${chalk.greenBright('--outDir')} The directory where the files will be generated.
19 | If you skip this param, script will work in linter mode without emitting files to file system.
20 | ${chalk.bold(
21 | 'Please note',
22 | )} that this folder will be cleared after starting the generation.
23 |
24 | ${chalk.greenBright(
25 | '--methods',
26 | )} List of methods to generate responses and all needed objects.
27 | Example:
28 | - ${chalk.bold("'*'")} - to generate all responses and objects.
29 | - ${chalk.bold(
30 | "'messages.*, users.get, groups.isMember'",
31 | )} - to generate all methods from messages section, users.get and groups.isMember.
32 | `;
33 |
34 | export async function main() {
35 | console.log(chalk.bold('VK API Schema TypeScript generator'));
36 | const startTime = performance.now();
37 |
38 | const args = parseArguments();
39 | let { help, schemaDir, outDir, methods } = args;
40 |
41 | if (help) {
42 | console.log(helpMessage);
43 | return;
44 | }
45 |
46 | const helpHint = `Use ${chalk.greenBright('--help')} to see all options.`;
47 |
48 | if (!schemaDir) {
49 | consoleLogErrorAndExit(`You should specify ${chalk.greenBright('schemaDir')}. ${helpHint}`);
50 | return;
51 | }
52 |
53 | if (!outDir) {
54 | consoleLogInfo(`${chalk.greenBright('outDir')} option is empty. ${helpHint}`);
55 | consoleLogInfo('Script will work in linter mode without emitting files to file system.');
56 | }
57 |
58 | if (!Array.isArray(methods) || !methods.length) {
59 | consoleLogErrorAndExit(`You should specify ${chalk.greenBright('methods')}. ${helpHint}`);
60 | return;
61 | }
62 |
63 | schemaDir = path.resolve(schemaDir);
64 | outDir = outDir ? path.resolve(outDir) : '';
65 |
66 | // Read and check required schema files
67 |
68 | const [
69 | methodsDefinitions,
70 | { definitions: responsesDefinitions },
71 | { definitions: objectsDefinitions },
72 | { errors: errorsDefinitions },
73 | ] = await Promise.all([
74 | readJSONFile(path.resolve(schemaDir, 'methods.json')),
75 | readJSONFile(path.resolve(schemaDir, 'responses.json')),
76 | readJSONFile(path.resolve(schemaDir, 'objects.json')),
77 | readJSONFile(path.resolve(schemaDir, 'errors.json')),
78 | ]);
79 |
80 | if (!Object.keys(methodsDefinitions).length) {
81 | consoleLogErrorAndExit(`${chalk.greenBright('responses.json')} file is invalid.`);
82 | return;
83 | }
84 |
85 | if (!Object.keys(responsesDefinitions).length) {
86 | consoleLogErrorAndExit(`${chalk.greenBright('responses.json')} file is invalid.`);
87 | return;
88 | }
89 |
90 | if (!Object.keys(objectsDefinitions).length) {
91 | consoleLogErrorAndExit(`${chalk.greenBright('objects.json')} file is invalid.`);
92 | return;
93 | }
94 |
95 | const needEmit = !!outDir;
96 |
97 | const generator = new APITypingsGenerator({
98 | needEmit,
99 | outDirPath: outDir,
100 | methodsDefinitions,
101 | objects: objectsDefinitions,
102 | responses: responsesDefinitions,
103 | errors: errorsDefinitions,
104 | methodsPattern: methods.join(','),
105 | });
106 |
107 | generator.generate();
108 |
109 | const endTime = performance.now();
110 |
111 | consoleLog(`✨ Done in ${((endTime - startTime) / 1000).toFixed(2)}s.`);
112 | }
113 |
--------------------------------------------------------------------------------
/src/log.ts:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk';
2 | import { inspect } from 'util';
3 |
4 | function getInspectArgs(args: any[]) {
5 | return args.map((arg) => {
6 | if (typeof arg === 'object') {
7 | return inspect(arg, {
8 | showHidden: false,
9 | depth: 10,
10 | colors: true,
11 | });
12 | } else {
13 | return arg;
14 | }
15 | });
16 | }
17 |
18 | export function consoleLog(...args: any[]) {
19 | console.log(...getInspectArgs(args));
20 | }
21 |
22 | export function consoleLogInfo(...args: any[]) {
23 | console.log(`${chalk.cyanBright.bold('info')}`, ...getInspectArgs(args));
24 | }
25 |
26 | export function consoleLogError(...args: any[]) {
27 | console.log(`${chalk.redBright.bold('error')}`, ...getInspectArgs(args));
28 | }
29 |
30 | export function consoleLogErrorAndExit(...args: any[]) {
31 | consoleLogError(...args);
32 | process.exit(1);
33 | }
34 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface Dictionary {
2 | [key: string]: T;
3 | }
4 |
5 | export enum RefsDictionaryType {
6 | GenerateAndImport,
7 | Generate,
8 | }
9 |
10 | export type RefsDictionary = Record;
11 |
12 | export type EnumLikeArray = Array;
13 |
14 | export enum ObjectType {
15 | Object = 'object',
16 | Response = 'response',
17 | Params = 'params',
18 | }
19 |
--------------------------------------------------------------------------------
/src/types/schema.ts:
--------------------------------------------------------------------------------
1 | export type Format = 'json' | 'int32' | 'int64';
2 |
3 | /**
4 | * Enum values text representations
5 | */
6 | export type EnumNames = [string, ...string[]];
7 |
8 | /**
9 | * This interface was referenced by `undefined`'s JSON-Schema definition
10 | * via the `patternProperty` "^[a-zA-Z0-9_]+$".
11 | */
12 | export type ResponseProperty = {
13 | [k: string]: unknown;
14 | };
15 |
16 | /**
17 | * Possible custom errors
18 | */
19 | export type MethodErrors = Array<{
20 | $ref?: string;
21 | }>;
22 |
23 | /**
24 | * VK API declaration
25 | */
26 | export interface API {
27 | errors?: {
28 | [k: string]: Error;
29 | };
30 | methods?: Method[];
31 | definitions?: {
32 | [k: string]: Response;
33 | };
34 | $schema?: string;
35 | title?: string;
36 | description?: string;
37 | termsOfService?: string;
38 | version?: string;
39 | }
40 |
41 | /**
42 | * This interface was referenced by `undefined`'s JSON-Schema definition
43 | * via the `patternProperty` "^[a-z][a-z0-9_]+$".
44 | */
45 | export interface Error {
46 | /**
47 | * Error code
48 | */
49 | code: number;
50 | /**
51 | * Error description
52 | */
53 | description: string;
54 | /**
55 | * Array of error subcodes
56 | */
57 | subcodes?: ErrorSubcode[];
58 | global?: boolean;
59 | disabled?: boolean;
60 | }
61 |
62 | export interface ErrorSubcode {
63 | subcode?: number;
64 | description?: string;
65 | $comment?: string;
66 | $ref?: string;
67 | }
68 |
69 | export interface Method {
70 | /**
71 | * Method name
72 | */
73 | name: string;
74 | /**
75 | * Method description
76 | */
77 | description?: string;
78 | timeout?: number;
79 | /**
80 | * Input parameters for method
81 | */
82 | access_token_type: Array<'open' | 'user' | 'group' | 'service'>;
83 | /**
84 | * Input parameters for method
85 | */
86 | parameters?: Parameter[];
87 | /**
88 | * References to response objects
89 | */
90 | responses: {
91 | [k: string]: Response;
92 | };
93 | emptyResponse?: boolean;
94 | errors?: MethodErrors;
95 | }
96 |
97 | export interface Parameter {
98 | /**
99 | * Parameter name
100 | */
101 | name: string;
102 | format?: Format;
103 | /**
104 | * Parameter type
105 | */
106 | type: 'array' | 'boolean' | 'integer' | 'number' | 'string';
107 | items?: {
108 | $ref: string;
109 | };
110 | maxItems?: number;
111 | minItems?: number;
112 | maximum?: number;
113 | minimum?: number;
114 | $ref?: string;
115 | enum?: [unknown, ...unknown[]];
116 | enumNames?: EnumNames;
117 | /**
118 | * Default property value
119 | */
120 | default?: {
121 | [k: string]: unknown;
122 | };
123 | required?: boolean;
124 | maxLength?: number;
125 | minLength?: number;
126 | /**
127 | * Parameter description
128 | */
129 | description?: string;
130 | }
131 |
132 | /**
133 | * This interface was referenced by `undefined`'s JSON-Schema definition
134 | * via the `patternProperty` "^([a-zA-Z0-9_]+)?[rR]esponse$".
135 | *
136 | * This interface was referenced by `undefined`'s JSON-Schema definition
137 | * via the `patternProperty` "^([a-zA-Z0-9_]+)?[rR]esponse$".
138 | */
139 | export interface Response {
140 | type?: string;
141 | description?: string;
142 | allOf?: Response[];
143 | items?: any[];
144 | required?: unknown[];
145 | title?: string;
146 | oneOf?: unknown[];
147 | $ref?: string;
148 | properties?: {
149 | [k: string]: ResponseProperty;
150 | };
151 | additionalProperties?: boolean;
152 | }
153 |
154 | export interface ErrorInterface {
155 | code: number;
156 | description: string;
157 | $comment?: string;
158 | subcodes?: Array<{ $ref: string }>;
159 | }
160 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { Dictionary } from './types';
2 |
3 | export function flatten(input: Array): T[] {
4 | const stack = [...input];
5 | const result: T[] = [];
6 | while (stack.length) {
7 | const next = stack.pop();
8 | if (next) {
9 | if (Array.isArray(next)) {
10 | stack.push(...next);
11 | } else {
12 | result.push(next);
13 | }
14 | }
15 | }
16 | return result.reverse();
17 | }
18 |
19 | export function isString(object: any): object is string {
20 | return typeof object === 'string';
21 | }
22 |
23 | export function isObject(object: any): boolean {
24 | return Object.prototype.toString.call(object) === '[object Object]';
25 | }
26 |
27 | export function capitalizeFirstLetter(string: string): string {
28 | return string.charAt(0).toUpperCase() + string.slice(1);
29 | }
30 |
31 | export function uniqueArray(array: T[]): T[] {
32 | return array.filter((v, i, a) => a.indexOf(v) === i);
33 | }
34 |
35 | export function sortArrayAlphabetically(array: string[]): string[] {
36 | return array.sort((a: string, b: string) => a.localeCompare(b));
37 | }
38 |
39 | export function arrayToMap(array: any[]): Dictionary {
40 | if (!array) {
41 | return {};
42 | }
43 |
44 | return array.reduce((acc, value) => {
45 | acc[value] = true;
46 | return acc;
47 | }, {});
48 | }
49 |
50 | export function trimStringDoubleSpaces(string: string): string {
51 | return string.trim().replace(/\s\s+/g, ' ');
52 | }
53 |
54 | export function quoteJavaScriptValue(value: string | number) {
55 | return isString(value) ? `'${value}'` : value;
56 | }
57 |
58 | /**
59 | * Removes empty string array elements from start and end of array, trim array elements and returns the new array
60 | *
61 | * @example trimArray(['', 'First', '', 'Second', '', '']) => ['First', '', 'Second']
62 | */
63 | export function trimArray(array: string[]): string[] {
64 | let trimmedArray = array.map((v) => v.trim());
65 |
66 | while (trimmedArray[0] === '') {
67 | trimmedArray.shift();
68 | }
69 |
70 | while (trimmedArray[trimmedArray.length - 1] === '') {
71 | trimmedArray.pop();
72 | }
73 |
74 | return trimmedArray;
75 | }
76 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "lib": ["es6", "dom"],
5 | "esModuleInterop": true,
6 | "allowSyntheticDefaultImports": true,
7 | "noUnusedLocals": true,
8 | "noUnusedParameters": true,
9 | "noImplicitThis": true,
10 | "noImplicitReturns": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "moduleResolution": "node",
13 | "isolatedModules": false,
14 | "noImplicitAny": true,
15 | "module": "commonjs",
16 | "noEmitOnError": true,
17 | "outDir": "./dist",
18 | "strict": true
19 | },
20 | "include": [
21 | "src/**/*.ts"
22 | ],
23 | "exclude": ["node_modules"]
24 | }
25 |
--------------------------------------------------------------------------------