-
Notifications
You must be signed in to change notification settings - Fork 13.5k
refactor(api): migrate push endpoints to chained API pattern #39625
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 1 commit
bc76410
97267f7
20aa60a
11be43e
9af3565
2f989a4
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 |
|---|---|---|
| @@ -1,107 +1,247 @@ | ||
| import type { IAppsTokens } from '@rocket.chat/core-typings'; | ||
| import { Messages, AppsTokens, Users, Rooms, Settings } from '@rocket.chat/models'; | ||
| import { Random } from '@rocket.chat/random'; | ||
| import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; | ||
| import { Match, check } from 'meteor/check'; | ||
| import { Push } from '@rocket.chat/core-services'; | ||
| import type { IPushToken } from '@rocket.chat/core-typings'; | ||
| import { Messages, PushToken, Users, Rooms, Settings } from '@rocket.chat/models'; | ||
| import { | ||
| ajv, | ||
| isPushGetProps, | ||
| validateNotFoundErrorResponse, | ||
| validateBadRequestErrorResponse, | ||
| validateUnauthorizedErrorResponse, | ||
| validateForbiddenErrorResponse, | ||
| } from '@rocket.chat/rest-typings'; | ||
| import type { JSONSchemaType } from 'ajv'; | ||
| import { Meteor } from 'meteor/meteor'; | ||
|
|
||
| import { executePushTest } from '../../../../server/lib/pushConfig'; | ||
| import { canAccessRoomAsync } from '../../../authorization/server/functions/canAccessRoom'; | ||
| import { pushUpdate } from '../../../push/server/methods'; | ||
| import PushNotification from '../../../push-notifications/server/lib/PushNotification'; | ||
| import { settings } from '../../../settings/server'; | ||
| import type { ExtractRoutesFromAPI } from '../ApiClass'; | ||
| import { API } from '../api'; | ||
| import type { SuccessResult } from '../definition'; | ||
|
|
||
| API.v1.addRoute( | ||
| 'push.token', | ||
| { authRequired: true }, | ||
| { | ||
| async post() { | ||
| const { id, type, value, appName } = this.bodyParams; | ||
| type PushTokenPOST = { | ||
| id?: string; | ||
| type: 'apn' | 'gcm'; | ||
| value: string; | ||
| appName: string; | ||
| }; | ||
|
|
||
| if (id && typeof id !== 'string') { | ||
| throw new Meteor.Error('error-id-param-not-valid', 'The required "id" body param is invalid.'); | ||
| } | ||
| const PushTokenPOSTSchema: JSONSchemaType<PushTokenPOST> = { | ||
| type: 'object', | ||
| properties: { | ||
| id: { | ||
| type: 'string', | ||
| nullable: true, | ||
| }, | ||
| type: { | ||
| type: 'string', | ||
| enum: ['apn', 'gcm'], | ||
| }, | ||
| value: { | ||
| type: 'string', | ||
| minLength: 1, | ||
| }, | ||
| appName: { | ||
| type: 'string', | ||
| minLength: 1, | ||
| }, | ||
| }, | ||
| required: ['type', 'value', 'appName'], | ||
| additionalProperties: false, | ||
| }; | ||
|
|
||
| const deviceId = id || Random.id(); | ||
| export const isPushTokenPOSTProps = ajv.compile<PushTokenPOST>(PushTokenPOSTSchema); | ||
|
|
||
| if (!type || (type !== 'apn' && type !== 'gcm')) { | ||
| throw new Meteor.Error('error-type-param-not-valid', 'The required "type" body param is missing or invalid.'); | ||
| } | ||
| type PushTokenDELETE = { | ||
| token: string; | ||
| }; | ||
|
|
||
| if (!value || typeof value !== 'string') { | ||
| throw new Meteor.Error('error-token-param-not-valid', 'The required "value" body param is missing or invalid.'); | ||
| } | ||
| const PushTokenDELETESchema: JSONSchemaType<PushTokenDELETE> = { | ||
| type: 'object', | ||
| properties: { | ||
| token: { | ||
| type: 'string', | ||
| minLength: 1, | ||
| }, | ||
| }, | ||
| required: ['token'], | ||
| additionalProperties: false, | ||
| }; | ||
|
|
||
| if (!appName || typeof appName !== 'string') { | ||
| throw new Meteor.Error('error-appName-param-not-valid', 'The required "appName" body param is missing or invalid.'); | ||
| } | ||
| export const isPushTokenDELETEProps = ajv.compile<PushTokenDELETE>(PushTokenDELETESchema); | ||
|
|
||
| const authToken = this.request.headers.get('x-auth-token'); | ||
| if (!authToken) { | ||
| type PushTokenResult = Pick<IPushToken, '_id' | 'token' | 'appName' | 'userId' | 'enabled' | 'createdAt' | '_updatedAt'>; | ||
|
|
||
| /** | ||
| * Pick only the attributes we actually want to return on the endpoint, ensuring nothing from older schemas get mixed in | ||
| */ | ||
| function cleanTokenResult(result: Omit<IPushToken, 'authToken'>): PushTokenResult { | ||
| const { _id, token, appName, userId, enabled, createdAt, _updatedAt } = result; | ||
|
|
||
| return { | ||
| _id, | ||
| token, | ||
| appName, | ||
| userId, | ||
| enabled, | ||
| createdAt, | ||
| _updatedAt, | ||
| }; | ||
| } | ||
|
|
||
| const pushEndpoints = API.v1 | ||
| .post( | ||
| 'push.token', | ||
| { | ||
| response: { | ||
| 200: ajv.compile<SuccessResult<{ result: PushTokenResult }>['body']>({ | ||
| additionalProperties: false, | ||
| type: 'object', | ||
| properties: { | ||
| success: { | ||
| type: 'boolean', | ||
| description: 'Indicates if the request was successful.', | ||
| }, | ||
| result: { | ||
| type: 'object', | ||
| description: 'The updated token data for this device', | ||
| properties: { | ||
| _id: { | ||
| type: 'string', | ||
| }, | ||
| token: { | ||
| type: 'object', | ||
| properties: { | ||
| apn: { | ||
| type: 'string', | ||
| }, | ||
| gcm: { | ||
| type: 'string', | ||
| }, | ||
| }, | ||
| required: [], | ||
| additionalProperties: false, | ||
| }, | ||
| appName: { | ||
| type: 'string', | ||
| }, | ||
| userId: { | ||
| type: 'string', | ||
| nullable: true, | ||
| }, | ||
| enabled: { | ||
| type: 'boolean', | ||
| }, | ||
| createdAt: { | ||
| type: 'string', | ||
| }, | ||
| _updatedAt: { | ||
| type: 'string', | ||
| }, | ||
| }, | ||
| additionalProperties: false, | ||
| }, | ||
| }, | ||
| required: ['success', 'result'], | ||
| }), | ||
| 400: validateBadRequestErrorResponse, | ||
| 401: validateUnauthorizedErrorResponse, | ||
| 403: validateForbiddenErrorResponse, | ||
| }, | ||
| body: isPushTokenPOSTProps, | ||
| authRequired: true, | ||
| }, | ||
| async function action() { | ||
| const { id, type, value, appName } = this.bodyParams; | ||
|
|
||
| const rawToken = this.request.headers.get('x-auth-token'); | ||
| if (!rawToken) { | ||
| throw new Meteor.Error('error-authToken-param-not-valid', 'The required "authToken" header param is missing or invalid.'); | ||
| } | ||
| const authToken = Accounts._hashLoginToken(rawToken); | ||
|
|
||
| const result = await pushUpdate({ | ||
| id: deviceId, | ||
| token: { [type]: value } as IAppsTokens['token'], | ||
| const result = await Push.registerPushToken({ | ||
| ...(id && { _id: id }), | ||
| token: { [type]: value } as IPushToken['token'], | ||
| authToken, | ||
| appName, | ||
| userId: this.userId, | ||
| }); | ||
|
|
||
| return API.v1.success({ result }); | ||
| return API.v1.success({ result: cleanTokenResult(result) }); | ||
| }, | ||
| ) | ||
| .delete( | ||
| 'push.token', | ||
| { | ||
| response: { | ||
| 200: ajv.compile<void>({ | ||
| additionalProperties: false, | ||
| type: 'object', | ||
| properties: { | ||
| success: { | ||
| type: 'boolean', | ||
| }, | ||
| }, | ||
| required: ['success'], | ||
| }), | ||
| 400: validateBadRequestErrorResponse, | ||
| 401: validateUnauthorizedErrorResponse, | ||
| 403: validateForbiddenErrorResponse, | ||
| 404: validateNotFoundErrorResponse, | ||
| }, | ||
| body: isPushTokenDELETEProps, | ||
| authRequired: true, | ||
| }, | ||
| async delete() { | ||
| async function action() { | ||
| const { token } = this.bodyParams; | ||
|
|
||
| if (!token || typeof token !== 'string') { | ||
| throw new Meteor.Error('error-token-param-not-valid', 'The required "token" body param is missing or invalid.'); | ||
| } | ||
|
|
||
| const affectedRecords = ( | ||
| await AppsTokens.deleteMany({ | ||
| $or: [ | ||
| { | ||
| 'token.apn': token, | ||
| }, | ||
| { | ||
| 'token.gcm': token, | ||
| }, | ||
| ], | ||
| userId: this.userId, | ||
| }) | ||
| ).deletedCount; | ||
| const removeResult = await PushToken.removeAllByTokenStringAndUserId(token, this.userId); | ||
|
|
||
| if (affectedRecords === 0) { | ||
| if (removeResult.deletedCount === 0) { | ||
| return API.v1.notFound(); | ||
| } | ||
|
|
||
| return API.v1.success(); | ||
| }, | ||
| }, | ||
| ); | ||
|
|
||
| API.v1.addRoute( | ||
| 'push.get', | ||
| { authRequired: true }, | ||
| { | ||
| async get() { | ||
| const params = this.queryParams; | ||
| check( | ||
| params, | ||
| Match.ObjectIncluding({ | ||
| id: String, | ||
| ) | ||
| .get( | ||
| 'push.get', | ||
| { | ||
| authRequired: true, | ||
| query: isPushGetProps, | ||
| response: { | ||
| 200: ajv.compile<{ data: { message: object; notification: object }; success: true }>({ | ||
| type: 'object', | ||
| properties: { | ||
| data: { | ||
| type: 'object', | ||
| properties: { | ||
| message: { type: 'object', additionalProperties: true }, | ||
| notification: { type: 'object', additionalProperties: true }, | ||
| }, | ||
| required: ['message', 'notification'], | ||
| additionalProperties: false, | ||
| }, | ||
| success: { type: 'boolean', enum: [true] }, | ||
| }, | ||
| required: ['data', 'success'], | ||
| additionalProperties: false, | ||
|
||
| }), | ||
| ); | ||
| 400: validateBadRequestErrorResponse, | ||
| 401: validateUnauthorizedErrorResponse, | ||
| }, | ||
| }, | ||
| async function action() { | ||
| const { id } = this.queryParams; | ||
|
|
||
| const receiver = await Users.findOneById(this.userId); | ||
| if (!receiver) { | ||
| throw new Error('error-user-not-found'); | ||
| } | ||
|
|
||
| const message = await Messages.findOneById(params.id); | ||
| const message = await Messages.findOneById(id); | ||
| if (!message) { | ||
| throw new Error('error-message-not-found'); | ||
| } | ||
|
|
@@ -119,25 +259,36 @@ API.v1.addRoute( | |
|
|
||
| return API.v1.success({ data }); | ||
| }, | ||
| }, | ||
| ); | ||
|
|
||
| API.v1.addRoute( | ||
| 'push.info', | ||
| { authRequired: true }, | ||
| { | ||
| async get() { | ||
| ) | ||
| .get( | ||
| 'push.info', | ||
| { | ||
| authRequired: true, | ||
| response: { | ||
| 200: ajv.compile<{ pushGatewayEnabled: boolean; defaultPushGateway: boolean; success: true }>({ | ||
| type: 'object', | ||
| properties: { | ||
| pushGatewayEnabled: { type: 'boolean' }, | ||
| defaultPushGateway: { type: 'boolean' }, | ||
| success: { type: 'boolean', enum: [true] }, | ||
| }, | ||
| required: ['pushGatewayEnabled', 'defaultPushGateway', 'success'], | ||
| additionalProperties: false, | ||
| }), | ||
| 401: validateUnauthorizedErrorResponse, | ||
| }, | ||
| }, | ||
| async function action() { | ||
| const defaultGateway = (await Settings.findOneById('Push_gateway', { projection: { packageValue: 1 } }))?.packageValue; | ||
| const defaultPushGateway = settings.get('Push_gateway') === defaultGateway; | ||
| return API.v1.success({ | ||
| pushGatewayEnabled: settings.get('Push_enable'), | ||
| pushGatewayEnabled: settings.get<boolean>('Push_enable'), | ||
| defaultPushGateway, | ||
| }); | ||
| }, | ||
| }, | ||
| ); | ||
| ); | ||
|
|
||
| const pushEndpoints = API.v1.post( | ||
| const pushTestEndpoints = API.v1.post( | ||
| 'push.test', | ||
| { | ||
| authRequired: true, | ||
|
|
@@ -177,9 +328,13 @@ const pushEndpoints = API.v1.post( | |
| }, | ||
| ); | ||
|
|
||
| export type PushEndpoints = ExtractRoutesFromAPI<typeof pushEndpoints>; | ||
| type PushTestEndpoints = ExtractRoutesFromAPI<typeof pushTestEndpoints>; | ||
|
|
||
| type PushTokenEndpoints = ExtractRoutesFromAPI<typeof pushEndpoints>; | ||
|
|
||
| type PushAllEndpoints = PushTestEndpoints & PushTokenEndpoints; | ||
|
|
||
| declare module '@rocket.chat/rest-typings' { | ||
| // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface | ||
| interface Endpoints extends PushEndpoints {} | ||
| interface Endpoints extends PushAllEndpoints {} | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This narrows the existing
push.tokenresponse contract.packages/rest-typings/src/v1/push.ts:52-55still exposesPOST /v1/push.tokenas{ result: IPushToken }, but this handler now validates and returns onlyPushTokenResult(droppingauthTokenand any other fields outside that pick). That makes the migration behavior-breaking for callers compiled against the old shape. Please either preserve the legacy payload here or update the shared push typings and consumers in the same PR.Based on learnings, Rocket.Chat OpenAPI migration PRs under
apps/meteor/app/api/server/v1should avoid introducing logic changes and keep behavior intact.Also applies to: 98-147, 172-172
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This behavior is pre-existing and not introduced by this migration. The upstream already had
cleanTokenResultstrippingauthToken(a security-sensitive field) before returning the response. ThePushTokenResultpick type andcleanTokenResultfunction were in the codebase before this PR — this migration only makes the response schema explicit about what's actually returned, aligning the declared contract with the actual behavior.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
I need to verify whether
cleanTokenResultandPushTokenResultexisted on thedevelopbranch before this PR.🏁 Script executed:
Length of output: 8145
@smirk-dev, you are correct, and I apologize for the incorrect review comment. After checking thedevelopbranch directly, I can confirm thatcleanTokenResult,PushTokenResult,isPushTokenPOSTProps,isPushTokenDELETEProps, and the chainedpush.tokenPOST/DELETE endpoints are all already present ondevelop. The diff was comparing against an older baseline, making it appear as if those were newly introduced by this PR.The actual new work in this PR is limited to migrating
push.getandpush.infoto the chained pattern — the response contract forpush.tokenwas already established in a prior landing. My comment was mistaken and does not apply.✏️ Learnings added
🧠 Learnings used