Skip to content

Commit

Permalink
THREESCALE-3927 Update Swagger to Open API 3.0 (#3103)
Browse files Browse the repository at this point in the history
Co-authored-by: Joan Lledó <jlledo@redhat.com>
Co-authored-by: Daria Mayorova <mayorova@users.noreply.github.com>
Co-authored-by: josemigallas <josemigallas@gmail.com>
  • Loading branch information
4 people authored Apr 27, 2023
1 parent b9471ca commit a115df9
Show file tree
Hide file tree
Showing 33 changed files with 14,874 additions and 12,893 deletions.
15 changes: 6 additions & 9 deletions app/controllers/api_docs/services_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def initialize(name, system_name)

def json
parsed_content = JSON.parse(file_content)
parsed_content['basePath'] = backend_base_host if backend_api?
parsed_content['servers'] = [{ 'url' => backend_base_host }] if backend_api?

parsed_content
end
Expand All @@ -54,9 +54,9 @@ def file_path

def file_name
if onpremises_version? && onpremises_version_preferred?
"#{name} (on-premises)"
"#{system_name}_on_premises"
else
name
system_name
end
end

Expand Down Expand Up @@ -115,18 +115,15 @@ def index
def show
system_name = params[:id].to_sym
api_file = (api_files.fetch(system_name) { raise ActiveRecord::RecordNotFound }).dup
api_file['apis'] = exclude_forbidden_endpoints(api_file['apis'])
api_file['paths'] = exclude_forbidden_endpoints(api_file['paths']) if master_on_premises?

render json: api_file
end

private

def exclude_forbidden_endpoints(apis)
apis.select do |api|
path = api['path']
!master_on_premises? || path.exclude?('plan')
end
def exclude_forbidden_endpoints(paths)
paths.select { |url| url.exclude?('plan') }
end

def allowed_api?(api)
Expand Down
19 changes: 13 additions & 6 deletions app/javascript/packs/active_docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,24 @@
import SwaggerUI from 'swagger-ui'
import 'swagger-ui/dist/swagger-ui.css'

import { autocompleteInterceptor } from 'ActiveDocs/OAS3Autocomplete'
import { autocompleteRequestInterceptor } from 'ActiveDocs/OAS3Autocomplete'
import { fetchData } from 'utilities/fetchData'

import type { AccountDataResponse } from 'Types/SwaggerTypes'

import 'ActiveDocs/swagger-ui-3-patch.scss'

const accountDataUrl = '/api_docs/account_data.json'

window.SwaggerUI = (args: SwaggerUI.SwaggerUIOptions, serviceEndpoint: string) => {
const responseInterceptor = (response: SwaggerUI.Response) => autocompleteInterceptor(response, accountDataUrl, serviceEndpoint, args.url)
fetchData<AccountDataResponse>(accountDataUrl)
.then(accountData => {
const requestInterceptor = (request: SwaggerUI.Request) => autocompleteRequestInterceptor(request, accountData, serviceEndpoint)

SwaggerUI({
...args,
responseInterceptor
} as SwaggerUI.SwaggerUIOptions)
return SwaggerUI({
...args,
requestInterceptor
} as SwaggerUI.SwaggerUIOptions)
})
.catch(error => { console.error(error) })
}
24 changes: 24 additions & 0 deletions app/javascript/packs/provider_active_docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// We can define the 3scale plugins here and export the modified bundle
import 'swagger-ui/dist/swagger-ui.css'

import { renderSwaggerUI } from 'ActiveDocs/ThreeScaleApiDocs'

import 'ActiveDocs/swagger-ui-3-provider-patch.scss'

const renderActiveDocs = async () => {
const containerId = 'api-containers'
const container = document.getElementById(containerId)

if (!container) {
console.error(`The target element with ID ${containerId} was not found`)
return
}

const { baseUrl = '', apiDocsPath = '', apiDocsAccountDataPath = '' } = container.dataset

await renderSwaggerUI(container, apiDocsPath, baseUrl, apiDocsAccountDataPath)
}

document.addEventListener('DOMContentLoaded', () => {
renderActiveDocs().catch(error => { console.error(error) })
})
20 changes: 15 additions & 5 deletions app/javascript/packs/service_active_docs.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,37 @@
import SwaggerUI from 'swagger-ui'
import 'swagger-ui/dist/swagger-ui.css'

import { autocompleteInterceptor } from 'ActiveDocs/OAS3Autocomplete'
import { autocompleteRequestInterceptor } from 'ActiveDocs/OAS3Autocomplete'

import type { AccountDataResponse } from 'Types/SwaggerTypes'

import 'ActiveDocs/swagger-ui-3-provider-patch.scss'
import { fetchData } from '../src/utilities/fetchData'

document.addEventListener('DOMContentLoaded', () => {
const renderActiveDocs = async () => {
const containerId = 'swagger-ui-container'
const DATA_URL = 'p/admin/api_docs/account_data.json'
const container = document.getElementById(containerId)

if (!container) {
throw new Error('The target ID was not found: ' + containerId)
console.error(`Element with ID ${containerId} not found`)
return
}
const { url = '', baseUrl = '', serviceEndpoint = '' } = container.dataset
const accountDataUrl = `${baseUrl}${DATA_URL}`

const responseInterceptor: SwaggerUI.SwaggerUIOptions['responseInterceptor'] = (response) => autocompleteInterceptor(response, accountDataUrl, serviceEndpoint, url)
const accountData: AccountDataResponse = await fetchData<AccountDataResponse>(accountDataUrl)

const requestInterceptor: SwaggerUI.SwaggerUIOptions['requestInterceptor'] = (request) => autocompleteRequestInterceptor(request, accountData, serviceEndpoint)

SwaggerUI({
url,
// eslint-disable-next-line @typescript-eslint/naming-convention -- SwaggerUI API
dom_id: `#${containerId}`,
responseInterceptor
requestInterceptor
})
}

document.addEventListener('DOMContentLoaded', () => {
renderActiveDocs().catch(error => { console.error(error) })
})
50 changes: 30 additions & 20 deletions app/javascript/src/ActiveDocs/OAS3Autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,29 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* TODO: this module needs to be properly typed !!! */

import { fetchData } from 'utilities/fetchData'

import type { Response as SwaggerUIResponse } from 'swagger-ui'
import type { AccountData } from 'Types/SwaggerTypes'
import type { Request as SwaggerUIRequest, Response as SwaggerUIResponse } from 'swagger-ui'
import type { AccountData, AccountDataResponse } from 'Types/SwaggerTypes'

const X_DATA_ATTRIBUTE = 'x-data-threescale-name'

const X_DATA_PARAMS_DESCRIPTIONS = {
user_keys: 'First user key from latest 5 applications',
metric_names: 'Latest 5 metrics',
metric_ids: 'Latest 5 metrics',
app_keys: 'First application key from the latest five applications',
app_ids: 'Latest 5 applications (across all accounts and services)',
app_keys: 'First application key from the latest five applications'
application_ids: 'Latest 5 applications',
user_keys: 'First user key from latest 5 applications',
account_ids: 'Latest 5 accounts',
access_token: 'Access Token',
user_ids: 'First user (admin) of the latest 5 account',
service_ids: 'Service IDs',
service_tokens: 'Service tokens',
admin_ids: 'Latest 5 users (admin) from your account',
service_plan_ids: 'Latest 5 service plans',
application_plan_ids: 'Latest 5 application plans',
account_plan_ids: 'Latest 5 account plans',
client_ids: 'Client IDs from the latest five applications',
client_secrets: 'Client secrets from the latest five applications'
} as const

const addAutocompleteToParam = (param: any, accountData: AccountData): any => {
Expand All @@ -34,7 +46,7 @@ const addAutocompleteToParam = (param: any, accountData: AccountData): any => {
? {
...param,
examples: autocompleteData.reduce<{ summary: string; value: string }[]>((examples, item) => (
[...examples, { summary: item.name, value: item.value }]
[...examples, { summary: item.value ? `${item.name} - ${item.value}` : item.name, value: item.value }]
), [{ summary: X_DATA_PARAMS_DESCRIPTIONS[xDataKey], value: '-' }])
}
: param
Expand Down Expand Up @@ -100,14 +112,13 @@ export interface Response extends SwaggerUIResponse {
text: string;
}

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

let body = undefined
try {
body = data.results
? injectAutocompleteToResponseBody(bodyWithServer, data.results)
body = accountData.results
? injectAutocompleteToResponseBody(bodyWithServer, accountData.results)
: bodyWithServer
} catch (error: unknown) {
console.error(error)
Expand All @@ -123,17 +134,16 @@ const autocompleteOAS3 = async (response: SwaggerUIResponse, accountDataUrl: str
}

/**
* Intercept and process the response made by Swagger UI
* Apply transformations (inject servers list and autocomplete data) to the response for OpenAPI spec requests, and
* Intercept the request, and if it fetches the OpenAPI specification, apply a custom responseInterceptor that applies
* transformations (inject servers list and autocomplete data) to the response
* keep the responses to the actual API calls (made through 'Try it out') untouched
* @param response response to the request made through Swagger UI
* @param specUrl URL of the OpenAPI specification
* @param accountDataUrl URL of the data for autocompletion
* @param request request made through Swagger UI
* @param accountData data for autocompletion
* @param serviceEndpoint Public Base URL of the gateway, that will replace the URL in the "servers" object
*/
export const autocompleteInterceptor = (response: SwaggerUIResponse, accountDataUrl: string, serviceEndpoint: string, specUrl?: string): Promise<Response> | SwaggerUIResponse => {
if (!response.url.includes(specUrl)) {
return response
export const autocompleteRequestInterceptor = (request: SwaggerUIRequest, accountData: AccountDataResponse, serviceEndpoint: string): Promise<SwaggerUIRequest> | SwaggerUIRequest => {
if (request.loadSpec) {
request.responseInterceptor = (response: SwaggerUIResponse) => autocompleteOAS3(response, accountData, serviceEndpoint)
}
return autocompleteOAS3(response, accountDataUrl, serviceEndpoint)
return request
}
141 changes: 141 additions & 0 deletions app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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 { safeFromJsonString } from 'utilities/json-utils'
import { autocompleteRequestInterceptor } from 'ActiveDocs/OAS3Autocomplete'

import type { AccountDataResponse, 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}`
}

const appendSwaggerDiv = (container: HTMLElement, id: string): void => {
const div = document.createElement('div')
div.setAttribute('class', 'api-docs-wrap')
div.setAttribute('id', id)

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
* @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 renderSwaggerUI = async (container: HTMLElement, apiDocsPath: string, baseUrl: string, accountDataUrl: string): Promise<void> => {
const apiSpecs: ApiDocsServices = await fetchData<ApiDocsServices>(apiDocsPath)

const accountData: AccountDataResponse = await fetchData<AccountDataResponse>(accountDataUrl)

apiSpecs.apis.forEach( api => {
const domId = api.system_name.replace(/_/g, '-')
const url = getApiSpecUrl(baseUrl, api.path)
appendSwaggerDiv(container, domId)
SwaggerUI({
url,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Swagger UI
dom_id: `#${domId}`,
requestInterceptor: (request) => autocompleteRequestInterceptor(request, accountData, ''),
tryItOutEnabled: true,
plugins: [
RequestBodyTransformerPlugin
]
})
})
}
Loading

0 comments on commit a115df9

Please sign in to comment.