-
Notifications
You must be signed in to change notification settings - Fork 13.5k
chore: migrate users.setStatus endpoint to new OpenAPI pattern with AJV validation #39485
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1cb495e
21285f3
681512a
b9c6a9c
0a3f712
1c82df8
d18f875
f45b12e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "@rocket.chat/meteor": patch | ||
| "@rocket.chat/rest-typings": patch | ||
| --- | ||
|
|
||
| Migrates the `users.setStatus` REST API endpoint from the legacy `API.v1.addRoute` pattern to the new chained `API.v1.post()` pattern with AJV request body and response schema validation and OpenAPI documentation support. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ import { | |
| ajv, | ||
| validateBadRequestErrorResponse, | ||
| validateUnauthorizedErrorResponse, | ||
| validateForbiddenErrorResponse | ||
| } from '@rocket.chat/rest-typings'; | ||
| import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools'; | ||
| import { Accounts } from 'meteor/accounts-base'; | ||
|
|
@@ -878,6 +879,112 @@ const usersEndpoints = API.v1 | |
|
|
||
| return API.v1.success({ suggestions }); | ||
| }, | ||
| ) | ||
| .post( | ||
| 'users.setStatus', | ||
| { | ||
| authRequired: true, | ||
| rateLimiterOptions: { | ||
| numRequestsAllowed: 5, | ||
| intervalTimeInMS: 60000, | ||
| }, | ||
| body: ajv.compile<{ | ||
| status?: string; | ||
| message?: string; | ||
| userId?: string; | ||
| username?: string; | ||
| user?: string; | ||
| }>({ | ||
| type: 'object', | ||
| properties: { | ||
| status: { type: 'string', minLength: 1 }, | ||
| message: { type: 'string' }, | ||
| userId: { type: 'string' }, | ||
| username: { type: 'string' }, | ||
| user: { type: 'string' }, | ||
| }, | ||
| anyOf: [ | ||
| { required: ['message'] }, | ||
| { required: ['status'] }, | ||
| ], | ||
| additionalProperties: false, | ||
| }), | ||
| response: { | ||
| 400: validateBadRequestErrorResponse, | ||
| 401: validateUnauthorizedErrorResponse, | ||
| 403: validateForbiddenErrorResponse, | ||
| 200: ajv.compile<{ success: boolean }>({ | ||
| type: 'object', | ||
| properties: { | ||
| success: { type: 'boolean', enum: [true] }, | ||
| }, | ||
| required: ['success'], | ||
| additionalProperties: false, | ||
| }), | ||
| }, | ||
|
Comment on lines
+887
to
+924
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add the 429 response schema to this contract. This route is rate-limited, and 🤖 Prompt for AI Agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainLet me check how the existing validators are defined and whether there's already an inline 429 pattern used elsewhere in the codebase. 🏁 Script executed: Length of output: 69067
Two options to keep the OpenAPI contract complete without blocking this PR: Option A – Inline schema (unblocks this PR immediately) Add the 429 entry directly in the response map using response: {
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
429: ajv.compile<{ success: false; error: string }>({
type: 'object',
properties: {
success: { type: 'boolean', enum: [false] },
error: { type: 'string' },
},
required: ['success', 'error'],
additionalProperties: false,
}),
200: ajv.compile<{ success: boolean }>({ ... }),
},This can later be replaced with the shared Option B – Follow-up issue Defer the 429 schema entirely and track it as a follow-up. Would you like me to open a GitHub issue to add 🧠 Learnings used
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for confirming. Since there is currently no shared 429 validator in
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🧠 Learnings used |
||
| }, | ||
| async function action() { | ||
| if (!settings.get('Accounts_AllowUserStatusMessageChange')) { | ||
| return API.v1.failure('Change status is not allowed [error-not-allowed]', 'error-not-allowed', 400); | ||
| } | ||
|
|
||
| const user = await (async () => { | ||
| if (isUserFromParams(this.bodyParams, this.userId, this.user)) { | ||
| return Users.findOneById(this.userId); | ||
| } | ||
| if (await hasPermissionAsync(this.userId, 'edit-other-user-info')) { | ||
| return getUserFromParams(this.bodyParams); | ||
| } | ||
| })(); | ||
|
|
||
| if (!user) { | ||
| return API.v1.forbidden(); | ||
| } | ||
|
|
||
| const { _id, username, roles, name } = user; | ||
| let { statusText, status } = user; | ||
|
|
||
| if (this.bodyParams.message || this.bodyParams.message === '') { | ||
| await setStatusText(user, this.bodyParams.message, { emit: false }); | ||
| statusText = this.bodyParams.message; | ||
| } | ||
|
|
||
| if ('status' in this.bodyParams) { | ||
| if (!this.bodyParams.status) { | ||
| throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { | ||
| method: 'users.setStatus', | ||
| }); | ||
| } | ||
| const validStatus = ['online', 'away', 'offline', 'busy']; | ||
| if (validStatus.includes(this.bodyParams.status)) { | ||
| status = this.bodyParams.status as UserStatus; | ||
|
|
||
| if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) { | ||
| throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', { | ||
| method: 'users.setStatus', | ||
| }); | ||
| } | ||
|
|
||
| await Users.updateOne( | ||
| { _id: user._id }, | ||
| { $set: { status, statusDefault: status } }, | ||
| ); | ||
|
|
||
| void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress(); | ||
| } else { | ||
| throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { | ||
| method: 'users.setStatus', | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| void api.broadcast('presence.status', { | ||
| user: { status, _id, username, statusText, roles, name }, | ||
| previousStatus: user.status, | ||
| }); | ||
|
|
||
| return API.v1.success(); | ||
| }, | ||
| ); | ||
|
|
||
| API.v1.addRoute( | ||
|
|
@@ -1426,97 +1533,6 @@ API.v1.addRoute( | |
| }, | ||
| ); | ||
|
|
||
| API.v1.addRoute( | ||
| 'users.setStatus', | ||
| { | ||
| authRequired: true, | ||
| rateLimiterOptions: { | ||
| numRequestsAllowed: 5, | ||
| intervalTimeInMS: 60000, | ||
| }, | ||
| }, | ||
| { | ||
| async post() { | ||
| check( | ||
| this.bodyParams, | ||
| Match.OneOf( | ||
| Match.ObjectIncluding({ | ||
| status: Match.Maybe(String), | ||
| message: String, | ||
| }), | ||
| Match.ObjectIncluding({ | ||
| status: String, | ||
| message: Match.Maybe(String), | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| if (!settings.get('Accounts_AllowUserStatusMessageChange')) { | ||
| throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', { | ||
| method: 'users.setStatus', | ||
| }); | ||
| } | ||
|
|
||
| const user = await (async () => { | ||
| if (isUserFromParams(this.bodyParams, this.userId, this.user)) { | ||
| return Users.findOneById(this.userId); | ||
| } | ||
| if (await hasPermissionAsync(this.userId, 'edit-other-user-info')) { | ||
| return getUserFromParams(this.bodyParams); | ||
| } | ||
| })(); | ||
|
|
||
| if (!user) { | ||
| return API.v1.forbidden(); | ||
| } | ||
|
|
||
| const { _id, username, roles, name } = user; | ||
| let { statusText, status } = user; | ||
|
|
||
| if (this.bodyParams.message || this.bodyParams.message === '') { | ||
| await setStatusText(user, this.bodyParams.message, { emit: false }); | ||
| statusText = this.bodyParams.message; | ||
| } | ||
|
|
||
| if (this.bodyParams.status) { | ||
| const validStatus = ['online', 'away', 'offline', 'busy']; | ||
| if (validStatus.includes(this.bodyParams.status)) { | ||
| status = this.bodyParams.status; | ||
|
|
||
| if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) { | ||
| throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', { | ||
| method: 'users.setStatus', | ||
| }); | ||
| } | ||
|
|
||
| await Users.updateOne( | ||
| { _id: user._id }, | ||
| { | ||
| $set: { | ||
| status, | ||
| statusDefault: status, | ||
| }, | ||
| }, | ||
| ); | ||
|
|
||
| void wrapExceptions(() => Calendar.cancelUpcomingStatusChanges(user._id)).suppress(); | ||
| } else { | ||
| throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { | ||
| method: 'users.setStatus', | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| void api.broadcast('presence.status', { | ||
| user: { status, _id, username, statusText, roles, name }, | ||
| previousStatus: user.status, | ||
| }); | ||
|
|
||
| return API.v1.success(); | ||
| }, | ||
| }, | ||
| ); | ||
|
|
||
| // status: 'online' | 'offline' | 'away' | 'busy'; | ||
| // message?: string; | ||
| // _id: string; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.