diff --git a/.changeset/selfish-items-jump.md b/.changeset/selfish-items-jump.md new file mode 100644 index 000000000..5a2d88f68 --- /dev/null +++ b/.changeset/selfish-items-jump.md @@ -0,0 +1,5 @@ +--- +"openapi-metadata": minor +--- + +Handle array types and fix ApiProperty decorator type diff --git a/packages/openapi-metadata/src/loaders/type.ts b/packages/openapi-metadata/src/loaders/type.ts index db751d8d3..9277a9285 100644 --- a/packages/openapi-metadata/src/loaders/type.ts +++ b/packages/openapi-metadata/src/loaders/type.ts @@ -7,7 +7,7 @@ import { PropertyMetadataStorage } from "../metadata/property.js"; import { schemaPath } from "../utils/schema.js"; import { isThunk } from "../utils/metadata.js"; -const PrimitiveTypeLoader: TypeLoaderFn = async (_context, value) => { +export const PrimitiveTypeLoader: TypeLoaderFn = async (_context, value) => { if (typeof value === "string") { return { type: value }; } @@ -28,7 +28,38 @@ const PrimitiveTypeLoader: TypeLoaderFn = async (_context, value) => { } }; -const ClassTypeLoader: TypeLoaderFn = async (context, value) => { +export const ArrayTypeLoader: TypeLoaderFn = async (context, value) => { + if (!Array.isArray(value)) { + return; + } + + if (value.length <= 0) { + context.logger.warn("You tried to specify an array type without any item"); + return; + } + + if (value.length > 1) { + context.logger.warn( + "You tried to specify an array type with multiple items. Please use the 'enum' option if you want to specify an enum.", + ); + return; + } + + const itemsSchema = await loadType(context, { type: value[0] }); + + // TODO: Better warn stack trace + if (!itemsSchema) { + context.logger.warn("You tried to specify an array type with an item that resolves to undefined."); + return; + } + + return { + type: "array", + items: itemsSchema, + }; +}; + +export const ClassTypeLoader: TypeLoaderFn = async (context, value) => { if (typeof value !== "function" || !value.prototype) { return; } @@ -49,8 +80,6 @@ const ClassTypeLoader: TypeLoaderFn = async (context, value) => { if (!properties) { context.logger.warn(`You tried to use '${model}' as a type but it does not contain any ApiProperty.`); - - return; } context.schemas[model] = schema; diff --git a/packages/openapi-metadata/src/metadata/property.ts b/packages/openapi-metadata/src/metadata/property.ts index 83d6d7721..23825ec6f 100644 --- a/packages/openapi-metadata/src/metadata/property.ts +++ b/packages/openapi-metadata/src/metadata/property.ts @@ -1,7 +1,8 @@ +import type { OpenAPIV3 } from "openapi-types"; import type { TypeOptions } from "../types.js"; import { createMetadataStorage } from "./factory.js"; -export type PropertyMetadata = { +export type PropertyMetadata = Omit & { name: string; required: boolean; } & TypeOptions; diff --git a/packages/openapi-metadata/src/types.ts b/packages/openapi-metadata/src/types.ts index d5ddd0c02..c18975019 100644 --- a/packages/openapi-metadata/src/types.ts +++ b/packages/openapi-metadata/src/types.ts @@ -5,7 +5,7 @@ export type HttpMethods = `${OpenAPIV3.HttpMethods}`; export type PrimitiveType = OpenAPIV3.NonArraySchemaObjectType; -export type TypeValue = Function | PrimitiveType; +export type TypeValue = Function | PrimitiveType | [PrimitiveType | Function]; export type Thunk = (context: Context) => T; export type EnumTypeValue = string[] | number[] | Record; diff --git a/packages/openapi-metadata/test/decorators.test.ts b/packages/openapi-metadata/test/decorators.test.ts index a04772f7d..989d9cee4 100644 --- a/packages/openapi-metadata/test/decorators.test.ts +++ b/packages/openapi-metadata/test/decorators.test.ts @@ -8,11 +8,12 @@ import { ApiHeader, ApiOperation, ApiParam, + ApiProperty, ApiQuery, ApiResponse, ApiSecurity, ApiTags, -} from "../src/decorators"; +} from "../src/decorators/index.js"; import { ExcludeMetadataStorage, ExtraModelsMetadataStorage, @@ -21,8 +22,9 @@ import { OperationParameterMetadataStorage, OperationResponseMetadataStorage, OperationSecurityMetadataStorage, -} from "../src/metadata"; -import { ApiBasicAuth, ApiBearerAuth, ApiCookieAuth, ApiOauth2 } from "../src/decorators/api-security"; + PropertyMetadataStorage, +} from "../src/metadata/index.js"; +import { ApiBasicAuth, ApiBearerAuth, ApiCookieAuth, ApiOauth2 } from "../src/decorators/api-security.js"; test("@ApiOperation", () => { class MyController { @@ -189,3 +191,64 @@ test("@ApiExtraModels", () => { expect(metadata).toEqual(["string"]); }); + +test("@ApiProperty", () => { + class User { + @ApiProperty() + declare declared: string; + + @ApiProperty() + // biome-ignore lint/style/noInferrableTypes: required for metadata + defined: number = 4; + + @ApiProperty({ type: "string" }) + explicitType = "test"; + + @ApiProperty({ example: "hey" }) + get getter(): string { + return "hello"; + } + + @ApiProperty() + func(): boolean { + return false; + } + } + + const metadata = PropertyMetadataStorage.getMetadata(User.prototype); + + expect(metadata.declared).toMatchObject({ + name: "declared", + required: true, + }); + // @ts-expect-error + expect(metadata.declared?.type()).toEqual(String); + + expect(metadata.defined).toMatchObject({ + name: "defined", + required: true, + }); + // @ts-expect-error + expect(metadata.defined?.type()).toEqual(Number); + + expect(metadata.explicitType).toMatchObject({ + name: "explicitType", + required: true, + type: "string", + }); + + expect(metadata.getter).toMatchObject({ + name: "getter", + required: true, + example: "hey", + }); + // @ts-expect-error + expect(metadata.getter?.type()).toEqual(String); + + expect(metadata.func).toMatchObject({ + name: "func", + required: true, + }); + // @ts-expect-error + expect(metadata.func?.type()).toEqual(Boolean); +}); diff --git a/packages/openapi-metadata/test/loaders/array.test.ts b/packages/openapi-metadata/test/loaders/array.test.ts new file mode 100644 index 000000000..c78a6b085 --- /dev/null +++ b/packages/openapi-metadata/test/loaders/array.test.ts @@ -0,0 +1,34 @@ +import type { Context } from "../../src/context.js"; +import { ArrayTypeLoader } from "../../src/loaders/type.js"; + +let error: string | undefined = undefined; +const context: Context = { + schemas: {}, + typeLoaders: [], + logger: { + warn: (message) => { + error = message; + }, + }, +}; + +test("simple array", async () => { + expect(await ArrayTypeLoader(context, [String])).toEqual({ + type: "array", + items: { + type: "string", + }, + }); +}); + +test("empty array should warn", async () => { + // @ts-expect-error + expect(await ArrayTypeLoader(context, [])).toEqual(undefined); + expect(error).toContain("You tried to specify an array type without any item"); +}); + +test("array with multiple items should warn", async () => { + // @ts-expect-error + expect(await ArrayTypeLoader(context, [String, Number])).toEqual(undefined); + expect(error).toContain("You tried to specify an array type with multiple items."); +}); diff --git a/packages/openapi-metadata/test/loaders/class.test.ts b/packages/openapi-metadata/test/loaders/class.test.ts new file mode 100644 index 000000000..68fd80e0a --- /dev/null +++ b/packages/openapi-metadata/test/loaders/class.test.ts @@ -0,0 +1,25 @@ +import "reflect-metadata"; +import { ApiProperty } from "../../src/decorators/api-property.js"; +import { ClassTypeLoader } from "../../src/loaders/type.js"; +import type { Context } from "../../src/context.js"; + +test("simple class", async () => { + const context: Context = { schemas: {}, typeLoaders: [], logger: console }; + class Post { + @ApiProperty() + declare id: string; + } + + const result = await ClassTypeLoader(context, Post); + + expect(result).toEqual({ $ref: "#/components/schemas/Post" }); + expect(context.schemas.Post).toEqual({ + type: "object", + properties: { + id: { + type: "string", + }, + }, + required: ["id"], + }); +}); diff --git a/packages/openapi-metadata/test/loaders/primitives.test.ts b/packages/openapi-metadata/test/loaders/primitives.test.ts new file mode 100644 index 000000000..ee2745320 --- /dev/null +++ b/packages/openapi-metadata/test/loaders/primitives.test.ts @@ -0,0 +1,36 @@ +import type { Context } from "../../src/context.js"; +import { PrimitiveTypeLoader } from "../../src/loaders/type.js"; + +const context: Context = { schemas: {}, typeLoaders: [], logger: console }; + +test("string", async () => { + expect(await PrimitiveTypeLoader(context, "string")).toEqual({ + type: "string", + }); + + expect(await PrimitiveTypeLoader(context, "number")).toEqual({ + type: "number", + }); + + expect(await PrimitiveTypeLoader(context, "boolean")).toEqual({ + type: "boolean", + }); + + expect(await PrimitiveTypeLoader(context, "integer")).toEqual({ + type: "integer", + }); +}); + +test("constructor", async () => { + expect(await PrimitiveTypeLoader(context, String)).toEqual({ + type: "string", + }); + + expect(await PrimitiveTypeLoader(context, Number)).toEqual({ + type: "number", + }); + + expect(await PrimitiveTypeLoader(context, Boolean)).toEqual({ + type: "boolean", + }); +});