Reorganize BaseService-related modules (#2831)

Ref #2698
This commit is contained in:
Paul Melnikow
2019-01-22 23:52:13 -05:00
committed by GitHub
parent bbc10c5a68
commit fc12b591db
50 changed files with 71 additions and 71 deletions

View File

@@ -0,0 +1,43 @@
'use strict'
// See available emoji at http://emoji.muan.co/
const emojic = require('emojic')
const BaseService = require('./base')
const trace = require('./trace')
const { InvalidResponse } = require('./errors')
class BaseJsonService extends BaseService {
_parseJson(buffer) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
let json
try {
json = JSON.parse(buffer)
} catch (err) {
logTrace(emojic.dart, 'Response JSON (unparseable)', buffer)
throw new InvalidResponse({
prettyMessage: 'unparseable json response',
underlyingError: err,
})
}
logTrace(emojic.dart, 'Response JSON (before validation)', json, {
deep: true,
})
return json
}
async _requestJson({ schema, url, options = {}, errorMessages = {} }) {
const mergedOptions = {
...{ headers: { Accept: 'application/json' } },
...options,
}
const { buffer } = await this._request({
url,
options: mergedOptions,
errorMessages,
})
const json = this._parseJson(buffer)
return this.constructor._validate(json, schema)
}
}
module.exports = BaseJsonService

View File

@@ -0,0 +1,135 @@
'use strict'
const Joi = require('joi')
const { expect } = require('chai')
const sinon = require('sinon')
const BaseJsonService = require('./base-json')
const dummySchema = Joi.object({
requiredString: Joi.string().required(),
}).required()
class DummyJsonService extends BaseJsonService {
static get category() {
return 'cat'
}
static get route() {
return {
base: 'foo',
}
}
async handle() {
const { requiredString } = await this._requestJson({
schema: dummySchema,
url: 'http://example.com/foo.json',
})
return { message: requiredString }
}
}
describe('BaseJsonService', function() {
describe('Making requests', function() {
let sendAndCacheRequest
beforeEach(function() {
sendAndCacheRequest = sinon.stub().returns(
Promise.resolve({
buffer: '{"some": "json"}',
res: { statusCode: 200 },
})
)
})
it('invokes _sendAndCacheRequest', async function() {
await DummyJsonService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
expect(sendAndCacheRequest).to.have.been.calledOnceWith(
'http://example.com/foo.json',
{ headers: { Accept: 'application/json' } }
)
})
it('forwards options to _sendAndCacheRequest', async function() {
class WithOptions extends DummyJsonService {
async handle() {
const { value } = await this._requestJson({
schema: dummySchema,
url: 'http://example.com/foo.json',
options: { method: 'POST', qs: { queryParam: 123 } },
})
return { message: value }
}
}
await WithOptions.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
expect(sendAndCacheRequest).to.have.been.calledOnceWith(
'http://example.com/foo.json',
{
headers: { Accept: 'application/json' },
method: 'POST',
qs: { queryParam: 123 },
}
)
})
})
describe('Making badges', function() {
it('handles valid json responses', async function() {
const sendAndCacheRequest = async () => ({
buffer: '{"requiredString": "some-string"}',
res: { statusCode: 200 },
})
expect(
await DummyJsonService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
message: 'some-string',
})
})
it('handles json responses which do not match the schema', async function() {
const sendAndCacheRequest = async () => ({
buffer: '{"unexpectedKey": "some-string"}',
res: { statusCode: 200 },
})
expect(
await DummyJsonService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'invalid response data',
})
})
it('handles unparseable json responses', async function() {
const sendAndCacheRequest = async () => ({
buffer: 'not json',
res: { statusCode: 200 },
})
expect(
await DummyJsonService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'unparseable json response',
})
})
})
})

View File

@@ -0,0 +1,55 @@
'use strict'
const makeBadge = require('../../gh-badges/lib/make-badge')
const BaseService = require('./base')
const { setCacheHeaders } = require('./cache-headers')
const { makeSend } = require('./legacy-result-sender')
// Badges are subject to two independent types of caching: in-memory and
// downstream.
//
// Services deriving from `NonMemoryCachingBaseService` are not cached in
// memory on the server. This means that each request that hits the server
// triggers another call to the handler. When using badges for server
// diagnostics, that's useful!
//
// In contrast, The `handle()` function of most other `BaseService`
// subclasses is wrapped in onboard, in-memory caching. See `lib /request-
// handler.js` and `BaseService.prototype.register()`.
//
// All services, including those extending NonMemoryCachingBaseServices, may
// be cached _downstream_. This is governed by cache headers, which are
// configured by the service, the user's request, and the server's default
// cache length.
module.exports = class NonMemoryCachingBaseService extends BaseService {
static register({ camp }, serviceConfig) {
const { cacheHeaders: cacheHeaderConfig } = serviceConfig
const { _cacheLength: serviceDefaultCacheLengthSeconds } = this
camp.route(this._regex, async (queryParams, match, end, ask) => {
const namedParams = this._namedParamsForMatch(match)
const serviceData = await this.invoke(
{},
serviceConfig,
namedParams,
queryParams
)
const badgeData = this._makeBadgeData(queryParams, serviceData)
// The final capture group is the extension.
const format = match.slice(-1)[0]
badgeData.format = format
const svg = makeBadge(badgeData)
setCacheHeaders({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds,
queryParams,
res: ask.res,
})
makeSend(format, ask.res, end)(svg)
})
}
}

View File

@@ -0,0 +1,54 @@
'use strict'
const makeBadge = require('../../gh-badges/lib/make-badge')
const analytics = require('../server/analytics')
const BaseService = require('./base')
const {
serverHasBeenUpSinceResourceCached,
setCacheHeadersForStaticResource,
} = require('./cache-headers')
const { makeSend } = require('./legacy-result-sender')
module.exports = class BaseStaticService extends BaseService {
static register({ camp }, serviceConfig) {
const {
profiling: { makeBadge: shouldProfileMakeBadge },
} = serviceConfig
camp.route(this._regex, async (queryParams, match, end, ask) => {
analytics.noteRequest(queryParams, match)
if (serverHasBeenUpSinceResourceCached(ask.req)) {
// Send Not Modified.
ask.res.statusCode = 304
ask.res.end()
return
}
const namedParams = this._namedParamsForMatch(match)
const serviceData = await this.invoke(
{},
serviceConfig,
namedParams,
queryParams
)
const badgeData = this._makeBadgeData(queryParams, serviceData)
// The final capture group is the extension.
const format = match.slice(-1)[0]
badgeData.format = format
if (shouldProfileMakeBadge) {
console.time('makeBadge total')
}
const svg = makeBadge(badgeData)
if (shouldProfileMakeBadge) {
console.timeEnd('makeBadge total')
}
setCacheHeadersForStaticResource(ask.res)
makeSend(format, ask.res, end)(svg)
})
}
}

View File

@@ -0,0 +1,57 @@
'use strict'
// See available emoji at http://emoji.muan.co/
const emojic = require('emojic')
const BaseService = require('./base')
const trace = require('./trace')
const { InvalidResponse } = require('./errors')
const defaultValueMatcher = />([^<>]+)<\/text><\/g>/
const leadingWhitespace = /(?:\r\n\s*|\r\s*|\n\s*)/g
class BaseSvgScrapingService extends BaseService {
static valueFromSvgBadge(svg, valueMatcher = defaultValueMatcher) {
if (typeof svg !== 'string') {
throw TypeError('Parameter should be a string')
}
const stripped = svg.replace(leadingWhitespace, '')
const match = valueMatcher.exec(stripped)
if (match) {
return match[1]
} else {
throw new InvalidResponse({
prettyMessage: 'unparseable svg response',
underlyingError: Error(`Can't get value from SVG:\n${svg}`),
})
}
}
async _requestSvg({
schema,
valueMatcher,
url,
options = {},
errorMessages = {},
}) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
const mergedOptions = {
...{ headers: { Accept: 'image/svg+xml' } },
...options,
}
const { buffer } = await this._request({
url,
options: mergedOptions,
errorMessages,
})
logTrace(emojic.dart, 'Response SVG', buffer)
const data = {
message: this.constructor.valueFromSvgBadge(buffer, valueMatcher),
}
logTrace(emojic.dart, 'Response SVG (before validation)', data, {
deep: true,
})
return this.constructor._validate(data, schema)
}
}
module.exports = BaseSvgScrapingService

View File

@@ -0,0 +1,166 @@
'use strict'
const { expect } = require('chai')
const sinon = require('sinon')
const Joi = require('joi')
const { makeBadgeData } = require('../../lib/badge-data')
const makeBadge = require('../../gh-badges/lib/make-badge')
const BaseSvgScrapingService = require('./base-svg-scraping')
function makeExampleSvg({ label, message }) {
const badgeData = makeBadgeData('this is the label', {})
badgeData.text[1] = 'this is the result!'
return makeBadge(badgeData)
}
const schema = Joi.object({
message: Joi.string().required(),
}).required()
class DummySvgScrapingService extends BaseSvgScrapingService {
static get category() {
return 'cat'
}
static get route() {
return {
base: 'foo',
}
}
async handle() {
return this._requestSvg({
schema,
url: 'http://example.com/foo.svg',
})
}
}
describe('BaseSvgScrapingService', function() {
const exampleLabel = 'this is the label'
const exampleMessage = 'this is the result!'
const exampleSvg = makeExampleSvg({
label: exampleLabel,
message: exampleMessage,
})
describe('valueFromSvgBadge', function() {
it('should find the correct value', function() {
expect(BaseSvgScrapingService.valueFromSvgBadge(exampleSvg)).to.equal(
exampleMessage
)
})
})
describe('Making requests', function() {
let sendAndCacheRequest
beforeEach(function() {
sendAndCacheRequest = sinon.stub().returns(
Promise.resolve({
buffer: exampleSvg,
res: { statusCode: 200 },
})
)
})
it('invokes _sendAndCacheRequest with the expected header', async function() {
await DummySvgScrapingService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
expect(sendAndCacheRequest).to.have.been.calledOnceWith(
'http://example.com/foo.svg',
{ headers: { Accept: 'image/svg+xml' } }
)
})
it('forwards options to _sendAndCacheRequest', async function() {
class WithCustomOptions extends DummySvgScrapingService {
async handle() {
const { message } = await this._requestSvg({
schema,
url: 'http://example.com/foo.svg',
options: {
method: 'POST',
qs: { queryParam: 123 },
},
})
return { message }
}
}
await WithCustomOptions.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
expect(sendAndCacheRequest).to.have.been.calledOnceWith(
'http://example.com/foo.svg',
{
method: 'POST',
headers: { Accept: 'image/svg+xml' },
qs: { queryParam: 123 },
}
)
})
})
describe('Making badges', function() {
it('handles valid svg responses', async function() {
const sendAndCacheRequest = async () => ({
buffer: exampleSvg,
res: { statusCode: 200 },
})
expect(
await DummySvgScrapingService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
message: exampleMessage,
})
})
it('allows overriding the valueMatcher', async function() {
class WithValueMatcher extends BaseSvgScrapingService {
async handle() {
return this._requestSvg({
schema,
valueMatcher: />([^<>]+)<\/desc>/,
url: 'http://example.com/foo.svg',
})
}
}
const sendAndCacheRequest = async () => ({
buffer: '<desc>a different message</desc>',
res: { statusCode: 200 },
})
expect(
await WithValueMatcher.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
message: 'a different message',
})
})
it('handles unparseable svg responses', async function() {
const sendAndCacheRequest = async () => ({
buffer: 'not svg yo',
res: { statusCode: 200 },
})
expect(
await DummySvgScrapingService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'unparseable svg response',
})
})
})
})

