diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5d50a9c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "trailingComma": "all" +} \ No newline at end of file diff --git a/RcHttp.ts b/RcHttp.ts new file mode 100644 index 0000000..df96a02 --- /dev/null +++ b/RcHttp.ts @@ -0,0 +1,76 @@ +import { + IHttp, + IHttpRequest, + IHttpResponse, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; + +export class RcHttp implements IHttp { + private readonly baseUrl = "http://localhost:3000/api/v1"; + private readonly http: IHttp; + private readonly read: IRead; + + constructor(http: IHttp, read: IRead) { + this.http = http; + this.read = read; + } + + public async get(url: string, content?: any): Promise { + return this.http.get( + this.buildUrl(url), + await this.buildOptions(content) + ); + } + + public async post(url: string, content?: any): Promise { + return this.http.post( + this.buildUrl(url), + await this.buildOptions(content) + ); + } + + public async put(url: string, content?: any): Promise { + return this.http.put( + this.buildUrl(url), + await this.buildOptions(content) + ); + } + + public async del(url: string, content?: any): Promise { + return this.http.del( + this.buildUrl(url), + await this.buildOptions(content) + ); + } + + public async patch(url: string, content?: any): Promise { + return this.http.patch( + this.buildUrl(url), + await this.buildOptions(content) + ); + } + + private buildUrl(url: string): string { + return `${this.baseUrl}/${url}`; + } + + private async buildOptions(content?: any): Promise { + const options: IHttpRequest = { + headers: { + "X-User-Id": await this.read + .getEnvironmentReader() + .getSettings() + .getValueById("rc-user-id"), + "X-Auth-Token": await this.read + .getEnvironmentReader() + .getSettings() + .getValueById("rc-token"), + "Content-Type": "application/json", + }, + }; + if (content !== undefined) { + options.content = JSON.stringify(content); + } + return options; + } +} diff --git a/ScimEndpoint.ts b/ScimEndpoint.ts new file mode 100644 index 0000000..fd32322 --- /dev/null +++ b/ScimEndpoint.ts @@ -0,0 +1,171 @@ +import { + HttpStatusCode, + IHttp, + IHttpResponse, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { + ApiEndpoint, + IApiEndpointInfo, + IApiRequest, + IApiResponse, +} from "@rocket.chat/apps-engine/definition/api"; +import { IApp } from "@rocket.chat/apps-engine/definition/IApp"; +import { ConflictError } from "./errors/ConflictError"; +import { EmptyRequestError } from "./errors/EmptyRequestError"; +import { EmptyResponseError } from "./errors/EmptyResponseError"; +import { JsonParseError } from "./errors/JsonParseError"; +import { SCIMError, SCIMErrorType } from "./scim/Error"; + +type ApiEndpointMethod = ( + request: IApiRequest, + endpoint: IApiEndpointInfo, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence, +) => Promise; + +export interface IScimEndpoint { + _get?( + request: IApiRequest, + endpoint: IApiEndpointInfo, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence, + ): Promise; + _post?( + request: IApiRequest, + endpoint: IApiEndpointInfo, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence, + ): Promise; + _put?( + request: IApiRequest, + endpoint: IApiEndpointInfo, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence, + ): Promise; + _delete?( + request: IApiRequest, + endpoint: IApiEndpointInfo, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence, + ): Promise; +} + +export abstract class ScimEndpoint extends ApiEndpoint { + public get: ApiEndpointMethod | undefined; + public post: ApiEndpointMethod | undefined; + public put: ApiEndpointMethod | undefined; + public delete: ApiEndpointMethod | undefined; + + constructor(app: IApp) { + super(app); + this.get = this.wrapMethod("get"); + this.post = this.wrapMethod("post"); + this.put = this.wrapMethod("put"); + this.delete = this.wrapMethod("delete"); + } + + protected success(content?: any): IApiResponse { + return this.response({ + status: HttpStatusCode.OK, + content, + }); + } + + protected response(response: IApiResponse): IApiResponse { + if (response.headers === undefined) { + response.headers = {}; + } + response.headers["Content-Type"] = "application/scim+json"; + return response; + } + + protected error(error: SCIMError): IApiResponse { + return this.response({ + status: parseInt(error.status, 10), + content: error, + }); + } + + protected parseResponse(response: IHttpResponse): any { + if (!response.content) { + throw new EmptyResponseError(); + } + let content: any; + try { + content = JSON.parse(response.content); + } catch (e) { + throw new JsonParseError(); + } + return content; + } + + protected hasContent(request: IApiRequest) { + if (!request.content || Object.keys(request.content).length === 0) { + throw new EmptyRequestError(); + } + } + + protected handleError(o: any) { + if (!o.success) { + if (o.error.includes("already in use")) { + throw new ConflictError( + o.error.includes("@") ? "email" : "username", + ); + } + if (o.error.includes("not found")) { + } + throw new Error(o.error); + } + } + + private wrapMethod(name: string): ApiEndpointMethod | undefined { + const method = this[`_${name}`]; + if (method === undefined || typeof method !== "function") { + return undefined; + } + return async ( + request: IApiRequest, + endpoint: IApiEndpointInfo, + read: IRead, + modify: IModify, + http: IHttp, + persis: IPersistence, + ): Promise => { + try { + return await method.bind(this)( + request, + endpoint, + read, + modify, + http, + persis, + ); + } catch (e) { + let err: SCIMError; + if (e.toSCIMError && typeof e.toSCIMError === "function") { + err = e.toSCIMError(); + } else { + err = new SCIMError() + .setStatus(HttpStatusCode.INTERNAL_SERVER_ERROR) + .setScimType(SCIMErrorType.INVALID_VALUE) + .setDetail(e.message); + } + + return this.error(err); + } + }; + } +} diff --git a/UserEndpoint.ts b/UserEndpoint.ts index 32dd00b..2d8f154 100644 --- a/UserEndpoint.ts +++ b/UserEndpoint.ts @@ -6,161 +6,74 @@ import { IRead, } from "@rocket.chat/apps-engine/definition/accessors"; import { - ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse, } from "@rocket.chat/apps-engine/definition/api"; +import { RcHttp } from "./RcHttp"; import { SCIMUser } from "./scim/User"; +import { IScimEndpoint, ScimEndpoint } from "./ScimEndpoint"; -export class UserEndpoint extends ApiEndpoint { +export class UserEndpoint extends ScimEndpoint implements IScimEndpoint { public path = "Users/:id"; - public async get( + public async _get( request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, - persis: IPersistence + persis: IPersistence, ): Promise { - let user: SCIMUser; - try { - const response = await http.get( - `http://localhost:3000/api/v1/users.info?userId=` + - request.params.id, - { - headers: { - ...(await this.getAuthHeaders(read)), - "Content-Type": "application/json", - }, - } - ); - if (!response.content) throw new Error("Empty response"); - const o = JSON.parse(response.content); - if (!o.success) throw new Error(o.error); - user = SCIMUser.fromRC(o.user); - } catch (e) { - return { - headers: { - "Content-Type": "application/scim+json", - }, - status: HttpStatusCode.BAD_REQUEST, - content: { message: e.message }, - }; - } - return { - headers: { - "Content-Type": "application/scim+json", - }, - status: HttpStatusCode.OK, - content: user, - }; + const response = await new RcHttp(http, read).get( + `users.info?userId=${request.params.id}`, + ); + const o = this.parseResponse(response); + this.handleError(o); + const user = SCIMUser.fromRC(o.user); + return this.success(user); } - public async put( + public async _put( request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, - persis: IPersistence + persis: IPersistence, ): Promise { - let user: SCIMUser; - try { - const response = await http.post( - "http://localhost:3000/api/v1/users.update", - { - headers: { - ...(await this.getAuthHeaders(read)), - "Content-Type": "application/json", - }, - content: JSON.stringify( - this.scimToUserUpdate( - request.params.id, - SCIMUser.fromPlain(request.content) - ) - ), - } - ); - if (!response.content) throw new Error("Empty response"); - const o = JSON.parse(response.content); - if (!o.success) throw new Error(o.error); - user = SCIMUser.fromRC(o.user); - } catch (e) { - return { - headers: { - "Content-Type": "application/scim+json", - }, - status: HttpStatusCode.BAD_REQUEST, - content: { message: e.message }, - }; - } - return { - headers: { - "Content-Type": "application/scim+json", - }, - status: HttpStatusCode.OK, - content: user, - }; + this.hasContent(request); + const response = await new RcHttp(http, read).post( + "users.update", + this.scimToUserUpdate( + request.params.id, + SCIMUser.fromPlain(request.content), + ), + ); + const o = this.parseResponse(response); + this.handleError(o); + const user = SCIMUser.fromRC(o.user); + return this.success(user); } - public async delete( + public async _delete( request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, - persis: IPersistence + persis: IPersistence, ): Promise { - let d: IUserDelete = { + const d: IUserDelete = { userId: request.params.id, confirmRelinquish: true, }; - try { - const response = await http.post( - "http://localhost:3000/api/v1/users.delete", - { - headers: { - ...(await this.getAuthHeaders(read)), - "Content-Type": "application/json", - }, - content: JSON.stringify(d), - } - ); - if (!response.content) throw new Error("Empty response"); - const o = JSON.parse(response.content); - if (!o.success) throw new Error(o.error); - } catch (e) { - return { - headers: { - "Content-Type": "application/scim+json", - }, - status: HttpStatusCode.BAD_REQUEST, - content: { message: e.message }, - }; - } - return { - headers: { - "Content-Type": "application/scim+json", - }, + const response = await new RcHttp(http, read).post("users.delete", d); + const o = this.parseResponse(response); + this.handleError(o); + return this.response({ status: HttpStatusCode.NO_CONTENT, - }; - } - - private async getAuthHeaders( - read: IRead - ): Promise<{ [key: string]: string }> { - return { - "X-User-Id": await read - .getEnvironmentReader() - .getSettings() - .getValueById("rc-user-id"), - "X-Auth-Token": await read - .getEnvironmentReader() - .getSettings() - .getValueById("rc-token"), - }; + }); } private scimToUserUpdate(userId: string, user: SCIMUser): IUserUpdate { diff --git a/UsersEndpoint.ts b/UsersEndpoint.ts index 55a524b..8234a6e 100644 --- a/UsersEndpoint.ts +++ b/UsersEndpoint.ts @@ -6,128 +6,67 @@ import { IRead, } from "@rocket.chat/apps-engine/definition/accessors"; import { - ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse, } from "@rocket.chat/apps-engine/definition/api"; import crypto = require("crypto"); -import { SCIMError, SCIMErrorType } from "./scim/Error"; +import { RcHttp } from "./RcHttp"; import { SCIMListResponse } from "./scim/ListResponse"; import { SCIMUser } from "./scim/User"; +import { IScimEndpoint, ScimEndpoint } from "./ScimEndpoint"; -export class UsersEndpoint extends ApiEndpoint { +export class UsersEndpoint extends ScimEndpoint implements IScimEndpoint { public path = "Users"; - public async get( + public async _get( request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, - persis: IPersistence + persis: IPersistence, ): Promise { + const response = await new RcHttp(http, read).get( + `users.list?query={"type":{"$eq":"user"}}&fields={"createdAt":1}`, + ); + const o = this.parseResponse(response); + this.handleError(o); const list = new SCIMListResponse(); - try { - const response = await http.get( - `http://localhost:3000/api/v1/users.list?query={"type":{"$eq":"user"}}&fields={"createdAt":1}`, - { - headers: { - ...(await this.getAuthHeaders(read)), - "Content-Type": "application/json", - }, - } - ); - if (!response.content) { - throw new Error("Empty response"); - } - const o = JSON.parse(response.content); - if (!o.success) { - throw new Error(o.error); - } - list.Resources = o.users.map(SCIMUser.fromRC); - list.totalResults = o.total; - } catch (e) { - const err = new SCIMError(); - err.scimType = SCIMErrorType.INVALID_VALUE; - err.detail = e.message; - err.status = "400"; - return err.toApiResponse(); - } - return { - headers: { - "Content-Type": "application/scim+json", - }, - status: HttpStatusCode.OK, - content: list, - }; + list.Resources = o.users.map(SCIMUser.fromRC); + list.totalResults = o.total; + return this.success(list); } - public async post( + public async _post( request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, - persis: IPersistence + persis: IPersistence, ): Promise { - let user = request.content; - try { - const response = await http.post( - `http://localhost:3000/api/v1/users.create`, - { - headers: { - ...(await this.getAuthHeaders(read)), - "Content-Type": "application/json", - }, - content: JSON.stringify( - this.scimToUserCreate(SCIMUser.fromPlain(user)) - ), - } - ); - if (!response.content) { - throw new Error("Empty response"); - } - const o = JSON.parse(response.content); - if (!o.success) { - throw new Error(o.error); - } - user = SCIMUser.fromRC(o.user); - } catch (e) { - const err = new SCIMError(); - err.scimType = SCIMErrorType.INVALID_VALUE; - err.detail = e.message; - err.status = "400"; - return err.toApiResponse(); - } - return { - headers: { - "Content-Type": "application/scim+json", - }, + this.hasContent(request); + const response = await new RcHttp(http, read).post( + `users.create`, + this.scimToUserCreate(SCIMUser.fromPlain(request.content)), + ); + const o = this.parseResponse(response); + this.handleError(o); + const user = SCIMUser.fromRC(o.user); + return this.response({ status: HttpStatusCode.CREATED, content: user, - }; - } - - private async getAuthHeaders( - read: IRead - ): Promise<{ [key: string]: string }> { - return { - "X-User-Id": await read - .getEnvironmentReader() - .getSettings() - .getValueById("rc-user-id"), - "X-Auth-Token": await read - .getEnvironmentReader() - .getSettings() - .getValueById("rc-token"), - }; + }); } private scimToUserCreate(user: SCIMUser): IUserCreate { return { - email: user.emails[0].value, - name: user.displayName, + email: user.getEmail(), + name: + user.displayName || + `${user.name.givenName} ${user.name.familyName}` || + user.userName, username: user.userName, password: crypto.randomBytes(64).toString("base64").slice(0, 64), verified: true, diff --git a/errors/BaseError.ts b/errors/BaseError.ts new file mode 100644 index 0000000..abfdd37 --- /dev/null +++ b/errors/BaseError.ts @@ -0,0 +1,6 @@ +import { SCIMError } from "../scim/Error"; + +export abstract class BaseError extends Error { + public readonly isBaseError = true; + public abstract toSCIMError(): SCIMError; +} diff --git a/errors/ConflictError.ts b/errors/ConflictError.ts new file mode 100644 index 0000000..eafde67 --- /dev/null +++ b/errors/ConflictError.ts @@ -0,0 +1,22 @@ +import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors"; +import { SCIMError, SCIMErrorType } from "../scim/Error"; +import { BaseError } from "./BaseError"; + +export class ConflictError extends BaseError { + public get message() { + return `This ${this.type} already exists`; + } + private type = ""; + + constructor(type: string) { + super(); + this.type = type; + } + + public toSCIMError(): SCIMError { + return new SCIMError() + .setStatus(HttpStatusCode.CONFLICT) + .setScimType(SCIMErrorType.INVALID_VALUE) + .setDetail(this.message); + } +} diff --git a/errors/EmptyRequestError.ts b/errors/EmptyRequestError.ts new file mode 100644 index 0000000..a6935de --- /dev/null +++ b/errors/EmptyRequestError.ts @@ -0,0 +1,13 @@ +import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors"; +import { SCIMError, SCIMErrorType } from "../scim/Error"; +import { BaseError } from "./BaseError"; + +export class EmptyRequestError extends BaseError { + public message = "Request body is empty or content type is unsupported"; + public toSCIMError(): SCIMError { + return new SCIMError() + .setStatus(HttpStatusCode.BAD_REQUEST) + .setScimType(SCIMErrorType.INVALID_VALUE) + .setDetail(this.message); + } +} diff --git a/errors/EmptyResponseError.ts b/errors/EmptyResponseError.ts new file mode 100644 index 0000000..fd7dedf --- /dev/null +++ b/errors/EmptyResponseError.ts @@ -0,0 +1,13 @@ +import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors"; +import { SCIMError, SCIMErrorType } from "../scim/Error"; +import { BaseError } from "./BaseError"; + +export class EmptyResponseError extends BaseError { + public message = "Rocket.Chat API returned an empty response"; + public toSCIMError(): SCIMError { + return new SCIMError() + .setStatus(HttpStatusCode.INTERNAL_SERVER_ERROR) + .setScimType(SCIMErrorType.INVALID_VALUE) + .setDetail(this.message); + } +} diff --git a/errors/JsonParseError.ts b/errors/JsonParseError.ts new file mode 100644 index 0000000..6a7d1d6 --- /dev/null +++ b/errors/JsonParseError.ts @@ -0,0 +1,13 @@ +import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors"; +import { SCIMError, SCIMErrorType } from "../scim/Error"; +import { BaseError } from "./BaseError"; + +export class JsonParseError extends BaseError { + public message = "Failed to parse Rocket.Chat API response content"; + public toSCIMError(): SCIMError { + return new SCIMError() + .setStatus(HttpStatusCode.INTERNAL_SERVER_ERROR) + .setScimType(SCIMErrorType.INVALID_VALUE) + .setDetail(this.message); + } +} diff --git a/scim/Error.ts b/scim/Error.ts index 283a892..54ef4ad 100644 --- a/scim/Error.ts +++ b/scim/Error.ts @@ -1,4 +1,4 @@ -import { IApiResponse } from "@rocket.chat/apps-engine/definition/api"; +import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors"; export enum SCIMErrorType { INVALID_FILTER = "invalidFilter", @@ -27,15 +27,24 @@ export enum SCIMErrorDetail { NOT_IMPLEMENTED = "Service provider does not support the request operation.", } -export class SCIMError { +export class SCIMError { public readonly schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"]; public scimType: SCIMErrorType; public detail: SCIMErrorDetail | string; public status: string; - public toApiResponse(): IApiResponse { - return { - status: parseInt(this.status, 10), - content: this, - }; + + public setScimType(scimType: SCIMErrorType): SCIMError { + this.scimType = scimType; + return this; } -} \ No newline at end of file + + public setStatus(status: HttpStatusCode): SCIMError { + this.status = `${status}`; + return this; + } + + public setDetail(detail: SCIMErrorDetail | string): SCIMError { + this.detail = detail; + return this; + } +}