Home Manual Reference Source

src/sdk.js

import fs from 'fs';
import FormData from 'form-data';
import glob from 'glob';
import got from 'got';
import path from 'path';
import pMap from 'p-map';
import pkg from '../package.json';

class VisWiz {
	/**
	 * @class VisWiz
	 * @typicalname client
	 * @param {string} [apiKey] - The API Key value for a VisWiz.io account
	 *
	 * If omitted, the environment variable `VISWIZ_API_KEY` will be used
	 * @param {object} [options]
	 * @param {string} [options.server=https://api.viswiz.io] - The server URL prefix for all requests
	 *
	 * @example
	 * const client = new VisWiz('your-unique-api-key-here');
	 *
	 * // Assuming environment variable VISWIZ_API_KEY is set
	 * const client = new VisWiz();
	 */
	constructor(apiKey, options) {
		this.apiKey = apiKey || process.env.VISWIZ_API_KEY;
		this.server =
			(options && options.server) || process.env.VISWIZ_SERVER || 'https://api.viswiz.io';

		if (!this.apiKey) {
			throw new Error('Missing API key value!');
		}
	}

	/**
	 * Execute a HTTP request
	 *
	 * @private
	 * @param {string} method - http method
	 * @param {string} endpoint - endpoint for the request
	 * @param {object} body - body parameters / object
	 * @param {object} [headers] - HTTP headers for the request
	 * @param {object} [options] - `got` options
	 */
	_request(method, endpoint, body, headers, options = {}) {
		const url = this.server + endpoint;

		options.headers = headers;
		options.method = method;
		if (!options.retry) {
			options.retry = {
				limit: 0,
			};
		}

		if (body) {
			if (body instanceof FormData) {
				// `form-data` has a stream bug: https://github.com/sindresorhus/got/issues/1340
				// so we're using it as a Buffer here
				options.headers = {
					...options.headers,
					...body.getHeaders(),
				};
				options.body = body.getBuffer();
			} else {
				options.json = body;
			}
		}

		return got(url, options).json();
	}

	/**
	 * Get the list of required headers for an API request
	 *
	 * @private
	 * @param {object} [additionalHeaders={}] - headers object
	 */
	_getHeaders(additionalHeaders = {}) {
		return {
			Accept: 'application/json',
			Authorization: 'Bearer ' + this.apiKey,
			'Content-Type': 'application/json',
			'User-Agent': `viswiz-nodejs-sdk/${pkg.version} (${pkg.repository.url})`,
			...additionalHeaders,
		};
	}

	/**
	 * Get the current account information
	 *
	 * @method
	 * @returns {Promise}
	 * @fulfil {object} - The current account object
	 *
	 * @example
	 * const account = await client.getAccount();
	 */
	getAccount() {
		return this._request('GET', '/account', null, this._getHeaders());
	}

	/**
	 * Get the list of webhooks configured for the account.
	 *
	 * @method
	 * @returns {Promise}
	 * @fulfil {array<object>} - The list of webhooks objects
	 *
	 * @example
	 * const webhooks = await client.getWebhooks();
	 */
	getWebhooks() {
		return this._request('GET', '/webhooks', null, this._getHeaders()).then(
			results => results.webhooks
		);
	}

	/**
	 * When a build comparison is finished a POST HTTP request will be triggered towards all
	 * webhooks configured for the account.
	 *
	 * @method
	 * @param {object} params
	 * @returns {Promise}
	 * @fulfil {object} - The new webhook object
	 *
	 * @example
	 * const webhook = await client.createWebhook({
	 *   name: 'My first webhook',
	 *   url: 'http://amazing.com/webhook-handler'
	 * });
	 */
	createWebhook(params) {
		if (!params) {
			return Promise.reject(new Error('Missing required parameter: params'));
		}

		return this._request('POST', '/webhooks', params, this._getHeaders());
	}

	/**
	 * Get a list of all the projects for the account.
	 *
	 * @method
	 * @returns {Promise}
	 * @fulfil {array<object>} - The list of projects objects
	 *
	 * @example
	 * const projects = await client.getProjects();
	 */
	getProjects() {
		return this._request('GET', '/projects', null, this._getHeaders()).then(
			results => results.projects
		);
	}

	/**
	 * Get a list of all the projects for the account.
	 *
	 * @method
	 * @param {string} projectID - The requested project ID
	 * @returns {Promise}
	 * @fulfil {Object} - The requested project object
	 *
	 * @example
	 * const project = await client.getProject(projectID);
	 */
	getProject(projectID) {
		return this.getProjects().then(projects => projects.find(item => item.id === projectID));
	}