View File

@@ -0,0 +1,43 @@
'use strict'
// See available emoji at http://emoji.muan.co/
const emojic = require('emojic')
const fastXmlParser = require('fast-xml-parser')
const BaseService = require('./base')
const trace = require('./trace')
const { InvalidResponse } = require('./errors')
class BaseXmlService extends BaseService {
async _requestXml({
schema,
url,
options = {},
errorMessages = {},
parserOptions = {},
}) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
const mergedOptions = {
...{ headers: { Accept: 'application/xml, text/xml' } },
...options,
}
const { buffer } = await this._request({
url,
options: mergedOptions,
errorMessages,
})
const validateResult = fastXmlParser.validate(buffer)
if (validateResult !== true) {
throw new InvalidResponse({
prettyMessage: 'unparseable xml response',
underlyingError: validateResult.err,
})
}
const xml = fastXmlParser.parse(buffer, parserOptions)
logTrace(emojic.dart, 'Response XML (before validation)', xml, {
deep: true,
})
return this.constructor._validate(xml, schema)
}
}
module.exports = BaseXmlService

View File

@@ -0,0 +1,162 @@
'use strict'
const Joi = require('joi')
const { expect } = require('chai')
const sinon = require('sinon')
const BaseXmlService = require('./base-xml')
const dummySchema = Joi.object({
requiredString: Joi.string().required(),
}).required()
class DummyXmlService extends BaseXmlService {
static get category() {
return 'cat'
}
static get route() {
return {
base: 'foo',
}
}
async handle() {
const { requiredString } = await this._requestXml({
schema: dummySchema,
url: 'http://example.com/foo.xml',
})
return { message: requiredString }
}
}
describe('BaseXmlService', function() {
describe('Making requests', function() {
let sendAndCacheRequest
beforeEach(function() {
sendAndCacheRequest = sinon.stub().returns(
Promise.resolve({
buffer: '<requiredString>some-string</requiredString>',
res: { statusCode: 200 },
})
)
})
it('invokes _sendAndCacheRequest', async function() {
await DummyXmlService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
expect(sendAndCacheRequest).to.have.been.calledOnceWith(
'http://example.com/foo.xml',
{ headers: { Accept: 'application/xml, text/xml' } }
)
})
it('forwards options to _sendAndCacheRequest', async function() {
class WithCustomOptions extends BaseXmlService {
async handle() {
const { requiredString } = await this._requestXml({
schema: dummySchema,
url: 'http://example.com/foo.xml',
options: { method: 'POST', qs: { queryParam: 123 } },
})
return { message: requiredString }
}
}
await WithCustomOptions.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
expect(sendAndCacheRequest).to.have.been.calledOnceWith(
'http://example.com/foo.xml',
{
headers: { Accept: 'application/xml, text/xml' },
method: 'POST',
qs: { queryParam: 123 },
}
)
})
})
describe('Making badges', function() {
it('handles valid xml responses', async function() {
const sendAndCacheRequest = async () => ({
buffer: '<requiredString>some-string</requiredString>',
res: { statusCode: 200 },
})
expect(
await DummyXmlService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
message: 'some-string',
})
})
it('parses XML response with custom parser options', async function() {
const customParserOption = { trimValues: false }
class DummyXmlServiceWithParserOption extends DummyXmlService {
async handle() {
const { requiredString } = await this._requestXml({
schema: dummySchema,
url: 'http://example.com/foo.xml',
parserOptions: customParserOption,
})
return { message: requiredString }
}
}
const sendAndCacheRequest = async () => ({
buffer:
'<requiredString>some-string with trailing whitespace </requiredString>',
res: { statusCode: 200 },
})
expect(
await DummyXmlServiceWithParserOption.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
message: 'some-string with trailing whitespace ',
})
})
it('handles xml responses which do not match the schema', async function() {
const sendAndCacheRequest = async () => ({
buffer: '<unexpectedAttribute>some-string</unexpectedAttribute>',
res: { statusCode: 200 },
})
expect(
await DummyXmlService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'invalid response data',
})
})
it('handles unparseable xml responses', async function() {
const sendAndCacheRequest = async () => ({
buffer: 'not xml',
res: { statusCode: 200 },
})
expect(
await DummyXmlService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'unparseable xml response',
})
})
})
})

View File

@@ -0,0 +1,49 @@
'use strict'
const BaseService = require('./base')
const emojic = require('emojic')
const { InvalidResponse } = require('./errors')
const trace = require('./trace')
const yaml = require('js-yaml')
class BaseYamlService extends BaseService {
async _requestYaml({
schema,
url,
options = {},
errorMessages = {},
encoding = 'utf8',
}) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
const mergedOptions = {
...{
headers: {
Accept:
'text/x-yaml, text/yaml, application/x-yaml, application/yaml, text/plain',
},
},
...options,
}
const { buffer } = await this._request({
url,
options: mergedOptions,
errorMessages,
})
let parsed
try {
parsed = yaml.safeLoad(buffer.toString(), encoding)
} catch (err) {
logTrace(emojic.dart, 'Response YAML (unparseable)', buffer)
throw new InvalidResponse({
prettyMessage: 'unparseable yaml response',
underlyingError: err,
})
}
logTrace(emojic.dart, 'Response YAML (before validation)', parsed, {
deep: true,
})
return this.constructor._validate(parsed, schema)
}
}
module.exports = BaseYamlService

View File

@@ -0,0 +1,158 @@
'use strict'
const Joi = require('joi')
const { expect } = require('chai')
const sinon = require('sinon')
const BaseYamlService = require('./base-yaml')
const dummySchema = Joi.object({
requiredString: Joi.string().required(),
}).required()
class DummyYamlService extends BaseYamlService {
static get category() {
return 'cat'
}
static get route() {
return {
base: 'foo',
}
}
async handle() {
const { requiredString } = await this._requestYaml({
schema: dummySchema,
url: 'http://example.com/foo.yaml',
})
return { message: requiredString }
}
}
const expectedYaml = `
---
requiredString: some-string
`
const unexpectedYaml = `
---
unexpectedKey: some-string
`
const invalidYaml = `
---
foo: bar
foo: baz
`
describe('BaseYamlService', function() {
describe('Making requests', function() {
let sendAndCacheRequest
beforeEach(function() {
sendAndCacheRequest = sinon.stub().returns(
Promise.resolve({
buffer: expectedYaml,
res: { statusCode: 200 },
})
)
})
it('invokes _sendAndCacheRequest', async function() {
await DummyYamlService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
expect(sendAndCacheRequest).to.have.been.calledOnceWith(
'http://example.com/foo.yaml',
{
headers: {
Accept:
'text/x-yaml, text/yaml, application/x-yaml, application/yaml, text/plain',
},
}
)
})
it('forwards options to _sendAndCacheRequest', async function() {
class WithOptions extends DummyYamlService {
async handle() {
const { requiredString } = await this._requestYaml({
schema: dummySchema,
url: 'http://example.com/foo.yaml',
options: { method: 'POST', qs: { queryParam: 123 } },
})
return { message: requiredString }
}
}
await WithOptions.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
expect(sendAndCacheRequest).to.have.been.calledOnceWith(
'http://example.com/foo.yaml',
{
headers: {
Accept:
'text/x-yaml, text/yaml, application/x-yaml, application/yaml, text/plain',
},
method: 'POST',
qs: { queryParam: 123 },
}
)
})
})
describe('Making badges', function() {
it('handles valid yaml responses', async function() {
const sendAndCacheRequest = async () => ({
buffer: expectedYaml,
res: { statusCode: 200 },
})
expect(
await DummyYamlService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
message: 'some-string',
})
})
it('handles yaml responses which do not match the schema', async function() {
const sendAndCacheRequest = async () => ({
buffer: unexpectedYaml,
res: { statusCode: 200 },
})
expect(
await DummyYamlService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'invalid response data',
})
})
it('handles unparseable yaml responses', async function() {
const sendAndCacheRequest = async () => ({
buffer: invalidYaml,
res: { statusCode: 200 },
})
expect(
await DummyYamlService.invoke(
{ sendAndCacheRequest },
{ handleInternalErrors: false }
)
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'unparseable yaml response',
})
})
})
})

554
core/base-service/base.js Normal file
View File

