Skip to content

Commit 02bb817

Browse files
committed
refactor: Migrate users.create endpoint to OpenAPI with AJV validation
1 parent a834e17 commit 02bb817

5 files changed

Lines changed: 116 additions & 109 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@rocket.chat/meteor": minor
3+
"@rocket.chat/rest-typings": minor
4+
---
5+
6+
Add OpenAPI support for the `users.create` API endpoint by migrating to chained route definition with AJV body and response validation.

apps/meteor/app/api/server/v1/users.ts

Lines changed: 108 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { MeteorError, Team, api, Calendar } from '@rocket.chat/core-services';
2-
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings';
2+
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, IUserSettings, UserStatus } from '@rocket.chat/core-typings';
33
import { Users, Subscriptions, Sessions } from '@rocket.chat/models';
44
import {
5-
isUserCreateParamsPOST,
65
isUserSetActiveStatusParamsPOST,
76
isUserDeactivateIdleParamsPOST,
87
isUsersInfoParamsGetProps,
@@ -300,51 +299,6 @@ API.v1.addRoute(
300299
},
301300
);
302301

303-
API.v1.addRoute(
304-
'users.create',
305-
{ authRequired: true, validateParams: isUserCreateParamsPOST },
306-
{
307-
async post() {
308-
// New change made by pull request #5152
309-
if (typeof this.bodyParams.joinDefaultChannels === 'undefined') {
310-
this.bodyParams.joinDefaultChannels = true;
311-
}
312-
313-
if (this.bodyParams.name && !validateNameChars(this.bodyParams.name)) {
314-
return API.v1.failure('Name contains invalid characters');
315-
}
316-
317-
if (this.bodyParams.customFields) {
318-
validateCustomFields(this.bodyParams.customFields);
319-
}
320-
321-
if (this.bodyParams.freeSwitchExtension && !(await canEditExtension(this.bodyParams.freeSwitchExtension))) {
322-
return API.v1.failure('Setting user voice call extension is not allowed', 'error-action-not-allowed');
323-
}
324-
325-
const newUserId = await saveUser(this.userId, this.bodyParams);
326-
const userId = typeof newUserId !== 'string' ? this.userId : newUserId;
327-
328-
if (this.bodyParams.customFields) {
329-
await saveCustomFieldsWithoutValidation(userId, this.bodyParams.customFields);
330-
}
331-
332-
if (typeof this.bodyParams.active !== 'undefined') {
333-
await executeSetUserActiveStatus(this.userId, userId, this.bodyParams.active);
334-
}
335-
336-
const { fields } = await this.parseJsonQuery();
337-
338-
const user = await Users.findOneById(userId, { projection: fields });
339-
if (!user) {
340-
return API.v1.failure('User not found');
341-
}
342-
343-
return API.v1.success({ user });
344-
},
345-
},
346-
);
347-
348302
API.v1.addRoute(
349303
'users.delete',
350304
{ authRequired: true, permissionsRequired: ['delete-user'] },
@@ -753,6 +707,55 @@ API.v1.addRoute(
753707
},
754708
);
755709

