diff --git a/generator/konfig-next-app/package.json b/generator/konfig-next-app/package.json index 316f01cac..ebb68b123 100644 --- a/generator/konfig-next-app/package.json +++ b/generator/konfig-next-app/package.json @@ -41,6 +41,7 @@ "axios": "^1.4.0", "camelcase": "^8.0.0", "chroma-js": "^2.4.2", + "clone": "^2.1.2", "dayjs": "^1.11.8", "deepmerge": "^4.3.1", "eslint": "8.41.0", @@ -79,6 +80,7 @@ }, "devDependencies": { "@types/chroma-js": "^2.4.0", + "@types/clone": "^2.1.3", "@types/har-format": "^1.2.14", "@types/jest": "^29.5.5", "@types/js-yaml": "^4.0.5", diff --git a/generator/konfig-next-app/src/components/CopyButton.tsx b/generator/konfig-next-app/src/components/CopyButton.tsx index 3a8d0c01a..46be84203 100644 --- a/generator/konfig-next-app/src/components/CopyButton.tsx +++ b/generator/konfig-next-app/src/components/CopyButton.tsx @@ -30,7 +30,7 @@ export function CopyButton({ value }: { value: string }) { { ['bg-white/5 hover:bg-white/7.5 dark:bg-white/2.5 shadow dark:shadow-none dark:hover:bg-white/5']: !copied, - ['dark:bg-brand-400/10 bg-brand-600/20 ring-1 ring-inset dark:ring-brand-400/20 ring-brand-600/40']: + ['bg-brand-600/20 ring-1 ring-inset dark:ring-brand-400/20 ring-brand-600/40']: copied, } )} diff --git a/generator/konfig-next-app/src/components/OperationFormGeneratedCode.tsx b/generator/konfig-next-app/src/components/OperationFormGeneratedCode.tsx index ebeb5a5af..689e00779 100644 --- a/generator/konfig-next-app/src/components/OperationFormGeneratedCode.tsx +++ b/generator/konfig-next-app/src/components/OperationFormGeneratedCode.tsx @@ -11,23 +11,41 @@ import { CopyButton } from './CopyButton' export function OperationFormGeneratedCode( args: CodeGeneratorConstructorArgs & { language: Language } ) { - const [data, setData] = useState('Loading...') // Initial state + const [data, setData] = useState('Loading...') + const [copyData, setCopyData] = useState('') + + console.log(args.formData) useEffect(() => { if (args.language === 'typescript') { new CodeGeneratorTypeScript(args).snippet().then((result) => { setData(result) }) + new CodeGeneratorTypeScript({ ...args, mode: 'copy' }) + .snippet() + .then((result) => { + setCopyData(result) + }) } else if (args.language === 'python') { new CodeGeneratorPython(args).snippet().then((result) => { setData(result) }) + new CodeGeneratorPython({ ...args, mode: 'copy' }) + .snippet() + .then((result) => { + setCopyData(result) + }) } else if (args.language === 'bash') { new CodeGeneratorHttpsnippet({ ...args, targetId: 'shell' }) .snippet() .then((result) => { setData(result) }) + new CodeGeneratorHttpsnippet({ ...args, targetId: 'shell', mode: 'copy' }) + .snippet() + .then((result) => { + setCopyData(result) + }) } else { throw Error(`Unxpected language: "${args.language}"`) } @@ -50,7 +68,7 @@ export function OperationFormGeneratedCode( {data} - + ) } diff --git a/generator/konfig-next-app/src/components/OperationReferenceMain.tsx b/generator/konfig-next-app/src/components/OperationReferenceMain.tsx index 225de9bb8..129c76c2f 100644 --- a/generator/konfig-next-app/src/components/OperationReferenceMain.tsx +++ b/generator/konfig-next-app/src/components/OperationReferenceMain.tsx @@ -220,8 +220,6 @@ export function OperationReferenceMain({ } } - const [language, setLanguage] = useState('typescript') - const header = operation.operation.summary ?? operation.path return ( diff --git a/generator/konfig-next-app/src/utils/code-generator-httpsnippet.ts b/generator/konfig-next-app/src/utils/code-generator-httpsnippet.ts index 25e305cc7..0b726377c 100644 --- a/generator/konfig-next-app/src/utils/code-generator-httpsnippet.ts +++ b/generator/konfig-next-app/src/utils/code-generator-httpsnippet.ts @@ -15,10 +15,14 @@ export class CodeGeneratorHttpsnippet extends CodeGenerator { options: Options constructor(args: CodeGeneratorHttpSnippetConstructorArgs) { super(args) + + const securities = this.isCopyMode() + ? this.nonEmptySecurity() + : this.nonEmptySecurityMasked() const harRequest = convertToHarRequest( - this.nonEmptyParameters, - this.mode === 'ui' ? this.nonEmptySecurityMasked : this.nonEmptySecurity, - this.requestBodyValue, + this.nonEmptyParameters(), + securities, + this.requestBodyValue(), // can't use URL because we don't want to encode { and } in path yet `${this.basePath}${this.configuration.path}`, this.configuration.httpMethod.toUpperCase(), diff --git a/generator/konfig-next-app/src/utils/code-generator-python.ts b/generator/konfig-next-app/src/utils/code-generator-python.ts index d0a0628d8..54f6cc9cd 100644 --- a/generator/konfig-next-app/src/utils/code-generator-python.ts +++ b/generator/konfig-next-app/src/utils/code-generator-python.ts @@ -43,13 +43,13 @@ from ${this.packageName} import ${this.clientName}` get setupArgs(): string { const args = [] - for (const [name, security] of this.nonEmptySecurity) { + for (const [name, security] of this.nonEmptySecurity()) { if (security[SECURITY_TYPE_PROPERTY] === 'clientState') { args.push(`${this.snake_case(name)}="${this.mask(security.value)}"`) } else if (security[SECURITY_TYPE_PROPERTY] === 'apiKey') { args.push( `${ - this.hasMultipleApiKeys + this.hasMultipleApiKeys() ? this.snake_case(security[API_KEY_NAME_PROPERTY]) : 'api_key' }="${this.mask(security.value)}"` @@ -118,9 +118,9 @@ from ${this.packageName} import ${this.clientName}` } get args(): string { - if (this.isArrayRequestBody) { + if (this.isArrayRequestBody()) { const arrayValue = this.recursivelyRemoveEmptyValues( - this.requestBodyValue + this.requestBodyValue() ) if (Array.isArray(arrayValue)) { return `[${arrayValue @@ -130,7 +130,7 @@ from ${this.packageName} import ${this.clientName}` if (arrayValue === '') return '' } const args: string[] = [] - for (const [parameter, value] of this.nonEmptyParameters) { + for (const [parameter, value] of this.nonEmptyParameters()) { args.push( `${this.snake_case(parameter.name)}=${this.toPythonLiteralString( value, diff --git a/generator/konfig-next-app/src/utils/code-generator-typescript.ts b/generator/konfig-next-app/src/utils/code-generator-typescript.ts index e8061ac3e..00c0f780f 100644 --- a/generator/konfig-next-app/src/utils/code-generator-typescript.ts +++ b/generator/konfig-next-app/src/utils/code-generator-typescript.ts @@ -45,15 +45,15 @@ export class CodeGeneratorTypeScript extends CodeGenerator { const ${this.clientNameLowercase} = new ${this.client}(${this.setupArgs}) -${this.mode === 'ui' ? `const response =` : 'return'} await ${ +${this.isUiOrCopyMode() ? `const response =` : 'return'} await ${ this.clientNameLowercase }.${this.namespace}.${this.methodName}(${this.args}) -${this.mode === 'ui' ? `console.log(response.data)` : ''} +${this.isUiOrCopyMode() ? `console.log(response.data)` : ''} ` } get importStatement(): string { - if (this.mode === 'ui') { + if (this.isUiOrCopyMode()) { if (this.args.includes('fs.readFileSync')) return `import { ${this.clientName} } from '${this.packageName}'\nimport fs from 'fs'` return `import { ${this.clientName} } from '${this.packageName}'` @@ -62,54 +62,48 @@ ${this.mode === 'ui' ? `console.log(response.data)` : ''} } get client(): string { - if (this.mode === 'ui') { + if (this.isUiOrCopyMode()) { return `${this.clientName}` } return `client.${this.clientName}` } get setupArgs(): string { - return Object.keys(this.nonEmptySecurity).length === 0 + return Object.keys(this.nonEmptySecurity()).length === 0 ? `{${this.proxySetupArgs}}` : `{ -${this.nonEmptySecurity +${this.nonEmptySecurity() .map(([_name, value]) => { if (value.type === 'oauth2-client-credentials') { - // convert value.clientSecret to string of same length with all values replace with char 'X' const clientSecret = this.mode === 'execution' ? value.clientSecret - : value.clientSecret.replace(/./g, 'X') + : this.mask(value.clientSecret) const clientId = - this.mode === 'execution' - ? value.clientId - : value.clientId.replace(/./g, 'X') + this.mode === 'execution' ? value.clientId : this.mask(value.clientId) return ` "oauthClientId": "${clientId}", "oauthClientSecret": "${clientSecret}",` } if (value.type === 'bearer') { - // convert value.value to string of same length with all values replace with char 'X' const bearer = - this.mode === 'execution' ? value.value : value.value.replace(/./g, 'X') + this.mode === 'execution' ? value.value : this.mask(value.value) return ` "accessToken": "${bearer}",` } const securityValue = value.type === 'apiKey' ? value.value : value.value const securityKey = this.quoteIfNecessary( value.type === 'apiKey' - ? this.hasMultipleApiKeys + ? this.hasMultipleApiKeys() ? value.key : 'apiKey' : value.name ) // convert securityValue to string of same length with all values replace with char 'X' const securityValueMasked = - this.mode === 'execution' - ? securityValue - : securityValue.replace(/./g, 'X') + this.mode === 'execution' ? securityValue : this.mask(securityValue) return ` ${securityKey}: '${securityValueMasked}',` }) .join('\n')}${ - this.oauthTokenUrl !== null && this.isUsingCustomOAuthTokenUrl + this.oauthTokenUrl !== null && this.isUsingCustomOAuthTokenUrl() ? `oauthTokenUrl: "${this.oauthTokenUrl}",` : '' } @@ -128,8 +122,8 @@ ${this.nonEmptySecurity } get proxySetupArgs(): string { - return this.mode === 'ui' - ? this.isUsingCustomBasePath + return this.isUiOrCopyMode() + ? this.isUsingCustomBasePath() ? `basePath: "${this.basePath}",` : '' : `basePath: "/api/proxy", baseOptions: {headers: {"x-proxy-target": "${this.basePath}"}}` @@ -206,8 +200,8 @@ ${this.nonEmptySecurity } get args(): string { - if (this.isArrayRequestBody) { - const arrayValue = this.requestBodyValue + if (this.isArrayRequestBody()) { + const arrayValue = this.requestBodyValue() if (Array.isArray(arrayValue)) { return `[${arrayValue.map((v) => this.argValue(v)).join(', ')}]` } @@ -219,14 +213,14 @@ ${this.nonEmptySecurity } return '' } - const nonBodyParameters = this.nonEmptyParameters + const nonBodyParameters = this.nonEmptyParameters() .filter(([{ parameter }]) => { return parameter.in !== 'body' }) .map(([{ name }, value]) => { return [name, value] as [string, SdkArg] }) - const bodyParameters = this.nonEmptyParameters + const bodyParameters = this.nonEmptyParameters() .filter(([{ parameter }]) => { return parameter.in === 'body' }) diff --git a/generator/konfig-next-app/src/utils/code-generator.test.ts b/generator/konfig-next-app/src/utils/code-generator.test.ts new file mode 100644 index 000000000..88628418a --- /dev/null +++ b/generator/konfig-next-app/src/utils/code-generator.test.ts @@ -0,0 +1,75 @@ +import { HttpMethodsEnum } from 'konfig-lib' +import { CodeGenerator, CodeGeneratorConstructorArgs } from './code-generator' +import { SECURITY_FORM_NAME_PREFIX } from './generate-initial-operation-form-values' +import clone from 'clone' + +class CodeGeneratorTest extends CodeGenerator { + protected format(code: string): Promise { + throw new Error( + 'This class is only used for testing shared functions in CodeGenerator' + ) + } + protected gen(): string { + throw new Error( + 'This class is only used for testing shared functions in CodeGenerator' + ) + } +} + +/** + * Creates new instance of CodeGeneratorTest with values used for testing + */ +function testArgs(): CodeGeneratorConstructorArgs { + return { + path: '', + contentType: 'application/json', + httpMethod: HttpMethodsEnum.POST, + securitySchemes: { + apiKey: { + type: 'apiKey', + in: 'header', + name: 'X-Api-Key', + }, + }, + formData: { + requestBody: '', + parameters: {}, + security: { + apiKey: { + type: 'apiKey', + in: 'header', + key: 'X-API-Key', + value: 'my_api_key', + }, + }, + }, + parameters: [], + languageConfigurations: { + typescript: { + clientName: 'Test', + packageName: 'test', + }, + python: { + clientName: 'Test', + packageName: 'tet', + }, + }, + tag: 'Test', + operationId: 'Test_test', + requestBody: null, + basePath: 'https://test.com/api', + requestBodyRequired: true, + servers: ['https://test.com/api'], + oauthTokenUrl: null, + originalOauthTokenUrl: null, + } +} + +test('ensure nonEmptySecurityMasked does not modify security values in-place', async () => { + const args: CodeGeneratorConstructorArgs = testArgs() + const test = new CodeGeneratorTest(args) + const before = clone(test.configuration.formData[SECURITY_FORM_NAME_PREFIX]) + test.nonEmptySecurityMasked() + const after = test.configuration.formData[SECURITY_FORM_NAME_PREFIX] + expect(before).toStrictEqual(after) +}) diff --git a/generator/konfig-next-app/src/utils/code-generator.ts b/generator/konfig-next-app/src/utils/code-generator.ts index c17fcc75d..c4756d652 100644 --- a/generator/konfig-next-app/src/utils/code-generator.ts +++ b/generator/konfig-next-app/src/utils/code-generator.ts @@ -1,4 +1,5 @@ import { Parameter } from '@/components/OperationParameter' +import clone from 'clone' import { API_KEY_VALUE_PROPERTY, BEARER_VALUE_PROPERTY, @@ -149,6 +150,9 @@ export abstract class CodeGenerator { * @returns The masked value */ mask(value: string) { + // Don't mask if in copy mode + if (this.isCopyMode()) return value + return value.replace(/./g, 'X') } @@ -156,15 +160,15 @@ export abstract class CodeGenerator { return await this.format(this.gen()) } - get isUsingCustomBasePath(): boolean { + isUsingCustomBasePath(): boolean { return this.basePath !== this.servers[0] } - get isUsingCustomOAuthTokenUrl(): boolean { + isUsingCustomOAuthTokenUrl(): boolean { return this.originalOauthTokenUrl !== this.oauthTokenUrl } - get hasMultipleApiKeys(): boolean { + hasMultipleApiKeys(): boolean { if (this.configuration.securitySchemes === null) return false const hasMultipleApiKeys = Object.values(this.configuration.securitySchemes).filter( @@ -173,7 +177,7 @@ export abstract class CodeGenerator { return hasMultipleApiKeys } - get isArrayRequestBody(): boolean { + isArrayRequestBody(): boolean { return this.configuration.requestBody?.schema?.type === 'array' } @@ -185,14 +189,14 @@ export abstract class CodeGenerator { * a scalar or array request body, the request body cannot be flattened so it * is passed as a separate argument. */ - get requestBodyValue(): FormInputValue { + requestBodyValue(): FormInputValue { return this.recursivelyRemoveEmptyValues(this._formData['requestBody']) } /** * Returns the setup values that are non-empty and exist as part of passed parameters */ - get nonEmptyParameters(): NonEmptyParameters { + nonEmptyParameters(): NonEmptyParameters { const parameters = Object.entries( this._formData[PARAMETER_FORM_NAME_PREFIX] ) @@ -286,8 +290,8 @@ export abstract class CodeGenerator { return filtered.length > 0 } - get nonEmptySecurityMasked(): CodeGenerator['nonEmptySecurity'] { - return this.nonEmptySecurity.map(([name, security]) => { + nonEmptySecurityMasked(): NonEmptySecurity { + return clone(this.nonEmptySecurity()).map(([name, security]) => { if (security.type === 'apiKey') { security[API_KEY_VALUE_PROPERTY] = this.mask( security[API_KEY_VALUE_PROPERTY] @@ -308,10 +312,18 @@ export abstract class CodeGenerator { }) } + isUiOrCopyMode(): boolean { + return this.mode === 'ui' || this.mode === 'copy' + } + + isCopyMode(): boolean { + return this.mode === 'copy' + } + /** * Returns the security schemes that are non-empty */ - get nonEmptySecurity() { + nonEmptySecurity() { return Object.entries(this._formData[SECURITY_FORM_NAME_PREFIX]).filter( ([_name, security]) => { if (security.type === 'apiKey') { @@ -342,4 +354,4 @@ export abstract class CodeGenerator { } } -export type NonEmptySecurity = CodeGenerator['nonEmptySecurity'] +export type NonEmptySecurity = ReturnType diff --git a/generator/konfig-next-app/yarn.lock b/generator/konfig-next-app/yarn.lock index a4361a59b..673d58a98 100644 --- a/generator/konfig-next-app/yarn.lock +++ b/generator/konfig-next-app/yarn.lock @@ -1680,6 +1680,11 @@ resolved "https://registry.yarnpkg.com/@types/chroma-js/-/chroma-js-2.4.0.tgz#476a16ae848c77478079d6749236fdb98837b92c" integrity sha512-JklMxityrwjBTjGY2anH8JaTx3yjRU3/sEHSblLH1ba5lqcSh1LnImXJZO5peJfXyqKYWjHTGy4s5Wz++hARrw== +"@types/clone@^2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@types/clone/-/clone-2.1.3.tgz#91d16af4006a24fd3d683f5454d48b29a695b0e9" + integrity sha512-DxFaNYaIUXW1OSRCVCC1UHoLcvk6bVJ0v9VvUaZ6kR5zK8/QazXlOThgdvnK0Xpa4sBq+b/Yoq/mnNn383hVRw== + "@types/debug@^4.0.0": version "4.1.8" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.8.tgz#cef723a5d0a90990313faec2d1e22aee5eecb317" @@ -2487,6 +2492,11 @@ cliui@^8.0.1: strip-ansi "^6.0.1" wrap-ansi "^7.0.0" +clone@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== + clsx@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"