diff --git a/demo/playground/hmr-playground.tsx b/demo/playground/hmr-playground.tsx index 11ae79ab..821466e3 100644 --- a/demo/playground/hmr-playground.tsx +++ b/demo/playground/hmr-playground.tsx @@ -14,7 +14,7 @@ const specUrl = const options: RedocRawOptions = { nativeScrollbars: false, maxDisplayedEnumValues: 3, - codeSamplesLanguages: ['json', 'xml'], + codeSamplesLanguages: ['json', 'xml', 'csv'], }; render(, document.getElementById('example')); diff --git a/src/constants/languages.ts b/src/constants/languages.ts index c8dca0da..9225c161 100644 --- a/src/constants/languages.ts +++ b/src/constants/languages.ts @@ -1,4 +1,5 @@ export const CODE_SAMPLE_LANGUAGES = { JSON: 'json', XML: 'xml', + CSV: 'csv', } as const; diff --git a/src/services/OpenAPIParser.ts b/src/services/OpenAPIParser.ts index f886ad6a..c97f7b6d 100644 --- a/src/services/OpenAPIParser.ts +++ b/src/services/OpenAPIParser.ts @@ -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, diff --git a/src/services/models/MediaType.ts b/src/services/models/MediaType.ts index 71f7eacc..425d4a94 100644 --- a/src/services/models/MediaType.ts +++ b/src/services/models/MediaType.ts @@ -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 = { - value: Sampler.sample(info.schema as any, samplerOptions, parser.spec), + let infoOrRef: Referenced = { + 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, + }); + } } diff --git a/src/types/example.ts b/src/types/example.ts new file mode 100644 index 00000000..b1c9f0f4 --- /dev/null +++ b/src/types/example.ts @@ -0,0 +1,6 @@ +export interface Example { + exampleDescription: string; + exampleId: string; + exampleSummary: string; + exampleValue: string; +} diff --git a/src/utils/__tests__/__snapshots__/csv.test.ts.snap b/src/utils/__tests__/__snapshots__/csv.test.ts.snap new file mode 100644 index 00000000..7e427e0a --- /dev/null +++ b/src/utils/__tests__/__snapshots__/csv.test.ts.snap @@ -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", + }, +] +`; diff --git a/src/utils/__tests__/csv.test.ts b/src/utils/__tests__/csv.test.ts new file mode 100644 index 00000000..2d87d72f --- /dev/null +++ b/src/utils/__tests__/csv.test.ts @@ -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); + }, + ); +}); diff --git a/src/utils/__tests__/fixtures/csv-compatible-schema.json b/src/utils/__tests__/fixtures/csv-compatible-schema.json new file mode 100644 index 00000000..60a2dc0c --- /dev/null +++ b/src/utils/__tests__/fixtures/csv-compatible-schema.json @@ -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" + } + } + } + } + } +} diff --git a/src/utils/csv.ts b/src/utils/csv.ts new file mode 100644 index 00000000..1e8b3d90 --- /dev/null +++ b/src/utils/csv.ts @@ -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); +}; diff --git a/src/utils/openapi.ts b/src/utils/openapi.ts index 0f38ca9b..22424e90 100644 --- a/src/utils/openapi.ts +++ b/src/utils/openapi.ts @@ -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); diff --git a/src/utils/xml.ts b/src/utils/xml.ts index 5d762298..6be8e41c 100644 --- a/src/utils/xml.ts +++ b/src/utils/xml.ts @@ -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;