	/**
	 * Create a new project for the account.
	 *
	 * @method
	 * @param {object} params
	 * @returns {Promise}
	 * @fulfil {object} - The new project object
	 *
	 * @example
	 * const project = await client.createProject({
	 *   baselineBranch: 'master',
	 *   name: 'My Amazing Project',
	 *   url: 'http://github.com/amaze/project'
	 * });
	 */
	createProject(params) {
		if (!params) {
			return Promise.reject(new Error('Missing required parameter: params'));
		}

		return this._request('POST', '/projects', params, this._getHeaders());
	}

	/**
	 * Get the notifications settings for a project.
	 *
	 * @method
	 * @param {string} projectID - The requested project ID
	 * @returns {Promise}
	 * @fulfil {array<object>} - The notifications settings
	 *
	 * @example
	 * const notifications = await client.getProjectNotifications('mwwuciQG7ETAmKoyRHgkGg');
	 */
	getProjectNotifications(projectID) {
		if (!projectID) {
			return Promise.reject(new Error('Missing required parameter: projectID'));
		}

		return this._request('GET', `/projects/${projectID}/notifications`, null, this._getHeaders());
	}

	/**
	 * Update the notifications settings for a project.
	 *
	 * @method
	 * @param {string} projectID - The requested project ID
	 * @param {object} params
	 * @param {string} [params.emailEnabled] - Controls if email reports are sent on new builds
	 * @param {string} [params.slackEnabled] - Controls if Slack notifications are sent on new builds
	 * @param {string} [params.slackURL] - The Slack webhook URL to use for sending notifications
	 * @returns {Promise}
	 * @fulfil {array<object>} - The updated notifications settings
	 *
	 * @example
	 * const build = await client.updateProjectNotifications('mwwuciQG7ETAmKoyRHgkGg', {
	 *   emailEnabled: false,
	 *   slackEnabled: true,
	 *   slackURL: 'https://hooks.slack.com/services/FOO/BAR/A18759GACAsgawg351ac',
	 * });
	 */
	updateProjectNotifications(projectID, params) {
		if (!projectID) {
			return Promise.reject(new Error('Missing required parameter: projectID'));
		}

		return this._request('PUT', `/projects/${projectID}/notifications`, params, this._getHeaders());
	}

	/**
	 * Get a list of all the builds for a project.
	 *
	 * @method
	 * @param {string} projectID - The requested project ID
	 * @returns {Promise}
	 * @fulfil {array<object>} - The list of builds objects
	 *
	 * @example
	 * const builds = await client.getBuilds('mwwuciQG7ETAmKoyRHgkGg');
	 */
	getBuilds(projectID) {
		if (!projectID) {
			return Promise.reject(new Error('Missing required parameter: projectID'));
		}

		return this._request('GET', `/projects/${projectID}/builds`, null, this._getHeaders()).then(
			results => results.builds
		);
	}

	/**
	 * Create a new build for a project.
	 *
	 * @method
	 * @param {object} build
	 * @param {string} build.branch - The branch name for this build
	 * @param {string} build.projectID - The requested project ID
	 * @param {string} build.name - The commit name for this build
	 * @param {string} build.revision - The revision for this build
	 * @returns {Promise}
	 * @fulfil {object} - The new build object
	 *
	 * @example
	 * const build = await client.createBuild({
	 *   branch: 'master',
	 *   projectID: 'mwwuciQG7ETAmKoyRHgkGg',
	 *   name: 'New amazing changes',
	 *   revision: '62388d1e81be184d4f255ca2354efef1e80fbfb8'
	 * });
	 */
	createBuild(build) {
		if (!build || !build.projectID) {
			return Promise.reject(new Error('Missing required parameter: projectID'));
		}

		const { projectID, ...body } = build;

		return this._request('POST', `/projects/${build.projectID}/builds`, body, this._getHeaders());
	}

	/**
	 * Finish a build when all images have been created. This triggers the actual build comparison to execute.
	 *
	 * @method
	 * @param {string} buildID - The requested build ID
	 * @returns {Promise}
	 *
	 * @example
	 * await client.finishBuild('gjVgsiWeh4TYVseqJsU6ev');
	 */
	finishBuild(buildID) {
		if (!buildID) {
			return Promise.reject(new Error('Missing required parameter: buildID'));
		}

		return this._request('POST', `/builds/${buildID}/finish`, null, this._getHeaders());
	}

