In my TypeScript app, I'm taking an OpenAPI spec and constructing an example from it. So the spec might look something like this, to simplify:
const spec = {
type: 'object',
properties: {
firstName: {
type: 'string',
example: 'Johnbob',
},
lastName: {
type: 'string',
// No example provided
},
age: {
type: 'integer',
example: 30
},
}
}
Etc. It's much more complicated than that, because OpenAPI also has more complicated "keywords" (oneOf
, anyOf
), as well as array types, and objects/arrays/keywords can be nested within one another.
But fundamentally, any OpenAPI specification for a "schema" can be converted into an example object, including with auto-generated dummy examples. The above would become something like this once I've turned it into an example:
{
firstName: 'Johnbob',
lastName: 'Default example string',
age: 30
}
The question: Is there any way to automatically infer/generate the type of the generated example? I know I could do this:
const example = {
firstName: 'Johnbob',
lastName: 'Default example string',
age: 30
}
// Ideally, { firstName: string, lastName: string, age: number }
type ExampleType = typeof example;
But I want the return of my generate-example functionality to be typed automatically. Currently, it's just throwing its hands up and returning any
.
Its basic structure is that it has a processSchema
function that takes any schema type (whether object, oneOf, or simple integer type), and then recursively runs through it "processing" each child.
Full playground of code here, and the current WIP implementation below:
type PropertyType = "string" | "number" | "integer" | "object" | "boolean" | "array";
type Property =
| BooleanProperty
| NumberProperty
| IntegerProperty
| StringProperty
| ObjectProperty
| ArrayProperty;
interface OneOf {
oneOf: PropertyOrKeyword[];
}
interface AnyOf {
anyOf: PropertyOrKeyword[];
}
type Keyword = OneOf | AnyOf;
type PropertyOrKeyword = Property | Keyword;
type Properties = Record<string, PropertyOrKeyword>;
interface BaseProperty<T> {
type: PropertyType;
enum?: Array<T>;
example?: T;
description?: string;
}
interface BooleanProperty extends BaseProperty<boolean> {
type: "boolean";
}
interface NumberProperty extends BaseProperty<number> {
type: "number";
minimum?: number;
maximum?: number;
format?: "float";
}
interface IntegerProperty extends BaseProperty<number> {
type: "integer";
minimum?: number;
maximum?: number;
}
type StringFormats =
// OpenAPI built-in formats: https://swagger.io/docs/specification/data-models/data-types/#string
| "date"
| "date-time"
| "password"
| "byte"
| "binary"
// But arbitrary others are accepted
| "uuid"
| "email";
interface StringProperty extends BaseProperty<string> {
type: "string";
format?: StringFormats;
/** A string of a regex pattern **/
pattern?: string;
}
interface ObjectProperty extends BaseProperty<Record<string, Property>> {
type: "object";
properties: Record<string, PropertyOrKeyword>;
required?: string[];
title?: string; // If a schema
additionalProperties?: boolean;
}
interface ArrayProperty extends BaseProperty<Array<any>> {
type: "array";
items: PropertyOrKeyword;
}
class Example {
example;
schema: PropertyOrKeyword;
constructor(schema: PropertyOrKeyword) {
this.schema = schema;
const value = this._processSchema(schema);
this.example = value as typeof value;
}
fullExample(description?: string, externalValue?: string) {
return { value: this.example, description, externalValue };
}
/** Traverses schema and builds an example object from its properties */
_processSchema(schema: PropertyOrKeyword) {
if ("oneOf" in schema) {
return this._processSchema(schema.oneOf[0]);
} else if ("anyOf" in schema) {
return this._processSchema(schema.anyOf[0]);
} else if ("items" in schema) {
return [this._processSchema(schema.items)];
} else if ("type" in schema) {
if (schema.type === "object") {
return Object.entries(schema.properties).reduce(
(obj, [key, val]) => ({
[key]: this._processSchema(val as PropertyOrKeyword),
...obj,
}),
{} as object
);
} else {
if (["integer", "number"].includes(schema.type)) this._processSimpleProperty(schema) as number;
if (schema.type === "boolean") this._processSimpleProperty(schema) as boolean;
if (schema.type === "number") this._processSimpleProperty(schema) as number;
return this._processSimpleProperty(schema) as string;
}
}
}
/** Produces a sensible example for non-object properties */
_processSimpleProperty(
prop: NumberProperty | StringProperty | BooleanProperty | IntegerProperty
): number | boolean | string {
// If an example has been explicitly set, return that
if (prop.example) return prop.example;
// If an enum type, grab the first option as an example
if (prop.enum) return prop.enum[0];
// If a string type with format, return a formatted string
if (prop.type === "string" && prop.format) {
return {
uuid: "asdfa-sdfea-wor13-dscas",
date: "1970-01-14",
["date-time"]: "1970-01-14T05:34:58Z+01:00",
email: "email@email.com",
password: "s00pers33cret",
byte: "0d5b4d43dbf25c433a455d4e736684570e78950d",
binary: "01101001001010100111010100100110100d",
}[prop.format] as string;
}
// Otherwise, return a sensible default
return {
string: "Example string",
integer: 5,
number: 4.5,
boolean: false,
}[prop.type];
}
}
const spec: ObjectProperty = {
type: 'object',
properties: {
firstName: {
type: 'string',
example: 'Johnbob',
},
lastName: {
type: 'string',
// No example provided
},
age: {
type: 'integer',
example: 30
},
}
};
const spec: ObjectProperty = {
type: 'object',
properties: {
firstName: {
type: 'string',
example: 'Johnbob',
},
lastName: {
type: 'string',
// No example provided
},
age: {
type: 'integer',
example: 30
},
favoriteThing: {
oneOf: [{
type: 'object',
properties: {
name: {
type: 'string',
example: 'Beer'
},
liters: {
type: 'integer',
example: 1
}
}
},
{
type: 'object',
properties: {
name: {
type: 'string',
example: 'Movie'
},
lengthInMins: {
type: 'integer',
example: 120
}
}
}
]
}
}
};
console.log(new Example(spec).example)