363 lines
10 KiB
JavaScript
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
|