Skip to content

Commit 1cd2614

Browse files
jase88wing328cubic-dev-ai[bot]
authored
feat(config): add support for environment variable placeholders in config (#1031)
* feat(config): add support for environment variable placeholders in config * feat(config): add tests for environment variable placeholder replacement * feat(config): refactor config service to improve environment variable handling * Update apps/generator-cli/src/README.md Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --------- Co-authored-by: William Cheng <wing328hk@gmail.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
1 parent 2cbc67f commit 1cd2614

File tree

3 files changed

+194
-39
lines changed

3 files changed

+194
-39
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 replaced 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: 97 additions & 6 deletions
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({
@@ -176,13 +214,66 @@ describe('ConfigService', () => {
176214
});
177215
describe('--openapitools not set', () => {
178216
it('returns default path, if openapitools argument not provided', () => {
179-
expect(
180-
fixture.configFile.endsWith(
181-
'openapi-generator-cli/openapitools.json'
182-
)
183-
).toBeTruthy();
217+
expect(fixture.configFile).toEqual(
218+
expect.stringMatching(/[/\\]openapitools\.json$/),
219+
);
184220
});
185221
});
186222
});
223+
224+
describe('replacePlaceholders', () => {
225+
let originalEnv: NodeJS.ProcessEnv;
226+
227+
beforeEach(() => {
228+
jest.clearAllMocks();
229+
originalEnv = { ...process.env };
230+
});
231+
232+
afterEach(() => {
233+
process.env = { ...originalEnv };
234+
});
235+
236+
it('replaces a simple placeholder with an environment variable', () => {
237+
process.env.TEST_VAR = 'value1';
238+
const input = { key: 'Hello ${TEST_VAR}' };
239+
const result = fixture['replacePlaceholders'](input);
240+
expect(result.key).toBe('Hello value1');
241+
});
242+
243+
it('leaves placeholder unchanged and logs error if env var is missing', () => {
244+
delete process.env.MISSING_VAR;
245+
const input = { key: 'Hello ${MISSING_VAR}' };
246+
const result = fixture['replacePlaceholders'](input);
247+
expect(result.key).toBe('Hello ${MISSING_VAR}');
248+
expect(error).toHaveBeenCalledWith(expect.stringContaining('MISSING_VAR'));
249+
});
250+
251+
it('replaces placeholders in nested objects and arrays', () => {
252+
process.env.NESTED_VAR = 'nested';
253+
const input = {
254+
arr: ['${NESTED_VAR}', { inner: '${NESTED_VAR}' }],
255+
obj: { deep: '${NESTED_VAR}' },
256+
};
257+
const result = fixture['replacePlaceholders'](input);
258+
expect(result.arr[0]).toBe('nested');
259+
expect((result.arr[1] as { inner: string }).inner).toBe('nested');
260+
expect((result.obj as { deep: string }).deep).toBe('nested');
261+
});
262+
263+
it('handles env. prefix in placeholders', () => {
264+
process.env.PREFIX_VAR = 'prefix';
265+
const input = { key: 'Value: ${env.PREFIX_VAR}' };
266+
const result = fixture['replacePlaceholders'](input);
267+
expect(result.key).toBe('Value: prefix');
268+
});
269+
270+
it('replaces multiple placeholders in a single string', () => {
271+
process.env.FIRST = 'one';
272+
process.env.SECOND = 'two';
273+
const input = { key: 'Values: ${FIRST}, ${SECOND}' };
274+
const result = fixture['replacePlaceholders'](input);
275+
expect(result.key).toBe('Values: one, two');
276+
});
277+
});
187278
});
188279
});

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

Lines changed: 70 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,42 +6,42 @@ import { Command } from 'commander';
66

77
@Injectable()
88
export class ConfigService {
9-
10-
public readonly cwd = process.env.PWD || process.env.INIT_CWD || process.cwd()
9+
public readonly cwd =
10+
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 = (
@@ -59,8 +59,14 @@ export class ConfigService {
5959
return getPath(obj[head], tail);
6060
};
6161

62-
const result = getPath(this.read(), path.split('.')) as T;
63-
return result !== undefined ? result : defaultValue;
62+
const raw = getPath(this.read(), path.split('.')) as Record<
63+
string,
64+
unknown
65+
>;
66+
67+
const resolved = this.replacePlaceholders(raw) as T;
68+
69+
return resolved !== undefined ? resolved : defaultValue;
6470
}
6571

6672
has(path: string) {
@@ -110,9 +116,9 @@ export class ConfigService {
110116

111117
private read() {
112118
const deepMerge = (
113-
target: object,
119+
target: Record<string, unknown>,
114120
source: object,
115-
): object => {
121+
): Record<string, unknown> => {
116122
if (!source || typeof source !== 'object') return target;
117123

118124
const result = { ...target };
@@ -124,7 +130,7 @@ export class ConfigService {
124130
typeof source[key] === 'object' &&
125131
!Array.isArray(source[key])
126132
) {
127-
const value = (result[key] || {});
133+
const value = (result[key] || {}) as Record<string, unknown>;
128134
result[key] = deepMerge(value, source[key]);
129135
} else {
130136
result[key] = source[key];
@@ -137,10 +143,56 @@ export class ConfigService {
137143

138144
fs.ensureFileSync(this.configFile);
139145

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

146198
private write(config) {

0 commit comments

Comments
 (0)