Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/users-info-openapi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use minor instead of patch for this project

---

chore: migrate users.info endpoint to new OpenAPI pattern with AJV validation
210 changes: 157 additions & 53 deletions apps/meteor/app/api/server/v1/users.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { MeteorError, Team, api, Calendar } from '@rocket.chat/core-services';
import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings';
import type { IExportOperation, ILoginToken, ISubscription, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings';
import { Users, Subscriptions, Sessions } from '@rocket.chat/models';
import {
isUserCreateParamsPOST,
isUserSetActiveStatusParamsPOST,
isUserDeactivateIdleParamsPOST,
isUsersInfoParamsGetProps,
isUsersListStatusProps,
isUsersSendWelcomeEmailProps,
isUserRegisterParamsPOST,
Expand Down Expand Up @@ -426,55 +425,6 @@ API.v1.addRoute(
},
);

API.v1.addRoute(
'users.info',
{ authRequired: true, validateParams: isUsersInfoParamsGetProps },
{
async get() {
const searchTerms: [string, 'id' | 'username' | 'importId'] | false =
('userId' in this.queryParams && !!this.queryParams.userId && [this.queryParams.userId, 'id']) ||
('username' in this.queryParams && !!this.queryParams.username && [this.queryParams.username, 'username']) ||
('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']);

if (!searchTerms) {
return API.v1.failure('Invalid search query.');
}

const user = await getFullUserDataByIdOrUsernameOrImportId(this.userId, ...searchTerms);

if (!user) {
return API.v1.failure('User not found.');
}
const myself = user._id === this.userId;
if (this.queryParams.includeUserRooms === 'true' && (myself || (await hasPermissionAsync(this.userId, 'view-other-user-channels')))) {
return API.v1.success({
user: {
...user,
rooms: await Subscriptions.findByUserId(user._id, {
projection: {
rid: 1,
name: 1,
t: 1,
roles: 1,
unread: 1,
federated: 1,
},
sort: {
t: 1,
name: 1,
},
}).toArray(),
},
});
}

return API.v1.success({
user,
});
},
},
);

API.v1.addRoute(
'users.list',
{
Expand Down Expand Up @@ -752,6 +702,69 @@ API.v1.addRoute(
},
);

type UsersInfoParamsGet = ({ userId: string } | { username: string } | { importId: string }) & {
fields?: string;
includeUserRooms?: string;
};

const UsersInfoParamsGetSchema = {
anyOf: [
{
type: 'object',
properties: {
userId: {
type: 'string',
},
includeUserRooms: {
type: 'string',
},
fields: {
type: 'string',
nullable: true,
},
},
required: ['userId'],
additionalProperties: false,
},
{
type: 'object',
properties: {
username: {
type: 'string',
},
includeUserRooms: {
type: 'string',
},
fields: {
type: 'string',
nullable: true,
},
},
required: ['username'],
additionalProperties: false,
},
{
type: 'object',
properties: {
importId: {
type: 'string',
},
includeUserRooms: {
type: 'string',
},
fields: {
type: 'string',
nullable: true,
},
},
required: ['importId'],
additionalProperties: false,
},
],
};

const isUsersInfoParamsGetProps = ajv.compile<UsersInfoParamsGet>(UsersInfoParamsGetSchema);

const usersEndpoints = API.v1
.post(
'users.createToken',
Expand Down Expand Up @@ -877,7 +890,99 @@ const usersEndpoints = API.v1

return API.v1.success({ suggestions });
},
);
)
.get(
'users.info',
{
authRequired: true,
query: isUsersInfoParamsGetProps,
response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,

200: ajv.compile<{
user: IUser & { rooms?: Pick<ISubscription, 'rid' | 'name' | 't' | 'roles' | 'unread'> & { federated?: boolean }[] };
success: true;
}>({
type: 'object',
properties: {
user: {
allOf: [
{ $ref: '#/components/schemas/IUser' },
{
type: 'object',
properties: {
rooms: {
type: 'array',
items: {
type: 'object',
properties: {
rid: { type: 'string' },
name: { type: 'string' },
t: { type: 'string' },
roles: { type: 'array', items: { type: 'string' } },
unread: { type: 'number' },
federated: { type: 'boolean' },
},
required: ['rid', 't', 'unread', 'name'],
additionalProperties: false,
},
},
},
additionalProperties: true,
},
],
},
success: { type: 'boolean', enum: [true] },
},
required: ['user', 'success'],
additionalProperties: false,
}),
Comment on lines +903 to +940
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to review your JSON schema because it doesn't match the TypeScript definition correctly.

The current TypeScript definition:

user: IUser & { 
  rooms?: (Pick<ISubscription, 'rid' | 'name' | 't' | 'roles' | 'unread'> & { federated?: boolean[] })[] 
}

The schema should reflect this structure strictly. Additionally, we must avoid using additionalProperties: true to ensure the schema remains strict and prevents the injection of unexpected data

},
},
async function action() {
const searchTerms: [string, 'id' | 'username' | 'importId'] | false =
('userId' in this.queryParams && !!this.queryParams.userId && [this.queryParams.userId, 'id']) ||
('username' in this.queryParams && !!this.queryParams.username && [this.queryParams.username, 'username']) ||
('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']);

if (!searchTerms) {
return API.v1.failure('Invalid search query.');
}

