From 00256a8da434f7b9661a588ac1d38938a4404238 Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Tue, 13 Jun 2023 09:39:31 +0100 Subject: [PATCH 1/7] FBI-451: Implement XML example code generation for Redoc --- src/services/OpenAPIParser.ts | 95 ++- src/services/__tests__/OpenAPIParser.test.ts | 30 + .../__snapshots__/OpenAPIParser.test.ts.snap | 125 ++++ .../__tests__/fixtures/properties.json | 234 +++++++ src/services/models/MediaType.ts | 114 +++- src/types/open-api.ts | 14 + .../__tests__/__snapshots__/xml.test.ts.snap | 167 +++++ src/utils/__tests__/object.test.ts | 22 +- src/utils/__tests__/xml.test.ts | 497 +++++++++++++++ src/utils/index.ts | 1 + src/utils/object.ts | 19 + src/utils/openapi.ts | 4 + src/utils/xml.ts | 573 ++++++++++++++++++ tsconfig.json | 23 +- 14 files changed, 1878 insertions(+), 40 deletions(-) create mode 100644 src/services/__tests__/fixtures/properties.json create mode 100644 src/utils/__tests__/__snapshots__/xml.test.ts.snap create mode 100644 src/utils/__tests__/xml.test.ts create mode 100644 src/utils/xml.ts diff --git a/src/services/OpenAPIParser.ts b/src/services/OpenAPIParser.ts index 1542cf7e..f886ad6a 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,98 @@ export class OpenAPIParser { return receiver; } + 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/__tests__/OpenAPIParser.test.ts b/src/services/__tests__/OpenAPIParser.test.ts index d32e0f8e..6a69535c 100644 --- a/src/services/__tests__/OpenAPIParser.test.ts +++ b/src/services/__tests__/OpenAPIParser.test.ts @@ -72,5 +72,35 @@ 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 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 3b983a1f..0162aadf 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 00000000..cef728ca --- /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 1b7263ae..c9fb1a15 100644 --- a/src/services/models/MediaType.ts +++ b/src/services/models/MediaType.ts @@ -4,9 +4,12 @@ import type { OpenAPIMediaType } from '../../types'; import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { SchemaModel } from './Schema'; -import { isJsonLike, mapValues } from '../../utils'; +import { isJsonLike, isXml, mapValues } from '../../utils'; import type { OpenAPIParser } from '../OpenAPIParser'; import { ExampleModel } from './Example'; +import { ConfigAccessOptions, FinalExamples, generateXmlExample } from '../../utils/xml'; +import { MergedOpenAPISchema } from '../types'; +import { OpenAPIExample, Referenced } from '../../types'; export class MediaTypeModel { examples?: { [name: string]: ExampleModel }; @@ -45,7 +48,7 @@ export class MediaTypeModel { info.encoding, ), }; - } else if (isJsonLike(name)) { + } else if (isJsonLike(name) || isXml(name)) { this.generateExample(parser, info); } } @@ -57,35 +60,92 @@ export class MediaTypeModel { 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, samplerOptions, parser.spec); - if (this.schema.discriminatorProp && typeof sample === 'object' && sample) { - sample[this.schema.discriminatorProp] = subSchema.title; + 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 (isXml(this.name)) { + const xmlExamples = this.resolveXmlExample(parser, sample as OpenAPIExample); + this.examples[subSchema.title].value = xmlExamples[0]?.exampleValue; + } } + } else { + const infoOrRef: Referenced = { + value: Sampler.sample(info.schema as any, samplerOptions, parser.spec), + }; + const xmlExamples = this.resolveXmlExample(parser, infoOrRef.value); - this.examples[subSchema.title] = new ExampleModel( - parser, - { - value: sample, - }, - this.name, - info.encoding, - ); + if (xmlExamples.length > 1) { + this.examples = Object.fromEntries( + xmlExamples.map(item => [ + item.exampleId, + new ExampleModel( + parser, + { + value: item.exampleValue, + }, + this.name, + info.encoding, + ), + ]), + ); + } else { + this.examples = { + default: new ExampleModel( + parser, + { + value: xmlExamples[0]?.exampleValue || infoOrRef.value, + }, + 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, - ), - }; } } + + 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; + 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; + } } diff --git a/src/types/open-api.ts b/src/types/open-api.ts index fd80bf8d..838d0db9 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__/xml.test.ts.snap b/src/utils/__tests__/__snapshots__/xml.test.ts.snap new file mode 100644 index 00000000..8a52f7f5 --- /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-0 1`] = ` +" + + + this is foo + this is bar + + + this is foo2 + this is bar2 + +" +`; + +exports[`generateXmlExample should generate xml example with a list: example-0 1`] = ` +" + + string + string +" +`; + +exports[`generateXmlExample should generate xml example with readOnly and writeOnly: example-0 1`] = ` +" + + string + string +" +`; + +exports[`generateXmlExample should generate xml example with readOnly: example-0 1`] = ` +" + + string + string +" +`; + +exports[`generateXmlExample should generate xml example with writeOnly: example-0 1`] = ` +" + + string + string +" +`; + +exports[`generateXmlExample should generate xml example with xml attributes: example-0 1`] = ` +" + + + 0 + + 0 + string + + string + + + Guru + + http://example.com + + + + + + 0 + string + + + available + string + +" +`; + +exports[`generateXmlExample should generate xml example: example-0 1`] = ` +" + + string + string +" +`; + +exports[`generateXmlExample should generate xml for schemas with an array of items: example-0 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-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 + 0 +" +`; diff --git a/src/utils/__tests__/object.test.ts b/src/utils/__tests__/object.test.ts index a9f545fe..7f4442e8 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 00000000..0db3a41e --- /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/index.ts b/src/utils/index.ts index c25ed970..f68a9c85 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/object.ts b/src/utils/object.ts index 6efaab15..9c382075 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/openapi.ts b/src/utils/openapi.ts index 22424e90..0f38ca9b 100644 --- a/src/utils/openapi.ts +++ b/src/utils/openapi.ts @@ -168,6 +168,10 @@ 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 new file mode 100644 index 00000000..7c433265 --- /dev/null +++ b/src/utils/xml.ts @@ -0,0 +1,573 @@ +import { MergedOpenAPISchema } from '../services'; +import { OpenAPISchema } from '../types'; + +export interface ConfigAccessOptions { + includeReadOnly?: boolean; + includeWriteOnly?: boolean; +} + +export interface ExampleConfig extends ConfigAccessOptions { + includeDeprecated?: boolean; + useXmlTagForProp?: boolean; +} + +export interface GenerateExampleProps extends ConfigAccessOptions { + schema: MergedOpenAPISchema | undefined; +} + +export interface FinalExamples { + exampleDescription: string; + exampleId: string; + exampleSummary: string; + exampleValue: string; +} + +/** + * 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 + * + * + **/ +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; +}; + +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) { + mergedObj[`example-${i}`] = { ...obj[exampleKey] }; + mergedObj[`example-${i}`][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 '?'; +}; + +/* 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; + } + 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) { + // 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[`example-${i}`] = finalExample; + addSchemaInfoToExample(schema.oneOf[key], obj[`example-${i}`]); + i++; + } + } + } + } else if (schema.anyOf) { + // First generate values for regular properties + let commonObj; + if (schema.type === 'object' || schema.properties) { + commonObj = { 'example-0': {} }; + 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) { + if (typeof commonObj !== 'undefined') { + for (const commonKey in commonObj) { + obj[`example-${i}`] = { ...commonObj[commonKey], ...anyOfSamples[sampleKey] }; + } + } else { + obj[`example-${i}`] = anyOfSamples[sampleKey]; + } + addSchemaInfoToExample(schema.anyOf[key], obj[`example-${i}`]); + i++; + } + } + } else if (schema.type === 'object' || schema.properties) { + obj['example-0'] = {}; + addSchemaInfoToExample(schema, obj['example-0']); + if (schema.example) { + obj['example-0'] = 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['example-0'], + )} } }`, + ); + 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['example-0'] = schema.example; + } else if (schemaItems?.example) { + // schemas and properties support single example but not multiple examples. + obj['example-0'] = [schemaItems.example]; + } else { + const samples = schemaToSampleObj(schemaItems, config); + let i = 0; + for (const key in samples) { + obj[`example-${i}`] = [samples[key]]; + addSchemaInfoToExample(schemaItems, obj[`example-${i}`]); + i++; + } + } + } else { + obj['example-0'] = []; + } + } else { + return { 'example-0': 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): FinalExamples[] => { + const finalExamples: FinalExamples[] = []; + + 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 7c4df771..58978f37 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,21 @@ { "compilerOptions": { - "experimentalDecorators": true, - "moduleResolution": "node", - "target": "es5", - "noImplicitAny": false, - "noUnusedParameters": true, - "noUnusedLocals": true, - "strictNullChecks": true, - "sourceMap": true, + "allowSyntheticDefaultImports": true, "declaration": true, - "noEmitHelpers": true, + "experimentalDecorators": true, "importHelpers": true, + "jsx": "react", + "lib": ["esnext", "dom", "WebWorker.ImportScripts"], + "moduleResolution": "node", + "noEmitHelpers": true, + "noImplicitAny": false, + "noUnusedLocals": 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, From 18aadd54975f7ca73019b7f492a67bec0a49fe82 Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Wed, 14 Jun 2023 09:43:01 +0100 Subject: [PATCH 2/7] FBI-451: Improve example ID --- .../__tests__/__snapshots__/xml.test.ts.snap | 18 +++---- src/utils/xml.ts | 47 +++++++++++-------- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/src/utils/__tests__/__snapshots__/xml.test.ts.snap b/src/utils/__tests__/__snapshots__/xml.test.ts.snap index 8a52f7f5..3ca5f9aa 100644 --- a/src/utils/__tests__/__snapshots__/xml.test.ts.snap +++ b/src/utils/__tests__/__snapshots__/xml.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`generateXmlExample should generate xml example with a complex list and each property has an example: example-0 1`] = ` +exports[`generateXmlExample should generate xml example with a complex list and each property has an example: Example 1 1`] = ` " @@ -14,7 +14,7 @@ exports[`generateXmlExample should generate xml example with a complex list and " `; -exports[`generateXmlExample should generate xml example with a list: example-0 1`] = ` +exports[`generateXmlExample should generate xml example with a list: Example 1 1`] = ` " string @@ -22,7 +22,7 @@ exports[`generateXmlExample should generate xml example with a list: example-0 1 " `; -exports[`generateXmlExample should generate xml example with readOnly and writeOnly: example-0 1`] = ` +exports[`generateXmlExample should generate xml example with readOnly and writeOnly: Example 1 1`] = ` " string @@ -30,7 +30,7 @@ exports[`generateXmlExample should generate xml example with readOnly and writeO " `; -exports[`generateXmlExample should generate xml example with readOnly: example-0 1`] = ` +exports[`generateXmlExample should generate xml example with readOnly: Example 1 1`] = ` " string @@ -38,7 +38,7 @@ exports[`generateXmlExample should generate xml example with readOnly: example-0 " `; -exports[`generateXmlExample should generate xml example with writeOnly: example-0 1`] = ` +exports[`generateXmlExample should generate xml example with writeOnly: Example 1 1`] = ` " string @@ -46,7 +46,7 @@ exports[`generateXmlExample should generate xml example with writeOnly: example- " `; -exports[`generateXmlExample should generate xml example with xml attributes: example-0 1`] = ` +exports[`generateXmlExample should generate xml example with xml attributes: Example 1 1`] = ` " @@ -76,7 +76,7 @@ exports[`generateXmlExample should generate xml example with xml attributes: exa " `; -exports[`generateXmlExample should generate xml example: example-0 1`] = ` +exports[`generateXmlExample should generate xml example: Example 1 1`] = ` " string @@ -84,7 +84,7 @@ exports[`generateXmlExample should generate xml example: example-0 1`] = ` " `; -exports[`generateXmlExample should generate xml for schemas with an array of items: example-0 1`] = ` +exports[`generateXmlExample should generate xml for schemas with an array of items: Example 1 1`] = ` " 0 @@ -127,7 +127,7 @@ exports[`generateXmlExample should generate xml for schemas with an array of ite " `; -exports[`generateXmlExample should generate xml for schemas with an array of items: example-1 1`] = ` +exports[`generateXmlExample should generate xml for schemas with an array of items: Example 2 1`] = ` " 0 diff --git a/src/utils/xml.ts b/src/utils/xml.ts index 7c433265..5e9416a0 100644 --- a/src/utils/xml.ts +++ b/src/utils/xml.ts @@ -93,8 +93,9 @@ const mergePropertyExamples = ( const mergedObj = {}; for (const exampleKey in obj) { for (const propExampleKey in propExamples) { - mergedObj[`example-${i}`] = { ...obj[exampleKey] }; - mergedObj[`example-${i}`][propertyName] = propExamples[propExampleKey]; + const exampleId = getExampleId(i + 1); + mergedObj[exampleId] = { ...obj[exampleKey] }; + mergedObj[exampleId][propertyName] = propExamples[propExampleKey]; i++; if (i >= maxCombinations) { break; @@ -261,12 +262,15 @@ const getSampleValueByType = (schemaObj: OpenAPISchema) => { 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 = {}; @@ -340,7 +344,7 @@ const schemaToSampleObj = (schema: OpenAPISchema, config: ExampleConfig = {}) => * * The above Schema should generate the following 2 examples * - * Example-1 + * Example 1 * { * prop1: 'string', * prop2: 'AAAAAAAAAA', <-- min-length 10 @@ -348,7 +352,7 @@ const schemaToSampleObj = (schema: OpenAPISchema, config: ExampleConfig = {}) => * option1_PropB: 'string' * } * - * Example-2 + * Example 2 * { * prop1: 'string', * prop2: 'AAAAAAAAAA', <-- min-length 10 @@ -360,6 +364,7 @@ const schemaToSampleObj = (schema: OpenAPISchema, config: ExampleConfig = {}) => 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) { @@ -372,8 +377,8 @@ const schemaToSampleObj = (schema: OpenAPISchema, config: ExampleConfig = {}) => } else { finalExample = oneOfSamples[sampleKey]; } - obj[`example-${i}`] = finalExample; - addSchemaInfoToExample(schema.oneOf[key], obj[`example-${i}`]); + obj[exampleId] = finalExample; + addSchemaInfoToExample(schema.oneOf[key], obj[exampleId]); i++; } } @@ -382,7 +387,7 @@ const schemaToSampleObj = (schema: OpenAPISchema, config: ExampleConfig = {}) => // First generate values for regular properties let commonObj; if (schema.type === 'object' || schema.properties) { - commonObj = { 'example-0': {} }; + commonObj = { [defaultExampleId]: {} }; for (const propertyName in schema.properties) { if (schema.example) { commonObj = schema; @@ -410,22 +415,23 @@ const schemaToSampleObj = (schema: OpenAPISchema, config: ExampleConfig = {}) => 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[`example-${i}`] = { ...commonObj[commonKey], ...anyOfSamples[sampleKey] }; + obj[exampleId] = { ...commonObj[commonKey], ...anyOfSamples[sampleKey] }; } } else { - obj[`example-${i}`] = anyOfSamples[sampleKey]; + obj[exampleId] = anyOfSamples[sampleKey]; } - addSchemaInfoToExample(schema.anyOf[key], obj[`example-${i}`]); + addSchemaInfoToExample(schema.anyOf[key], obj[exampleId]); i++; } } } else if (schema.type === 'object' || schema.properties) { - obj['example-0'] = {}; - addSchemaInfoToExample(schema, obj['example-0']); + obj[defaultExampleId] = {}; + addSchemaInfoToExample(schema, obj[defaultExampleId]); if (schema.example) { - obj['example-0'] = schema.example; + obj[defaultExampleId] = schema.example; } else { for (const propertyName in schema.properties) { const prop = schema.properties[propertyName] as OpenAPISchema; @@ -455,7 +461,7 @@ const schemaToSampleObj = (schema: OpenAPISchema, config: ExampleConfig = {}) => if (prop.xml?.wrapped) { const wrappedItemSample = JSON.parse( `{ "${xmlTagName}" : { "${xmlTagName}" : ${JSON.stringify( - itemSamples['example-0'], + itemSamples[defaultExampleId], )} } }`, ); obj = mergePropertyExamples(obj, xmlTagName, wrappedItemSample); @@ -483,24 +489,25 @@ const schemaToSampleObj = (schema: OpenAPISchema, config: ExampleConfig = {}) => const schemaItems = schema.items as OpenAPISchema; if (schemaItems || schema.example) { if (schema.example) { - obj['example-0'] = schema.example; + obj[defaultExampleId] = schema.example; } else if (schemaItems?.example) { // schemas and properties support single example but not multiple examples. - obj['example-0'] = [schemaItems.example]; + obj[defaultExampleId] = [schemaItems.example]; } else { const samples = schemaToSampleObj(schemaItems, config); let i = 0; for (const key in samples) { - obj[`example-${i}`] = [samples[key]]; - addSchemaInfoToExample(schemaItems, obj[`example-${i}`]); + const exampleId = getExampleId(i + 1); + obj[exampleId] = [samples[key]]; + addSchemaInfoToExample(schemaItems, obj[exampleId]); i++; } } } else { - obj['example-0'] = []; + obj[defaultExampleId] = []; } } else { - return { 'example-0': getSampleValueByType(schema) }; + return { [defaultExampleId]: getSampleValueByType(schema) }; } return obj; }; From eee14dd87e2b18f0c1b0e96ea5763fe7d6637304 Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Wed, 14 Jun 2023 11:30:03 +0100 Subject: [PATCH 3/7] FBI-451: Provide `codeSamplesLanguage` as an option --- README.md | 12 +++ demo/playground/hmr-playground.tsx | 6 +- .../DiscriminatorDropdown.test.tsx.snap | 30 +++++++ src/services/RedocNormalizedOptions.ts | 17 ++++ src/services/models/MediaType.ts | 12 ++- src/utils/jsonToXml.ts | 85 +++++++++++++++++++ src/utils/xml.ts | 61 +------------ 7 files changed, 158 insertions(+), 65 deletions(-) create mode 100644 src/utils/jsonToXml.ts diff --git a/README.md b/README.md index 58cf29f6..d582cbd1 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,17 @@ 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 5f865af5..2dbf7172 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/services/RedocNormalizedOptions.ts b/src/services/RedocNormalizedOptions.ts index 4a219eef..17204f6e 100644 --- a/src/services/RedocNormalizedOptions.ts +++ b/src/services/RedocNormalizedOptions.ts @@ -6,6 +6,8 @@ import { setRedocLabels } from './Labels'; import { SideNavStyleEnum } from './types'; import type { LabelsConfigRaw, MDXComponentMeta } from './types'; +export type CodeSamplesLanguage = 'json' | 'xml'; + export interface RedocRawOptions { theme?: ThemeInterface; scrollYOffset?: number | string | (() => number); @@ -56,6 +58,7 @@ export interface RedocRawOptions { hideFab?: boolean; minCharacterLengthToInitSearch?: number; showWebhookVerb?: boolean; + codeSamplesLanguages?: CodeSamplesLanguage[]; } export function argValueToBoolean(val?: string | boolean, defaultValue?: boolean): boolean { @@ -211,6 +214,16 @@ export class RedocNormalizedOptions { return 10; } + private static normalizeCodeSamplesLanguages( + value?: CodeSamplesLanguage[], + ): CodeSamplesLanguage[] { + if (isArray(value)) { + return value.map(lang => lang.toLowerCase()) as CodeSamplesLanguage[]; + } + + return ['json']; + } + theme: ResolvedThemeInterface; scrollYOffset: () => number; hideHostname: boolean; @@ -258,6 +271,7 @@ export class RedocNormalizedOptions { showWebhookVerb: boolean; nonce?: string; + codeSamplesLanguages: CodeSamplesLanguage[]; constructor(raw: RedocRawOptions, defaults: RedocRawOptions = {}) { raw = { ...defaults, ...raw }; @@ -335,5 +349,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/models/MediaType.ts b/src/services/models/MediaType.ts index c9fb1a15..a3556d66 100644 --- a/src/services/models/MediaType.ts +++ b/src/services/models/MediaType.ts @@ -4,7 +4,7 @@ import type { OpenAPIMediaType } from '../../types'; import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { SchemaModel } from './Schema'; -import { isJsonLike, isXml, mapValues } from '../../utils'; +import { isXml, mapValues } from '../../utils'; import type { OpenAPIParser } from '../OpenAPIParser'; import { ExampleModel } from './Example'; import { ConfigAccessOptions, FinalExamples, generateXmlExample } from '../../utils/xml'; @@ -34,6 +34,9 @@ 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), + ); if (info.examples !== undefined) { this.examples = mapValues( info.examples, @@ -48,7 +51,7 @@ export class MediaTypeModel { info.encoding, ), }; - } else if (isJsonLike(name) || isXml(name)) { + } else if (isCodeGenerationSupported) { this.generateExample(parser, info); } } @@ -78,8 +81,9 @@ export class MediaTypeModel { this.name, info.encoding, ); - if (isXml(this.name)) { - const xmlExamples = this.resolveXmlExample(parser, sample as OpenAPIExample); + + const xmlExamples = this.resolveXmlExample(parser, sample as OpenAPIExample); + if (xmlExamples[0]) { this.examples[subSchema.title].value = xmlExamples[0]?.exampleValue; } } diff --git a/src/utils/jsonToXml.ts b/src/utils/jsonToXml.ts new file mode 100644 index 00000000..f245a4aa --- /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/xml.ts b/src/utils/xml.ts index 5e9416a0..5d762298 100644 --- a/src/utils/xml.ts +++ b/src/utils/xml.ts @@ -1,5 +1,6 @@ import { MergedOpenAPISchema } from '../services'; import { OpenAPISchema } from '../types'; +import { json2xml } from './jsonToXml'; export interface ConfigAccessOptions { includeReadOnly?: boolean; @@ -22,66 +23,6 @@ export interface FinalExamples { exampleValue: string; } -/** - * 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 - * - * - **/ -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; -}; - const mergePropertyExamples = ( obj: { [x: string]: any }, propertyName: string, From d1b54be4c58072698164feb7748d52936cd19b43 Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Wed, 14 Jun 2023 11:30:03 +0100 Subject: [PATCH 4/7] FBI-451: Provide `codeSamplesLanguage` as an option --- README.md | 12 +++ demo/playground/hmr-playground.tsx | 6 +- .../DiscriminatorDropdown.test.tsx.snap | 30 +++++++ src/services/RedocNormalizedOptions.ts | 17 ++++ src/services/models/MediaType.ts | 12 ++- src/utils/jsonToXml.ts | 85 +++++++++++++++++++ src/utils/xml.ts | 61 +------------ 7 files changed, 158 insertions(+), 65 deletions(-) create mode 100644 src/utils/jsonToXml.ts diff --git a/README.md b/README.md index 58cf29f6..6d8cb7e9 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,17 @@ 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 5f865af5..2dbf7172 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/services/RedocNormalizedOptions.ts b/src/services/RedocNormalizedOptions.ts index 4a219eef..17204f6e 100644 --- a/src/services/RedocNormalizedOptions.ts +++ b/src/services/RedocNormalizedOptions.ts @@ -6,6 +6,8 @@ import { setRedocLabels } from './Labels'; import { SideNavStyleEnum } from './types'; import type { LabelsConfigRaw, MDXComponentMeta } from './types'; +export type CodeSamplesLanguage = 'json' | 'xml'; + export interface RedocRawOptions { theme?: ThemeInterface; scrollYOffset?: number | string | (() => number); @@ -56,6 +58,7 @@ export interface RedocRawOptions { hideFab?: boolean; minCharacterLengthToInitSearch?: number; showWebhookVerb?: boolean; + codeSamplesLanguages?: CodeSamplesLanguage[]; } export function argValueToBoolean(val?: string | boolean, defaultValue?: boolean): boolean { @@ -211,6 +214,16 @@ export class RedocNormalizedOptions { return 10; } + private static normalizeCodeSamplesLanguages( + value?: CodeSamplesLanguage[], + ): CodeSamplesLanguage[] { + if (isArray(value)) { + return value.map(lang => lang.toLowerCase()) as CodeSamplesLanguage[]; + } + + return ['json']; + } + theme: ResolvedThemeInterface; scrollYOffset: () => number; hideHostname: boolean; @@ -258,6 +271,7 @@ export class RedocNormalizedOptions { showWebhookVerb: boolean; nonce?: string; + codeSamplesLanguages: CodeSamplesLanguage[]; constructor(raw: RedocRawOptions, defaults: RedocRawOptions = {}) { raw = { ...defaults, ...raw }; @@ -335,5 +349,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/models/MediaType.ts b/src/services/models/MediaType.ts index c9fb1a15..a3556d66 100644 --- a/src/services/models/MediaType.ts +++ b/src/services/models/MediaType.ts @@ -4,7 +4,7 @@ import type { OpenAPIMediaType } from '../../types'; import type { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { SchemaModel } from './Schema'; -import { isJsonLike, isXml, mapValues } from '../../utils'; +import { isXml, mapValues } from '../../utils'; import type { OpenAPIParser } from '../OpenAPIParser'; import { ExampleModel } from './Example'; import { ConfigAccessOptions, FinalExamples, generateXmlExample } from '../../utils/xml'; @@ -34,6 +34,9 @@ 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), + ); if (info.examples !== undefined) { this.examples = mapValues( info.examples, @@ -48,7 +51,7 @@ export class MediaTypeModel { info.encoding, ), }; - } else if (isJsonLike(name) || isXml(name)) { + } else if (isCodeGenerationSupported) { this.generateExample(parser, info); } } @@ -78,8 +81,9 @@ export class MediaTypeModel { this.name, info.encoding, ); - if (isXml(this.name)) { - const xmlExamples = this.resolveXmlExample(parser, sample as OpenAPIExample); + + const xmlExamples = this.resolveXmlExample(parser, sample as OpenAPIExample); + if (xmlExamples[0]) { this.examples[subSchema.title].value = xmlExamples[0]?.exampleValue; } } diff --git a/src/utils/jsonToXml.ts b/src/utils/jsonToXml.ts new file mode 100644 index 00000000..f245a4aa --- /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/xml.ts b/src/utils/xml.ts index 5e9416a0..5d762298 100644 --- a/src/utils/xml.ts +++ b/src/utils/xml.ts @@ -1,5 +1,6 @@ import { MergedOpenAPISchema } from '../services'; import { OpenAPISchema } from '../types'; +import { json2xml } from './jsonToXml'; export interface ConfigAccessOptions { includeReadOnly?: boolean; @@ -22,66 +23,6 @@ export interface FinalExamples { exampleValue: string; } -/** - * 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 - * - * - **/ -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; -}; - const mergePropertyExamples = ( obj: { [x: string]: any }, propertyName: string, From 8e56a3b12511a8e58594b6899e8ce9e0fc4ffaa1 Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Wed, 14 Jun 2023 12:42:49 +0100 Subject: [PATCH 5/7] FBI-451: clean up --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d8cb7e9..99afd7a8 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ You can use all of the following options with the standalone version of the ` theme object * `spacing` From f29b7aeb0899efbf293871651d1b504bf4823787 Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Wed, 14 Jun 2023 16:02:08 +0100 Subject: [PATCH 6/7] FBI-451: clean up --- src/constants/languages.ts | 4 ++++ src/services/RedocNormalizedOptions.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 src/constants/languages.ts diff --git a/src/constants/languages.ts b/src/constants/languages.ts new file mode 100644 index 00000000..c8dca0da --- /dev/null +++ b/src/constants/languages.ts @@ -0,0 +1,4 @@ +export const CODE_SAMPLE_LANGUAGES = { + JSON: 'json', + XML: 'xml', +} as const; diff --git a/src/services/RedocNormalizedOptions.ts b/src/services/RedocNormalizedOptions.ts index 17204f6e..45e10660 100644 --- a/src/services/RedocNormalizedOptions.ts +++ b/src/services/RedocNormalizedOptions.ts @@ -5,8 +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 = 'json' | 'xml'; +export type CodeSamplesLanguage = typeof CODE_SAMPLE_LANGUAGES[keyof typeof CODE_SAMPLE_LANGUAGES]; export interface RedocRawOptions { theme?: ThemeInterface; From 69c7fc4f34eda7a2d521489957752963efe82d3c Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Mon, 19 Jun 2023 15:51:03 +0100 Subject: [PATCH 7/7] FBI-451: address CR comments --- src/services/RedocNormalizedOptions.ts | 2 +- src/services/__tests__/OpenAPIParser.test.ts | 11 ++++++----- src/services/models/MediaType.ts | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/services/RedocNormalizedOptions.ts b/src/services/RedocNormalizedOptions.ts index 45e10660..d117a188 100644 --- a/src/services/RedocNormalizedOptions.ts +++ b/src/services/RedocNormalizedOptions.ts @@ -222,7 +222,7 @@ export class RedocNormalizedOptions { return value.map(lang => lang.toLowerCase()) as CodeSamplesLanguage[]; } - return ['json']; + return [CODE_SAMPLE_LANGUAGES.JSON]; } theme: ResolvedThemeInterface; diff --git a/src/services/__tests__/OpenAPIParser.test.ts b/src/services/__tests__/OpenAPIParser.test.ts index 6a69535c..bb63b68f 100644 --- a/src/services/__tests__/OpenAPIParser.test.ts +++ b/src/services/__tests__/OpenAPIParser.test.ts @@ -76,26 +76,27 @@ describe('Models', () => { 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', + name: string, sub: { - prop1: 'string', + prop1: string, }, }, name: 'Guru', - photoUrls: ['string'], + photoUrls: [string], friend: {}, tags: [ { id: 0, - name: 'string', + name: string, }, ], status: 'available', - petType: 'string', + petType: string, }; expect( diff --git a/src/services/models/MediaType.ts b/src/services/models/MediaType.ts index a3556d66..71f7eacc 100644 --- a/src/services/models/MediaType.ts +++ b/src/services/models/MediaType.ts @@ -84,7 +84,7 @@ export class MediaTypeModel { const xmlExamples = this.resolveXmlExample(parser, sample as OpenAPIExample); if (xmlExamples[0]) { - this.examples[subSchema.title].value = xmlExamples[0]?.exampleValue; + this.examples[subSchema.title].value = xmlExamples[0].exampleValue; } } } else {