fix: serialize parameter example values according to the spec (#917)

This commit is contained in:
Mathias Schreck 2019-06-20 15:44:02 +02:00 committed by Roman Hotsiy
parent 888f04e872
commit 39392869f4
10 changed files with 399 additions and 38 deletions

View File

@ -154,7 +154,8 @@
"slugify": "^1.3.4", "slugify": "^1.3.4",
"stickyfill": "^1.1.1", "stickyfill": "^1.1.1",
"swagger2openapi": "^5.2.3", "swagger2openapi": "^5.2.3",
"tslib": "^1.9.3" "tslib": "^1.9.3",
"uri-template-lite": "^19.4.0"
}, },
"bundlesize": [ "bundlesize": [
{ {

View File

@ -4,6 +4,7 @@ import { ExampleValue, FieldLabel } from '../../common-elements/fields';
export interface FieldDetailProps { export interface FieldDetailProps {
value?: any; value?: any;
label: string; label: string;
raw?: boolean;
} }
export class FieldDetail extends React.PureComponent<FieldDetailProps> { export class FieldDetail extends React.PureComponent<FieldDetailProps> {
@ -11,11 +12,14 @@ export class FieldDetail extends React.PureComponent<FieldDetailProps> {
if (this.props.value === undefined) { if (this.props.value === undefined) {
return null; return null;
} }
const value = this.props.raw ? this.props.value : JSON.stringify(this.props.value);
return ( return (
<div> <div>
<FieldLabel> {this.props.label} </FieldLabel>{' '} <FieldLabel> {this.props.label} </FieldLabel>{' '}
<ExampleValue> <ExampleValue>
{JSON.stringify(this.props.value)} {value}
</ExampleValue> </ExampleValue>
</div> </div>
); );

View File

@ -9,6 +9,7 @@ import {
TypePrefix, TypePrefix,
TypeTitle, TypeTitle,
} from '../../common-elements/fields'; } from '../../common-elements/fields';
import { serializeParameterValue } from '../../utils/openapi';
import { ExternalDocumentation } from '../ExternalDocumentation/ExternalDocumentation'; import { ExternalDocumentation } from '../ExternalDocumentation/ExternalDocumentation';
import { Markdown } from '../Markdown/Markdown'; import { Markdown } from '../Markdown/Markdown';
import { EnumValues } from './EnumValues'; import { EnumValues } from './EnumValues';
@ -27,6 +28,18 @@ export class FieldDetails extends React.PureComponent<FieldProps> {
const { schema, description, example, deprecated } = field; 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 ( return (
<div> <div>
<div> <div>
@ -53,7 +66,7 @@ export class FieldDetails extends React.PureComponent<FieldProps> {
)} )}
<FieldDetail label={l('default') + ':'} value={schema.default} /> <FieldDetail label={l('default') + ':'} value={schema.default} />
{!renderDiscriminatorSwitch && <EnumValues type={schema.type} values={schema.enum} />}{' '} {!renderDiscriminatorSwitch && <EnumValues type={schema.type} values={schema.enum} />}{' '}
{showExamples && <FieldDetail label={l('example') + ':'} value={example} />} {exampleField}
{<Extensions extensions={{ ...field.extensions, ...schema.extensions }} />} {<Extensions extensions={{ ...field.extensions, ...schema.extensions }} />}
<div> <div>
<Markdown compact={true} source={description} /> <Markdown compact={true} source={description} />

View File

@ -10,6 +10,7 @@ exports[`Components SchemaView discriminator should correctly render discriminat
"description": "", "description": "",
"example": undefined, "example": undefined,
"expanded": false, "expanded": false,
"explode": false,
"in": undefined, "in": undefined,
"kind": "field", "kind": "field",
"name": "packSize", "name": "packSize",
@ -59,6 +60,7 @@ exports[`Components SchemaView discriminator should correctly render discriminat
"description": "", "description": "",
"example": undefined, "example": undefined,
"expanded": false, "expanded": false,
"explode": false,
"in": undefined, "in": undefined,
"kind": "field", "kind": "field",
"name": "type", "name": "type",

View File

@ -10,6 +10,13 @@
"in": "path", "in": "path",
"name": "test_name", "name": "test_name",
"schema": { "type": "string" } "schema": { "type": "string" }
},
"serializationParam": {
"in": "query",
"name": "serialization_test_name",
"schema": { "type": "array" },
"style": "form",
"explode": true
} }
}, },
"headers": { "headers": {

View File

@ -26,6 +26,23 @@ describe('Models', () => {
expect(field.schema.type).toEqual('string'); 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)', () => { test('field name should populated from name even if $ref (headers)', () => {
const field = new FieldModel( const field = new FieldModel(
parser, parser,

View File

@ -1,12 +1,30 @@
import { action, observable } from 'mobx'; import { action, observable } from 'mobx';
import { OpenAPIParameter, Referenced } from '../../types'; import {
OpenAPIParameter,
OpenAPIParameterLocation,
OpenAPIParameterStyle,
Referenced,
} from '../../types';
import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { RedocNormalizedOptions } from '../RedocNormalizedOptions';
import { extractExtensions } from '../../utils/openapi'; import { extractExtensions } from '../../utils/openapi';
import { OpenAPIParser } from '../OpenAPIParser'; import { OpenAPIParser } from '../OpenAPIParser';
import { SchemaModel } from './Schema'; 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 * Field or Parameter model ready to be used by components
*/ */
@ -20,9 +38,11 @@ export class FieldModel {
description: string; description: string;
example?: string; example?: string;
deprecated: boolean; deprecated: boolean;
in?: string; in?: OpenAPIParameterLocation;
kind: string; kind: string;
extensions?: Dict<any>; extensions?: Dict<any>;
explode: boolean;
style?: OpenAPIParameterStyle;
constructor( constructor(
parser: OpenAPIParser, parser: OpenAPIParser,
@ -40,6 +60,14 @@ export class FieldModel {
info.description === undefined ? this.schema.description || '' : info.description; info.description === undefined ? this.schema.description || '' : info.description;
this.example = info.example || this.schema.example; 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; this.deprecated = info.deprecated === undefined ? !!this.schema.deprecated : info.deprecated;
parser.exitRef(infoOrRef); parser.exitRef(infoOrRef);

View File

@ -8,10 +8,11 @@ import {
mergeParams, mergeParams,
normalizeServers, normalizeServers,
pluralizeType, pluralizeType,
serializeParameterValue,
} from '../'; } from '../';
import { OpenAPIParser } from '../../services'; import { OpenAPIParser } from '../../services';
import { OpenAPIParameter } from '../../types'; import { OpenAPIParameter, OpenAPIParameterLocation, OpenAPIParameterStyle } from '../../types';
describe('Utils', () => { describe('Utils', () => {
describe('openapi getStatusCode', () => { 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);
});
});
});
});
});
});
});
}); });

