diff --git a/README.md b/README.md index 58cf29f6e9..5457195112 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,7 @@ You can use all of the following options with the standalone version of the ` theme object * `spacing` @@ -324,6 +325,18 @@ You can use all of the following options with the standalone version of the , document.getElementById('example')); diff --git a/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap b/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap index 5f865af57e..2dbf7172c8 100644 --- a/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap @@ -76,6 +76,9 @@ exports[`Components SchemaView discriminator should correctly render SchemaView "minItems": undefined, "options": RedocNormalizedOptions { "allowedMdComponents": Object {}, + "codeSamplesLanguages": Array [ + "json", + ], "disableSearch": false, "downloadDefinitionUrl": undefined, "downloadFileName": undefined, @@ -347,6 +350,9 @@ exports[`Components SchemaView discriminator should correctly render SchemaView "minItems": undefined, "options": RedocNormalizedOptions { "allowedMdComponents": Object {}, + "codeSamplesLanguages": Array [ + "json", + ], "disableSearch": false, "downloadDefinitionUrl": undefined, "downloadFileName": undefined, @@ -605,6 +611,9 @@ exports[`Components SchemaView discriminator should correctly render SchemaView "minItems": undefined, "options": RedocNormalizedOptions { "allowedMdComponents": Object {}, + "codeSamplesLanguages": Array [ + "json", + ], "disableSearch": false, "downloadDefinitionUrl": undefined, "downloadFileName": undefined, @@ -925,6 +934,9 @@ exports[`Components SchemaView discriminator should correctly render SchemaView "minItems": undefined, "options": RedocNormalizedOptions { "allowedMdComponents": Object {}, + "codeSamplesLanguages": Array [ + "json", + ], "disableSearch": false, "downloadDefinitionUrl": undefined, "downloadFileName": undefined, @@ -1208,6 +1220,9 @@ exports[`Components SchemaView discriminator should correctly render SchemaView "minItems": undefined, "options": RedocNormalizedOptions { "allowedMdComponents": Object {}, + "codeSamplesLanguages": Array [ + "json", + ], "disableSearch": false, "downloadDefinitionUrl": undefined, "downloadFileName": undefined, @@ -1462,6 +1477,9 @@ exports[`Components SchemaView discriminator should correctly render SchemaView "minItems": undefined, "options": RedocNormalizedOptions { "allowedMdComponents": Object {}, + "codeSamplesLanguages": Array [ + "json", + ], "disableSearch": false, "downloadDefinitionUrl": undefined, "downloadFileName": undefined, @@ -1741,6 +1759,9 @@ exports[`Components SchemaView discriminator should correctly render SchemaView ], "options": RedocNormalizedOptions { "allowedMdComponents": Object {}, + "codeSamplesLanguages": Array [ + "json", + ], "disableSearch": false, "downloadDefinitionUrl": undefined, "downloadFileName": undefined, @@ -2050,6 +2071,9 @@ exports[`Components SchemaView discriminator should correctly render SchemaView "minItems": undefined, "options": RedocNormalizedOptions { "allowedMdComponents": Object {}, + "codeSamplesLanguages": Array [ + "json", + ], "disableSearch": false, "downloadDefinitionUrl": undefined, "downloadFileName": undefined, @@ -2321,6 +2345,9 @@ exports[`Components SchemaView discriminator should correctly render SchemaView "minItems": undefined, "options": RedocNormalizedOptions { "allowedMdComponents": Object {}, + "codeSamplesLanguages": Array [ + "json", + ], "disableSearch": false, "downloadDefinitionUrl": undefined, "downloadFileName": undefined, @@ -2579,6 +2606,9 @@ exports[`Components SchemaView discriminator should correctly render SchemaView "minItems": undefined, "options": RedocNormalizedOptions { "allowedMdComponents": Object {}, + "codeSamplesLanguages": Array [ + "json", + ], "disableSearch": false, "downloadDefinitionUrl": undefined, "downloadFileName": undefined, diff --git a/src/constants/languages.ts b/src/constants/languages.ts new file mode 100644 index 0000000000..9225c161d1 --- /dev/null +++ b/src/constants/languages.ts @@ -0,0 +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 1542cf7e8a..c97f7b6d8c 100644 --- a/src/services/OpenAPIParser.ts +++ b/src/services/OpenAPIParser.ts @@ -1,9 +1,10 @@ import type { OpenAPIRef, OpenAPISchema, OpenAPISpec } from '../types'; -import { IS_BROWSER, getDefinitionName } from '../utils/'; +import { IS_BROWSER, getDefinitionName, compact, isObject, isObjectEmpty } from '../utils/'; import { JsonPointer } from '../utils/JsonPointer'; import { RedocNormalizedOptions } from './RedocNormalizedOptions'; import type { MergedOpenAPISchema } from './types'; +import type { OpenAPIExample } from '../types'; const MAX_DEREF_DEPTH = 999; // prevent circular detection crashes by adding hard limit on deref depth @@ -335,6 +336,105 @@ 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, + ): OpenAPISchema { + const { resolved: resolvedSchema } = this.deref(schema); + + const worker = ( + currentSchema: MergedOpenAPISchema, + currentExample: OpenAPIExample & OpenAPISchema, + ) => { + const receiver: OpenAPISchema = { + ...currentSchema, + }; + if (isObject(currentSchema.properties)) { + receiver.properties = Object.fromEntries( + Object.entries(currentSchema.properties).map(([key, value]) => { + let resolvedValue: OpenAPISchema = {}; + const exampleForProp = currentExample?.[key]; + + if (Array.isArray(value.allOf) && !isObjectEmpty(exampleForProp)) { + resolvedValue = this.mergeAllOf(value, undefined, value['x-refsStack'] || []); + } else if (Array.isArray(value.oneOf) && !isObjectEmpty(exampleForProp)) { + resolvedValue = this.deref(value.oneOf[0]).resolved; + } else if (value.$ref) { + resolvedValue = this.deref(value).resolved; + } else if ((value.items as OpenAPISchema)?.$ref) { + resolvedValue = { + ...value, + items: this.deref(value.items as OpenAPISchema, value.items?.['x-refsStack'] || []) + .resolved, + }; + } else if (Array.isArray(value.items)) { + resolvedValue = { + ...value, + items: value.items.map((item, i) => + item.properties + ? worker(item, exampleForProp[i]) + : this.deref(item, item['x-refsStack'] || []).resolved, + ), + }; + } else { + resolvedValue = value; + } + + if ( + resolvedValue.properties && + (!isObjectEmpty(exampleForProp) || exampleForProp.length > 0) + ) { + resolvedValue = worker(resolvedValue, exampleForProp?.[0] ?? exampleForProp); + } + if ((resolvedValue.items as OpenAPISchema)?.properties && isObject(exampleForProp[0])) { + resolvedValue.items = worker(resolvedValue.items as OpenAPISchema, exampleForProp[0]); + } + + if (!isObject(exampleForProp)) { + resolvedValue = { + ...resolvedValue, + example: exampleForProp, + }; + } + + const resolved = compact({ + const: resolvedValue.const, + description: resolvedValue.description, + deprecated: resolvedValue.deprecated, + enum: resolvedValue.enum, + example: resolvedValue.example, + exclusiveMinimum: resolvedValue.exclusiveMinimum, + format: resolvedValue.format, + items: resolvedValue.items, + maximum: resolvedValue.maximum, + maxLength: resolvedValue.maxLength, + minimum: resolvedValue.minimum, + minLength: resolvedValue.minLength, + pattern: resolvedValue.pattern, + properties: resolvedValue.properties, + readOnly: resolvedValue.readOnly, + type: resolvedValue.type, + writeOnly: resolvedValue.writeOnly, + xml: resolvedValue.xml, + }); + + return [key, resolved]; + }), + ); + } + return receiver; + }; + + return worker(resolvedSchema, example); + } + /** * Find all derived definitions among #/components/schemas from any of $refs * returns map of definition pointer to definition name diff --git a/src/services/RedocNormalizedOptions.ts b/src/services/RedocNormalizedOptions.ts index 4a219eef16..d117a188c6 100644 --- a/src/services/RedocNormalizedOptions.ts +++ b/src/services/RedocNormalizedOptions.ts @@ -5,6 +5,9 @@ import { isArray, isNumeric, mergeObjects } from '../utils/helpers'; import { setRedocLabels } from './Labels'; import { SideNavStyleEnum } from './types'; import type { LabelsConfigRaw, MDXComponentMeta } from './types'; +import { CODE_SAMPLE_LANGUAGES } from '../constants/languages'; + +export type CodeSamplesLanguage = typeof CODE_SAMPLE_LANGUAGES[keyof typeof CODE_SAMPLE_LANGUAGES]; export interface RedocRawOptions { theme?: ThemeInterface; @@ -56,6 +59,7 @@ export interface RedocRawOptions { hideFab?: boolean; minCharacterLengthToInitSearch?: number; showWebhookVerb?: boolean; + codeSamplesLanguages?: CodeSamplesLanguage[]; } export function argValueToBoolean(val?: string | boolean, defaultValue?: boolean): boolean { @@ -211,6 +215,16 @@ export class RedocNormalizedOptions { return 10; } + private static normalizeCodeSamplesLanguages( + value?: CodeSamplesLanguage[], + ): CodeSamplesLanguage[] { + if (isArray(value)) { + return value.map(lang => lang.toLowerCase()) as CodeSamplesLanguage[]; + } + + return [CODE_SAMPLE_LANGUAGES.JSON]; + } + theme: ResolvedThemeInterface; scrollYOffset: () => number; hideHostname: boolean; @@ -258,6 +272,7 @@ export class RedocNormalizedOptions { showWebhookVerb: boolean; nonce?: string; + codeSamplesLanguages: CodeSamplesLanguage[]; constructor(raw: RedocRawOptions, defaults: RedocRawOptions = {}) { raw = { ...defaults, ...raw }; @@ -335,5 +350,8 @@ export class RedocNormalizedOptions { this.hideFab = argValueToBoolean(raw.hideFab); this.minCharacterLengthToInitSearch = argValueToNumber(raw.minCharacterLengthToInitSearch) || 3; this.showWebhookVerb = argValueToBoolean(raw.showWebhookVerb); + this.codeSamplesLanguages = RedocNormalizedOptions.normalizeCodeSamplesLanguages( + raw.codeSamplesLanguages, + ); } } diff --git a/src/services/__tests__/OpenAPIParser.test.ts b/src/services/__tests__/OpenAPIParser.test.ts index d32e0f8ecf..bb63b68f5c 100644 --- a/src/services/__tests__/OpenAPIParser.test.ts +++ b/src/services/__tests__/OpenAPIParser.test.ts @@ -72,5 +72,36 @@ describe('Models', () => { expect(parser.deref(schemaOrRef, [], true)).toMatchSnapshot(); }); + + test('should deref the properties of a schema', () => { + const spec = require('./fixtures/properties.json'); + parser = new OpenAPIParser(spec, undefined, opts); + const string = 'string'; + const example = { + id: 0, + category: { + id: 0, + name: string, + sub: { + prop1: string, + }, + }, + name: 'Guru', + photoUrls: [string], + friend: {}, + tags: [ + { + id: 0, + name: string, + }, + ], + status: 'available', + petType: string, + }; + + expect( + parser.derefSchemaWithExample(spec.components.schemas.test, example), + ).toMatchSnapshot(); + }); }); }); diff --git a/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap b/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap index 3b983a1f7c..0162aadfb0 100644 --- a/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap +++ b/src/services/__tests__/__snapshots__/OpenAPIParser.test.ts.snap @@ -16,6 +16,131 @@ Object { } `; +exports[`Models Schema should deref the properties of a schema 1`] = ` +Object { + "discriminator": Object { + "mapping": Object { + "bee": "#/components/schemas/HoneyBee", + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog", + }, + "propertyName": "petType", + }, + "properties": Object { + "category": Object { + "description": "Categories this pet belongs to", + "properties": Object { + "id": Object { + "description": "Category ID", + "example": 0, + "format": "int64", + "readOnly": true, + "type": "integer", + }, + "name": Object { + "description": "Category name", + "example": "string", + "minLength": 1, + "type": "string", + }, + "sub": Object { + "description": "Test Sub Category", + "properties": Object { + "prop1": Object { + "description": "Dumb Property", + "example": "string", + "type": "string", + }, + }, + "type": "object", + }, + }, + "type": "object", + "xml": Object { + "name": "Category", + }, + }, + "friend": Object {}, + "id": Object { + "description": "Pet ID", + "example": 0, + "format": "int64", + "readOnly": true, + "type": "integer", + }, + "name": Object { + "description": "The name given to a pet", + "example": "Guru", + "type": "string", + }, + "petType": Object { + "description": "Type of a pet", + "example": "string", + "type": "string", + }, + "photoUrls": Object { + "description": "The list of URL to a cute photos featuring pet", + "items": Object { + "format": "url", + "type": "string", + }, + "type": "array", + "xml": Object { + "name": "photoUrl", + "wrapped": true, + }, + }, + "status": Object { + "description": "Pet status in the store", + "enum": Array [ + "available", + "pending", + "sold", + ], + "example": "available", + "type": "string", + }, + "tags": Object { + "description": "Tags attached to the pet", + "items": Object { + "properties": Object { + "id": Object { + "description": "Tag ID", + "example": 0, + "format": "int64", + "readOnly": true, + "type": "integer", + }, + "name": Object { + "description": "Tag name", + "example": "string", + "minLength": 1, + "type": "string", + }, + }, + "type": "object", + "xml": Object { + "name": "Tag", + }, + }, + "type": "array", + "xml": Object { + "name": "tag", + "wrapped": true, + }, + }, + }, + "required": Array [ + "name", + "photoUrls", + ], + "type": "object", + "xml": Object { + "name": "Pet", + }, +} +`; + exports[`Models Schema should hoist oneOfs when mergin allOf 1`] = ` Object { "oneOf": Array [ diff --git a/src/services/__tests__/fixtures/properties.json b/src/services/__tests__/fixtures/properties.json new file mode 100644 index 0000000000..cef728cac8 --- /dev/null +++ b/src/services/__tests__/fixtures/properties.json @@ -0,0 +1,234 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0", + "title": "Foo" + }, + "components": { + "schemas": { + "test": { + "type": "object", + "required": ["name", "photoUrls"], + "discriminator": { + "propertyName": "petType", + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog", + "bee": "#/components/schemas/HoneyBee" + } + }, + "properties": { + "id": { + "externalDocs": { + "description": "Find more info here", + "url": "https://example.com" + }, + "description": "Pet ID", + "allOf": [ + { + "$ref": "#/components/schemas/Id" + } + ] + }, + "category": { + "description": "Categories this pet belongs to", + "allOf": [ + { + "$ref": "#/components/schemas/Category" + } + ] + }, + "name": { + "description": "The name given to a pet", + "type": "string", + "example": "Guru" + }, + "photoUrls": { + "description": "The list of URL to a cute photos featuring pet", + "type": "array", + "maxItems": 20, + "xml": { + "name": "photoUrl", + "wrapped": true + }, + "items": { + "type": "string", + "format": "url" + } + }, + "friend": { + "allOf": [ + { + "$ref": "#/components/schemas/Pet" + } + ] + }, + "tags": { + "description": "Tags attached to the pet", + "type": "array", + "minItems": 1, + "xml": { + "name": "tag", + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "description": "Pet status in the store", + "enum": ["available", "pending", "sold"] + }, + "petType": { + "description": "Type of a pet", + "type": "string" + } + }, + "xml": { + "name": "Pet" + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "description": "Category ID", + "allOf": [ + { + "$ref": "#/components/schemas/Id" + } + ] + }, + "name": { + "description": "Category name", + "type": "string", + "minLength": 1 + }, + "sub": { + "description": "Test Sub Category", + "type": "object", + "properties": { + "prop1": { + "type": "string", + "description": "Dumb Property" + } + } + } + }, + "xml": { + "name": "Category" + } + }, + "Id": { + "type": "integer", + "format": "int64", + "readOnly": true + }, + "Pet": { + "type": "object", + "required": ["name", "photoUrls"], + "discriminator": { + "propertyName": "petType", + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog", + "bee": "#/components/schemas/HoneyBee" + } + }, + "properties": { + "id": { + "externalDocs": { + "description": "Find more info here", + "url": "https://example.com" + }, + "description": "Pet ID", + "allOf": [ + { + "$ref": "#/components/schemas/Id" + } + ] + }, + "category": { + "description": "Categories this pet belongs to", + "allOf": [ + { + "$ref": "#/components/schemas/Category" + } + ] + }, + "name": { + "description": "The name given to a pet", + "type": "string", + "example": "Guru" + }, + "photoUrls": { + "description": "The list of URL to a cute photos featuring pet", + "type": "array", + "maxItems": 20, + "xml": { + "name": "photoUrl", + "wrapped": true + }, + "items": { + "type": "string", + "format": "url" + } + }, + "friend": { + "allOf": [ + { + "$ref": "#/components/schemas/Pet" + } + ] + }, + "tags": { + "description": "Tags attached to the pet", + "type": "array", + "minItems": 1, + "xml": { + "name": "tag", + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "description": "Pet status in the store", + "enum": ["available", "pending", "sold"] + }, + "petType": { + "description": "Type of a pet", + "type": "string" + } + }, + "xml": { + "name": "Pet" + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "description": "Tag ID", + "allOf": [ + { + "$ref": "#/components/schemas/Id" + } + ] + }, + "name": { + "description": "Tag name", + "type": "string", + "minLength": 1 + } + }, + "xml": { + "name": "Tag" + } + } + } + } +} diff --git a/src/services/models/MediaType.ts b/src/services/models/MediaType.ts index 1b7263ae95..425d4a94a6 100644 --- a/src/services/models/MediaType.ts +++ b/src/services/models/MediaType.ts @@ -1,12 +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 { isJsonLike, mapValues } from '../../utils'; +import { mapValues } from '../../utils'; import type { OpenAPIParser } from '../OpenAPIParser'; import { ExampleModel } from './Example'; +import { ConfigAccessOptions, generateXmlExample } from '../../utils/xml'; +import { MergedOpenAPISchema } 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 }; @@ -15,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 @@ -31,6 +42,15 @@ export class MediaTypeModel { this.schema = info.schema && new SchemaModel(parser, info.schema, '', options); this.onlyRequiredInSamples = options.onlyRequiredInSamples; this.generatedPayloadSamplesMaxDepth = options.generatedPayloadSamplesMaxDepth; + 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, @@ -45,47 +65,130 @@ export class MediaTypeModel { info.encoding, ), }; - } else if (isJsonLike(name)) { + } else if (isCodeGenerationSupported) { this.generateExample(parser, info); } } generateExample(parser: OpenAPIParser, info: OpenAPIMediaType) { - const samplerOptions = { - skipReadOnly: this.isRequestType, - skipWriteOnly: !this.isRequestType, - skipNonRequired: this.isRequestType && this.onlyRequiredInSamples, - maxSampleDepth: this.generatedPayloadSamplesMaxDepth, - }; - if (this.schema && this.schema.oneOf) { - this.examples = {}; - for (const subSchema of this.schema.oneOf) { - const sample = Sampler.sample(subSchema.rawSchema as any, samplerOptions, parser.spec); + if (this.schema) { + if (this.schema.oneOf) { + this.examples = {}; + for (const subSchema of this.schema.oneOf) { + 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; + } + + this.examples[subSchema.title] = new ExampleModel( + parser, + { + value: sample, + }, + this.name, + info.encoding, + ); - if (this.schema.discriminatorProp && typeof sample === 'object' && sample) { - sample[this.schema.discriminatorProp] = subSchema.title; + const [generatedExample] = this.resolveGeneratedExample(parser, sample as OpenAPIExample); + if (generatedExample) { + this.examples[subSchema.title].value = generatedExample.exampleValue; + } } + } else { + let infoOrRef: Referenced = { + value: Sampler.sample(info.schema as any, this.samplerOptions, parser.spec), + }; + const generatedExamples = this.resolveGeneratedExample(parser, infoOrRef.value); - this.examples[subSchema.title] = new ExampleModel( - parser, - { - value: sample, - }, - this.name, - info.encoding, - ); + if (generatedExamples.length > 1) { + this.examples = Object.fromEntries( + generatedExamples.map(item => [ + item.exampleId, + new ExampleModel( + parser, + { + description: item.exampleDescription, + summary: item.exampleSummary, + value: item.exampleValue, + }, + this.name, + info.encoding, + ), + ]), + ); + } else { + const [generatedExample] = generatedExamples; + if (generatedExample) { + infoOrRef = { + description: generatedExample.exampleDescription, + summary: generatedExample.exampleSummary, + value: generatedExample.exampleValue, + }; + } + this.examples = { + default: new ExampleModel(parser, infoOrRef, this.name, info.encoding), + }; + } } - } else if (this.schema) { - this.examples = { - default: new ExampleModel( - parser, - { - value: Sampler.sample(info.schema as any, samplerOptions, parser.spec), - }, - this.name, - info.encoding, - ), - }; } } + + 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: Example[] = []; + if (subSchema) { + let resolved: OpenAPISchema; + if (subSchema.items) { + resolved = { + ...subSchema, + items: parser.derefSchemaWithExample( + subSchema.items as MergedOpenAPISchema, + Array.isArray(sample) ? sample[0] : sample, + ), + }; + } else { + resolved = parser.derefSchemaWithExample(subSchema, sample); + } + xmlExamples = generateXmlExample({ + includeReadOnly: configAccessOptions?.includeReadOnly, + includeWriteOnly: configAccessOptions?.includeWriteOnly, + schema: resolved, + }); + } + + 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 0000000000..b1c9f0f4b3 --- /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/types/open-api.ts b/src/types/open-api.ts index fd80bf8d6e..838d0db929 100644 --- a/src/types/open-api.ts +++ b/src/types/open-api.ts @@ -112,6 +112,19 @@ export interface OpenAPIExample { externalValue?: string; } +export interface XMLObject { + /** Replaces the name of the element/attribute used for the described schema property. When defined within `items`, it will affect the name of the individual XML elements within the list. When defined alongside `type` being `array` (outside the `items`), it will affect the wrapping element and only if `wrapped` is `true`. If `wrapped` is `false`, it will be ignored. */ + name?: string; + /** The URI of the namespace definition. This MUST be in the form of an absolute URI. */ + namespace?: string; + /** The prefix to be used for the name. */ + prefix?: string; + /** Declares whether the property definition translates to an attribute instead of an element. Default value is `false`. */ + attribute?: boolean; + /** MAY be used only for an array definition. Signifies whether the array is wrapped (for example, ``) or unwrapped (``). Default value is `false`. The definition takes effect only when defined alongside `type` being `array` (outside the `items`). */ + wrapped?: boolean; +} + export interface OpenAPISchema { $ref?: string; type?: string | string[]; @@ -161,6 +174,7 @@ export interface OpenAPISchema { contentMediaType?: string; prefixItems?: OpenAPISchema[]; additionalItems?: OpenAPISchema | boolean; + xml?: XMLObject; } export interface OpenAPIDiscriminator { 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 0000000000..7e427e0a8e --- /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__/__snapshots__/xml.test.ts.snap b/src/utils/__tests__/__snapshots__/xml.test.ts.snap new file mode 100644 index 0000000000..3ca5f9aa62 --- /dev/null +++ b/src/utils/__tests__/__snapshots__/xml.test.ts.snap @@ -0,0 +1,167 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateXmlExample should generate xml example with a complex list and each property has an example: Example 1 1`] = ` +" + + + this is foo + this is bar + + + this is foo2 + this is bar2 + +" +`; + +exports[`generateXmlExample should generate xml example with a list: Example 1 1`] = ` +" + + string + string +" +`; + +exports[`generateXmlExample should generate xml example with readOnly and writeOnly: Example 1 1`] = ` +" + + string + string +" +`; + +exports[`generateXmlExample should generate xml example with readOnly: Example 1 1`] = ` +" + + string + string +" +`; + +exports[`generateXmlExample should generate xml example with writeOnly: Example 1 1`] = ` +" + + string + string +" +`; + +exports[`generateXmlExample should generate xml example with xml attributes: Example 1 1`] = ` +" + + + 0 + + 0 + string + + string + + + Guru + + http://example.com + + + + + + 0 + string + + + available + string + +" +`; + +exports[`generateXmlExample should generate xml example: Example 1 1`] = ` +" + + string + string +" +`; + +exports[`generateXmlExample should generate xml for schemas with an array of items: Example 1 1`] = ` +" + + 0 + + 0 + + 0 + string + + string + + + Guru + + http://example.com + + + + + + 0 + string + + + available + string + + John78 + John + Smith + john.smith@example.com + drowssaP123 + +1-202-555-0192 + 0 + + string + string + string + +" +`; + +exports[`generateXmlExample should generate xml for schemas with an array of items: Example 2 1`] = ` +" + + 0 + + 0 + + 0 + string + + string + + + Guru + + http://example.com + + + + + + 0 + string + + + available + string + + John78 + John + Smith + john.smith@example.com + drowssaP123 + +1-202-555-0192 + 0 + 0 +" +`; diff --git a/src/utils/__tests__/csv.test.ts b/src/utils/__tests__/csv.test.ts new file mode 100644 index 0000000000..2d87d72f4e --- /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 0000000000..60a2dc0c60 --- /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/__tests__/object.test.ts b/src/utils/__tests__/object.test.ts index a9f545fe30..7f4442e8ab 100644 --- a/src/utils/__tests__/object.test.ts +++ b/src/utils/__tests__/object.test.ts @@ -1,4 +1,4 @@ -import { objectHas, objectSet } from '../object'; +import { compact, objectHas, objectSet } from '../object'; describe('object utils', () => { let obj; @@ -48,3 +48,23 @@ describe('object utils', () => { }); }); }); + +describe('compact', () => { + const obj = { + foo: 'bar', + bar: null, + cool: undefined, + test: '', + }; + const obj2 = { + foo: 'bar', + }; + + it('should strip away nullish values from the object', () => { + expect(compact(obj)).toMatchObject(obj2); + }); + + it('should return the same object if there is nothing to compact', () => { + expect(compact(obj2)).toMatchObject(obj2); + }); +}); diff --git a/src/utils/__tests__/xml.test.ts b/src/utils/__tests__/xml.test.ts new file mode 100644 index 0000000000..0db3a41e36 --- /dev/null +++ b/src/utils/__tests__/xml.test.ts @@ -0,0 +1,497 @@ +import { generateXmlExample } from '../xml'; + +describe('generateXmlExample', () => { + it('should generate xml example', () => { + const examples = generateXmlExample({ + includeReadOnly: false, + includeWriteOnly: false, + schema: { + type: 'object', + properties: { + foo: { + type: 'string', + }, + bar: { + type: 'string', + }, + }, + }, + }); + examples.forEach(example => { + expect(example.exampleValue).toMatchSnapshot(example.exampleId); + }); + }); + + it('should generate xml example with readOnly', () => { + const examples = generateXmlExample({ + includeReadOnly: true, + includeWriteOnly: false, + schema: { + type: 'object', + properties: { + foo: { + type: 'string', + readOnly: true, + }, + bar: { + type: 'string', + }, + }, + }, + }); + examples.forEach(example => { + expect(example.exampleValue).toMatchSnapshot(example.exampleId); + }); + }); + + it('should generate xml example with writeOnly', () => { + const examples = generateXmlExample({ + includeReadOnly: false, + includeWriteOnly: true, + schema: { + type: 'object', + properties: { + foo: { + type: 'string', + }, + bar: { + type: 'string', + writeOnly: true, + }, + }, + }, + }); + examples.forEach(example => { + expect(example.exampleValue).toMatchSnapshot(example.exampleId); + }); + }); + + it('should generate xml example with readOnly and writeOnly', () => { + const examples = generateXmlExample({ + includeReadOnly: true, + includeWriteOnly: true, + schema: { + type: 'object', + properties: { + foo: { + type: 'string', + readOnly: true, + }, + bar: { + type: 'string', + writeOnly: true, + }, + }, + }, + }); + examples.forEach(example => { + expect(example.exampleValue).toMatchSnapshot(example.exampleId); + }); + }); + + it('should generate xml example with a list', () => { + const examples = generateXmlExample({ + includeReadOnly: false, + includeWriteOnly: false, + schema: { + type: 'object', + properties: { + foo: { + type: 'array', + items: { + type: 'string', + }, + }, + bar: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + }); + examples.forEach(example => { + expect(example.exampleValue).toMatchSnapshot(example.exampleId); + }); + }); + + it('should generate xml example with a complex list and each property has an example', () => { + const examples = generateXmlExample({ + includeReadOnly: false, + includeWriteOnly: false, + schema: { + type: 'object', + properties: { + foo: { + type: 'array', + items: { + type: 'object', + properties: { + foo: { + type: 'string', + example: 'this is foo', + }, + bar: { + type: 'string', + example: 'this is bar', + }, + }, + }, + }, + bar: { + type: 'array', + items: { + type: 'object', + properties: { + foo: { + type: 'string', + example: 'this is foo2', + }, + bar: { + type: 'string', + example: 'this is bar2', + }, + }, + }, + }, + }, + }, + }); + examples.forEach(example => { + expect(example.exampleValue).toMatchSnapshot(example.exampleId); + }); + }); + + it('should generate xml example with xml attributes', () => { + const examples = generateXmlExample({ + includeReadOnly: true, + includeWriteOnly: true, + schema: { + type: 'array', + maxItems: 999, + items: { + type: 'object', + required: ['name', 'photoUrls'], + discriminator: { + propertyName: 'petType', + mapping: { + cat: '#/components/schemas/Cat', + dog: '#/components/schemas/Dog', + bee: '#/components/schemas/HoneyBee', + }, + }, + properties: { + id: { + description: 'Pet ID', + example: 0, + format: 'int64', + readOnly: true, + type: 'integer', + }, + category: { + description: 'Categories this pet belongs to', + properties: { + id: { + description: 'Category ID', + example: 0, + format: 'int64', + readOnly: true, + type: 'integer', + }, + name: { + description: 'Category name', + example: 'string', + minLength: 1, + type: 'string', + }, + sub: { + description: 'Test Sub Category', + properties: { + prop1: { + description: 'Dumb Property', + example: 'string', + type: 'string', + }, + }, + type: 'object', + }, + }, + type: 'object', + xml: { + name: 'Category', + }, + }, + name: { + description: 'The name given to a pet', + example: 'Guru', + type: 'string', + }, + photoUrls: { + description: 'The list of URL to a cute photos featuring pet', + items: { + type: 'string', + format: 'url', + }, + type: 'array', + xml: { + name: 'photoUrl', + wrapped: true, + }, + }, + friend: {}, + tags: { + description: 'Tags attached to the pet', + items: { + type: 'object', + properties: { + id: { + description: 'Tag ID', + example: 0, + format: 'int64', + readOnly: true, + type: 'integer', + }, + name: { + description: 'Tag name', + example: 'string', + minLength: 1, + type: 'string', + }, + }, + xml: { + name: 'Tag', + }, + }, + type: 'array', + xml: { + name: 'tag', + wrapped: true, + }, + }, + status: { + description: 'Pet status in the store', + enum: ['available', 'pending', 'sold'], + example: 'available', + type: 'string', + }, + petType: { + description: 'Type of a pet', + example: 'string', + type: 'string', + }, + }, + xml: { + name: 'Pet', + }, + }, + }, + }); + examples.forEach(example => { + expect(example.exampleValue).toMatchSnapshot(example.exampleId); + }); + }); + + it('should generate xml for schemas with an array of items', () => { + const examples = generateXmlExample({ + schema: { + type: 'object', + properties: { + id: { + example: 0, + format: 'int64', + readOnly: true, + type: 'integer', + }, + pet: { + properties: { + id: { + description: 'Pet ID', + example: 0, + format: 'int64', + readOnly: true, + type: 'integer', + }, + category: { + description: 'Categories this pet belongs to', + properties: { + id: { + description: 'Category ID', + example: 0, + format: 'int64', + readOnly: true, + type: 'integer', + }, + name: { + description: 'Category name', + example: 'string', + minLength: 1, + type: 'string', + }, + sub: { + description: 'Test Sub Category', + properties: { + prop1: { + description: 'Dumb Property', + example: 'string', + type: 'string', + }, + }, + type: 'object', + }, + }, + type: 'object', + xml: { + name: 'Category', + }, + }, + name: { + description: 'The name given to a pet', + example: 'Guru', + type: 'string', + }, + photoUrls: { + description: 'The list of URL to a cute photos featuring pet', + items: { + type: 'string', + format: 'url', + }, + type: 'array', + xml: { + name: 'photoUrl', + wrapped: true, + }, + }, + friend: {}, + tags: { + description: 'Tags attached to the pet', + items: { + type: 'object', + properties: { + id: { + description: 'Tag ID', + example: 0, + format: 'int64', + readOnly: true, + type: 'integer', + }, + name: { + description: 'Tag name', + example: 'string', + minLength: 1, + type: 'string', + }, + }, + xml: { + name: 'Tag', + }, + }, + type: 'array', + xml: { + name: 'tag', + wrapped: true, + }, + }, + status: { + description: 'Pet status in the store', + enum: ['available', 'pending', 'sold'], + example: 'available', + type: 'string', + }, + petType: { + description: 'Type of a pet', + example: 'string', + type: 'string', + }, + }, + type: 'object', + xml: { + name: 'Pet', + }, + }, + username: { + description: 'User supplied username', + example: 'John78', + minLength: 4, + type: 'string', + }, + firstName: { + description: 'User first name', + example: 'John', + minLength: 1, + type: 'string', + }, + lastName: { + description: 'User last name', + example: 'Smith', + minLength: 1, + type: 'string', + }, + email: { + description: 'User email address', + example: 'john.smith@example.com', + format: 'email', + type: 'string', + }, + password: { + description: + 'User password, MUST contain a mix of upper and lower case letters, as well as digits', + example: 'drowssaP123', + format: 'password', + minLength: 8, + pattern: '/(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])/', + type: 'string', + }, + phone: { + description: 'User phone number in international format', + example: '+1-202-555-0192', + pattern: '/^\\+(?:[0-9]-?){6,14}[0-9]$/', + type: 'string', + }, + userStatus: { + description: 'User status', + example: 0, + format: 'int32', + type: 'integer', + }, + addresses: { + items: [ + { + type: 'object', + properties: { + city: { + example: 'string', + minLength: 0, + type: 'string', + }, + country: { + example: 'string', + minLength: 0, + type: 'string', + }, + street: { + description: 'includes build/apartment number', + example: 'string', + minLength: 0, + type: 'string', + }, + }, + }, + { + type: 'number', + }, + ], + maxLength: 10, + type: 'array', + }, + }, + xml: { + name: 'User', + }, + }, + }); + examples.forEach(example => { + expect(example.exampleValue).toMatchSnapshot(example.exampleId); + }); + }); +}); diff --git a/src/utils/csv.ts b/src/utils/csv.ts new file mode 100644 index 0000000000..1e8b3d9008 --- /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/index.ts b/src/utils/index.ts index c25ed97009..f68a9c852d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,3 +9,4 @@ export * from './decorators'; export * from './debug'; export * from './memoize'; export * from './sort'; +export * from './object'; diff --git a/src/utils/jsonToXml.ts b/src/utils/jsonToXml.ts new file mode 100644 index 0000000000..f245a4aaf6 --- /dev/null +++ b/src/utils/jsonToXml.ts @@ -0,0 +1,85 @@ +/** + * @license + * MIT License + * + * Copyright (c) 2022 Mrinmoy Majumdar + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** + * json2xml + * @example + * Schema: { + * 'prop1' : 'one', + * 'prop2' : 'two', + * 'prop3' : [ 'a', 'b', 'c' ], + * 'prop4' : { + * 'ob1' : 'val-1', + * 'ob2' : 'val-2' + * } + * } + * XML: + * + * simple + * + * <0>a + * <1>b + * <2>c + * + * + * val-1 + * val-2 + * + * + **/ +export const json2xml = (obj: any, level: number = 1): string => { + const indent = ' '.repeat(level); + let xmlText = ''; + if (level === 1 && typeof obj !== 'object') { + return `\n${indent}${obj.toString()}`; + } + for (const prop in obj) { + const tagNameOrProp = obj[prop]?.['::XML_TAG'] || prop; + let tagName = ''; + if (Array.isArray(obj[prop])) { + tagName = tagNameOrProp[0]?.['::XML_TAG'] || `${prop}`; + } else { + tagName = tagNameOrProp; + } + if (prop.startsWith('::')) { + continue; + } + if (Array.isArray(obj[prop])) { + xmlText = `${xmlText}\n${indent}<${tagName}>${json2xml( + obj[prop], + level + 1, + )}\n${indent}`; + } else if (typeof obj[prop] === 'object') { + xmlText = `${xmlText}\n${indent}<${tagName}>${json2xml( + obj[prop], + level + 1, + )}\n${indent}`; + } else { + xmlText = `${xmlText}\n${indent}<${tagName}>${obj[prop].toString()}`; + } + } + return xmlText; +}; diff --git a/src/utils/object.ts b/src/utils/object.ts index 6efaab150b..9c38207519 100644 --- a/src/utils/object.ts +++ b/src/utils/object.ts @@ -26,3 +26,22 @@ export function objectSet(object: object, path: string | Array, value: a const key = _path[limit]; object[key] = value; } + +export const isObjectEmpty = (obj: object): boolean => + !!obj && Object.keys(obj).length === 0 && obj.constructor === Object; + +const emptyValues = new Set([undefined, 'undefined', null, 'null', NaN, 'NaN', '']); + +/** + * Filters out falsy / empty values from an object + */ +export const compact = (toCompact: object): object => { + const removeEmpty = (obj: object) => + Object.fromEntries( + Object.entries(obj) + .filter(([, v]) => !emptyValues.has(v)) + .map(([k, v]) => [k, typeof v === 'object' && !Array.isArray(v) ? removeEmpty(v) : v]), + ); + + return removeEmpty(toCompact); +}; diff --git a/src/utils/xml.ts b/src/utils/xml.ts new file mode 100644 index 0000000000..6be8e41c92 --- /dev/null +++ b/src/utils/xml.ts @@ -0,0 +1,515 @@ +import { MergedOpenAPISchema } from '../services'; +import { OpenAPISchema } from '../types'; +import { json2xml } from './jsonToXml'; +import { Example } from '../types/example'; + +export interface ConfigAccessOptions { + includeReadOnly?: boolean; + includeWriteOnly?: boolean; +} + +interface ExampleConfig extends ConfigAccessOptions { + includeDeprecated?: boolean; + useXmlTagForProp?: boolean; +} + +interface GenerateExampleProps extends ConfigAccessOptions { + schema: MergedOpenAPISchema | undefined; +} + +const mergePropertyExamples = ( + obj: { [x: string]: any }, + propertyName: string, + propExamples: never[], +) => { + // Create an example for each variant of the propertyExample, merging them with the current (parent) example + let i = 0; + const maxCombinations = 10; + const mergedObj = {}; + for (const exampleKey in obj) { + for (const propExampleKey in propExamples) { + const exampleId = getExampleId(i + 1); + mergedObj[exampleId] = { ...obj[exampleKey] }; + mergedObj[exampleId][propertyName] = propExamples[propExampleKey]; + i++; + if (i >= maxCombinations) { + break; + } + } + if (i >= maxCombinations) { + break; + } + } + return mergedObj; +}; + +const addSchemaInfoToExample = (schema: OpenAPISchema, obj: any) => { + if (typeof obj !== 'object' || obj === null) { + return; + } + if (schema.title) { + obj['::TITLE'] = schema.title; + } + if (schema.description) { + obj['::DESCRIPTION'] = schema.description; + } + if (schema.xml?.name) { + obj['::XML_TAG'] = schema.xml?.name; + } + if (schema.xml?.wrapped) { + obj['::XML_WRAP'] = schema.xml?.wrapped.toString(); + } +}; + +const addPropertyExampleToObjectExamples = (example: any, obj: object, propertyKey: string) => { + for (const key in obj) { + obj[key][propertyKey] = example; + } +}; + +const getSampleValueByType = (schemaObj: OpenAPISchema) => { + const example = schemaObj.examples + ? schemaObj.examples[0] + : schemaObj.example === null + ? null + : schemaObj.example || undefined; + if (example === '') { + return ''; + } + if (example === null) { + return null; + } + if (example === 0) { + return 0; + } + if (example === false) { + return false; + } + if (example instanceof Date) { + switch (schemaObj.format?.toLowerCase()) { + case 'date': + return example.toISOString().split('T')[0]; + case 'time': + return example.toISOString().split('T')[1]; + default: + return example.toISOString(); + } + } + if (example) { + return example; + } + + if (Object.keys(schemaObj).length === 0) { + return null; + } + if (schemaObj.$ref) { + // Indicates a Circular ref + return schemaObj.$ref; + } + if (schemaObj.const === null || schemaObj.const === '') { + return schemaObj.const; + } + if (schemaObj.const) { + return schemaObj.const; + } + const typeValue = Array.isArray(schemaObj.type) ? schemaObj.type[0] : schemaObj.type; + if (!typeValue) { + return '?'; + } + if (typeValue.match(/^integer|^number/g)) { + const multipleOf = Number.isNaN(Number(schemaObj.multipleOf)) + ? undefined + : Number(schemaObj.multipleOf); + const maximum = Number.isNaN(Number(schemaObj.maximum)) ? undefined : Number(schemaObj.maximum); + const minimumPossibleVal = Number.isNaN(Number(schemaObj.minimum)) + ? Number.isNaN(Number(schemaObj.exclusiveMinimum)) + ? maximum || 0 + : Number(schemaObj.exclusiveMinimum) + (typeValue.startsWith('integer') ? 1 : 0.001) + : Number(schemaObj.minimum); + return multipleOf + ? multipleOf >= minimumPossibleVal + ? multipleOf + : minimumPossibleVal % multipleOf === 0 + ? minimumPossibleVal + : Math.ceil(minimumPossibleVal / multipleOf) * multipleOf + : minimumPossibleVal; + } + if (typeValue.match(/^boolean/g)) { + return false; + } + if (typeValue.match(/^null/g)) { + return null; + } + if (typeValue.match(/^string/g)) { + if (schemaObj.enum) { + return schemaObj.enum[0]; + } + if (schemaObj.const) { + return schemaObj.const; + } + if (schemaObj.pattern) { + return schemaObj.pattern; + } + if (schemaObj.format) { + const u = `${Date.now().toString(16)}${Math.random().toString(16)}0`.repeat(16); + switch (schemaObj.format.toLowerCase()) { + case 'url': + case 'uri': + return 'http://example.com'; + case 'date': + return new Date(0).toISOString().split('T')[0]; + case 'time': + return new Date(0).toISOString().split('T')[1]; + case 'date-time': + return new Date(0).toISOString(); + case 'duration': + return 'P3Y6M4DT12H30M5S'; // P=Period 3-Years 6-Months 4-Days 12-Hours 30-Minutes 5-Seconds + case 'email': + case 'idn-email': + return 'user@example.com'; + case 'hostname': + case 'idn-hostname': + return 'www.example.com'; + case 'ipv4': + return '198.51.100.42'; + case 'ipv6': + return '2001:0db8:5b96:0000:0000:426f:8e17:642a'; + case 'uuid': + return [ + u.substr(0, 8), + u.substr(8, 4), + `4000-8${u.substr(13, 3)}`, + u.substr(16, 12), + ].join('-'); + case 'byte': + return 'ZXhhbXBsZQ=='; // 'example' base64 encoded. See https://spec.openapis.org/oas/v3.0.0#data-types + default: + return ''; + } + } else { + const minLength = Number.isNaN(schemaObj.minLength) ? undefined : Number(schemaObj.minLength); + const maxLength = Number.isNaN(schemaObj.maxLength) ? undefined : Number(schemaObj.maxLength); + const finalLength = minLength || (maxLength && maxLength > 6 ? 6 : maxLength || undefined); + return finalLength ? 'A'.repeat(finalLength) : 'string'; + } + } + // If type cannot be determined + return '?'; +}; + +const getExampleId = (id: number = 1): string => `Example ${id}`; + +/* For changing JSON-Schema to a Sample Object, as per the schema (to generate examples based on schema) */ +const schemaToSampleObj = (schema: OpenAPISchema, config: ExampleConfig = {}) => { + let obj = {}; + if (!schema) { + return; + } + const defaultExampleId = getExampleId(); + if (schema.allOf) { + const objWithAllProps = {}; + + if (schema.allOf.length === 1 && !schema.allOf[0]?.properties && !schema.allOf[0]?.items) { + // If allOf has single item and the type is not an object or array, then its a primitive + if (schema.allOf[0].$ref) { + return '{ }'; + } + if (schema.allOf[0].readOnly && config.includeReadOnly) { + const tempSchema = schema.allOf[0]; + return getSampleValueByType(tempSchema); + } + return; + } + + schema.allOf.forEach(v => { + if (v.type === 'object' || v.properties || v.allOf || v.anyOf || v.oneOf) { + const partialObj = schemaToSampleObj(v, config); + Object.assign(objWithAllProps, partialObj); + } else if (v.type === 'array' || v.items) { + const partialObj = [schemaToSampleObj(v, config)]; + Object.assign(objWithAllProps, partialObj); + } else if (v.type) { + const prop = `prop${Object.keys(objWithAllProps).length}`; + objWithAllProps[prop] = getSampleValueByType(v); + } else { + return ''; + } + }); + + obj = objWithAllProps; + } else if (schema.oneOf) { + // 1. First create example with scheme.properties + const objWithSchemaProps = {}; + if (schema.properties) { + for (const propertyName in schema.properties) { + if ( + schema.properties[propertyName].properties || + schema.properties[propertyName].properties?.items + ) { + objWithSchemaProps[propertyName] = schemaToSampleObj( + schema.properties[propertyName], + config, + ); + } else { + objWithSchemaProps[propertyName] = getSampleValueByType(schema.properties[propertyName]); + } + } + } + + if (schema.oneOf.length > 0) { + /** + * @example + * oneOf: + * - type: object + * properties: + * option1_PropA: + * type: string + * option1_PropB: + * type: string + * - type: object + * properties: + * option2_PropX: + * type: string + * properties: + * prop1: + * type: string + * prop2: + * type: string + * minLength: 10 + * + * The above Schema should generate the following 2 examples + * + * Example 1 + * { + * prop1: 'string', + * prop2: 'AAAAAAAAAA', <-- min-length 10 + * option1_PropA: 'string', + * option1_PropB: 'string' + * } + * + * Example 2 + * { + * prop1: 'string', + * prop2: 'AAAAAAAAAA', <-- min-length 10 + * option2_PropX: 'string' + * } + */ + let i = 0; + // Merge all examples of each oneOf-schema + for (const key in schema.oneOf) { + const oneOfSamples = schemaToSampleObj(schema.oneOf[key], config); + for (const sampleKey in oneOfSamples) { + const exampleId = getExampleId(i + 1); + // 2. In the final example include a one-of item along with properties + let finalExample; + if (Object.keys(objWithSchemaProps).length > 0) { + if (oneOfSamples[sampleKey] === null || typeof oneOfSamples[sampleKey] !== 'object') { + // This doesn't really make sense since every oneOf schema _should_ be an object if there are common properties, so we'll skip this + continue; + } else { + finalExample = Object.assign(oneOfSamples[sampleKey], objWithSchemaProps); + } + } else { + finalExample = oneOfSamples[sampleKey]; + } + obj[exampleId] = finalExample; + addSchemaInfoToExample(schema.oneOf[key], obj[exampleId]); + i++; + } + } + } + } else if (schema.anyOf) { + // First generate values for regular properties + let commonObj; + if (schema.type === 'object' || schema.properties) { + commonObj = { [defaultExampleId]: {} }; + for (const propertyName in schema.properties) { + if (schema.example) { + commonObj = schema; + break; + } + if (schema.properties[propertyName].deprecated && !config.includeDeprecated) { + continue; + } + if (schema.properties[propertyName].readOnly && !config.includeReadOnly) { + continue; + } + if (schema.properties[propertyName].writeOnly && !config.includeWriteOnly) { + continue; + } + commonObj = mergePropertyExamples( + commonObj, + propertyName, + schemaToSampleObj(schema.properties[propertyName], config), + ); + } + } + + // Combine every variant of the regular properties with every variant of the anyOf samples + let i = 0; + for (const key in schema.anyOf) { + const anyOfSamples = schemaToSampleObj(schema.anyOf[key], config); + for (const sampleKey in anyOfSamples) { + const exampleId = getExampleId(i + 1); + if (typeof commonObj !== 'undefined') { + for (const commonKey in commonObj) { + obj[exampleId] = { ...commonObj[commonKey], ...anyOfSamples[sampleKey] }; + } + } else { + obj[exampleId] = anyOfSamples[sampleKey]; + } + addSchemaInfoToExample(schema.anyOf[key], obj[exampleId]); + i++; + } + } + } else if (schema.type === 'object' || schema.properties) { + obj[defaultExampleId] = {}; + addSchemaInfoToExample(schema, obj[defaultExampleId]); + if (schema.example) { + obj[defaultExampleId] = schema.example; + } else { + for (const propertyName in schema.properties) { + const prop = schema.properties[propertyName] as OpenAPISchema; + if (prop?.deprecated && !config.includeDeprecated) { + continue; + } + if (prop?.readOnly && !config.includeReadOnly) { + continue; + } + if (prop?.writeOnly && !config.includeWriteOnly) { + continue; + } + const propItems = prop?.items as OpenAPISchema; + if (prop?.type === 'array' || propItems) { + if (prop.example) { + addPropertyExampleToObjectExamples(prop.example, obj, propertyName); + } else if (propItems?.example) { + // schemas and properties support single example but not multiple examples. + addPropertyExampleToObjectExamples([propItems.example], obj, propertyName); + } else { + const itemSamples = schemaToSampleObj( + Array.isArray(propItems) ? { allOf: propItems } : propItems, + config, + ); + if (config.useXmlTagForProp) { + const xmlTagName = prop.xml?.name || propertyName; + if (prop.xml?.wrapped) { + const wrappedItemSample = JSON.parse( + `{ "${xmlTagName}" : { "${xmlTagName}" : ${JSON.stringify( + itemSamples[defaultExampleId], + )} } }`, + ); + obj = mergePropertyExamples(obj, xmlTagName, wrappedItemSample); + } else { + obj = mergePropertyExamples(obj, xmlTagName, itemSamples); + } + } else { + const arraySamples = []; + for (const key in itemSamples) { + arraySamples[key] = [itemSamples[key]]; + } + obj = mergePropertyExamples(obj, propertyName, arraySamples); + } + } + continue; + } + obj = mergePropertyExamples( + obj, + propertyName, + schemaToSampleObj(schema.properties[propertyName], config), + ); + } + } + } else if (schema.type === 'array' || schema.items) { + const schemaItems = schema.items as OpenAPISchema; + if (schemaItems || schema.example) { + if (schema.example) { + obj[defaultExampleId] = schema.example; + } else if (schemaItems?.example) { + // schemas and properties support single example but not multiple examples. + obj[defaultExampleId] = [schemaItems.example]; + } else { + const samples = schemaToSampleObj(schemaItems, config); + let i = 0; + for (const key in samples) { + const exampleId = getExampleId(i + 1); + obj[exampleId] = [samples[key]]; + addSchemaInfoToExample(schemaItems, obj[exampleId]); + i++; + } + } + } else { + obj[defaultExampleId] = []; + } + } else { + return { [defaultExampleId]: getSampleValueByType(schema) }; + } + return obj; +}; + +/** + * @license + * MIT License + * + * Copyright (c) 2022 Mrinmoy Majumdar + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +export const generateXmlExample = ({ + includeReadOnly = true, + includeWriteOnly = true, + schema, +}: GenerateExampleProps): Example[] => { + const finalExamples: Example[] = []; + + if (!schema) return finalExamples; + + const xmlRootStart = schema.xml?.name + ? `<${schema.xml.name}${schema.xml.namespace ? ` xmlns="${schema.xml.namespace}"` : ''}>` + : ''; + const xmlRootEnd = schema.xml?.name ? `` : ''; + const samples = schemaToSampleObj(schema, { + includeReadOnly, + includeWriteOnly, + includeDeprecated: true, + useXmlTagForProp: true, + }); + let i = 0; + for (const samplesKey in samples) { + if (!samples[samplesKey]) { + continue; + } + const summary = samples[samplesKey]['::TITLE'] || `Example ${++i}`; + const description = samples[samplesKey]['::DESCRIPTION'] || ''; + const exampleValue = `\n${xmlRootStart}${json2xml( + samples[samplesKey], + 1, + )}\n${xmlRootEnd}`; + + finalExamples.push({ + exampleDescription: description, + exampleId: samplesKey, + exampleSummary: summary, + exampleValue, + }); + } + + return finalExamples; +}; diff --git a/tsconfig.json b/tsconfig.json index 7c4df771c7..58978f3750 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,21 @@ { "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, "experimentalDecorators": true, + "importHelpers": true, + "jsx": "react", + "lib": ["esnext", "dom", "WebWorker.ImportScripts"], "moduleResolution": "node", - "target": "es5", + "noEmitHelpers": true, "noImplicitAny": false, - "noUnusedParameters": true, "noUnusedLocals": true, - "strictNullChecks": true, - "sourceMap": true, - "declaration": true, - "noEmitHelpers": true, - "importHelpers": true, + "noUnusedParameters": true, "outDir": "lib", "pretty": true, - "lib": ["es2015", "es2016", "es2017", "dom", "WebWorker.ImportScripts"], - "jsx": "react", + "sourceMap": true, + "strictNullChecks": true, + "target": "es5", "types": ["webpack", "webpack-env", "jest"] }, "compileOnSave": false,