@@ -0,0 +1,554 @@
'use strict'
// See available emoji at http://emoji.muan.co/
const emojic = require('emojic')
const pathToRegexp = require('path-to-regexp')
const Joi = require('joi')
const { checkErrorResponse } = require('../../lib/error-helper')
const { toArray } = require('../../lib/badge-data')
const { svg2base64 } = require('../../lib/svg-helpers')
const {
decodeDataUrlFromQueryParam,
prepareNamedLogo,
} = require('../../lib/logos')
const { assertValidCategory } = require('../../services/categories')
const coalesce = require('./coalesce')
const {
NotFound,
InvalidResponse,
Inaccessible,
InvalidParameter,
Deprecated,
} = require('./errors')
const { assertValidServiceDefinition } = require('./service-definitions')
const trace = require('./trace')
const { validateExample, transformExample } = require('./transform-example')
const validate = require('./validate')
const defaultBadgeDataSchema = Joi.object({
label: Joi.string(),
color: Joi.string(),
labelColor: Joi.string(),
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(''),
// 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.string().uri(),
// 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: optionalStringWhenNamedLogoPrsent,
logoWidth: optionalNumberWhenAnyLogoPresent,
logoPosition: optionalNumberWhenAnyLogoPresent,
cacheSeconds: Joi.number()
.integer()
.min(0),
style: Joi.string(),
})
.oxor('namedLogo', 'logoSvg')
.required()
class BaseService {
constructor({ sendAndCacheRequest }, { handleInternalErrors }) {
this._requestFetcher = 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. Take the route
* parameters (as defined in the `route` property), perform a request using
* `this._sendAndCacheRequest`, and return 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() {
throw new Error(`Category not set for ${this.name}`)
}
/**
* Returns an object:
* - base: (Optional) The base path of the routes for this service. This is
* used as a prefix.
* - format: Regular expression to use for routes 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 route() {
throw new Error(`Route not defined for ${this.name}`)
}
static get isDeprecated() {
return false
}
/**
* Default data for the badge. Can include label, logo, and color. 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.
*/
static get defaultBadgeData() {
return {}
}
/**
* Example URLs for this service. These should use the format
* specified in `route`, and can be used to demonstrate how to use badges for
* this service.
*
* The preferred way to specify an example is with `namedParams` which are
* substitued into the service's compiled route pattern. The rendered badge
* is specified with `staticPreview`.
*
* For services which use a route `format`, the `pattern` can be specified as
* part of the example.
*
* title: Descriptive text that will be shown next to the badge. The default
* is to use the service class name, which probably is not what you want.
* namedParams: An object containing the values of named parameters to
* substitute into the compiled route pattern.
* queryParams: An object containing query parameters to include in the
* example URLs.
* pattern: The route pattern to compile. Defaults to `this.route.pattern`.
* staticPreview: A rendered badge of the sort returned by `handle()` or
* `render()`: an object containing `message` and optional `label` and
* `color`. This is usually generated by invoking `this.render()` with some
* explicit props.
* previewUrl: Deprecated. An explicit example which is rendered as part of
* the badge listing.
* keywords: Additional keywords, other than words in the title. This helps
* users locate relevant badges.
* documentation: An HTML string that is included in the badge popup.
*/
static get examples() {
return []
}
static _makeFullUrl(partialUrl) {
return `/${[this.route.base, partialUrl].filter(Boolean).join('/')}`
}
static validateDefinition() {
assertValidCategory(this.category, `Category for ${this.name}`)
Joi.assert(
this.defaultBadgeData,
defaultBadgeDataSchema,
`Default badge data for ${this.name}`
)
this.examples.forEach((example, index) =>
validateExample(example, index, this)
)
}
static getDefinition() {
const { category, name, isDeprecated } = this
let format, pattern, queryParams
try {
;({ format, pattern, query: queryParams = [] } = this.route)
} catch (e) {
// Legacy services do not have a route.
}
const examples = this.examples.map((example, index) =>
transformExample(example, index, this)
)
let route
if (pattern) {
route = { pattern: this._makeFullUrl(pattern), queryParams }
} else if (format) {
route = { format, queryParams }
} else {
route = undefined
}
const result = { category, name, isDeprecated, route, examples }
assertValidServiceDefinition(result, `getDefinition() for ${this.name}`)
return result
}
static get _regexFromPath() {
const { pattern } = this.route
const fullPattern = `${this._makeFullUrl(
pattern
)}.:ext(svg|png|gif|jpg|json)`
const keys = []
const regex = pathToRegexp(fullPattern, keys, {
strict: true,
sensitive: true,
})
const capture = keys.map(item => item.name).slice(0, -1)
return { regex, capture }
}
static get _regex() {
const { pattern, format, capture } = this.route
if (
pattern !== undefined &&
(format !== undefined || capture !== undefined)
) {
throw Error(
`Since the route for ${
this.name
} includes a pattern, it should not include a format or capture`
)
} else if (pattern !== undefined) {
return this._regexFromPath.regex
} else if (format !== undefined) {
return new RegExp(
`^${this._makeFullUrl(this.route.format)}\\.(svg|png|gif|jpg|json)$`
)
} else {
throw Error(`The route for ${this.name} has neither pattern nor format`)
}
}
static get _cacheLength() {
const cacheLengths = {
build: 30,
license: 3600,
version: 300,
debug: 60,
}
return cacheLengths[this.category]
}
static _namedParamsForMatch(match) {
const { pattern, capture } = this.route
const names = pattern ? this._regexFromPath.capture : 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
}
_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 InvalidResponse ||
error instanceof Inaccessible ||
error instanceof Deprecated
) {
trace.logTrace('outbound', emojic.noGoodWoman, 'Handled error', error)
return {
isError: true,
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 {
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)
const serviceInstance = new this(context, config)
let serviceData
try {
serviceData = await serviceInstance.handle(namedParams, queryParams)
Joi.assert(serviceData, serviceDataSchema)
} catch (error) {
serviceData = serviceInstance._handleError(error)
}
trace.logTrace('outbound', emojic.shield, 'Service data', serviceData)
return serviceData
}
// Translate modern badge data to the legacy schema understood by the badge
// maker. Allow the user to override the label, color, logo, etc. through
// the query string. Provide support for most badge options via
// `serviceData` so the Endpoint badge can specify logos and colors, though
// allow that the user's logo or color to take precedence. A notable
// exception is the case of errors. When the service specifies that an error
// has occurred, the user's requested color does not override the error color.
//
// Logos are resolved in this manner:
//
// 1. When `?logo=` contains the name of one of the Shields logos, or contains
// base64-encoded SVG, that logo is used. In the case of a named logo, when
// a `&logoColor=` is specified, that color is used. Otherwise the default
// color is used. `logoColor` will not be applied to a custom
// (base64-encoded) logo; if a custom color is desired the logo should be
// recolored prior to making the request. The appearance of the logo can be
// customized using `logoWidth`, and in the case of the popout badge,
// `logoPosition`. When `?logo=` is specified, any logo-related parameters
// specified dynamically by the service, or by default in the service, are
// ignored.
// 2. The second precedence is the dynamic logo returned by a service. This is
// used only by the Endpoint badge. The `logoColor` can be overridden by the
// query string.
// 3. In the case of the `social` style only, the last precedence is the
// service's default logo. The `logoColor` can be overridden by the query
// string.
static _makeBadgeData(overrides, serviceData) {
const {
style: overrideStyle,
label: overrideLabel,
logoColor: overrideLogoColor,
link: overrideLink,
} = overrides
// Scoutcamp converts numeric query params to numbers. Convert them back.
let {
colorB: overrideColor,
colorA: overrideLabelColor,
logoWidth: overrideLogoWidth,
logoPosition: overrideLogoPosition,
} = overrides
if (typeof overrideColor === 'number') {
overrideColor = `${overrideColor}`
}
if (typeof overrideLabelColor === 'number') {
overrideLabelColor = `${overrideLabelColor}`
}
overrideLogoWidth = +overrideLogoWidth || undefined
overrideLogoPosition = +overrideLogoPosition || undefined
// `?logo=` could be a named logo or encoded svg. Split up these cases.
const overrideLogoSvgBase64 = decodeDataUrlFromQueryParam(overrides.logo)
const overrideNamedLogo = overrideLogoSvgBase64 ? undefined : overrides.logo
const {
isError,
label: serviceLabel,
message: serviceMessage,
color: serviceColor,
labelColor: serviceLabelColor,
logoSvg: serviceLogoSvg,
namedLogo: serviceNamedLogo,
logoColor: serviceLogoColor,
logoWidth: serviceLogoWidth,
logoPosition: serviceLogoPosition,
link: serviceLink,
cacheSeconds: serviceCacheSeconds,
style: serviceStyle,
} = serviceData
const serviceLogoSvgBase64 = serviceLogoSvg
? svg2base64(serviceLogoSvg)
: undefined
const {
color: defaultColor,
namedLogo: defaultNamedLogo,
label: defaultLabel,
labelColor: defaultLabelColor,
} = this.defaultBadgeData
const defaultCacheSeconds = this._cacheLength
const style = coalesce(overrideStyle, serviceStyle)
const namedLogoSvgBase64 = prepareNamedLogo({
name: coalesce(
overrideNamedLogo,
serviceNamedLogo,
style === 'social' ? defaultNamedLogo : undefined
),
color: coalesce(
overrideLogoColor,
// If the logo has been overridden it does not make sense to inherit
// the color.
overrideNamedLogo ? undefined : serviceLogoColor
),
})
return {
text: [
// Use `coalesce()` to support empty labels and messages, as in the
// static badge.
coalesce(overrideLabel, serviceLabel, defaultLabel, this.category),
coalesce(serviceMessage, 'n/a'),
],
color: coalesce(
// In case of an error, disregard user's color override.
isError ? undefined : overrideColor,
serviceColor,
defaultColor,
'lightgrey'
),
labelColor: coalesce(
// In case of an error, disregard user's color override.
isError ? undefined : overrideLabelColor,
serviceLabelColor,
defaultLabelColor
),
template: style,
logo: coalesce(
overrideLogoSvgBase64,
serviceLogoSvgBase64,
namedLogoSvgBase64
),
logoWidth: coalesce(
overrideLogoWidth,
// If the logo has been overridden it does not make sense to inherit
// the width or position.
overrideNamedLogo ? undefined : serviceLogoWidth
),
logoPosition: coalesce(
overrideLogoPosition,
overrideNamedLogo ? undefined : serviceLogoPosition
),
links: toArray(overrideLink || serviceLink),
cacheLengthSeconds: coalesce(serviceCacheSeconds, defaultCacheSeconds),
}
}
static register({ camp, handleRequest, githubApiProvider }, serviceConfig) {
const { cacheHeaders: cacheHeaderConfig, fetchLimitBytes } = serviceConfig
camp.route(
this._regex,
handleRequest(cacheHeaderConfig, {
queryParams: this.route.queryParams,
handler: async (queryParams, match, sendBadge, request) => {
const namedParams = this._namedParamsForMatch(match)
const serviceData = await this.invoke(
{
sendAndCacheRequest: request.asPromise,
sendAndCacheRequestWithCallbacks: request,
githubApiProvider,
},
serviceConfig,
namedParams,
queryParams
)
const badgeData = this._makeBadgeData(queryParams, serviceData)
// The final capture group is the extension.
const format = match.slice(-1)[0]
sendBadge(format, badgeData)
},
cacheLength: this._cacheLength,
fetchLimitBytes,
})
)
}
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
)
}
static _validateQueryParams(queryParams, queryParamSchema) {
return validate(
{
ErrorClass: InvalidParameter,
prettyErrorMessage: 'invalid query parameter',
includeKeys: true,
traceErrorMessage: 'Query params did not match schema',
traceSuccessMessage: 'Query params after validation',
},
queryParams,
queryParamSchema
)
}
async _request({ url, options = {}, errorMessages = {} }) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
logTrace(emojic.bowAndArrow, 'Request', url, '\n', options)
const { res, buffer } = await this._requestFetcher(url, options)
logTrace(emojic.dart, 'Response status code', res.statusCode)
return checkErrorResponse.asPromise(errorMessages)({ buffer, res })
}
}
module.exports = BaseService

