diff --git a/package.json b/package.json index 2450b681..573f2be6 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "mobx": "5", "prettier": "^2.7.1", "rimraf": "^3.0.2", + "ts-node": "^10.9.1", "typedoc": "^0.23.2", "vite": "^2.9.13", "vitest": "^0.16.0", diff --git a/packages/generator/__tests__/helpers.tests.ts b/packages/generator/__tests__/helpers.test.ts similarity index 100% rename from packages/generator/__tests__/helpers.tests.ts rename to packages/generator/__tests__/helpers.test.ts diff --git a/packages/generator/__tests__/openapi/openApi2Parser.test.ts b/packages/generator/__tests__/openapi/openApi2Parser.test.ts new file mode 100644 index 00000000..805048dd --- /dev/null +++ b/packages/generator/__tests__/openapi/openApi2Parser.test.ts @@ -0,0 +1,267 @@ +import type { OpenAPIV2 } from "openapi-types"; +import { describe, expect, it } from "vitest"; +import AliasEntity from "../../src/openapi/models/aliasEntity"; +import Enum from "../../src/openapi/models/enum"; +import ObjectEntity from "../../src/openapi/models/objectEntity"; +import OpenApi2Parser from "../../src/openapi/parsers/openApi2Parser"; + +function createParser(definitions?: OpenAPIV2.DefinitionsObject) { + return new OpenApi2Parser({ definitions } as any); +} + +describe("OpenApi2Parser", () => { + describe("arrays", () => { + it("parses inlined array with value items", () => { + const parser = createParser({ + ParentEntity: { + type: "object", + properties: { + collection: { + type: "array", + items: { + type: "string", + }, + }, + }, + }, + }); + + parser.parse(); + + const parentEntity = parser.types.get("ParentEntity"); + expect(parentEntity).toBeDefined(); + + const collection = parser.types.get("ParentEntityCollection"); + expect(collection).toBeDefined(); + + expect((parentEntity?.type as ObjectEntity).properties.find(x => x.name === "collection")?.type).toBe(collection); + + const item = parser.types.get("ParentEntityCollectionItem"); + expect(item).toBeDefined(); + + expect(collection?.type as AliasEntity).toMatchObject({ isArray: true, referencedEntity: item }); + }); + + it("parses inlined array with inlined items", () => { + const parser = createParser({ + ParentEntity: { + type: "object", + properties: { + collection: { + type: "array", + items: { + type: "object", + properties: { + firstName: { type: "string" }, + lastName: { type: "string" }, + }, + } as any, + }, + }, + }, + }); + + parser.parse(); + + const parentEntity = parser.types.get("ParentEntity"); + expect(parentEntity).toBeDefined(); + + const collection = parser.types.get("ParentEntityCollection"); + expect(collection).toBeDefined(); + + expect((parentEntity?.type as ObjectEntity).properties.find(x => x.name === "collection")?.type).toBe(collection); + + const item = parser.types.get("ParentEntityCollectionItem"); + expect(item).toBeDefined(); + + expect(collection?.type as AliasEntity).toMatchObject({ isArray: true, referencedEntity: item }); + }); + + it("parses inlined array with referenced items", () => { + const parser = createParser({ + ParentEntity: { + type: "object", + properties: { + collection: { + type: "array", + items: { $ref: "#/definitions/Item" }, + }, + }, + }, + Item: { + type: "object", + properties: { + firstName: { type: "string" }, + lastName: { type: "string" }, + }, + }, + }); + + parser.parse(); + + const parentEntity = parser.types.get("ParentEntity"); + expect(parentEntity).toBeDefined(); + + const collection = parser.types.get("ParentEntityCollection"); + expect(collection).toBeDefined(); + + expect((parentEntity?.type as ObjectEntity).properties.find(x => x.name === "collection")?.type).toBe(collection); + + const item = parser.types.get("Item"); + expect(item).toBeDefined(); + + expect(collection?.type as AliasEntity).toMatchObject({ isArray: true, referencedEntity: item }); + }); + + it("parses references array with inlined items", () => { + const parser = createParser({ + ParentEntity: { + type: "object", + properties: { + collection: { $ref: "#/definitions/ItemsList" }, + }, + }, + ItemsList: { + type: "array", + items: { + type: "object", + properties: { + firstName: { type: "string" }, + lastName: { type: "string" }, + }, + } as any, + }, + }); + + parser.parse(); + + const parentEntity = parser.types.get("ParentEntity"); + expect(parentEntity).toBeDefined(); + + const collection = parser.types.get("ItemsList"); + expect(collection).toBeDefined(); + + expect((parentEntity?.type as ObjectEntity).properties.find(x => x.name === "collection")?.type).toBe(collection); + + const item = parser.types.get("ItemsListItem"); + expect(item).toBeDefined(); + + expect(collection?.type as AliasEntity).toMatchObject({ isArray: true, referencedEntity: item }); + }); + + it("parses references array with referenced items", () => { + const parser = createParser({ + ParentEntity: { + type: "object", + properties: { + collection: { $ref: "#/definitions/ItemsList" }, + }, + }, + ItemsList: { + type: "array", + items: { $ref: "#/definitions/Item" }, + }, + Item: { + type: "object", + properties: { + firstName: { type: "string" }, + lastName: { type: "string" }, + }, + }, + }); + + parser.parse(); + + const parentEntity = parser.types.get("ParentEntity"); + expect(parentEntity).toBeDefined(); + + const collection = parser.types.get("ItemsList"); + expect(collection).toBeDefined(); + + expect((parentEntity?.type as ObjectEntity).properties.find(x => x.name === "collection")?.type).toBe(collection); + + const item = parser.types.get("Item"); + expect(item).toBeDefined(); + + expect(collection?.type as AliasEntity).toMatchObject({ isArray: true, referencedEntity: item }); + }); + }); + + describe("parseSchemaObject", () => { + it("returns simple type", () => { + const definition: OpenAPIV2.SchemaObject = { + type: "integer", + }; + + const { type } = createParser().parseSchemaObject("MyType", definition); + + expect(type).toBe("number"); + }); + + it("generates a placeholder for reference object", () => { + const definition: OpenAPIV2.SchemaObject = { + $ref: "#/definitions/Category", + }; + + const { type } = createParser().parseSchemaObject("MyType", definition); + expect(type).toBeUndefined(); + }); + + it("returns object entity", () => { + const definition: OpenAPIV2.SchemaObject = { + type: "object", + properties: { foo: { type: "integer" }, bar: { type: "string" } }, + }; + + const parser = createParser(); + const { type } = parser.parseSchemaObject("MyType", definition); + expect(type).toBeInstanceOf(ObjectEntity); + + expect(type).toMatchObject({ + name: "MyType", + properties: [ + { name: "foo", type: { type: "number" } }, + { name: "bar", type: { type: "string" } }, + ], + }); + }); + + it("returns array of simple types", () => { + const definition: OpenAPIV2.SchemaObject = { + type: "array", + items: { + type: "string", + }, + }; + + const { type } = createParser().parseSchemaObject("MyType", definition); + expect(type).toBeInstanceOf(AliasEntity); + expect(type).toMatchObject({ isArray: true, referencedEntity: { type: "string" } }); + }); + + it("returns array of entity references", () => { + const definition: OpenAPIV2.SchemaObject = { + type: "array", + items: { + type: "any", + $ref: "#/definitions/Tag", + }, + }; + + const { type } = createParser().parseSchemaObject("MyType", definition); + expect(type).toBeInstanceOf(AliasEntity); + expect(type).toMatchObject({ isArray: true, referencedEntity: { type: undefined } }); + }); + + it("returns enum", () => { + const definition: OpenAPIV2.SchemaObject = { + type: "string", + enum: ["one", "two", "three"], + }; + + const { type } = createParser().parseSchemaObject("MyType", definition); + expect(type).toBeInstanceOf(Enum); + expect(type).toMatchObject({ items: ["one", "two", "three"] }); + }); + }); +}); diff --git a/packages/generator/__tests__/openapi/openApi2Parser.tests.ts b/packages/generator/__tests__/openapi/openApi2Parser.tests.ts deleted file mode 100644 index b53cae6c..00000000 --- a/packages/generator/__tests__/openapi/openApi2Parser.tests.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { OpenAPIV2 } from "openapi-types"; -import { describe, expect, it } from "vitest"; -import AliasEntity from "../../src/openapi/models/aliasEntity"; -import Enum from "../../src/openapi/models/enum"; -import ObjectEntity from "../../src/openapi/models/objectEntity"; -import OpenApi2Parser from "../../src/openapi/parsers/openApi2Parser"; - -function createParser() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - return new OpenApi2Parser({} as any); -} - -describe("OpenApi2Parser", () => { - describe("parseSchemaObject", () => { - it("returns simple type", () => { - const definition: OpenAPIV2.SchemaObject = { - type: "integer", - }; - - const { type } = createParser().parseSchemaObject("MyType", definition); - - expect(type).toBe("number"); - }); - - it("generates a placeholder for reference object", () => { - const definition: OpenAPIV2.SchemaObject = { - $ref: "#/definitions/Category", - }; - - const { type } = createParser().parseSchemaObject("MyType", definition); - expect(type).toBeUndefined(); - }); - - it("returns object entity", () => { - const definition: OpenAPIV2.SchemaObject = { - type: "object", - properties: { foo: { type: "integer" }, bar: { type: "string" } }, - }; - - const parser = createParser(); - const { type } = parser.parseSchemaObject("MyType", definition); - expect(type).toBeInstanceOf(ObjectEntity); - - expect(type).toMatchObject({ - name: "MyType", - properties: [ - { name: "foo", type: { type: "number" } }, - { name: "bar", type: { type: "string" } }, - ], - }); - }); - - it("returns array of simple types", () => { - const definition: OpenAPIV2.SchemaObject = { - type: "array", - items: { - type: "string", - }, - }; - - const { type } = createParser().parseSchemaObject("MyType", definition); - expect(type).toBeInstanceOf(AliasEntity); - expect(type).toMatchObject({ isArray: true, referencedEntity: { type: "string" } }); - }); - - it("returns array of entity references", () => { - const definition: OpenAPIV2.SchemaObject = { - type: "array", - items: { - type: "any", - $ref: "#/definitions/Tag", - }, - }; - - const { type } = createParser().parseSchemaObject("MyType", definition); - expect(type).toBeInstanceOf(AliasEntity); - expect(type).toMatchObject({ isArray: true, referencedEntity: { type: undefined } }); - }); - - it("returns enum", () => { - const definition: OpenAPIV2.SchemaObject = { - type: "string", - enum: ["one", "two", "three"], - }; - - const { type } = createParser().parseSchemaObject("MyType", definition); - expect(type).toBeInstanceOf(Enum); - expect(type).toMatchObject({ items: ["one", "two", "three"] }); - }); - }); -}); diff --git a/packages/generator/__tests__/openapi/openApi3Parser.test.ts b/packages/generator/__tests__/openapi/openApi3Parser.test.ts new file mode 100644 index 00000000..0f7d7b42 --- /dev/null +++ b/packages/generator/__tests__/openapi/openApi3Parser.test.ts @@ -0,0 +1,330 @@ +import type { OpenAPIV3 } from "openapi-types"; +import { describe, expect, it } from "vitest"; +import AliasEntity from "../../src/openapi/models/aliasEntity"; +import Enum from "../../src/openapi/models/enum"; +import InheritedEntity from "../../src/openapi/models/inheritedEntity"; +import ObjectEntity from "../../src/openapi/models/objectEntity"; +import UnionEntity from "../../src/openapi/models/unionEntity"; +import OpenApi3Parser from "../../src/openapi/parsers/openApi3Parser"; + +function createParser(schemas?: OpenAPIV3.ComponentsObject["schemas"]) { + return new OpenApi3Parser({ components: { schemas }, paths: {} } as any); +} + +describe("OpenApi3Parser", () => { + describe("arrays", () => { + it("parses inlined array with value items", () => { + const parser = createParser({ + ParentEntity: { + type: "object", + properties: { + collection: { + type: "array", + items: { + type: "string", + }, + }, + }, + }, + }); + + parser.parse(); + + const parentEntity = parser.types.get("ParentEntity"); + expect(parentEntity).toBeDefined(); + + const collection = parser.types.get("ParentEntityCollection"); + expect(collection).toBeDefined(); + + expect((parentEntity?.type as ObjectEntity).properties.find(x => x.name === "collection")?.type).toBe(collection); + + const item = parser.types.get("ParentEntityCollectionItem"); + expect(item).toBeDefined(); + + expect(collection?.type as AliasEntity).toMatchObject({ isArray: true, referencedEntity: item }); + }); + + it("parses inlined array with inlined items", () => { + const parser = createParser({ + ParentEntity: { + type: "object", + properties: { + collection: { + type: "array", + items: { + type: "object", + properties: { + firstName: { type: "string" }, + lastName: { type: "string" }, + }, + } as any, + }, + }, + }, + }); + + parser.parse(); + + const parentEntity = parser.types.get("ParentEntity"); + expect(parentEntity).toBeDefined(); + + const collection = parser.types.get("ParentEntityCollection"); + expect(collection).toBeDefined(); + + expect((parentEntity?.type as ObjectEntity).properties.find(x => x.name === "collection")?.type).toBe(collection); + + const item = parser.types.get("ParentEntityCollectionItem"); + expect(item).toBeDefined(); + + expect(collection?.type as AliasEntity).toMatchObject({ isArray: true, referencedEntity: item }); + }); + + it("parses inlined array with referenced items", () => { + const parser = createParser({ + ParentEntity: { + type: "object", + properties: { + collection: { + type: "array", + items: { $ref: "#/components/schemas/Item" }, + }, + }, + }, + Item: { + type: "object", + properties: { + firstName: { type: "string" }, + lastName: { type: "string" }, + }, + }, + }); + + parser.parse(); + + const parentEntity = parser.types.get("ParentEntity"); + expect(parentEntity).toBeDefined(); + + const collection = parser.types.get("ParentEntityCollection"); + expect(collection).toBeDefined(); + + expect((parentEntity?.type as ObjectEntity).properties.find(x => x.name === "collection")?.type).toBe(collection); + + const item = parser.types.get("Item"); + expect(item).toBeDefined(); + + expect(collection?.type as AliasEntity).toMatchObject({ isArray: true, referencedEntity: item }); + }); + + it("parses references array with inlined items", () => { + const parser = createParser({ + ParentEntity: { + type: "object", + properties: { + collection: { $ref: "#/components/schemas/ItemsList" }, + }, + }, + ItemsList: { + type: "array", + items: { + type: "object", + properties: { + firstName: { type: "string" }, + lastName: { type: "string" }, + }, + } as any, + }, + }); + + parser.parse(); + + const parentEntity = parser.types.get("ParentEntity"); + expect(parentEntity).toBeDefined(); + + const collection = parser.types.get("ItemsList"); + expect(collection).toBeDefined(); + + expect((parentEntity?.type as ObjectEntity).properties.find(x => x.name === "collection")?.type).toBe(collection); + + const item = parser.types.get("ItemsListItem"); + expect(item).toBeDefined(); + + expect(collection?.type as AliasEntity).toMatchObject({ isArray: true, referencedEntity: item }); + }); + + it("parses references array with referenced items", () => { + const parser = createParser({ + ParentEntity: { + type: "object", + properties: { + collection: { $ref: "#/components/schemas/ItemsList" }, + }, + }, + ItemsList: { + type: "array", + items: { $ref: "#/components/schemas/Item" }, + }, + Item: { + type: "object", + properties: { + firstName: { type: "string" }, + lastName: { type: "string" }, + }, + }, + }); + + parser.parse(); + + const parentEntity = parser.types.get("ParentEntity"); + expect(parentEntity).toBeDefined(); + + const collection = parser.types.get("ItemsList"); + expect(collection).toBeDefined(); + + expect((parentEntity?.type as ObjectEntity).properties.find(x => x.name === "collection")?.type).toBe(collection); + + const item = parser.types.get("Item"); + expect(item).toBeDefined(); + + expect(collection?.type as AliasEntity).toMatchObject({ isArray: true, referencedEntity: item }); + }); + }); + + describe("parseSchemaObject", () => { + it("returns simple type", () => { + const definition: OpenAPIV3.SchemaObject = { + type: "integer", + }; + + const { type } = createParser().parseSchemaObject("MyType", definition); + + expect(type).toBe("number"); + }); + + it("generates a placeholder for reference object", () => { + const definition: OpenAPIV3.ReferenceObject = { + $ref: "#/components/schemas/Category", + }; + + const { type } = createParser().parseSchemaObject("MyType", definition); + expect(type).toBeUndefined(); + }); + + it("returns object entity", () => { + const definition: OpenAPIV3.SchemaObject = { + type: "object", + properties: { foo: { type: "integer" }, bar: { type: "string" } }, + }; + + const { type } = createParser().parseSchemaObject("MyType", definition); + expect(type).toBeInstanceOf(ObjectEntity); + + expect(type).toMatchObject({ + name: "MyType", + properties: [ + { name: "foo", type: { type: "number" } }, + { name: "bar", type: { type: "string" } }, + ], + }); + }); + + it("returns array of simple types", () => { + const definition: OpenAPIV3.SchemaObject = { + type: "array", + items: { + type: "string", + }, + }; + + const { type } = createParser().parseSchemaObject("MyType", definition); + expect(type).toBeInstanceOf(AliasEntity); + expect(type).toMatchObject({ isArray: true, referencedEntity: { type: "string" } }); + }); + + it("returns array of entity references", () => { + const definition: OpenAPIV3.SchemaObject = { + type: "array", + items: { + $ref: "#/components/schemas/Tag", + }, + }; + + const { type } = createParser().parseSchemaObject("MyType", definition); + expect(type).toBeInstanceOf(AliasEntity); + expect(type).toMatchObject({ isArray: true, referencedEntity: { type: undefined } }); + }); + + it("returns array of embedded entities", () => { + const definition: OpenAPIV3.SchemaObject = { + type: "array", + items: { + type: "object", + properties: { foo: { type: "integer" }, bar: { type: "string" } }, + }, + }; + + const parser = createParser(); + const { type } = parser.parseSchemaObject("MyType", definition); + expect(type).toBeInstanceOf(AliasEntity); + expect(type).toMatchObject({ isArray: true }); + + const { type: innerType } = (type as AliasEntity).referencedEntity; + expect(innerType).toBeInstanceOf(ObjectEntity); + expect(innerType).toMatchObject({ + name: "MyTypeItem", + properties: [ + { name: "foo", type: { type: "number" } }, + { name: "bar", type: { type: "string" } }, + ], + }); + }); + + it("returns enum", () => { + const definition: OpenAPIV3.SchemaObject = { + type: "string", + enum: ["one", "two", "three"], + }; + + const { type } = createParser().parseSchemaObject("MyType", definition); + expect(type).toBeInstanceOf(Enum); + expect(type).toMatchObject({ items: ["one", "two", "three"] }); + }); + + it("returns union when oneOf is used", () => { + const definition: OpenAPIV3.SchemaObject = { + type: "object", + oneOf: [ + { + $ref: "#/components/schemas/PartOne", + }, + { + $ref: "#/components/schemas/PartTwo", + }, + ], + }; + + const { type } = createParser().parseSchemaObject("MyType", definition); + expect(type).toBeInstanceOf(UnionEntity); + const union = type as UnionEntity; + expect(union.entities.length).toBe(2); + }); + + it("returns inherited entity when allOf is used", () => { + const definition: OpenAPIV3.SchemaObject = { + type: "object", + allOf: [ + { + $ref: "#/components/schemas/PartOne", + }, + { + $ref: "#/components/schemas/PartTwo", + }, + ], + }; + + const { type } = createParser().parseSchemaObject("MyType", definition); + expect(type).toBeInstanceOf(InheritedEntity); + const union = type as InheritedEntity; + expect(union.baseEntities.length).toBe(2); + }); + }); +}); diff --git a/packages/generator/__tests__/openapi/openApi3Parser.tests.ts b/packages/generator/__tests__/openapi/openApi3Parser.tests.ts deleted file mode 100644 index 69346f3d..00000000 --- a/packages/generator/__tests__/openapi/openApi3Parser.tests.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { OpenAPIV3 } from "openapi-types"; -import { describe, expect, it } from "vitest"; -import AliasEntity from "../../src/openapi/models/aliasEntity"; -import Enum from "../../src/openapi/models/enum"; -import InheritedEntity from "../../src/openapi/models/inheritedEntity"; -import ObjectEntity from "../../src/openapi/models/objectEntity"; -import UnionEntity from "../../src/openapi/models/unionEntity"; -import OpenApi3Parser from "../../src/openapi/parsers/openApi3Parser"; - -function createParser() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - return new OpenApi3Parser({} as any); -} - -describe("OpenApi3Parser", () => { - describe("parseSchemaObject", () => { - it("returns simple type", () => { - const definition: OpenAPIV3.SchemaObject = { - type: "integer", - }; - - const { type } = createParser().parseSchemaObject("MyType", definition); - - expect(type).toBe("number"); - }); - - it("generates a placeholder for reference object", () => { - const definition: OpenAPIV3.ReferenceObject = { - $ref: "#/components/schemas/Category", - }; - - const { type } = createParser().parseSchemaObject("MyType", definition); - expect(type).toBeUndefined(); - }); - - it("returns object entity", () => { - const definition: OpenAPIV3.SchemaObject = { - type: "object", - properties: { foo: { type: "integer" }, bar: { type: "string" } }, - }; - - const { type } = createParser().parseSchemaObject("MyType", definition); - expect(type).toBeInstanceOf(ObjectEntity); - - expect(type).toMatchObject({ - name: "MyType", - properties: [ - { name: "foo", type: { type: "number" } }, - { name: "bar", type: { type: "string" } }, - ], - }); - }); - - it("returns array of simple types", () => { - const definition: OpenAPIV3.SchemaObject = { - type: "array", - items: { - type: "string", - }, - }; - - const { type } = createParser().parseSchemaObject("MyType", definition); - expect(type).toBeInstanceOf(AliasEntity); - expect(type).toMatchObject({ isArray: true, referencedEntity: { type: "string" } }); - }); - - it("returns array of entity references", () => { - const definition: OpenAPIV3.SchemaObject = { - type: "array", - items: { - $ref: "#/components/schemas/Tag", - }, - }; - - const { type } = createParser().parseSchemaObject("MyType", definition); - expect(type).toBeInstanceOf(AliasEntity); - expect(type).toMatchObject({ isArray: true, referencedEntity: { type: undefined } }); - }); - - it("returns array of embedded entities", () => { - const definition: OpenAPIV3.SchemaObject = { - type: "array", - items: { - type: "object", - properties: { foo: { type: "integer" }, bar: { type: "string" } }, - }, - }; - - const parser = createParser(); - const { type } = parser.parseSchemaObject("MyType", definition); - expect(type).toBeInstanceOf(AliasEntity); - expect(type).toMatchObject({ isArray: true }); - - const { type: innerType } = (type as AliasEntity).referencedEntity; - expect(innerType).toBeInstanceOf(ObjectEntity); - expect(innerType).toMatchObject({ - name: "MyTypeItem", - properties: [ - { name: "foo", type: { type: "number" } }, - { name: "bar", type: { type: "string" } }, - ], - }); - }); - - it("returns enum", () => { - const definition: OpenAPIV3.SchemaObject = { - type: "string", - enum: ["one", "two", "three"], - }; - - const { type } = createParser().parseSchemaObject("MyType", definition); - expect(type).toBeInstanceOf(Enum); - expect(type).toMatchObject({ items: ["one", "two", "three"] }); - }); - - it("returns union when oneOf is used", () => { - const definition: OpenAPIV3.SchemaObject = { - type: "object", - oneOf: [ - { - $ref: "#/components/schemas/PartOne", - }, - { - $ref: "#/components/schemas/PartTwo", - }, - ], - }; - - const { type } = createParser().parseSchemaObject("MyType", definition); - expect(type).toBeInstanceOf(UnionEntity); - const union = type as UnionEntity; - expect(union.entities.length).toBe(2); - }); - - it("returns inherited entity when allOf is used", () => { - const definition: OpenAPIV3.SchemaObject = { - type: "object", - allOf: [ - { - $ref: "#/components/schemas/PartOne", - }, - { - $ref: "#/components/schemas/PartTwo", - }, - ], - }; - - const { type } = createParser().parseSchemaObject("MyType", definition); - expect(type).toBeInstanceOf(InheritedEntity); - const union = type as InheritedEntity; - expect(union.baseEntities.length).toBe(2); - }); - }); -}); diff --git a/packages/generator/src/openapi/defaultConfig.json b/packages/generator/src/openapi/defaultConfig.json index d911e835..95bcf054 100644 --- a/packages/generator/src/openapi/defaultConfig.json +++ b/packages/generator/src/openapi/defaultConfig.json @@ -34,6 +34,7 @@ "enumEntityFile": "@enumEntityFile.hbs", "objectEntityContent": "@objectEntityContent.hbs", "objectEntityFile": "@objectEntityFile.hbs", + "entityImport": "@entityImport.hbs", "generatedEntityHeader": "@generatedEntityHeader.hbs", "repositoryAction": "@repositoryAction.hbs", "repositoryFile": "@repositoryFile.hbs", diff --git a/packages/generator/src/openapi/fileGenerator.ts b/packages/generator/src/openapi/fileGenerator.ts index f8f70331..3712bf65 100644 --- a/packages/generator/src/openapi/fileGenerator.ts +++ b/packages/generator/src/openapi/fileGenerator.ts @@ -30,6 +30,9 @@ export default class FileGenerator { rawText: (x: string) => x, and: (...args) => Array.prototype.every.call(args, Boolean), or: (...args) => Array.prototype.slice.call(args, 0, -1).some(Boolean), + eq: (a: unknown, b: unknown) => a == b, + ne: (a: unknown, b: unknown) => a != b, + coalesce: (...args) => Array.prototype.find.call(args, x => !!x) as string, }); } @@ -47,6 +50,7 @@ export default class FileGenerator { enumEntityFile: await this.readTemplate("enumEntityFile"), objectEntityContent: await this.readTemplate("objectEntityContent"), objectEntityFile: await this.readTemplate("objectEntityFile"), + entityImport: await this.readTemplate("entityImport"), stringLiteralEntity: await this.readTemplate("stringLiteralEntity"), stringLiteralEntityFile: await this.readTemplate("stringLiteralEntityFile"), unionEntity: await this.readTemplate("unionEntity"), @@ -67,8 +71,8 @@ export default class FileGenerator { if (type instanceof Enum) { enumWriter.write(type); } else if (type instanceof InheritedEntity) { - const baseEntity = type.baseEntities.map(x => x.type).filter(x => x instanceof ObjectEntity)[0] as ObjectEntity; - objectWriter.write(type, baseEntity); + const baseEntities = type.baseEntities.map(x => x.type).filter((x): x is ObjectEntity => x instanceof ObjectEntity); + objectWriter.write(type, baseEntities); } else if (type instanceof ObjectEntity) { objectWriter.write(type); } else if (type instanceof UnionEntity) { @@ -131,11 +135,18 @@ export default class FileGenerator { return Handlebars.compile(`// missing template ${name}`); } - const fullPath = filePath.startsWith("@") - ? path.resolve(__dirname, filePath.replace("@", "./openapi/templates/")) - : path.resolve(process.cwd(), filePath); - + const fullPath = FileGenerator.getFullPath(filePath); const file = await fs.promises.readFile(fullPath); return Handlebars.compile(file.toString()); } + + private static getFullPath(filePath: string) { + if (filePath.startsWith("@")) { + // use built-in template + const templatesRoot = __filename.endsWith(".ts") ? "./templates/" : "./openapi/templates/"; + return path.resolve(__dirname, filePath.replace("@", templatesRoot)); + } else { + return path.resolve(process.cwd(), filePath); + } + } } diff --git a/packages/generator/src/openapi/parsers/openApi3Parser.ts b/packages/generator/src/openapi/parsers/openApi3Parser.ts index c18f342c..c4647220 100644 --- a/packages/generator/src/openapi/parsers/openApi3Parser.ts +++ b/packages/generator/src/openapi/parsers/openApi3Parser.ts @@ -44,12 +44,10 @@ export default class OpenApi3Parser implements ApiModel { switch (definition.type) { case "array": { - const itemName = `${name}Item`; - const innerType = this.parseSchemaObject(itemName, definition.items); - - const arrayName = `${name}Array`; - const aliasType = new AliasEntity(arrayName, innerType, true); - return this.setTypeReference(arrayName, aliasType); + const fallbackName = `${name}Item`; + const innerType = this.parseSchemaObject(fallbackName, definition.items); + const aliasType = new AliasEntity(name, innerType, true); + return this.setTypeReference(name, aliasType); } case "object": @@ -194,6 +192,7 @@ export default class OpenApi3Parser implements ApiModel { } private setTypeReference(name: string, type: TypeReference["type"]) { + // TODO set name on the type as well? const existingReference = this.types.get(name); if (existingReference) { existingReference.type = existingReference.type ?? type; diff --git a/packages/generator/src/openapi/templates/entityImport.hbs b/packages/generator/src/openapi/templates/entityImport.hbs new file mode 100644 index 00000000..badba497 --- /dev/null +++ b/packages/generator/src/openapi/templates/entityImport.hbs @@ -0,0 +1 @@ +import { {{entity}} } from "{{filePath}}"; \ No newline at end of file diff --git a/packages/generator/src/openapi/templates/enumEntityFile.hbs b/packages/generator/src/openapi/templates/enumEntityFile.hbs index 08237c8a..72980d37 100644 --- a/packages/generator/src/openapi/templates/enumEntityFile.hbs +++ b/packages/generator/src/openapi/templates/enumEntityFile.hbs @@ -1,3 +1,7 @@ +import z from "zod"; + {{> generatedEntityHeader}} {{{content}}} -export default {{pascalCase enum.name}}; + +export const {{pascalCase enum.name}}Enum = z.nativeEnum({{pascalCase enum.name}}); +export type {{pascalCase enum.name}}Enum = z.infer; diff --git a/packages/generator/src/openapi/templates/objectEntityContent.hbs b/packages/generator/src/openapi/templates/objectEntityContent.hbs index 8d4e4e27..c45ceb9a 100644 --- a/packages/generator/src/openapi/templates/objectEntityContent.hbs +++ b/packages/generator/src/openapi/templates/objectEntityContent.hbs @@ -1,59 +1,73 @@ -{{#*inline "property"}} - {{#if (or description example)}} - /** - {{#if description}} - * {{{description}}} - {{/if}} - {{#if example}} - * @example - * {{{example}}} - {{/if}} - */ - {{/if}} - {{#if isObservable}} - @observable - {{/if}} - {{#each conversions}} - {{{this}}} - {{/each}} - {{#if externalName}} - @Expose({ name: "{{externalName}}" }) - {{/if}} - {{#if readonly}}readonly {{/if}}{{name}}{{#if required}}!{{else}}?{{/if}}: {{type}}; -{{/inline}} -{{#*inline "validationProperties"}} - {{#each properties}} - {{#if validations}} - {{name}}: { {{#each validations}}{{{this}}}{{#unless @last}}, {{/unless}}{{/each}} }, - {{/if}} - {{/each}} -{{/inline}} -{{#*inline "validationEntity"}} - {{#if useBaseClassValidation}} - static ValidationRules = Object.assign( - { - {{> validationProperties}} - }, - {{baseClass.name}}.ValidationRules - ); - {{else}} - static ValidationRules = { - {{> validationProperties}} - }; - {{/if}} -{{/inline}} -{{#each properties}} -{{> property}} -{{#unless @last}} +{{#*inline "type"~}} +{{#if hasTypeImport}}{{ typeName }}{{else}} +{{#if (eq typeName "Date")}} +z.preprocess(arg => (typeof arg == "string" || arg instanceof Date ? new Date(arg) : undefined), z.date()) +{{~else}}z.{{ typeName }}(){{/if}} +{{~/if}} +{{/inline~}} + +{{#*inline "validation"}} + {{#with (lookup rawValidations "number") }} + + /* is number */ + {{~/with}} + {{#if (eq typeName "string") }} + {{#if rawValidations.minLength}} + + .min({{rawValidations.minLength}}) + {{~else}} + {{#if required}} + + .min(1) + {{~/if}} + {{~/if}} + {{#if rawValidations.maxLength}} -{{/unless}} -{{/each}} -{{#if validationEntity}} + .max({{rawValidations.maxLength}}) + {{~/if}} + {{~/if}} +{{~/inline~}} -{{> validationEntity}} +{{#*inline "property"}} +{{#if example}} +/** + * @example + * {{{example}}} + */ +{{/if}} +{{name}}: + +{{~#if isArray}} + z.array({{> type}}) {{else}} - {{#if useBaseClassValidation}} + {{> type}} +{{/if}} + +{{~> validation~}} +{{#unless required}} + + .optional() +{{~/unless}} +{{#if nullable}} + + .nullable() +{{~/if}} +{{#if description}} + + .describe("{{description}}") +{{~/if}} +, +{{/inline}} + return z + .object({ + {{#each properties}} + {{> property}} + {{#unless @last}} - static ValidationRules = {{baseClass.name}}.ValidationRules; - {{/if}} -{{/if}} \ No newline at end of file + {{/unless}} + {{/each}} + }) + {{#each baseClasses}} + .merge({{name}}) + {{/each}} + .describe("{{entity.name}}"); diff --git a/packages/generator/src/openapi/templates/objectEntityFile.hbs b/packages/generator/src/openapi/templates/objectEntityFile.hbs index 6d326b8e..4eaef9eb 100644 --- a/packages/generator/src/openapi/templates/objectEntityFile.hbs +++ b/packages/generator/src/openapi/templates/objectEntityFile.hbs @@ -1,10 +1,12 @@ {{#each imports}} {{{this}}} {{/each}} -{{#if imports}} +import z from "zod"; -{{/if}} {{> generatedEntityHeader}} -export default class {{entity.name}}{{#if baseClass}} extends {{baseClass.name}}{{/if}} { -{{{content}}} -{{~rawText "}" }} +function build{{pascalCase entity.name}}() { + {{{content}}} +} + +export const {{pascalCase entity.name}} = build{{pascalCase entity.name}}(); +export type {{pascalCase entity.name}} = z.infer; diff --git a/packages/generator/src/openapi/templates/stringLiteralEntity.hbs b/packages/generator/src/openapi/templates/stringLiteralEntity.hbs index 6db61344..3d11b91b 100644 --- a/packages/generator/src/openapi/templates/stringLiteralEntity.hbs +++ b/packages/generator/src/openapi/templates/stringLiteralEntity.hbs @@ -1 +1,7 @@ -type {{pascalCase name}} = {{#each items}}"{{this}}"{{#unless @last}} | {{/unless}}{{/each}}; \ No newline at end of file +function build{{name}}() { + return z.enum([ + {{#each items}} + "{{this}}", + {{/each}} + ]); +} \ No newline at end of file diff --git a/packages/generator/src/openapi/templates/stringLiteralEntityFile.hbs b/packages/generator/src/openapi/templates/stringLiteralEntityFile.hbs index 08237c8a..019e8027 100644 --- a/packages/generator/src/openapi/templates/stringLiteralEntityFile.hbs +++ b/packages/generator/src/openapi/templates/stringLiteralEntityFile.hbs @@ -1,3 +1,7 @@ +import z from "zod"; + {{> generatedEntityHeader}} {{{content}}} -export default {{pascalCase enum.name}}; + +export const {{pascalCase enum.name}} = build{{enum.name}}(); +export type {{pascalCase enum.name}} = z.infer; diff --git a/packages/generator/src/openapi/templates/unionEntity.hbs b/packages/generator/src/openapi/templates/unionEntity.hbs index 3cfbd768..f93eeb0e 100644 --- a/packages/generator/src/openapi/templates/unionEntity.hbs +++ b/packages/generator/src/openapi/templates/unionEntity.hbs @@ -1 +1,11 @@ -type {{pascalCase name}} = {{#each subEntities}}{{this}}{{#unless @last}} | {{/unless}}{{/each}}; \ No newline at end of file +{{#*inline "type"~}} +{{#if hasTypeImport}}{{ typeName }}{{else}}z.{{ typeName }}(){{/if}} +{{~/inline~}} + +function build{{name}}() { + return z.union([ + {{#each subEntities}} + {{> type}}, + {{/each}} + ]); +} \ No newline at end of file diff --git a/packages/generator/src/openapi/templates/unionEntityFile.hbs b/packages/generator/src/openapi/templates/unionEntityFile.hbs index 97e7f005..5f9174ea 100644 --- a/packages/generator/src/openapi/templates/unionEntityFile.hbs +++ b/packages/generator/src/openapi/templates/unionEntityFile.hbs @@ -1,9 +1,10 @@ -{{#each importedEntities}} -import {{this}} from "./{{camelCase this}}"; +{{#each imports}} +{{{this}}} {{/each}} -{{#if importedEntities}} +import z from "zod"; -{{/if}} {{> generatedEntityHeader}} {{{content}}} -export default {{pascalCase entity.name}}; + +export const {{pascalCase entity.name}} = build{{entity.name}}(); +export type {{pascalCase entity.name}} = z.infer; diff --git a/packages/generator/src/openapi/writers/enumWriter.ts b/packages/generator/src/openapi/writers/enumWriter.ts index a934395e..af5d6c01 100644 --- a/packages/generator/src/openapi/writers/enumWriter.ts +++ b/packages/generator/src/openapi/writers/enumWriter.ts @@ -19,7 +19,14 @@ export default class EnumWriter { } private updateFile(file: SourceFile, definition: Enum) { - const currentEnum = file.getEnumOrThrow(definition.name); + const currentEnum = file.getFunction(`build${definition.name}`) ?? file.getEnum(definition.name); + if (!currentEnum) { + throw new Error( + `Could not find node to replace (enum ${definition.name}, or function build${ + definition.name + }) in file ${file.getFilePath()}` + ); + } currentEnum.replaceWithText(this.getEnumContent(definition)); return file; diff --git a/packages/generator/src/openapi/writers/objectEntityWriter.ts b/packages/generator/src/openapi/writers/objectEntityWriter.ts index 26a8363a..44255b73 100644 --- a/packages/generator/src/openapi/writers/objectEntityWriter.ts +++ b/packages/generator/src/openapi/writers/objectEntityWriter.ts @@ -7,123 +7,91 @@ import type EntityProperty from "../models/entityProperty"; import Enum from "../models/enum"; import type ObjectEntity from "../models/objectEntity"; import Restriction from "../models/restriction"; -import type TypeReference from "../models/typeReference"; import type { IConfig, ValidationRuleConfig } from "../types"; export default class ObjectEntityWriter { constructor( private parentDirectory: Directory, private config: Partial, - private templates: Record<"objectEntityContent" | "objectEntityFile", Handlebars.TemplateDelegate> + private templates: Record<"objectEntityContent" | "objectEntityFile" | "entityImport", Handlebars.TemplateDelegate> ) {} - write(definition: ObjectEntity, baseClass?: ObjectEntity) { + write(definition: ObjectEntity, baseClasses?: ObjectEntity[]) { const fileName = `${camelCase(definition.name)}.ts`; if (!GeneratorBase.canOverwiteFile(this.parentDirectory, fileName)) { return undefined; } const file = this.parentDirectory.getSourceFile(fileName); - return file ? this.updateFile(file, definition, baseClass) : this.createFile(fileName, definition, baseClass); + return file ? this.updateFile(file, definition, baseClasses) : this.createFile(fileName, definition, baseClasses); } - private updateFile(file: SourceFile, definition: ObjectEntity, baseClass: ObjectEntity | undefined) { + private updateFile(file: SourceFile, definition: ObjectEntity, baseClasses: ObjectEntity[] | undefined) { const currentClass = file.getClass(definition.name); if (currentClass) { currentClass.removeText(); currentClass.insertText(currentClass.getEnd() - 1, writer => { writer.newLineIfLastNot(); - writer.write(this.getEntityContent(definition, baseClass)); + writer.write(this.getEntityContent(definition, baseClasses)); }); + const baseClass = baseClasses?.[0]; if (baseClass) { currentClass.setExtends(baseClass.name); } + + return file; + } + + const currentBuildFunction = file.getFunction(`build${definition.name}`); + if (currentBuildFunction) { + currentBuildFunction.removeText(); + currentBuildFunction.insertText(currentBuildFunction.getEnd() - 1, writer => { + writer.newLineIfLastNot(); + writer.write(this.getEntityContent(definition, baseClasses)); + }); } return file; } - private createFile(fileName: string, definition: ObjectEntity, baseClass: ObjectEntity | undefined) { - const decoratorImports = this.getPropertyDecoratorsImports(definition.properties); + private createFile(fileName: string, definition: ObjectEntity, baseClasses: ObjectEntity[] | undefined) { const entitiesToImport = definition.properties.filter(x => x.type.isImportRequired).map(x => x.type.getTypeName()); - if (baseClass) { - entitiesToImport.push(baseClass.name); - } + baseClasses?.forEach(x => entitiesToImport.push(x.name)); const entityImports = uniq(entitiesToImport) .sort() - .map(x => `import ${x} from "./${camelCase(x)}";`); + .map(x => + this.templates.entityImport({ + entity: x, + filePath: `./${camelCase(x)}`, + }) + ); const result = this.templates.objectEntityFile({ - imports: [...decoratorImports, ...entityImports], - content: () => this.getEntityContent(definition, baseClass), + imports: entityImports, + content: () => this.getEntityContent(definition, baseClasses), entity: definition, - baseClass, + baseClasses, }); return this.parentDirectory.createSourceFile(fileName, result, { overwrite: true }); } - getPropertyDecoratorsImports(properties: EntityProperty[]) { - const result = new Set(); - - if (properties.some(p => p.tags?.get(ObservableFormatter.OBSERVABLE))) { - result.add(`import { observable } from "mobx";`); - } - - if (this.config.conversion !== false) { - for (const property of properties) { - for (const importStatement of this.getPropertyTypeConversionImports(property.type)) { - result.add(importStatement); - } - } - - if (properties.some(p => p.externalName)) { - result.add(`import { Expose } from "class-transformer";`); - } - } - - return result; - } - - getPropertyTypeConversionImports(reference: TypeReference): string[] { - if (reference.type instanceof AliasEntity) { - return this.getPropertyTypeConversionImports(reference.type.referencedEntity); - } - - const result: string[] = []; - - if (reference.type instanceof Enum) { - return result; - } - - if (typeof reference.type === "object") { - result.push(`import { Type } from "class-transformer";`); - } - - if (reference.type === "Date") { - if (this.config.dates === "date-fns") { - result.push(`import { Transform } from "class-transformer";`, `import formatISO from "date-fns/formatISO";`); - } else { - result.push(`import { Type } from "class-transformer";`); - } - } - - return result; - } - - private getEntityContent(definition: ObjectEntity, baseClass: ObjectEntity | undefined) { + private getEntityContent(definition: ObjectEntity, baseClasses: ObjectEntity[] | undefined) { const context = { entity: definition, - baseClass, + baseClasses, properties: definition.properties.map(p => { const nullable = p.restrictions?.get(Restriction.nullable); return { name: p.name, externalName: this.config.conversion !== false && p.externalName, type: p.type.getTypeDeclaration() || "UNKNOWN", + typeName: p.type.getTypeName(), + isArray: p.type.isArray, + hasTypeImport: p.type.isImportRequired, description: p.description, example: p.example, isObservable: p.tags?.get(ObservableFormatter.OBSERVABLE) as boolean, @@ -132,10 +100,11 @@ export default class ObjectEntityWriter { nullable, required: nullable !== true && p.restrictions?.has(Restriction.required), validations: this.getPropertyValidations(p), + rawValidations: this.getRawPropertyValidations(p), }; }), validationEntity: this.config.validation !== false && hasValidation(definition) && {}, - useBaseClassValidation: baseClass && hasValidation(baseClass), + useBaseClassValidation: !!baseClasses?.some(x => hasValidation(x)), }; return this.templates.objectEntityContent(context); } @@ -187,6 +156,18 @@ export default class ObjectEntityWriter { return undefined; } + private getRawPropertyValidations(property: EntityProperty) { + if (property.validations) { + const validations = Array.from(property.validations.entries()) + .filter(x => !!x[1]) + .map(x => [Restriction[x[0]], x[1]]); + + return Object.fromEntries(validations) as Record; + } + + return undefined; + } + private getValidationDefinition(restriction: Restriction, params: any) { const restrictionName = Restriction[restriction]; const validationConfiguration = diff --git a/packages/generator/src/openapi/writers/stringLiteralWriter.ts b/packages/generator/src/openapi/writers/stringLiteralWriter.ts index 0db5ae1e..d4466b18 100644 --- a/packages/generator/src/openapi/writers/stringLiteralWriter.ts +++ b/packages/generator/src/openapi/writers/stringLiteralWriter.ts @@ -20,7 +20,15 @@ export default class StringLiteralWriter { } private updateFile(file: SourceFile, definition: Enum) { - const currentEnum = file.getTypeAliasOrThrow(definition.name); + const currentEnum = file.getFunction(`build${definition.name}`) ?? file.getTypeAlias(definition.name); + if (!currentEnum) { + throw new Error( + `Could not find node to replace (type ${definition.name}, or function build${ + definition.name + }) in file ${file.getFilePath()}` + ); + } + currentEnum.replaceWithText(this.getEnumContent(definition)); return file; diff --git a/packages/generator/src/openapi/writers/unionEntityWriter.ts b/packages/generator/src/openapi/writers/unionEntityWriter.ts index 227162d4..3bedd36b 100644 --- a/packages/generator/src/openapi/writers/unionEntityWriter.ts +++ b/packages/generator/src/openapi/writers/unionEntityWriter.ts @@ -6,7 +6,7 @@ import type UnionEntity from "../models/unionEntity"; export default class UnionEntityWriter { constructor( private parentDirectory: Directory, - private templates: Record<"unionEntity" | "unionEntityFile", Handlebars.TemplateDelegate> + private templates: Record<"unionEntity" | "unionEntityFile" | "entityImport", Handlebars.TemplateDelegate> ) {} write(definition: UnionEntity) { @@ -21,8 +21,17 @@ export default class UnionEntityWriter { private updateFile(file: SourceFile, definition: UnionEntity) { try { - const currentEntity = file.getTypeAliasOrThrow(definition.name); + const currentEntity = file.getFunction(`build${definition.name}`) ?? file.getTypeAlias(definition.name); + if (!currentEntity) { + throw new Error( + `Could not find node to replace (type ${definition.name}, or function build${ + definition.name + }) in file ${file.getFilePath()}` + ); + } + currentEntity.replaceWithText(this.getEntityContent(definition)); + return file; } catch (error) { console.error(`Error while updating union type ${definition.name} in file ${file.getFilePath()}.`); @@ -31,7 +40,15 @@ export default class UnionEntityWriter { } private createFile(fileName: string, definition: UnionEntity) { - const importedEntities = definition.entities.filter(x => x.isImportRequired).map(x => x.getTypeName()); + const importedEntities = definition.entities + .filter(x => x.isImportRequired) + .map(x => x.getTypeName()) + .map(x => + this.templates.entityImport({ + entity: x, + filePath: `./${camelCase(x)}`, + }) + ); const result = this.templates.unionEntityFile({ importedEntities, @@ -45,7 +62,12 @@ export default class UnionEntityWriter { private getEntityContent(definition: UnionEntity) { const context = { name: definition.name, - subEntities: definition.entities.map(x => x.getTypeDeclaration()), + subEntities: definition.entities.map(x => ({ + type: x.getTypeDeclaration() || "UNKNOWN", + typeName: x.getTypeName(), + isArray: x.isArray, + hasTypeImport: x.isImportRequired, + })), }; return this.templates.unionEntity(context); } diff --git a/yarn.lock b/yarn.lock index c8185e2e..b0821e2d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13603,6 +13603,25 @@ ts-node@^10.8.1: v8-compile-cache-lib "^3.0.1" yn "3.1.1" +ts-node@^10.9.1: + version "10.9.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" + integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + ts-pnp@^1.1.6: version "1.2.0" resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92"