Skip to content
Open
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
6 changes: 5 additions & 1 deletion app/javascript/packs/active_docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import SwaggerUI from 'swagger-ui'
import 'swagger-ui/dist/swagger-ui.css'

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

Expand All @@ -16,9 +17,12 @@ window.SwaggerUI = (args: SwaggerUI.SwaggerUIOptions, serviceEndpoint: string) =
.then(accountData => {
const requestInterceptor = (request: SwaggerUI.Request) => autocompleteRequestInterceptor(request, accountData, serviceEndpoint)

const plugins = [...(args.plugins ?? []), ClearDefaultValuesPlugin]

return SwaggerUI({
...args,
requestInterceptor
requestInterceptor,
plugins
} as SwaggerUI.SwaggerUIOptions)
})
.catch(error => { console.error(error) })
Expand Down
7 changes: 4 additions & 3 deletions app/javascript/packs/service_active_docs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import SwaggerUI from 'swagger-ui'
import 'swagger-ui/dist/swagger-ui.css'

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

import type { AccountDataResponse } from 'Types/SwaggerTypes'
Expand All @@ -26,9 +27,9 @@ const renderActiveDocs = async () => {

SwaggerUI({
url,
// eslint-disable-next-line @typescript-eslint/naming-convention -- SwaggerUI API
dom_id: `#${containerId}`,
requestInterceptor
domNode: container,
requestInterceptor,
plugins: [ClearDefaultValuesPlugin]
})
}

Expand Down
136 changes: 136 additions & 0 deletions app/javascript/src/ActiveDocs/ClearDefaultValuesPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { SwaggerUIPlugin } from 'swagger-ui'
import type { Component } from 'react'
import type { SwaggerUIContext, JsonSchemaFormProperties } from 'swagger-ui-utils'

// A plain schema property as it appears in the resolved OAS3 spec JSON.
interface SchemaProperty {
type?: string;
example?: unknown;
default?: unknown;
examples?: unknown[];
}

// Primitive types for which swagger-ui auto-generates placeholder values
// ("string", 0, true) when no explicit value exists in the spec.
const PRIMITIVE_TYPES = new Set(['string', 'integer', 'number', 'boolean'])

// The exact placeholder values swagger-ui generates per type, used to detect
// auto-filled fields vs. values the user or spec author actually provided.
const GENERATED_DEFAULTS: Record<string, unknown> = {
string: 'string',
integer: 0,
number: 0,
boolean: true
}

// Returns true when a plain-object schema property has an explicit example or
// default, meaning the generated placeholder is intentional and should not be
// cleared. Used with the resolved spec (plain JS objects).
const hasSchemaExample = (prop: SchemaProperty): boolean =>
prop.example !== undefined || prop.default !== undefined ||
(Array.isArray(prop.examples) && prop.examples.length > 0)

// Same check, but for Immutable.js Map objects. swagger-ui passes schema as
// an Immutable Map to JsonSchemaForm, so ordinary property access won't work.
const hasImmutableSchemaExample = (schema: { get?: (key: string) => unknown } | undefined): boolean => {
if (!schema?.get) return false
return schema.get('example') !== undefined ||
schema.get('default') !== undefined ||
// Immutable Lists have a `size` property; a non-empty examples list suffices.
(schema.get('examples') as { size?: number } | undefined)?.size !== undefined
}

// Replaces swagger-ui's auto-generated placeholder values with empty strings
// for properties that have no explicit example or default in the spec.
// Properties with real spec values, or body keys absent from the schema, are
// left untouched. Does not mutate the input object.
export const clearGeneratedDefaults = (
body: Record<string, unknown>,
properties: Record<string, SchemaProperty>
): Record<string, unknown> => {
const result = { ...body }
for (const [key, prop] of Object.entries(properties)) {
if (!(key in result)) continue
const type = prop.type
if (type && PRIMITIVE_TYPES.has(type) && !hasSchemaExample(prop) && result[key] === GENERATED_DEFAULTS[type]) {
result[key] = ''
}
}
return result
}

