Fix dangerous destructuration in typescript-nestjs services (#20157)

* refactor: remove requestParameters destructuration

* feat: add reserved param names sample

* feat: quote params

* feat: improve with reservedWords

* feat: use vendorExtensions instead of extending CodegenParameter
This commit is contained in:
Gregory Merlet 2024-12-03 11:38:43 +01:00 committed by GitHub
parent 26609e9ad3
commit cf78f1028d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 659 additions and 5 deletions

View File

@ -0,0 +1,6 @@
generatorName: typescript-nestjs
outputDir: samples/client/petstore/typescript-nestjs/builds/reservedParamNames
inputSpec: modules/openapi-generator/src/test/resources/3_0/typescript-nestjs/reserved-param-names.yaml
templateDir: modules/openapi-generator/src/main/resources/typescript-nestjs
additionalProperties:
"useSingleRequestParameter" : true

View File

@ -116,9 +116,11 @@ These options may be applied as additional-properties (cli) or configOptions (pl
<li>float</li>
<li>for</li>
<li>formParams</li>
<li>from</li>
<li>function</li>
<li>goto</li>
<li>headerParams</li>
<li>headers</li>
<li>if</li>
<li>implements</li>
<li>import</li>

View File

@ -88,6 +88,8 @@ public class TypeScriptNestjsClientCodegen extends AbstractTypeScriptClientCodeg
apiPackage = "api";
modelPackage = "model";
reservedWords.addAll(Arrays.asList("from", "headers"));
this.cliOptions.add(new CliOption(NPM_REPOSITORY,
"Use this property to set an url your private npmRepo in the package.json"));
this.cliOptions.add(CliOption.newBoolean(WITH_INTERFACES,
@ -327,6 +329,10 @@ public class TypeScriptNestjsClientCodegen extends AbstractTypeScriptClientCodeg
// Overwrite path to TypeScript template string, after applying everything we just did.
op.path = pathBuffer.toString();
for (CodegenParameter param : op.allParams) {
param.vendorExtensions.putIfAbsent("x-param-has-sanitized-name", !param.baseName.equals(param.paramName));
}
}
operations.put("hasSomeFormParams", hasSomeFormParams);

View File

@ -35,7 +35,7 @@ export interface {{classname}}{{operationIdCamelCase}}Request {
* @type {{=<% %>=}}{<%&dataType%>}<%={{ }}=%>
* @memberof {{classname}}{{operationIdCamelCase}}
*/
readonly {{paramName}}{{^required}}?{{/required}}: {{{dataType}}}
readonly {{#vendorExtensions.x-param-has-sanitized-name}}'{{{baseName}}}'{{/vendorExtensions.x-param-has-sanitized-name}}{{^vendorExtensions.x-param-has-sanitized-name}}{{{paramName}}}{{/vendorExtensions.x-param-has-sanitized-name}}{{^required}}?{{/required}}: {{{dataType}}}
{{^-last}}
{{/-last}}
@ -106,7 +106,7 @@ export class {{classname}} {
{{#useSingleRequestParameter}}
const {
{{#allParams}}
{{paramName}},
{{#vendorExtensions.x-param-has-sanitized-name}}'{{{baseName}}}': {{/vendorExtensions.x-param-has-sanitized-name}}{{paramName}},
{{/allParams}}
} = requestParameters;

View File

@ -5,6 +5,6 @@ export interface {{classname}}{{#allParents}}{{#-first}} extends {{/-first}}{{{.
* {{{.}}}
*/
{{/description}}
{{#isReadOnly}}readonly {{/isReadOnly}}{{{name}}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{#isNullable}} | null{{/isNullable}};
{{#isReadOnly}}readonly {{/isReadOnly}}{{#hasSanitizedName}}'{{{baseName}}}'{{/hasSanitizedName}}{{^hasSanitizedName}}{{{name}}}{{/hasSanitizedName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{#isNullable}} | null{{/isNullable}};
{{/vars}}
}{{>modelGenericEnums}}

View File

@ -10,7 +10,7 @@ export interface {{classname}} { {{>modelGenericAdditionalProperties}}
* {{{.}}}
*/
{{/description}}
{{name}}{{^required}}?{{/required}}: {{#discriminatorValue}}'{{.}}'{{/discriminatorValue}}{{^discriminatorValue}}{{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{/discriminatorValue}}{{#isNullable}} | null{{/isNullable}};
{{#hasSanitizedName}}'{{{baseName}}}'{{/hasSanitizedName}}{{^hasSanitizedName}}{{{name}}}{{/hasSanitizedName}}{{^required}}?{{/required}}: {{#discriminatorValue}}'{{.}}'{{/discriminatorValue}}{{^discriminatorValue}}{{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{/discriminatorValue}}{{#isNullable}} | null{{/isNullable}};
{{/allVars}}
}
{{>modelGenericEnums}}
@ -18,4 +18,4 @@ export interface {{classname}} { {{>modelGenericAdditionalProperties}}
{{^parent}}
{{>modelGeneric}}
{{/parent}}
{{/discriminator}}
{{/discriminator}}

View File

@ -0,0 +1,43 @@
openapi: 3.0.0
info:
description: Test reserved param names
version: 1.0.0
title: Reserved param names
paths:
/test:
post:
security:
- bearerAuth: []
summary: Test reserved param names
description: ''
operationId: testReservedParamNames
parameters:
- name: notReserved
in: query
description: Should not be treated as a reserved param name
required: true
schema:
type: string
- name: from
in: query
description: Might conflict with rxjs import
required: true
schema:
type: string
- name: headers
in: header
description: Might conflict with headers const
required: true
schema:
type: string
responses:
'200':
description: successful operation
'405':
description: Invalid input
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT

View File

@ -0,0 +1,4 @@
wwwroot/*.js
node_modules
typings
dist

View File

@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View File

@ -0,0 +1,10 @@
.gitignore
README.md
api.module.ts
api/api.ts
api/default.service.ts
configuration.ts
git_push.sh
index.ts
model/models.ts
variables.ts

View File

@ -0,0 +1,162 @@
## @
### Building
To install the required dependencies and to build the typescript sources run:
```
npm install
npm run build
```
#### General usage
In your Nestjs project:
```
// without configuring providers
import { ApiModule } from '';
import { HttpModule } from '@nestjs/axios';
@Module({
imports: [
ApiModule,
HttpModule
],
providers: []
})
export class AppModule {}
```
```
// configuring providers
import { ApiModule, Configuration, ConfigurationParameters } from '';
export function apiConfigFactory (): Configuration => {
const params: ConfigurationParameters = {
// set configuration parameters here.
}
return new Configuration(params);
}
@Module({
imports: [ ApiModule.forRoot(apiConfigFactory) ],
declarations: [ AppComponent ],
providers: [],
bootstrap: [ AppComponent ]
})
export class AppModule {}
```
```
import { DefaultApi } from '';
export class AppComponent {
constructor(private apiGateway: DefaultApi) { }
}
```
Note: The ApiModule a dynamic module and instantiated once app wide.
This is to ensure that all services are treated as singletons.
#### Using multiple swagger files / APIs / ApiModules
In order to use multiple `ApiModules` generated from different swagger files,
you can create an alias name when importing the modules
in order to avoid naming conflicts:
```
import { ApiModule } from 'my-api-path';
import { ApiModule as OtherApiModule } from 'my-other-api-path';
import { HttpModule } from '@nestjs/axios';
@Module({
imports: [
ApiModule,
OtherApiModule,
HttpModule
]
})
export class AppModule {
}
```
### Set service base path
If different than the generated base path, during app bootstrap, you can provide the base path to your service.
```
import { BASE_PATH } from '';
bootstrap(AppComponent, [
{ provide: BASE_PATH, useValue: 'https://your-web-service.com' },
]);
```
or
```
import { BASE_PATH } from '';
@Module({
imports: [],
declarations: [ AppComponent ],
providers: [ provide: BASE_PATH, useValue: 'https://your-web-service.com' ],
bootstrap: [ AppComponent ]
})
export class AppModule {}
```
### Configuring the module with `forRootAsync`
You can also use the Nestjs Config Module/Service to configure your app with `forRootAsync`.
```
@Module({
imports: [
ApiModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService): Configuration => {
const params: ConfigurationParameters = {
// set configuration parameters here.
basePath: config.get('API_URL'),
};
return new Configuration(params);
},
})
],
declarations: [ AppComponent ],
providers: [],
bootstrap: [ AppComponent ]
})
export class AppModule {}
```
#### Using @nestjs/cli
First extend your `src/environments/*.ts` files by adding the corresponding base path:
```
export const environment = {
production: false,
API_BASE_PATH: 'http://127.0.0.1:8080'
};
```
In the src/app/app.module.ts:
```
import { BASE_PATH } from '';
import { environment } from '../environments/environment';
@Module({
declarations: [
AppComponent
],
imports: [ ],
providers: [
{
provide: 'BASE_PATH',
useValue: environment.API_BASE_PATH
}
]
})
export class AppModule { }
```

View File

@ -0,0 +1,70 @@
import { DynamicModule, Module, Global, Provider } from '@nestjs/common';
import { HttpModule, HttpService } from '@nestjs/axios';
import { AsyncConfiguration, Configuration, ConfigurationFactory } from './configuration';
import { DefaultService } from './api/default.service';
@Global()
@Module({
imports: [ HttpModule ],
exports: [
DefaultService
],
providers: [
DefaultService
]
})
export class ApiModule {
public static forRoot(configurationFactory: () => Configuration): DynamicModule {
return {
module: ApiModule,
providers: [ { provide: Configuration, useFactory: configurationFactory } ]
};
}
/**
* Register the module asynchronously.
*/
static forRootAsync(options: AsyncConfiguration): DynamicModule {
const providers = [...this.createAsyncProviders(options)];
return {
module: ApiModule,
imports: options.imports || [],
providers,
exports: providers,
};
}
private static createAsyncProviders(options: AsyncConfiguration): Provider[] {
if (options.useClass) {
return [
this.createAsyncConfigurationProvider(options),
{
provide: options.useClass,
useClass: options.useClass,
},
];
}
return [this.createAsyncConfigurationProvider(options)];
}
private static createAsyncConfigurationProvider(
options: AsyncConfiguration,
): Provider {
if (options.useFactory) {
return {
provide: Configuration,
useFactory: options.useFactory,
inject: options.inject || [],
};
}
return {
provide: Configuration,
useFactory: async (optionsFactory: ConfigurationFactory) =>
await optionsFactory.createConfiguration(),
inject: (options.useExisting && [options.useExisting]) || (options.useClass && [options.useClass]) || [],
};
}
constructor( httpService: HttpService) { }
}

View File

@ -0,0 +1,3 @@
export * from './default.service';
import { DefaultService } from './default.service';
export const APIS = [DefaultService];

View File

@ -0,0 +1,147 @@
/**
* Reserved param names
* Test reserved param names
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/* tslint:disable:no-unused-variable member-ordering */
import { Injectable, Optional } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { AxiosResponse } from 'axios';
import { Observable, from, of, switchMap } from 'rxjs';
import { Configuration } from '../configuration';
import { COLLECTION_FORMATS } from '../variables';
/**
* Request parameters for testReservedParamNames operation in DefaultService.
* @export
* @interface DefaultServiceTestReservedParamNamesRequest
*/
export interface DefaultServiceTestReservedParamNamesRequest {
/**
* Should not be treated as a reserved param name
* @type {string}
* @memberof DefaultServiceTestReservedParamNames
*/
readonly notReserved: string
/**
* Might conflict with rxjs import
* @type {string}
* @memberof DefaultServiceTestReservedParamNames
*/
readonly 'from': string
/**
* Might conflict with headers const
* @type {string}
* @memberof DefaultServiceTestReservedParamNames
*/
readonly 'headers': string
}
@Injectable()
export class DefaultService {
protected basePath = 'http://localhost';
public defaultHeaders: Record<string,string> = {};
public configuration = new Configuration();
protected httpClient: HttpService;
constructor(httpClient: HttpService, @Optional() configuration: Configuration) {
this.configuration = configuration || this.configuration;
this.basePath = configuration?.basePath || this.basePath;
this.httpClient = configuration?.httpClient || httpClient;
}
/**
* @param consumes string[] mime-types
* @return true: consumes contains 'multipart/form-data', false: otherwise
*/
private canConsumeForm(consumes: string[]): boolean {
const form = 'multipart/form-data';
return consumes.includes(form);
}
/**
* Test reserved param names
*
* @param {DefaultServiceTestReservedParamNamesRequest} requestParameters Request parameters.
*/
public testReservedParamNames(requestParameters: DefaultServiceTestReservedParamNamesRequest, ): Observable<AxiosResponse<any>>;
public testReservedParamNames(requestParameters: DefaultServiceTestReservedParamNamesRequest, ): Observable<any> {
const {
notReserved,
'from': _from,
'headers': _headers,
} = requestParameters;
if (notReserved === null || notReserved === undefined) {
throw new Error('Required parameter notReserved was null or undefined when calling testReservedParamNames.');
}
if (_from === null || _from === undefined) {
throw new Error('Required parameter _from was null or undefined when calling testReservedParamNames.');
}
if (_headers === null || _headers === undefined) {
throw new Error('Required parameter _headers was null or undefined when calling testReservedParamNames.');
}
let queryParameters = new URLSearchParams();
if (notReserved !== undefined && notReserved !== null) {
queryParameters.append('notReserved', <any>notReserved);
}
if (_from !== undefined && _from !== null) {
queryParameters.append('from', <any>_from);
}
let headers = {...this.defaultHeaders};
if (_headers !== undefined && _headers !== null) {
headers['headers'] = String(_headers);
}
let accessTokenObservable: Observable<any> = of(null);
// authentication (bearerAuth) required
if (typeof this.configuration.accessToken === 'function') {
accessTokenObservable = from(Promise.resolve(this.configuration.accessToken()));
} else if (this.configuration.accessToken) {
accessTokenObservable = from(Promise.resolve(this.configuration.accessToken));
}
// to determine the Accept header
let httpHeaderAccepts: string[] = [
];
const httpHeaderAcceptSelected: string | undefined = this.configuration.selectHeaderAccept(httpHeaderAccepts);
if (httpHeaderAcceptSelected != undefined) {
headers['Accept'] = httpHeaderAcceptSelected;
}
// to determine the Content-Type header
const consumes: string[] = [
];
return accessTokenObservable.pipe(
switchMap((accessToken) => {
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
return this.httpClient.post<any>(`${this.basePath}/test`,
null,
{
params: queryParameters,
withCredentials: this.configuration.withCredentials,
headers: headers
}
);
})
);
}
}

View File

@ -0,0 +1,109 @@
import type { HttpService } from '@nestjs/axios';
import { ModuleMetadata, Type } from '@nestjs/common/interfaces';
export interface ConfigurationParameters {
apiKeys?: {[ key: string ]: string};
username?: string;
password?: string;
accessToken?: string | Promise<string> | (() => string | Promise<string>);
basePath?: string;
withCredentials?: boolean;
httpClient?: HttpService;
}
export class Configuration {
apiKeys?: {[ key: string ]: string};
username?: string;
password?: string;
accessToken?: string | Promise<string> | (() => string | Promise<string>);
basePath?: string;
withCredentials?: boolean;
httpClient?: HttpService;
constructor(configurationParameters: ConfigurationParameters = {}) {
this.apiKeys = configurationParameters.apiKeys;
this.username = configurationParameters.username;
this.password = configurationParameters.password;
this.accessToken = configurationParameters.accessToken;
this.basePath = configurationParameters.basePath;
this.withCredentials = configurationParameters.withCredentials;
this.httpClient = configurationParameters.httpClient;
}
/**
* Select the correct content-type to use for a request.
* Uses {@link Configuration#isJsonMime} to determine the correct content-type.
* If no content type is found return the first found type if the contentTypes is not empty
* @param contentTypes - the array of content types that are available for selection
* @returns the selected content-type or <code>undefined</code> if no selection could be made.
*/
public selectHeaderContentType (contentTypes: string[]): string | undefined {
if (contentTypes.length == 0) {
return undefined;
}
let type = contentTypes.find(x => this.isJsonMime(x));
if (type === undefined) {
return contentTypes[0];
}
return type;
}
/**
* Select the correct accept content-type to use for a request.
* Uses {@link Configuration#isJsonMime} to determine the correct accept content-type.
* If no content type is found return the first found type if the contentTypes is not empty
* @param accepts - the array of content types that are available for selection.
* @returns the selected content-type or <code>undefined</code> if no selection could be made.
*/
public selectHeaderAccept(accepts: string[]): string | undefined {
if (accepts.length == 0) {
return undefined;
}
let type = accepts.find(x => this.isJsonMime(x));
if (type === undefined) {
return accepts[0];
}
return type;
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
public isJsonMime(mime: string): boolean {
const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i');
return mime != null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json');
}
}
export interface ConfigurationFactory {
createConfiguration(): Promise<Configuration> | Configuration;
}
export interface AsyncConfiguration extends Pick<ModuleMetadata, 'imports'> {
/**
* The `useExisting` syntax allows you to create aliases for existing providers.
*/
useExisting?: Type<ConfigurationFactory>;
/**
* The `useClass` syntax allows you to dynamically determine a class
* that a token should resolve to.
*/
useClass?: Type<ConfigurationFactory>;
/**
* The `useFactory` syntax allows for creating providers dynamically.
*/
useFactory?: (...args: any[]) => Promise<Configuration> | Configuration;
/**
* Optional list of providers to be injected into the context of the Factory function.
*/
inject?: any[];
}

View File

@ -0,0 +1,57 @@
#!/bin/sh
# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
#
# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com"
git_user_id=$1
git_repo_id=$2
release_note=$3
git_host=$4
if [ "$git_host" = "" ]; then
git_host="github.com"
echo "[INFO] No command line input provided. Set \$git_host to $git_host"
fi
if [ "$git_user_id" = "" ]; then
git_user_id="GIT_USER_ID"
echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
fi
if [ "$git_repo_id" = "" ]; then
git_repo_id="GIT_REPO_ID"
echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
fi
if [ "$release_note" = "" ]; then
release_note="Minor update"
echo "[INFO] No command line input provided. Set \$release_note to $release_note"
fi
# Initialize the local directory as a Git repository
git init
# Adds the files in the local repository and stages them for commit.
git add .
# Commits the tracked changes and prepares them to be pushed to a remote repository.
git commit -m "$release_note"
# Sets the new remote
git_remote=$(git remote)
if [ "$git_remote" = "" ]; then # git remote not defined
if [ "$GIT_TOKEN" = "" ]; then
echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git
else
git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git
fi
fi
git pull origin master
# Pushes (Forces) the changes in the local repository up to the remote repository
echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git"
git push origin master 2>&1 | grep -v 'To https'

View File

@ -0,0 +1,4 @@
export * from './api/api';
export * from './variables';
export * from './configuration';
export * from './api.module';

View File

@ -0,0 +1,7 @@
export const COLLECTION_FORMATS = {
'csv': ',',
'tsv': ' ',
'ssv': ' ',
'pipes': '|'
}