Skip to content

Commit e864bec

Browse files
authored
feat(metadata): handle array types and improve api property types (#2001)
1 parent af46ce5 commit e864bec

File tree

8 files changed

+202
-9
lines changed

8 files changed

+202
-9
lines changed

.changeset/selfish-items-jump.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-metadata": minor
3+
---
4+
5+
Handle array types and fix ApiProperty decorator type

packages/openapi-metadata/src/loaders/type.ts

+33-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { PropertyMetadataStorage } from "../metadata/property.js";
77
import { schemaPath } from "../utils/schema.js";
88
import { isThunk } from "../utils/metadata.js";
99

10-
const PrimitiveTypeLoader: TypeLoaderFn = async (_context, value) => {
10+
export const PrimitiveTypeLoader: TypeLoaderFn = async (_context, value) => {
1111
if (typeof value === "string") {
1212
return { type: value };
1313
}
@@ -28,7 +28,38 @@ const PrimitiveTypeLoader: TypeLoaderFn = async (_context, value) => {
2828
}
2929
};
3030

31-
const ClassTypeLoader: TypeLoaderFn = async (context, value) => {
31+
export const ArrayTypeLoader: TypeLoaderFn = async (context, value) => {
32+
if (!Array.isArray(value)) {
33+
return;
34+
}
35+
36+
if (value.length <= 0) {
37+
context.logger.warn("You tried to specify an array type without any item");
38+
return;
39+
}
40+
41+
if (value.length > 1) {
42+
context.logger.warn(
43+
"You tried to specify an array type with multiple items. Please use the 'enum' option if you want to specify an enum.",
44+
);
45+
return;
46+
}
47+
48+
const itemsSchema = await loadType(context, { type: value[0] });
49+
50+
// TODO: Better warn stack trace
51+
if (!itemsSchema) {
52+
context.logger.warn("You tried to specify an array type with an item that resolves to undefined.");
53+
return;
54+
}
55+
56+
return {
57+
type: "array",
58+
items: itemsSchema,
59+
};
60+
};
61+
62+
export const ClassTypeLoader: TypeLoaderFn = async (context, value) => {
3263
if (typeof value !== "function" || !value.prototype) {
3364
return;
3465
}
@@ -49,8 +80,6 @@ const ClassTypeLoader: TypeLoaderFn = async (context, value) => {
4980

5081
if (!properties) {
5182
context.logger.warn(`You tried to use '${model}' as a type but it does not contain any ApiProperty.`);
52-
53-
return;
5483
}
5584

5685
context.schemas[model] = schema;

packages/openapi-metadata/src/metadata/property.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import type { OpenAPIV3 } from "openapi-types";
12
import type { TypeOptions } from "../types.js";
23
import { createMetadataStorage } from "./factory.js";
34

4-
export type PropertyMetadata = {
5+
export type PropertyMetadata = Omit<OpenAPIV3.NonArraySchemaObject, "type" | "enum" | "properties" | "required"> & {
56
name: string;
67
required: boolean;
78
} & TypeOptions;

packages/openapi-metadata/src/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export type HttpMethods = `${OpenAPIV3.HttpMethods}`;
55

66
export type PrimitiveType = OpenAPIV3.NonArraySchemaObjectType;
77

8-
export type TypeValue = Function | PrimitiveType;
8+
export type TypeValue = Function | PrimitiveType | [PrimitiveType | Function];
99
export type Thunk<T> = (context: Context) => T;
1010
export type EnumTypeValue = string[] | number[] | Record<number, string>;
1111

packages/openapi-metadata/test/decorators.test.ts

+66-3
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import {
88
ApiHeader,
99
ApiOperation,
1010
ApiParam,
11+
ApiProperty,
1112
ApiQuery,
1213
ApiResponse,
1314
ApiSecurity,
1415
ApiTags,
15-
} from "../src/decorators";
16+
} from "../src/decorators/index.js";
1617
import {
1718
ExcludeMetadataStorage,
1819
ExtraModelsMetadataStorage,
@@ -21,8 +22,9 @@ import {
2122
OperationParameterMetadataStorage,
2223
OperationResponseMetadataStorage,
2324
OperationSecurityMetadataStorage,
24-
} from "../src/metadata";
25-
import { ApiBasicAuth, ApiBearerAuth, ApiCookieAuth, ApiOauth2 } from "../src/decorators/api-security";
25+
PropertyMetadataStorage,
26+
} from "../src/metadata/index.js";
27+
import { ApiBasicAuth, ApiBearerAuth, ApiCookieAuth, ApiOauth2 } from "../src/decorators/api-security.js";
2628

2729
test("@ApiOperation", () => {
2830
class MyController {
@@ -189,3 +191,64 @@ test("@ApiExtraModels", () => {
189191

190192
expect(metadata).toEqual(["string"]);
191193
});
194+
195+
test("@ApiProperty", () => {
196+
class User {
197+
@ApiProperty()
198+
declare declared: string;
199+
200+
@ApiProperty()
201+
// biome-ignore lint/style/noInferrableTypes: required for metadata
202+
defined: number = 4;
203+
204+
@ApiProperty({ type: "string" })
205+
explicitType = "test";
206+
207+
@ApiProperty({ example: "hey" })
208+
get getter(): string {
209+
return "hello";
210+
}
211+
212+
@ApiProperty()
213+
func(): boolean {
214+
return false;
215+
}
216+
}
217+
218+
const metadata = PropertyMetadataStorage.getMetadata(User.prototype);
219+
220+
expect(metadata.declared).toMatchObject({
221+
name: "declared",
222+
required: true,
223+
});
224+
// @ts-expect-error
225+
expect(metadata.declared?.type()).toEqual(String);
226+
227+
expect(metadata.defined).toMatchObject({
228+
name: "defined",
229+
required: true,
230+
});
231+
// @ts-expect-error
232+
expect(metadata.defined?.type()).toEqual(Number);
233+
234+
expect(metadata.explicitType).toMatchObject({
235+
name: "explicitType",
236+
required: true,
237+
type: "string",
238+
});
239+
240+
expect(metadata.getter).toMatchObject({
241+
name: "getter",
242+
required: true,
243+
example: "hey",
244+
});
245+
// @ts-expect-error
246+
expect(metadata.getter?.type()).toEqual(String);
247+
248+
expect(metadata.func).toMatchObject({
249+
name: "func",
250+
required: true,
251+
});
252+
// @ts-expect-error
253+
expect(metadata.func?.type()).toEqual(Boolean);
254+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { Context } from "../../src/context.js";
2+
import { ArrayTypeLoader } from "../../src/loaders/type.js";
3+
4+
let error: string | undefined = undefined;
5+
const context: Context = {
6+
schemas: {},
7+
typeLoaders: [],
8+
logger: {
9+
warn: (message) => {
10+
error = message;
11+
},
12+
},
13+
};
14+
15+
test("simple array", async () => {
16+
expect(await ArrayTypeLoader(context, [String])).toEqual({
17+
type: "array",
18+
items: {
19+
type: "string",
20+
},
21+
});
22+
});
23+
24+
test("empty array should warn", async () => {
25+
// @ts-expect-error
26+
expect(await ArrayTypeLoader(context, [])).toEqual(undefined);
27+
expect(error).toContain("You tried to specify an array type without any item");
28+
});
29+
30+
test("array with multiple items should warn", async () => {
31+
// @ts-expect-error
32+
expect(await ArrayTypeLoader(context, [String, Number])).toEqual(undefined);
33+
expect(error).toContain("You tried to specify an array type with multiple items.");
34+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import "reflect-metadata";
2+
import { ApiProperty } from "../../src/decorators/api-property.js";
3+
import { ClassTypeLoader } from "../../src/loaders/type.js";
4+
import type { Context } from "../../src/context.js";
5+
6+
test("simple class", async () => {
7+
const context: Context = { schemas: {}, typeLoaders: [], logger: console };
8+
class Post {
9+
@ApiProperty()
10+
declare id: string;
11+
}
12+
13+
const result = await ClassTypeLoader(context, Post);
14+
15+
expect(result).toEqual({ $ref: "#/components/schemas/Post" });
16+
expect(context.schemas.Post).toEqual({
17+
type: "object",
18+
properties: {
19+
id: {
20+
type: "string",
21+
},
22+
},
23+
required: ["id"],
24+
});
25+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Context } from "../../src/context.js";
2+
import { PrimitiveTypeLoader } from "../../src/loaders/type.js";
3+
4+
const context: Context = { schemas: {}, typeLoaders: [], logger: console };
5+
6+
test("string", async () => {
7+
expect(await PrimitiveTypeLoader(context, "string")).toEqual({
8+
type: "string",
9+
});
10+
11+
expect(await PrimitiveTypeLoader(context, "number")).toEqual({
12+
type: "number",
13+
});
14+
15+
expect(await PrimitiveTypeLoader(context, "boolean")).toEqual({
16+
type: "boolean",
17+
});
18+
19+
expect(await PrimitiveTypeLoader(context, "integer")).toEqual({
20+
type: "integer",
21+
});
22+
});
23+
24+
test("constructor", async () => {
25+
expect(await PrimitiveTypeLoader(context, String)).toEqual({
26+
type: "string",
27+
});
28+
29+
expect(await PrimitiveTypeLoader(context, Number)).toEqual({
30+
type: "number",
31+
});
32+
33+
expect(await PrimitiveTypeLoader(context, Boolean)).toEqual({
34+
type: "boolean",
35+
});
36+
});

0 commit comments

Comments
 (0)