710+
type UserCreateParamsPOST = {
711+
email: string;
712+
name: string;
713+
password: string;
714+
username: string;
715+
active?: boolean;
716+
bio?: string;
717+
nickname?: string;
718+
statusText?: string;
719+
roles?: string[];
720+
joinDefaultChannels?: boolean;
721+
requirePasswordChange?: boolean;
722+
setRandomPassword?: boolean;
723+
sendWelcomeEmail?: boolean;
724+
verified?: boolean;
725+
customFields?: Record<string, any>;
726+
settings?: IUserSettings;
727+
freeSwitchExtension?: string;
728+
/* @deprecated */
729+
fields: string;
730+
};
731+
732+
const userCreateParamsPostSchema = {
733+
type: 'object',
734+
properties: {
735+
email: { type: 'string' },
736+
name: { type: 'string' },
737+
password: { type: 'string' },
738+
username: { type: 'string' },
739+
active: { type: 'boolean', nullable: true },
740+
bio: { type: 'string', nullable: true },
741+
nickname: { type: 'string', nullable: true },
742+
statusText: { type: 'string', nullable: true },
743+
roles: { type: 'array', items: { type: 'string' } },
744+
joinDefaultChannels: { type: 'boolean', nullable: true },
745+
requirePasswordChange: { type: 'boolean', nullable: true },
746+
setRandomPassword: { type: 'boolean', nullable: true },
747+
sendWelcomeEmail: { type: 'boolean', nullable: true },
748+
verified: { type: 'boolean', nullable: true },
749+
customFields: { type: 'object' },
750+
fields: { type: 'string', nullable: true },
751+
freeSwitchExtension: { type: 'string', nullable: true },
752+
},
753+
additionalProperties: false,
754+
required: ['email', 'name', 'password', 'username'],
755+
};
756+
757+
const isUserCreateParamsPOST = ajv.compile<UserCreateParamsPOST>(userCreateParamsPostSchema);
758+
756759
const usersEndpoints = API.v1
757760
.post(
758761
'users.createToken',
@@ -878,6 +881,64 @@ const usersEndpoints = API.v1
878881

879882
return API.v1.success({ suggestions });
880883
},
884+
)
885+
.post(
886+
'users.create',
887+
{
888+
authRequired: true,
889+
body: isUserCreateParamsPOST,
890+
response: {
891+
200: ajv.compile<{ user: IUser }>({
892+
type: 'object',
893+
properties: {
894+
user: { $ref: '#/components/schemas/IUser' },
895+
success: { type: 'boolean', enum: [true] },
896+
},
897+
required: ['user', 'success'],
898+
additionalProperties: false,
899+
}),
900+
400: validateBadRequestErrorResponse,
901+
401: validateUnauthorizedErrorResponse,
902+
},
903+
},
904+
async function action() {
905+
// New change made by pull request #5152
906+
if (typeof this.bodyParams.joinDefaultChannels === 'undefined') {
907+
this.bodyParams.joinDefaultChannels = true;
908+
}
909+
910+
if (this.bodyParams.name && !validateNameChars(this.bodyParams.name)) {
911+
return API.v1.failure('Name contains invalid characters');
912+
}
913+
914+
if (this.bodyParams.customFields) {
915+
validateCustomFields(this.bodyParams.customFields);
916+
}
917+
918+
if (this.bodyParams.freeSwitchExtension && !(await canEditExtension(this.bodyParams.freeSwitchExtension))) {
919+
return API.v1.failure('Setting user voice call extension is not allowed', 'error-action-not-allowed');
920+
}
921+
922+
const newUserId = await saveUser(this.userId, this.bodyParams);
923+
const userId = typeof newUserId !== 'string' ? this.userId : newUserId;
924+
925+
if (this.bodyParams.customFields) {
926+
await saveCustomFieldsWithoutValidation(userId, this.bodyParams.customFields);
927+
}
928+
929+
if (typeof this.bodyParams.active !== 'undefined') {
930+
await executeSetUserActiveStatus(this.userId, userId, this.bodyParams.active);
931+
}
932+
933+
const { fields } = await this.parseJsonQuery();
934+
935+
const user = await Users.findOneById(userId, { projection: fields });
936+
if (!user) {
937+
return API.v1.failure('User not found');
938+
}
939+
940+
return API.v1.success({ user });
941+
},
881942
);
882943

883944
API.v1.addRoute(

apps/meteor/client/views/admin/users/AdminUserForm.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
} from '@rocket.chat/fuselage';
1919
import type { SelectOption } from '@rocket.chat/fuselage';
2020
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
21-
import type { UserCreateParamsPOST } from '@rocket.chat/rest-typings';
21+
import type { OperationParams } from '@rocket.chat/rest-typings';
2222
import { validateEmail } from '@rocket.chat/tools';
2323
import { CustomFieldsForm, ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client';
2424
import {
@@ -55,7 +55,7 @@ type AdminUserFormProps = {
5555
};
5656

5757
export type UserFormProps = Omit<
58-
UserCreateParamsPOST & { avatar: AvatarObject; passwordConfirmation: string; freeSwitchExtension?: string },
58+
OperationParams<'POST', '/v1/users.create'> & { avatar: AvatarObject; passwordConfirmation: string; freeSwitchExtension?: string },
5959
'fields'
6060
>;
6161

packages/rest-typings/src/v1/users.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { IExportOperation, ISubscription, ITeam, IUser, IPersonalAccessToke
33
import { ajv } from './Ajv';
44
import type { PaginatedRequest } from '../helpers/PaginatedRequest';
55
import type { PaginatedResult } from '../helpers/PaginatedResult';
6-
import type { UserCreateParamsPOST } from './users/UserCreateParamsPOST';
76
import type { UserDeactivateIdleParamsPOST } from './users/UserDeactivateIdleParamsPOST';
87
import type { UserLogoutParamsPOST } from './users/UserLogoutParamsPOST';
98
import type { UserRegisterParamsPOST } from './users/UserRegisterParamsPOST';
@@ -271,12 +270,6 @@ export type UsersEndpoints = {
271270
};
272271
};
273272

274-
'/v1/users.create': {
275-
POST: (params: UserCreateParamsPOST) => {
276-
user: IUser;
277-
};
278-
};
279-
280273
'/v1/users.update': {
281274
POST: (params: UsersUpdateParamsPOST) => {
282275
user: IUser;
@@ -370,7 +363,6 @@ export type UsersEndpoints = {
370363
};
371364
};
372365

373-
export * from './users/UserCreateParamsPOST';
374366
export * from './users/UserSetActiveStatusParamsPOST';
375367
export * from './users/UserDeactivateIdleParamsPOST';
376368
export * from './users/UsersInfoParamsGet';

packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts

Lines changed: 0 additions & 52 deletions
This file was deleted.

0 commit comments

Comments
 (0)