43
core/base-service/base-json.js
Normal file
43
core/base-service/base-json.js
Normal 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
|
||||
135
core/base-service/base-json.spec.js
Normal file
135
core/base-service/base-json.spec.js
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
55
core/base-service/base-non-memory-caching.js
Normal file
55
core/base-service/base-non-memory-caching.js
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
54
core/base-service/base-static.js
Normal file
54
core/base-service/base-static.js
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
57
core/base-service/base-svg-scraping.js
Normal file
57
core/base-service/base-svg-scraping.js
Normal 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
|
||||
166
core/base-service/base-svg-scraping.spec.js
Normal file
166
core/base-service/base-svg-scraping.spec.js
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
43
core/base-service/base-xml.js
Normal file
43
core/base-service/base-xml.js
Normal 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
|
||||
162
core/base-service/base-xml.spec.js
Normal file
162
core/base-service/base-xml.spec.js
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
49
core/base-service/base-yaml.js
Normal file
49
core/base-service/base-yaml.js
Normal 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
|
||||
158
core/base-service/base-yaml.spec.js
Normal file
158
core/base-service/base-yaml.spec.js
Normal 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
554
core/base-service/base.js
Normal 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
|
||||
854
core/base-service/base.spec.js
Normal file
854
core/base-service/base.spec.js
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
113
core/base-service/cache-headers.js
Normal file
113
core/base-service/cache-headers.js
Normal 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,
|
||||
}
|
||||
229
core/base-service/cache-headers.spec.js
Normal file
229
core/base-service/cache-headers.spec.js
Normal 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)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
35
core/base-service/deprecated-service.js
Normal file
35
core/base-service/deprecated-service.js
Normal 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
|
||||
62
core/base-service/deprecated-service.spec.js
Normal file
62
core/base-service/deprecated-service.spec.js
Normal 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
109
core/base-service/errors.js
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
97
core/base-service/service-definitions.js
Normal file
97
core/base-service/service-definitions.js
Normal 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,
|
||||
}
|
||||
41
core/base-service/trace.js
Normal file
41
core/base-service/trace.js
Normal 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,
|
||||
}
|
||||
166
core/base-service/transform-example.js
Normal file
166
core/base-service/transform-example.js
Normal 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,
|
||||
}
|
||||
53
core/base-service/transform-example.spec.js
Normal file
53
core/base-service/transform-example.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const emojic = require('emojic')
|
||||
const Joi = require('joi')
|
||||
const trace = require('../../services/trace')
|
||||
const trace = require('./trace')
|
||||
|
||||
function validate(
|
||||
{
|
||||
|
||||
@@ -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() {
|
||||
|
||||
6
core/register-chai-plugins.spec.js
Normal file
6
core/register-chai-plugins.spec.js
Normal file
@@ -0,0 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { use } = require('chai')
|
||||
|
||||
use(require('chai-string'))
|
||||
use(require('sinon-chai'))
|
||||
25
core/service-test-runner/create-service-tester.js
Normal file
25
core/service-test-runner/create-service-tester.js
Normal 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
|
||||
31
core/service-test-runner/icedfrisby-no-nock.js
Normal file
31
core/service-test-runner/icedfrisby-no-nock.js
Normal 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
|
||||
109
core/service-test-runner/service-tester.js
Normal file
109
core/service-test-runner/service-tester.js
Normal 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
|
||||
Reference in New Issue
Block a user