View File

@@ -0,0 +1,854 @@
'use strict'
const Joi = require('joi')
const { expect } = require('chai')
const { test, given, forCases } = require('sazerac')
const sinon = require('sinon')
const { getShieldsIcon } = require('../../lib/logos')
const trace = require('./trace')
const {
NotFound,
Inaccessible,
InvalidResponse,
InvalidParameter,
Deprecated,
} = require('./errors')
const BaseService = require('./base')
require('../register-chai-plugins.spec')
class DummyService extends BaseService {
static render({ namedParamA, queryParamA }) {
return {
message: `Hello namedParamA: ${namedParamA} with queryParamA: ${queryParamA}`,
}
}
async handle({ namedParamA }, { queryParamA }) {
return this.constructor.render({ namedParamA, queryParamA })
}
static get category() {
return 'other'
}
static get defaultBadgeData() {
return { label: 'cat', namedLogo: 'appveyor' }
}
static get examples() {
return [
{ previewUrl: 'World' },
{ previewUrl: 'World', queryParams: { queryParamA: '!!!' } },
{
pattern: ':world',
namedParams: { world: 'World' },
staticPreview: this.render({ namedParamA: 'foo', queryParamA: 'bar' }),
keywords: ['hello'],
},
{
namedParams: { namedParamA: 'World' },
staticPreview: this.render({ namedParamA: 'foo', queryParamA: 'bar' }),
keywords: ['hello'],
},
{
pattern: ':world',
namedParams: { world: 'World' },
queryParams: { queryParamA: '!!!' },
staticPreview: this.render({ namedParamA: 'foo', queryParamA: 'bar' }),
keywords: ['hello'],
},
]
}
static get route() {
return {
base: 'foo',
pattern: ':namedParamA',
queryParams: ['queryParamA'],
}
}
}
describe('BaseService', function() {
const defaultConfig = { handleInternalErrors: false }
describe('URL pattern matching', function() {
context('A `pattern` with a named param is declared', function() {
const regexExec = str => DummyService._regex.exec(str)
const getNamedParamA = str => {
const [, namedParamA] = regexExec(str)
return namedParamA
}
const namedParams = str => {
const match = regexExec(str)
return DummyService._namedParamsForMatch(match)
}
test(regexExec, () => {
forCases([
given('/foo/bar.bar.bar.zip'),
given('/foo/bar/bar.svg'),
// This is a valid example with the wrong extension separator, to
// test that we only accept a `.`.
given('/foo/bar.bar.bar_svg'),
]).expect(null)
})
test(getNamedParamA, () => {
forCases([
given('/foo/bar.bar.bar.svg'),
given('/foo/bar.bar.bar.png'),
given('/foo/bar.bar.bar.gif'),
given('/foo/bar.bar.bar.jpg'),
given('/foo/bar.bar.bar.json'),
]).expect('bar.bar.bar')
})
test(namedParams, () => {
forCases([
given('/foo/bar.bar.bar.svg'),
given('/foo/bar.bar.bar.png'),
given('/foo/bar.bar.bar.gif'),
given('/foo/bar.bar.bar.jpg'),
given('/foo/bar.bar.bar.json'),
]).expect({ namedParamA: 'bar.bar.bar' })
})
})
context('A `format` with a named param is declared', function() {
class ServiceWithFormat extends BaseService {
static get route() {
return {
base: 'foo',
format: '([^/]+)',
capture: ['namedParamA'],
}
}
}
const regexExec = str => ServiceWithFormat._regex.exec(str)
const getNamedParamA = str => {
const [, namedParamA] = regexExec(str)
return namedParamA
}
const namedParams = str => {
const match = regexExec(str)
return ServiceWithFormat._namedParamsForMatch(match)
}
test(regexExec, () => {
forCases([
given('/foo/bar.bar.bar.zip'),
given('/foo/bar/bar.svg'),
// This is a valid example with the wrong extension separator, to
// test that we only accept a `.`.
given('/foo/bar.bar.bar_svg'),
]).expect(null)
})
test(getNamedParamA, () => {
forCases([
given('/foo/bar.bar.bar.svg'),
given('/foo/bar.bar.bar.png'),
given('/foo/bar.bar.bar.gif'),
given('/foo/bar.bar.bar.jpg'),
given('/foo/bar.bar.bar.json'),
]).expect('bar.bar.bar')
})
test(namedParams, () => {
forCases([
given('/foo/bar.bar.bar.svg'),
given('/foo/bar.bar.bar.png'),
given('/foo/bar.bar.bar.gif'),
given('/foo/bar.bar.bar.jpg'),
given('/foo/bar.bar.bar.json'),
]).expect({ namedParamA: 'bar.bar.bar' })
})
})
context('No named params are declared', function() {
class ServiceWithZeroNamedParams extends BaseService {
static get route() {
return {
base: 'foo',
format: '(?:[^/]+)',
}
}
}
const namedParams = str => {
const match = ServiceWithZeroNamedParams._regex.exec(str)
return ServiceWithZeroNamedParams._namedParamsForMatch(match)
}
test(namedParams, () => {
forCases([
given('/foo/bar.bar.bar.svg'),
given('/foo/bar.bar.bar.png'),
given('/foo/bar.bar.bar.gif'),
given('/foo/bar.bar.bar.jpg'),
given('/foo/bar.bar.bar.json'),
]).expect({})
})
})
})
it('Invokes the handler as expected', async function() {
expect(
await DummyService.invoke(
{},
defaultConfig,
{ namedParamA: 'bar.bar.bar' },
{ queryParamA: '!' }
)
).to.deep.equal({
message: 'Hello namedParamA: bar.bar.bar with queryParamA: !',
})
})
describe('Logging', function() {
let sandbox
beforeEach(function() {
sandbox = sinon.createSandbox()
})
afterEach(function() {
sandbox.restore()
})
beforeEach(function() {
sandbox.stub(trace, 'logTrace')
})
it('Invokes the logger as expected', async function() {
await DummyService.invoke(
{},
defaultConfig,
{ namedParamA: 'bar.bar.bar' },
{ queryParamA: '!' }
)
expect(trace.logTrace).to.be.calledWithMatch(
'inbound',
sinon.match.string,
'Service class',
'DummyService'
)
expect(trace.logTrace).to.be.calledWith(
'inbound',
sinon.match.string,
'Named params',
{ namedParamA: 'bar.bar.bar' }
)
expect(trace.logTrace).to.be.calledWith(
'inbound',
sinon.match.string,
'Query params',
{ queryParamA: '!' }
)
})
})
describe('Error handling', function() {
it('Handles internal errors', async function() {
class ThrowingService extends DummyService {
async handle() {
throw Error("I've made a huge mistake")
}
}
expect(
await ThrowingService.invoke(
{},
{ handleInternalErrors: true },
{ namedParamA: 'bar.bar.bar' }
)
).to.deep.equal({
isError: true,
color: 'lightgray',
label: 'shields',
message: 'internal error',
})
})
context('handle() returns invalid data', function() {
it('Throws a validation error', async function() {
class ThrowingService extends DummyService {
async handle() {
return {
some: 'nonsense',
}
}
}
try {
await ThrowingService.invoke(
{},
{ handleInternalErrors: false },
{ namedParamA: 'bar.bar.bar' }
)
expect.fail('Expected to throw')
} catch (e) {
expect(e.name).to.equal('ValidationError')
expect(e.details.map(({ message }) => message)).to.deep.equal([
'"message" is required',
])
}
})
})
describe('Handles known subtypes of ShieldsInternalError', function() {
it('handles NotFound errors', async function() {
class ThrowingService extends DummyService {
async handle() {
throw new NotFound()
}
}
expect(
await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' })
).to.deep.equal({
isError: true,
color: 'red',
message: 'not found',
})
})
it('handles Inaccessible errors', async function() {
class ThrowingService extends DummyService {
async handle() {
throw new Inaccessible()
}
}
expect(
await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' })
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'inaccessible',
})
})
it('handles InvalidResponse errors', async function() {
class ThrowingService extends DummyService {
async handle() {
throw new InvalidResponse()
}
}
expect(
await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' })
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'invalid',
})
})
it('handles Deprecated', async function() {
class ThrowingService extends DummyService {
async handle() {
throw new Deprecated()
}
}
expect(
await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' })
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'no longer available',
})
})
it('handles InvalidParameter errors', async function() {
class ThrowingService extends DummyService {
async handle() {
throw new InvalidParameter()
}
}
expect(
await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' })
).to.deep.equal({
isError: true,
color: 'red',
message: 'invalid parameter',
})
})
})
})
describe('_makeBadgeData', function() {
describe('Overrides', function() {
it('overrides the label', function() {
const badgeData = DummyService._makeBadgeData(
{ label: 'purr count' },
{ label: 'purrs' }
)
expect(badgeData.text).to.deep.equal(['purr count', 'n/a'])
})
it('overrides the label color', function() {
const badgeData = DummyService._makeBadgeData(
{ colorA: '42f483' },
{ color: 'green' }
)
expect(badgeData.labelColor).to.equal('42f483')
})
it('overrides the color', function() {
const badgeData = DummyService._makeBadgeData(
{ colorB: '10ADED' },
{ color: 'red' }
)
expect(badgeData.color).to.equal('10ADED')
})
it('converts a query-string numeric color to a string', function() {
const badgeData = DummyService._makeBadgeData(
// Scoutcamp converts numeric query params to numbers.
{ colorB: 123 },
{ color: 'green' }
)
expect(badgeData.color).to.equal('123')
})
it('does not override the color in case of an error', function() {
const badgeData = DummyService._makeBadgeData(
{ colorB: '10ADED' },
{ isError: true, color: 'lightgray' }
)
expect(badgeData.color).to.equal('lightgray')
})
it('overrides the logo', function() {
const badgeData = DummyService._makeBadgeData(
{ logo: 'github' },
{ namedLogo: 'appveyor' }
)
// .not.be.empty for confidence that nothing has changed with `getShieldsIcon()`.
expect(badgeData.logo).to.equal(getShieldsIcon({ name: 'github' })).and
.not.be.empty
})
it('overrides the logo with a color', function() {
const badgeData = DummyService._makeBadgeData(
{ logo: 'github', logoColor: 'blue' },
{ namedLogo: 'appveyor' }
)
expect(badgeData.logo).to.equal(
getShieldsIcon({ name: 'github', color: 'blue' })
).and.not.be.empty
})
it("when the logo is overridden, it ignores the service's logo color, position, and width", function() {
const badgeData = DummyService._makeBadgeData(
{ logo: 'github' },
{
namedLogo: 'appveyor',
logoColor: 'red',
logoPosition: -3,
logoWidth: 100,
}
)
expect(badgeData.logo).to.equal(getShieldsIcon({ name: 'github' })).and
.not.be.empty
})
it("overrides the service logo's color", function() {
const badgeData = DummyService._makeBadgeData(
{ logoColor: 'blue' },
{ namedLogo: 'github', logoColor: 'red' }
)
expect(badgeData.logo).to.equal(
getShieldsIcon({ name: 'github', color: 'blue' })
).and.not.be.empty
})
it('overrides the logo with custom svg', function() {
const logoSvg = 'data:image/svg+xml;base64,PHN2ZyB4bWxu'
const badgeData = DummyService._makeBadgeData(
{ logo: logoSvg },
{ namedLogo: 'appveyor' }
)
expect(badgeData.logo).to.equal(logoSvg)
})
it('ignores the color when custom svg is provided', function() {
const logoSvg = 'data:image/svg+xml;base64,PHN2ZyB4bWxu'
const badgeData = DummyService._makeBadgeData(
{ logo: logoSvg, logoColor: 'brightgreen' },
{ namedLogo: 'appveyor' }
)
expect(badgeData.logo).to.equal(logoSvg)
})
it('overrides the logoWidth', function() {
const badgeData = DummyService._makeBadgeData({ logoWidth: 20 }, {})
expect(badgeData.logoWidth).to.equal(20)
})
it('overrides the logoPosition', function() {
const badgeData = DummyService._makeBadgeData({ logoPosition: -10 }, {})
expect(badgeData.logoPosition).to.equal(-10)
})
it('overrides the links', function() {
const badgeData = DummyService._makeBadgeData(
{ link: 'https://circleci.com/gh/badges/daily-tests' },
{
link:
'https://circleci.com/workflow-run/184ef3de-4836-4805-a2e4-0ceba099f92d',
}
)
expect(badgeData.links).to.deep.equal([
'https://circleci.com/gh/badges/daily-tests',
])
})
it('overrides the template', function() {
const badgeData = DummyService._makeBadgeData({ style: 'pill' }, {})
expect(badgeData.template).to.equal('pill')
})
it('overrides the cache length', function() {
const badgeData = DummyService._makeBadgeData(
{ style: 'pill' },
{ cacheSeconds: 123 }
)
expect(badgeData.cacheLengthSeconds).to.equal(123)
})
})
describe('Service data', function() {
it('applies the service message', function() {
const badgeData = DummyService._makeBadgeData({}, { message: '10k' })
expect(badgeData.text).to.deep.equal(['cat', '10k'])
})
it('preserves an empty label', function() {
const badgeData = DummyService._makeBadgeData(
{},
{ label: '', message: '10k' }
)
expect(badgeData.text).to.deep.equal(['', '10k'])
})
it('applies a numeric service message', function() {
// While a number of badges use this, in the long run we may want
// `render()` to always return a string.
const badgeData = DummyService._makeBadgeData({}, { message: 10 })
expect(badgeData.text).to.deep.equal(['cat', 10])
})
it('applies the service color', function() {
const badgeData = DummyService._makeBadgeData({}, { color: 'red' })
expect(badgeData.color).to.equal('red')
})
it('applies the named logo', function() {
const badgeData = DummyService._makeBadgeData(
{},
{ namedLogo: 'github' }
)
// .not.be.empty for confidence that nothing has changed with `getShieldsIcon()`.
expect(badgeData.logo).to.equal(getShieldsIcon({ name: 'github' })).and
.not.to.be.empty
})
it('applies the named logo with color', function() {
const badgeData = DummyService._makeBadgeData(
{},
{ namedLogo: 'github', logoColor: 'blue' }
)
expect(badgeData.logo).to.equal(
getShieldsIcon({ name: 'github', color: 'blue' })
).and.not.to.be.empty
})
it('applies the logo width', function() {
const badgeData = DummyService._makeBadgeData(
{},
{ namedLogo: 'github', logoWidth: 275 }
)
expect(badgeData.logoWidth).to.equal(275)
})
it('applies the logo position', function() {
const badgeData = DummyService._makeBadgeData(
{},
{ namedLogo: 'github', logoPosition: -10 }
)
expect(badgeData.logoPosition).to.equal(-10)
})
it('applies the service label color', function() {
const badgeData = DummyService._makeBadgeData({}, { labelColor: 'red' })
expect(badgeData.labelColor).to.equal('red')
})
})
describe('Defaults', function() {
it('uses the default label', function() {
const badgeData = DummyService._makeBadgeData({}, {})
expect(badgeData.text).to.deep.equal(['cat', 'n/a'])
})
it('uses the default color', function() {
const badgeData = DummyService._makeBadgeData({}, {})
expect(badgeData.color).to.equal('lightgrey')
})
it('provides no default label color', function() {
const badgeData = DummyService._makeBadgeData({}, {})
expect(badgeData.labelColor).to.be.undefined
})
it('when not a social badge, ignores the default named logo', function() {
const badgeData = DummyService._makeBadgeData({}, {})
expect(badgeData.logo).to.be.undefined
})
it('when a social badge, uses the default named logo', function() {
const badgeData = DummyService._makeBadgeData({ style: 'social' }, {})
expect(badgeData.logo).to.equal(getShieldsIcon({ name: 'appveyor' }))
.and.not.be.empty
})
})
})
describe('ScoutCamp integration', function() {
const expectedRouteRegex = /^\/foo\/([^/]+?)\.(svg|png|gif|jpg|json)$/
let mockCamp
let mockHandleRequest
beforeEach(function() {
mockCamp = {
route: sinon.spy(),
}
mockHandleRequest = sinon.spy()
DummyService.register(
{ camp: mockCamp, handleRequest: mockHandleRequest },
defaultConfig
)
})
it('registers the service', function() {
expect(mockCamp.route).to.have.been.calledOnce
expect(mockCamp.route).to.have.been.calledWith(expectedRouteRegex)
})
it('handles the request', async function() {
expect(mockHandleRequest).to.have.been.calledOnce
const { handler: requestHandler } = mockHandleRequest.getCall(0).args[1]
const mockSendBadge = sinon.spy()
const mockRequest = {
asPromise: sinon.spy(),
}
const queryParams = { queryParamA: '?' }
const match = '/foo/bar.svg'.match(expectedRouteRegex)
await requestHandler(queryParams, match, mockSendBadge, mockRequest)
const expectedFormat = 'svg'
expect(mockSendBadge).to.have.been.calledOnce
expect(mockSendBadge).to.have.been.calledWith(expectedFormat, {
text: ['cat', 'Hello namedParamA: bar with queryParamA: ?'],
color: 'lightgrey',
template: undefined,
logo: undefined,
logoWidth: undefined,
logoPosition: undefined,
links: [],
labelColor: undefined,
cacheLengthSeconds: undefined,
})
})
})
describe('getDefinition', function() {
it('returns the expected result', function() {
const {
category,
name,
isDeprecated,
route,
examples,
} = DummyService.getDefinition()
expect({
category,
name,
isDeprecated,
route,
}).to.deep.equal({
category: 'other',
name: 'DummyService',
isDeprecated: false,
route: {
pattern: '/foo/:namedParamA',
queryParams: [],
},
})
const [first, second, third, fourth, fifth] = examples
expect(first).to.deep.equal({
title: 'DummyService',
example: {
path: '/foo/World',
queryParams: {},
},
preview: {
path: '/foo/World',
queryParams: {},
},
keywords: [],
documentation: undefined,
})
expect(second).to.deep.equal({
title: 'DummyService',
example: {
path: '/foo/World',
queryParams: { queryParamA: '!!!' },
},
preview: {
path: '/foo/World',
queryParams: { queryParamA: '!!!' },
},
keywords: [],
documentation: undefined,
})
expect(third).to.deep.equal({
title: 'DummyService',
example: {
pattern: '/foo/:world',
namedParams: { world: 'World' },
queryParams: {},
},
preview: {
label: 'cat',
message: 'Hello namedParamA: foo with queryParamA: bar',
color: 'lightgrey',
},
keywords: ['hello'],
documentation: undefined,
})
expect(fourth).to.deep.equal({
title: 'DummyService',
example: {
pattern: '/foo/:namedParamA',
namedParams: { namedParamA: 'World' },
queryParams: {},
},
preview: {
label: 'cat',
message: 'Hello namedParamA: foo with queryParamA: bar',
color: 'lightgrey',
},
keywords: ['hello'],
documentation: undefined,
})
expect(fifth).to.deep.equal({
title: 'DummyService',
example: {
pattern: '/foo/:world',
namedParams: { world: 'World' },
queryParams: { queryParamA: '!!!' },
},
preview: {
color: 'lightgrey',
label: 'cat',
message: 'Hello namedParamA: foo with queryParamA: bar',
},
keywords: ['hello'],
documentation: undefined,
})
})
})
describe('validate', function() {
const dummySchema = Joi.object({
requiredString: Joi.string().required(),
}).required()
it('throws error for invalid responses', async function() {
try {
DummyService._validate(
{ requiredString: ['this', "shouldn't", 'work'] },
dummySchema
)
expect.fail('Expected to throw')
} catch (e) {
expect(e).to.be.an.instanceof(InvalidResponse)
}
})
it('throws error for invalid query params', async function() {
try {
DummyService._validateQueryParams(
{ requiredString: ['this', "shouldn't", 'work'] },
dummySchema
)
expect.fail('Expected to throw')
} catch (e) {
expect(e).to.be.an.instanceof(InvalidParameter)
}
})
})
describe('request', function() {
let sandbox
beforeEach(function() {
sandbox = sinon.createSandbox()
})
afterEach(function() {
sandbox.restore()
})
beforeEach(function() {
sandbox.stub(trace, 'logTrace')
})
it('logs appropriate information', async function() {
const sendAndCacheRequest = async () => ({
buffer: '',
res: { statusCode: 200 },
})
const serviceInstance = new DummyService(
{ sendAndCacheRequest },
defaultConfig
)
const url = 'some-url'
const options = { headers: { Cookie: 'some-cookie' } }
await serviceInstance._request({ url, options })
expect(trace.logTrace).to.be.calledWithMatch(
'fetch',
sinon.match.string,
'Request',
url,
'\n',
options
)
expect(trace.logTrace).to.be.calledWithMatch(
'fetch',
sinon.match.string,
'Response status code',
200
)
})
it('handles errors', async function() {
const sendAndCacheRequest = async () => ({
buffer: '',
res: { statusCode: 404 },
})
const serviceInstance = new DummyService(
{ sendAndCacheRequest },
defaultConfig
)
try {
await serviceInstance._request({})
expect.fail('Expected to throw')
} catch (e) {
expect(e).to.be.an.instanceof(NotFound)
expect(e.message).to.equal('Not Found')
expect(e.prettyMessage).to.equal('not found')
}
})
})
})

