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;