Skip to content

Commit a08584e

Browse files
committed
feat(openapi-metadata): handle array types and improve api property types
1 parent d29e53f commit a08584e

File tree

7 files changed

+285
-29
lines changed

7 files changed

+285
-29
lines changed

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

+60-13
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,40 @@ 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(
53+
"You tried to specify an array type with an item that resolves to undefined.",
54+
);
55+
return;
56+
}
57+
58+
return {
59+
type: "array",
60+
items: itemsSchema,
61+
};
62+
};
63+
64+
export const ClassTypeLoader: TypeLoaderFn = async (context, value) => {
3265
if (typeof value !== "function" || !value.prototype) {
3366
return;
3467
}
@@ -39,24 +72,32 @@ const ClassTypeLoader: TypeLoaderFn = async (context, value) => {
3972
return { $ref: schemaPath(model) };
4073
}
4174

42-
const schema: SetRequired<OpenAPIV3.SchemaObject, "properties" | "required"> = {
43-
type: "object",
44-
properties: {},
45-
required: [],
46-
};
75+
const schema: SetRequired<OpenAPIV3.SchemaObject, "properties" | "required"> =
76+
{
77+
type: "object",
78+
properties: {},
79+
required: [],
80+
};
4781

4882
const properties = PropertyMetadataStorage.getMetadata(value.prototype);
4983

5084
if (!properties) {
51-
context.logger.warn(`You tried to use '${model}' as a type but it does not contain any ApiProperty.`);
52-
53-
return;
85+
context.logger.warn(
86+
`You tried to use '${model}' as a type but it does not contain any ApiProperty.`,
87+
);
5488
}
5589

5690
context.schemas[model] = schema;
5791

5892
for (const [key, property] of Object.entries(properties)) {
59-
const { required, type, name, enum: e, schema: s, ...metadata } = property as any;
93+
const {
94+
required,
95+
type,
96+
name,
97+
enum: e,
98+
schema: s,
99+
...metadata
100+
} = property as any;
60101
schema.properties[key] = {
61102
...(await loadType(context, property)),
62103
...metadata,
@@ -96,12 +137,18 @@ export async function loadType(
96137
const thunk = isThunk(options.type);
97138
const value = thunk ? (options.type as Function)(context) : options.type;
98139

99-
for (const loader of [PrimitiveTypeLoader, ...context.typeLoaders, ClassTypeLoader]) {
140+
for (const loader of [
141+
PrimitiveTypeLoader,
142+
...context.typeLoaders,
143+
ClassTypeLoader,
144+
]) {
100145
const result = await loader(context, value, options.type);
101146
if (result) {
102147
return result;
103148
}
104149
}
105150

106-
context.logger.warn(`You tried to use '${options.type.toString()}' as a type but no loader supports it ${thunk}`);
151+
context.logger.warn(
152+
`You tried to use '${options.type.toString()}' as a type but no loader supports it ${thunk}`,
153+
);
107154
}
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
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<
6+
OpenAPIV3.NonArraySchemaObject,
7+
"type" | "enum" | "properties" | "required"
8+
> & {
59
name: string;
610
required: boolean;
711
} & TypeOptions;
812

913
export const PropertyMetadataKey = Symbol("Property");
1014

11-
export const PropertyMetadataStorage = createMetadataStorage<Record<string, PropertyMetadata>>(PropertyMetadataKey);
15+
export const PropertyMetadataStorage =
16+
createMetadataStorage<Record<string, PropertyMetadata>>(PropertyMetadataKey);

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

+118-13
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,16 +22,25 @@ 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 {
28+
ApiBasicAuth,
29+
ApiBearerAuth,
30+
ApiCookieAuth,
31+
ApiOauth2,
32+
} from "../src/decorators/api-security.js";
2633

2734
test("@ApiOperation", () => {
2835
class MyController {
2936
@ApiOperation({ summary: "Hello", path: "/test", methods: ["get"] })
3037
operation() {}
3138
}
3239

33-
const metadata = OperationMetadataStorage.getMetadata(MyController.prototype, "operation");
40+
const metadata = OperationMetadataStorage.getMetadata(
41+
MyController.prototype,
42+
"operation",
43+
);
3444

3545
expect(metadata).toEqual({
3646
summary: "Hello",
@@ -45,7 +55,10 @@ test("@ApiBody", () => {
4555
operation() {}
4656
}
4757

48-
const metadata = OperationBodyMetadataStorage.getMetadata(MyController.prototype, "operation");
58+
const metadata = OperationBodyMetadataStorage.getMetadata(
59+
MyController.prototype,
60+
"operation",
61+
);
4962

5063
expect(metadata).toEqual({
5164
type: "string",
@@ -60,7 +73,11 @@ test("@ApiParam", () => {
6073
operation() {}
6174
}
6275

63-
const metadata = OperationParameterMetadataStorage.getMetadata(MyController.prototype, "operation", true);
76+
const metadata = OperationParameterMetadataStorage.getMetadata(
77+
MyController.prototype,
78+
"operation",
79+
true,
80+
);
6481

6582
expect(metadata).toEqual([
6683
{ in: "path", name: "test" },
@@ -75,7 +92,11 @@ test("@ApiHeader", () => {
7592
operation() {}
7693
}
7794

78-
const metadata = OperationParameterMetadataStorage.getMetadata(MyController.prototype, "operation", true);
95+
const metadata = OperationParameterMetadataStorage.getMetadata(
96+
MyController.prototype,
97+
"operation",
98+
true,
99+
);
79100

80101
expect(metadata).toEqual([
81102
{ in: "header", name: "test" },
@@ -90,7 +111,11 @@ test("@ApiCookie", () => {
90111
operation() {}
91112
}
92113

93-
const metadata = OperationParameterMetadataStorage.getMetadata(MyController.prototype, "operation", true);
114+
const metadata = OperationParameterMetadataStorage.getMetadata(
115+
MyController.prototype,
116+
"operation",
117+
true,
118+
);
94119

95120
expect(metadata).toEqual([
96121
{ in: "cookie", name: "test" },
@@ -105,7 +130,11 @@ test("@ApiQuery", () => {
105130
operation() {}
106131
}
107132

108-
const metadata = OperationParameterMetadataStorage.getMetadata(MyController.prototype, "operation", true);
133+
const metadata = OperationParameterMetadataStorage.getMetadata(
134+
MyController.prototype,
135+
"operation",
136+
true,
137+
);
109138

110139
expect(metadata).toEqual([
111140
{ in: "query", name: "test" },
@@ -120,7 +149,11 @@ test("@ApiResponse", () => {
120149
operation() {}
121150
}
122151

123-
const metadata = OperationResponseMetadataStorage.getMetadata(MyController.prototype, "operation", true);
152+
const metadata = OperationResponseMetadataStorage.getMetadata(
153+
MyController.prototype,
154+
"operation",
155+
true,
156+
);
124157

125158
expect(metadata).toEqual({
126159
default: { status: "default", mediaType: "text/html", type: "string" },
@@ -135,7 +168,11 @@ test("@ApiTags", () => {
135168
operation() {}
136169
}
137170

138-
const metadata = OperationMetadataStorage.getMetadata(MyController.prototype, "operation", true);
171+
const metadata = OperationMetadataStorage.getMetadata(
172+
MyController.prototype,
173+
"operation",
174+
true,
175+
);
139176

140177
expect(metadata.tags).toEqual(["Root", "Hello", "World"]);
141178
});
@@ -150,7 +187,11 @@ test("@ApiSecurity", () => {
150187
operation() {}
151188
}
152189

153-
const metadata = OperationSecurityMetadataStorage.getMetadata(MyController.prototype, "operation", true);
190+
const metadata = OperationSecurityMetadataStorage.getMetadata(
191+
MyController.prototype,
192+
"operation",
193+
true,
194+
);
154195

155196
expect(metadata).toEqual({
156197
custom: [],
@@ -175,7 +216,10 @@ test("@ApiExcludeOperation", () => {
175216
operation() {}
176217
}
177218

178-
const metadata = ExcludeMetadataStorage.getMetadata(MyController.prototype, "operation");
219+
const metadata = ExcludeMetadataStorage.getMetadata(
220+
MyController.prototype,
221+
"operation",
222+
);
179223
expect(metadata).toBe(true);
180224
});
181225

@@ -189,3 +233,64 @@ test("@ApiExtraModels", () => {
189233

190234
expect(metadata).toEqual(["string"]);
191235
});
236+
237+
test("@ApiProperty", () => {
238+
class User {
239+
@ApiProperty()
240+
declare declared: string;
241+
242+
@ApiProperty()
243+
// biome-ignore lint/style/noInferrableTypes: required for metadata
244+
defined: number = 4;
245+
246+
@ApiProperty({ type: "string" })
247+
explicitType = "test";
248+
249+
@ApiProperty({ example: "hey" })
250+
get getter(): string {
251+
return "hello";
252+
}
253+
254+
@ApiProperty()
255+
func(): boolean {
256+
return false;
257+
}
258+
}
259+
260+
const metadata = PropertyMetadataStorage.getMetadata(User.prototype);
261+
262+
expect(metadata.declared).toMatchObject({
263+
name: "declared",
264+
required: true,
265+
});
266+
// @ts-expect-error
267+
expect(metadata.declared?.type()).toEqual(String);
268+
269+
expect(metadata.defined).toMatchObject({
270+
name: "defined",
271+
required: true,
272+
});
273+
// @ts-expect-error
274+
expect(metadata.defined?.type()).toEqual(Number);
275+
276+
expect(metadata.explicitType).toMatchObject({
277+
name: "explicitType",
278+
required: true,
279+
type: "string",
280+
});
281+
282+
expect(metadata.getter).toMatchObject({
283+
name: "getter",
284+
required: true,
285+
example: "hey",
286+
});
287+
// @ts-expect-error
288+
expect(metadata.getter?.type()).toEqual(String);
289+
290+
expect(metadata.func).toMatchObject({
291+
name: "func",
292+
required: true,
293+
});
294+
// @ts-expect-error
295+
expect(metadata.func?.type()).toEqual(Boolean);
296+
});

0 commit comments

Comments
 (0)