View File

@@ -0,0 +1,113 @@
'use strict'
const assert = require('assert')
const Joi = require('joi')
const coalesce = require('./coalesce')
const serverStartTimeGMTString = new Date().toGMTString()
const serverStartTimestamp = Date.now()
const queryParamSchema = Joi.object({
// Not using nonNegativeInteger because it's not required.
maxAge: Joi.number()
.integer()
.min(0),
})
.unknown(true)
.required()
function overrideCacheLengthFromQueryParams(queryParams) {
try {
const { maxAge: overrideCacheLength } = Joi.attempt(
queryParams,
queryParamSchema,
{ allowUnknown: true }
)
return overrideCacheLength
} catch (e) {
return undefined
}
}
function coalesceCacheLength({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds,
serviceOverrideCacheLengthSeconds,
queryParams,
}) {
const { defaultCacheLengthSeconds } = cacheHeaderConfig
// The config returns a number so this should never happen. But this logic
// would be completely broken if it did.
assert(defaultCacheLengthSeconds !== undefined)
const cacheLength = coalesce(
serviceDefaultCacheLengthSeconds,
defaultCacheLengthSeconds
)
// Overrides can apply _more_ caching, but not less. Query param overriding
// can request more overriding than service override, but not less.
const candidateOverrides = [
serviceOverrideCacheLengthSeconds,
overrideCacheLengthFromQueryParams(queryParams),
].filter(x => x !== undefined)
return Math.max(cacheLength, ...candidateOverrides)
}
function setHeadersForCacheLength(res, cacheLengthSeconds) {
const now = new Date()
const nowGMTString = now.toGMTString()
// Send both Cache-Control max-age and Expires in case the client implements
// HTTP/1.0 but not HTTP/1.1.
let cacheControl, expires
if (cacheLengthSeconds === 0) {
// Prevent as much downstream caching as possible.
cacheControl = 'no-cache, no-store, must-revalidate'
expires = nowGMTString
} else {
cacheControl = `max-age=${cacheLengthSeconds}`
expires = new Date(now.getTime() + cacheLengthSeconds * 1000).toGMTString()
}
res.setHeader('Date', nowGMTString)
res.setHeader('Cache-Control', cacheControl)
res.setHeader('Expires', expires)
}
function setCacheHeaders({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds,
serviceOverrideCacheLengthSeconds,
queryParams,
res,
}) {
const cacheLengthSeconds = coalesceCacheLength({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds,
serviceOverrideCacheLengthSeconds,
queryParams,
})
setHeadersForCacheLength(res, cacheLengthSeconds)
}
const staticCacheControlHeader = `max-age=${24 * 3600}` // 1 day.
function setCacheHeadersForStaticResource(res) {
res.setHeader('Cache-Control', staticCacheControlHeader)
res.setHeader('Last-Modified', serverStartTimeGMTString)
}
function serverHasBeenUpSinceResourceCached(req) {
return (
serverStartTimestamp <= new Date(req.headers['if-modified-since']).getTime()
)
}
module.exports = {
coalesceCacheLength,
setCacheHeaders,
setHeadersForCacheLength,
setCacheHeadersForStaticResource,
serverHasBeenUpSinceResourceCached,
}