View File

@ -1,4 +1,5 @@
import { dirname } from 'path'; import { dirname } from 'path';
import { URI } from 'uri-template-lite';
import { OpenAPIParser } from '../services/OpenAPIParser'; import { OpenAPIParser } from '../services/OpenAPIParser';
import { import {
@ -6,6 +7,7 @@ import {
OpenAPIMediaType, OpenAPIMediaType,
OpenAPIOperation, OpenAPIOperation,
OpenAPIParameter, OpenAPIParameter,
OpenAPIParameterStyle,
OpenAPISchema, OpenAPISchema,
OpenAPIServer, OpenAPIServer,
Referenced, Referenced,
@ -135,36 +137,6 @@ export function isFormUrlEncoded(contentType: string): boolean {
return contentType === 'application/x-www-form-urlencoded'; 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 { function delimitedEncodeField(fieldVal: any, fieldName: string, delimeter: string): string {
if (Array.isArray(fieldVal)) { if (Array.isArray(fieldVal)) {
return fieldVal.map(v => v.toString()).join(delimeter); 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 * Should be used only for url-form-encoded body payloads
* To be used for parmaters should be extended with other style values * 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] || {}; const { style = 'form', explode = true } = encoding[fieldName] || {};
switch (style) { switch (style) {
case 'form': case 'form':
return formEncodeField(fieldVal, fieldName, explode); return serializeFormValue(fieldName, explode, fieldVal);
break; break;
case 'spaceDelimited': case 'spaceDelimited':
return delimitedEncodeField(fieldVal, fieldName, '%20'); 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 { export function langFromMime(contentType: string): string {
if (contentType.search(/xml/i) !== -1) { if (contentType.search(/xml/i) !== -1) {
return 'xml'; return 'xml';

View File

@ -9651,6 +9651,11 @@ uri-js@^4.2.2:
dependencies: dependencies:
punycode "^2.1.0" 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: urix@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"