diff --git a/src/Bot.js b/src/Bot.js index f764df2..18386b0 100644 --- a/src/Bot.js +++ b/src/Bot.js @@ -42,7 +42,7 @@ class Bot { return null; } - let client = new GrafanaClient(robot.http, robot.logger, host, apiKey); + let client = new GrafanaClient(robot.logger, host, apiKey); return new GrafanaService(client); } diff --git a/src/adapters/implementations/RocketChatUploader.js b/src/adapters/implementations/RocketChatUploader.js index 5f1dc37..eecb7b8 100644 --- a/src/adapters/implementations/RocketChatUploader.js +++ b/src/adapters/implementations/RocketChatUploader.js @@ -1,5 +1,4 @@ 'strict'; -const { post } = require('../../http'); const { Uploader } = require('../Uploader'); class RocketChatUploader extends Uploader { @@ -20,7 +19,11 @@ class RocketChatUploader extends Uploader { /** @type {string} */ this.rocketchat_url = process.env.ROCKETCHAT_URL; - if (this.rocketchat_url && !this.rocketchat_url.startsWith('http://') && !this.rocketchat_url.startsWith('https://')) { + if ( + this.rocketchat_url && + !this.rocketchat_url.startsWith('http://') && + !this.rocketchat_url.startsWith('https://') + ) { this.rocketchat_url = `http://${rocketchat_url}`; } @@ -31,6 +34,40 @@ class RocketChatUploader extends Uploader { this.logger = logger; } + /** + * Logs in to the RocketChat API using the provided credentials. + * @returns {Promise<{'X-Auth-Token': string, 'X-User-Id': string}>} A promise that resolves to the authentication headers if successful. + * @throws {Error} If authentication fails. + */ + async login() { + const authUrl = `${this.rocketchat_url}/api/v1/login`; + const authForm = { + username: this.rocketchat_user, + password: this.rocketchat_password, + }; + + let rocketchatResBodyJson = null; + + try { + rocketchatResBodyJson = await post(authUrl, authForm); + } catch (err) { + this.logger.error(err); + throw new Error('Could not authenticate.'); + } + + const { status } = rocketchatResBodyJson; + if (status === 'success') { + return { + 'X-Auth-Token': rocketchatResBodyJson.data.authToken, + 'X-User-Id': rocketchatResBodyJson.data.userId, + }; + } + + const errMsg = rocketchatResBodyJson.message; + this.logger.error(errMsg); + throw new Error(errMsg); + } + /** * Uploads the a screenshot of the dashboards. * @@ -39,69 +76,67 @@ class RocketChatUploader extends Uploader { * @param {{ body: Buffer, contentType: string}=>void} file the screenshot. * @param {string} grafanaChartLink link to the Grafana chart. */ - upload(res, title, file, grafanaChartLink) { - const authData = { - url: `${this.rocketchat_url}/api/v1/login`, - form: { - username: this.rocketchat_user, - password: this.rocketchat_password, + async upload(res, title, file, grafanaChartLink) { + let authHeaders = null; + try { + authHeaders = await this.login(); + } catch (ex) { + let msg = ex == 'Could not authenticate.' ? "invalid url, user or password/can't access rocketchat api" : ex; + res.send(`${title} - [Rocketchat auth Error - ${msg}] - ${grafanaChartLink}`); + return; + } + + // fill in the POST request. This must be www-form/multipart + // TODO: needs some extra testing! + const uploadUrl = `${this.rocketchat_url}/api/v1/rooms.upload/${res.envelope.user.roomID}`; + const uploadForm = { + msg: `${title}: ${grafanaChartLink}`, + // grafanaDashboardRequest() is the method that downloads the .png + file: { + value: file.body, + options: { + filename: `${title} ${Date()}.png`, + contentType: 'image/png', + }, }, }; - // We auth against rocketchat to obtain the auth token - post(robot, authData, async (err, rocketchatResBodyJson) => { - if (err) { - this.logger.error(err); - res.send(`${title} - [Rocketchat auth Error - invalid url, user or password/can't access rocketchat api] - ${grafanaChartLink}`); - return; - } - let errMsg; - const { status } = rocketchatResBodyJson; - if (status !== 'success') { - errMsg = rocketchatResBodyJson.message; - this.logger.error(errMsg); - res.send(`${title} - [Rocketchat auth Error - ${errMsg}] - ${grafanaChartLink}`); - return; - } - - const auth = rocketchatResBodyJson.data; - - // fill in the POST request. This must be www-form/multipart - // TODO: needs some extra testing! - const uploadData = { - url: `${this.rocketchat_url}/api/v1/rooms.upload/${res.envelope.user.roomID}`, - headers: { - 'X-Auth-Token': auth.authToken, - 'X-User-Id': auth.userId, - }, - formData: { - msg: `${title}: ${grafanaChartLink}`, - // grafanaDashboardRequest() is the method that downloads the .png - file: { - value: file.body, - options: { - filename: `${title} ${Date()}.png`, - contentType: 'image/png', - }, - }, - }, - }; + let body = null; + + try { + body = await this.post(uploadUrl, uploadForm, authHeaders); + } catch (err) { + this.logger.error(err); + res.send(`${title} - [Upload Error] - ${grafanaChartLink}`); + return; + } + + if (!body.success) { + this.logger.error(`rocketchat service error while posting data:${body.error}`); + return res.send(`${title} - [Form Error: can't upload file : ${body.error}] - ${grafanaChartLink}`); + } + } - // Try to upload the image to rocketchat else pass the link over - return post(this.robot, uploadData, (err, body) => { - // Error logging, we must also check the body response. - // It will be something like: { "success": , "error": } - if (err) { - this.logger.error(err); - return res.send(`${title} - [Upload Error] - ${grafanaChartLink}`); - } - if (!body.success) { - errMsg = body.error; - this.logger.error(`rocketchat service error while posting data:${errMsg}`); - return res.send(`${title} - [Form Error: can't upload file : ${errMsg}] - ${grafanaChartLink}`); - } - }); + /** + * Posts the data data to the specified url and returns JSON. + * @param {string} url - the URL + * @param {Record} formData - formatData + * @param {Record|null} headers - formatData + * @returns {Promise} The deserialized JSON response or an error if something went wrong. + */ + async post(url, formData, headers = null) { + const response = await fetch(url, { + method: 'POST', + headers: headers, + body: new FormData(formData), }); + + if (!response.ok) { + throw new Error('HTTP request failed'); + } + + const data = await response.json(); + return data; } } diff --git a/src/grafana-client.js b/src/grafana-client.js index e8ad877..2695a93 100644 --- a/src/grafana-client.js +++ b/src/grafana-client.js @@ -7,18 +7,11 @@ const { URL, URLSearchParams } = require('url'); class GrafanaClient { /** * Creates a new instance. - * @param {(url: string, options?: HttpOptions)=>ScopedClient} http the HTTP client. * @param {Hubot.Log} logger the logger. * @param {string} host the host. * @param {string} apiKey the api key. */ - constructor(http, logger, host, apiKey) { - /** - * The HTTP client - * @type {(url: string, options?: HttpOptions)=>ScopedClient} - */ - this.http = http; - + constructor(logger, host, apiKey) { /** * The logger. * @type {Hubot.Log} @@ -39,66 +32,71 @@ class GrafanaClient { } /** - * Creates a scoped HTTP client. - * @param {string} url The URL. - * @param {string | null} contentType Indicates if the HTTP client should post. - * @param {encoding | false} encoding Indicates if an encoding should be set. - * @returns {ScopedClient} + * Performs a GET on the Grafana API. + * Remarks: uses Hubot because of Nock testing. + * @param {string} url the url + * @returns {Promise} the response data */ - createHttpClient(url, contentType = null, encoding = false) { + async get(url) { if (!url.startsWith('http://') && !url.startsWith('https://') && !this.host) { throw new Error('No Grafana endpoint configured.'); } - // in case of a download we get a "full" URL const fullUrl = url.startsWith('http://') || url.startsWith('https://') ? url : `${this.host}/api/${url}`; - const headers = grafanaHeaders(contentType, encoding, this.apiKey); - const client = this.http(fullUrl).headers(headers); - - return client; - } - /** - * Performs a GET on the Grafana API. - * Remarks: uses Hubot because of Nock testing. - * @param {string} url the url - * @returns {Promise} - */ - async get(url) { - let client = this.createHttpClient(url); - return new Promise((resolve) => { - client.get()((err, res, body) => { - if (err) { - throw err; - } - const data = JSON.parse(body); - return resolve(data); - }); + const response = await fetch(fullUrl, { + method: 'GET', + headers: grafanaHeaders(null, false, this.apiKey), }); + + await this.throwIfNotOk(response); + + const json = await response.json(); + return json; } /** * Performs a POST call to the Grafana API. * * @param {string} url The API sub URL - * @param {Record} data The data that will be sent. - * @returns {Promise} + * @param {Record} data The data that will be sent. + * @returns {Promise} */ - post(url, data) { - const http = this.createHttpClient(url, 'application/json'); - const jsonPayload = JSON.stringify(data); - - return new Promise((resolve, reject) => { - http.post(jsonPayload)((err, res, body) => { - if (err) { - reject(err); - return; - } - - data = JSON.parse(body); - resolve(data); - }); + async post(url, data) { + const fullUrl = url.startsWith('http://') || url.startsWith('https://') ? url : `${this.host}/api/${url}`; + + const response = await fetch(fullUrl, { + method: 'POST', + headers: grafanaHeaders('application/json', false, this.apiKey), + body: JSON.stringify(data), }); + + await this.throwIfNotOk(response); + + const json = await response.json(); + return json; + } + + /** + * Ensures that the response is OK. If the response is not OK, an error is thrown. + * @param {fetch.Response} response - The response object. + * @throws {Error} If the response is not OK, an error with the response text is thrown. + */ + async throwIfNotOk(response) { + if (response.ok) { + return; + } + + if (response.headers.get('content-type') == 'application/json') { + const json = await response.json(); + + const error = new Error(json.message || 'Error while fetching data from Grafana.'); + error.data = json; + throw error; + } + + const text = await response.text(); + throw new Error(text); } /** @@ -107,18 +105,20 @@ class GrafanaClient { * @returns {Promise} */ async download(url) { - return await fetch(url, { + let response = await fetch(url, { method: 'GET', headers: grafanaHeaders(null, null, this.apiKey), - }).then(async (res) => { - const contentType = res.headers.get('content-type'); - const body = await res.arrayBuffer(); - - return { - body: Buffer.from(body), - contentType: contentType, - }; }); + + await this.throwIfNotOk(response); + + const contentType = response.headers.get('content-type'); + const body = await response.arrayBuffer(); + + return { + body: Buffer.from(body), + contentType: contentType, + }; } createGrafanaChartLink(query, uid, panel, timeSpan, variables) { diff --git a/src/http.js b/src/http.js deleted file mode 100644 index 4e8e152..0000000 --- a/src/http.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * - * @param {Hubot.Robot} robot the robot, which will provide an HTTP - * @param {{url: string, formData: Record}} uploadData - * @param {(err: Error | null, data: any)=>void} callback - */ -function post(robot, uploadData, callback) { - robot.http(uploadData.url).post(new FormData(uploadData.formData))((err, res, body) => { - if (err) { - callback(err, null); - return; - } - - data = JSON.parse(body); - callback(null, data); - }); -} - -module.exports = { - post, -}; diff --git a/src/service/GrafanaService.js b/src/service/GrafanaService.js index cc4a0c7..db6e083 100644 --- a/src/service/GrafanaService.js +++ b/src/service/GrafanaService.js @@ -267,8 +267,12 @@ class GrafanaService { try { dashboard = await this.client.get(url); } catch (err) { - this.logger.error(err, `Error while getting dashboard on URL: ${url}`); - return null; + if (err.message !== 'Dashboard not found') { + this.logger.error(err, `Error while getting dashboard on URL: ${url}`); + return null; + } + + dashboard = { message: err.message }; } this.logger.debug(dashboard);