View File

@@ -0,0 +1,229 @@
'use strict'
const { test, given } = require('sazerac')
const chai = require('chai')
const { expect } = require('chai')
const sinon = require('sinon')
const httpMocks = require('node-mocks-http')
const {
coalesceCacheLength,
setHeadersForCacheLength,
setCacheHeaders,
setCacheHeadersForStaticResource,
serverHasBeenUpSinceResourceCached,
} = require('./cache-headers')
chai.use(require('chai-datetime'))
describe('Cache header functions', function() {
let res
beforeEach(function() {
res = httpMocks.createResponse()
})
describe('coalesceCacheLength', function() {
const cacheHeaderConfig = { defaultCacheLengthSeconds: 777 }
test(coalesceCacheLength, () => {
given({ cacheHeaderConfig, queryParams: {} }).expect(777)
given({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds: 900,
queryParams: {},
}).expect(900)
given({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds: 900,
queryParams: { maxAge: 1000 },
}).expect(1000)
given({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds: 900,
queryParams: { maxAge: 1000, other: 'here', maybe: 'bogus' },
}).expect(1000)
given({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds: 900,
queryParams: { maxAge: 400 },
}).expect(900)
given({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds: 900,
queryParams: { maxAge: '-1000' },
}).expect(900)
given({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds: 900,
queryParams: { maxAge: '' },
}).expect(900)
given({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds: 900,
queryParams: { maxAge: 'not a number' },
}).expect(900)
given({
cacheHeaderConfig,
serviceDefaultCacheLengthSeconds: 900,
serviceOverrideCacheLengthSeconds: 400,
queryParams: {},
}).expect(900)
given({
cacheHeaderConfig,
serviceOverrideCacheLengthSeconds: 400,
queryParams: {},
}).expect(777)
given({
cacheHeaderConfig,
serviceOverrideCacheLengthSeconds: 900,
queryParams: {},
}).expect(900)
given({
cacheHeaderConfig,
serviceOverrideCacheLengthSeconds: 800,
queryParams: { maxAge: 500 },
}).expect(800)
given({
cacheHeaderConfig,
serviceOverrideCacheLengthSeconds: 900,
queryParams: { maxAge: 800 },
}).expect(900)
})
})
describe('setHeadersForCacheLength', function() {
let sandbox
beforeEach(function() {
sandbox = sinon.createSandbox()
sandbox.useFakeTimers()
})
afterEach(function() {
sandbox.restore()
sandbox = undefined
})
it('should set the correct Date header', function() {
// Confidence check.
expect(res._headers.date).to.equal(undefined)
// Act.
setHeadersForCacheLength(res, 123)
// Assert.
const now = new Date().toGMTString()
expect(res._headers.date).to.equal(now)
})
context('cacheLengthSeconds is zero', function() {
beforeEach(function() {
setHeadersForCacheLength(res, 0)
})
it('should set the expected Cache-Control header', function() {
expect(res._headers['cache-control']).to.equal(
'no-cache, no-store, must-revalidate'
)
})
it('should set the expected Expires header', function() {
expect(res._headers.expires).to.equal(new Date().toGMTString())
})
})
context('cacheLengthSeconds is nonzero', function() {
beforeEach(function() {
setHeadersForCacheLength(res, 123)
})
it('should set the expected Cache-Control header', function() {
expect(res._headers['cache-control']).to.equal('max-age=123')
})
it('should set the expected Expires header', function() {
const expires = new Date(Date.now() + 123 * 1000).toGMTString()
expect(res._headers.expires).to.equal(expires)
})
})
})
describe('setCacheHeaders', function() {
it('sets the expected fields', function() {
const expectedFields = ['date', 'cache-control', 'expires']
expectedFields.forEach(field =>
expect(res._headers[field]).to.equal(undefined)
)
setCacheHeaders({
cacheHeaderConfig: { defaultCacheLengthSeconds: 1234 },
serviceDefaultCacheLengthSeconds: 567,
queryParams: { maxAge: 999999 },
res,
})
expectedFields.forEach(field =>
expect(res._headers[field])
.to.be.a('string')
.and.have.lengthOf.at.least(1)
)
})
})
describe('setCacheHeadersForStaticResource', function() {
beforeEach(function() {
setCacheHeadersForStaticResource(res)
})
it('should set the expected Cache-Control header', function() {
expect(res._headers['cache-control']).to.equal(`max-age=${24 * 3600}`)
})
it('should set the expected Last-Modified header', function() {
const lastModified = res._headers['last-modified']
expect(new Date(lastModified)).to.be.withinTime(
// Within the last 60 seconds.
new Date(Date.now() - 60 * 1000),
new Date()
)
})
})
describe('serverHasBeenUpSinceResourceCached', function() {
// The stringified req's are hard to understand. I thought Sazerac
// provided a way to override the describe message, though I can't find it.
context('when there is no If-Modified-Since header', function() {
it('returns false', function() {
const req = httpMocks.createRequest()
expect(serverHasBeenUpSinceResourceCached(req)).to.equal(false)
})
})
context('when the If-Modified-Since header is invalid', function() {
it('returns false', function() {
const req = httpMocks.createRequest({
headers: { 'If-Modified-Since': 'this-is-not-a-date' },
})
expect(serverHasBeenUpSinceResourceCached(req)).to.equal(false)
})
})
context(
'when the If-Modified-Since header is before the process started',
function() {
it('returns false', function() {
const req = httpMocks.createRequest({
headers: { 'If-Modified-Since': '2018-02-01T05:00:00.000Z' },
})
expect(serverHasBeenUpSinceResourceCached(req)).to.equal(false)
})
}
)
context(
'when the If-Modified-Since header is after the process started',
function() {
it('returns true', function() {
const modifiedTimeStamp = new Date(Date.now() + 1800000)
const req = httpMocks.createRequest({
headers: { 'If-Modified-Since': modifiedTimeStamp.toISOString() },
})
expect(serverHasBeenUpSinceResourceCached(req)).to.equal(true)
})
}
)
})
})

