From b5888842f942da9a7fc8d3d0a4392153d2b496e8 Mon Sep 17 00:00:00 2001 From: Kay Schecker Date: Fri, 15 Apr 2022 17:08:21 +0200 Subject: [PATCH] feat: add docker support --- apps/generator-cli/src/README.md | 12 +++ .../src/app/services/config.service.ts | 8 ++ .../src/app/services/generator.service.ts | 55 +++++++++--- .../app/services/pass-through.service.spec.ts | 86 ++++++++++--------- .../src/app/services/pass-through.service.ts | 31 ++++--- .../app/services/version-manager.service.ts | 55 +++++++++--- apps/generator-cli/src/config.schema.json | 8 ++ 7 files changed, 179 insertions(+), 76 deletions(-) diff --git a/apps/generator-cli/src/README.md b/apps/generator-cli/src/README.md index 56f96567f23..444626ca329 100644 --- a/apps/generator-cli/src/README.md +++ b/apps/generator-cli/src/README.md @@ -200,6 +200,18 @@ If the `version` property param is set it is not necessary to configure the `que | openapi-generator-cli generate --generator-key v3.0 v2.0 | yes | yes | | openapi-generator-cli generate --generator-key foo | no | no | +## Use Docker instead of running java locally + +```json +{ + "$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json", + "spaces": 2, + "generator-cli": { + "useDocker": true + } +} +``` + ## Custom Generators Custom generators can be used by passing the `--custom-generator=/my/custom-generator.jar` argument. diff --git a/apps/generator-cli/src/app/services/config.service.ts b/apps/generator-cli/src/app/services/config.service.ts index 6d4335f26f9..a6f0a9f0e4b 100644 --- a/apps/generator-cli/src/app/services/config.service.ts +++ b/apps/generator-cli/src/app/services/config.service.ts @@ -10,6 +10,14 @@ export class ConfigService { public readonly cwd = process.env.PWD || process.env.INIT_CWD || process.cwd() public readonly configFile = path.resolve(this.cwd, 'openapitools.json') + public get useDocker() { + return this.get('generator-cli.useDocker', false); + } + + public get dockerImageName() { + return this.get('generator-cli.dockerImageName', 'openapitools/openapi-generator-cli'); + } + private readonly defaultConfig = { $schema: './node_modules/@openapitools/openapi-generator-cli/config.schema.json', spaces: 2, diff --git a/apps/generator-cli/src/app/services/generator.service.ts b/apps/generator-cli/src/app/services/generator.service.ts index 4b6328a159c..20ed597df36 100644 --- a/apps/generator-cli/src/app/services/generator.service.ts +++ b/apps/generator-cli/src/app/services/generator.service.ts @@ -1,13 +1,14 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { flatten, isString, kebabCase, sortBy, upperFirst } from 'lodash'; +import {Inject, Injectable} from '@nestjs/common'; +import {flatten, isString, kebabCase, sortBy, upperFirst} from 'lodash'; import * as concurrently from 'concurrently'; import * as path from 'path'; +import * as fs from 'fs-extra'; import * as glob from 'glob'; import * as chalk from 'chalk'; -import { VersionManagerService } from './version-manager.service'; -import { ConfigService } from './config.service'; -import { LOGGER } from '../constants'; +import {VersionManagerService} from './version-manager.service'; +import {ConfigService} from './config.service'; +import {LOGGER} from '../constants'; interface GeneratorConfig { glob: string @@ -96,6 +97,7 @@ export class GeneratorService { } private buildCommand(cwd: string, params: Record, customGenerator?: string, specFile?: string) { + const dockerVolumes = {}; const absoluteSpecPath = specFile ? path.resolve(cwd, specFile) : String(params.inputSpec) const command = Object.entries({ @@ -114,7 +116,19 @@ export class GeneratorService { case 'boolean': return undefined default: - return `"${v}"` + + if (this.configService.useDocker) { + if (key === 'output') { + fs.ensureDirSync(v); + } + + if (fs.existsSync(v)) { + dockerVolumes[`/local/${key}`] = path.resolve(cwd, v); + return `"/local/${key}"`; + } + } + + return `"${v}"`; } })() @@ -139,14 +153,31 @@ export class GeneratorService { ext: ext.split('.').slice(-1).pop() } - return this.cmd(customGenerator, Object.entries(placeholders) - .filter(([, replacement]) => !!replacement) - .reduce((cmd, [search, replacement]) => { - return cmd.split(`#{${search}}`).join(replacement) - }, command)) + return this.cmd( + customGenerator, + Object.entries(placeholders) + .filter(([, replacement]) => !!replacement) + .reduce((cmd, [search, replacement]) => { + return cmd.split(`#{${search}}`).join(replacement) + }, command), + dockerVolumes, + ) } - private cmd = (customGenerator: string | undefined, appendix: string) => { + private cmd = (customGenerator: string | undefined, appendix: string, dockerVolumes = {}) => { + + if (this.configService.useDocker) { + const volumes = Object.entries(dockerVolumes).map(([k, v]) => `-v "${v}:${k}"`).join(' '); + + return [ + `docker run --rm`, + volumes, + this.versionManager.getDockerImageName(), + 'generate', + appendix + ].join(' '); + } + const cliPath = this.versionManager.filePath(); const subCmd = customGenerator ? `-cp "${[cliPath, customGenerator].join(this.isWin() ? ';' : ':')}" org.openapitools.codegen.OpenAPIGenerator` diff --git a/apps/generator-cli/src/app/services/pass-through.service.spec.ts b/apps/generator-cli/src/app/services/pass-through.service.spec.ts index 3c94bbc2197..21057ccb136 100644 --- a/apps/generator-cli/src/app/services/pass-through.service.spec.ts +++ b/apps/generator-cli/src/app/services/pass-through.service.spec.ts @@ -1,10 +1,11 @@ -import { Test } from '@nestjs/testing' +import {Test} from '@nestjs/testing' import * as chalk from 'chalk' -import { Command, createCommand } from 'commander' -import { COMMANDER_PROGRAM, LOGGER } from '../constants' -import { GeneratorService } from './generator.service' -import { PassThroughService } from './pass-through.service' -import { VersionManagerService } from './version-manager.service' +import {Command, createCommand} from 'commander' +import {COMMANDER_PROGRAM, LOGGER} from '../constants' +import {GeneratorService} from './generator.service' +import {PassThroughService} from './pass-through.service' +import {VersionManagerService} from './version-manager.service' +import {ConfigService} from "./config.service"; jest.mock('child_process') // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -19,6 +20,7 @@ describe('PassThroughService', () => { const generate = jest.fn().mockResolvedValue(true) const getSelectedVersion = jest.fn().mockReturnValue('4.2.1') const filePath = jest.fn().mockReturnValue(`/some/path/to/4.2.1.jar`) + const configServiceMock = {useDocker: false, get: jest.fn(), cwd: '/foo/bar'}; const getCommand = (name: string) => program.commands.find(c => c.name() === name); @@ -29,17 +31,20 @@ describe('PassThroughService', () => { const moduleRef = await Test.createTestingModule({ providers: [ PassThroughService, - { provide: VersionManagerService, useValue: { filePath, getSelectedVersion } }, - { provide: GeneratorService, useValue: { generate, enabled: true } }, - { provide: COMMANDER_PROGRAM, useValue: program }, - { provide: LOGGER, useValue: { log } }, + {provide: VersionManagerService, useValue: {filePath, getSelectedVersion, getDockerImageName: (v) => `openapitools/openapi-generator-cli:v${v || getSelectedVersion()}`}}, + {provide: GeneratorService, useValue: {generate, enabled: true}}, + {provide: ConfigService, useValue: configServiceMock}, + {provide: COMMANDER_PROGRAM, useValue: program}, + {provide: LOGGER, useValue: {log}}, ], }).compile() fixture = moduleRef.get(PassThroughService) - childProcess.spawn.mockReset().mockReturnValue({ on: jest.fn() }) - + childProcess.spawn.mockReset().mockReturnValue({on: jest.fn()}) + configServiceMock.get.mockClear() + configServiceMock.get.mockReset() + configServiceMock.useDocker = false; }) describe('API', () => { @@ -147,8 +152,28 @@ describe('PassThroughService', () => { expect(cmd['_allowUnknownOption']).toBeTruthy() }) + describe('useDocker is true', () => { + + beforeEach(() => { + configServiceMock.useDocker = true; + }); + + it('delegates to docker', async () => { + await program.parseAsync([name, ...argv], {from: 'user'}) + expect(childProcess.spawn).toHaveBeenNthCalledWith( + 1, + 'docker run --rm -v "/foo/bar:/local" openapitools/openapi-generator-cli:v4.2.1', + [name, ...argv], + { + stdio: 'inherit', + shell: true + } + ) + }) + }) + it('can delegate', async () => { - await program.parseAsync([name, ...argv], { from: 'user' }) + await program.parseAsync([name, ...argv], {from: 'user'}) expect(childProcess.spawn).toHaveBeenNthCalledWith( 1, 'java -jar "/some/path/to/4.2.1.jar"', @@ -162,7 +187,7 @@ describe('PassThroughService', () => { it('can delegate with JAVA_OPTS', async () => { process.env['JAVA_OPTS'] = 'java-opt-1=1' - await program.parseAsync([name, ...argv], { from: 'user' }) + await program.parseAsync([name, ...argv], {from: 'user'}) expect(childProcess.spawn).toHaveBeenNthCalledWith( 1, 'java java-opt-1=1 -jar "/some/path/to/4.2.1.jar"', @@ -175,7 +200,7 @@ describe('PassThroughService', () => { }) it('can delegate with custom jar', async () => { - await program.parseAsync([name, ...argv, '--custom-generator=../some/custom.jar'], { from: 'user' }) + await program.parseAsync([name, ...argv, '--custom-generator=../some/custom.jar'], {from: 'user'}) const cpDelimiter = process.platform === 'win32' ? ';' : ':' expect(childProcess.spawn).toHaveBeenNthCalledWith( @@ -191,8 +216,8 @@ describe('PassThroughService', () => { if (name === 'generate') { it('can delegate with custom jar to generate command', async () => { - await program.parseAsync([name, ...argv, '--generator-key=genKey', '--custom-generator=../some/custom.jar'], { from: 'user' }) - + await program.parseAsync([name, ...argv, '--generator-key=genKey', '--custom-generator=../some/custom.jar'], {from: 'user'}) + expect(generate).toHaveBeenNthCalledWith( 1, '../some/custom.jar', @@ -201,29 +226,6 @@ describe('PassThroughService', () => { }) } - // if (name === 'help') { - // it('prints the help info and does not delegate, if args length = 0', async () => { - // childProcess.spawn.mockReset() - // cmd.args = [] - // const logSpy = jest.spyOn(console, 'log').mockImplementation(noop) - // await program.parseAsync([name], { from: 'user' }) - // expect(childProcess.spawn).toBeCalledTimes(0) - // expect(program.helpInformation).toBeCalledTimes(1) - // // expect(logSpy).toHaveBeenCalledTimes(2) - // expect(logSpy).toHaveBeenNthCalledWith(1, 'some help text') - // expect(logSpy).toHaveBeenNthCalledWith(2, 'has custom generator') - // }) - // } - // - // if (name === 'generate') { - // it('generates by using the generator config', async () => { - // childProcess.spawn.mockReset() - // await program.parseAsync([name], { from: 'user' }) - // expect(childProcess.spawn).toBeCalledTimes(0) - // expect(generate).toHaveBeenNthCalledWith(1) - // }) - // } - }) describe('command behavior', () => { @@ -239,13 +241,13 @@ describe('PassThroughService', () => { ${'help generate'} | ${commandHelp('generate')} | ${'a'} ${'help author'} | ${commandHelp('author')} | ${'b'} ${'help hidden'} | ${undefined} | ${'c'} - `('$cmd', ({ cmd, helpText, spawn }) => { + `('$cmd', ({cmd, helpText, spawn}) => { let spy: jest.SpyInstance; beforeEach(async () => { spy = jest.spyOn(console, 'log').mockClear().mockImplementation(); - await program.parseAsync(cmd.split(' '), { from: 'user' }) + await program.parseAsync(cmd.split(' '), {from: 'user'}) }) describe('help text', () => { diff --git a/apps/generator-cli/src/app/services/pass-through.service.ts b/apps/generator-cli/src/app/services/pass-through.service.ts index 510ddafd5f5..ac1264c5d58 100644 --- a/apps/generator-cli/src/app/services/pass-through.service.ts +++ b/apps/generator-cli/src/app/services/pass-through.service.ts @@ -1,11 +1,12 @@ -import { Inject, Injectable } from '@nestjs/common' +import {Inject, Injectable} from '@nestjs/common' import * as chalk from 'chalk' -import { exec, spawn } from 'child_process' -import { Command } from 'commander' -import { isString, startsWith, trim } from 'lodash' -import { COMMANDER_PROGRAM, LOGGER } from '../constants' -import { GeneratorService } from './generator.service' -import { VersionManagerService } from './version-manager.service' +import {exec, spawn} from 'child_process' +import {Command} from 'commander' +import {isString, startsWith, trim} from 'lodash' +import {COMMANDER_PROGRAM, LOGGER} from '../constants' +import {GeneratorService} from './generator.service' +import {VersionManagerService} from './version-manager.service' +import {ConfigService} from "./config.service"; @Injectable() export class PassThroughService { @@ -14,6 +15,7 @@ export class PassThroughService { @Inject(LOGGER) private readonly logger: LOGGER, @Inject(COMMANDER_PROGRAM) private readonly program: Command, private readonly versionManager: VersionManagerService, + private readonly configService: ConfigService, private readonly generatorService: GeneratorService ) { } @@ -21,12 +23,12 @@ export class PassThroughService { public async init() { this.program - .allowUnknownOption() - .option("--custom-generator ", "Custom generator jar") + .allowUnknownOption() + .option("--custom-generator ", "Custom generator jar") const commands = (await this.getCommands()).reduce((acc, [name, desc]) => { return acc.set(name, this.program - .command(name, { hidden: !desc }) + .command(name, {hidden: !desc}) .description(desc) .allowUnknownOption() .action((_, c) => this.passThrough(c))) @@ -93,7 +95,7 @@ export class PassThroughService { .filter(line => startsWith(line, ' ')) .map(trim) .map(line => line.match(/^([a-z-]+)\s+(.+)/i).slice(1)) - .reduce((acc, [cmd, desc]) => ({ ...acc, [cmd]: desc }), {}); + .reduce((acc, [cmd, desc]) => ({...acc, [cmd]: desc}), {}); const allCommands = completion.split('\n') .map(trim) @@ -114,6 +116,13 @@ export class PassThroughService { }); private cmd() { + if (this.configService.useDocker) { + return [ + `docker run --rm -v "${this.configService.cwd}:/local"`, + this.versionManager.getDockerImageName(), + ].join(' '); + } + const customGenerator = this.program.opts()?.customGenerator; const cliPath = this.versionManager.filePath(); diff --git a/apps/generator-cli/src/app/services/version-manager.service.ts b/apps/generator-cli/src/app/services/version-manager.service.ts index 3ac377c2fe1..37c48158381 100644 --- a/apps/generator-cli/src/app/services/version-manager.service.ts +++ b/apps/generator-cli/src/app/services/version-manager.service.ts @@ -1,17 +1,18 @@ -import { HttpService, Inject, Injectable } from '@nestjs/common'; -import { catchError, map, switchMap } from 'rxjs/operators'; -import { replace } from 'lodash'; -import { Observable } from 'rxjs'; -import { AxiosError } from 'axios'; +import {HttpService, Inject, Injectable} from '@nestjs/common'; +import {catchError, map, switchMap} from 'rxjs/operators'; +import {replace} from 'lodash'; +import {Observable} from 'rxjs'; +import {AxiosError} from 'axios'; import * as fs from 'fs-extra'; import * as path from 'path'; import * as os from 'os'; import * as Stream from 'stream'; import * as chalk from 'chalk'; import * as compare from 'compare-versions'; -import { LOGGER } from '../constants'; -import { ConfigService } from './config.service'; +import {LOGGER} from '../constants'; +import {ConfigService} from './config.service'; import * as configSchema from '../../config.schema.json'; +import {spawn, spawnSync} from 'child_process'; export interface Version { version: string @@ -51,7 +52,7 @@ export class VersionManagerService { ); return this.httpService.get(queryUrl).pipe( - map(({ data }) => data.response.docs), + map(({data}) => data.response.docs), map(docs => docs.map((doc) => ({ version: doc.v, versionTags: [ @@ -88,6 +89,10 @@ export class VersionManagerService { return this.configService.get('generator-cli.version'); } + getDockerImageName(versionName?: string) { + return `${this.configService.dockerImageName}:v${versionName || this.getSelectedVersion()}`; + } + async setSelectedVersion(versionName: string) { const downloaded = await this.downloadIfNeeded(versionName); if (downloaded) { @@ -97,18 +102,41 @@ export class VersionManagerService { } async remove(versionName: string) { - fs.removeSync(this.filePath(versionName)); + if (this.configService.useDocker) { + await new Promise(resolve => { + spawn('docker', ['rmi', this.getDockerImageName(versionName)], { + stdio: 'inherit', + shell: true + }).on('exit', () => resolve()) + }) + } else { + fs.removeSync(this.filePath(versionName)); + } + this.logger.log(chalk.green(`Removed ${versionName}`)); } async download(versionName: string) { this.logger.log(chalk.yellow(`Download ${versionName} ...`)); + + if (this.configService.useDocker) { + await new Promise(resolve => { + spawn('docker', ['pull', this.getDockerImageName(versionName)], { + stdio: 'inherit', + shell: true + }).on('exit', () => resolve()) + }) + + this.logger.log(chalk.green(`Downloaded ${versionName}`)); + return; + } + const downloadLink = this.createDownloadLink(versionName); const filePath = this.filePath(versionName); try { await this.httpService - .get(downloadLink, { responseType: 'stream' }) + .get(downloadLink, {responseType: 'stream'}) .pipe(switchMap(res => new Promise(resolve => { fs.ensureDirSync(this.storage); const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'generator-cli-')); @@ -142,6 +170,11 @@ export class VersionManagerService { } isDownloaded(versionName: string) { + if (this.configService.useDocker) { + const {status} = spawnSync('docker', ['image', 'inspect', this.getDockerImageName(versionName)]); + return status === 0; + } + return fs.existsSync(path.resolve(this.storage, `${versionName}.jar`)); } @@ -159,7 +192,7 @@ export class VersionManagerService { return this.replacePlaceholders(( this.configService.get('generator-cli.repository.downloadUrl') || configSchema.properties['generator-cli'].properties.repository.downloadUrl.default - ), { versionName }); + ), {versionName}); } private replacePlaceholders(str: string, additionalPlaceholders = {}) { diff --git a/apps/generator-cli/src/config.schema.json b/apps/generator-cli/src/config.schema.json index edbbbaf8757..621a07035b0 100644 --- a/apps/generator-cli/src/config.schema.json +++ b/apps/generator-cli/src/config.schema.json @@ -33,6 +33,14 @@ "default": "https://repo1.maven.org/maven2/${groupId}/${artifactId}/${versionName}/${artifactId}-${versionName}.jar" } }, + "useDocker": { + "type": "boolean", + "default": false + }, + "dockerImageName": { + "type": "string", + "default": "openapitools/openapi-generator-cli" + }, "generators": { "type": "object", "additionalProperties": {