From 39392869f414e86049e0123839f06a5d520cd1de Mon Sep 17 00:00:00 2001 From: Mathias Schreck Date: Thu, 20 Jun 2019 15:44:02 +0200 Subject: [PATCH] fix: serialize parameter example values according to the spec (#917) --- package.json | 3 +- src/components/Fields/FieldDetail.tsx | 6 +- src/components/Fields/FieldDetails.tsx | 15 +- .../DiscriminatorDropdown.test.tsx.snap | 2 + src/services/__tests__/fixtures/fields.json | 9 +- .../__tests__/models/FieldModel.test.ts | 17 ++ src/services/models/Field.ts | 32 ++- src/utils/__tests__/openapi.test.ts | 189 +++++++++++++++++- src/utils/openapi.ts | 159 ++++++++++++--- yarn.lock | 5 + 10 files changed, 399 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index f00bd023..32b726c7 100644 --- a/package.json +++ b/package.json @@ -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": [ { diff --git a/src/components/Fields/FieldDetail.tsx b/src/components/Fields/FieldDetail.tsx index 26221439..e9ea1099 100644 --- a/src/components/Fields/FieldDetail.tsx +++ b/src/components/Fields/FieldDetail.tsx @@ -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 { @@ -11,11 +12,14 @@ export class FieldDetail extends React.PureComponent { if (this.props.value === undefined) { return null; } + + const value = this.props.raw ? this.props.value : JSON.stringify(this.props.value); + return (
{this.props.label} {' '} - {JSON.stringify(this.props.value)} + {value}
); diff --git a/src/components/Fields/FieldDetails.tsx b/src/components/Fields/FieldDetails.tsx index 04672726..9626da0e 100644 --- a/src/components/Fields/FieldDetails.tsx +++ b/src/components/Fields/FieldDetails.tsx @@ -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 { 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 = ; + } else { + exampleField = ; + } + } + return (
@@ -53,7 +66,7 @@ export class FieldDetails extends React.PureComponent { )} {!renderDiscriminatorSwitch && }{' '} - {showExamples && } + {exampleField} {}
diff --git a/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap b/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap index 5aa3ccfe..745c4cad 100644 --- a/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap @@ -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", diff --git a/src/services/__tests__/fixtures/fields.json b/src/services/__tests__/fixtures/fields.json index 266a030b..cab48001 100644 --- a/src/services/__tests__/fixtures/fields.json +++ b/src/services/__tests__/fixtures/fields.json @@ -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": { @@ -21,4 +28,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/services/__tests__/models/FieldModel.test.ts b/src/services/__tests__/models/FieldModel.test.ts index cc0e8631..f86827dd 100644 --- a/src/services/__tests__/models/FieldModel.test.ts +++ b/src/services/__tests__/models/FieldModel.test.ts @@ -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, diff --git a/src/services/models/Field.ts b/src/services/models/Field.ts index f54a3cf8..1e717196 100644 --- a/src/services/models/Field.ts +++ b/src/services/models/Field.ts @@ -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; + 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); diff --git a/src/utils/__tests__/openapi.test.ts b/src/utils/__tests__/openapi.test.ts index d026f519..162c8776 100644 --- a/src/utils/__tests__/openapi.test.ts +++ b/src/utils/__tests__/openapi.test.ts @@ -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); + }); + }); + }); + }); + }); + }); + }); }); diff --git a/src/utils/openapi.ts b/src/utils/openapi.ts index 2637fb1e..143e7255 100644 --- a/src/utils/openapi.ts +++ b/src/utils/openapi.ts @@ -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'; diff --git a/yarn.lock b/yarn.lock index 90cd58a0..cc6fe7a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"