const user = await getFullUserDataByIdOrUsernameOrImportId(this.userId, ...searchTerms);

if (!user) {
return API.v1.failure('User not found.');
}
const myself = user._id === this.userId;
if (this.queryParams.includeUserRooms === 'true' && (myself || (await hasPermissionAsync(this.userId, 'view-other-user-channels')))) {
return API.v1.success({
user: {
...user,
rooms: await Subscriptions.findByUserId(user._id, {
projection: {
rid: 1,
name: 1,
t: 1,
roles: 1,
unread: 1,
federated: 1,
},
sort: {
t: 1,
name: 1,
},
}).toArray(),
},
});
}

return API.v1.success({
user,
});
},
);

API.v1.addRoute(
'users.getPreferences',
Expand Down Expand Up @@ -1555,7 +1660,6 @@ settings.watch<number>('Rate_Limiter_Limit_RegisterUser', (value) => {
});

type UsersEndpoints = ExtractRoutesFromAPI<typeof usersEndpoints>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends UsersEndpoints {}
Expand Down
3 changes: 3 additions & 0 deletions packages/core-typings/src/Ajv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import type { ISubscription } from './ISubscription';
import type { SlashCommand } from './SlashCommands';
import type { IMediaCall } from './mediaCalls/IMediaCall';

import type { IUser } from './IUser';

export const schemas = typia.json.schemas<
[
ISubscription | IInvite | ICustomSound | IMessage | IOAuthApps | IPermission | IMediaCall,
CallHistoryItem,
ICustomUserStatus,
SlashCommand,
IUser,
],
'3.0'
>();
34 changes: 9 additions & 25 deletions packages/rest-typings/src/v1/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type { UserLogoutParamsPOST } from './users/UserLogoutParamsPOST';
import type { UserRegisterParamsPOST } from './users/UserRegisterParamsPOST';
import type { UserSetActiveStatusParamsPOST } from './users/UserSetActiveStatusParamsPOST';
import type { UsersAutocompleteParamsGET } from './users/UsersAutocompleteParamsGET';
import type { UsersInfoParamsGet } from './users/UsersInfoParamsGet';
import type { UsersListStatusParamsGET } from './users/UsersListStatusParamsGET';
import type { UsersListTeamsParamsGET } from './users/UsersListTeamsParamsGET';
import type { UsersSendConfirmationEmailParamsPOST } from './users/UsersSendConfirmationEmailParamsPOST';
Expand All @@ -18,26 +17,11 @@ import type { UsersSetPreferencesParamsPOST } from './users/UsersSetPreferencePa
import type { UsersUpdateOwnBasicInfoParamsPOST } from './users/UsersUpdateOwnBasicInfoParamsPOST';
import type { UsersUpdateParamsPOST } from './users/UsersUpdateParamsPOST';

type UsersInfo = { userId?: IUser['_id']; username?: IUser['username'] };

const UsersInfoSchema = {
type: 'object',
properties: {
userId: {
type: 'string',
nullable: true,
},
username: {
type: 'string',
nullable: true,
},
},
required: [],
additionalProperties: false,
type UsersInfoParamsGet = ({ userId: string } | { username: string } | { importId: string }) & {
fields?: string;
includeUserRooms?: string;
};

export const isUsersInfoProps = ajv.compile<UsersInfo>(UsersInfoSchema);

type Users2faSendEmailCode = { emailOrUsername: string };

const Users2faSendEmailCodeSchema = {
Expand Down Expand Up @@ -271,6 +255,12 @@ export type UsersEndpoints = {
};
};

'/v1/users.info': {
GET: (params: UsersInfoParamsGet) => {
user: IUser & { rooms?: Pick<ISubscription, 'rid' | 'name' | 't' | 'roles' | 'unread'>[] };
};
};

'/v1/users.create': {
POST: (params: UserCreateParamsPOST) => {
user: IUser;
Expand Down Expand Up @@ -327,11 +317,6 @@ export type UsersEndpoints = {
};
};

'/v1/users.info': {
GET: (params: UsersInfoParamsGet) => {
user: IUser & { rooms?: Pick<ISubscription, 'rid' | 'name' | 't' | 'roles' | 'unread'>[] };
};
};
Comment on lines -330 to -334
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you should return this code should solve the problem you're having with the legacy SDK.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I return this block of code, UserInfoParamsGet throws an error because I deleted UsersInfoParamsGet.ts during cleanup. What’s the correct approach here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the PR #38913 that I previously mentioned to you

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I return this block of code, UserInfoParamsGet throws an error because I deleted UsersInfoParamsGet.ts during cleanup. What’s the correct approach here?

you should also return the UserInfoParamsGet to solve the error


'/v1/users.register': {
POST: (params: UserRegisterParamsPOST) => {
Expand Down Expand Up @@ -373,7 +358,6 @@ export type UsersEndpoints = {
export * from './users/UserCreateParamsPOST';
export * from './users/UserSetActiveStatusParamsPOST';
export * from './users/UserDeactivateIdleParamsPOST';
export * from './users/UsersInfoParamsGet';
export * from './users/UsersListStatusParamsGET';
export * from './users/UsersSendWelcomeEmailParamsPOST';
export * from './users/UserRegisterParamsPOST';
Expand Down
Loading