diff --git a/apps/generator-cli/src/README.md b/apps/generator-cli/src/README.md index d085a5ab6..ae2ecafb2 100644 --- a/apps/generator-cli/src/README.md +++ b/apps/generator-cli/src/README.md @@ -30,7 +30,7 @@ To make that happen, a version management was added to the package. The first time you run the command `openapi-generator-cli` the last stable version of [OpenAPITools/openapi-generator](https://github.com/OpenAPITools/openapi-generator) is downloaded by default. -That version is saved in the file *openapitools.json*. Therefore you should include this file in your version control, +That version is saved in the file *openapitools.json*. Therefore, you should include this file in your version control, to ensure that the correct version is being used next time you call the command. If you would like to use a different version of the [OpenAPITools/openapi-generator](https://github.com/OpenAPITools/openapi-generator), @@ -66,7 +66,7 @@ After the installation has finished you can run `npx openapi-generator-cli` or a "name": "my-cool-package", "version": "0.0.0", "scripts": { - "my-awesome-script-name": "openapi-generator-cli generate -i docs/openapi.yaml -g typescript-angular -o generated-sources/openapi --additional-properties=ngVersion=6.1.7,npmName=restClient,supportsES6=true,npmVersion=6.9.0,withInterfaces=true", + "my-awesome-script-name": "openapi-generator-cli generate -i docs/openapi.yaml -g typescript-angular -o generated-sources/openapi --additional-properties=ngVersion=6.1.7,npmName=restClient,supportsES6=true,npmVersion=6.9.0,withInterfaces=true" } } ``` @@ -164,17 +164,18 @@ is automatically used to generate your code. 🎉 ##### Available placeholders -| placeholder | description | example | -|--------------|---------------------------------------------------------------|-------------------------------------------------------| -| name | just file name | auth | -| Name | just file name, but starting with a capital letter | Auth | -| cwd | the current cwd | /Users/some-user/projects/some-project | -| base | file name and extension | auth.yaml | -| path | full path and filename | /Users/some-user/projects/some-project/docs/auth.yaml | -| dir | path without the filename | /Users/some-user/projects/some-project/docs | -| relDir | directory name of file relative to the glob provided | docs | -| relPath | file name and extension of file relative to the glob provided | docs/auth.yaml | -| ext | just file extension | yaml | +| placeholder | description | example | +|-------------|---------------------------------------------------------------|-------------------------------------------------------| +| name | just file name | auth | +| Name | just file name, but starting with a capital letter | Auth | +| cwd | the current cwd | /Users/some-user/projects/some-project | +| base | file name and extension | auth.yaml | +| path | full path and filename | /Users/some-user/projects/some-project/docs/auth.yaml | +| dir | path without the filename | /Users/some-user/projects/some-project/docs | +| relDir | directory name of file relative to the glob provided | docs | +| relPath | file name and extension of file relative to the glob provided | docs/auth.yaml | +| ext | just file extension | yaml | +| env. | environment variable (use ${env.name} syntax) | | ### Using custom / private maven registry @@ -196,6 +197,17 @@ If you're using a private maven registry you can configure the `downloadUrl` and If the `version` property param is set it is not necessary to configure the `queryUrl`. +`queryUrl` and `downloadUrl` can use the following placeholders: + +| placeholder | description | +|-------------|----------------------------------------------------| +| groupId | maven groupId where '.' has been replace with / | +| artifactId | maven artifactId where '.' has been replace with / | +| versionName | maven version (only for downloadUrl) | +| group.id | maven groupId | +| artifact.id | maven artifactId | +| env. | environment variable name | + ### Use locally built JAR In order to use a locally built jar of the generator CLI, you can copy the jar from your local build (i.e. if you were to `build` the [OpenAPITools/openapi-generator](https://github.com/OpenAPITools/openapi-generator) repository it would be in `~/openapi-generator/modules/openapi-generator-cli/target/openapi-generator-cli.jar`) into `./node_modules/@openapitools/openapi-generator-cli/versions/` and change the `version` in the `openapitools.json` file to the base name of the jar file. E.g.: @@ -210,7 +222,7 @@ and then: "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", "spaces": 2, "generator-cli": { - "version": "my-local-snapshot", + "version": "my-local-snapshot" } } ``` @@ -232,7 +244,7 @@ Change your `openapitools.json` to: ``` Example is with a snapshot of `7.17.0`, please change the `version` and `downloadUrl` accordingly. -You can find the published snapshots in the build log of the [Publish to Maven Central Github workflow](https://github.com/OpenAPITools/openapi-generator/actions/workflows/maven-release.yml) in OpenAPI Generator repo, e.g. +You can find the published snapshots in the build log of the [Publish to Maven Central GitHub workflow](https://github.com/OpenAPITools/openapi-generator/actions/workflows/maven-release.yml) in OpenAPI Generator repo, e.g. ``` [INFO] Uploading to central: https://central.sonatype.com/repository/maven-snapshots/org/openapitools/openapi-generator-cli/7.17.0-SNAPSHOT/openapi-generator-cli-7.17.0-20251003.020930-8.jar diff --git a/apps/generator-cli/src/app/services/config.service.spec.ts b/apps/generator-cli/src/app/services/config.service.spec.ts index 92c81446c..215bcb9b6 100644 --- a/apps/generator-cli/src/app/services/config.service.spec.ts +++ b/apps/generator-cli/src/app/services/config.service.spec.ts @@ -12,6 +12,7 @@ describe('ConfigService', () => { let program: Command; const log = jest.fn(); + const error = jest.fn(); beforeEach(async () => { program = createCommand(); @@ -20,7 +21,7 @@ describe('ConfigService', () => { const moduleRef = await Test.createTestingModule({ providers: [ ConfigService, - { provide: LOGGER, useValue: { log } }, + { provide: LOGGER, useValue: { log, error } }, { provide: COMMANDER_PROGRAM, useValue: program }, ], }).compile(); @@ -92,6 +93,43 @@ describe('ConfigService', () => { }); }); + describe('the config has values having placeholders', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + + fs.readJSONSync.mockReturnValue({ + $schema: 'foo.json', + spaces: 4, + 'generator-cli': { + version: '1.2.3', + repository: { + queryUrl: 'https://${env.__unit_test_username}:${env.__unit_test_password}@server/api', + downloadUrl: 'https://${env.__unit_test_non_matching}@server/api' + } + }, + }); + process.env['__unit_test_username'] = 'myusername'; + process.env['__unit_test_password'] = 'mypassword'; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('verify placeholder replaced with env vars', () => { + const value = fixture.get('generator-cli.repository.queryUrl'); + expect(value).toEqual('https://myusername:mypassword@server/api'); + }); + + it('verify placeholders not matching env vars are not replaced', () => { + const value = fixture.get('generator-cli.repository.downloadUrl'); + expect(value).toEqual('https://${env.__unit_test_non_matching}@server/api'); + expect(error).toHaveBeenCalledWith('Environment variable for placeholder \'__unit_test_non_matching\' not found.'); + }); + }); + describe('has()', () => { beforeEach(() => { fs.readJSONSync.mockReturnValue({ @@ -184,5 +222,60 @@ describe('ConfigService', () => { }); }); }); + + describe('replacePlaceholders', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + jest.clearAllMocks(); + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('replaces a simple placeholder with an environment variable', () => { + process.env.TEST_VAR = 'value1'; + const input = { key: 'Hello ${TEST_VAR}' }; + const result = fixture['replacePlaceholders'](input); + expect(result.key).toBe('Hello value1'); + }); + + it('leaves placeholder unchanged and logs error if env var is missing', () => { + delete process.env.MISSING_VAR; + const input = { key: 'Hello ${MISSING_VAR}' }; + const result = fixture['replacePlaceholders'](input); + expect(result.key).toBe('Hello ${MISSING_VAR}'); + expect(error).toHaveBeenCalledWith(expect.stringContaining('MISSING_VAR')); + }); + + it('replaces placeholders in nested objects and arrays', () => { + process.env.NESTED_VAR = 'nested'; + const input = { + arr: ['${NESTED_VAR}', { inner: '${NESTED_VAR}' }], + obj: { deep: '${NESTED_VAR}' }, + }; + const result = fixture['replacePlaceholders'](input); + expect(result.arr[0]).toBe('nested'); + expect((result.arr[1] as { inner: string }).inner).toBe('nested'); + expect((result.obj as { deep: string }).deep).toBe('nested'); + }); + + it('handles env. prefix in placeholders', () => { + process.env.PREFIX_VAR = 'prefix'; + const input = { key: 'Value: ${env.PREFIX_VAR}' }; + const result = fixture['replacePlaceholders'](input); + expect(result.key).toBe('Value: prefix'); + }); + + it('replaces multiple placeholders in a single string', () => { + process.env.FIRST = 'one'; + process.env.SECOND = 'two'; + const input = { key: 'Values: ${FIRST}, ${SECOND}' }; + const result = fixture['replacePlaceholders'](input); + expect(result.key).toBe('Values: one, two'); + }); + }); }); }); diff --git a/apps/generator-cli/src/app/services/config.service.ts b/apps/generator-cli/src/app/services/config.service.ts index 5a0cc2dad..205dc52fe 100644 --- a/apps/generator-cli/src/app/services/config.service.ts +++ b/apps/generator-cli/src/app/services/config.service.ts @@ -7,41 +7,41 @@ import { Command } from 'commander'; @Injectable() export class ConfigService { - public readonly cwd = process.env.PWD || process.env.INIT_CWD || process.cwd() + public readonly cwd = process.env.PWD || process.env.INIT_CWD || process.cwd(); public readonly configFile = this.configFileOrDefault(); private configFileOrDefault() { this.program.parseOptions(process.argv); const conf = this.program.opts().openapitools; - if(!conf) { + if (!conf) { return path.resolve(this.cwd, 'openapitools.json'); } return path.isAbsolute(conf) ? conf : path.resolve(this.cwd, conf); } - public get useDocker() { + public get useDocker() { return this.get('generator-cli.useDocker', false); } - public get dockerImageName() { + 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', + $schema: + './node_modules/@openapitools/openapi-generator-cli/config.schema.json', spaces: 2, 'generator-cli': { version: undefined, }, - } + }; constructor( @Inject(LOGGER) private readonly logger: LOGGER, @Inject(COMMANDER_PROGRAM) private readonly program: Command, - ) { - } + ) {} get(path: string, defaultValue?: T): T { const getPath = ( @@ -110,9 +110,9 @@ export class ConfigService { private read() { const deepMerge = ( - target: object, + target: Record, source: object, - ): object => { + ): Record => { if (!source || typeof source !== 'object') return target; const result = { ...target }; @@ -124,7 +124,7 @@ export class ConfigService { typeof source[key] === 'object' && !Array.isArray(source[key]) ) { - const value = (result[key] || {}); + const value = (result[key] || {}) as Record; result[key] = deepMerge(value, source[key]); } else { result[key] = source[key]; @@ -137,10 +137,57 @@ export class ConfigService { fs.ensureFileSync(this.configFile); - return deepMerge( + const config = deepMerge( this.defaultConfig, fs.readJSONSync(this.configFile, { throws: false, encoding: 'utf8' }), ); + + return this.replacePlaceholders(config); + } + + private replacePlaceholders(config: Record): Record { + const replacePlaceholderInString = (inputString: string): string => { + return inputString.replace(/\${(.*?)}/g, (fullMatch, placeholderKey) => { + const environmentVariableKey = placeholderKey.startsWith('env.') + ? placeholderKey.substring(4) + : placeholderKey; + + const environmentVariableValue = process.env[environmentVariableKey]; + + if (environmentVariableValue === undefined) { + this.logger.error( + `Environment variable for placeholder '${environmentVariableKey}' not found.`, + ); + return fullMatch; + } + + return environmentVariableValue; + }); + }; + + const traverseConfigurationObject = ( + configurationValue: unknown, + ): unknown => { + if (typeof configurationValue === 'string') { + return replacePlaceholderInString(configurationValue); + } + if (Array.isArray(configurationValue)) { + return configurationValue.map(traverseConfigurationObject); + } + if (configurationValue && typeof configurationValue === 'object') { + return Object.fromEntries( + Object.entries(configurationValue as Record).map( + ([propertyKey, propertyValue]) => [ + propertyKey, + traverseConfigurationObject(propertyValue), + ], + ), + ); + } + return configurationValue; + }; + + return traverseConfigurationObject(config) as Record; } private write(config) {