// Swagger-ui plugin that prevents auto-generated placeholder values ("string",
// 0, true) from appearing in form fields when no example or default is defined
// in the spec. It combines two targeted overrides:
//
// 1. JsonSchemaForm wrapper — runs once at mount. Sets dispatchInitialValue=false
// for primitive fields without spec examples/defaults, which stops swagger-ui
// from dispatching the generated placeholder into the form state on mount.
// Fields with explicit examples/defaults are left at dispatchInitialValue=true
// so their spec values are dispatched normally.
//
// 2. selectDefaultRequestBodyValue selector wrapper — runs only when the user
// clicks Reset. Post-processes the selector's JSON string result, replacing
// generated placeholders with empty strings using the resolved spec schema.
//
// Only form-encoded content types are processed by the selector wrapper;
// JSON and other types pass through unchanged.
export const ClearDefaultValuesPlugin: SwaggerUIPlugin = () => ({
wrapComponents: {
// eslint-disable-next-line @typescript-eslint/naming-convention
JsonSchemaForm: (Original: Component, { React }: SwaggerUIContext) =>
// eslint-disable-next-line @typescript-eslint/naming-convention
function JsonSchemaFormWrapped (props: JsonSchemaFormProperties) {
const newProps = { ...props }
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
const schema = (props as any).schema as { get?: (key: string) => unknown } | undefined
const type = schema?.get?.('type') as string | undefined
if (type && PRIMITIVE_TYPES.has(type) && !hasImmutableSchemaExample(schema)) {
newProps.dispatchInitialValue = false
}
return React.createElement(Original, newProps)
}
},
statePlugins: {
oas3: {
wrapSelectors: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selectDefaultRequestBodyValue: (oriSelector: (...args: any[]) => unknown, system: any) =>
// The wrapSelectors convention injects state as the first arg; we
// don't need it since the original selector already has state bound.
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
(_state: unknown, path: string, method: string) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const result = oriSelector(path, method) as string | null
if (typeof result !== 'string') return result

// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
const { specSelectors, oas3Selectors } = system.getSystem()
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
const contentType: string | null = oas3Selectors.requestContentType(path, method)
if (!contentType) return result

// Only form-encoded bodies are represented as flat key/value pairs;
// JSON bodies should pass through to preserve their structure.
const isFormEncoded = contentType === 'application/x-www-form-urlencoded' || contentType.startsWith('multipart/')
if (!isFormEncoded) return result

// specResolvedSubtree returns an Immutable Map with $refs already resolved.
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
const requestBody = specSelectors.specResolvedSubtree(['paths', path, method, 'requestBody'])
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
const schema = requestBody?.getIn?.(['content', contentType, 'schema'])?.toJS?.() as Record<string, unknown> | undefined
const properties = schema?.properties as Record<string, SchemaProperty> | undefined
if (!properties) return result

try {
const parsed = JSON.parse(result) as Record<string, unknown>
return JSON.stringify(clearGeneratedDefaults(parsed, properties), null, 2)
} catch {
return result
}
}
}
}
}
})
13 changes: 7 additions & 6 deletions app/javascript/src/ActiveDocs/ThreeScaleApiDocs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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 { ClearDefaultValuesPlugin } from 'ActiveDocs/ClearDefaultValuesPlugin'
import { fetchData } from 'utilities/fetchData'
import { safeFromJsonString } from 'utilities/json-utils'
import { autocompleteRequestInterceptor } from 'ActiveDocs/OAS3Autocomplete'
Expand All @@ -16,12 +17,13 @@ const getApiSpecUrl = (baseUrl: string, specPath: string): string => {
return `${baseUrl.replace(/\/$/, '')}${specPath}`
}

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

container.appendChild(div)
return div
}

