Add [GitlabPipeline] badge (#2325)
There's a lot of demand for the Gitlab badges (#541) and the PR has been lingering, so I thought I'd start off one of the simple ones as a new-style service. This one is SVG-based, so it shouldn't require the API-token logic which could use some more testing and will require us to create an app and configure it on our server. We don't have any validation in place for `queryParams`. Probably this should be added to BaseService, though for the time being I extracted a helper function. Thanks to @LVMBDV for getting this work started in #1838!
This commit is contained in:
43
lib/validate.js
Normal file
43
lib/validate.js
Normal file
@@ -0,0 +1,43 @@
|
||||
'use strict'
|
||||
|
||||
const emojic = require('emojic')
|
||||
const Joi = require('joi')
|
||||
const trace = require('../services/trace')
|
||||
|
||||
function validate(
|
||||
{
|
||||
ErrorClass,
|
||||
prettyErrorMessage = 'data does not match schema',
|
||||
traceErrorMessage = 'Data did not match schema',
|
||||
traceSuccessMessage = 'Data after validation',
|
||||
},
|
||||
data,
|
||||
schema
|
||||
) {
|
||||
if (!schema || !schema.isJoi) {
|
||||
throw Error('A Joi schema is required')
|
||||
}
|
||||
const { error, value } = Joi.validate(data, schema, {
|
||||
allowUnknown: true,
|
||||
stripUnknown: true,
|
||||
})
|
||||
if (error) {
|
||||
trace.logTrace(
|
||||
'validate',
|
||||
emojic.womanShrugging,
|
||||
traceErrorMessage,
|
||||
error.message
|
||||
)
|
||||
throw new ErrorClass({
|
||||
prettyMessage: prettyErrorMessage,
|
||||
underlyingError: error,
|
||||
})
|
||||
} else {
|
||||
trace.logTrace('validate', emojic.bathtub, traceSuccessMessage, value, {
|
||||
deep: true,
|
||||
})
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = validate
|
||||
87
lib/validate.spec.js
Normal file
87
lib/validate.spec.js
Normal file
@@ -0,0 +1,87 @@
|
||||
'use strict'
|
||||
|
||||
const Joi = require('joi')
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const trace = require('../services/trace')
|
||||
const { InvalidParameter } = require('../services/errors')
|
||||
const validate = require('./validate')
|
||||
|
||||
describe('validate', function() {
|
||||
const schema = Joi.object({
|
||||
requiredString: Joi.string().required(),
|
||||
}).required()
|
||||
|
||||
let sandbox
|
||||
beforeEach(function() {
|
||||
sandbox = sinon.createSandbox()
|
||||
})
|
||||
afterEach(function() {
|
||||
sandbox.restore()
|
||||
})
|
||||
beforeEach(function() {
|
||||
sandbox.stub(trace, 'logTrace')
|
||||
})
|
||||
|
||||
const ErrorClass = InvalidParameter
|
||||
const prettyErrorMessage = 'parameter does not match schema'
|
||||
const traceErrorMessage = 'Params did not match schema'
|
||||
const traceSuccessMessage = 'Params after validation'
|
||||
|
||||
const options = {
|
||||
ErrorClass,
|
||||
prettyErrorMessage,
|
||||
traceErrorMessage,
|
||||
traceSuccessMessage,
|
||||
}
|
||||
|
||||
context('schema is not provided', function() {
|
||||
it('throws the expected programmer error', function() {
|
||||
try {
|
||||
validate(options, { requiredString: 'bar' }, undefined)
|
||||
expect.fail('Expected to throw')
|
||||
} catch (e) {
|
||||
expect(e).to.be.an.instanceof(Error)
|
||||
expect(e.message).to.equal('A Joi schema is required')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
context('data matches schema', function() {
|
||||
it('logs the data', function() {
|
||||
validate(options, { requiredString: 'bar' }, schema)
|
||||
expect(trace.logTrace).to.be.calledWithMatch(
|
||||
'validate',
|
||||
sinon.match.string,
|
||||
traceSuccessMessage,
|
||||
{ requiredString: 'bar' },
|
||||
{ deep: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
context('data does not match schema', function() {
|
||||
it('logs the data and throws the expected error', async function() {
|
||||
try {
|
||||
validate(
|
||||
options,
|
||||
{ requiredString: ['this', "shouldn't", 'work'] },
|
||||
schema
|
||||
)
|
||||
expect.fail('Expected to throw')
|
||||
} catch (e) {
|
||||
expect(e).to.be.an.instanceof(InvalidParameter)
|
||||
expect(e.message).to.equal(
|
||||
'Invalid Parameter: child "requiredString" fails because ["requiredString" must be a string]'
|
||||
)
|
||||
expect(e.prettyMessage).to.equal(prettyErrorMessage)
|
||||
}
|
||||
expect(trace.logTrace).to.be.calledWithMatch(
|
||||
'validate',
|
||||
sinon.match.string,
|
||||
traceErrorMessage,
|
||||
'child "requiredString" fails because ["requiredString" must be a string]'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
// See available emoji at http://emoji.muan.co/
|
||||
const emojic = require('emojic')
|
||||
const Joi = require('joi')
|
||||
const queryString = require('query-string')
|
||||
const pathToRegexp = require('path-to-regexp')
|
||||
const {
|
||||
@@ -12,6 +11,7 @@ const {
|
||||
InvalidParameter,
|
||||
Deprecated,
|
||||
} = require('./errors')
|
||||
const validate = require('../lib/validate')
|
||||
const { checkErrorResponse } = require('../lib/error-helper')
|
||||
const {
|
||||
makeLogo,
|
||||
@@ -413,34 +413,16 @@ class BaseService {
|
||||
}
|
||||
|
||||
static _validate(data, schema) {
|
||||
if (!schema || !schema.isJoi) {
|
||||
throw Error('A Joi schema is required')
|
||||
}
|
||||
const { error, value } = Joi.validate(data, schema, {
|
||||
allowUnknown: true,
|
||||
stripUnknown: true,
|
||||
})
|
||||
if (error) {
|
||||
trace.logTrace(
|
||||
'validate',
|
||||
emojic.womanShrugging,
|
||||
'Response did not match schema',
|
||||
error.message
|
||||
)
|
||||
throw new InvalidResponse({
|
||||
prettyMessage: 'invalid response data',
|
||||
underlyingError: error,
|
||||
})
|
||||
} else {
|
||||
trace.logTrace(
|
||||
'validate',
|
||||
emojic.bathtub,
|
||||
'Data after validation',
|
||||
value,
|
||||
{ deep: true }
|
||||
)
|
||||
return value
|
||||
}
|
||||
return validate(
|
||||
{
|
||||
ErrorClass: InvalidResponse,
|
||||
prettyErrorMessage: 'invalid response data',
|
||||
traceErrorMessage: 'Response did not match schema',
|
||||
traceSuccessMessage: 'Response after validation',
|
||||
},
|
||||
data,
|
||||
schema
|
||||
)
|
||||
}
|
||||
|
||||
async _request({ url, options = {}, errorMessages = {} }) {
|
||||
|
||||
@@ -512,39 +512,7 @@ describe('BaseService', function() {
|
||||
requiredString: Joi.string().required(),
|
||||
}).required()
|
||||
|
||||
let sandbox
|
||||
beforeEach(function() {
|
||||
sandbox = sinon.createSandbox()
|
||||
})
|
||||
afterEach(function() {
|
||||
sandbox.restore()
|
||||
})
|
||||
beforeEach(function() {
|
||||
sandbox.stub(trace, 'logTrace')
|
||||
})
|
||||
|
||||
it('throws the expected error if schema is not provided', async function() {
|
||||
try {
|
||||
DummyService._validate({ requiredString: 'bar' }, undefined)
|
||||
expect.fail('Expected to throw')
|
||||
} catch (e) {
|
||||
expect(e).to.be.an.instanceof(Error)
|
||||
expect(e.message).to.equal('A Joi schema is required')
|
||||
}
|
||||
})
|
||||
|
||||
it('logs valid responses', async function() {
|
||||
DummyService._validate({ requiredString: 'bar' }, dummySchema)
|
||||
expect(trace.logTrace).to.be.calledWithMatch(
|
||||
'validate',
|
||||
sinon.match.string,
|
||||
'Data after validation',
|
||||
{ requiredString: 'bar' },
|
||||
{ deep: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('logs invalid responses and throws error', async function() {
|
||||
it('throws error for invalid responses', async function() {
|
||||
try {
|
||||
DummyService._validate(
|
||||
{ requiredString: ['this', "shouldn't", 'work'] },
|
||||
@@ -553,17 +521,7 @@ describe('BaseService', function() {
|
||||
expect.fail('Expected to throw')
|
||||
} catch (e) {
|
||||
expect(e).to.be.an.instanceof(InvalidResponse)
|
||||
expect(e.message).to.equal(
|
||||
'Invalid Response: child "requiredString" fails because ["requiredString" must be a string]'
|
||||
)
|
||||
expect(e.prettyMessage).to.equal('invalid response data')
|
||||
}
|
||||
expect(trace.logTrace).to.be.calledWithMatch(
|
||||
'validate',
|
||||
sinon.match.string,
|
||||
'Response did not match schema',
|
||||
'child "requiredString" fails because ["requiredString" must be a string]'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -77,8 +77,10 @@ class InvalidParameter extends ShieldsRuntimeError {
|
||||
return 'invalid parameter'
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
const message = 'Invalid Parameter'
|
||||
constructor(props = {}) {
|
||||
const message = props.underlyingError
|
||||
? `Invalid Parameter: ${props.underlyingError.message}`
|
||||
: 'Invalid Parameter'
|
||||
super(props, message)
|
||||
}
|
||||
}
|
||||
|
||||
16
services/gitlab/gitlab-helpers.js
Normal file
16
services/gitlab/gitlab-helpers.js
Normal file
@@ -0,0 +1,16 @@
|
||||
'use strict'
|
||||
|
||||
const Joi = require('joi')
|
||||
|
||||
const isPipelineStatus = Joi.equal(
|
||||
'pending',
|
||||
'running',
|
||||
'passed',
|
||||
'failed',
|
||||
'skipped',
|
||||
'canceled'
|
||||
).required()
|
||||
|
||||
module.exports = {
|
||||
isPipelineStatus,
|
||||
}
|
||||
111
services/gitlab/gitlab-pipeline-status.service.js
Normal file
111
services/gitlab/gitlab-pipeline-status.service.js
Normal file
@@ -0,0 +1,111 @@
|
||||
'use strict'
|
||||
|
||||
const Joi = require('joi')
|
||||
const validate = require('../../lib/validate')
|
||||
const BaseSvgScrapingService = require('../base-svg-scraping')
|
||||
const { InvalidParameter, NotFound } = require('../errors')
|
||||
const { isPipelineStatus } = require('./gitlab-helpers')
|
||||
|
||||
const badgeSchema = Joi.object({
|
||||
message: Joi.alternatives()
|
||||
.try([isPipelineStatus, Joi.equal('unknown')])
|
||||
.required(),
|
||||
}).required()
|
||||
|
||||
const queryParamsSchema = Joi.object({
|
||||
// TODO This accepts URLs with query strings and fragments, which should be
|
||||
// rejected.
|
||||
gitlab_url: Joi.string().uri({ scheme: ['https'] }),
|
||||
}).required()
|
||||
|
||||
module.exports = class GitlabPipelineStatus extends BaseSvgScrapingService {
|
||||
static get category() {
|
||||
return 'build'
|
||||
}
|
||||
|
||||
static get route() {
|
||||
return {
|
||||
base: 'gitlab/pipeline',
|
||||
format: '([^/]+)/([^/]+)(?:/([^/]+))?',
|
||||
capture: ['user', 'repo', 'branch'],
|
||||
// Trailing optional parameters don't work. The issue relates to the `.`
|
||||
// separator before the extension.
|
||||
// pattern: ':user/:repo/:branch?',
|
||||
queryParams: ['gitlab_url'],
|
||||
}
|
||||
}
|
||||
|
||||
static get examples() {
|
||||
return [
|
||||
{
|
||||
title: 'Gitlab pipeline status',
|
||||
pattern: ':user/:repo',
|
||||
namedParams: { user: 'gitlab-org', repo: 'gitlab-ce' },
|
||||
staticExample: this.render({ status: 'passed' }),
|
||||
},
|
||||
{
|
||||
title: 'Gitlab pipeline status (branch)',
|
||||
pattern: ':user/:repo/:branch',
|
||||
namedParams: {
|
||||
user: 'gitlab-org',
|
||||
repo: 'gitlab-ce',
|
||||
branch: 'master',
|
||||
},
|
||||
staticExample: this.render({ status: 'passed' }),
|
||||
},
|
||||
{
|
||||
title: 'Gitlab pipeline status (self-hosted)',
|
||||
pattern: ':user/:repo',
|
||||
namedParams: { user: 'GNOME', repo: 'pango' },
|
||||
query: { gitlab_url: 'https://gitlab.gnome.org' },
|
||||
staticExample: this.render({ status: 'passed' }),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
static validateQueryParams(queryParams) {
|
||||
return validate(
|
||||
{
|
||||
ErrorClass: InvalidParameter,
|
||||
prettyErrorMessage: 'invalid query parameter',
|
||||
traceErrorMessage: 'Query params did not match schema',
|
||||
traceSuccessMessage: 'Query params after validation',
|
||||
},
|
||||
queryParams,
|
||||
queryParamsSchema
|
||||
)
|
||||
}
|
||||
|
||||
static render({ status }) {
|
||||
const color = {
|
||||
pending: 'yellow',
|
||||
running: 'yellow',
|
||||
passed: 'brightgreen',
|
||||
failed: 'red',
|
||||
skipped: 'lightgray',
|
||||
canceled: 'lightgray',
|
||||
}[status]
|
||||
|
||||
return {
|
||||
message: status,
|
||||
color,
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ user, repo, branch = 'master' }, queryParams) {
|
||||
const {
|
||||
gitlab_url: baseUrl = 'https://gitlab.com',
|
||||
} = this.constructor.validateQueryParams(queryParams)
|
||||
const { message: status } = await this._requestSvg({
|
||||
schema: badgeSchema,
|
||||
url: `${baseUrl}/${user}/${repo}/badges/${branch}/pipeline.svg`,
|
||||
errorMessages: {
|
||||
401: 'repo not found',
|
||||
},
|
||||
})
|
||||
if (status === 'unknown') {
|
||||
throw new NotFound({ prettyMessage: 'branch not found' })
|
||||
}
|
||||
return this.constructor.render({ status })
|
||||
}
|
||||
}
|
||||
48
services/gitlab/gitlab-pipeline-status.tester.js
Normal file
48
services/gitlab/gitlab-pipeline-status.tester.js
Normal file
@@ -0,0 +1,48 @@
|
||||
'use strict'
|
||||
|
||||
const Joi = require('joi')
|
||||
const { isPipelineStatus } = require('./gitlab-helpers')
|
||||
|
||||
const t = require('../create-service-tester')()
|
||||
module.exports = t
|
||||
|
||||
t.create('Pipeline status')
|
||||
.get('/gitlab-org/gitlab-ce.json')
|
||||
.expectJSONTypes(
|
||||
Joi.object().keys({
|
||||
name: 'build',
|
||||
value: isPipelineStatus,
|
||||
})
|
||||
)
|
||||
|
||||
t.create('Pipeline status (branch)')
|
||||
.get('/gitlab-org/gitlab-ce/v10.7.6.json')
|
||||
.expectJSONTypes(
|
||||
Joi.object().keys({
|
||||
name: 'build',
|
||||
value: isPipelineStatus,
|
||||
})
|
||||
)
|
||||
|
||||
t.create('Pipeline status (nonexistent branch)')
|
||||
.get('/gitlab-org/gitlab-ce/nope-not-a-branch.json')
|
||||
.expectJSON({
|
||||
name: 'build',
|
||||
value: 'branch not found',
|
||||
})
|
||||
|
||||
t.create('Pipeline status (nonexistent repo)')
|
||||
.get('/this-repo/does-not-exist.json')
|
||||
.expectJSON({
|
||||
name: 'build',
|
||||
value: 'repo not found',
|
||||
})
|
||||
|
||||
t.create('Pipeline status (custom gitlab URL)')
|
||||
.get('/GNOME/pango.json?gitlab_url=https://gitlab.gnome.org')
|
||||
.expectJSONTypes(
|
||||
Joi.object().keys({
|
||||
name: 'build',
|
||||
value: isPipelineStatus,
|
||||
})
|
||||
)
|
||||
@@ -96,6 +96,7 @@ const isBuildStatus = Joi.equal(
|
||||
'no tests',
|
||||
'not built',
|
||||
'not run',
|
||||
'passed',
|
||||
'passing',
|
||||
'pending',
|
||||
'processing',
|
||||
|
||||
Reference in New Issue
Block a user