* you missed one * remove examples from deprecatedService() I'm not going to replace this with openApi We have zero examples of deprecated services that declare examples * remove examples from redirector() * update test * remove compatibility code for converting examples to openApi * remove all the code for handling examples * remove a few bits of redundant code * improve docs for openApi property * last one, I promise
592 lines
17 KiB
JavaScript
592 lines
17 KiB
JavaScript
/**
|
|
* @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,
|
|
logoPosition: 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
|