/**
* @module
*/
// See available emoji at http://emoji.muan.co/
import emojic from 'emojic'
import Joi from 'joi'
import log from '../server/log.js'
import { AuthHelper } from './auth-helper.js'
import { MetricHelper, MetricNames } from './metric-helper.js'
import { assertValidCategory } from './categories.js'
import checkErrorResponse from './check-error-response.js'
import coalesceBadge from './coalesce-badge.js'
import {
NotFound,
InvalidResponse,
Inaccessible,
ImproperlyConfigured,
InvalidParameter,
Deprecated,
} from './errors.js'
import { fetch } from './got.js'
import { getEnum } from './openapi.js'
import {
makeFullUrl,
assertValidRoute,
prepareRoute,
namedParamsForMatch,
getQueryParamNames,
} from './route.js'
import { assertValidServiceDefinition } from './service-definitions.js'
import trace from './trace.js'
import validate from './validate.js'
const defaultBadgeDataSchema = Joi.object({
label: Joi.string(),
color: Joi.string(),
labelColor: Joi.string(),
namedLogo: Joi.string(),
}).required()
const optionalStringWhenNamedLogoPresent = Joi.alternatives().conditional(
'namedLogo',
{
is: Joi.string().required(),
then: Joi.string(),
},
)
const optionalNumberWhenAnyLogoPresent = Joi.alternatives()
.conditional('namedLogo', { is: Joi.string().required(), then: Joi.number() })
.conditional('logoSvg', { is: Joi.string().required(), then: Joi.number() })
const serviceDataSchema = Joi.object({
isError: Joi.boolean(),
label: Joi.string().allow(''),
// While a number of badges pass a number here, in the long run we may want
// `render()` to always return a string.
message: Joi.alternatives(Joi.string().allow(''), Joi.number()).required(),
color: Joi.string(),
link: Joi.array().items(Joi.string().uri()).single().max(2),
// Generally services should not use these options, which are provided to
// support the Endpoint badge.
labelColor: Joi.string(),
namedLogo: Joi.string(),
logoSvg: Joi.string(),
logoColor: optionalStringWhenNamedLogoPresent,
logoWidth: optionalNumberWhenAnyLogoPresent,
cacheSeconds: Joi.number().integer().min(0),
style: Joi.string(),
})
.oxor('namedLogo', 'logoSvg')
.required()
/**
* Abstract base class which all service classes inherit from.
* Concrete implementations of BaseService must implement the methods
* category(), route() and handle(namedParams, queryParams)
*/
class BaseService {
/**
* Name of the category to sort this badge into (eg. "build"). Used to sort
* the badges on the main shields.io website.
*
* @abstract
* @type {string}
*/
static get category() {
throw new Error(`Category not set for ${this.name}`)
}
static isDeprecated = false
/**
* Route to mount this service on
*
* @abstract
* @type {module:core/base-service/base~Route}
*/
static get route() {
throw new Error(`Route not defined for ${this.name}`)
}
/**
* Extract an array of allowed values from this service's route pattern
* for a given route parameter
*
* @param {string} param The name of a param in this service's route pattern
* @returns {string[]} Array of allowed values for this param
*/
static getEnum(param) {
if (!('pattern' in this.route)) {
throw new Error('getEnum() requires route to have a .pattern property')
}
const enumeration = getEnum(this.route.pattern, param)
if (!Array.isArray(enumeration)) {
throw new Error(
`Could not extract enum for param ${param} from pattern ${this.route.pattern}`,
)
}
return enumeration
}
/**
* Configuration for the authentication helper that prepares credentials
* for upstream requests.
*
* See also the config schema in `./server.js` and `doc/server-secrets.md`.
*
* To use the configured auth in the handler or fetch method, wrap the
* _request() input params in a call to one of:
* - this.authHelper.withBasicAuth()
* - this.authHelper.withBearerAuthHeader()
* - this.authHelper.withQueryStringAuth()
*
* For example:
* this._request(this.authHelper.withBasicAuth({ url, schema, options }))
*
* @abstract
* @type {module:core/base-service/base~Auth}
*/
static auth = undefined
/**
* An OpenAPI Paths Object describing this service's
* route or routes in OpenAPI format.
*
* @abstract
* @see https://swagger.io/specification/#paths-object
* @type {module:core/base-service/service-definitions~openApiSchema}
*/
static openApi = {}
static get _cacheLength() {
const cacheLengths = {
build: 30,
license: 3600,
version: 300,
debug: 60,
downloads: 900,
rating: 900,
social: 900,
}
return cacheLengths[this.category]
}
/**
* Default data for the badge.
* These defaults are used if the value is neither included in the service data
* from the handler nor overridden by the user via query parameters.
*
* @type {module:core/base-service/base~DefaultBadgeData}
*/
static defaultBadgeData = {}
static render(props) {
throw new Error(`render() function not implemented for ${this.name}`)
}
static validateDefinition() {
assertValidCategory(this.category, `Category for ${this.name}`)
assertValidRoute(this.route, `Route for ${this.name}`)
Joi.assert(
this.defaultBadgeData,
defaultBadgeDataSchema,
`Default badge data for ${this.name}`,
)
// ensure openApi spec matches route
const preparedRoute = prepareRoute(this.route)
for (const [key, value] of Object.entries(this.openApi)) {
let example = key
for (const param of value.get.parameters) {
example = example.replace(`{${param.name}}`, param.example)
}
if (!example.match(preparedRoute.regex)) {
throw new Error(
`Inconsistent Open Api spec and Route found for service ${this.name}`,
)
}
}
}
static getDefinition() {
const { category, name, isDeprecated, openApi } = this
const { base, format, pattern } = this.route
const queryParams = getQueryParamNames(this.route)
let route
if (pattern) {
route = { pattern: makeFullUrl(base, pattern), queryParams }
} else if (format) {
route = { format, queryParams }
} else {
route = undefined
}
const result = { category, name, isDeprecated, route, openApi }
assertValidServiceDefinition(result, `getDefinition() for ${this.name}`)
return result
}
constructor(
{ requestFetcher, authHelper, metricHelper },
{ handleInternalErrors },
) {
this._requestFetcher = requestFetcher
this.authHelper = authHelper
this._handleInternalErrors = handleInternalErrors
this._metricHelper = metricHelper
}
async _request({
url,
options = {},
httpErrors = {},
systemErrors = {},
logErrors = [429],
}) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
let logUrl = url
const logOptions = Object.assign({}, options)
if ('searchParams' in options && options.searchParams != null) {
const params = new URLSearchParams(
Object.fromEntries(
Object.entries(options.searchParams).filter(
([k, v]) => v !== undefined,
),
),
)
logUrl = `${url}?${params.toString()}`
delete logOptions.searchParams
}
logTrace(
emojic.bowAndArrow,
'Request',
`${logUrl}\n${JSON.stringify(logOptions, null, 2)}`,
)
const { res, buffer } = await this._requestFetcher(
url,
options,
systemErrors,
)
await this._meterResponse(res, buffer)
logTrace(emojic.dart, 'Response status code', res.statusCode)
return checkErrorResponse(httpErrors, logErrors)({ buffer, res })
}
static enabledMetrics = []
static isMetricEnabled(metricName) {
return this.enabledMetrics.includes(metricName)
}
async _meterResponse(res, buffer) {
if (
this._metricHelper &&
this.constructor.isMetricEnabled(MetricNames.SERVICE_RESPONSE_SIZE) &&
res.statusCode === 200
) {
this._metricHelper.noteServiceResponseSize(buffer.length)
}
}
static _validate(
data,
schema,
{
prettyErrorMessage = 'invalid response data',
includeKeys = false,
allowAndStripUnknownKeys = true,
} = {},
) {
return validate(
{
ErrorClass: InvalidResponse,
prettyErrorMessage,
includeKeys,
traceErrorMessage: 'Response did not match schema',
traceSuccessMessage: 'Response after validation',
allowAndStripUnknownKeys,
},
data,
schema,
)
}
/**
* Asynchronous function to handle requests for this service. Take the route
* parameters (as defined in the `route` property), perform a request using
* `this._requestFetcher`, and return the badge data.
*
* @abstract
* @param {object} namedParams Params parsed from route pattern
* defined in this.route.pattern or this.route.capture
* @param {object} queryParams Params parsed from the query string
* @returns {module:core/base-service/base~Badge}
* badge Object validated against serviceDataSchema
*/
async handle(namedParams, queryParams) {
throw new Error(`Handler not implemented for ${this.constructor.name}`)
}
// Making this an instance method ensures debuggability.
// https://github.com/badges/shields/issues/3784
_validateServiceData(serviceData) {
Joi.assert(serviceData, serviceDataSchema)
}
_handleError(error) {
if (error instanceof NotFound || error instanceof InvalidParameter) {
trace.logTrace('outbound', emojic.noGoodWoman, 'Handled error', error)
return {
isError: true,
message: error.prettyMessage,
color: 'red',
}
} else if (
error instanceof ImproperlyConfigured ||
error instanceof InvalidResponse ||
error instanceof Inaccessible ||
error instanceof Deprecated
) {
trace.logTrace('outbound', emojic.noGoodWoman, 'Handled error', error)
const serviceData = {
isError: true,
message: error.prettyMessage,
color: 'lightgray',
}
if (error.cacheSeconds !== undefined) {
serviceData.cacheSeconds = error.cacheSeconds
}
return serviceData
} 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 Sentry and the logs.
log.error(error)
}
return {
isError: true,
label: 'shields',
message: 'internal error',
color: 'lightgray',
}
} else {
trace.logTrace(
'unhandledError',
emojic.boom,
'Unhandled internal error',
error,
)
throw error
}
}
static async invoke(
context = {},
config = {},
namedParams = {},
queryParams = {},
) {
trace.logTrace('inbound', emojic.womanCook, 'Service class', this.name)
trace.logTrace('inbound', emojic.ticket, 'Named params', namedParams)
trace.logTrace('inbound', emojic.crayon, 'Query params', queryParams)
// Like the service instance, the auth helper could be reused for each request.
// However, moving its instantiation to `register()` makes `invoke()` harder
// to test.
const authHelper = this.auth ? new AuthHelper(this.auth, config) : undefined
const serviceInstance = new this({ ...context, authHelper }, config)
let serviceError
if (authHelper && !authHelper.isValid) {
const prettyMessage = authHelper.isRequired
? 'credentials have not been configured'
: 'credentials are misconfigured'
serviceError = new ImproperlyConfigured({ prettyMessage })
}
const { queryParamSchema } = this.route
let transformedQueryParams
if (!serviceError && queryParamSchema) {
try {
transformedQueryParams = validate(
{
ErrorClass: InvalidParameter,
prettyErrorMessage: 'invalid query parameter',
includeKeys: true,
traceErrorMessage: 'Query params did not match schema',
traceSuccessMessage: 'Query params after validation',
},
queryParams,
queryParamSchema,
)
trace.logTrace(
'inbound',
emojic.crayon,
'Query params after validation',
queryParams,
)
} catch (error) {
serviceError = error
}
} else {
transformedQueryParams = {}
}
let serviceData
if (!serviceError) {
try {
serviceData = await serviceInstance.handle(
namedParams,
transformedQueryParams,
)
serviceInstance._validateServiceData(serviceData)
} catch (error) {
serviceError = error
}
}
if (serviceError) {
serviceData = serviceInstance._handleError(serviceError)
}
trace.logTrace('outbound', emojic.shield, 'Service data', serviceData)
return serviceData
}
static register(
{
camp,
handleRequest,
githubApiProvider,
librariesIoApiProvider,
metricInstance,
},
serviceConfig,
) {
const { cacheHeaders: cacheHeaderConfig } = serviceConfig
const { regex, captureNames } = prepareRoute(this.route)
const queryParams = getQueryParamNames(this.route)
const metricHelper = MetricHelper.create({
metricInstance,
ServiceClass: this,
})
camp.route(
regex,
handleRequest(cacheHeaderConfig, {
queryParams,
handler: async (queryParams, match, sendBadge) => {
const metricHandle = metricHelper.startRequest()
const namedParams = namedParamsForMatch(captureNames, match, this)
const serviceData = await this.invoke(
{
requestFetcher: fetch,
githubApiProvider,
librariesIoApiProvider,
metricHelper,
},
serviceConfig,
namedParams,
queryParams,
)
const badgeData = coalesceBadge(
queryParams,
serviceData,
this.defaultBadgeData,
this,
)
// The final capture group is the extension.
const format = (match.slice(-1)[0] || '.svg').replace(/^\./, '')
sendBadge(format, badgeData)
metricHandle.noteResponseSent()
},
cacheLength: this._cacheLength,
}),
)
}
}
/**
* Default badge properties, validated against defaultBadgeDataSchema
*
* @typedef {object} DefaultBadgeData
* @property {string} label (Optional)
* @property {string} color (Optional)
* @property {string} labelColor (Optional)
* @property {string} namedLogo (Optional)
*/
/**
* Badge Object, validated against serviceDataSchema
*
* @typedef {object} Badge
* @property {boolean} isError (Optional)
* @property {string} label (Optional)
* @property {(string|number)} message
* @property {string} color (Optional)
* @property {string[]} link (Optional)
*/
/**
* @typedef {object} Route
* @property {string} base
* (Optional) The base path of the routes for this service.
* This is used as a prefix.
* @property {string} pattern
* A path-to-regexp pattern defining the route pattern and param names
* See {@link https://www.npmjs.com/package/path-to-regexp}
* @property {RegExp} format
* Deprecated: Regular expression to use for routes for this service's badges
* Use `pattern` instead
* @property {string[]} capture
* Deprecated: Array of names for the capture groups in the regular
* expression. The handler will be passed an object containing
* the matches.
* Use `pattern` instead
* @property {Joi.object} queryParamSchema
* (Optional) A Joi schema (`Joi.object({ ... }).required()`)
* for the query param object. If you know a parameter
* will never receive a numeric string, you can use
* `Joi.string()`. Because of quirks in Scoutcamp and Joi,
* alphanumeric strings should be declared using
* `Joi.alternatives().try(Joi.string(), Joi.number())`,
* otherwise a value like `?success_color=999` will fail.
* A parameter requiring a numeric string can use
* `Joi.number()`. A parameter that receives only non-numeric
* strings can use `Joi.string()`. A parameter that never
* receives numeric can use `Joi.string()`. A boolean
* parameter should use `Joi.equal('')` and will receive an
* empty string on e.g. `?compact_message` and undefined
* when the parameter is absent. In the OpenApi definitions,
* this type of param should be documented as
* queryParam({
* name: 'compact_message', schema: { type: 'boolean' }, example: null
* })
*/
/**
* @typedef {object} Auth
* @property {string} userKey
* (Optional) The key from `privateConfig` to use as the username.
* @property {string} passKey
* (Optional) The key from `privateConfig` to use as the password.
* If auth is configured, either `userKey` or `passKey` is required.
* @property {string} isRequired
* (Optional) If `true`, the service will return `NotFound` unless the
* configured credentials are present.
*/
export default BaseService