diff --git a/.changeset/teams-discovery-openapi.md b/.changeset/teams-discovery-openapi.md new file mode 100644 index 0000000000000..c08f4cb05a18f --- /dev/null +++ b/.changeset/teams-discovery-openapi.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +chore: Add OpenAPI support for teams discovery endpoints diff --git a/apps/meteor/app/api/server/v1/teams.ts b/apps/meteor/app/api/server/v1/teams.ts index 0281d0f7a8080..52abb334a1de8 100644 --- a/apps/meteor/app/api/server/v1/teams.ts +++ b/apps/meteor/app/api/server/v1/teams.ts @@ -3,6 +3,8 @@ import type { ITeam, UserStatus } from '@rocket.chat/core-typings'; import { TeamType } from '@rocket.chat/core-typings'; import { Users, Rooms } from '@rocket.chat/models'; import { + ajv, + ajvQuery, isTeamsConvertToChannelProps, isTeamsRemoveRoomProps, isTeamsUpdateMemberProps, @@ -12,6 +14,10 @@ import { isTeamsLeaveProps, isTeamsUpdateProps, isTeamsListChildrenProps, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, + validateNotFoundErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; @@ -25,42 +31,299 @@ import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { eraseTeam } from '../lib/eraseTeam'; -API.v1.addRoute( - 'teams.list', - { authRequired: true }, - { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, query } = await this.parseJsonQuery(); +type TeamsListQuery = { + count?: number; + offset?: number; + sort?: string; + query?: string; +}; - const { records, total } = await Team.list(this.userId, { offset, count }, { sort, query }); +type TeamsListAllQuery = { + count?: number; + offset?: number; +}; - return API.v1.success({ - teams: records, - total, - count: records.length, - offset, - }); +type TeamsInfoQuery = { + teamId?: string; + teamName?: string; +}; + +type TeamsMembersQuery = { + teamId?: string; + teamName?: string; + status?: string[]; + username?: string; + name?: string; + count?: number; + offset?: number; +}; + +type TeamsAutocompleteQuery = { + name: string; +}; + +const TeamsListQuerySchema = { + type: 'object', + properties: { + count: { type: 'number', nullable: true }, + offset: { type: 'number', nullable: true }, + sort: { type: 'string', nullable: true }, + query: { type: 'string', nullable: true }, + }, + required: [], + additionalProperties: true, +} as const; + +const TeamsListAllQuerySchema = { + type: 'object', + properties: { + count: { type: 'number', nullable: true }, + offset: { type: 'number', nullable: true }, + }, + required: [], + additionalProperties: true, +} as const; + +const TeamsInfoQuerySchema = { + type: 'object', + properties: { + teamId: { type: 'string', nullable: true }, + teamName: { type: 'string', nullable: true }, + }, + oneOf: [{ required: ['teamId'] }, { required: ['teamName'] }], + additionalProperties: true, +} as const; + +const TeamsMembersQuerySchema = { + type: 'object', + properties: { + teamId: { type: 'string', nullable: true }, + teamName: { type: 'string', nullable: true }, + status: { type: 'array', items: { type: 'string' }, nullable: true }, + username: { type: 'string', nullable: true }, + name: { type: 'string', nullable: true }, + count: { type: 'number', nullable: true }, + offset: { type: 'number', nullable: true }, + }, + oneOf: [{ required: ['teamId'] }, { required: ['teamName'] }], + additionalProperties: true, +} as const; + +const TeamsAutocompleteQuerySchema = { + type: 'object', + properties: { + name: { type: 'string', minLength: 1 }, + }, + required: ['name'], + additionalProperties: false, +} as const; + +const isTeamsListProps = ajvQuery.compile(TeamsListQuerySchema); +const isTeamsListAllProps = ajvQuery.compile(TeamsListAllQuerySchema); +const isTeamsInfoProps = ajvQuery.compile(TeamsInfoQuerySchema); +const isTeamsMembersProps = ajvQuery.compile(TeamsMembersQuerySchema); +const isTeamsAutocompleteProps = ajvQuery.compile(TeamsAutocompleteQuerySchema); + +const teamsPaginatedResponseSchema = ajv.compile<{ + teams: ITeam[]; + total: number; + count: number; + offset: number; + success: true; +}>({ + type: 'object', + properties: { + teams: { type: 'array', items: { $ref: '#/components/schemas/ITeam' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teams', 'total', 'count', 'offset', 'success'], + additionalProperties: false, +}); + +const teamsListChildrenResponseSchema = ajv.compile<{ + data: Record[]; + total: number; + count: number; + offset: number; + success: true; +}>({ + type: 'object', + properties: { + data: { type: 'array', items: { $ref: '#/components/schemas/IRoom' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'total', 'count', 'offset', 'success'], + additionalProperties: false, +}); + +const teamsMembersResponseSchema = ajv.compile<{ + members: { + user: { + _id: string; + username?: string; + name?: string; + status?: string; + settings?: Record; + }; + roles?: string[] | null; + createdBy: { + _id: string; + username?: string; + }; + createdAt: string; + }[]; + total: number; + count: number; + offset: number; + success: true; +}>({ + type: 'object', + properties: { + members: { + type: 'array', + items: { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + _id: { type: 'string' }, + username: { type: 'string' }, + name: { type: 'string' }, + status: { type: 'string' }, + settings: { type: 'object', additionalProperties: true }, + }, + required: ['_id'], + additionalProperties: true, + }, + roles: { + oneOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }], + }, + createdBy: { + type: 'object', + properties: { + _id: { type: 'string' }, + username: { type: 'string' }, + }, + required: ['_id'], + additionalProperties: true, + }, + createdAt: { type: 'string', format: 'date-time' }, + }, + required: ['user', 'createdBy', 'createdAt'], + additionalProperties: true, + }, }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['members', 'total', 'count', 'offset', 'success'], + additionalProperties: false, +}); + +const teamsInfoResponseSchema = ajv.compile<{ teamInfo: ITeam; success: true }>({ + type: 'object', + properties: { + teamInfo: { $ref: '#/components/schemas/ITeam' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teamInfo', 'success'], + additionalProperties: false, +}); + +const teamsAutocompleteResponseSchema = ajv.compile<{ + teams: { + _id: string; + fname?: string; + teamId?: string; + name?: string; + t?: string; + avatarETag?: string; + }[]; + success: true; +}>({ + type: 'object', + properties: { + teams: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + fname: { type: 'string' }, + teamId: { type: 'string' }, + name: { type: 'string' }, + t: { type: 'string' }, + avatarETag: { type: 'string' }, + }, + required: ['_id'], + additionalProperties: true, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teams', 'success'], + additionalProperties: false, +}); + +API.v1.get( + 'teams.list', + { + authRequired: true, + query: isTeamsListProps, + response: { + 200: teamsPaginatedResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, query } = await this.parseJsonQuery(); + + const { records, total } = await Team.list(this.userId, { offset, count }, { sort, query }); + + return API.v1.success({ + teams: records, + total, + count: records.length, + offset, + }); }, ); -API.v1.addRoute( +API.v1.get( 'teams.listAll', - { authRequired: true, permissionsRequired: ['view-all-teams'] }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); + authRequired: true, + permissionsRequired: ['view-all-teams'], + query: isTeamsListAllProps, + response: { + 200: teamsPaginatedResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); - const { records, total } = await Team.listAll({ offset, count }); + const { records, total } = await Team.listAll({ offset, count }); - return API.v1.success({ - teams: records, - total, - count: records.length, - offset, - }); - }, + return API.v1.success({ + teams: records, + total, + count: records.length, + offset, + }); }, ); @@ -386,80 +649,72 @@ const getTeamByIdOrNameOrParentRoom = async ( // This should accept a teamId, filter (search by name on rooms collection) and sort/pagination // should return a list of rooms/discussions from the team. the discussions will only be returned from the main room -API.v1.addRoute( +API.v1.get( 'teams.listChildren', - { authRequired: true, validateParams: isTeamsListChildrenProps }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - const { filter, type } = this.queryParams; + authRequired: true, + query: isTeamsListChildrenProps, + response: { + 200: teamsListChildrenResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { filter, type } = this.queryParams; - const team = await getTeamByIdOrNameOrParentRoom(this.queryParams); - if (!team) { - return API.v1.notFound(); - } + const team = await getTeamByIdOrNameOrParentRoom(this.queryParams); + if (!team) { + return API.v1.notFound(); + } - const data = await Team.listChildren(this.userId, team, filter, type, sort, offset, count); + const data = await Team.listChildren(this.userId, team, filter, type, sort, offset, count); - return API.v1.success({ ...data, offset, count }); - }, + return API.v1.success({ ...data, offset, count }); }, ); -API.v1.addRoute( +API.v1.get( 'teams.members', - { authRequired: true }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - - check( - this.queryParams, - Match.OneOf( - Match.ObjectIncluding({ - teamId: String, - }), - Match.ObjectIncluding({ - teamName: String, - }), - ), - ); - - check( - this.queryParams, - Match.ObjectIncluding({ - status: Match.Maybe([String]), - username: Match.Maybe(String), - name: Match.Maybe(String), - }), - ); - - const { status, username, name } = this.queryParams; - - const team = await getTeamByIdOrName(this.queryParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } - - const canSeeAllMembers = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); - - const query = { - ...(username && { username: new RegExp(escapeRegExp(username), 'i') }), - ...(name && { name: new RegExp(escapeRegExp(name), 'i') }), - ...(status && { status: { $in: status as UserStatus[] } }), - }; - - const { records, total } = await Team.members(this.userId, team._id, canSeeAllMembers, { offset, count }, query); - - return API.v1.success({ - members: records, - total, - count: records.length, - offset, - }); + authRequired: true, + query: isTeamsMembersProps, + response: { + 200: teamsMembersResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { status, username, name } = this.queryParams; + + const team = await getTeamByIdOrName(this.queryParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + const canSeeAllMembers = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); + + const query = { + ...(username && { username: new RegExp(escapeRegExp(username), 'i') }), + ...(name && { name: new RegExp(escapeRegExp(name), 'i') }), + ...(status && { status: { $in: status as UserStatus[] } }), + }; + + const { records, total } = await Team.members(this.userId, team._id, canSeeAllMembers, { offset, count }, query); + + return API.v1.success({ + members: records, + total, + count: records.length, + offset, + }); + }, ); API.v1.addRoute( @@ -592,43 +847,38 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +API.v1.get( 'teams.info', - { authRequired: true }, { - async get() { - check( - this.queryParams, - Match.OneOf( - Match.ObjectIncluding({ - teamId: String, - }), - Match.ObjectIncluding({ - teamName: String, - }), - ), - ); - - const teamInfo = await getTeamByIdOrName(this.queryParams); - if (!teamInfo) { - return API.v1.failure('Team not found'); - } + authRequired: true, + query: isTeamsInfoProps, + response: { + 200: teamsInfoResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const teamInfo = await getTeamByIdOrName(this.queryParams); + if (!teamInfo) { + return API.v1.failure('Team not found'); + } - const room = await Rooms.findOneById(teamInfo.roomId); + const room = await Rooms.findOneById(teamInfo.roomId); - if (!room) { - return API.v1.failure('Room not found'); - } + if (!room) { + return API.v1.failure('Room not found'); + } - const canViewInfo = - (await canAccessRoomAsync(room, { _id: this.userId })) || (await hasPermissionAsync(this.userId, 'view-all-teams')); + const canViewInfo = + (await canAccessRoomAsync(room, { _id: this.userId })) || (await hasPermissionAsync(this.userId, 'view-all-teams')); - if (!canViewInfo) { - return API.v1.forbidden(); - } + if (!canViewInfo) { + return API.v1.forbidden(); + } - return API.v1.success({ teamInfo }); - }, + return API.v1.success({ teamInfo }); }, ); @@ -659,24 +909,23 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +API.v1.get( 'teams.autocomplete', - { authRequired: true }, { - async get() { - check( - this.queryParams, - Match.ObjectIncluding({ - name: String, - }), - ); - - const { name } = this.queryParams; + authRequired: true, + query: isTeamsAutocompleteProps, + response: { + 200: teamsAutocompleteResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { name } = this.queryParams; - const teams = await Team.autocomplete(this.userId, name); + const teams = await Team.autocomplete(this.userId, name); - return API.v1.success({ teams }); - }, + return API.v1.success({ teams }); }, );