From 39854122aafd62c3d776ad02bb6c59ef8e689b52 Mon Sep 17 00:00:00 2001 From: Daria Mayorova Date: Thu, 30 Mar 2023 01:03:10 +0200 Subject: [PATCH 1/7] Serialize Service Mgmt API's report endpoint body correctly Restore custom.d.ts Fixes Fix tests --- .../src/ActiveDocs/OAS3Autocomplete.ts | 2 +- .../src/ActiveDocs/ThreeScaleApiDocs.ts | 100 +++++++++++++++++- app/javascript/src/Types/SwaggerTypes.ts | 49 +++++++++ app/javascript/src/Types/swagger.d.ts | 3 + package.json | 1 + .../ActiveDocs/OAS3Autocomplete.spec.ts | 56 ++++++---- .../ActiveDocs/ThreeScaleApiDocs.spec.ts | 61 +++++++++++ .../swagger-client/es/execute/index.js | 3 + 8 files changed, 249 insertions(+), 26 deletions(-) create mode 100644 app/javascript/src/Types/swagger.d.ts create mode 100644 spec/javascripts/ActiveDocs/ThreeScaleApiDocs.spec.ts create mode 100644 spec/javascripts/__mocks__/swagger-client/es/execute/index.js diff --git a/app/javascript/src/ActiveDocs/OAS3Autocomplete.ts b/app/javascript/src/ActiveDocs/OAS3Autocomplete.ts index f0bc1b46f6..1733be0f7f 100644 --- a/app/javascript/src/ActiveDocs/OAS3Autocomplete.ts +++ b/app/javascript/src/ActiveDocs/OAS3Autocomplete.ts @@ -114,7 +114,7 @@ export interface Response extends SwaggerUIResponse { text: string; } -const autocompleteOAS3 = async (response: SwaggerUIResponse, accountDataUrl: string, serviceEndpoint: string): Promise => { +export const autocompleteOAS3 = async (response: SwaggerUIResponse, accountDataUrl: string, serviceEndpoint: string): Promise => { const bodyWithServer = injectServerToResponseBody(response.body, serviceEndpoint) const data = await fetchData<{ results: AccountData }>(accountDataUrl) diff --git a/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts b/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts index 8a8a7f59b1..9e0606eb02 100644 --- a/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts +++ b/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts @@ -1,9 +1,12 @@ import SwaggerUI from 'swagger-ui' +// this is how SwaggerUI imports this function https://github.com/swagger-api/swagger-ui/pull/6208 +import { execute } from 'swagger-client/es/execute' import { fetchData } from 'utilities/fetchData' import { autocompleteRequestInterceptor } from 'ActiveDocs/OAS3Autocomplete' -import type { ApiDocsServices } from 'Types/SwaggerTypes' +import type { ApiDocsServices, BackendApiReportBody, BackendApiTransaction, ExecuteData } from 'Types/SwaggerTypes' +import type { SwaggerUIPlugin } from 'swagger-ui' const getApiSpecUrl = (baseUrl: string, specPath: string): string => { return `${baseUrl.replace(/\/$/, '')}${specPath}` @@ -17,6 +20,96 @@ const appendSwaggerDiv = (container: HTMLElement, id: string): void => { container.appendChild(div) } +/** + * when using Record notation, the following error is thrown: + * 'TS2456: Type alias 'BodyValue' circularly references itself.' + */ +// eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style +type BodyValue = boolean | number | string | { [key: string]: BodyValue } + +/** + * Transforms an object into form data representation, also URL-encoding the values, + * Example: + * { + * a_string: 'hello', + * an_array: [ + * { first: 1 }, + * { second: 1, extra_param: 'with whitespace'} + * ] + * } + * => + * { + * a_string: 'hello', + * 'an_array[0][first]': '1', + * 'an_array[1][second]': '1', + * 'an_array[1][extra_param]': 'with%20whitespace' + * } + * @param object + */ +export const objectToFormData = (object: BodyValue): Record => { + const buildFormData = (formData: Record, data: BodyValue, parentKey?: string) => { + if (data && typeof data === 'object') { + Object.keys(data).forEach((key: string) => { + buildFormData(formData, data[key], parentKey ? `${parentKey}[${key}]` : key) + }) + } else { + if (parentKey) { + formData[parentKey] = data ? data : '' + } + } + } + const formData = {} + buildFormData(formData, object) + return formData +} + +/** + * Transforms the request body of the Service Management API Report, as + * SwaggerUI (or rather swagger-js) does not serialize arrays of objects properly + * The hack is to process the request body in two steps: + * 1. normalize the 'transactions' array, as its elements may be either an object (if the value is taken + * from the example in the spec), or as a serialized JSON (if the field is changed manually) + * 2. "flatten" the objects by transforming them into form-data structure with the entries like + * 'transactions[0][app_id]': 'example' + * 'transactions[0][usage][hits]': 1 + * @param body BackendApiReportBody + */ +export const transformReportRequestBody = (body: BackendApiReportBody): Record => { + if (Array.isArray(body.transactions)) { + body.transactions = body.transactions.map(transaction => { + switch (typeof transaction) { + case 'object': + return transaction + case 'string': + try { + return JSON.parse(transaction) as BackendApiTransaction + } catch (error: unknown) { + return null + } + default: + return null + } + }).filter(element => element != null) as BackendApiTransaction[] + } + return objectToFormData(body as BodyValue) +} + +const RequestBodyTransformerPlugin: SwaggerUIPlugin = () => { + return { + fn: { + execute: (req: ExecuteData): unknown => { + if (req.contextUrl.includes('api_docs/services/service_management_api.json') + && req.operationId === 'report' + && req.requestBody) { + req.requestBody = transformReportRequestBody(req.requestBody as BackendApiReportBody) + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + return execute(req) + } + } + } +} + export const renderApiDocs = async (container: HTMLElement, apiDocsPath: string, baseUrl: string, apiDocsAccountDataPath: string): Promise => { const apiSpecs: ApiDocsServices = await fetchData(apiDocsPath) apiSpecs.apis.forEach( api => { @@ -28,7 +121,10 @@ export const renderApiDocs = async (container: HTMLElement, apiDocsPath: string, // eslint-disable-next-line @typescript-eslint/naming-convention -- Swagger UI dom_id: `#${domId}`, requestInterceptor: (request) => autocompleteRequestInterceptor(request, apiDocsAccountDataPath, ''), - tryItOutEnabled: true + tryItOutEnabled: true, + plugins: [ + RequestBodyTransformerPlugin + ] }) }) } diff --git a/app/javascript/src/Types/SwaggerTypes.ts b/app/javascript/src/Types/SwaggerTypes.ts index 521639d573..5fe7134796 100644 --- a/app/javascript/src/Types/SwaggerTypes.ts +++ b/app/javascript/src/Types/SwaggerTypes.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import type { Request, Response, SupportedHTTPMethods } from 'swagger-ui' export type AccountData = Record; + requestInterceptor?: ((request: Request) => Promise | Request) | undefined; + responseInterceptor?: ((response: Response) => Promise | Response) | undefined; +} + +export interface ExecuteData { + contextUrl: string; + fetch: (arg: unknown) => unknown; + method: SupportedHTTPMethods; + operation: unknown; + operationId: string; + parameters: unknown; + pathName: string; + requestBody: unknown; + requestContentType: string; + requestInterceptor?: ((request: Request) => Promise | Request) | undefined; + responseContentType: string; + responseInterceptor?: ((response: Response) => Promise | Response) | undefined; + scheme: string; + securities: unknown; + server: string; + serverVariables: unknown; + spec: unknown; +} + +export interface BackendApiTransaction { + app_id?: string; + user_key?: string; + timestamp?: string; + usage: Record; + log?: { + request?: string; + response?: string; + code?: string; + }; +} + +export interface BackendApiReportBody { + service_token?: string; + service_id?: string; + transactions?: (BackendApiTransaction | string)[]; +} diff --git a/app/javascript/src/Types/swagger.d.ts b/app/javascript/src/Types/swagger.d.ts new file mode 100644 index 0000000000..9d562446fc --- /dev/null +++ b/app/javascript/src/Types/swagger.d.ts @@ -0,0 +1,3 @@ +declare module 'swagger-client/es/execute' { + export function execute (req: unknown): unknown +} diff --git a/package.json b/package.json index 71bb80fda5..3f8011dba7 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "redux-thunk": "^2.3.0", "select2": "^4.0.6-rc.1", "swagger-ui": "^3.51.1", + "swagger-client": "^3.13.5", "validate.js": "^0.13.1", "virtual-dom": "^2.1.1" } diff --git a/spec/javascripts/ActiveDocs/OAS3Autocomplete.spec.ts b/spec/javascripts/ActiveDocs/OAS3Autocomplete.spec.ts index f9d4946c7f..faf33d8def 100644 --- a/spec/javascripts/ActiveDocs/OAS3Autocomplete.spec.ts +++ b/spec/javascripts/ActiveDocs/OAS3Autocomplete.spec.ts @@ -1,7 +1,7 @@ -import { autocompleteInterceptor } from 'ActiveDocs/OAS3Autocomplete' +import { autocompleteOAS3, autocompleteRequestInterceptor } from 'ActiveDocs/OAS3Autocomplete' import * as utils from 'utilities/fetchData' -import type { Response as SwaggerUIResponse } from 'swagger-ui' +import type { Request as SwaggerUIRequest, Response as SwaggerUIResponse } from 'swagger-ui' const specUrl = 'https://provider.3scale.test/foo/bar.json' const specRelativeUrl = 'foo/bar.json' @@ -67,25 +67,14 @@ const accountData = { const fetchDataSpy = jest.spyOn(utils, 'fetchData') fetchDataSpy.mockResolvedValue(accountData) -describe('when the request is fetching OpenAPI spec', () => { - const response = specResponse - - describe('when spec url is absolute', () => { - it('should inject servers to the spec', async () => { - const res: SwaggerUIResponse = await autocompleteInterceptor(response, accountDataUrl, serviceEndpoint, specUrl) - expect(res.body.servers).toEqual([{ 'url': serviceEndpoint }]) - }) - }) - - describe('when spec url is relative', () => { - it('should inject servers to the spec', async () => { - const res: SwaggerUIResponse = await autocompleteInterceptor(response, accountDataUrl, serviceEndpoint, specRelativeUrl) - expect(res.body.servers).toEqual([{ 'url': serviceEndpoint }]) - }) +describe('autocompleteOAS3', () => { + it('should inject servers to the spec', async () => { + const res: SwaggerUIResponse = await autocompleteOAS3(specResponse, accountDataUrl, serviceEndpoint) + expect(res.body.servers).toEqual([{ 'url': 'foo/bar/serviceEndpoint' }]) }) it('should autocomplete fields of OpenAPI spec with x-data-threescale-name property', async () => { - const res: SwaggerUIResponse = await autocompleteInterceptor(response, accountDataUrl, serviceEndpoint, specUrl) + const res: SwaggerUIResponse = await autocompleteOAS3(specResponse, accountDataUrl, serviceEndpoint) const examplesFirstParam = res.body.paths['/'].get.parameters[0].examples const examplesSecondParam = res.body.paths['/'].get.parameters[1].examples @@ -98,10 +87,31 @@ describe('when the request is fetching OpenAPI spec', () => { }) }) -describe('when the request is fetching API call response', () => { - const response = apiResponse - it('should not inject servers to the response', () => { - const res: SwaggerUIResponse = autocompleteInterceptor(response, accountDataUrl, serviceEndpoint, specUrl) - expect(res.body.servers).toBe(undefined) +describe('autocompleteRequestInterceptor', () => { + describe('when the request is fetching OpenAPI spec', () => { + it('should update the response interceptor', async () => { + let request: SwaggerUIRequest = { loadSpec: true } + request = autocompleteRequestInterceptor(request, accountDataUrl, serviceEndpoint) + + expect(request.responseInterceptor).toBeDefined() + + const res: SwaggerUIResponse = await request.responseInterceptor(specResponse, accountDataUrl, serviceEndpoint) + expect(res.body.servers).toEqual([{ 'url': 'foo/bar/serviceEndpoint' }]) + }) + }) + + describe('when the request is fetching API call response', () => { + const originalInterceptor = jest.fn((res: SwaggerUIRequest)=> { return res }) + let request: SwaggerUIRequest = { responseInterceptor: originalInterceptor } + request = autocompleteRequestInterceptor(request, accountDataUrl, serviceEndpoint) + + it('should not update the response interceptor', () => { + expect(request.responseInterceptor).toEqual(originalInterceptor) + }) + + it('should prevent injecting servers to the response', async () => { + const res: SwaggerUIResponse = await request.responseInterceptor(apiResponse, accountDataUrl, serviceEndpoint, specUrl) + expect(res.body.servers).toBe(undefined) + }) }) }) diff --git a/spec/javascripts/ActiveDocs/ThreeScaleApiDocs.spec.ts b/spec/javascripts/ActiveDocs/ThreeScaleApiDocs.spec.ts new file mode 100644 index 0000000000..d2e4db045f --- /dev/null +++ b/spec/javascripts/ActiveDocs/ThreeScaleApiDocs.spec.ts @@ -0,0 +1,61 @@ +import { transformReportRequestBody } from 'ActiveDocs/ThreeScaleApiDocs' + +import type { BackendApiReportBody } from 'Types/SwaggerTypes' + +const body: BackendApiReportBody = { + service_token: 'token', + service_id: '123', + transactions: [ + { + app_id: 'app-id1', + timestamp: '2023-03-29 00:00:00 -01:00', + usage: { + 'hit1-1': 11, + 'hit1-2': 12 + }, + log: { + request: 'request1', + response: 'response1', + code: '200' + } + }, + { + app_id: 'app-id2', + timestamp: '2023-03-29 00:00:00 -02:00', + usage: { + 'hit2-1': 21, + 'hit2-2': 22 + }, + log: { + request: 'request2', + response: 'response2', + code: '200' + } + } + ] +} + +describe('transformReportRequestBody', () => { + it('transforms the transactions array when transaction is an object', () => { + const result = transformReportRequestBody(body) + + expect(result).toEqual({ + service_token: 'token', + service_id: '123', + 'transactions[0][app_id]': 'app-id1', + 'transactions[0][timestamp]': '2023-03-29 00:00:00 -01:00', + 'transactions[0][usage][hit1-1]': 11, + 'transactions[0][usage][hit1-2]': 12, + 'transactions[0][log][request]': 'request1', + 'transactions[0][log][response]': 'response1', + 'transactions[0][log][code]': '200', + 'transactions[1][app_id]': 'app-id2', + 'transactions[1][timestamp]': '2023-03-29 00:00:00 -02:00', + 'transactions[1][usage][hit2-1]': 21, + 'transactions[1][usage][hit2-2]': 22, + 'transactions[1][log][request]': 'request2', + 'transactions[1][log][response]': 'response2', + 'transactions[1][log][code]': '200' + }) + }) +}) diff --git a/spec/javascripts/__mocks__/swagger-client/es/execute/index.js b/spec/javascripts/__mocks__/swagger-client/es/execute/index.js new file mode 100644 index 0000000000..8cfd7ace8f --- /dev/null +++ b/spec/javascripts/__mocks__/swagger-client/es/execute/index.js @@ -0,0 +1,3 @@ +module.exports = { + execute: jest.fn() +} From 9e39a6756c4b68ca197e49bd7d5e41355690c8a9 Mon Sep 17 00:00:00 2001 From: Daria Mayorova Date: Wed, 12 Apr 2023 12:47:24 +0200 Subject: [PATCH 2/7] Some small refactoring --- .../src/ActiveDocs/ThreeScaleApiDocs.ts | 56 +++---- app/javascript/src/Types/SwaggerTypes.ts | 21 ++- .../ActiveDocs/ThreeScaleApiDocs.spec.ts | 157 ++++++++++++++---- .../swagger-client/es/execute/index.js | 2 +- 4 files changed, 167 insertions(+), 69 deletions(-) diff --git a/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts b/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts index 9e0606eb02..e1acc3f987 100644 --- a/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts +++ b/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts @@ -3,9 +3,10 @@ import SwaggerUI from 'swagger-ui' import { execute } from 'swagger-client/es/execute' import { fetchData } from 'utilities/fetchData' +import { safeFromJsonString } from 'utilities/json-utils' import { autocompleteRequestInterceptor } from 'ActiveDocs/OAS3Autocomplete' -import type { ApiDocsServices, BackendApiReportBody, BackendApiTransaction, ExecuteData } from 'Types/SwaggerTypes' +import type { ApiDocsServices, BackendApiReportBody, BackendApiTransaction, BodyValue, BodyValueObject, FormData, ExecuteData } from 'Types/SwaggerTypes' import type { SwaggerUIPlugin } from 'swagger-ui' const getApiSpecUrl = (baseUrl: string, specPath: string): string => { @@ -21,14 +22,9 @@ const appendSwaggerDiv = (container: HTMLElement, id: string): void => { } /** - * when using Record notation, the following error is thrown: - * 'TS2456: Type alias 'BodyValue' circularly references itself.' - */ -// eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style -type BodyValue = boolean | number | string | { [key: string]: BodyValue } - -/** - * Transforms an object into form data representation, also URL-encoding the values, + * Transforms an object into form data representation. Does not URL-encode, because it will be done by + * swagger-client itself + * Returns an empty object if the argument is not an object * Example: * { * a_string: 'hello', @@ -42,15 +38,19 @@ type BodyValue = boolean | number | string | { [key: string]: BodyValue } * a_string: 'hello', * 'an_array[0][first]': '1', * 'an_array[1][second]': '1', - * 'an_array[1][extra_param]': 'with%20whitespace' + * 'an_array[1][extra_param]': 'with whitespace' * } * @param object */ -export const objectToFormData = (object: BodyValue): Record => { - const buildFormData = (formData: Record, data: BodyValue, parentKey?: string) => { +export const objectToFormData = (object: BodyValue): FormData => { + if (typeof object !== 'object' || Array.isArray(object)) { + return {} + } + const buildFormData = (formData: FormData, data: BodyValue, parentKey?: string) => { if (data && typeof data === 'object') { - Object.keys(data).forEach((key: string) => { - buildFormData(formData, data[key], parentKey ? `${parentKey}[${key}]` : key) + const dataObject = data as BodyValueObject + Object.keys(dataObject).forEach((key: string) => { + buildFormData(formData, dataObject[key], parentKey ? `${parentKey}[${key}]` : key) }) } else { if (parentKey) { @@ -58,7 +58,7 @@ export const objectToFormData = (object: BodyValue): Record => { +export const transformReportRequestBody = (body: BackendApiReportBody): FormData => { if (Array.isArray(body.transactions)) { - body.transactions = body.transactions.map(transaction => { - switch (typeof transaction) { - case 'object': - return transaction - case 'string': - try { - return JSON.parse(transaction) as BackendApiTransaction - } catch (error: unknown) { - return null - } - default: - return null + body.transactions = body.transactions.reduce((acc: BackendApiTransaction[], transaction) => { + let value = undefined + if (typeof transaction === 'object') { + value = transaction + } else { + value = safeFromJsonString(transaction) + } + if (value) { + acc.push(value) } - }).filter(element => element != null) as BackendApiTransaction[] + return acc + }, []) } return objectToFormData(body as BodyValue) } diff --git a/app/javascript/src/Types/SwaggerTypes.ts b/app/javascript/src/Types/SwaggerTypes.ts index 5fe7134796..8763bf210d 100644 --- a/app/javascript/src/Types/SwaggerTypes.ts +++ b/app/javascript/src/Types/SwaggerTypes.ts @@ -19,16 +19,6 @@ export interface ApiDocsServices { apis: ApiDocsService[]; } -export interface RequestData extends Request { - url: string; - method: SupportedHTTPMethods; - body: string; - credentials: string; - headers: Record; - requestInterceptor?: ((request: Request) => Promise | Request) | undefined; - responseInterceptor?: ((response: Response) => Promise | Response) | undefined; -} - export interface ExecuteData { contextUrl: string; fetch: (arg: unknown) => unknown; @@ -37,7 +27,7 @@ export interface ExecuteData { operationId: string; parameters: unknown; pathName: string; - requestBody: unknown; + requestBody?: unknown; requestContentType: string; requestInterceptor?: ((request: Request) => Promise | Request) | undefined; responseContentType: string; @@ -66,3 +56,12 @@ export interface BackendApiReportBody { service_id?: string; transactions?: (BackendApiTransaction | string)[]; } + +/** + * when using Record notation, the following error is thrown: + * 'TS2456: Type alias 'BodyValue' circularly references itself.' + */ +export type BodyValue = BodyValue[] | boolean | number | string | { [key: string]: BodyValue } | null | undefined +export type BodyValueObject = Record + +export type FormData = Record diff --git a/spec/javascripts/ActiveDocs/ThreeScaleApiDocs.spec.ts b/spec/javascripts/ActiveDocs/ThreeScaleApiDocs.spec.ts index d2e4db045f..6f638fb412 100644 --- a/spec/javascripts/ActiveDocs/ThreeScaleApiDocs.spec.ts +++ b/spec/javascripts/ActiveDocs/ThreeScaleApiDocs.spec.ts @@ -1,40 +1,88 @@ -import { transformReportRequestBody } from 'ActiveDocs/ThreeScaleApiDocs' +import { objectToFormData, transformReportRequestBody } from 'ActiveDocs/ThreeScaleApiDocs' -import type { BackendApiReportBody } from 'Types/SwaggerTypes' +import type { BackendApiReportBody, BackendApiTransaction, BodyValue } from 'Types/SwaggerTypes' +const transaction1: BackendApiTransaction = { + app_id: 'app-id1', + timestamp: '2023-03-29 00:00:00 -01:00', + usage: { + 'hit1-1': 11, + 'hit1-2': 12 + }, + log: { + request: 'request1', + response: 'response1', + code: '200' + } +} +const transaction2: BackendApiTransaction = { + app_id: 'app-id2', + timestamp: '2023-03-29 00:00:00 -02:00', + usage: { + 'hit2-1': 21, + 'hit2-2': 22 + }, + log: { + request: 'request2', + response: 'response2', + code: '200' + } +} const body: BackendApiReportBody = { service_token: 'token', service_id: '123', transactions: [ - { - app_id: 'app-id1', - timestamp: '2023-03-29 00:00:00 -01:00', - usage: { - 'hit1-1': 11, - 'hit1-2': 12 - }, - log: { - request: 'request1', - response: 'response1', - code: '200' - } - }, - { - app_id: 'app-id2', - timestamp: '2023-03-29 00:00:00 -02:00', - usage: { - 'hit2-1': 21, - 'hit2-2': 22 - }, - log: { - request: 'request2', - response: 'response2', - code: '200' - } - } + transaction1, + transaction2 ] } +describe('objectToFormData', () => { + it('transforms the object to form data', () => { + const object: BodyValue = { + number: 1, + string: 'string with whitespace', + object: { + numField: 2, + strField: 'str', + boolField: true + }, + array: [ + { + foo: 'bar' + }, + { + foo: 'baz' + } + ], + nullField: null, + undefinedField: undefined, + emptyField: '' + } + + expect(objectToFormData(object)).toEqual({ + number: 1, + string: 'string with whitespace', + 'object[numField]': 2, + 'object[strField]': 'str', + 'object[boolField]': true, + 'array[0][foo]': 'bar', + 'array[1][foo]': 'baz', + nullField: '', + undefinedField: '', + emptyField: '' + }) + }) + + it('returns an empty object if argument is not a valid object', () => { + expect(objectToFormData('hello')).toEqual({}) + expect(objectToFormData(true)).toEqual({}) + expect(objectToFormData(123)).toEqual({}) + expect(objectToFormData(['q', 'w', 'r'])).toEqual({}) + }) + +}) + describe('transformReportRequestBody', () => { it('transforms the transactions array when transaction is an object', () => { const result = transformReportRequestBody(body) @@ -58,4 +106,57 @@ describe('transformReportRequestBody', () => { 'transactions[1][log][code]': '200' }) }) + + it('transforms the transactions array when a transaction is a serialized JSON', () => { + const bodyWithSerializedTransaction = { + ...body, + transactions: [ + transaction1, + '{\n "app_id": "app-id2",\n "timestamp": "2023-03-29 00:00:00 -02:00",\n "usage": {\n "hit2-1": 21,\n "hit2-2": 22\n },\n "log": {\n "request": "request2",\n "response": "response2",\n "code": "200"\n }\n}'] + } + const result = transformReportRequestBody(bodyWithSerializedTransaction) + + expect(result).toEqual({ + service_token: 'token', + service_id: '123', + 'transactions[0][app_id]': 'app-id1', + 'transactions[0][timestamp]': '2023-03-29 00:00:00 -01:00', + 'transactions[0][usage][hit1-1]': 11, + 'transactions[0][usage][hit1-2]': 12, + 'transactions[0][log][request]': 'request1', + 'transactions[0][log][response]': 'response1', + 'transactions[0][log][code]': '200', + 'transactions[1][app_id]': 'app-id2', + 'transactions[1][timestamp]': '2023-03-29 00:00:00 -02:00', + 'transactions[1][usage][hit2-1]': 21, + 'transactions[1][usage][hit2-2]': 22, + 'transactions[1][log][request]': 'request2', + 'transactions[1][log][response]': 'response2', + 'transactions[1][log][code]': '200' + }) + }) + + it('skips the transactions with invalid format', () => { + const bodyWithInvalidTransactions = { + ...body, + transactions: [transaction1, 'invalid', 'anotherOne'] + } + const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation() + + const result = transformReportRequestBody(bodyWithInvalidTransactions) + expect(result).toEqual({ + service_token: 'token', + service_id: '123', + 'transactions[0][app_id]': 'app-id1', + 'transactions[0][timestamp]': '2023-03-29 00:00:00 -01:00', + 'transactions[0][usage][hit1-1]': 11, + 'transactions[0][usage][hit1-2]': 12, + 'transactions[0][log][request]': 'request1', + 'transactions[0][log][response]': 'response1', + 'transactions[0][log][code]': '200' + }) + + expect(consoleErrorMock).toHaveBeenCalledTimes(2) + consoleErrorMock.mockRestore() + }) }) diff --git a/spec/javascripts/__mocks__/swagger-client/es/execute/index.js b/spec/javascripts/__mocks__/swagger-client/es/execute/index.js index 8cfd7ace8f..9846f0040e 100644 --- a/spec/javascripts/__mocks__/swagger-client/es/execute/index.js +++ b/spec/javascripts/__mocks__/swagger-client/es/execute/index.js @@ -1,3 +1,3 @@ module.exports = { - execute: jest.fn() + execute: jest.fn() } From ffa3f34c9a95d715618391f41dbee05c224bd9f6 Mon Sep 17 00:00:00 2001 From: Daria Mayorova Date: Wed, 12 Apr 2023 15:47:32 +0200 Subject: [PATCH 3/7] Tiny update --- spec/javascripts/ActiveDocs/OAS3Autocomplete.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spec/javascripts/ActiveDocs/OAS3Autocomplete.spec.ts b/spec/javascripts/ActiveDocs/OAS3Autocomplete.spec.ts index faf33d8def..d9107afafc 100644 --- a/spec/javascripts/ActiveDocs/OAS3Autocomplete.spec.ts +++ b/spec/javascripts/ActiveDocs/OAS3Autocomplete.spec.ts @@ -4,7 +4,6 @@ import * as utils from 'utilities/fetchData' import type { Request as SwaggerUIRequest, Response as SwaggerUIResponse } from 'swagger-ui' const specUrl = 'https://provider.3scale.test/foo/bar.json' -const specRelativeUrl = 'foo/bar.json' const apiUrl = 'https://some.api.domain/foo/bar/api-url' const accountDataUrl = 'foo/bar' const serviceEndpoint = 'foo/bar/serviceEndpoint' @@ -110,7 +109,7 @@ describe('autocompleteRequestInterceptor', () => { }) it('should prevent injecting servers to the response', async () => { - const res: SwaggerUIResponse = await request.responseInterceptor(apiResponse, accountDataUrl, serviceEndpoint, specUrl) + const res: SwaggerUIResponse = await request.responseInterceptor(apiResponse, accountDataUrl, serviceEndpoint) expect(res.body.servers).toBe(undefined) }) }) From 14ac7f08e8ada81e5c07e27709bbaec711fe6152 Mon Sep 17 00:00:00 2001 From: Daria Mayorova Date: Thu, 13 Apr 2023 12:16:02 +0200 Subject: [PATCH 4/7] Remove cast to BodyValue --- app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts | 4 ++-- app/javascript/src/Types/SwaggerTypes.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts b/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts index e1acc3f987..04f7f8d579 100644 --- a/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts +++ b/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts @@ -42,7 +42,7 @@ const appendSwaggerDiv = (container: HTMLElement, id: string): void => { * } * @param object */ -export const objectToFormData = (object: BodyValue): FormData => { +export const objectToFormData = (object: BodyValueObject): FormData => { if (typeof object !== 'object' || Array.isArray(object)) { return {} } @@ -89,7 +89,7 @@ export const transformReportRequestBody = (body: BackendApiReportBody): FormData return acc }, []) } - return objectToFormData(body as BodyValue) + return objectToFormData(body) } const RequestBodyTransformerPlugin: SwaggerUIPlugin = () => { diff --git a/app/javascript/src/Types/SwaggerTypes.ts b/app/javascript/src/Types/SwaggerTypes.ts index 8763bf210d..025d4494ea 100644 --- a/app/javascript/src/Types/SwaggerTypes.ts +++ b/app/javascript/src/Types/SwaggerTypes.ts @@ -39,7 +39,7 @@ export interface ExecuteData { spec: unknown; } -export interface BackendApiTransaction { +export interface BackendApiTransaction extends BodyValueObject { app_id?: string; user_key?: string; timestamp?: string; @@ -51,7 +51,7 @@ export interface BackendApiTransaction { }; } -export interface BackendApiReportBody { +export interface BackendApiReportBody extends BodyValueObject { service_token?: string; service_id?: string; transactions?: (BackendApiTransaction | string)[]; From 1045e25a1351186fc2de58309041bc5bf8ca09bd Mon Sep 17 00:00:00 2001 From: Daria Mayorova Date: Thu, 13 Apr 2023 12:41:56 +0200 Subject: [PATCH 5/7] Split objectToFormData into two functions --- .../src/ActiveDocs/ThreeScaleApiDocs.ts | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts b/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts index 04f7f8d579..fbb374675b 100644 --- a/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts +++ b/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts @@ -21,6 +21,27 @@ const appendSwaggerDiv = (container: HTMLElement, id: string): void => { container.appendChild(div) } +/** + * A recursive function that traverses the tree of the multi-level object `data` + * and for every leaf (i.e. value of primitive type) adds the value to `formData` single-level object, + * with the key that is the `path` to that leaf, e.g. 'paramName[nestedArray][0][arrayProp]' + * @param formData single-level object used as accumulator + * @param data current node of the object + * @param parentKey part of the formData key inherited from the upper level + */ +const buildFormData = (formData: FormData, data: BodyValue, parentKey?: string) => { + if (data && typeof data === 'object') { + const dataObject = data as BodyValueObject + Object.keys(dataObject).forEach((key: string) => { + buildFormData(formData, dataObject[key], parentKey ? `${parentKey}[${key}]` : key) + }) + } else { + if (parentKey) { + formData[parentKey] = data ? data : '' + } + } +} + /** * Transforms an object into form data representation. Does not URL-encode, because it will be done by * swagger-client itself @@ -46,18 +67,6 @@ export const objectToFormData = (object: BodyValueObject): FormData => { if (typeof object !== 'object' || Array.isArray(object)) { return {} } - const buildFormData = (formData: FormData, data: BodyValue, parentKey?: string) => { - if (data && typeof data === 'object') { - const dataObject = data as BodyValueObject - Object.keys(dataObject).forEach((key: string) => { - buildFormData(formData, dataObject[key], parentKey ? `${parentKey}[${key}]` : key) - }) - } else { - if (parentKey) { - formData[parentKey] = data ? data : '' - } - } - } const formData: FormData = {} buildFormData(formData, object) return formData From f2c9ac473af4e5db5aa6873a28ceceaa93b38d14 Mon Sep 17 00:00:00 2001 From: josemigallas Date: Thu, 13 Apr 2023 14:18:01 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20move=20swagger-clie?= =?UTF-8?q?nt=20types=20to=20d.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/ActiveDocs/ThreeScaleApiDocs.ts | 5 ++-- app/javascript/src/Types/SwaggerTypes.ts | 21 --------------- app/javascript/src/Types/swagger.d.ts | 26 ++++++++++++++++++- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts b/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts index fbb374675b..91a76ab718 100644 --- a/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts +++ b/app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts @@ -6,7 +6,8 @@ import { fetchData } from 'utilities/fetchData' import { safeFromJsonString } from 'utilities/json-utils' import { autocompleteRequestInterceptor } from 'ActiveDocs/OAS3Autocomplete' -import type { ApiDocsServices, BackendApiReportBody, BackendApiTransaction, BodyValue, BodyValueObject, FormData, ExecuteData } from 'Types/SwaggerTypes' +import type { ApiDocsServices, BackendApiReportBody, BackendApiTransaction, BodyValue, BodyValueObject, FormData } from 'Types/SwaggerTypes' +import type { ExecuteData } from 'swagger-client/es/execute' import type { SwaggerUIPlugin } from 'swagger-ui' const getApiSpecUrl = (baseUrl: string, specPath: string): string => { @@ -110,7 +111,7 @@ const RequestBodyTransformerPlugin: SwaggerUIPlugin = () => { && req.requestBody) { req.requestBody = transformReportRequestBody(req.requestBody as BackendApiReportBody) } - // eslint-disable-next-line @typescript-eslint/no-unsafe-call + return execute(req) } } diff --git a/app/javascript/src/Types/SwaggerTypes.ts b/app/javascript/src/Types/SwaggerTypes.ts index 025d4494ea..51026c34b0 100644 --- a/app/javascript/src/Types/SwaggerTypes.ts +++ b/app/javascript/src/Types/SwaggerTypes.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import type { Request, Response, SupportedHTTPMethods } from 'swagger-ui' export type AccountData = Record unknown; - method: SupportedHTTPMethods; - operation: unknown; - operationId: string; - parameters: unknown; - pathName: string; - requestBody?: unknown; - requestContentType: string; - requestInterceptor?: ((request: Request) => Promise | Request) | undefined; - responseContentType: string; - responseInterceptor?: ((response: Response) => Promise | Response) | undefined; - scheme: string; - securities: unknown; - server: string; - serverVariables: unknown; - spec: unknown; -} - export interface BackendApiTransaction extends BodyValueObject { app_id?: string; user_key?: string; diff --git a/app/javascript/src/Types/swagger.d.ts b/app/javascript/src/Types/swagger.d.ts index 9d562446fc..94677ce9be 100644 --- a/app/javascript/src/Types/swagger.d.ts +++ b/app/javascript/src/Types/swagger.d.ts @@ -1,3 +1,27 @@ +// There are no official types for swagger-client. These has been inspired by: +// - https://github.com/swagger-api/swagger-js/blob/master/src/execute/index.js declare module 'swagger-client/es/execute' { - export function execute (req: unknown): unknown + import type { Request, Response, SupportedHTTPMethods } from 'swagger-ui' + + export interface ExecuteData { + contextUrl: string; + fetch: (arg: unknown) => unknown; + method: SupportedHTTPMethods; + operation: unknown; + operationId: string; + parameters: unknown; + pathName: string; + requestBody?: unknown; + requestContentType: string; + requestInterceptor?: ((request: Request) => Promise | Request) | undefined; + responseContentType: string; + responseInterceptor?: ((response: Response) => Promise | Response) | undefined; + scheme: string; + securities: unknown; + server: string; + serverVariables: unknown; + spec: unknown; + } + function execute (req: ExecuteData): unknown + export { execute } } From 1f56654606298ac06c1b0e00159a3e7aa74be5e9 Mon Sep 17 00:00:00 2001 From: josemigallas Date: Thu, 13 Apr 2023 14:27:28 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=E2=9C=85=20fixes=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ActiveDocs/ThreeScaleApiDocs.spec.ts | 15 +++++++++------ spec/javascripts/__mocks__/swagger-ui.js | 1 + 2 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 spec/javascripts/__mocks__/swagger-ui.js diff --git a/spec/javascripts/ActiveDocs/ThreeScaleApiDocs.spec.ts b/spec/javascripts/ActiveDocs/ThreeScaleApiDocs.spec.ts index 6f638fb412..2038aaa824 100644 --- a/spec/javascripts/ActiveDocs/ThreeScaleApiDocs.spec.ts +++ b/spec/javascripts/ActiveDocs/ThreeScaleApiDocs.spec.ts @@ -1,6 +1,6 @@ import { objectToFormData, transformReportRequestBody } from 'ActiveDocs/ThreeScaleApiDocs' -import type { BackendApiReportBody, BackendApiTransaction, BodyValue } from 'Types/SwaggerTypes' +import type { BackendApiReportBody, BackendApiTransaction, BodyValue, BodyValueObject } from 'Types/SwaggerTypes' const transaction1: BackendApiTransaction = { app_id: 'app-id1', @@ -75,12 +75,15 @@ describe('objectToFormData', () => { }) it('returns an empty object if argument is not a valid object', () => { - expect(objectToFormData('hello')).toEqual({}) - expect(objectToFormData(true)).toEqual({}) - expect(objectToFormData(123)).toEqual({}) - expect(objectToFormData(['q', 'w', 'r'])).toEqual({}) - }) + const invalidObjects = [ + 'hello', + true, + 123, + ['q', 'w', 'r'] + ] as unknown as BodyValueObject[] + invalidObjects.forEach(i => { expect(objectToFormData(i)).toEqual({}) }) + }) }) describe('transformReportRequestBody', () => { diff --git a/spec/javascripts/__mocks__/swagger-ui.js b/spec/javascripts/__mocks__/swagger-ui.js new file mode 100644 index 0000000000..b4bbacb111 --- /dev/null +++ b/spec/javascripts/__mocks__/swagger-ui.js @@ -0,0 +1 @@ +module.exports = jest.fn()