/**
Expand Down Expand Up @@ -137,18 +139,17 @@ export const renderSwaggerUI = async (container: HTMLElement, apiDocsPath: strin

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

apiSpecs.apis.forEach( api => {
apiSpecs.apis.forEach(api => {
const domId = api.system_name.replace(/_/g, '-')
const url = getApiSpecUrl(baseUrl, api.path)
appendSwaggerDiv(container, domId)
const div = appendSwaggerDiv(container, domId)
SwaggerUI({
url,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Swagger UI
dom_id: `#${domId}`,
domNode: div,
requestInterceptor: (request) => autocompleteRequestInterceptor(request, accountData, ''),
tryItOutEnabled: true,
plugins: [
RequestBodyTransformerPlugin, UncheckSendEmptyValuePlugin
RequestBodyTransformerPlugin, UncheckSendEmptyValuePlugin, ClearDefaultValuesPlugin
]
})
})
Expand Down
8 changes: 7 additions & 1 deletion app/javascript/src/Types/swagger.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ declare module 'swagger-ui-utils' {
import type { Component } from 'react'

export interface ReactType {
createElement: (Original: Component, props: ParameterIncludeEmptyProperties) => Component;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createElement: (Original: Component, props: any) => Component;
}

export interface SwaggerUIContext {
Expand All @@ -43,4 +44,9 @@ declare module 'swagger-ui-utils' {
defaultValue: boolean;
};
}

export interface JsonSchemaFormProperties {
[key: string]: unknown;
dispatchInitialValue: boolean;
}
}
7 changes: 7 additions & 0 deletions doc/dependency_decisions.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# RH allowed licenses list: https://docs.fedoraproject.org/en-US/legal/allowed-licenses/
---
- - :permit
- MIT
Expand Down Expand Up @@ -243,3 +244,9 @@
:why: Public domain license
:versions: []
:when: 2024-11-12 19:29:30.296279869 Z
- - :permit
- BlueOak-1.0.0
- :who: Joan Lledó
:why: Approved by OSI and Fedora
:versions: []
:when: 2026-04-29 07:57:00.547859043 Z
34 changes: 34 additions & 0 deletions features/api/services/api_docs/default_values.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
@javascript
Feature: Product > ActiveDocs default form field values

The ClearDefaultValuesPlugin should prevent swagger-ui from auto-filling
form fields with generated values like "string" or 0, while preserving
explicit examples and defaults defined in the spec.

Background:
Given a provider is logged in
And a product
And the product has an OAS 3.0 spec "User API" from fixture "user-api"


Scenario: Initial form field values respect spec defaults
When they go to the spec's preview page from Product context
And they press "POST"
And they press "Try it out"
Then the request body field "name" should have value "Jane Doe"
And the request body field "age" should have value "30"
And the request body field "email" should have value ""
And the request body field "score" should have value ""
And the request body field "active" should have value ""

Scenario: Reset restores spec defaults and clears generated values
When they go to the spec's preview page from Product context
And they press "POST"
And they press "Try it out"
And they fill in "email" with "test@example.com"
And they press "Reset"
Then the request body field "name" should have value "Jane Doe"
And the request body field "age" should have value "30"
And the request body field "email" should have value ""
And the request body field "score" should have value ""
And the request body field "active" should have value ""
18 changes: 18 additions & 0 deletions features/step_definitions/api/services/api_docs_steps.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@
body: spec_body_builder(version))
end

Given "the product has a(n) {spec_version} spec {string} from fixture {string}" do |version, name, fixture|
@api_docs_service = FactoryBot.create(:api_docs_service, account: @product.provider,
name: name,
service: @product,
published: true,
body: spec_body_builder(version, spec_name: fixture))
end

Given "{product} has no specs" do |product|
product.api_docs_services.delete_all
end
Expand All @@ -38,6 +46,16 @@
assert curl_commmand.has_text?(swagger_version[:version] == '1.2' ? 'Authorization: Oauth:\"test\"' : 'Authorization: Oauth:"test"')
end

Then "the request body field {string} should have value {string}" do |field_name, expected_value|
row = find("tr.parameters[data-property-name='#{field_name}']")
field = if row.has_css?('input[type="text"]', wait: 0)
row.find('input[type="text"]')
else
row.find('select')
end
assert_equal expected_value, field.value
end

When "the ActiveDocs form is submitted with:" do |table|
if (api_json_spec = table.rows_hash.delete('API JSON Spec'))
swagger_version = transform_swagger_version(api_json_spec)
Expand Down
4 changes: 2 additions & 2 deletions features/support/helpers/api_docs_service_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

module ApiDocsServiceHelper

def spec_body_builder(swagger_version)
spec_name = swagger_version[:invalid] ? 'invalid' : 'echo-api'
def spec_body_builder(swagger_version, spec_name: nil)
spec_name ||= swagger_version[:invalid] ? 'invalid' : 'echo-api'
file_fixture("swagger/#{spec_name}-#{swagger_version[:version]}.json").read
end

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"@types/react-redux": "^7.1.34",
"@types/redux-api-middleware": "^3.2.3",
"@types/redux-immutable-state-invariant": "^2.1.2",
"@types/swagger-ui": "^3.52.0",
"@types/swagger-ui": "^5.21.1",
"@types/virtual-dom": "^2.1.1",
"bootstrap-sass": "3.4.3",
"braintree-web": "3.102.0",
Expand Down Expand Up @@ -105,7 +105,7 @@
"sass-loader": "13.3.3",
"showdown": "2.1.0",
"swagger-client": "^3.36.1",
"swagger-ui": "~5.12.3",
"swagger-ui": "5.27.1",
"validate.js": "^0.13.1",
"virtual-dom": "^2.1.1",
"webpack-assets-manifest": "5.2.1",
Expand Down
Loading