View File

@@ -0,0 +1,35 @@
'use strict'
const BaseService = require('./base')
const { Deprecated } = require('./errors')
// Only `url` is required.
function deprecatedService({ url, label, category, examples = [], message }) {
return class DeprecatedService extends BaseService {
static get category() {
return category
}
static get route() {
return url
}
static get isDeprecated() {
return true
}
static get defaultBadgeData() {
return { label }
}
static get examples() {
return examples
}
async handle() {
throw new Deprecated({ prettyMessage: message })
}
}
}
module.exports = deprecatedService

View File

@@ -0,0 +1,62 @@
'use strict'
const { expect } = require('chai')
const deprecatedService = require('./deprecated-service')
describe('DeprecatedService', function() {
const url = {
base: 'coverity/ondemand',
format: '(?:.+)',
}
it('returns true on isDeprecated', function() {
const service = deprecatedService({ url })
expect(service.isDeprecated).to.be.true
})
it('sets specified route', function() {
const service = deprecatedService({ url })
expect(service.route).to.deep.equal(url)
})
it('sets specified label', function() {
const label = 'coverity'
const service = deprecatedService({ url, label })
expect(service.defaultBadgeData.label).to.equal(label)
})
it('sets specified category', function() {
const category = 'analysis'
const service = deprecatedService({ url, category })
expect(service.category).to.equal(category)
})
it('sets specified examples', function() {
const examples = [
{
title: 'Not sure we would have examples',
},
]
const service = deprecatedService({ url, examples })
expect(service.examples).to.deep.equal(examples)
})
it('uses default deprecation message when no message specified', async function() {
const service = deprecatedService({ url })
expect(await service.invoke()).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'no longer available',
})
})
it('uses custom deprecation message when specified', async function() {
const message = 'extended outage'
const service = deprecatedService({ url, message })
expect(await service.invoke()).to.deep.equal({
isError: true,
color: 'lightgray',
message,
})
})
})

109
core/base-service/errors.js Normal file
View File

@@ -0,0 +1,109 @@
'use strict'
class ShieldsRuntimeError extends Error {
get name() {
return 'ShieldsRuntimeError'
}
get defaultPrettyMessage() {
throw new Error('Must implement abstract method')
}
constructor(props = {}, message) {
super(message)
this.prettyMessage = props.prettyMessage || this.defaultPrettyMessage
if (props.underlyingError) {
this.stack = props.underlyingError.stack
}
}
}
const defaultNotFoundError = 'not found'
class NotFound extends ShieldsRuntimeError {
get name() {
return 'NotFound'
}
get defaultPrettyMessage() {
return defaultNotFoundError
}
constructor(props = {}) {
const prettyMessage = props.prettyMessage || defaultNotFoundError
const message =
prettyMessage === defaultNotFoundError
? 'Not Found'
: `Not Found: ${prettyMessage}`
super(props, message)
}
}
class InvalidResponse extends ShieldsRuntimeError {
get name() {
return 'InvalidResponse'
}
get defaultPrettyMessage() {
return 'invalid'
}
constructor(props = {}) {
const message = props.underlyingError
? `Invalid Response: ${props.underlyingError.message}`
: 'Invalid Response'
super(props, message)
}
}
class Inaccessible extends ShieldsRuntimeError {
get name() {
return 'Inaccessible'
}
get defaultPrettyMessage() {
return 'inaccessible'
}
constructor(props = {}) {
const message = props.underlyingError
? `Inaccessible: ${props.underlyingError.message}`
: 'Inaccessible'
super(props, message)
}
}
class InvalidParameter extends ShieldsRuntimeError {
get name() {
return 'InvalidParameter'
}
get defaultPrettyMessage() {
return 'invalid parameter'
}
constructor(props = {}) {
const message = props.underlyingError
? `Invalid Parameter: ${props.underlyingError.message}`
: 'Invalid Parameter'
super(props, message)
}
}
class Deprecated extends ShieldsRuntimeError {
get name() {
return 'Deprecated'
}
get defaultPrettyMessage() {
return 'no longer available'
}
constructor(props) {
const message = 'Deprecated'
super(props, message)
}
}
module.exports = {
ShieldsRuntimeError,
NotFound,
InvalidResponse,
Inaccessible,
InvalidParameter,
Deprecated,
}

View File

@@ -1,14 +1,14 @@
'use strict'
const BaseService = require('../../services/base')
const BaseJsonService = require('../../services/base-json')
const NonMemoryCachingBaseService = require('../../services/base-non-memory-caching')
const BaseStaticService = require('../../services/base-static')
const BaseSvgScrapingService = require('../../services/base-svg-scraping')
const BaseXmlService = require('../../services/base-xml')
const BaseYamlService = require('../../services/base-yaml')
const BaseService = require('./base')
const BaseJsonService = require('./base-json')
const NonMemoryCachingBaseService = require('./base-non-memory-caching')
const BaseStaticService = require('./base-static')
const BaseSvgScrapingService = require('./base-svg-scraping')
const BaseXmlService = require('./base-xml')
const BaseYamlService = require('./base-yaml')
const deprecatedService = require('../../services/deprecated-service')
const deprecatedService = require('./deprecated-service')
const {
NotFound,
@@ -16,7 +16,7 @@ const {
Inaccessible,
InvalidParameter,
Deprecated,
} = require('../../services/errors')
} = require('./errors')
module.exports = {
BaseService,

View File

@@ -4,17 +4,17 @@
const domain = require('domain')
const request = require('request')
const queryString = require('query-string')
const log = require('../server/log')
const analytics = require('../server/analytics')
const LruCache = require('../../gh-badges/lib/lru-cache')
const makeBadge = require('../../gh-badges/lib/make-badge')
const { makeBadgeData: getBadgeData } = require('../../lib/badge-data')
const analytics = require('../server/analytics')
const log = require('../server/log')
const { setCacheHeaders } = require('./cache-headers')
const {
Inaccessible,
InvalidResponse,
ShieldsRuntimeError,
} = require('../../services/errors')
const { setCacheHeaders } = require('../../services/cache-headers')
const { makeBadgeData: getBadgeData } = require('../../lib/badge-data')
} = require('./errors')
const { makeSend } = require('./legacy-result-sender')
// We avoid calling the vendor's server for computation of the information in a

View File

@@ -0,0 +1,97 @@
'use strict'
const Joi = require('joi')
const arrayOfStrings = Joi.array()
.items(Joi.string())
.allow([])
.required()
const objectOfKeyValues = Joi.object()
.pattern(/./, Joi.string().allow(null))
.required()
const staticBadgeContent = Joi.object({
label: Joi.string(),
message: Joi.string().required(),
color: Joi.string().required(),
})
const serviceDefinition = Joi.object({
category: Joi.string().required(),
name: Joi.string().required(),
isDeprecated: Joi.boolean().required(),
route: Joi.alternatives().try(
Joi.object({
pattern: Joi.string().required(),
queryParams: arrayOfStrings,
}),
Joi.object({
format: Joi.string().required(),
queryParams: arrayOfStrings,
})
),
examples: Joi.array()
.items(
Joi.object({
title: Joi.string().required(),
example: Joi.alternatives()
.try(
Joi.object({
pattern: Joi.string(),
namedParams: objectOfKeyValues,
queryParams: objectOfKeyValues,
}),
Joi.object({
path: Joi.string().required(), // URL convertible.
queryParams: objectOfKeyValues,
})
)
.required(),
preview: Joi.alternatives()
.try(
staticBadgeContent,
Joi.object({
path: Joi.string().required(), // URL convertible.
queryParams: objectOfKeyValues,
})
)
.required(),
keywords: arrayOfStrings,
documentation: Joi.object({
__html: Joi.string().required(), // Valid HTML.
}),
})
)
.default([]),
}).required()
function assertValidServiceDefinition(example, message = undefined) {
Joi.assert(example, serviceDefinition, message)
}
const serviceDefinitionExport = Joi.object({
schemaVersion: Joi.equal('0').required(),
categories: Joi.array()
.items(
Joi.object({
id: Joi.string().required(),
name: Joi.string().required(),
})
)
.required(),
services: Joi.array()
.items(serviceDefinition)
.required(),
}).required()
function assertValidServiceDefinitionExport(examples, message = undefined) {
Joi.assert(examples, serviceDefinitionExport, message)
}
module.exports = {
serviceDefinition,
assertValidServiceDefinition,
serviceDefinitionExport,
assertValidServiceDefinitionExport,
}

View File

@@ -0,0 +1,41 @@
'use strict'
const chalk = require('chalk')
// Config is loaded globally but it would be better to inject it. To do that,
// there needs to be one instance of the service created at registration time,
// which gets the config injected into it, instead of one instance per request.
// That way most of the current static methods could become instance methods,
// thereby gaining access to the injected config.
const {
services: { trace: enableTraceLogging },
} = require('config').util.toObject().public
function _formatLabelForStage(stage, label) {
const colorFn = {
inbound: chalk.black.bgBlue,
fetch: chalk.black.bgYellow,
validate: chalk.black.bgGreen,
unhandledError: chalk.white.bgRed,
outbound: chalk.black.bgBlue,
}[stage]
return colorFn(` ${label} `)
}
function logTrace(stage, symbol, label, content, { deep = false } = {}) {
if (enableTraceLogging) {
if (deep) {
console.log(_formatLabelForStage(stage, label), symbol)
console.dir(content, { depth: null })
} else {
console.log(_formatLabelForStage(stage, label), symbol, '\n', content)
}
return true
} else {
return false
}
}
module.exports = {
logTrace,
}

