[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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -12,7 +12,9 @@ const queryParamSchema = Joi.object({
|
||||
maxAge: Joi.number()
|
||||
.integer()
|
||||
.min(0),
|
||||
}).required()
|
||||
})
|
||||
.unknown(true)
|
||||
.required()
|
||||
|
||||
function overrideCacheLengthFromQueryParams(queryParams) {
|
||||
try {
|
||||
|
||||
@@ -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,
|
||||
|
||||
132
services/endpoint/endpoint.service.js
Normal file
132
services/endpoint/endpoint.service.js
Normal 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)
|
||||
}
|
||||
}
|
||||
257
services/endpoint/endpoint.tester.js
Normal file
257
services/endpoint/endpoint.tester.js
Normal 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' })
|
||||
Reference in New Issue
Block a user