Skip to content

Commit

Permalink
feat: support boolean schemas
Browse files Browse the repository at this point in the history
ref #605
  • Loading branch information
emonadeo committed Apr 24, 2024
1 parent 59fcc26 commit cf5c99c
Show file tree
Hide file tree
Showing 5 changed files with 259 additions and 13 deletions.
86 changes: 86 additions & 0 deletions packages/codegen/src/__fixtures__/booleanSchema.json
@@ -0,0 +1,86 @@
{
"openapi": "3.1.0",
"info": {
"title": "Boolean schema example",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com"
}
],
"paths": {
"/blog/{id}": {
"get": {
"tags": ["blog"],
"summary": "Get blog entry by ID",
"description": "Returns a single blog entry",
"operationId": "getBlogEntryById",
"parameters": [
{
"name": "id",
"in": "path",
"description": "ID of blog entry",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "successful operation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BlogEntry"
}
}
}
}
}
}
},
"/explode": {
"get": {
"responses": {
"200": {
"description": "successful operation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Paradox"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"BlogEntry": {
"type": "object",
"required": ["id", "title", "content"],
"properties": {
"id": {
"type": "integer"
},
"title": {
"type": "string"
},
"content": true
},
"additionalProperties": false
},
"Paradox": {
"type": "object",
"required": ["foo"],
"properties": {
"foo": false
}
}
}
}
}
92 changes: 92 additions & 0 deletions packages/codegen/src/__fixtures__/booleanSchemaRefs.json
@@ -0,0 +1,92 @@
{
"openapi": "3.1.0",
"info": {
"title": "Boolean schema example",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com"
}
],
"paths": {
"/blog/{id}": {
"get": {
"tags": ["blog"],
"summary": "Get blog entry by ID",
"description": "Returns a single blog entry",
"operationId": "getBlogEntryById",
"parameters": [
{
"name": "id",
"in": "path",
"description": "ID of blog entry",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "successful operation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BlogEntry"
}
}
}
}
}
}
},
"/explode": {
"get": {
"responses": {
"200": {
"description": "successful operation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Paradox"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"BlogEntry": {
"type": "object",
"required": ["id", "title", "content"],
"properties": {
"id": {
"type": "integer"
},
"title": {
"type": "string"
},
"content": {
"$ref": "#/components/schemas/AlwaysAccept"
}
},
"additionalProperties": false
},
"Paradox": {
"type": "object",
"required": ["foo"],
"properties": {
"foo": {
"$ref": "#/components/schemas/NeverAccept"
}
}
},
"AlwaysAccept": true,
"NeverAccept": false
}
}
}
71 changes: 59 additions & 12 deletions packages/codegen/src/generate.ts
Expand Up @@ -26,7 +26,11 @@ type OnlyMode = "readOnly" | "writeOnly";
type OnlyModes = Record<OnlyMode, boolean>;

// Use union of OAS 3.0 and 3.1 types throughout
type OpenAPISchemaObject = OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject;
// openapi-types does not define boolean json schemas (https://json-schema.org/draft/2020-12/json-schema-core#section-4.3.2)
type OpenAPISchemaObject =
| OpenAPIV3.SchemaObject
| OpenAPIV3_1.SchemaObject
| boolean;
type OpenAPIReferenceObject =
| OpenAPIV3.ReferenceObject
| OpenAPIV3_1.ReferenceObject;
Expand Down Expand Up @@ -88,6 +92,10 @@ export type SchemaObject = OpenAPISchemaObject & {
prefixItems?: (OpenAPIReferenceObject | SchemaObject)[];
};

export type DiscriminatingSchemaObject = Exclude<SchemaObject, boolean> & {
discriminator: NonNullable<Exclude<SchemaObject, boolean>["discriminator"]>;
};

/**
* Get the name of a formatter function for a given parameter.
*/
Expand Down Expand Up @@ -140,6 +148,8 @@ export function getOperationName(
}

export function isNullable(schema?: SchemaObject | OpenAPIReferenceObject) {
if (typeof schema === "boolean") return schema;

if (schema && "nullable" in schema)
return !isReference(schema) && schema.nullable;

Expand Down Expand Up @@ -447,11 +457,13 @@ export default class ApiGenerator {

if (!this.refs[$ref]) {
let schema = this.resolve<SchemaObject>(obj);
if (ignoreDiscriminator) {

if (typeof schema !== "boolean" && ignoreDiscriminator) {
schema = _.cloneDeep(schema);
delete schema.discriminator;
}
const name = schema.title || getRefName($ref);
const name =
(typeof schema !== "boolean" && schema.title) || getRefName($ref);
const identifier = toIdentifier(name, true);

// When this is a true enum we can reference it directly,
Expand Down Expand Up @@ -614,11 +626,19 @@ export default class ApiGenerator {
name?: string,
onlyMode?: OnlyMode,
): ts.TypeNode {
if (!schema) return cg.keywordType.any;
if (!schema && typeof schema !== "boolean") return cg.keywordType.any;
if (isReference(schema)) {
return this.getRefAlias(schema, onlyMode) as ts.TypeReferenceNode;
}

if (schema === true) {
return cg.keywordType.any;
}

if (schema === false) {
return cg.keywordType.never;
}

if (schema.oneOf) {
const clone = { ...schema };
delete clone.oneOf;
Expand Down Expand Up @@ -657,8 +677,9 @@ export default class ApiGenerator {
isReference(childSchema) &&
this.discriminatingSchemas.has(childSchema.$ref)
) {
const discriminatingSchema = this.resolve<SchemaObject>(childSchema);
const discriminator = discriminatingSchema.discriminator!;
const discriminatingSchema =
this.resolve<DiscriminatingSchemaObject>(childSchema);
const discriminator = discriminatingSchema.discriminator;
const matched = Object.entries(discriminator.mapping || {}).find(
([, ref]) => ref === schema["x-component-ref-path"],
);
Expand Down Expand Up @@ -756,7 +777,11 @@ export default class ApiGenerator {

isTrueEnum(schema: SchemaObject, name?: string): name is string {
return Boolean(
schema.enum && this.opts.useEnumType && name && schema.type !== "boolean",
typeof schema !== "boolean" &&
schema.enum &&
this.opts.useEnumType &&
name &&
schema.type !== "boolean",
);
}

Expand Down Expand Up @@ -792,6 +817,13 @@ export default class ApiGenerator {
with a new name adding a number
*/
getTrueEnum(schema: SchemaObject, propName: string) {
if (typeof schema === "boolean") {
// this should never be thrown, since the only `getTrueEnum` call is
// behind an `isTrueEnum` check, which returns false for boolean schemas.
throw new Error(
"cannot get enum from boolean schema. schema must be an object",
);
}
const baseName = schema.title || _.upperFirst(propName);
// TODO: use _.camelCase in future major version
// (currently we allow _ and $ for backwards compatibility)
Expand Down Expand Up @@ -886,6 +918,10 @@ export default class ApiGenerator {
return ret;
}

if (typeof schema === "boolean") {
return { readOnly: false, writeOnly: false };
}

let readOnly = schema.readOnly ?? false;
let writeOnly = schema.writeOnly ?? false;

Expand Down Expand Up @@ -959,7 +995,11 @@ export default class ApiGenerator {
type,
});

if ("description" in schema && schema.description) {
if (
typeof schema !== "boolean" &&
"description" in schema &&
schema.description
) {
// Escape any JSDoc comment closing tags in description
const description = schema.description.replace("*/", "*\\/");

Expand Down Expand Up @@ -1121,11 +1161,16 @@ export default class ApiGenerator {
// First scan: Add `x-component-ref-path` property and record discriminating schemas
for (const name of Object.keys(schemas)) {
const schema = schemas[name];
if (isReference(schema)) continue;
if (isReference(schema) || typeof schema === "boolean") continue;

schema["x-component-ref-path"] = prefix + name;

if (schema.discriminator && !schema.oneOf && !schema.anyOf) {
if (
typeof schema !== "boolean" &&
schema.discriminator &&
!schema.oneOf &&
!schema.anyOf
) {
this.discriminatingSchemas.add(prefix + name);
}
}
Expand All @@ -1142,7 +1187,9 @@ export default class ApiGenerator {
for (const name of Object.keys(schemas)) {
const schema = schemas[name];

if (isReference(schema) || !schema.allOf) continue;
if (isReference(schema) || typeof schema === "boolean" || !schema.allOf) {
continue;
}

for (const childSchema of schema.allOf) {
if (
Expand All @@ -1154,7 +1201,7 @@ export default class ApiGenerator {

const discriminatingSchema = schemas[
getRefBasename(childSchema.$ref)
] as SchemaObject;
] as DiscriminatingSchemaObject;
const discriminator = discriminatingSchema.discriminator!;

if (isExplicit(discriminator, prefix + name)) continue;
Expand Down
22 changes: 21 additions & 1 deletion packages/codegen/src/index.test.ts
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll } from "vitest";
import { describe, it, expect, beforeAll, assert } from "vitest";
import * as path from "node:path";
import { Opts, generateSource } from "./index";
import { readFile } from "node:fs/promises";
Expand Down Expand Up @@ -86,6 +86,26 @@ describe("generateSource", () => {
);
});

it("should support boolean schemas", async () => {
const src = await generate(__dirname + "/__fixtures__/booleanSchema.json");
expect(src).toContain(
"export type BlogEntry = { id: number; title: string; content: any | null; };",
);
expect(src).toContain("export type Paradox = { foo: never; };");
});

it("should support referenced boolean schemas", async () => {
const src = await generate(
__dirname + "/__fixtures__/booleanSchemaRefs.json",
);
expect(src).toContain(
"export type BlogEntry = { id: number; title: string; content: AlwaysAccept; };",
);
expect(src).toContain("export type Paradox = { foo: NeverAccept; };");
expect(src).toContain("export type AlwaysAccept = any | null;");
expect(src).toContain("export type NeverAccept = never;");
});

it("should handle application/geo+json", async () => {
const src = await generate(__dirname + "/__fixtures__/geojson.json");
expect(src).toContain(
Expand Down
1 change: 1 addition & 0 deletions packages/codegen/src/tscodegen.ts
Expand Up @@ -19,6 +19,7 @@ export const keywordType = {
boolean: factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword),
undefined: factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword),
void: factory.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword),
never: factory.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword),
null: factory.createLiteralTypeNode(factory.createNull()),
};

Expand Down

0 comments on commit cf5c99c

Please sign in to comment.