Skip to content

Commit f5b1ec9

Browse files
authoredApr 11, 2023
feat(core): remove readonly properties from request body (#813)
Readonly properties only have effect on responses. So for request bodies the properties that are readonly should be removed from the type.
1 parent 673bb55 commit f5b1ec9

19 files changed

+349
-5
lines changed
 

‎packages/core/src/getters/array.ts

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const getArray = ({
3131
isEnum: false,
3232
type: 'array',
3333
isRef: false,
34+
hasReadonlyProps: resolvedObject.hasReadonlyProps,
3435
};
3536
} else {
3637
throw new Error('All arrays must have an `items` key define');

‎packages/core/src/getters/body.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export const getBody = ({
4242
const schemas = filteredBodyTypes.flatMap(({ schemas }) => schemas);
4343

4444
const definition = filteredBodyTypes.map(({ value }) => value).join(' | ');
45+
const hasReadonlyProps = filteredBodyTypes.some((x) => x.hasReadonlyProps);
46+
const nonReadonlyDefinition =
47+
hasReadonlyProps && definition ? `NonReadonly<${definition}>` : definition;
4548

4649
const implementation =
4750
generalJSTypesWithArray.includes(definition.toLowerCase()) ||
@@ -51,7 +54,7 @@ export const getBody = ({
5154

5255
return {
5356
originalSchema: requestBody,
54-
definition,
57+
definition: nonReadonlyDefinition,
5558
implementation,
5659
imports,
5760
schemas,

‎packages/core/src/getters/combine.ts

+8
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type CombinedData = {
2020
isRef: boolean[];
2121
isEnum: boolean[];
2222
types: string[];
23+
hasReadonlyProps: boolean;
2324
};
2425

2526
type Separator = 'allOf' | 'anyOf' | 'oneOf';
@@ -92,6 +93,7 @@ export const combineSchemas = ({
9293
acc.types.push(resolvedValue.type);
9394
acc.isRef.push(resolvedValue.isRef);
9495
acc.originalSchema.push(resolvedValue.originalSchema);
96+
acc.hasReadonlyProps ||= resolvedValue.hasReadonlyProps;
9597

9698
return acc;
9799
},
@@ -103,6 +105,7 @@ export const combineSchemas = ({
103105
isRef: [],
104106
types: [],
105107
originalSchema: [],
108+
hasReadonlyProps: false,
106109
} as CombinedData,
107110
);
108111

@@ -133,6 +136,7 @@ export const combineSchemas = ({
133136
isEnum: false,
134137
type: 'object' as SchemaType,
135138
isRef: false,
139+
hasReadonlyProps: resolvedData.hasReadonlyProps,
136140
};
137141
}
138142

@@ -147,6 +151,10 @@ export const combineSchemas = ({
147151
isEnum: false,
148152
type: 'object' as SchemaType,
149153
isRef: false,
154+
hasReadonlyProps:
155+
resolvedData?.hasReadonlyProps ||
156+
resolvedValue?.hasReadonlyProps ||
157+
false,
150158
};
151159
};
152160

‎packages/core/src/getters/object.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ReferenceObject, SchemaObject } from 'openapi3-ts';
2-
import { resolveObject, resolveValue } from '../resolvers';
2+
import { resolveObject, resolveRef, resolveValue } from '../resolvers';
33
import { ContextSpecs, ScalarValue, SchemaType } from '../types';
44
import { isBoolean, isReference, jsDoc, pascal } from '../utils';
55
import { combineSchemas } from './combine';
@@ -31,6 +31,7 @@ export const getObject = ({
3131
isEnum: false,
3232
type: 'object',
3333
isRef: true,
34+
hasReadonlyProps: item.readOnly || false,
3435
};
3536
}
3637

@@ -102,6 +103,7 @@ export const getObject = ({
102103

103104
const doc = jsDoc(schema as SchemaObject, true);
104105

106+
acc.hasReadonlyProps ||= isReadOnly || false;
105107
acc.imports.push(...resolvedValue.imports);
106108
acc.value += `\n ${doc ? `${doc} ` : ''}${
107109
isReadOnly ? 'readonly ' : ''
@@ -137,6 +139,7 @@ export const getObject = ({
137139
type: 'object' as SchemaType,
138140
isRef: false,
139141
schema: {},
142+
hasReadonlyProps: false,
140143
} as ScalarValue,
141144
);
142145
}
@@ -150,6 +153,7 @@ export const getObject = ({
150153
isEnum: false,
151154
type: 'object',
152155
isRef: false,
156+
hasReadonlyProps: item.readOnly || false,
153157
};
154158
}
155159
const resolvedValue = resolveValue({
@@ -164,6 +168,7 @@ export const getObject = ({
164168
isEnum: false,
165169
type: 'object',
166170
isRef: false,
171+
hasReadonlyProps: resolvedValue.hasReadonlyProps,
167172
};
168173
}
169174

@@ -175,5 +180,6 @@ export const getObject = ({
175180
isEnum: false,
176181
type: 'object',
177182
isRef: false,
183+
hasReadonlyProps: item.readOnly || false,
178184
};
179185
};

‎packages/core/src/getters/res-req-types.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export const getResReqTypes = (
7474
type: 'unknown',
7575
isEnum: false,
7676
isRef: true,
77+
hasReadonlyProps: false,
7778
originalSchema: mediaType?.schema,
7879
key,
7980
contentType,
@@ -113,6 +114,7 @@ export const getResReqTypes = (
113114
schemas: [],
114115
type: 'unknown',
115116
isEnum: false,
117+
hasReadonlyProps: false,
116118
formData,
117119
formUrlEncoded,
118120
isRef: true,
@@ -146,8 +148,19 @@ export const getResReqTypes = (
146148
const isFormUrlEncoded =
147149
formUrlEncodedContentTypes.includes(contentType);
148150

151+
const imports = [
152+
...resolvedValue.imports,
153+
...(resolvedValue.hasReadonlyProps
154+
? [{ name: 'NonReadonly' }]
155+
: []),
156+
];
157+
149158
if ((!isFormData && !isFormUrlEncoded) || !propName) {
150-
return { ...resolvedValue, contentType };
159+
return {
160+
...resolvedValue,
161+
imports,
162+
contentType,
163+
};
151164
}
152165

153166
const formData = isFormData
@@ -169,6 +182,7 @@ export const getResReqTypes = (
169182

170183
return {
171184
...resolvedValue,
185+
imports,
172186
formData,
173187
formUrlEncoded,
174188
contentType,
@@ -190,6 +204,7 @@ export const getResReqTypes = (
190204
isEnum: false,
191205
key,
192206
isRef: false,
207+
hasReadonlyProps: false,
193208
contentType: 'application/json',
194209
},
195210
] as ResReqTypesValue[];

‎packages/core/src/getters/scalar.ts

+5
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const getScalar = ({
4343
schemas: [],
4444
imports: [],
4545
isRef: false,
46+
hasReadonlyProps: item.readOnly || false,
4647
};
4748
}
4849

@@ -54,6 +55,7 @@ export const getScalar = ({
5455
schemas: [],
5556
imports: [],
5657
isRef: false,
58+
hasReadonlyProps: item.readOnly || false,
5759
};
5860

5961
case 'array': {
@@ -100,6 +102,7 @@ export const getScalar = ({
100102
imports: [],
101103
schemas: [],
102104
isRef: false,
105+
hasReadonlyProps: item.readOnly || false,
103106
};
104107
}
105108

@@ -111,6 +114,7 @@ export const getScalar = ({
111114
imports: [],
112115
schemas: [],
113116
isRef: false,
117+
hasReadonlyProps: item.readOnly || false,
114118
};
115119

116120
case 'object':
@@ -130,6 +134,7 @@ export const getScalar = ({
130134
imports: [],
131135
schemas: [],
132136
isRef: false,
137+
hasReadonlyProps: item.readOnly || false,
133138
};
134139
}
135140

‎packages/core/src/resolvers/object.ts

+2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const resolveObject = ({
4343
type: 'object',
4444
originalSchema: resolvedValue.originalSchema,
4545
isRef: resolvedValue.isRef,
46+
hasReadonlyProps: resolvedValue.hasReadonlyProps,
4647
};
4748
}
4849

@@ -68,6 +69,7 @@ export const resolveObject = ({
6869
type: 'enum',
6970
originalSchema: resolvedValue.originalSchema,
7071
isRef: resolvedValue.isRef,
72+
hasReadonlyProps: resolvedValue.hasReadonlyProps,
7173
};
7274
}
7375

‎packages/core/src/resolvers/value.ts

+4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getScalar } from '../getters';
33
import { ContextSpecs, ResolverValue, SchemaType } from '../types';
44
import { isReference } from '../utils';
55
import { resolveRef } from './ref';
6+
import { resolveObject } from './object';
67

78
export const resolveValue = ({
89
schema,
@@ -19,6 +20,8 @@ export const resolveValue = ({
1920
context,
2021
);
2122

23+
const resolvedObject = resolveObject({ schema: schemaObject, context });
24+
2225
const { name, specKey, schemaName } = imports[0];
2326

2427
const importSpecKey =
@@ -32,6 +35,7 @@ export const resolveValue = ({
3235
schemas: [],
3336
isEnum: !!schemaObject?.enum,
3437
originalSchema: schemaObject,
38+
hasReadonlyProps: resolvedObject.hasReadonlyProps,
3539
isRef: true,
3640
};
3741
}

‎packages/core/src/types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,7 @@ export const SchemaType = {
685685
export type ScalarValue = {
686686
value: string;
687687
isEnum: boolean;
688+
hasReadonlyProps: boolean;
688689
type: SchemaType;
689690
imports: GeneratorImport[];
690691
schemas: GeneratorSchema[];
@@ -699,6 +700,7 @@ export type ResReqTypesValue = ScalarValue & {
699700
formData?: string;
700701
formUrlEncoded?: string;
701702
isRef?: boolean;
703+
hasReadonlyProps?: boolean;
702704
key: string;
703705
contentType: string;
704706
originalSchema?: SchemaObject;

‎packages/core/src/writers/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './schemas';
2+
export * from './types';
23
export * from './single-mode';
34
export * from './split-mode';
45
export * from './split-tags-mode';

‎packages/core/src/writers/schemas.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'fs-extra';
22
import { generateImports } from '../generators';
33
import { GeneratorSchema } from '../types';
44
import { camel, upath } from '../utils';
5+
import { getOrvalGeneratedTypes } from './types';
56

67
const getSchema = ({
78
schema: { imports, model },
@@ -125,10 +126,12 @@ export const writeSchemas = async ({
125126
.match(/export \* from(.*)('|")/g)
126127
?.map((s) => s + ';') ?? []) as string[];
127128

128-
const fileContent = [...currentFileExports, ...importStatements]
129+
const exports = [...currentFileExports, ...importStatements]
129130
.sort()
130131
.join('\n');
131132

133+
const fileContent = `${header}\n${exports}\n${getOrvalGeneratedTypes()}`;
134+
132135
await fs.writeFile(schemaFilePath, fileContent);
133136
} catch (e) {
134137
throw `Oups... 🍻. An Error occurred while writing schema index file ${schemaFilePath} => ${e}`;

‎packages/core/src/writers/types.ts

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export const getOrvalGeneratedTypes = () => `
2+
// https://stackoverflow.com/questions/49579094/typescript-conditional-types-filter-out-readonly-properties-pick-only-requir/49579497#49579497
3+
type IfEquals<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <
4+
T,
5+
>() => T extends Y ? 1 : 2
6+
? A
7+
: B;
8+
9+
type WritableKeys<T> = {
10+
[P in keyof T]-?: IfEquals<
11+
{ [Q in P]: T[P] },
12+
{ -readonly [Q in P]: T[P] },
13+
P
14+
>;
15+
}[keyof T];
16+
17+
type UnionToIntersection<U> =
18+
(U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never;
19+
type DistributeReadOnlyOverUnions<T> = T extends any ? NonReadonly<T> : never;
20+
21+
type Writable<T> = Pick<T, WritableKeys<T>>;
22+
export type NonReadonly<T> = [T] extends [UnionToIntersection<T>] ? {
23+
[P in keyof Writable<T>]: T[P] extends object
24+
? NonReadonly<NonNullable<T[P]>>
25+
: T[P];
26+
} : DistributeReadOnlyOverUnions<T>;
27+
`;

‎samples/react-query/basic/petstore.yaml

+25
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,30 @@ paths:
7070
application/json:
7171
schema:
7272
$ref: '#/components/schemas/Error'
73+
put:
74+
summary: Update a pet
75+
operationId: updatePets
76+
tags:
77+
- pets
78+
requestBody:
79+
required: true
80+
content:
81+
application/json:
82+
schema:
83+
$ref: '#/components/schemas/Pet'
84+
responses:
85+
'200':
86+
description: Created Pet
87+
content:
88+
application/json:
89+
schema:
90+
$ref: '#/components/schemas/Pet'
91+
default:
92+
description: unexpected error
93+
content:
94+
application/json:
95+
schema:
96+
$ref: '#/components/schemas/Error'
7397
/pets/{petId}:
7498
get:
7599
summary: Info for a specific pet
@@ -168,6 +192,7 @@ components:
168192
properties:
169193
petsRequested:
170194
type: integer
195+
readOnly: true
171196
type:
172197
type: string
173198
enum:

‎samples/react-query/basic/src/api/endpoints/petstoreFromFileSpecWithTransformer.msw.ts

+62
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,61 @@ export const getCreatePetsMock = () =>
122122
},
123123
]);
124124

125+
export const getUpdatePetsMock = () =>
126+
faker.helpers.arrayElement([
127+
{
128+
cuteness: faker.datatype.number({ min: undefined, max: undefined }),
129+
breed: faker.helpers.arrayElement(['Labradoodle']),
130+
barksPerMinute: faker.helpers.arrayElement([
131+
faker.datatype.number({ min: undefined, max: undefined }),
132+
undefined,
133+
]),
134+
type: faker.helpers.arrayElement(['dog']),
135+
},
136+
{
137+
length: faker.datatype.number({ min: undefined, max: undefined }),
138+
breed: faker.helpers.arrayElement(['Dachshund']),
139+
barksPerMinute: faker.helpers.arrayElement([
140+
faker.datatype.number({ min: undefined, max: undefined }),
141+
undefined,
142+
]),
143+
type: faker.helpers.arrayElement(['dog']),
144+
'@id': faker.helpers.arrayElement([faker.random.word(), undefined]),
145+
id: faker.datatype.number({ min: undefined, max: undefined }),
146+
name: (() => faker.name.lastName())(),
147+
tag: (() => faker.name.lastName())(),
148+
email: faker.helpers.arrayElement([faker.internet.email(), undefined]),
149+
callingCode: faker.helpers.arrayElement([
150+
faker.helpers.arrayElement(['+33', '+420', '+33']),
151+
undefined,
152+
]),
153+
country: faker.helpers.arrayElement([
154+
faker.helpers.arrayElement(["People's Republic of China", 'Uruguay']),
155+
undefined,
156+
]),
157+
},
158+
{
159+
petsRequested: faker.helpers.arrayElement([
160+
faker.datatype.number({ min: undefined, max: undefined }),
161+
undefined,
162+
]),
163+
type: faker.helpers.arrayElement(['cat']),
164+
'@id': faker.helpers.arrayElement([faker.random.word(), undefined]),
165+
id: faker.datatype.number({ min: undefined, max: undefined }),
166+
name: (() => faker.name.lastName())(),
167+
tag: (() => faker.name.lastName())(),
168+
email: faker.helpers.arrayElement([faker.internet.email(), undefined]),
169+
callingCode: faker.helpers.arrayElement([
170+
faker.helpers.arrayElement(['+33', '+420', '+33']),
171+
undefined,
172+
]),
173+
country: faker.helpers.arrayElement([
174+
faker.helpers.arrayElement(["People's Republic of China", 'Uruguay']),
175+
undefined,
176+
]),
177+
},
178+
]);
179+
125180
export const getShowPetByIdMock = () =>
126181
(() => ({
127182
id: faker.datatype.number({ min: 1, max: 99 }),
@@ -144,6 +199,13 @@ export const getSwaggerPetstoreMSW = () => [
144199
ctx.json(getCreatePetsMock()),
145200
);
146201
}),
202+
rest.put('*/v:version/pets', (_req, res, ctx) => {
203+
return res(
204+
ctx.delay(1000),
205+
ctx.status(200, 'Mocked status'),
206+
ctx.json(getUpdatePetsMock()),
207+
);
208+
}),
147209
rest.get('*/v:version/pets/:petId', (_req, res, ctx) => {
148210
return res(
149211
ctx.delay(1000),

‎samples/react-query/basic/src/api/endpoints/petstoreFromFileSpecWithTransformer.ts

+49
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
} from '@tanstack/react-query';
1818
import type {
1919
Pets,
20+
NonReadonly,
2021
Error,
2122
ListPetsParams,
2223
Pet,
@@ -181,6 +182,54 @@ export const useCreatePets = <
181182
>(mutationFn, mutationOptions);
182183
};
183184

185+
/**
186+
* @summary Update a pet
187+
*/
188+
export const updatePets = (pet: NonReadonly<Pet>, version = 1) => {
189+
return customInstance<Pet>({
190+
url: `/v${version}/pets`,
191+
method: 'put',
192+
headers: { 'Content-Type': 'application/json' },
193+
data: pet,
194+
});
195+
};
196+
197+
export type UpdatePetsMutationResult = NonNullable<
198+
Awaited<ReturnType<typeof updatePets>>
199+
>;
200+
export type UpdatePetsMutationBody = NonReadonly<Pet>;
201+
export type UpdatePetsMutationError = ErrorType<Error>;
202+
203+
export const useUpdatePets = <
204+
TError = ErrorType<Error>,
205+
TContext = unknown,
206+
>(options?: {
207+
mutation?: UseMutationOptions<
208+
Awaited<ReturnType<typeof updatePets>>,
209+
TError,
210+
{ data: NonReadonly<Pet>; version?: number },
211+
TContext
212+
>;
213+
}) => {
214+
const { mutation: mutationOptions } = options ?? {};
215+
216+
const mutationFn: MutationFunction<
217+
Awaited<ReturnType<typeof updatePets>>,
218+
{ data: NonReadonly<Pet>; version?: number }
219+
> = (props) => {
220+
const { data, version } = props ?? {};
221+
222+
return updatePets(data, version);
223+
};
224+
225+
return useMutation<
226+
Awaited<ReturnType<typeof updatePets>>,
227+
TError,
228+
{ data: NonReadonly<Pet>; version?: number },
229+
TContext
230+
>(mutationFn, mutationOptions);
231+
};
232+
184233
/**
185234
* @summary Info for a specific pet
186235
*/

‎samples/react-query/basic/src/api/model/cat.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
import type { CatType } from './catType';
88

99
export interface Cat {
10-
petsRequested?: number;
10+
readonly petsRequested?: number;
1111
type: CatType;
1212
}

‎samples/react-query/basic/src/api/model/index.ts

+38
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
/**
2+
* Generated by orval v6.13.1 🍺
3+
* Do not edit manually.
4+
* Swagger Petstore
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
18
export * from './cat';
29
export * from './catType';
310
export * from './createPetsBody';
@@ -13,3 +20,34 @@ export * from './pet';
1320
export * from './petCallingCode';
1421
export * from './petCountry';
1522
export * from './pets';
23+
24+
// https://stackoverflow.com/questions/49579094/typescript-conditional-types-filter-out-readonly-properties-pick-only-requir/49579497#49579497
25+
type IfEquals<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <
26+
T,
27+
>() => T extends Y ? 1 : 2
28+
? A
29+
: B;
30+
31+
type WritableKeys<T> = {
32+
[P in keyof T]-?: IfEquals<
33+
{ [Q in P]: T[P] },
34+
{ -readonly [Q in P]: T[P] },
35+
P
36+
>;
37+
}[keyof T];
38+
39+
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
40+
k: infer I,
41+
) => void
42+
? I
43+
: never;
44+
type DistributeReadOnlyOverUnions<T> = T extends any ? NonReadonly<T> : never;
45+
46+
type Writable<T> = Pick<T, WritableKeys<T>>;
47+
export type NonReadonly<T> = [T] extends [UnionToIntersection<T>]
48+
? {
49+
[P in keyof Writable<T>]: T[P] extends object
50+
? NonReadonly<NonNullable<T[P]>>
51+
: T[P];
52+
}
53+
: DistributeReadOnlyOverUnions<T>;

‎tests/configs/default.config.ts

+7
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,11 @@ export default defineConfig({
5757
target: '../generated/default/null-type/endpoints.ts',
5858
},
5959
},
60+
readonly: {
61+
input: '../specifications/readonly.yaml',
62+
output: {
63+
schemas: '../generated/default/readonly/model',
64+
target: '../generated/default/readonly/endpoints.ts',
65+
},
66+
},
6067
});

‎tests/specifications/readonly.yaml

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
openapi: 3.1.0
2+
info:
3+
title: Readonly Properties
4+
description: 'Readonly properties'
5+
version: 1.0.0
6+
tags:
7+
- name: readonly
8+
description: Readonly properties
9+
servers:
10+
- url: http://localhost
11+
paths:
12+
/without-readonly:
13+
post:
14+
tags:
15+
- readonly
16+
summary: Create readonly object
17+
operationId: createReadonlyFreeObject
18+
requestBody:
19+
content:
20+
application/json:
21+
schema:
22+
type: object
23+
properties:
24+
nonReadonlyProperty:
25+
type: string
26+
name:
27+
type: string
28+
responses:
29+
200:
30+
description: Successful Operation
31+
content:
32+
application/json:
33+
schema:
34+
$ref: '#/components/schemas/ReadonlyObject'
35+
/readonly-ref:
36+
post:
37+
tags:
38+
- readonly
39+
summary: Create readonly object
40+
operationId: createReadonly
41+
requestBody:
42+
content:
43+
application/json:
44+
schema:
45+
$ref: '#/components/schemas/ReadonlyObject'
46+
responses:
47+
200:
48+
description: Successful Operation
49+
content:
50+
application/json:
51+
schema:
52+
$ref: '#/components/schemas/ReadonlyObject'
53+
/readonly-direct:
54+
put:
55+
tags:
56+
- readonly
57+
summary: Update object response
58+
operationId: updateReadonly
59+
requestBody:
60+
content:
61+
application/json:
62+
schema:
63+
type: object
64+
properties:
65+
readonlyProperty:
66+
type: string
67+
readOnly: true
68+
name:
69+
type: string
70+
responses:
71+
200:
72+
description: Successful Operation
73+
content:
74+
application/json:
75+
schema:
76+
$ref: '#/components/schemas/ReadonlyObject'
77+
components:
78+
schemas:
79+
ReadonlyObject:
80+
type: object
81+
properties:
82+
readonlyProperty:
83+
type: string
84+
readOnly: true
85+
name:
86+
type: string

1 commit comments

Comments
 (1)

vercel[bot] commented on Apr 11, 2023

@vercel[bot]
Please sign in to comment.