Merge pull request #5 from Adthena/feature-FBI-459-implement-csv-code-sample-generation-for-redoc

FBI-459: Implement CSV code sample generation for redoc
This commit is contained in:
Rishi Tank 2023-06-22 19:04:25 +01:00 committed by GitHub
commit 8080b338fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 434 additions and 47 deletions

View File

@ -14,7 +14,7 @@ const specUrl =
const options: RedocRawOptions = { const options: RedocRawOptions = {
nativeScrollbars: false, nativeScrollbars: false,
maxDisplayedEnumValues: 3, maxDisplayedEnumValues: 3,
codeSamplesLanguages: ['json', 'xml'], codeSamplesLanguages: ['json', 'xml', 'csv'],
}; };
render(<RedocStandalone specUrl={specUrl} options={options} />, document.getElementById('example')); render(<RedocStandalone specUrl={specUrl} options={options} />, document.getElementById('example'));

View File

@ -1,4 +1,5 @@
export const CODE_SAMPLE_LANGUAGES = { export const CODE_SAMPLE_LANGUAGES = {
JSON: 'json', JSON: 'json',
XML: 'xml', XML: 'xml',
CSV: 'csv',
} as const; } as const;

View File

@ -336,6 +336,13 @@ export class OpenAPIParser {
return receiver; return receiver;
} }
/**
* Recursively deref the properties of a schema and attach examples
*
* @param {MergedOpenAPISchema} schema
* @param {OpenAPIExample & OpenAPISchema} example
* @returns {OpenAPISchema}
*/
derefSchemaWithExample( derefSchemaWithExample(
schema: MergedOpenAPISchema, schema: MergedOpenAPISchema,
example: OpenAPIExample & OpenAPISchema, example: OpenAPIExample & OpenAPISchema,

View File

@ -1,15 +1,17 @@
import * as Sampler from 'openapi-sampler'; import * as Sampler from 'openapi-sampler';
import type { OpenAPIMediaType } from '../../types'; import { OpenAPIExample, OpenAPIMediaType, OpenAPISchema, Referenced } from '../../types';
import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import type { RedocNormalizedOptions } from '../RedocNormalizedOptions';
import { SchemaModel } from './Schema'; import { SchemaModel } from './Schema';
import { isXml, mapValues } from '../../utils'; import { mapValues } from '../../utils';
import type { OpenAPIParser } from '../OpenAPIParser'; import type { OpenAPIParser } from '../OpenAPIParser';
import { ExampleModel } from './Example'; import { ExampleModel } from './Example';
import { ConfigAccessOptions, FinalExamples, generateXmlExample } from '../../utils/xml'; import { ConfigAccessOptions, generateXmlExample } from '../../utils/xml';
import { MergedOpenAPISchema } from '../types'; import { MergedOpenAPISchema } from '../types';
import { OpenAPIExample, Referenced } from '../../types'; import { generateCsvExample } from '../../utils/csv';
import { CODE_SAMPLE_LANGUAGES } from '../../constants/languages';
import { Example } from '../../types/example';
export class MediaTypeModel { export class MediaTypeModel {
examples?: { [name: string]: ExampleModel }; examples?: { [name: string]: ExampleModel };
@ -18,6 +20,12 @@ export class MediaTypeModel {
isRequestType: boolean; isRequestType: boolean;
onlyRequiredInSamples: boolean; onlyRequiredInSamples: boolean;
generatedPayloadSamplesMaxDepth: number; generatedPayloadSamplesMaxDepth: number;
private readonly samplerOptions: {
maxSampleDepth: number;
skipNonRequired: boolean;
skipReadOnly: boolean;
skipWriteOnly: boolean;
};
/** /**
* @param isRequestType needed to know if skipe RO/RW fields in objects * @param isRequestType needed to know if skipe RO/RW fields in objects
@ -37,6 +45,12 @@ export class MediaTypeModel {
const isCodeGenerationSupported = options.codeSamplesLanguages.some(lang => const isCodeGenerationSupported = options.codeSamplesLanguages.some(lang =>
name.toLowerCase().includes(lang), name.toLowerCase().includes(lang),
); );
this.samplerOptions = {
maxSampleDepth: this.generatedPayloadSamplesMaxDepth,
skipNonRequired: this.isRequestType && this.onlyRequiredInSamples,
skipReadOnly: this.isRequestType,
skipWriteOnly: !this.isRequestType,
};
if (info.examples !== undefined) { if (info.examples !== undefined) {
this.examples = mapValues( this.examples = mapValues(
info.examples, info.examples,
@ -57,17 +71,15 @@ export class MediaTypeModel {
} }
generateExample(parser: OpenAPIParser, info: OpenAPIMediaType) { generateExample(parser: OpenAPIParser, info: OpenAPIMediaType) {
const samplerOptions = {
skipReadOnly: this.isRequestType,
skipWriteOnly: !this.isRequestType,
skipNonRequired: this.isRequestType && this.onlyRequiredInSamples,
maxSampleDepth: this.generatedPayloadSamplesMaxDepth,
};
if (this.schema) { if (this.schema) {
if (this.schema.oneOf) { if (this.schema.oneOf) {
this.examples = {}; this.examples = {};
for (const subSchema of this.schema.oneOf) { for (const subSchema of this.schema.oneOf) {
const sample = Sampler.sample(subSchema.rawSchema as any, samplerOptions, parser.spec); const sample = Sampler.sample(
subSchema.rawSchema as any,
this.samplerOptions,
parser.spec,
);
if (this.schema.discriminatorProp && typeof sample === 'object' && sample) { if (this.schema.discriminatorProp && typeof sample === 'object' && sample) {
sample[this.schema.discriminatorProp] = subSchema.title; sample[this.schema.discriminatorProp] = subSchema.title;
@ -82,24 +94,26 @@ export class MediaTypeModel {
info.encoding, info.encoding,
); );
const xmlExamples = this.resolveXmlExample(parser, sample as OpenAPIExample); const [generatedExample] = this.resolveGeneratedExample(parser, sample as OpenAPIExample);
if (xmlExamples[0]) { if (generatedExample) {
this.examples[subSchema.title].value = xmlExamples[0].exampleValue; this.examples[subSchema.title].value = generatedExample.exampleValue;
} }
} }
} else { } else {
const infoOrRef: Referenced<OpenAPIExample> = { let infoOrRef: Referenced<OpenAPIExample> = {
value: Sampler.sample(info.schema as any, samplerOptions, parser.spec), value: Sampler.sample(info.schema as any, this.samplerOptions, parser.spec),
}; };
const xmlExamples = this.resolveXmlExample(parser, infoOrRef.value); const generatedExamples = this.resolveGeneratedExample(parser, infoOrRef.value);
if (xmlExamples.length > 1) { if (generatedExamples.length > 1) {
this.examples = Object.fromEntries( this.examples = Object.fromEntries(
xmlExamples.map(item => [ generatedExamples.map(item => [
item.exampleId, item.exampleId,
new ExampleModel( new ExampleModel(
parser, parser,
{ {
description: item.exampleDescription,
summary: item.exampleSummary,
value: item.exampleValue, value: item.exampleValue,
}, },
this.name, this.name,
@ -108,30 +122,45 @@ export class MediaTypeModel {
]), ]),
); );
} else { } else {
const [generatedExample] = generatedExamples;
if (generatedExample) {
infoOrRef = {
description: generatedExample.exampleDescription,
summary: generatedExample.exampleSummary,
value: generatedExample.exampleValue,
};
}
this.examples = { this.examples = {
default: new ExampleModel( default: new ExampleModel(parser, infoOrRef, this.name, info.encoding),
parser,
{
value: xmlExamples[0]?.exampleValue || infoOrRef.value,
},
this.name,
info.encoding,
),
}; };
} }
} }
} }
} }
resolveXmlExample(parser: OpenAPIParser, sample: OpenAPIExample) { private resolveGeneratedExample(parser: OpenAPIParser, sample: OpenAPIExample): Example[] {
const mimeType = this.name.toLowerCase();
switch (true) {
case mimeType.includes(CODE_SAMPLE_LANGUAGES.JSON):
return []; // Already supported
case mimeType.includes(CODE_SAMPLE_LANGUAGES.XML):
return this.resolveXmlExample(parser, sample);
case mimeType.includes(CODE_SAMPLE_LANGUAGES.CSV):
return this.resolveCsvExample(parser, sample);
default:
throw new Error(`Unsupported code sample language: ${this.name}`);
}
}
private resolveXmlExample(parser: OpenAPIParser, sample: OpenAPIExample) {
const configAccessOptions: ConfigAccessOptions = { const configAccessOptions: ConfigAccessOptions = {
includeReadOnly: !this.isRequestType, includeReadOnly: !this.isRequestType,
includeWriteOnly: this.isRequestType, includeWriteOnly: this.isRequestType,
}; };
const subSchema = this.schema?.schema; const subSchema = this.schema?.schema;
let xmlExamples: FinalExamples[] = []; let xmlExamples: Example[] = [];
if (subSchema && isXml(this.name)) { if (subSchema) {
let resolved; let resolved: OpenAPISchema;
if (subSchema.items) { if (subSchema.items) {
resolved = { resolved = {
...subSchema, ...subSchema,
@ -152,4 +181,14 @@ export class MediaTypeModel {
return xmlExamples; return xmlExamples;
} }
private resolveCsvExample(parser: OpenAPIParser, sample: OpenAPIExample): Example[] {
const subSchema = this.schema?.schema;
return generateCsvExample({
parser,
schema: subSchema as MergedOpenAPISchema,
sample,
samplerOptions: this.samplerOptions,
});
}
} }

6
src/types/example.ts Normal file
View File

@ -0,0 +1,6 @@
export interface Example {
exampleDescription: string;
exampleId: string;
exampleSummary: string;
exampleValue: string;
}

View File

@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateCsvExample generates a csv example for an array of items using $ref 1`] = `
Array [
Object {
"exampleDescription": "",
"exampleId": "Example 1",
"exampleSummary": "Example CSV",
"exampleValue": "Competitor,2023-02-02,2023-02-03
google.com,0.356263,0.1251661",
},
]
`;
exports[`generateCsvExample generates a csv example for an array of items using allOf 1`] = `
Array [
Object {
"exampleDescription": "",
"exampleId": "Example 1",
"exampleSummary": "Example CSV",
"exampleValue": "Competitor,2023-02-02,2023-02-03
google.com,0.356263,0.1251661
facebook.com,0.74324,0.73542",
},
]
`;
exports[`generateCsvExample generates a csv example for an array of items using oneOf 1`] = `
Array [
Object {
"exampleDescription": "",
"exampleId": "Example 1",
"exampleSummary": "Example CSV",
"exampleValue": "Competitor,2023-02-02,2023-02-03
google.com,0.356263,0.1251661",
},
Object {
"exampleDescription": "",
"exampleId": "Example 2",
"exampleSummary": "Example CSV",
"exampleValue": "Competitor,2023-02-02,2023-02-03
facebook.com,0.74324,0.73542",
},
]
`;

View File

@ -0,0 +1,104 @@
import { generateCsvExample } from '../csv';
import { OpenAPIParser, RedocNormalizedOptions } from '../../services';
import { OpenAPIExample } from '../../types';
const opts = new RedocNormalizedOptions({});
const samplerOptions = {};
describe('generateCsvExample', () => {
const spec = require('./fixtures/csv-compatible-schema.json');
let parser;
it('generates a csv example for an array of items using allOf', () => {
parser = new OpenAPIParser(spec, undefined, opts);
const sample = [
{
Competitor: 'google.com',
'2023-02-02': 0.356263,
'2023-02-03': 0.1251661,
},
{
Competitor: 'facebook.com',
'2023-02-02': 0.74324,
'2023-02-03': 0.73542,
},
] as unknown as OpenAPIExample;
const examples = generateCsvExample({
parser,
schema: spec.components.schemas.test,
sample,
samplerOptions,
});
expect(examples).toMatchSnapshot();
});
it('generates a csv example for an array of items using oneOf', () => {
parser = new OpenAPIParser(spec, undefined, opts);
const sample = [
{
Competitor: 'facebook.com',
'2023-02-02': 0.74324,
'2023-02-03': 0.73542,
},
] as unknown as OpenAPIExample;
const examples = generateCsvExample({
parser,
schema: spec.components.schemas.test2,
sample,
samplerOptions,
});
expect(examples).toMatchSnapshot();
});
it('generates a csv example for an array of items using $ref', () => {
parser = new OpenAPIParser(spec, undefined, opts);
const sample = [
{
Competitor: 'google.com',
'2023-02-02': 0.356263,
'2023-02-03': 0.1251661,
},
] as unknown as OpenAPIExample;
const examples = generateCsvExample({
parser,
schema: spec.components.schemas.test3,
sample,
samplerOptions,
});
expect(examples).toMatchSnapshot();
});
it.each([
{
prop: [],
},
{
prop2: {},
},
{
prop3: null,
},
{
prop4: undefined,
},
] as unknown[] as OpenAPIExample[])(
'should not generate a csv example',
(sample: OpenAPIExample) => {
parser = new OpenAPIParser(spec, undefined, opts);
const examples = generateCsvExample({
parser,
schema: spec.components.schemas.test,
sample,
samplerOptions,
});
expect(examples.length).toEqual(0);
},
);
});

View File

@ -0,0 +1,74 @@
{
"openapi": "3.0.0",
"info": {
"version": "1.0",
"title": "Foo"
},
"components": {
"schemas": {
"test": {
"type": "array",
"items": {
"allOf": [
{
"$ref": "#/components/schemas/Category1"
},
{
"$ref": "#/components/schemas/Category2"
}
]
}
},
"test2": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/Category1"
},
{
"$ref": "#/components/schemas/Category2"
}
]
}
},
"test3": {
"$ref": "#/components/schemas/Category1"
},
"Category1": {
"type": "object",
"properties": {
"Competitor": {
"example": "google.com",
"type": "string"
},
"2023-02-02": {
"example": "0.356263",
"type": "string"
},
"2023-02-03": {
"example": "0.1251661",
"type": "string"
}
}
},
"Category2": {
"type": "object",
"properties": {
"Competitor": {
"example": "facebook.com",
"type": "string"
},
"2023-02-02": {
"example": "0.74324",
"type": "string"
},
"2023-02-03": {
"example": "0.73542",
"type": "string"
}
}
}
}
}
}

121
src/utils/csv.ts Normal file
View File

@ -0,0 +1,121 @@
import * as Sampler from 'openapi-sampler';
import { OpenAPIExample, OpenAPISchema } from '../types';
import { Example } from '../types/example';
import { MergedOpenAPISchema, OpenAPIParser } from '../services';
const MAX_ITEM_DEPTH = 1;
interface CsvExampleProps {
parser: OpenAPIParser;
schema: OpenAPISchema;
sample: OpenAPIExample;
samplerOptions: object;
}
const hasSameHeaders = (headers: string, sample: OpenAPIExample) =>
Object.keys(sample).every(key => headers.includes(key));
const getCsvRows = (sample: OpenAPIExample): string => {
const headers = Object.keys(sample?.[0] ?? sample).join(',');
// Ensure the schema has deterministic headers
const hasValidHeaders = (Array.isArray(sample) ? sample : [sample]).every(row =>
hasSameHeaders(headers, row),
);
if (!hasValidHeaders) return '';
let values: string;
if (Array.isArray(sample)) {
values = sample.map(Object.values).join('\n');
} else {
values = Object.values(sample).join(',');
}
return headers + '\n' + values;
};
const cleanUpExamples = (examples: Example[]): Example[] =>
examples.filter(({ exampleValue }) => exampleValue);
export const generateCsvExample = ({
parser,
schema,
sample,
samplerOptions,
}: CsvExampleProps): Example[] => {
let examples: Example[] = [];
let depthCount = 0;
let exampleCount = 1;
const isValidSample = (Array.isArray(sample) ? sample : [sample]).every(sampleItem =>
Object.values(sampleItem).every(
value => typeof value !== 'object' && typeof value !== 'undefined',
),
);
const processSamplesWithSchema = (subSchema: OpenAPISchema) => {
if (!subSchema) {
return;
}
const subItems = subSchema.items as OpenAPISchema;
if (subSchema.type === 'array' && subItems && depthCount < MAX_ITEM_DEPTH) {
depthCount++;
processSamplesWithSchema(subItems);
}
const metadata = {
exampleDescription: subSchema.description || schema.description || '',
exampleSummary: subSchema.title || schema.title || 'Example CSV',
};
if (subSchema.allOf) {
const resolved: OpenAPISchema = {
...schema,
items: parser.deref(subSchema.allOf as MergedOpenAPISchema).resolved,
};
const sampleData = Sampler.sample(
resolved as any,
samplerOptions,
parser.spec,
) as OpenAPIExample;
const csvRows = getCsvRows(sampleData);
examples.push({
exampleId: `Example ${exampleCount++}`,
exampleValue: csvRows,
...metadata,
});
} else if (subSchema.oneOf) {
const oneOfExamples = subSchema.oneOf.map(oneOfSchema => {
const { resolved } = parser.deref(oneOfSchema as MergedOpenAPISchema);
const sampleData = Sampler.sample(
resolved as any,
samplerOptions,
parser.spec,
) as OpenAPIExample;
const csvRows = getCsvRows(sampleData);
const currentMetadata = {
exampleDescription: oneOfSchema.description || metadata.exampleDescription,
exampleSummary: oneOfSchema.title || metadata.exampleSummary,
};
return {
exampleId: `Example ${exampleCount++}`,
exampleValue: csvRows,
...currentMetadata,
};
});
examples = [...examples, ...oneOfExamples];
} else if (subSchema.$ref) {
const csvRows = getCsvRows(sample);
examples.push({
exampleId: `Example ${exampleCount++}`,
exampleValue: csvRows,
...metadata,
});
}
};
if (isValidSample) {
processSamplesWithSchema(schema);
}
return cleanUpExamples(examples);
};

View File

@ -168,10 +168,6 @@ export function isFormUrlEncoded(contentType: string): boolean {
return contentType === 'application/x-www-form-urlencoded'; return contentType === 'application/x-www-form-urlencoded';
} }
export const isXml = (contentType: string): boolean => {
return contentType === 'application/xml';
};
function delimitedEncodeField(fieldVal: any, fieldName: string, delimiter: string): string { function delimitedEncodeField(fieldVal: any, fieldName: string, delimiter: string): string {
if (isArray(fieldVal)) { if (isArray(fieldVal)) {
return fieldVal.map(v => v.toString()).join(delimiter); return fieldVal.map(v => v.toString()).join(delimiter);

View File

@ -1,28 +1,22 @@
import { MergedOpenAPISchema } from '../services'; import { MergedOpenAPISchema } from '../services';
import { OpenAPISchema } from '../types'; import { OpenAPISchema } from '../types';
import { json2xml } from './jsonToXml'; import { json2xml } from './jsonToXml';
import { Example } from '../types/example';
export interface ConfigAccessOptions { export interface ConfigAccessOptions {
includeReadOnly?: boolean; includeReadOnly?: boolean;
includeWriteOnly?: boolean; includeWriteOnly?: boolean;
} }
export interface ExampleConfig extends ConfigAccessOptions { interface ExampleConfig extends ConfigAccessOptions {
includeDeprecated?: boolean; includeDeprecated?: boolean;
useXmlTagForProp?: boolean; useXmlTagForProp?: boolean;
} }
export interface GenerateExampleProps extends ConfigAccessOptions { interface GenerateExampleProps extends ConfigAccessOptions {
schema: MergedOpenAPISchema | undefined; schema: MergedOpenAPISchema | undefined;
} }
export interface FinalExamples {
exampleDescription: string;
exampleId: string;
exampleSummary: string;
exampleValue: string;
}
const mergePropertyExamples = ( const mergePropertyExamples = (
obj: { [x: string]: any }, obj: { [x: string]: any },
propertyName: string, propertyName: string,
@ -482,8 +476,8 @@ export const generateXmlExample = ({
includeReadOnly = true, includeReadOnly = true,
includeWriteOnly = true, includeWriteOnly = true,
schema, schema,
}: GenerateExampleProps): FinalExamples[] => { }: GenerateExampleProps): Example[] => {
const finalExamples: FinalExamples[] = []; const finalExamples: Example[] = [];
if (!schema) return finalExamples; if (!schema) return finalExamples;