	/**
	 * Get the results for a build which has been compared to another build.
	 *
	 * @method
	 * @param {string} buildID - The requested build ID
	 * @returns {Promise}
	 * @fulfil {object} - The build results object
	 *
	 * @example
	 * const buildResults = await client.getBuildResults('gjVgsiWeh4TYVseqJsU6ev');
	 */
	getBuildResults(buildID) {
		if (!buildID) {
			return Promise.reject(new Error('Missing required parameter: buildID'));
		}

		return this._request('GET', `/builds/${buildID}/results`, null, this._getHeaders());
	}

	/**
	 * Get a list of all images for a build.
	 *
	 * @method
	 * @param {string} buildID - The requested build ID
	 * @returns {Promise}
	 * @fulfil {array<object>} - The list of images objects
	 *
	 * @example
	 * const images = await client.getImages('gjVgsiWeh4TYVseqJsU6ev');
	 */
	getImages(buildID) {
		if (!buildID) {
			return Promise.reject(new Error('Missing required parameter: buildID'));
		}

		return this._request('GET', `/builds/${buildID}/images`, null, this._getHeaders());
	}

	/**
	 * Upload a new image for a build. This endpoint accepts only one PNG image per request.
	 *
	 * @method
	 * @param {string} buildID - The requested build ID
	 * @param {string} name - The image name
	 * @param {string} filePath - The image file path
	 * @returns {Promise}
	 * @fulfil {object} - The new image object
	 *
	 * @example
	 * const image = await client.createImage('gjVgsiWeh4TYVseqJsU6ev', 'image-name', '/path/to/image.png');
	 */
	createImage(buildID, name, filePath) {
		if (!buildID) {
			return Promise.reject(new Error('Missing required parameter: buildID'));
		}
		if (!name) {
			return Promise.reject(new Error('Missing required parameter: name'));
		}
		if (!filePath) {
			return Promise.reject(new Error('Missing required parameter: filePath'));
		}
		if (!fs.existsSync(filePath)) {
			return Promise.reject(new Error('File not found: ' + filePath));
		}

		const form = new FormData();
		form.append('name', name);
		form.append('image', fs.readFileSync(filePath), {
			filename: path.basename(filePath),
		});

		return this._request(
			'POST',
			`/builds/${buildID}/images`,
			form,
			this._getHeaders(form.getHeaders()),
			{
				retry: {
					limit: 2,
					methods: ['POST'],
				},
			}
		);
	}

	/**
	 * Creates a new build and uploads all images (`*.png`) found in a folder (scanned recursively)
	 *
	 * @method
	 * @param {object} build
	 * @param {string} build.branch - The branch name for this build
	 * @param {string} build.projectID - The requested project ID
	 * @param {string} build.name - The commit name for this build
	 * @param {string} build.revision - The revision for this build
	 * @param {string} folderPath
	 * @param {function} [progressCallback] - called with parameters: (current, total)
	 * @param {number} [concurrency] - The amount of concurrent images to upload (defaults to 4)
	 * @returns {Promise}
	 *
	 * @example
	 * await client.buildFolder({
	 *   branch: 'master',
	 *   projectID: 'mwwuciQG7ETAmKoyRHgkGg',
	 *   name: 'New amazing changes',
	 *   revision: '62388d1e81be184d4f255ca2354efef1e80fbfb8'
	 * }, '/path/to/folder/with/images');
	 */
	async buildFolder(build, folderPath, progressCallback, concurrency = 4) {
		const fullPath = path.resolve(folderPath);
		const imageFiles = glob.sync(path.join(fullPath, '**/*.png'));
		const total = imageFiles.length;
		if (!total) {
			return Promise.reject(new Error('No image files found in image directory!'));
		}

		const buildResponse = await this.createBuild(build);
		const buildID = buildResponse.id;

		let index = 0;

		await pMap(
			imageFiles,
			imageFile => {
				let name = imageFile;
				// glob under Windows returns `/` instead of `\`
				if (process.platform === 'win32') {
					name = name.replace(/\//g, '\\');
				}
				name = name
					.replace(fullPath, '')
					.replace(/^[/\\]/, '')
					.replace(/\.png$/i, '');

				return this.createImage(buildID, name, imageFile).then(
					() => progressCallback && progressCallback(++index, total)
				);
			},
			{ concurrency }
		);

		await this.finishBuild(buildID);

		return buildID;
	}

	/**
	 * Alias for `buildFolder`
	 *
	 * @method
	 */
	buildWithImages(build, folderPath, progressCallback, concurrency) {
		return this.buildFolder(build, folderPath, progressCallback, concurrency);
	}
}

export default VisWiz;