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 = {
nativeScrollbars: false,
maxDisplayedEnumValues: 3,
codeSamplesLanguages: ['json', 'xml'],
codeSamplesLanguages: ['json', 'xml', 'csv'],
};
render(<RedocStandalone specUrl={specUrl} options={options} />, document.getElementById('example'));

View File

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

View File

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

View File

@ -1,15 +1,17 @@
import * as Sampler from 'openapi-sampler';
import type { OpenAPIMediaType } from '../../types';
import { OpenAPIExample, OpenAPIMediaType, OpenAPISchema, Referenced } from '../../types';
import type { RedocNormalizedOptions } from '../RedocNormalizedOptions';
import { SchemaModel } from './Schema';
import { isXml, mapValues } from '../../utils';
import { mapValues } from '../../utils';
import type { OpenAPIParser } from '../OpenAPIParser';
import { ExampleModel } from './Example';
import { ConfigAccessOptions, FinalExamples, generateXmlExample } from '../../utils/xml';
import { ConfigAccessOptions, generateXmlExample } from '../../utils/xml';
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 {
examples?: { [name: string]: ExampleModel };
@ -18,6 +20,12 @@ export class MediaTypeModel {
isRequestType: boolean;
onlyRequiredInSamples: boolean;
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
@ -37,6 +45,12 @@ export class MediaTypeModel {
const isCodeGenerationSupported = options.codeSamplesLanguages.some(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) {
this.examples = mapValues(
info.examples,
@ -57,17 +71,15 @@ export class MediaTypeModel {
}
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.oneOf) {
this.examples = {};
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) {
sample[this.schema.discriminatorProp] = subSchema.title;
@ -82,24 +94,26 @@ export class MediaTypeModel {
info.encoding,
);
const xmlExamples = this.resolveXmlExample(parser, sample as OpenAPIExample);
if (xmlExamples[0]) {
this.examples[subSchema.title].value = xmlExamples[0].exampleValue;
const [generatedExample] = this.resolveGeneratedExample(parser, sample as OpenAPIExample);
if (generatedExample) {
this.examples[subSchema.title].value = generatedExample.exampleValue;
}
}
} else {
const infoOrRef: Referenced<OpenAPIExample> = {
value: Sampler.sample(info.schema as any, samplerOptions, parser.spec),
let infoOrRef: Referenced<OpenAPIExample> = {
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(
xmlExamples.map(item => [
generatedExamples.map(item => [
item.exampleId,
new ExampleModel(
parser,
{
description: item.exampleDescription,
summary: item.exampleSummary,
value: item.exampleValue,
},
this.name,
@ -108,30 +122,45 @@ export class MediaTypeModel {
]),
);
} else {
const [generatedExample] = generatedExamples;
if (generatedExample) {
infoOrRef = {
description: generatedExample.exampleDescription,
summary: generatedExample.exampleSummary,
value: generatedExample.exampleValue,
};
}
this.examples = {
default: new ExampleModel(
parser,
{
value: xmlExamples[0]?.exampleValue || infoOrRef.value,
},
this.name,
info.encoding,
),
default: new ExampleModel(parser, infoOrRef, 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 = {
includeReadOnly: !this.isRequestType,
includeWriteOnly: this.isRequestType,
};
const subSchema = this.schema?.schema;
let xmlExamples: FinalExamples[] = [];
if (subSchema && isXml(this.name)) {
let resolved;
let xmlExamples: Example[] = [];
if (subSchema) {
let resolved: OpenAPISchema;
if (subSchema.items) {
resolved = {
...subSchema,
@ -152,4 +181,14 @@ export class MediaTypeModel {
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';
}
export const isXml = (contentType: string): boolean => {
return contentType === 'application/xml';
};
function delimitedEncodeField(fieldVal: any, fieldName: string, delimiter: string): string {
if (isArray(fieldVal)) {
return fieldVal.map(v => v.toString()).join(delimiter);

View File

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