View File

@@ -0,0 +1,166 @@
'use strict'
const Joi = require('joi')
const pathToRegexp = require('path-to-regexp')
const optionalObjectOfKeyValues = Joi.object().pattern(
/./,
Joi.string().allow(null)
)
const optionalServiceData = Joi.object({
label: Joi.string(),
message: Joi.alternatives()
.try(
Joi.string()
.allow('')
.required(),
Joi.number()
)
.required(),
color: Joi.string(),
})
const schema = Joi.object({
// This should be:
// title: Joi.string().required(),
title: Joi.string(),
namedParams: optionalObjectOfKeyValues,
queryParams: optionalObjectOfKeyValues.default({}),
pattern: Joi.string(),
staticPreview: optionalServiceData,
previewUrl: Joi.string(),
keywords: Joi.array()
.items(Joi.string())
.default([]),
documentation: Joi.string(), // Valid HTML.
}).required()
function validateExample(example, index, ServiceClass) {
const result = Joi.attempt(
example,
schema,
`Example for ${ServiceClass.name} at index ${index}`
)
const { namedParams, pattern, staticPreview, previewUrl } = result
if (staticPreview) {
if (!pattern && !ServiceClass.route.pattern) {
throw new Error(
`Static preview for ${
ServiceClass.name
} at index ${index} does not declare a pattern`
)
} else if (!namedParams) {
throw new Error(
`Static preview for ${
ServiceClass.name
} at index ${index} does not declare namedParams`
)
}
if (previewUrl) {
throw new Error(
`Static preview for ${
ServiceClass.name
} at index ${index} also declares a dynamic previewUrl, which is not allowed`
)
}
if (pattern === ServiceClass.route.pattern) {
throw new Error(
`Example for ${
ServiceClass.name
} at index ${index} declares a redundant pattern which should be removed`
)
}
// Make sure we can build the full URL using these patterns.
try {
pathToRegexp.compile(pattern || ServiceClass.route.pattern)(namedParams)
} catch (e) {
throw Error(
`In example for ${
ServiceClass.name
} at index ${index}, ${e.message.toLowerCase()}`
)
}
// Make sure there are no extra keys.
let keys = []
pathToRegexp(pattern || ServiceClass.route.pattern, keys)
keys = keys.map(({ name }) => name)
const extraKeys = Object.keys(namedParams).filter(k => !keys.includes(k))
if (extraKeys.length) {
throw Error(
`In example for ${
ServiceClass.name
} at index ${index}, namedParams contains unknown keys: ${extraKeys.join(
', '
)}`
)
}
} else if (!previewUrl) {
throw Error(
`Example for ${
ServiceClass.name
} at index ${index} is missing required previewUrl or staticPreview`
)
}
return result
}
function transformExample(inExample, index, ServiceClass) {
const {
// We should get rid of this transform, since the class name is never what
// we want to see.
title = ServiceClass.name,
namedParams,
queryParams,
pattern,
staticPreview,
previewUrl,
keywords,
documentation,
} = validateExample(inExample, index, ServiceClass)
let example
if (namedParams) {
example = {
pattern: ServiceClass._makeFullUrl(pattern || ServiceClass.route.pattern),
namedParams,
queryParams,
}
} else {
example = {
path: ServiceClass._makeFullUrl(previewUrl),
queryParams,
}
}
let preview
if (staticPreview) {
const {
text: [label, message],
color,
} = ServiceClass._makeBadgeData({}, staticPreview)
preview = { label, message: `${message}`, color }
} else {
preview = {
path: ServiceClass._makeFullUrl(previewUrl),
queryParams,
}
}
return {
title,
example,
preview,
keywords,
documentation: documentation ? { __html: documentation } : undefined,
}
}
module.exports = {
validateExample,
transformExample,
}

View File

@@ -0,0 +1,53 @@
'use strict'
const { expect } = require('chai')
const { validateExample } = require('./transform-example')
describe('validateExample function', function() {
it('passes valid examples', function() {
const validExamples = [
{
staticPreview: { message: '123' },
pattern: 'dt/:package',
namedParams: { package: 'mypackage' },
},
{ previewUrl: 'dt/mypackage' },
]
validExamples.forEach(example => {
expect(() =>
validateExample(example, 0, { route: {}, name: 'mockService' })
).not.to.throw(Error)
})
})
it('rejects invalid examples', function() {
const invalidExamples = [
{},
{ staticPreview: { message: '123' } },
{
staticPreview: { message: '123' },
pattern: 'dt/:package',
namedParams: { package: 'mypackage' },
exampleUrl: 'dt/mypackage',
},
{ staticPreview: { message: '123' }, pattern: 'dt/:package' },
{
staticPreview: { message: '123' },
pattern: 'dt/:package',
previewUrl: 'dt/mypackage',
},
{
staticPreview: { message: '123' },
pattern: 'dt/:package',
exampleUrl: 'dt/mypackage',
},
]
invalidExamples.forEach(example => {
expect(() =>
validateExample(example, 0, { route: {}, name: 'mockService' })
).to.throw(Error)
})
})
})

View File

@@ -2,7 +2,7 @@
const emojic = require('emojic')
const Joi = require('joi')
const trace = require('../../services/trace')
const trace = require('./trace')
function validate(
{

View File

@@ -3,8 +3,8 @@
const Joi = require('joi')
const { expect } = require('chai')
const sinon = require('sinon')
const trace = require('../../services/trace')
const { InvalidParameter } = require('../../services/errors')
const trace = require('./trace')
const { InvalidParameter } = require('./errors')
const validate = require('./validate')
describe('validate', function() {

View File

@@ -0,0 +1,6 @@
'use strict'
const { use } = require('chai')
use(require('chai-string'))
use(require('sinon-chai'))

View File

@@ -0,0 +1,25 @@
'use strict'
const caller = require('caller')
const BaseService = require('../base-service/base')
const ServiceTester = require('./service-tester')
// Automatically create a ServiceTester.
//
// When run from e.g. `gem-rank.tester.js`, this will create a tester that
// attaches to the service found in `gem-rank.service.js`.
//
// This can't be used for `.service.js` files which export more than one
// service.
function createServiceTester() {
const servicePath = caller().replace('.tester.js', '.service.js')
const ServiceClass = require(servicePath)
if (!(ServiceClass.prototype instanceof BaseService)) {
throw Error(
`${servicePath} does not export a single service. Invoke new ServiceTester() directly.`
)
}
return ServiceTester.forServiceClass(ServiceClass)
}
module.exports = createServiceTester

View File

@@ -0,0 +1,31 @@
'use strict'
// based on https://github.com/paulmelnikow/icedfrisby-nock/blob/master/icedfrisby-nock.js
// can be used to wrap the original "icedfrisby-nock" to check if request was intercepred
const factory = superclass =>
class IcedFrisbyNock extends superclass {
constructor(message) {
super(message)
this.intercepted = false
}
intercept(setup) {
super.intercept(setup)
this.intercepted = true
return this
}
networkOff() {
super.networkOff()
this.intercepted = true
return this
}
networkOn() {
super.networkOn()
this.intercepted = true
return this
}
}
module.exports = factory

View File

@@ -0,0 +1,109 @@
'use strict'
const emojic = require('emojic')
const frisby = require('./icedfrisby-no-nock')(
require('icedfrisby-nock')(require('icedfrisby'))
)
const trace = require('../base-service/trace')
/**
* Encapsulate a suite of tests. Create new tests using create() and register
* them with Mocha using toss().
*/
class ServiceTester {
/**
* @param attrs { id, title, pathPrefix } The `id` is used to specify which
* tests to run from the CLI or pull requests. The `title` prints in the
* Mocha output. The `path` is the path prefix which is automatically
* prepended to each tested URI. The default is `/${attrs.id}`.
*/
constructor({ id, title, pathPrefix }) {
if (pathPrefix === undefined) {
pathPrefix = `/${id}`
}
Object.assign(this, {
id,
title,
pathPrefix,
specs: [],
_only: false,
})
}
static forServiceClass(ServiceClass) {
const id = ServiceClass.name
const pathPrefix = ServiceClass.route.base
? `/${ServiceClass.route.base}`
: ''
return new this({
id,
title: id,
pathPrefix,
})
}
/**
* Invoked before each test. This is a stub which can be overridden on
* instances.
*/
beforeEach() {}
/**
* Create a new test. The hard work is delegated to IcedFrisby.
* https://github.com/MarkHerhold/IcedFrisby/#show-me-some-code
*
* Note: The caller should not invoke toss() on the Frisby chain, as it's
* invoked automatically by the tester.
* @param msg The name of the test
*/
create(msg) {
const spec = frisby
.create(msg)
.before(() => {
this.beforeEach()
})
// eslint-disable-next-line mocha/prefer-arrow-callback
.finally(function() {
// `this` is the IcedFrisby instance.
let responseBody
try {
responseBody = JSON.parse(this._response.body)
} catch (e) {
responseBody = this._response.body
}
trace.logTrace('outbound', emojic.shield, 'Response', responseBody)
})
this.specs.push(spec)
return spec
}
/**
* Run only this tester. This can be invoked using the --only argument to
* the CLI, or directly on the tester.
*/
only() {
this._only = true
}
/**
* Register the tests with Mocha.
*/
toss({ baseUrl, skipIntercepted }) {
const { specs, pathPrefix } = this
const testerBaseUrl = `${baseUrl}${pathPrefix}`
const fn = this._only ? describe.only : describe
// eslint-disable-next-line mocha/prefer-arrow-callback
fn(this.title, function() {
specs.forEach(spec => {
if (!skipIntercepted || !spec.intercepted) {
spec.baseUri(testerBaseUrl)
spec.toss()
}
})
})
}
}
module.exports = ServiceTester