[Endpoint] badge (#2473)

This reimplements the idea @bkdotcom came up with in #1519, and took a stab at in #1525. It’s a really powerful way to add all sorts of custom badges, particularly considering [tools like RunKit endpoints and Jupyter Kernel Gateway](https://github.com/badges/shields/issues/2259#issuecomment-444186589), not to mention all the other ways cloud functions can be deployed these days.
This commit is contained in:
Paul Melnikow
2019-01-21 22:55:24 -05:00
committed by GitHub
parent 6a17210850
commit 0fc3df84d7
12 changed files with 675 additions and 31 deletions

View File

@@ -32,6 +32,15 @@ const defaultBadgeDataSchema = Joi.object({
namedLogo: Joi.string(),
}).required()
const optionalStringWhenNamedLogoPrsent = Joi.alternatives().when('namedLogo', {
is: Joi.string().required(),
then: Joi.string(),
})
const optionalNumberWhenAnyLogoPresent = Joi.alternatives()
.when('namedLogo', { is: Joi.string().required(), then: Joi.number() })
.when('logoSvg', { is: Joi.string().required(), then: Joi.number() })
const serviceDataSchema = Joi.object({
isError: Joi.boolean(),
label: Joi.string().allow(''),
@@ -45,27 +54,15 @@ const serviceDataSchema = Joi.object({
labelColor: Joi.string(),
namedLogo: Joi.string(),
logoSvg: Joi.string(),
logoColor: Joi.forbidden(),
logoWidth: Joi.forbidden(),
logoPosition: Joi.forbidden(),
cacheLengthSeconds: Joi.number()
logoColor: optionalStringWhenNamedLogoPrsent,
logoWidth: optionalNumberWhenAnyLogoPresent,
logoPosition: optionalNumberWhenAnyLogoPresent,
cacheSeconds: Joi.number()
.integer()
.min(0),
style: Joi.string(),
})
.oxor('namedLogo', 'logoSvg')
.when(
Joi.alternatives().try(
Joi.object({ namedLogo: Joi.string().required() }).unknown(),
Joi.object({ logoSvg: Joi.string().required() }).unknown()
),
{
then: Joi.object({
logoColor: Joi.string(),
logoWidth: Joi.number(),
logoPosition: Joi.number(),
}),
}
)
.required()
class BaseService {
@@ -379,7 +376,7 @@ class BaseService {
// string.
static _makeBadgeData(overrides, serviceData) {
const {
style,
style: overrideStyle,
label: overrideLabel,
logoColor: overrideLogoColor,
link: overrideLink,
@@ -415,7 +412,8 @@ class BaseService {
logoWidth: serviceLogoWidth,
logoPosition: serviceLogoPosition,
link: serviceLink,
cacheLengthSeconds: serviceCacheLengthSeconds,
cacheSeconds: serviceCacheSeconds,
style: serviceStyle,
} = serviceData
const serviceLogoSvgBase64 = serviceLogoSvg
? svg2base64(serviceLogoSvg)
@@ -427,7 +425,9 @@ class BaseService {
label: defaultLabel,
labelColor: defaultLabelColor,
} = this.defaultBadgeData
const defaultCacheLengthSeconds = this._cacheLength
const defaultCacheSeconds = this._cacheLength
const style = coalesce(overrideStyle, serviceStyle)
const namedLogoSvgBase64 = prepareNamedLogo({
name: coalesce(
@@ -480,10 +480,7 @@ class BaseService {
overrideNamedLogo ? undefined : serviceLogoPosition
),
links: toArray(overrideLink || serviceLink),
cacheLengthSeconds: coalesce(
serviceCacheLengthSeconds,
defaultCacheLengthSeconds
),
cacheLengthSeconds: coalesce(serviceCacheSeconds, defaultCacheSeconds),
}
}
@@ -517,13 +514,14 @@ class BaseService {
)
}
static _validate(data, schema) {
static _validate(data, schema, { allowAndStripUnknownKeys = true } = {}) {
return validate(
{
ErrorClass: InvalidResponse,
prettyErrorMessage: 'invalid response data',
traceErrorMessage: 'Response did not match schema',
traceSuccessMessage: 'Response after validation',
allowAndStripUnknownKeys,
},
data,
schema

View File

@@ -507,7 +507,7 @@ describe('BaseService', function() {
it('overrides the cache length', function() {
const badgeData = DummyService._makeBadgeData(
{ style: 'pill' },
{ cacheLengthSeconds: 123 }
{ cacheSeconds: 123 }
)
expect(badgeData.cacheLengthSeconds).to.equal(123)
})

View File

@@ -12,7 +12,9 @@ const queryParamSchema = Joi.object({
maxAge: Joi.number()
.integer()
.min(0),
}).required()
})
.unknown(true)
.required()
function overrideCacheLengthFromQueryParams(queryParams) {
try {

View File

@@ -35,6 +35,11 @@ describe('Cache header functions', function() {
serviceDefaultCacheLengthSeconds: 900,
queryParams: { maxAge: 1000 },
}).expect(1000)
given({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds: 900,
queryParams: { maxAge: 1000, other: 'here', maybe: 'bogus' },
}).expect(1000)
given({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds: 900,

View File

@@ -0,0 +1,132 @@
'use strict'
const { URL } = require('url')
const Joi = require('joi')
const { errorMessages } = require('../dynamic/dynamic-helpers')
const BaseJsonService = require('../base-json')
const { InvalidParameter } = require('../errors')
const { optionalUrl } = require('../validators')
const blockedDomains = ['github.com', 'shields.io']
const queryParamSchema = Joi.object({
url: optionalUrl.required(),
}).required()
const anySchema = Joi.any()
const optionalStringWhenNamedLogoPresent = Joi.alternatives().when(
'namedLogo',
{
is: Joi.string().required(),
then: Joi.string(),
}
)
const optionalNumberWhenAnyLogoPresent = Joi.alternatives()
.when('namedLogo', { is: Joi.string().required(), then: Joi.number() })
.when('logoSvg', { is: Joi.string().required(), then: Joi.number() })
const endpointSchema = Joi.object({
schemaVersion: 1,
label: Joi.string()
.allow('')
.required(),
message: Joi.string().required(),
color: Joi.string(),
labelColor: Joi.string(),
isError: Joi.boolean().default(false),
namedLogo: Joi.string(),
logoSvg: Joi.string(),
logoColor: optionalStringWhenNamedLogoPresent,
logoWidth: optionalNumberWhenAnyLogoPresent,
logoPosition: optionalNumberWhenAnyLogoPresent,
style: Joi.string(),
cacheSeconds: Joi.number()
.integer()
.min(0),
})
// `namedLogo` or `logoSvg`; not both.
.oxor('namedLogo', 'logoSvg')
.required()
module.exports = class Endpoint extends BaseJsonService {
static get category() {
return 'dynamic'
}
static get route() {
return {
base: 'badge/endpoint',
pattern: '',
queryParams: ['url'],
}
}
static get _cacheLength() {
return 300
}
static get defaultBadgeData() {
return {
label: 'custom badge',
}
}
static render({
label,
message,
color,
labelColor,
namedLogo,
logoSvg,
logoColor,
logoWidth,
logoPosition,
style,
isError,
cacheSeconds,
}) {
return {
isError,
label,
message,
color,
labelColor,
namedLogo,
logoSvg,
logoColor,
logoWidth,
logoPosition,
style,
cacheSeconds,
}
}
async handle(namedParams, queryParams) {
const { url } = this.constructor._validateQueryParams(
queryParams,
queryParamSchema
)
const { protocol, hostname } = new URL(url)
if (protocol !== 'https:') {
throw new InvalidParameter({ prettyMessage: 'please use https' })
}
if (blockedDomains.some(domain => hostname.endsWith(domain))) {
throw new InvalidParameter({ prettyMessage: 'domain is blocked' })
}
const json = await this._requestJson({
schema: anySchema,
url,
errorMessages,
})
// Override the validation options because we want to reject unknown keys.
const validated = this.constructor._validate(json, endpointSchema, {
allowAndStripUnknownKeys: false,
})
return this.constructor.render(validated)
}
}

View File

@@ -0,0 +1,257 @@
'use strict'
const { expect } = require('chai')
const { getShieldsIcon } = require('../../lib/logos')
const t = (module.exports = require('../create-service-tester')())
t.create('Valid schema (mocked)')
.get('.json?url=https://example.com/badge')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
label: '',
message: 'yo',
})
)
.expectJSON({ name: '', value: 'yo' })
t.create('color and labelColor')
.get('.json?url=https://example.com/badge&style=_shields_test')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
label: 'hey',
message: 'yo',
color: '#f0dcc3',
labelColor: '#e6e6fa',
})
)
.expectJSON({
name: 'hey',
value: 'yo',
color: '#f0dcc3',
labelColor: '#e6e6fa',
})
t.create('style')
.get('.json?url=https://example.com/badge')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
label: 'hey',
message: 'yo',
color: '#99c',
style: '_shields_test',
})
)
.expectJSON({
name: 'hey',
value: 'yo',
// `color` is only in _shields_test which is being specified by the
// service, not the request. If the color key is here we know this has
// worked.
color: '#99c',
})
t.create('named logo')
.get('.svg?url=https://example.com/badge')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
label: 'hey',
message: 'yo',
namedLogo: 'github',
})
)
.after((err, res, body) => {
expect(err).not.to.be.ok
expect(body).to.include(getShieldsIcon({ name: 'github' }))
})
t.create('named logo with color')
.get('.svg?url=https://example.com/badge')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
label: 'hey',
message: 'yo',
namedLogo: 'github',
logoColor: 'blue',
})
)
.after((err, res, body) => {
expect(err).not.to.be.ok
expect(body).to.include(getShieldsIcon({ name: 'github', color: 'blue' }))
})
const logoSvg = Buffer.from(
getShieldsIcon({ name: 'github' }).replace('data:image/svg+xml;base64,', ''),
'base64'
).toString('ascii')
t.create('custom svg logo')
.get('.svg?url=https://example.com/badge')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
label: 'hey',
message: 'yo',
logoSvg,
})
)
.after((err, res, body) => {
expect(err).not.to.be.ok
expect(body).to.include(getShieldsIcon({ name: 'github' }))
})
t.create('logoWidth')
.get('.json?url=https://example.com/badge&style=_shields_test')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
label: 'hey',
message: 'yo',
logoSvg,
logoWidth: 30,
})
)
.expectJSON({
name: 'hey',
value: 'yo',
color: 'lightgrey',
logoWidth: 30,
})
t.create('Invalid schema (mocked)')
.get('.json?url=https://example.com/badge')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: -1,
})
)
.expectJSON({ name: 'custom badge', value: 'invalid response data' })
t.create('Invalid schema (mocked)')
.get('.json?url=https://example.com/badge')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
label: 'hey',
message: 'yo',
extra: 'keys',
bogus: true,
})
)
.expectJSON({ name: 'custom badge', value: 'invalid response data' })
t.create('User color overrides success color')
.get('.json?url=https://example.com/badge&colorB=101010&style=_shields_test')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
label: '',
message: 'yo',
color: 'blue',
})
)
.expectJSON({ name: '', value: 'yo', color: '#101010' })
t.create('User color does not override error color')
.get('.json?url=https://example.com/badge&colorB=101010&style=_shields_test')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
isError: true,
label: 'something is',
message: 'not right',
color: 'red',
})
)
.expectJSON({ name: 'something is', value: 'not right', color: 'red' })
t.create('cacheSeconds')
.get('.json?url=https://example.com/badge')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
label: '',
message: 'yo',
cacheSeconds: 500,
})
)
.expectHeader('cache-control', 'max-age=500')
t.create('user can override service cacheSeconds')
.get('.json?url=https://example.com/badge&maxAge=1000')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
label: '',
message: 'yo',
cacheSeconds: 500,
})
)
.expectHeader('cache-control', 'max-age=1000')
t.create('user does not override longer service cacheSeconds')
.get('.json?url=https://example.com/badge&maxAge=450')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
label: '',
message: 'yo',
cacheSeconds: 500,
})
)
.expectHeader('cache-control', 'max-age=500')
t.create('cacheSeconds does not override longer Shields default')
.get('.json?url=https://example.com/badge')
.intercept(nock =>
nock('https://example.com/')
.get('/badge')
.reply(200, {
schemaVersion: 1,
label: '',
message: 'yo',
cacheSeconds: 10,
})
)
.expectHeader('cache-control', 'max-age=300')
t.create('Bad scheme')
.get('.json?url=http://example.com/badge')
.expectJSON({ name: 'custom badge', value: 'please use https' })
t.create('Blocked domain')
.get('.json?url=https://img.shields.io/badge/foo-bar-blue.json')
.expectJSON({ name: 'custom badge', value: 'domain is blocked' })