Files
shields/services/base.js
2018-10-03 19:08:05 +01:00

363 lines
10 KiB
JavaScript

'use strict'
// See available emoji at http://emoji.muan.co/
const emojic = require('emojic')
const Joi = require('joi')
const {
NotFound,
InvalidResponse,
Inaccessible,
InvalidParameter,
Deprecated,
} = require('./errors')
const { checkErrorResponse } = require('../lib/error-helper')
const queryString = require('query-string')
const {
makeLogo,
toArray,
makeColor,
setBadgeColor,
} = require('../lib/badge-data')
const trace = require('./trace')
class BaseService {
constructor({ sendAndCacheRequest }, { handleInternalErrors }) {
this._sendAndCacheRequest = sendAndCacheRequest
this._handleInternalErrors = handleInternalErrors
}
static render(props) {
throw new Error(`render() function not implemented for ${this.name}`)
}
/**
* Asynchronous function to handle requests for this service. Takes the URL
* parameters (as defined in the `url` property), performs a request using
* `this._sendAndCacheRequest`, and returns the badge data.
*/
async handle(namedParams, queryParams) {
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:
* - base: (Optional) The base path of the URLs for this service. This is
* used as a prefix.
* - format: Regular expression to use for URLs 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.
* - queryParams: Array of names for query parameters which will the service
* uses. For cache safety, only the whitelisted query
* parameters will be passed to the handler.
*/
static get url() {
throw new Error(`URL 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 URLs for this service. These should use the format
* specified in `url`, and can be used to demonstrate how to use badges for
* this service.
*/
static get examples() {
return []
}
static _makeFullUrl(partialUrl) {
return '/' + [this.url.base, partialUrl].filter(Boolean).join('/')
}
static _makeStaticExampleUrl(serviceData) {
const badgeData = this._makeBadgeData({}, serviceData)
const color = badgeData.colorscheme || badgeData.colorB
return this._makeStaticExampleUrlFromTextAndColor(
badgeData.text[0],
badgeData.text[1],
color
)
}
static _makeStaticExampleUrlFromTextAndColor(text1, text2, color) {
return `/badge/${encodeURIComponent(
text1.replace('-', '--')
)}-${encodeURIComponent(text2).replace('-', '--')}-${encodeURIComponent(
color
)}`
}
/**
* Return an array of examples. Each example is prepared according to the
* schema in `lib/all-badge-examples.js`.
*/
static prepareExamples() {
return this.examples.map(
({
title,
query,
exampleUrl,
previewUrl,
urlPattern,
staticExample,
documentation,
keywords,
}) => {
if (!previewUrl && !staticExample) {
throw Error(
`Example for ${
this.name
} is missing required previewUrl or staticExample`
)
}
if (staticExample && !urlPattern) {
throw new Error('Must declare a urlPattern if using staticExample')
}
if (staticExample && !exampleUrl) {
throw new Error('Must declare an exampleUrl if using staticExample')
}
const stringified = queryString.stringify(query)
const suffix = stringified ? `?${stringified}` : ''
return {
title: title ? `${title}` : this.name,
exampleUrl: exampleUrl
? `${this._makeFullUrl(exampleUrl, query)}.svg${suffix}`
: undefined,
previewUrl: staticExample
? `${this._makeStaticExampleUrl(staticExample)}.svg`
: `${this._makeFullUrl(previewUrl, query)}.svg${suffix}`,
urlPattern: urlPattern
? `${this._makeFullUrl(urlPattern, query)}.svg${suffix}`
: undefined,
documentation,
keywords,
}
}
)
}
static get _regex() {
// Regular expressions treat "/" specially, so we need to escape them
const escapedPath = this.url.format.replace(/\//g, '\\/')
const fullRegex = `^${this._makeFullUrl(
escapedPath
)}.(svg|png|gif|jpg|json)$`
return new RegExp(fullRegex)
}
static _namedParamsForMatch(match) {
const names = this.url.capture || []
// Assume the last match is the format, and drop match[0], which is the
// entire match.
const captures = match.slice(1, -1)
if (names.length !== captures.length) {
throw new Error(
`Service ${this.name} declares incorrect number of capture groups ` +
`(expected ${names.length}, got ${captures.length})`
)
}
const result = {}
names.forEach((name, index) => {
result[name] = captures[index]
})
return result
}
async invokeHandler(namedParams, queryParams) {
trace.logTrace(
'inbound',
emojic.womanCook,
'Service class',
this.constructor.name
)
trace.logTrace('inbound', emojic.ticket, 'Named params', namedParams)
trace.logTrace('inbound', emojic.crayon, 'Query params', queryParams)
try {
return await this.handle(namedParams, queryParams)
} catch (error) {
if (error instanceof NotFound || error instanceof InvalidParameter) {
trace.logTrace('outbound', emojic.noGoodWoman, 'Handled error', error)
return {
message: error.prettyMessage,
color: 'red',
}
} else if (
error instanceof InvalidResponse ||
error instanceof Inaccessible ||
error instanceof Deprecated
) {
trace.logTrace('outbound', emojic.noGoodWoman, 'Handled error', error)
return {
message: error.prettyMessage,
color: 'lightgray',
}
} else if (this._handleInternalErrors) {
if (
!trace.logTrace(
'unhandledError',
emojic.boom,
'Unhandled internal error',
error
)
) {
// This is where we end up if an unhandled exception is thrown in
// production. Send the error to the logs.
console.log(error)
}
return {
label: 'shields',
message: 'internal error',
color: 'lightgray',
}
} else {
trace.logTrace(
'unhandledError',
emojic.boom,
'Unhandled internal error',
error
)
throw error
}
}
}
static _makeBadgeData(overrides, serviceData) {
const {
style,
label: overrideLabel,
logo: overrideLogo,
logoColor: overrideLogoColor,
logoWidth: overrideLogoWidth,
link: overrideLink,
colorA: overrideColorA,
colorB: overrideColorB,
} = overrides
const {
label: serviceLabel,
message: serviceMessage,
color: serviceColor,
link: serviceLink,
} = serviceData
const {
color: defaultColor,
logo: defaultLogo,
label: defaultLabel,
} = this.defaultBadgeData
const badgeData = {
text: [
overrideLabel || serviceLabel || defaultLabel || this.category,
serviceMessage || 'n/a',
],
template: style,
logo: makeLogo(style === 'social' ? defaultLogo : undefined, {
logo: overrideLogo,
logoColor: overrideLogoColor,
}),
logoWidth: +overrideLogoWidth,
links: toArray(overrideLink || serviceLink),
colorA: makeColor(overrideColorA),
}
const color = overrideColorB || serviceColor || defaultColor || 'lightgrey'
setBadgeColor(badgeData, color)
return badgeData
}
static register({ camp, handleRequest, githubApiProvider }, serviceConfig) {
const ServiceClass = this // In a static context, "this" is the class.
camp.route(
this._regex,
handleRequest({
queryParams: this.url.queryParams,
handler: async (queryParams, match, sendBadge, request) => {
const namedParams = this._namedParamsForMatch(match)
const serviceInstance = new ServiceClass(
{
sendAndCacheRequest: request.asPromise,
},
serviceConfig
)
const serviceData = await serviceInstance.invokeHandler(
namedParams,
queryParams
)
trace.logTrace('outbound', emojic.shield, 'Service data', serviceData)
const badgeData = this._makeBadgeData(queryParams, serviceData)
// Assumes the final capture group is the extension
const format = match.slice(-1)[0]
sendBadge(format, badgeData)
},
})
)
}
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
}
}
async _request({ url, options = {}, errorMessages = {} }) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
logTrace(emojic.bowAndArrow, 'Request', url, '\n', options)
const { res, buffer } = await this._sendAndCacheRequest(url, options)
logTrace(emojic.dart, 'Response status code', res.statusCode)
return checkErrorResponse.asPromise(errorMessages)({ buffer, res })
}
}
module.exports = BaseService