New API for registering services: #963 #1423 #1425 #1450 #1451 #1544 #1543

This merges the `node-8` branch. The heavy lift was by @Daniel15 with refactoring from me and a patch by @RedSparr0w.

* New API for registering services (#963)
* Disable Node 6 tests on node-8 branch (#1423)
* BaseService: Factor out methods _regex and _namedParamsForMatch (#1425)
    - Adjust test grouping
    - Rename data -> queryParams, text -> message
* BaseService tests: Use Chai (#1450)
* BaseService: make serviceData and badgeData explicit and declarative (#1451)
* fix isValidStyle test (#1544)
* Run tests in Node 9, not Node 6 (#1543)
This commit is contained in:
Paul Melnikow
2018-03-11 17:53:01 -07:00
committed by GitHub
parent 7545dcfe42
commit 2d651533aa
12 changed files with 355 additions and 59 deletions
+11 -11
View File
@@ -3,7 +3,7 @@ version: 2
jobs:
npm-install:
docker:
- image: shieldsio/shields-node-8:0.0.1
- image: shieldsio/shields-ci-node-8:0.0.2
working_directory: ~/repo
steps:
- checkout
@@ -30,7 +30,7 @@ jobs:
main:
docker:
- image: shieldsio/shields-node-8:0.0.1
- image: shieldsio/shields-ci-node-8:0.0.2
working_directory: ~/repo
steps:
- checkout
@@ -57,9 +57,9 @@ jobs:
name: Upload Greenkeeper lockfile
command: greenkeeper-lockfile-upload
main@node-6:
main@node-9:
docker:
- image: shieldsio/shields-node-6:0.0.1
- image: shieldsio/shields-ci-node-9:0.0.2
working_directory: ~/repo
steps:
- checkout
@@ -83,7 +83,7 @@ jobs:
frontend:
docker:
- image: shieldsio/shields-node-8:0.0.1
- image: shieldsio/shields-ci-node-8:0.0.2
working_directory: ~/repo
steps:
- checkout
@@ -103,7 +103,7 @@ jobs:
services-pr:
docker:
- image: shieldsio/shields-node-8:0.0.1
- image: shieldsio/shields-ci-node-8:0.0.2
working_directory: ~/repo
steps:
- checkout
@@ -135,9 +135,9 @@ jobs:
echo 'This is not a pull request. Skipping.'
fi
services-pr@node-6:
services-pr@node-9:
docker:
- image: shieldsio/shields-node-6:0.0.1
- image: shieldsio/shields-ci-node-9:0.0.2
working_directory: ~/repo
steps:
- checkout
@@ -171,7 +171,7 @@ jobs:
services-daily:
docker:
- image: shieldsio/shields-node-8:0.0.1
- image: shieldsio/shields-ci-node-8:0.0.2
working_directory: ~/repo
steps:
- checkout
@@ -195,7 +195,7 @@ workflows:
- main:
requires:
- npm-install
- main@node-6:
- main@node-9:
requires:
- npm-install
- frontend:
@@ -204,7 +204,7 @@ workflows:
- services-pr:
requires:
- npm-install
- services-pr@node-6:
- services-pr@node-9:
requires:
- npm-install
+4 -1
View File
@@ -14,7 +14,8 @@ Prerequisites
Updating the images
-------------------
Note: Increment the patch version on the tag in each change.
Note: Increment the patch version on the tag in each change. Check
[Docker Hub][] to see the current versions.
```console
IMAGE_TAG=<version> npm run circle-images:build
@@ -24,6 +25,8 @@ IMAGE_TAG=<version> npm run circle-images:push
After pushing the images, bump the tag in `.circleci/config.yml`.
[Docker Hub]: https://hub.docker.com/u/shieldsio/
Reference
---------
@@ -1,4 +1,4 @@
FROM node:6
FROM node:9
ADD .circleci/images/prepare-container.sh /root/prepare-container.sh
RUN /root/prepare-container.sh
RUN rm /root/prepare-container.sh
+3
View File
@@ -1,3 +1,6 @@
parserOptions:
ecmaVersion: 8
env:
node: true
# We use Promise, Map, and occasional ES6 syntax.
+1
View File
@@ -105,6 +105,7 @@ function makeBadgeData(defaultLabel, overrides) {
}
module.exports = {
toArray,
prependPrefix,
isDataUri,
isSixHex,
+12
View File
@@ -167,6 +167,18 @@ function handleRequest (makeBadge, handlerOptions) {
});
}
// Wrapper around `cachingRequest` that returns a promise rather than
// needing to pass a callback.
cachingRequest.asPromise = (uri, options) => new Promise((resolve, reject) => {
cachingRequest(uri, options, (err, res, buffer) => {
if (err) {
reject(err);
} else {
resolve({res, buffer});
}
});
});
vendorDomain.run(() => {
handlerOptions.handler(filteredQueryParams, match, function sendBadge(format, badgeData) {
if (serverUnresponsive) { return; }
+3 -3
View File
@@ -54,14 +54,14 @@
"lint": "eslint '**/*.js'",
"danger": "danger",
"test:js:frontend": "NODE_ENV=mocha mocha --require babel-polyfill --require babel-register 'frontend/**/*.spec.js'",
"test:js:server": "mocha '*.spec.js' 'lib/**/*.spec.js' 'service-tests/**/*.spec.js'",
"test:js:server": "mocha '*.spec.js' 'lib/**/*.spec.js' 'services/**/*.spec.js' 'service-tests/**/*.spec.js'",
"test:services": "mocha --delay service-tests/runner/cli.js",
"test:services:pr:prepare": "node service-tests/runner/pull-request-services-cli.js > pull-request-services.log",
"test:services:pr:run": "mocha --delay service-tests/runner/cli.js --stdin < pull-request-services.log",
"test:services:pr": "npm run test:services:pr:prepare && npm run test:services:pr:run",
"test": "npm run lint && npm run test:js:frontend && npm run test:js:server",
"circle-images:build": "docker build -t shieldsio/shields-node-8:${IMAGE_TAG} -f .circleci/images/node-8/Dockerfile . && docker build -t shieldsio/shields-node-6:${IMAGE_TAG} -f .circleci/images/node-6/Dockerfile .",
"circle-images:push": "docker push shieldsio/shields-node-8:${IMAGE_TAG} && docker push shieldsio/shields-node-6:${IMAGE_TAG}",
"circle-images:build": "docker build -t shieldsio/shields-ci-node-8:${IMAGE_TAG} -f .circleci/images/node-8/Dockerfile . && docker build -t shieldsio/shields-ci-node-9:${IMAGE_TAG} -f .circleci/images/node-9/Dockerfile .",
"circle-images:push": "docker push shieldsio/shields-ci-node-8:${IMAGE_TAG} && docker push shieldsio/shields-ci-node-9:${IMAGE_TAG}",
"frontend-depcheck": "check-node-version --node \">= 8.0\"",
"server-depcheck": "check-node-version --node \">= 6.0 < 9.0\"",
"postinstall": "npm run server-depcheck",
+7 -42
View File
@@ -5,6 +5,7 @@ const dom = require('xmldom').DOMParser;
const jp = require('jsonpath');
const path = require('path');
const prettyBytes = require('pretty-bytes');
const glob = require('glob');
const queryString = require('query-string');
const semver = require('semver');
const xml2js = require('xml2js');
@@ -193,6 +194,12 @@ camp.notfound(/.*/, function(query, match, end, request) {
// Vendors.
// New-style services
glob.sync(`${__dirname}/services/*.js`)
.filter(path => !path.endsWith('base.js') && !path.endsWith('.spec.js'))
.map(path => require(path))
.forEach(serviceClass => serviceClass.register(camp, cache));
// JIRA issue integration
camp.route(/^\/jira\/issue\/(http(?:s)?)\/(.+)\/([^/]+)\.(svg|png|gif|jpg|json)$/,
cache(function (data, match, sendBadge, request) {
@@ -726,48 +733,6 @@ cache(function (data, match, sendBadge, request) {
});
}));
// AppVeyor CI integration.
camp.route(/^\/appveyor\/ci\/([^/]+\/[^/]+)(?:\/(.+))?\.(svg|png|gif|jpg|json)$/,
cache(function(data, match, sendBadge, request) {
var repo = match[1]; // eg, `gruntjs/grunt`.
var branch = match[2];
var format = match[3];
var apiUrl = 'https://ci.appveyor.com/api/projects/' + repo;
if (branch != null) {
apiUrl += '/branch/' + branch;
}
var badgeData = getBadgeData('build', data);
request(apiUrl, { headers: { 'Accept': 'application/json' } }, function(err, res, buffer) {
if (err != null) {
badgeData.text[1] = 'inaccessible';
sendBadge(format, badgeData);
return;
}
try {
if (res.statusCode === 404) {
badgeData.text[1] = 'project not found or access denied';
sendBadge(format, badgeData);
return;
}
var data = JSON.parse(buffer);
var status = data.build.status;
if (status === 'success') {
badgeData.text[1] = 'passing';
badgeData.colorscheme = 'brightgreen';
} else if (status !== 'running' && status !== 'queued') {
badgeData.text[1] = 'failing';
badgeData.colorscheme = 'red';
} else {
badgeData.text[1] = status;
}
sendBadge(format, badgeData);
} catch(e) {
badgeData.text[1] = 'invalid';
sendBadge(format, badgeData);
}
});
}));
// AppVeyor test status integration.
camp.route(/^\/appveyor\/tests\/([^/]+\/[^/]+)(?:\/(.+))?\.(svg|png|gif|jpg|json)$/,
cache(function(data, match, sendBadge, request) {
@@ -46,7 +46,7 @@ function servicesForTitle (title) {
}
const services = matches[1].toLowerCase().split(' ');
const blacklist = ['wip'];
const blacklist = ['wip', 'rfc'];
return difference(services, blacklist);
}
+56
View File
@@ -0,0 +1,56 @@
'use strict';
const BaseService = require('./base');
/**
* AppVeyor CI integration.
*/
module.exports = class AppVeyor extends BaseService {
async handle({repo, branch}) {
let apiUrl = 'https://ci.appveyor.com/api/projects/' + repo;
if (branch != null) {
apiUrl += '/branch/' + branch;
}
const {buffer, res} = await this._sendAndCacheRequest(apiUrl, {
headers: { 'Accept': 'application/json' }
});
if (res.statusCode === 404) {
return {message: 'project not found or access denied'};
}
const data = JSON.parse(buffer);
const status = data.build.status;
if (status === 'success') {
return {message: 'passing', colorscheme: 'brightgreen'};
} else if (status !== 'running' && status !== 'queued') {
return {message: 'failing', colorscheme: 'red'};
} else {
return {message: status};
}
}
// Metadata
static get category() {
return 'build';
}
static get uri() {
return {
format: '/appveyor/ci/([^/]+/[^/]+)(?:/(.+))?',
capture: ['repo', 'branch']
};
}
static getExamples() {
return [
{
uri: '/appveyor/ci/gruntjs/grunt',
},
{
name: 'Branch',
uri: '/appveyor/ci/gruntjs/grunt/master',
},
];
}
};
+157
View File
@@ -0,0 +1,157 @@
'use strict';
const {
makeLogo,
toArray,
makeColor,
setBadgeColor,
} = require('../lib/badge-data');
module.exports = class BaseService {
constructor({sendAndCacheRequest}) {
this._sendAndCacheRequest = sendAndCacheRequest;
}
/**
* Asynchronous function to handle requests for this service. Takes the URI
* parameters (as defined in the `uri` property), performs a request using
* `this._sendAndCacheRequest`, and returns the badge data.
*/
async handle(namedParams) {
throw new Error(
`Handler not implemented for ${this.constructor.name}`
);
}
// Metadata
/**
* Name of the category to sort this badge into (eg. "build"). Used to sort
* the badges on the main shields.io website.
*/
static get category() {
return 'unknown';
}
/**
* Returns an object with two fields:
* - format: Regular expression to use for URIs for this service's badges
* - capture: Array of names for the capture groups in the regular
* expression. The handler will be passed an object containing
* the matches.
*/
static get uri() {
throw new Error(`URI not defined for ${this.name}`);
}
/**
* Default data for the badge. Can include things such as default logo, color,
* etc. These defaults will be used if the value is not explicitly overridden
* by either the handler or by the user via URL parameters.
*/
static get defaultBadgeData() {
return {};
}
/**
* Example URIs for this service. These should use the format
* specified in `uri`, and can be used to demonstrate how to use badges for
* this service.
*/
static getExamples() {
return [];
}
static get _regex() {
// Regular expressions treat "/" specially, so we need to escape them
const escapedPath = this.uri.format.replace(/\//g, '\\/');
const fullRegex = '^' + escapedPath + '.(svg|png|gif|jpg|json)$';
return new RegExp(fullRegex);
}
static _namedParamsForMatch(match) {
// Assume the last match is the format, and drop match[0], which is the
// entire match.
const captures = match.slice(1, -1);
if (this.uri.capture.length !== captures.length) {
throw new Error(
`Service ${this.constructor.name} declares incorrect number of capture groups `+
`(expected ${this.uri.capture.length}, got ${captures.length})`
);
}
const result = {};
this.uri.capture.forEach((name, index) => {
result[name] = captures[index];
});
return result;
}
static _makeBadgeData(overrides, serviceData) {
const {
style,
label: overrideLabel,
logo: overrideLogo,
logoWidth: overrideLogoWidth,
link: overrideLink,
colorA: overrideColorA,
colorB: overrideColorB,
} = overrides;
const {
label: serviceLabel,
message: serviceMessage,
color: serviceColor,
link: serviceLink,
} = serviceData;
const defaultLabel = this.category;
const {
color: defaultColor,
logo: defaultLogo,
} = this.defaultBadgeData;
const badgeData = {
text: [
overrideLabel || serviceLabel || defaultLabel,
serviceMessage || 'n/a',
],
template: style,
logo: makeLogo(style === 'social' ? defaultLogo : undefined, { logo: overrideLogo }),
logoWidth: +overrideLogoWidth,
links: toArray(overrideLink || serviceLink),
colorA: makeColor(overrideColorA),
};
const color = makeColor(overrideColorB || serviceColor || defaultColor || 'lightgrey');
setBadgeColor(badgeData, color);
return badgeData;
}
static register(camp, handleRequest) {
const serviceClass = this; // In a static context, "this" is the class.
camp.route(this._regex,
handleRequest(async (queryParams, match, sendBadge, request) => {
let serviceData;
try {
const namedParams = this._namedParamsForMatch(match);
const serviceInstance = new serviceClass({
sendAndCacheRequest: request.asPromise,
});
serviceData = await serviceInstance.handle(namedParams);
} catch (error) {
serviceData = { message: 'error' };
console.log(error);
}
// Assumes the final capture group is the extension
const format = match.slice(-1)[0];
const badgeData = this._makeBadgeData(queryParams, serviceData);
sendBadge(format, badgeData);
}));
}
};
+99
View File
@@ -0,0 +1,99 @@
'use strict';
const { expect } = require('chai');
const sinon = require('sinon');
const BaseService = require('./base');
require('../lib/register-chai-plugins.spec');
class DummyService extends BaseService {
async handle({someArg}) {
return {
message: 'Hello ' + someArg,
};
}
static get category() { return 'cat'; }
static get uri() {
return {
format: '/foo/([^/]+)',
capture: ['someArg']
};
}
}
describe('BaseService', () => {
describe('_makeBadgeData', function () {
describe('Overrides', function () {
it('overrides the label', function () {
const badgeData = DummyService._makeBadgeData({ label: 'purr count' }, { label: 'purrs' });
expect(badgeData.text).to.deep.equal(['purr count', 'n/a']);
});
});
describe('Service data', function () {
it('applies the service message', function () {
const badgeData = DummyService._makeBadgeData({}, { message: '10k' });
expect(badgeData.text).to.deep.equal(['cat', '10k']);
});
});
describe('Defaults', function () {
it('uses the default label', function () {
const badgeData = DummyService._makeBadgeData({}, {});
expect(badgeData.text).to.deep.equal(['cat', 'n/a']);
});
});
});
describe('ScoutCamp integration', function () {
const expectedRouteRegex = /^\/foo\/([^/]+).(svg|png|gif|jpg|json)$/;
let mockCamp;
let mockHandleRequest;
beforeEach(() => {
mockCamp = {
route: sinon.spy(),
};
mockHandleRequest = sinon.spy();
DummyService.register(mockCamp, mockHandleRequest);
});
it('registers the service', () => {
expect(mockCamp.route).to.have.been.calledOnce;
expect(mockCamp.route).to.have.been.calledWith(expectedRouteRegex);
});
it('handles the request', async () => {
expect(mockHandleRequest).to.have.been.calledOnce;
const requestHandler = mockHandleRequest.getCall(0).args[0];
const mockSendBadge = sinon.spy();
const mockRequest = {
asPromise: sinon.spy(),
};
await requestHandler(
/*data*/ {},
/*match*/ '/foo/bar.svg'.match(expectedRouteRegex),
mockSendBadge,
mockRequest
);
expect(mockSendBadge).to.have.been.calledOnce;
expect(mockSendBadge).to.have.been.calledWith(
/*format*/ 'svg',
{
text: ['cat', 'Hello bar'],
colorscheme: 'lightgrey',
template: undefined,
logo: undefined,
logoWidth: NaN,
links: [],
colorA: undefined,
}
);
});
});
});