Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Serialize Service Mgmt API's report endpoint body correctly #3294

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/javascript/src/ActiveDocs/OAS3Autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export interface Response extends SwaggerUIResponse {
text: string;
}

const autocompleteOAS3 = async (response: SwaggerUIResponse, accountDataUrl: string, serviceEndpoint: string): Promise<Response> => {
export const autocompleteOAS3 = async (response: SwaggerUIResponse, accountDataUrl: string, serviceEndpoint: string): Promise<Response> => {
const bodyWithServer = injectServerToResponseBody(response.body, serviceEndpoint)
const data = await fetchData<{ results: AccountData }>(accountDataUrl)

Expand Down
108 changes: 106 additions & 2 deletions app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
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'
josemigallas marked this conversation as resolved.
Show resolved Hide resolved

import { fetchData } from 'utilities/fetchData'
import { safeFromJsonString } from 'utilities/json-utils'
import { autocompleteRequestInterceptor } from 'ActiveDocs/OAS3Autocomplete'

import type { ApiDocsServices } 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 => {
return `${baseUrl.replace(/\/$/, '')}${specPath}`
Expand All @@ -17,6 +22,102 @@ 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
* Returns an empty object if the argument is not an object
* 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 whitespace'
* }
* @param object
*/
export const objectToFormData = (object: BodyValueObject): FormData => {
if (typeof object !== 'object' || Array.isArray(object)) {
return {}
}
const formData: 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
jlledom marked this conversation as resolved.
Show resolved Hide resolved
* @param body BackendApiReportBody
*/
export const transformReportRequestBody = (body: BackendApiReportBody): FormData => {
if (Array.isArray(body.transactions)) {
body.transactions = body.transactions.reduce((acc: BackendApiTransaction[], transaction) => {
let value = undefined
if (typeof transaction === 'object') {
value = transaction
} else {
value = safeFromJsonString<BackendApiTransaction>(transaction)
}
if (value) {
acc.push(value)
}
return acc
}, [])
}
return objectToFormData(body)
}

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)
}

return execute(req)
}
}
}
}

export const renderApiDocs = async (container: HTMLElement, apiDocsPath: string, baseUrl: string, apiDocsAccountDataPath: string): Promise<void> => {
const apiSpecs: ApiDocsServices = await fetchData<ApiDocsServices>(apiDocsPath)
apiSpecs.apis.forEach( api => {
Expand All @@ -28,7 +129,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
]
})
})
}
27 changes: 27 additions & 0 deletions app/javascript/src/Types/SwaggerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,30 @@ export interface ApiDocsServices {
host: string;
apis: ApiDocsService[];
}

export interface BackendApiTransaction extends BodyValueObject {
app_id?: string;
user_key?: string;
timestamp?: string;
usage: Record<string, number>;
log?: {
request?: string;
response?: string;
code?: string;
};
}

export interface BackendApiReportBody extends BodyValueObject {
service_token?: string;
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<string, BodyValue>

export type FormData = Record<string, boolean | number | string>
27 changes: 27 additions & 0 deletions app/javascript/src/Types/swagger.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +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' {
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> | Request) | undefined;
responseContentType: string;
responseInterceptor?: ((response: Response) => Promise<Response> | Response) | undefined;
scheme: string;
securities: unknown;
server: string;
serverVariables: unknown;
spec: unknown;
}
function execute (req: ExecuteData): unknown
export { execute }
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
josemigallas marked this conversation as resolved.
Show resolved Hide resolved
"validate.js": "^0.13.1",
"virtual-dom": "^2.1.1"
}
Expand Down
57 changes: 33 additions & 24 deletions spec/javascripts/ActiveDocs/OAS3Autocomplete.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
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'
const apiUrl = 'https://some.api.domain/foo/bar/api-url'
const accountDataUrl = 'foo/bar'
const serviceEndpoint = 'foo/bar/serviceEndpoint'
Expand Down Expand Up @@ -67,25 +66,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

Expand All @@ -98,10 +86,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)
expect(res.body.servers).toBe(undefined)
})
})
})
Loading