mirror of
https://github.com/Redocly/redoc.git
synced 2024-11-26 02:23:43 +03:00
fix: serialize parameter example values according to the spec (#917)
This commit is contained in:
parent
888f04e872
commit
39392869f4
|
@ -154,7 +154,8 @@
|
|||
"slugify": "^1.3.4",
|
||||
"stickyfill": "^1.1.1",
|
||||
"swagger2openapi": "^5.2.3",
|
||||
"tslib": "^1.9.3"
|
||||
"tslib": "^1.9.3",
|
||||
"uri-template-lite": "^19.4.0"
|
||||
},
|
||||
"bundlesize": [
|
||||
{
|
||||
|
|
|
@ -4,6 +4,7 @@ import { ExampleValue, FieldLabel } from '../../common-elements/fields';
|
|||
export interface FieldDetailProps {
|
||||
value?: any;
|
||||
label: string;
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
export class FieldDetail extends React.PureComponent<FieldDetailProps> {
|
||||
|
@ -11,11 +12,14 @@ export class FieldDetail extends React.PureComponent<FieldDetailProps> {
|
|||
if (this.props.value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = this.props.raw ? this.props.value : JSON.stringify(this.props.value);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel> {this.props.label} </FieldLabel>{' '}
|
||||
<ExampleValue>
|
||||
{JSON.stringify(this.props.value)}
|
||||
{value}
|
||||
</ExampleValue>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
TypePrefix,
|
||||
TypeTitle,
|
||||
} from '../../common-elements/fields';
|
||||
import { serializeParameterValue } from '../../utils/openapi';
|
||||
import { ExternalDocumentation } from '../ExternalDocumentation/ExternalDocumentation';
|
||||
import { Markdown } from '../Markdown/Markdown';
|
||||
import { EnumValues } from './EnumValues';
|
||||
|
@ -27,6 +28,18 @@ export class FieldDetails extends React.PureComponent<FieldProps> {
|
|||
|
||||
const { schema, description, example, deprecated } = field;
|
||||
|
||||
let exampleField: JSX.Element | null = null;
|
||||
|
||||
if (showExamples) {
|
||||
const label = l('example') + ':';
|
||||
if (field.in && field.style) {
|
||||
const serializedValue = serializeParameterValue(field, example);
|
||||
exampleField = <FieldDetail label={label} value={serializedValue} raw={true} />;
|
||||
} else {
|
||||
exampleField = <FieldDetail label={label} value={example} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
|
@ -53,7 +66,7 @@ export class FieldDetails extends React.PureComponent<FieldProps> {
|
|||
)}
|
||||
<FieldDetail label={l('default') + ':'} value={schema.default} />
|
||||
{!renderDiscriminatorSwitch && <EnumValues type={schema.type} values={schema.enum} />}{' '}
|
||||
{showExamples && <FieldDetail label={l('example') + ':'} value={example} />}
|
||||
{exampleField}
|
||||
{<Extensions extensions={{ ...field.extensions, ...schema.extensions }} />}
|
||||
<div>
|
||||
<Markdown compact={true} source={description} />
|
||||
|
|
|
@ -10,6 +10,7 @@ exports[`Components SchemaView discriminator should correctly render discriminat
|
|||
"description": "",
|
||||
"example": undefined,
|
||||
"expanded": false,
|
||||
"explode": false,
|
||||
"in": undefined,
|
||||
"kind": "field",
|
||||
"name": "packSize",
|
||||
|
@ -59,6 +60,7 @@ exports[`Components SchemaView discriminator should correctly render discriminat
|
|||
"description": "",
|
||||
"example": undefined,
|
||||
"expanded": false,
|
||||
"explode": false,
|
||||
"in": undefined,
|
||||
"kind": "field",
|
||||
"name": "type",
|
||||
|
|
|
@ -10,6 +10,13 @@
|
|||
"in": "path",
|
||||
"name": "test_name",
|
||||
"schema": { "type": "string" }
|
||||
},
|
||||
"serializationParam": {
|
||||
"in": "query",
|
||||
"name": "serialization_test_name",
|
||||
"schema": { "type": "array" },
|
||||
"style": "form",
|
||||
"explode": true
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
|
|
|
@ -26,6 +26,23 @@ describe('Models', () => {
|
|||
expect(field.schema.type).toEqual('string');
|
||||
});
|
||||
|
||||
test('field details relevant for parameter serialization', () => {
|
||||
const field = new FieldModel(
|
||||
parser,
|
||||
{
|
||||
$ref: '#/components/parameters/serializationParam',
|
||||
},
|
||||
'#/components/parameters/serializationParam',
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(field.name).toEqual('serialization_test_name');
|
||||
expect(field.in).toEqual('query');
|
||||
expect(field.schema.type).toEqual('array');
|
||||
expect(field.style).toEqual('form');
|
||||
expect(field.explode).toEqual(true);
|
||||
});
|
||||
|
||||
test('field name should populated from name even if $ref (headers)', () => {
|
||||
const field = new FieldModel(
|
||||
parser,
|
||||
|
|
|
@ -1,12 +1,30 @@
|
|||
import { action, observable } from 'mobx';
|
||||
|
||||
import { OpenAPIParameter, Referenced } from '../../types';
|
||||
import {
|
||||
OpenAPIParameter,
|
||||
OpenAPIParameterLocation,
|
||||
OpenAPIParameterStyle,
|
||||
Referenced,
|
||||
} from '../../types';
|
||||
import { RedocNormalizedOptions } from '../RedocNormalizedOptions';
|
||||
|
||||
import { extractExtensions } from '../../utils/openapi';
|
||||
import { OpenAPIParser } from '../OpenAPIParser';
|
||||
import { SchemaModel } from './Schema';
|
||||
|
||||
function getDefaultStyleValue(parameterLocation: OpenAPIParameterLocation): OpenAPIParameterStyle {
|
||||
switch (parameterLocation) {
|
||||
case 'header':
|
||||
return 'simple';
|
||||
case 'query':
|
||||
return 'form';
|
||||
case 'path':
|
||||
return 'simple';
|
||||
default:
|
||||
return 'form';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Field or Parameter model ready to be used by components
|
||||
*/
|
||||
|
@ -20,9 +38,11 @@ export class FieldModel {
|
|||
description: string;
|
||||
example?: string;
|
||||
deprecated: boolean;
|
||||
in?: string;
|
||||
in?: OpenAPIParameterLocation;
|
||||
kind: string;
|
||||
extensions?: Dict<any>;
|
||||
explode: boolean;
|
||||
style?: OpenAPIParameterStyle;
|
||||
|
||||
constructor(
|
||||
parser: OpenAPIParser,
|
||||
|
@ -40,6 +60,14 @@ export class FieldModel {
|
|||
info.description === undefined ? this.schema.description || '' : info.description;
|
||||
this.example = info.example || this.schema.example;
|
||||
|
||||
if (info.style) {
|
||||
this.style = info.style;
|
||||
} else if (this.in) {
|
||||
this.style = getDefaultStyleValue(this.in);
|
||||
}
|
||||
|
||||
this.explode = !!info.explode;
|
||||
|
||||
this.deprecated = info.deprecated === undefined ? !!this.schema.deprecated : info.deprecated;
|
||||
parser.exitRef(infoOrRef);
|
||||
|
||||
|
|
|
@ -8,10 +8,11 @@ import {
|
|||
mergeParams,
|
||||
normalizeServers,
|
||||
pluralizeType,
|
||||
serializeParameterValue,
|
||||
} from '../';
|
||||
|
||||
import { OpenAPIParser } from '../../services';
|
||||
import { OpenAPIParameter } from '../../types';
|
||||
import { OpenAPIParameter, OpenAPIParameterLocation, OpenAPIParameterStyle } from '../../types';
|
||||
|
||||
describe('Utils', () => {
|
||||
describe('openapi getStatusCode', () => {
|
||||
|
@ -377,4 +378,190 @@ describe('Utils', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openapi serializeParameter', () => {
|
||||
interface TestCase {
|
||||
style: OpenAPIParameterStyle;
|
||||
explode: boolean;
|
||||
expected: string;
|
||||
}
|
||||
interface TestValueTypeGroup {
|
||||
value: any;
|
||||
description: string;
|
||||
cases: TestCase[];
|
||||
}
|
||||
interface TestLocationGroup {
|
||||
location: OpenAPIParameterLocation;
|
||||
name: string;
|
||||
description: string;
|
||||
cases: TestValueTypeGroup[];
|
||||
}
|
||||
const testCases: TestLocationGroup[] = [
|
||||
{
|
||||
location: 'path',
|
||||
name: 'id',
|
||||
description: 'path parameters',
|
||||
cases: [
|
||||
{
|
||||
value: 5,
|
||||
description: 'primitive values',
|
||||
cases: [
|
||||
{ style: 'simple', explode: false, expected: '5' },
|
||||
{ style: 'simple', explode: true, expected: '5' },
|
||||
{ style: 'label', explode: false, expected: '.5' },
|
||||
{ style: 'label', explode: true, expected: '.5' },
|
||||
{ style: 'matrix', explode: false, expected: ';id=5' },
|
||||
{ style: 'matrix', explode: true, expected: ';id=5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: [3, 4, 5],
|
||||
description: 'array values',
|
||||
cases: [
|
||||
{ style: 'simple', explode: false, expected: '3,4,5' },
|
||||
{ style: 'simple', explode: true, expected: '3,4,5' },
|
||||
{ style: 'label', explode: false, expected: '.3,4,5' },
|
||||
{ style: 'label', explode: true, expected: '.3.4.5' },
|
||||
{ style: 'matrix', explode: false, expected: ';id=3,4,5' },
|
||||
{ style: 'matrix', explode: true, expected: ';id=3;id=4;id=5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: { role: 'admin', firstName: 'Alex' },
|
||||
description: 'object values',
|
||||
cases: [
|
||||
{ style: 'simple', explode: false, expected: 'role,admin,firstName,Alex' },
|
||||
{ style: 'simple', explode: true, expected: 'role=admin,firstName=Alex' },
|
||||
{ style: 'label', explode: false, expected: '.role,admin,firstName,Alex' },
|
||||
{ style: 'label', explode: true, expected: '.role=admin,firstName=Alex' },
|
||||
{ style: 'matrix', explode: false, expected: ';id=role,admin,firstName,Alex' },
|
||||
{ style: 'matrix', explode: true, expected: ';role=admin;firstName=Alex' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
location: 'query',
|
||||
name: 'id',
|
||||
description: 'query parameters',
|
||||
cases: [
|
||||
{
|
||||
value: 5,
|
||||
description: 'primitive values',
|
||||
cases: [
|
||||
{ style: 'form', explode: true, expected: 'id=5' },
|
||||
{ style: 'form', explode: false, expected: 'id=5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: [3, 4, 5],
|
||||
description: 'array values',
|
||||
cases: [
|
||||
{ style: 'form', explode: true, expected: 'id=3&id=4&id=5' },
|
||||
{ style: 'form', explode: false, expected: 'id=3,4,5' },
|
||||
{ style: 'spaceDelimited', explode: true, expected: 'id=3&id=4&id=5' },
|
||||
{ style: 'spaceDelimited', explode: false, expected: 'id=3%204%205' },
|
||||
{ style: 'pipeDelimited', explode: true, expected: 'id=3&id=4&id=5' },
|
||||
{ style: 'pipeDelimited', explode: false, expected: 'id=3|4|5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: { role: 'admin', firstName: 'Alex' },
|
||||
description: 'object values',
|
||||
cases: [
|
||||
{ style: 'form', explode: true, expected: 'role=admin&firstName=Alex' },
|
||||
{ style: 'form', explode: false, expected: 'id=role,admin,firstName,Alex' },
|
||||
{ style: 'deepObject', explode: true, expected: 'id[role]=admin&id[firstName]=Alex' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
location: 'cookie',
|
||||
name: 'id',
|
||||
description: 'cookie parameters',
|
||||
cases: [
|
||||
{
|
||||
value: 5,
|
||||
description: 'primitive values',
|
||||
cases: [
|
||||
{ style: 'form', explode: true, expected: 'id=5' },
|
||||
{ style: 'form', explode: false, expected: 'id=5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: [3, 4, 5],
|
||||
description: 'array values',
|
||||
cases: [
|
||||
{ style: 'form', explode: true, expected: 'id=3&id=4&id=5' },
|
||||
{ style: 'form', explode: false, expected: 'id=3,4,5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: { role: 'admin', firstName: 'Alex' },
|
||||
description: 'object values',
|
||||
cases: [
|
||||
{ style: 'form', explode: true, expected: 'role=admin&firstName=Alex' },
|
||||
{ style: 'form', explode: false, expected: 'id=role,admin,firstName,Alex' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
location: 'header',
|
||||
name: 'id',
|
||||
description: 'header parameters',
|
||||
cases: [
|
||||
{
|
||||
value: 5,
|
||||
description: 'primitive values',
|
||||
cases: [
|
||||
{ style: 'simple', explode: false, expected: '5' },
|
||||
{ style: 'simple', explode: true, expected: '5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: [3, 4, 5],
|
||||
description: 'array values',
|
||||
cases: [
|
||||
{ style: 'simple', explode: false, expected: '3,4,5' },
|
||||
{ style: 'simple', explode: true, expected: '3,4,5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: { role: 'admin', firstName: 'Alex' },
|
||||
description: 'object values',
|
||||
cases: [
|
||||
{ style: 'simple', explode: false, expected: 'role,admin,firstName,Alex' },
|
||||
{ style: 'simple', explode: true, expected: 'role=admin,firstName=Alex' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(locationTestGroup => {
|
||||
describe(locationTestGroup.description, () => {
|
||||
locationTestGroup.cases.forEach(valueTypeTestGroup => {
|
||||
describe(valueTypeTestGroup.description, () => {
|
||||
valueTypeTestGroup.cases.forEach(testCase => {
|
||||
it(`should serialize correctly when style is ${testCase.style} and explode is ${
|
||||
testCase.explode
|
||||
}`, () => {
|
||||
const parameter: OpenAPIParameter = {
|
||||
name: locationTestGroup.name,
|
||||
in: locationTestGroup.location,
|
||||
style: testCase.style,
|
||||
explode: testCase.explode,
|
||||
};
|
||||
const serialized = serializeParameterValue(parameter, valueTypeTestGroup.value);
|
||||
|
||||
expect(serialized).toEqual(testCase.expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { dirname } from 'path';
|
||||
import { URI } from 'uri-template-lite';
|
||||
|
||||
import { OpenAPIParser } from '../services/OpenAPIParser';
|
||||
import {
|
||||
|
@ -6,6 +7,7 @@ import {
|
|||
OpenAPIMediaType,
|
||||
OpenAPIOperation,
|
||||
OpenAPIParameter,
|
||||
OpenAPIParameterStyle,
|
||||
OpenAPISchema,
|
||||
OpenAPIServer,
|
||||
Referenced,
|
||||
|
@ -135,36 +137,6 @@ export function isFormUrlEncoded(contentType: string): boolean {
|
|||
return contentType === 'application/x-www-form-urlencoded';
|
||||
}
|
||||
|
||||
function formEncodeField(fieldVal: any, fieldName: string, explode: boolean): string {
|
||||
if (!fieldVal || !fieldVal.length) {
|
||||
return fieldName + '=';
|
||||
}
|
||||
|
||||
if (Array.isArray(fieldVal)) {
|
||||
if (explode) {
|
||||
return fieldVal.map(val => `${fieldName}=${val}`).join('&');
|
||||
} else {
|
||||
return fieldName + '=' + fieldVal.map(val => val.toString()).join(',');
|
||||
}
|
||||
} else if (typeof fieldVal === 'object') {
|
||||
if (explode) {
|
||||
return Object.keys(fieldVal)
|
||||
.map(k => `${k}=${fieldVal[k]}`)
|
||||
.join('&');
|
||||
} else {
|
||||
return (
|
||||
fieldName +
|
||||
'=' +
|
||||
Object.keys(fieldVal)
|
||||
.map(k => `${k},${fieldVal[k]}`)
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return fieldName + '=' + fieldVal.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function delimitedEncodeField(fieldVal: any, fieldName: string, delimeter: string): string {
|
||||
if (Array.isArray(fieldVal)) {
|
||||
return fieldVal.map(v => v.toString()).join(delimeter);
|
||||
|
@ -191,6 +163,13 @@ function deepObjectEncodeField(fieldVal: any, fieldName: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
function serializeFormValue(name: string, explode: boolean, value: any) {
|
||||
const suffix = explode ? '*' : '';
|
||||
const template = new URI.Template(`{?${name}${suffix}}`);
|
||||
|
||||
return template.expand({ [name]: value }).substring(1);
|
||||
}
|
||||
|
||||
/*
|
||||
* Should be used only for url-form-encoded body payloads
|
||||
* To be used for parmaters should be extended with other style values
|
||||
|
@ -208,7 +187,7 @@ export function urlFormEncodePayload(
|
|||
const { style = 'form', explode = true } = encoding[fieldName] || {};
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return formEncodeField(fieldVal, fieldName, explode);
|
||||
return serializeFormValue(fieldName, explode, fieldVal);
|
||||
break;
|
||||
case 'spaceDelimited':
|
||||
return delimitedEncodeField(fieldVal, fieldName, '%20');
|
||||
|
@ -226,6 +205,124 @@ export function urlFormEncodePayload(
|
|||
}
|
||||
}
|
||||
|
||||
function serializePathParameter(
|
||||
name: string,
|
||||
style: OpenAPIParameterStyle,
|
||||
explode: boolean,
|
||||
value: any,
|
||||
): string {
|
||||
const suffix = explode ? '*' : '';
|
||||
let prefix = '';
|
||||
|
||||
if (style === 'label') {
|
||||
prefix = '.';
|
||||
} else if (style === 'matrix') {
|
||||
prefix = ';';
|
||||
}
|
||||
|
||||
const template = new URI.Template(`{${prefix}${name}${suffix}}`);
|
||||
|
||||
return template.expand({ [name]: value });
|
||||
}
|
||||
|
||||
function serializeQueryParameter(
|
||||
name: string,
|
||||
style: OpenAPIParameterStyle,
|
||||
explode: boolean,
|
||||
value: any,
|
||||
): string {
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return serializeFormValue(name, explode, value);
|
||||
case 'spaceDelimited':
|
||||
if (!Array.isArray(value)) {
|
||||
console.warn('The style spaceDelimited is only applicable to arrays');
|
||||
return '';
|
||||
}
|
||||
if (explode) {
|
||||
return serializeFormValue(name, explode, value);
|
||||
}
|
||||
|
||||
return `${name}=${value.join('%20')}`;
|
||||
case 'pipeDelimited':
|
||||
if (!Array.isArray(value)) {
|
||||
console.warn('The style pipeDelimited is only applicable to arrays');
|
||||
return '';
|
||||
}
|
||||
if (explode) {
|
||||
return serializeFormValue(name, explode, value);
|
||||
}
|
||||
|
||||
return `${name}=${value.join('|')}`;
|
||||
case 'deepObject':
|
||||
if (!explode || Array.isArray(value) || typeof value !== 'object') {
|
||||
console.warn('The style deepObject is only applicable for objects with expolde=true');
|
||||
return '';
|
||||
}
|
||||
|
||||
return deepObjectEncodeField(value, name);
|
||||
default:
|
||||
console.warn('Unexpected style for query: ' + style);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function serializeHeaderParameter(
|
||||
name: string,
|
||||
style: OpenAPIParameterStyle,
|
||||
explode: boolean,
|
||||
value: any,
|
||||
): string {
|
||||
switch (style) {
|
||||
case 'simple':
|
||||
const suffix = explode ? '*' : '';
|
||||
const template = new URI.Template(`{${name}${suffix}}`);
|
||||
|
||||
return template.expand({ [name]: value });
|
||||
default:
|
||||
console.warn('Unexpected style for header: ' + style);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function serializeCookieParameter(
|
||||
name: string,
|
||||
style: OpenAPIParameterStyle,
|
||||
explode: boolean,
|
||||
value: any,
|
||||
): string {
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return serializeFormValue(name, explode, value);
|
||||
default:
|
||||
console.warn('Unexpected style for cookie: ' + style);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeParameterValue(parameter: OpenAPIParameter, value: any): string {
|
||||
const { name, style, explode = false } = parameter;
|
||||
|
||||
if (!style) {
|
||||
console.warn(`Missing style attribute for parameter ${name}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (parameter.in) {
|
||||
case 'path':
|
||||
return serializePathParameter(name, style, explode, value);
|
||||
case 'query':
|
||||
return serializeQueryParameter(name, style, explode, value);
|
||||
case 'header':
|
||||
return serializeHeaderParameter(name, style, explode, value);
|
||||
case 'cookie':
|
||||
return serializeCookieParameter(name, style, explode, value);
|
||||
default:
|
||||
console.warn('Unexpected parameter location: ' + parameter.in);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function langFromMime(contentType: string): string {
|
||||
if (contentType.search(/xml/i) !== -1) {
|
||||
return 'xml';
|
||||
|
|
|
@ -9651,6 +9651,11 @@ uri-js@^4.2.2:
|
|||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
uri-template-lite@^19.4.0:
|
||||
version "19.4.0"
|
||||
resolved "https://registry.yarnpkg.com/uri-template-lite/-/uri-template-lite-19.4.0.tgz#cbc2c072cf4931428a2f9d3aea36b8254a33cce5"
|
||||
integrity sha512-VY8dgwyMwnCztkzhq0cA/YhNmO+YZqow//5FdmgE2fZU/JPi+U0rPL7MRDi0F+Ch4vJ7nYidWzeWAeY7uywe9g==
|
||||
|
||||
urix@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
|
||||
|
|
Loading…
Reference in New Issue
Block a user