FBI-451: Implement XML example code generation for Redoc

This commit is contained in:
Rishi Tank 2023-06-13 09:39:31 +01:00
parent 7e4639e8cf
commit 00256a8da4
No known key found for this signature in database
GPG Key ID: BF025217B93532E7
14 changed files with 1878 additions and 40 deletions

View File

@ -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

View File

@ -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();
});
});
});

View File

@ -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 [

View File

@ -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"
}
}
}
}
}

View File

@ -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<OpenAPIExample> = {
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;
}
}

View File

@ -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, `<books><book/><book/></books>`) or unwrapped (`<book/><book/>`). 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 {

View File

@ -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`] = `
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<root>
<foo>
<foo>this is foo</foo>
<bar>this is bar</bar>
</foo>
<bar>
<foo>this is foo2</foo>
<bar>this is bar2</bar>
</bar>
</root>"
`;
exports[`generateXmlExample should generate xml example with a list: example-0 1`] = `
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<root>
<foo>string</foo>
<bar>string</bar>
</root>"
`;
exports[`generateXmlExample should generate xml example with readOnly and writeOnly: example-0 1`] = `
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<root>
<foo>string</foo>
<bar>string</bar>
</root>"
`;
exports[`generateXmlExample should generate xml example with readOnly: example-0 1`] = `
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<root>
<foo>string</foo>
<bar>string</bar>
</root>"
`;
exports[`generateXmlExample should generate xml example with writeOnly: example-0 1`] = `
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<root>
<foo>string</foo>
<bar>string</bar>
</root>"
`;
exports[`generateXmlExample should generate xml example with xml attributes: example-0 1`] = `
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<root>
<Pet>
<id>0</id>
<Category>
<id>0</id>
<name>string</name>
<sub>
<prop1>string</prop1>
</sub>
</Category>
<name>Guru</name>
<photoUrl>
<photoUrl>http://example.com</photoUrl>
</photoUrl>
<friend>
</friend>
<tag>
<Tag>
<id>0</id>
<name>string</name>
</Tag>
</tag>
<status>available</status>
<petType>string</petType>
</Pet>
</root>"
`;
exports[`generateXmlExample should generate xml example: example-0 1`] = `
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<root>
<foo>string</foo>
<bar>string</bar>
</root>"
`;
exports[`generateXmlExample should generate xml for schemas with an array of items: example-0 1`] = `
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<User>
<id>0</id>
<Pet>
<id>0</id>
<Category>
<id>0</id>
<name>string</name>
<sub>
<prop1>string</prop1>
</sub>
</Category>
<name>Guru</name>
<photoUrl>
<photoUrl>http://example.com</photoUrl>
</photoUrl>
<friend>
</friend>
<tag>
<Tag>
<id>0</id>
<name>string</name>
</Tag>
</tag>
<status>available</status>
<petType>string</petType>
</Pet>
<username>John78</username>
<firstName>John</firstName>
<lastName>Smith</lastName>
<email>john.smith@example.com</email>
<password>drowssaP123</password>
<phone>+1-202-555-0192</phone>
<userStatus>0</userStatus>
<addresses>
<city>string</city>
<country>string</country>
<street>string</street>
</addresses>
</User>"
`;
exports[`generateXmlExample should generate xml for schemas with an array of items: example-1 1`] = `
"<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<User>
<id>0</id>
<Pet>
<id>0</id>
<Category>
<id>0</id>
<name>string</name>
<sub>
<prop1>string</prop1>
</sub>
</Category>
<name>Guru</name>
<photoUrl>
<photoUrl>http://example.com</photoUrl>
</photoUrl>
<friend>
</friend>
<tag>
<Tag>
<id>0</id>
<name>string</name>
</Tag>
</tag>
<status>available</status>
<petType>string</petType>
</Pet>
<username>John78</username>
<firstName>John</firstName>
<lastName>Smith</lastName>
<email>john.smith@example.com</email>
<password>drowssaP123</password>
<phone>+1-202-555-0192</phone>
<userStatus>0</userStatus>
<addresses>0</addresses>
</User>"
`;

View File

@ -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);
});
});

View File

@ -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);
});
});
});

View File

@ -9,3 +9,4 @@ export * from './decorators';
export * from './debug';
export * from './memoize';
export * from './sort';
export * from './object';

View File

@ -26,3 +26,22 @@ export function objectSet(object: object, path: string | Array<string>, 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);
};

View File

@ -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);

573
src/utils/xml.ts Normal file
View File

@ -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:
* <root>
* <prop1>simple</prop1>
* <prop2>
* <0>a</0>
* <1>b</1>
* <2>c</2>
* </prop2>
* <prop3>
* <ob1>val-1</ob1>
* <ob2>val-2</ob2>
* </prop3>
* </root>
**/
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}</${tagName}>`;
} else if (typeof obj[prop] === 'object') {
xmlText = `${xmlText}\n${indent}<${tagName}>${json2xml(
obj[prop],
level + 1,
)}\n${indent}</${tagName}>`;
} else {
xmlText = `${xmlText}\n${indent}<${tagName}>${obj[prop].toString()}</${tagName}>`;
}
}
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}"` : ''}>`
: '<root>';
const xmlRootEnd = schema.xml?.name ? `</${schema.xml.name}>` : '</root>';
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 = `<?xml version="1.0" encoding="UTF-8"?>\n${xmlRootStart}${json2xml(
samples[samplesKey],
1,
)}\n${xmlRootEnd}`;
finalExamples.push({
exampleDescription: description,
exampleId: samplesKey,
exampleSummary: summary,
exampleValue,
});
}
return finalExamples;
};

View File

@ -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,