Skip to content

Commit 5325b17

Browse files
committed
Merge branch 'feat/env-variables' of https://github.com/jase88/openapi-generator-cli into jase88-feat/env-variables
2 parents 2cbc67f + 13dc7c0 commit 5325b17

File tree

3 files changed

+180
-28
lines changed

3 files changed

+180
-28
lines changed

apps/generator-cli/src/README.md

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ To make that happen, a version management was added to the package.
3030
The first time you run the command `openapi-generator-cli` the last stable version
3131
of [OpenAPITools/openapi-generator](https://github.com/OpenAPITools/openapi-generator) is downloaded by default.
3232

33-
That version is saved in the file *openapitools.json*. Therefore you should include this file in your version control,
33+
That version is saved in the file *openapitools.json*. Therefore, you should include this file in your version control,
3434
to ensure that the correct version is being used next time you call the command.
3535

3636
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
6666
"name": "my-cool-package",
6767
"version": "0.0.0",
6868
"scripts": {
69-
"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",
69+
"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"
7070
}
7171
}
7272
```
@@ -164,17 +164,18 @@ is automatically used to generate your code. 🎉
164164

165165
##### Available placeholders
166166
167-
| placeholder | description | example |
168-
|--------------|---------------------------------------------------------------|-------------------------------------------------------|
169-
| name | just file name | auth |
170-
| Name | just file name, but starting with a capital letter | Auth |
171-
| cwd | the current cwd | /Users/some-user/projects/some-project |
172-
| base | file name and extension | auth.yaml |
173-
| path | full path and filename | /Users/some-user/projects/some-project/docs/auth.yaml |
174-
| dir | path without the filename | /Users/some-user/projects/some-project/docs |
175-
| relDir | directory name of file relative to the glob provided | docs |
176-
| relPath | file name and extension of file relative to the glob provided | docs/auth.yaml |
177-
| ext | just file extension | yaml |
167+
| placeholder | description | example |
168+
|-------------|---------------------------------------------------------------|-------------------------------------------------------|
169+
| name | just file name | auth |
170+
| Name | just file name, but starting with a capital letter | Auth |
171+
| cwd | the current cwd | /Users/some-user/projects/some-project |
172+
| base | file name and extension | auth.yaml |
173+
| path | full path and filename | /Users/some-user/projects/some-project/docs/auth.yaml |
174+
| dir | path without the filename | /Users/some-user/projects/some-project/docs |
175+
| relDir | directory name of file relative to the glob provided | docs |
176+
| relPath | file name and extension of file relative to the glob provided | docs/auth.yaml |
177+
| ext | just file extension | yaml |
178+
| env.<name> | environment variable (use ${env.name} syntax) | |
178179

179180
### Using custom / private maven registry
180181

@@ -196,6 +197,17 @@ If you're using a private maven registry you can configure the `downloadUrl` and
196197

197198
If the `version` property param is set it is not necessary to configure the `queryUrl`.
198199

200+
`queryUrl` and `downloadUrl` can use the following placeholders:
201+
202+
| placeholder | description |
203+
|-------------|----------------------------------------------------|
204+
| groupId | maven groupId where '.' has been replace with / |
205+
| artifactId | maven artifactId where '.' has been replace with / |
206+
| versionName | maven version (only for downloadUrl) |
207+
| group.id | maven groupId |
208+
| artifact.id | maven artifactId |
209+
| env.<name> | environment variable name |
210+
199211
### Use locally built JAR
200212
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.
201213
E.g.:
@@ -210,7 +222,7 @@ and then:
210222
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
211223
"spaces": 2,
212224
"generator-cli": {
213-
"version": "my-local-snapshot",
225+
"version": "my-local-snapshot"
214226
}
215227
}
216228
```
@@ -232,7 +244,7 @@ Change your `openapitools.json` to:
232244
```
233245

234246
Example is with a snapshot of `7.17.0`, please change the `version` and `downloadUrl` accordingly.
235-
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.
247+
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.
236248

237249
```
238250
[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

apps/generator-cli/src/app/services/config.service.spec.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ describe('ConfigService', () => {
1212
let program: Command;
1313

1414
const log = jest.fn();
15+
const error = jest.fn();
1516

1617
beforeEach(async () => {
1718
program = createCommand();
@@ -20,7 +21,7 @@ describe('ConfigService', () => {
2021
const moduleRef = await Test.createTestingModule({
2122
providers: [
2223
ConfigService,
23-
{ provide: LOGGER, useValue: { log } },
24+
{ provide: LOGGER, useValue: { log, error } },
2425
{ provide: COMMANDER_PROGRAM, useValue: program },
2526
],
2627
}).compile();
@@ -92,6 +93,43 @@ describe('ConfigService', () => {
9293
});
9394
});
9495

96+
describe('the config has values having placeholders', () => {
97+
let originalEnv: NodeJS.ProcessEnv;
98+
99+
beforeEach(() => {
100+
originalEnv = { ...process.env };
101+
102+
fs.readJSONSync.mockReturnValue({
103+
$schema: 'foo.json',
104+
spaces: 4,
105+
'generator-cli': {
106+
version: '1.2.3',
107+
repository: {
108+
queryUrl: 'https://${env.__unit_test_username}:${env.__unit_test_password}@server/api',
109+
downloadUrl: 'https://${env.__unit_test_non_matching}@server/api'
110+
}
111+
},
112+
});
113+
process.env['__unit_test_username'] = 'myusername';
114+
process.env['__unit_test_password'] = 'mypassword';
115+
});
116+
117+
afterEach(() => {
118+
process.env = { ...originalEnv };
119+
});
120+
121+
it('verify placeholder replaced with env vars', () => {
122+
const value = fixture.get('generator-cli.repository.queryUrl');
123+
expect(value).toEqual('https://myusername:mypassword@server/api');
124+
});
125+
126+
it('verify placeholders not matching env vars are not replaced', () => {
127+
const value = fixture.get('generator-cli.repository.downloadUrl');
128+
expect(value).toEqual('https://${env.__unit_test_non_matching}@server/api');
129+
expect(error).toHaveBeenCalledWith('Environment variable for placeholder \'__unit_test_non_matching\' not found.');
130+
});
131+
});
132+
95133
describe('has()', () => {
96134
beforeEach(() => {
97135
fs.readJSONSync.mockReturnValue({
@@ -184,5 +222,60 @@ describe('ConfigService', () => {
184222
});
185223
});
186224
});
225+
226+
describe('replacePlaceholders', () => {
227+
let originalEnv: NodeJS.ProcessEnv;
228+
229+
beforeEach(() => {
230+
jest.clearAllMocks();
231+
originalEnv = { ...process.env };
232+
});
233+
234+
afterEach(() => {
235+
process.env = { ...originalEnv };
236+
});
237+
238+
it('replaces a simple placeholder with an environment variable', () => {
239+
process.env.TEST_VAR = 'value1';
240+
const input = { key: 'Hello ${TEST_VAR}' };
241+
const result = fixture['replacePlaceholders'](input);
242+
expect(result.key).toBe('Hello value1');
243+
});
244+
245+
it('leaves placeholder unchanged and logs error if env var is missing', () => {
246+
delete process.env.MISSING_VAR;
247+
const input = { key: 'Hello ${MISSING_VAR}' };
248+
const result = fixture['replacePlaceholders'](input);
249+
expect(result.key).toBe('Hello ${MISSING_VAR}');
250+
expect(error).toHaveBeenCalledWith(expect.stringContaining('MISSING_VAR'));
251+
});
252+
253+
it('replaces placeholders in nested objects and arrays', () => {
254+
process.env.NESTED_VAR = 'nested';
255+
const input = {
256+
arr: ['${NESTED_VAR}', { inner: '${NESTED_VAR}' }],
257+
obj: { deep: '${NESTED_VAR}' },
258+
};
259+
const result = fixture['replacePlaceholders'](input);
260+
expect(result.arr[0]).toBe('nested');
261+
expect((result.arr[1] as { inner: string }).inner).toBe('nested');
262+
expect((result.obj as { deep: string }).deep).toBe('nested');
263+
});
264+
265+
it('handles env. prefix in placeholders', () => {
266+
process.env.PREFIX_VAR = 'prefix';
267+
const input = { key: 'Value: ${env.PREFIX_VAR}' };
268+
const result = fixture['replacePlaceholders'](input);
269+
expect(result.key).toBe('Value: prefix');
270+
});
271+
272+
it('replaces multiple placeholders in a single string', () => {
273+
process.env.FIRST = 'one';
274+
process.env.SECOND = 'two';
275+
const input = { key: 'Values: ${FIRST}, ${SECOND}' };
276+
const result = fixture['replacePlaceholders'](input);
277+
expect(result.key).toBe('Values: one, two');
278+
});
279+
});
187280
});
188281
});

apps/generator-cli/src/app/services/config.service.ts

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,41 +7,41 @@ import { Command } from 'commander';
77
@Injectable()
88
export class ConfigService {
99

10-
public readonly cwd = process.env.PWD || process.env.INIT_CWD || process.cwd()
10+
public readonly cwd = process.env.PWD || process.env.INIT_CWD || process.cwd();
1111
public readonly configFile = this.configFileOrDefault();
1212

1313
private configFileOrDefault() {
1414
this.program.parseOptions(process.argv);
1515
const conf = this.program.opts().openapitools;
1616

17-
if(!conf) {
17+
if (!conf) {
1818
return path.resolve(this.cwd, 'openapitools.json');
1919
}
2020

2121
return path.isAbsolute(conf) ? conf : path.resolve(this.cwd, conf);
2222
}
2323

24-
public get useDocker() {
24+
public get useDocker() {
2525
return this.get('generator-cli.useDocker', false);
2626
}
2727

28-
public get dockerImageName() {
28+
public get dockerImageName() {
2929
return this.get('generator-cli.dockerImageName', 'openapitools/openapi-generator-cli');
3030
}
3131

3232
private readonly defaultConfig = {
33-
$schema: './node_modules/@openapitools/openapi-generator-cli/config.schema.json',
33+
$schema:
34+
'./node_modules/@openapitools/openapi-generator-cli/config.schema.json',
3435
spaces: 2,
3536
'generator-cli': {
3637
version: undefined,
3738
},
38-
}
39+
};
3940

4041
constructor(
4142
@Inject(LOGGER) private readonly logger: LOGGER,
4243
@Inject(COMMANDER_PROGRAM) private readonly program: Command,
43-
) {
44-
}
44+
) {}
4545

4646
get<T = unknown>(path: string, defaultValue?: T): T {
4747
const getPath = (
@@ -110,9 +110,9 @@ export class ConfigService {
110110

111111
private read() {
112112
const deepMerge = (
113-
target: object,
113+
target: Record<string, unknown>,
114114
source: object,
115-
): object => {
115+
): Record<string, unknown> => {
116116
if (!source || typeof source !== 'object') return target;
117117

118118
const result = { ...target };
@@ -124,7 +124,7 @@ export class ConfigService {
124124
typeof source[key] === 'object' &&
125125
!Array.isArray(source[key])
126126
) {
127-
const value = (result[key] || {});
127+
const value = (result[key] || {}) as Record<string, unknown>;
128128
result[key] = deepMerge(value, source[key]);
129129
} else {
130130
result[key] = source[key];
@@ -137,10 +137,57 @@ export class ConfigService {
137137

138138
fs.ensureFileSync(this.configFile);
139139

140-
return deepMerge(
140+
const config = deepMerge(
141141
this.defaultConfig,
142142
fs.readJSONSync(this.configFile, { throws: false, encoding: 'utf8' }),
143143
);
144+
145+
return this.replacePlaceholders(config);
146+
}
147+
148+
private replacePlaceholders(config: Record<string, unknown>): Record<string, unknown> {
149+
const replacePlaceholderInString = (inputString: string): string => {
150+
return inputString.replace(/\${(.*?)}/g, (fullMatch, placeholderKey) => {
151+
const environmentVariableKey = placeholderKey.startsWith('env.')
152+
? placeholderKey.substring(4)
153+
: placeholderKey;
154+
155+
const environmentVariableValue = process.env[environmentVariableKey];
156+
157+
if (environmentVariableValue === undefined) {
158+
this.logger.error(
159+
`Environment variable for placeholder '${environmentVariableKey}' not found.`,
160+
);
161+
return fullMatch;
162+
}
163+
164+
return environmentVariableValue;
165+
});
166+
};
167+
168+
const traverseConfigurationObject = (
169+
configurationValue: unknown,
170+
): unknown => {
171+
if (typeof configurationValue === 'string') {
172+
return replacePlaceholderInString(configurationValue);
173+
}
174+
if (Array.isArray(configurationValue)) {
175+
return configurationValue.map(traverseConfigurationObject);
176+
}
177+
if (configurationValue && typeof configurationValue === 'object') {
178+
return Object.fromEntries(
179+
Object.entries(configurationValue as Record<string, unknown>).map(
180+
([propertyKey, propertyValue]) => [
181+
propertyKey,
182+
traverseConfigurationObject(propertyValue),
183+
],
184+
),
185+
);
186+
}
187+
return configurationValue;
188+
};
189+
190+
return traverseConfigurationObject(config) as Record<string, unknown>;
144191
}
145192

146193
private write(config) {

0 commit comments

Comments
 (0)