Compare commits
25 Commits
integratio
...
express
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc68b88a04 | ||
|
|
ca6ae88504 | ||
|
|
fc13e8e90a | ||
|
|
1761aab020 | ||
|
|
416ef3920a | ||
|
|
25ab4806c0 | ||
|
|
9d6b3e0985 | ||
|
|
18c76e392f | ||
|
|
78b886c010 | ||
|
|
b19ca203e8 | ||
|
|
00df3e1136 | ||
|
|
b4f7ec383e | ||
|
|
6d77534709 | ||
|
|
e8f59a2645 | ||
|
|
8cbc8cb926 | ||
|
|
be9d49083d | ||
|
|
aa046cb510 | ||
|
|
903bef2a4c | ||
|
|
15043dfc92 | ||
|
|
cf6b5b14c7 | ||
|
|
aeebfaa51f | ||
|
|
c3097aad0d | ||
|
|
6e6cec9b2b | ||
|
|
7c071e352e | ||
|
|
6d72fd68e8 |
@@ -1,6 +1,6 @@
|
|||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
const { normalizeColor, toSvgColor } = require('./color')
|
const { toSvgColor } = require('./color')
|
||||||
const badgeRenderers = require('./badge-renderers')
|
const badgeRenderers = require('./badge-renderers')
|
||||||
const { stripXmlWhitespace } = require('./xml')
|
const { stripXmlWhitespace } = require('./xml')
|
||||||
|
|
||||||
@@ -9,7 +9,6 @@ note: makeBadge() is fairly thinly wrapped so if we are making changes here
|
|||||||
it is likely this will impact on the package's public interface in index.js
|
it is likely this will impact on the package's public interface in index.js
|
||||||
*/
|
*/
|
||||||
module.exports = function makeBadge({
|
module.exports = function makeBadge({
|
||||||
format,
|
|
||||||
style = 'flat',
|
style = 'flat',
|
||||||
label,
|
label,
|
||||||
message,
|
message,
|
||||||
@@ -24,22 +23,6 @@ module.exports = function makeBadge({
|
|||||||
label = `${label}`.trim()
|
label = `${label}`.trim()
|
||||||
message = `${message}`.trim()
|
message = `${message}`.trim()
|
||||||
|
|
||||||
// This ought to be the responsibility of the server, not `makeBadge`.
|
|
||||||
if (format === 'json') {
|
|
||||||
return JSON.stringify({
|
|
||||||
label,
|
|
||||||
message,
|
|
||||||
logoWidth,
|
|
||||||
// Only call normalizeColor for the JSON case: this is handled
|
|
||||||
// internally by toSvgColor in the SVG case.
|
|
||||||
color: normalizeColor(color),
|
|
||||||
labelColor: normalizeColor(labelColor),
|
|
||||||
link: links,
|
|
||||||
name: label,
|
|
||||||
value: message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const render = badgeRenderers[style]
|
const render = badgeRenderers[style]
|
||||||
if (!render) {
|
if (!render) {
|
||||||
throw new Error(`Unknown badge style: '${style}'`)
|
throw new Error(`Unknown badge style: '${style}'`)
|
||||||
|
|||||||
@@ -1,143 +1,48 @@
|
|||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
const { test, given, forCases } = require('sazerac')
|
|
||||||
const { expect } = require('chai')
|
const { expect } = require('chai')
|
||||||
const snapshot = require('snap-shot-it')
|
const snapshot = require('snap-shot-it')
|
||||||
const isSvg = require('is-svg')
|
const isSvg = require('is-svg')
|
||||||
const prettier = require('prettier')
|
const prettier = require('prettier')
|
||||||
const makeBadge = require('./make-badge')
|
const makeBadge = require('./make-badge')
|
||||||
|
|
||||||
function expectBadgeToMatchSnapshot(format) {
|
function expectBadgeToMatchSnapshot(badgeData) {
|
||||||
snapshot(prettier.format(makeBadge(format), { parser: 'html' }))
|
snapshot(prettier.format(makeBadge(badgeData), { parser: 'html' }))
|
||||||
}
|
|
||||||
|
|
||||||
function testColor(color = '', colorAttr = 'color') {
|
|
||||||
return JSON.parse(
|
|
||||||
makeBadge({
|
|
||||||
label: 'name',
|
|
||||||
message: 'Bob',
|
|
||||||
[colorAttr]: color,
|
|
||||||
format: 'json',
|
|
||||||
})
|
|
||||||
).color
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('The badge generator', function () {
|
describe('The badge generator', function () {
|
||||||
describe('color test', function () {
|
|
||||||
test(testColor, () => {
|
|
||||||
// valid hex
|
|
||||||
forCases([
|
|
||||||
given('#4c1'),
|
|
||||||
given('#4C1'),
|
|
||||||
given('4C1'),
|
|
||||||
given('4c1'),
|
|
||||||
]).expect('#4c1')
|
|
||||||
forCases([
|
|
||||||
given('#abc123'),
|
|
||||||
given('#ABC123'),
|
|
||||||
given('abc123'),
|
|
||||||
given('ABC123'),
|
|
||||||
]).expect('#abc123')
|
|
||||||
// valid rgb(a)
|
|
||||||
given('rgb(0,128,255)').expect('rgb(0,128,255)')
|
|
||||||
given('rgb(220,128,255,0.5)').expect('rgb(220,128,255,0.5)')
|
|
||||||
given('rgba(0,0,255)').expect('rgba(0,0,255)')
|
|
||||||
given('rgba(0,128,255,0)').expect('rgba(0,128,255,0)')
|
|
||||||
// valid hsl(a)
|
|
||||||
given('hsl(100, 56%, 10%)').expect('hsl(100, 56%, 10%)')
|
|
||||||
given('hsl(360,50%,50%,0.5)').expect('hsl(360,50%,50%,0.5)')
|
|
||||||
given('hsla(25,20%,0%,0.1)').expect('hsla(25,20%,0%,0.1)')
|
|
||||||
given('hsla(0,50%,101%)').expect('hsla(0,50%,101%)')
|
|
||||||
// CSS named color.
|
|
||||||
given('papayawhip').expect('papayawhip')
|
|
||||||
// Shields named color.
|
|
||||||
given('red').expect('red')
|
|
||||||
given('green').expect('green')
|
|
||||||
given('blue').expect('blue')
|
|
||||||
given('yellow').expect('yellow')
|
|
||||||
// Semantic color alias
|
|
||||||
given('success').expect('brightgreen')
|
|
||||||
given('informational').expect('blue')
|
|
||||||
|
|
||||||
forCases(
|
|
||||||
// invalid hex
|
|
||||||
given('#123red'), // contains letter above F
|
|
||||||
given('#red'), // contains letter above F
|
|
||||||
// neither a css named color nor colorscheme
|
|
||||||
given('notacolor'),
|
|
||||||
given('bluish'),
|
|
||||||
given('almostred'),
|
|
||||||
given('brightmaroon'),
|
|
||||||
given('cactus')
|
|
||||||
).expect(undefined)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('color aliases', function () {
|
|
||||||
test(testColor, () => {
|
|
||||||
forCases([given('#4c1', 'color')]).expect('#4c1')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('SVG', function () {
|
describe('SVG', function () {
|
||||||
it('should produce SVG', function () {
|
it('should produce SVG', function () {
|
||||||
expect(makeBadge({ label: 'cactus', message: 'grown', format: 'svg' }))
|
expect(makeBadge({ label: 'cactus', message: 'grown' }))
|
||||||
.to.satisfy(isSvg)
|
.to.satisfy(isSvg)
|
||||||
.and.to.include('cactus')
|
.and.to.include('cactus')
|
||||||
.and.to.include('grown')
|
.and.to.include('grown')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should match snapshot', function () {
|
it('should match snapshot', function () {
|
||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({ label: 'cactus', message: 'grown' })
|
||||||
label: 'cactus',
|
|
||||||
message: 'grown',
|
|
||||||
format: 'svg',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('JSON', function () {
|
|
||||||
it('should produce the expected JSON', function () {
|
|
||||||
const json = makeBadge({
|
|
||||||
label: 'cactus',
|
|
||||||
message: 'grown',
|
|
||||||
format: 'json',
|
|
||||||
links: ['https://example.com/', 'https://other.example.com/'],
|
|
||||||
})
|
|
||||||
expect(JSON.parse(json)).to.deep.equal({
|
|
||||||
name: 'cactus',
|
|
||||||
label: 'cactus',
|
|
||||||
value: 'grown',
|
|
||||||
message: 'grown',
|
|
||||||
link: ['https://example.com/', 'https://other.example.com/'],
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should replace undefined svg badge style with "flat"', function () {
|
it('should replace undefined svg badge style with "flat"', function () {
|
||||||
const jsonBadgeWithUnknownStyle = makeBadge({
|
expect(
|
||||||
label: 'name',
|
makeBadge({
|
||||||
message: 'Bob',
|
label: 'name',
|
||||||
format: 'svg',
|
message: 'Bob',
|
||||||
})
|
})
|
||||||
const jsonBadgeWithDefaultStyle = makeBadge({
|
)
|
||||||
label: 'name',
|
.to.satisfy(isSvg)
|
||||||
message: 'Bob',
|
.and.to.equal(
|
||||||
format: 'svg',
|
makeBadge({
|
||||||
style: 'flat',
|
label: 'name',
|
||||||
})
|
message: 'Bob',
|
||||||
expect(jsonBadgeWithUnknownStyle)
|
style: 'flat',
|
||||||
.to.equal(jsonBadgeWithDefaultStyle)
|
})
|
||||||
.and.to.satisfy(isSvg)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should fail with unknown svg badge style', function () {
|
it('should fail with unknown svg badge style', function () {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
makeBadge({
|
makeBadge({ label: 'name', message: 'Bob', style: 'unknown_style' })
|
||||||
label: 'name',
|
|
||||||
message: 'Bob',
|
|
||||||
format: 'svg',
|
|
||||||
style: 'unknown_style',
|
|
||||||
})
|
|
||||||
).to.throw(Error, "Unknown badge style: 'unknown_style'")
|
).to.throw(Error, "Unknown badge style: 'unknown_style'")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -147,7 +52,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat',
|
style: 'flat',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -158,7 +62,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat',
|
style: 'flat',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -170,7 +73,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat',
|
style: 'flat',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
})
|
})
|
||||||
@@ -180,7 +82,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat',
|
style: 'flat',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
||||||
@@ -191,7 +92,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat',
|
style: 'flat',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -203,7 +103,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat',
|
style: 'flat',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -215,7 +114,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat',
|
style: 'flat',
|
||||||
color: '#000',
|
color: '#000',
|
||||||
labelColor: '#f3f3f3',
|
labelColor: '#f3f3f3',
|
||||||
@@ -226,7 +124,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat',
|
style: 'flat',
|
||||||
color: '#e2ffe1',
|
color: '#e2ffe1',
|
||||||
labelColor: '#000',
|
labelColor: '#000',
|
||||||
@@ -239,7 +136,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat-square',
|
style: 'flat-square',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -250,7 +146,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat-square',
|
style: 'flat-square',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -262,7 +157,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat-square',
|
style: 'flat-square',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
})
|
})
|
||||||
@@ -272,7 +166,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat-square',
|
style: 'flat-square',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
||||||
@@ -283,7 +176,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat-square',
|
style: 'flat-square',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -295,7 +187,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat-square',
|
style: 'flat-square',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -307,7 +198,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat-square',
|
style: 'flat-square',
|
||||||
color: '#000',
|
color: '#000',
|
||||||
labelColor: '#f3f3f3',
|
labelColor: '#f3f3f3',
|
||||||
@@ -318,7 +208,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'flat-square',
|
style: 'flat-square',
|
||||||
color: '#e2ffe1',
|
color: '#e2ffe1',
|
||||||
labelColor: '#000',
|
labelColor: '#000',
|
||||||
@@ -331,7 +220,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'plastic',
|
style: 'plastic',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -342,7 +230,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'plastic',
|
style: 'plastic',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -354,7 +241,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'plastic',
|
style: 'plastic',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
})
|
})
|
||||||
@@ -364,7 +250,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'plastic',
|
style: 'plastic',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
||||||
@@ -375,7 +260,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'plastic',
|
style: 'plastic',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -387,7 +271,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'plastic',
|
style: 'plastic',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -399,7 +282,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'plastic',
|
style: 'plastic',
|
||||||
color: '#000',
|
color: '#000',
|
||||||
labelColor: '#f3f3f3',
|
labelColor: '#f3f3f3',
|
||||||
@@ -410,7 +292,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'plastic',
|
style: 'plastic',
|
||||||
color: '#e2ffe1',
|
color: '#e2ffe1',
|
||||||
labelColor: '#000',
|
labelColor: '#000',
|
||||||
@@ -425,7 +306,6 @@ describe('The badge generator', function () {
|
|||||||
makeBadge({
|
makeBadge({
|
||||||
label: 1998,
|
label: 1998,
|
||||||
message: 1999,
|
message: 1999,
|
||||||
format: 'svg',
|
|
||||||
style: 'for-the-badge',
|
style: 'for-the-badge',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -438,7 +318,6 @@ describe('The badge generator', function () {
|
|||||||
makeBadge({
|
makeBadge({
|
||||||
label: 'Label',
|
label: 'Label',
|
||||||
message: '1 string',
|
message: '1 string',
|
||||||
format: 'svg',
|
|
||||||
style: 'for-the-badge',
|
style: 'for-the-badge',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -450,7 +329,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'for-the-badge',
|
style: 'for-the-badge',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -461,7 +339,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'for-the-badge',
|
style: 'for-the-badge',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -473,7 +350,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'for-the-badge',
|
style: 'for-the-badge',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
})
|
})
|
||||||
@@ -483,7 +359,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'for-the-badge',
|
style: 'for-the-badge',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
||||||
@@ -494,7 +369,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'for-the-badge',
|
style: 'for-the-badge',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -506,7 +380,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'for-the-badge',
|
style: 'for-the-badge',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -518,7 +391,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'for-the-badge',
|
style: 'for-the-badge',
|
||||||
color: '#000',
|
color: '#000',
|
||||||
labelColor: '#f3f3f3',
|
labelColor: '#f3f3f3',
|
||||||
@@ -529,7 +401,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'for-the-badge',
|
style: 'for-the-badge',
|
||||||
color: '#e2ffe1',
|
color: '#e2ffe1',
|
||||||
labelColor: '#000',
|
labelColor: '#000',
|
||||||
@@ -543,7 +414,6 @@ describe('The badge generator', function () {
|
|||||||
makeBadge({
|
makeBadge({
|
||||||
label: 'some-key',
|
label: 'some-key',
|
||||||
message: 'some-value',
|
message: 'some-value',
|
||||||
format: 'svg',
|
|
||||||
style: 'social',
|
style: 'social',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -557,11 +427,10 @@ describe('The badge generator', function () {
|
|||||||
makeBadge({
|
makeBadge({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'some-value',
|
message: 'some-value',
|
||||||
format: 'json',
|
|
||||||
style: 'social',
|
style: 'social',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.to.include('""')
|
.to.include('></text>')
|
||||||
.and.to.include('some-value')
|
.and.to.include('some-value')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -569,7 +438,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'social',
|
style: 'social',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -580,7 +448,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'social',
|
style: 'social',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -592,7 +459,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'social',
|
style: 'social',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
})
|
})
|
||||||
@@ -602,7 +468,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'social',
|
style: 'social',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
||||||
@@ -613,7 +478,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: '',
|
label: '',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'social',
|
style: 'social',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -625,7 +489,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'cactus',
|
label: 'cactus',
|
||||||
message: 'grown',
|
message: 'grown',
|
||||||
format: 'svg',
|
|
||||||
style: 'social',
|
style: 'social',
|
||||||
color: '#b3e',
|
color: '#b3e',
|
||||||
labelColor: '#0f0',
|
labelColor: '#0f0',
|
||||||
@@ -639,7 +502,6 @@ describe('The badge generator', function () {
|
|||||||
expectBadgeToMatchSnapshot({
|
expectBadgeToMatchSnapshot({
|
||||||
label: 'label',
|
label: 'label',
|
||||||
message: 'message',
|
message: 'message',
|
||||||
format: 'svg',
|
|
||||||
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
logo: 'data:image/svg+xml;base64,PHN2ZyB4bWxu',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,58 +1,29 @@
|
|||||||
import makeBadge from '../../badge-maker/lib/make-badge.js'
|
|
||||||
import BaseService from './base.js'
|
import BaseService from './base.js'
|
||||||
import {
|
import {
|
||||||
serverHasBeenUpSinceResourceCached,
|
serverHasBeenUpSinceResourceCached,
|
||||||
setCacheHeadersForStaticResource,
|
setCacheHeadersForStaticResource,
|
||||||
} from './cache-headers.js'
|
} from './cache-headers.js'
|
||||||
import { makeSend } from './legacy-result-sender.js'
|
import { prepareRoute } from './route.js'
|
||||||
import { MetricHelper } from './metric-helper.js'
|
|
||||||
import coalesceBadge from './coalesce-badge.js'
|
|
||||||
import { prepareRoute, namedParamsForMatch } from './route.js'
|
|
||||||
|
|
||||||
export default class BaseStaticService extends BaseService {
|
export default class BaseStaticService extends BaseService {
|
||||||
static register({ camp, metricInstance }, serviceConfig) {
|
static _applyCacheHeaders({ res }) {
|
||||||
const { regex, captureNames } = prepareRoute(this.route)
|
setCacheHeadersForStaticResource(res)
|
||||||
|
}
|
||||||
|
|
||||||
const metricHelper = MetricHelper.create({
|
static register({ app, ...serviceContext }, serviceConfig) {
|
||||||
metricInstance,
|
const { regex } = prepareRoute(this.route)
|
||||||
ServiceClass: this,
|
app.get(
|
||||||
})
|
regex,
|
||||||
|
(req, res, next) => {
|
||||||
camp.route(regex, async (queryParams, match, end, ask) => {
|
if (serverHasBeenUpSinceResourceCached(req)) {
|
||||||
if (serverHasBeenUpSinceResourceCached(ask.req)) {
|
// Send Not Modified.
|
||||||
// Send Not Modified.
|
res.status(304)
|
||||||
ask.res.statusCode = 304
|
res.end()
|
||||||
ask.res.end()
|
} else {
|
||||||
return
|
next()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const metricHandle = metricHelper.startRequest()
|
this.makeExpressHandler(serviceContext, serviceConfig)
|
||||||
|
)
|
||||||
const namedParams = namedParamsForMatch(captureNames, match, this)
|
|
||||||
const serviceData = await this.invoke(
|
|
||||||
{},
|
|
||||||
serviceConfig,
|
|
||||||
namedParams,
|
|
||||||
queryParams
|
|
||||||
)
|
|
||||||
|
|
||||||
const badgeData = coalesceBadge(
|
|
||||||
queryParams,
|
|
||||||
serviceData,
|
|
||||||
this.defaultBadgeData,
|
|
||||||
this
|
|
||||||
)
|
|
||||||
|
|
||||||
// The final capture group is the extension.
|
|
||||||
const format = (match.slice(-1)[0] || '.svg').replace(/^\./, '')
|
|
||||||
badgeData.format = format
|
|
||||||
|
|
||||||
setCacheHeadersForStaticResource(ask.res)
|
|
||||||
|
|
||||||
const svg = makeBadge(badgeData)
|
|
||||||
makeSend(format, ask.res, end)(svg)
|
|
||||||
|
|
||||||
metricHandle.noteResponseSent()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,13 @@
|
|||||||
import emojic from 'emojic'
|
import emojic from 'emojic'
|
||||||
import Joi from 'joi'
|
import Joi from 'joi'
|
||||||
import log from '../server/log.js'
|
import log from '../server/log.js'
|
||||||
|
import makeBadge from '../../badge-maker/lib/make-badge.js'
|
||||||
import { AuthHelper } from './auth-helper.js'
|
import { AuthHelper } from './auth-helper.js'
|
||||||
import { MetricHelper, MetricNames } from './metric-helper.js'
|
import { MetricHelper, MetricNames } from './metric-helper.js'
|
||||||
|
import {
|
||||||
|
coalesceCacheLength,
|
||||||
|
setHeadersForCacheLength,
|
||||||
|
} from './cache-headers.js'
|
||||||
import { assertValidCategory } from './categories.js'
|
import { assertValidCategory } from './categories.js'
|
||||||
import checkErrorResponse from './check-error-response.js'
|
import checkErrorResponse from './check-error-response.js'
|
||||||
import coalesceBadge from './coalesce-badge.js'
|
import coalesceBadge from './coalesce-badge.js'
|
||||||
@@ -21,11 +26,12 @@ import {
|
|||||||
} from './errors.js'
|
} from './errors.js'
|
||||||
import { validateExample, transformExample } from './examples.js'
|
import { validateExample, transformExample } from './examples.js'
|
||||||
import { fetch } from './got.js'
|
import { fetch } from './got.js'
|
||||||
|
import { makeJsonBadge } from './make-json-badge.js'
|
||||||
import {
|
import {
|
||||||
makeFullUrl,
|
makeFullUrl,
|
||||||
assertValidRoute,
|
assertValidRoute,
|
||||||
|
paramsForReq,
|
||||||
prepareRoute,
|
prepareRoute,
|
||||||
namedParamsForMatch,
|
|
||||||
getQueryParamNames,
|
getQueryParamNames,
|
||||||
} from './route.js'
|
} from './route.js'
|
||||||
import { assertValidServiceDefinition } from './service-definitions.js'
|
import { assertValidServiceDefinition } from './service-definitions.js'
|
||||||
@@ -423,60 +429,90 @@ class BaseService {
|
|||||||
return serviceData
|
return serviceData
|
||||||
}
|
}
|
||||||
|
|
||||||
static register(
|
// `defaultCacheLengthSeconds` can be overridden by
|
||||||
{
|
// `serviceDefaultCacheLengthSeconds` (either by category or on a badge-
|
||||||
camp,
|
// by-badge basis). Then in turn that can be overridden by
|
||||||
handleRequest,
|
// `serviceOverrideCacheLengthSeconds` (which we expect to be used only in
|
||||||
githubApiProvider,
|
// the dynamic badge) but only if `serviceOverrideCacheLengthSeconds` is
|
||||||
librariesIoApiProvider,
|
// longer than `serviceDefaultCacheLengthSeconds` and then the `cacheSeconds`
|
||||||
metricInstance,
|
// query param can also override both of those but again only if `cacheSeconds`
|
||||||
},
|
// is longer.
|
||||||
|
//
|
||||||
|
// Ref: https://github.com/badges/shields/pull/2755
|
||||||
|
static _applyCacheHeaders({
|
||||||
|
cacheHeaderConfig,
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
serviceOverrideCacheLengthSeconds,
|
||||||
|
}) {
|
||||||
|
const cacheLengthSeconds = coalesceCacheLength({
|
||||||
|
cacheHeaderConfig,
|
||||||
|
serviceDefaultCacheLengthSeconds: this._cacheLength,
|
||||||
|
serviceOverrideCacheLengthSeconds,
|
||||||
|
queryParams: req.query,
|
||||||
|
})
|
||||||
|
setHeadersForCacheLength(res, cacheLengthSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
static makeExpressHandler(
|
||||||
|
{ githubApiProvider, librariesIoApiProvider, metricInstance },
|
||||||
serviceConfig
|
serviceConfig
|
||||||
) {
|
) {
|
||||||
const { cacheHeaders: cacheHeaderConfig } = serviceConfig
|
|
||||||
const { regex, captureNames } = prepareRoute(this.route)
|
|
||||||
const queryParams = getQueryParamNames(this.route)
|
|
||||||
|
|
||||||
const metricHelper = MetricHelper.create({
|
const metricHelper = MetricHelper.create({
|
||||||
metricInstance,
|
metricInstance,
|
||||||
ServiceClass: this,
|
ServiceClass: this,
|
||||||
})
|
})
|
||||||
|
const { captureNames } = prepareRoute(this.route)
|
||||||
|
const { cacheHeaders: cacheHeaderConfig } = serviceConfig
|
||||||
|
|
||||||
camp.route(
|
return async (req, res) => {
|
||||||
regex,
|
const metricHandle = metricHelper.startRequest()
|
||||||
handleRequest(cacheHeaderConfig, {
|
|
||||||
queryParams,
|
|
||||||
handler: async (queryParams, match, sendBadge) => {
|
|
||||||
const metricHandle = metricHelper.startRequest()
|
|
||||||
|
|
||||||
const namedParams = namedParamsForMatch(captureNames, match, this)
|
const { namedParams, format } = paramsForReq(captureNames, req, this)
|
||||||
const serviceData = await this.invoke(
|
const serviceData = await this.invoke(
|
||||||
{
|
{
|
||||||
requestFetcher: fetch,
|
requestFetcher: fetch,
|
||||||
githubApiProvider,
|
githubApiProvider,
|
||||||
librariesIoApiProvider,
|
librariesIoApiProvider,
|
||||||
metricHelper,
|
metricHelper,
|
||||||
},
|
|
||||||
serviceConfig,
|
|
||||||
namedParams,
|
|
||||||
queryParams
|
|
||||||
)
|
|
||||||
|
|
||||||
const badgeData = coalesceBadge(
|
|
||||||
queryParams,
|
|
||||||
serviceData,
|
|
||||||
this.defaultBadgeData,
|
|
||||||
this
|
|
||||||
)
|
|
||||||
// The final capture group is the extension.
|
|
||||||
const format = (match.slice(-1)[0] || '.svg').replace(/^\./, '')
|
|
||||||
sendBadge(format, badgeData)
|
|
||||||
|
|
||||||
metricHandle.noteResponseSent()
|
|
||||||
},
|
},
|
||||||
cacheLength: this._cacheLength,
|
serviceConfig,
|
||||||
|
namedParams,
|
||||||
|
req.query
|
||||||
|
)
|
||||||
|
|
||||||
|
const badgeData = coalesceBadge(
|
||||||
|
req.query,
|
||||||
|
serviceData,
|
||||||
|
this.defaultBadgeData,
|
||||||
|
this
|
||||||
|
)
|
||||||
|
|
||||||
|
this._applyCacheHeaders({
|
||||||
|
cacheHeaderConfig,
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
serviceOverrideCacheLengthSeconds: badgeData.cacheLengthSeconds,
|
||||||
})
|
})
|
||||||
)
|
|
||||||
|
if (format === 'svg') {
|
||||||
|
res.setHeader('Content-Type', 'image/svg+xml')
|
||||||
|
res.send(makeBadge(badgeData))
|
||||||
|
} else if (format === 'json') {
|
||||||
|
res.json(makeJsonBadge(badgeData))
|
||||||
|
} else {
|
||||||
|
throw Error(`Unrecognized format: ${format}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end()
|
||||||
|
|
||||||
|
metricHandle.noteResponseSent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static register({ app, ...serviceContext }, serviceConfig) {
|
||||||
|
const { regex } = prepareRoute(this.route)
|
||||||
|
app.get(regex, this.makeExpressHandler(serviceContext, serviceConfig))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import Joi from 'joi'
|
import Joi from 'joi'
|
||||||
import chai from 'chai'
|
import chai from 'chai'
|
||||||
|
import isSvg from 'is-svg'
|
||||||
import sinon from 'sinon'
|
import sinon from 'sinon'
|
||||||
import prometheus from 'prom-client'
|
import prometheus from 'prom-client'
|
||||||
import chaiAsPromised from 'chai-as-promised'
|
import chaiAsPromised from 'chai-as-promised'
|
||||||
import PrometheusMetrics from '../server/prometheus-metrics.js'
|
import PrometheusMetrics from '../server/prometheus-metrics.js'
|
||||||
|
import { ExpressTestHarness } from '../express-test-harness.js'
|
||||||
import trace from './trace.js'
|
import trace from './trace.js'
|
||||||
import {
|
import {
|
||||||
NotFound,
|
NotFound,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
import BaseService from './base.js'
|
import BaseService from './base.js'
|
||||||
import { MetricHelper, MetricNames } from './metric-helper.js'
|
import { MetricHelper, MetricNames } from './metric-helper.js'
|
||||||
import '../register-chai-plugins.spec.js'
|
import '../register-chai-plugins.spec.js'
|
||||||
|
|
||||||
const { expect } = chai
|
const { expect } = chai
|
||||||
chai.use(chaiAsPromised)
|
chai.use(chaiAsPromised)
|
||||||
|
|
||||||
@@ -59,9 +62,12 @@ class DummyServiceWithServiceResponseSizeMetricEnabled extends DummyService {
|
|||||||
|
|
||||||
describe('BaseService', function () {
|
describe('BaseService', function () {
|
||||||
const defaultConfig = {
|
const defaultConfig = {
|
||||||
|
handleInternalErrors: false,
|
||||||
|
cacheHeaders: { defaultCacheLengthSeconds: 120 },
|
||||||
public: {
|
public: {
|
||||||
handleInternalErrors: false,
|
handleInternalErrors: false,
|
||||||
services: {},
|
services: {},
|
||||||
|
cacheHeaders: { defaultCacheLengthSeconds: 120 },
|
||||||
},
|
},
|
||||||
private: {},
|
private: {},
|
||||||
}
|
}
|
||||||
@@ -321,62 +327,45 @@ describe('BaseService', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('ScoutCamp integration', function () {
|
describe('Express integration', function () {
|
||||||
// TODO Strangly, without the useless escape the regexes do not match in Node 12.
|
let harness
|
||||||
// eslint-disable-next-line no-useless-escape
|
beforeEach(async function () {
|
||||||
const expectedRouteRegex = /^\/foo(?:\/([^\/#\?]+?))(|\.svg|\.json)$/
|
harness = new ExpressTestHarness()
|
||||||
|
DummyService.register({ app: harness.app }, defaultConfig)
|
||||||
|
await harness.start()
|
||||||
|
})
|
||||||
|
|
||||||
let mockCamp
|
afterEach(async function () {
|
||||||
let mockHandleRequest
|
await harness.stop()
|
||||||
|
})
|
||||||
|
|
||||||
beforeEach(function () {
|
it('fulfills the request for an SVG badge', async function () {
|
||||||
mockCamp = {
|
const { headers, body } = await harness.get(
|
||||||
route: sinon.spy(),
|
'/foo/bar.svg?queryParamA=%3F'
|
||||||
}
|
|
||||||
mockHandleRequest = sinon.spy()
|
|
||||||
DummyService.register(
|
|
||||||
{ camp: mockCamp, handleRequest: mockHandleRequest },
|
|
||||||
defaultConfig
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
expect(headers).to.include({
|
||||||
|
'content-type': 'image/svg+xml; charset=utf-8',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(body)
|
||||||
|
.to.satisfy(isSvg)
|
||||||
|
.and.to.include('cat: Hello namedParamA: bar with queryParamA: ?')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('registers the service', function () {
|
it('fulfills the request for a JSON badge', async function () {
|
||||||
expect(mockCamp.route).to.have.been.calledOnce
|
const { headers, body } = await harness.get(
|
||||||
expect(mockCamp.route).to.have.been.calledWith(expectedRouteRegex)
|
'/foo/bar.json?queryParamA=%3F',
|
||||||
})
|
{ responseType: 'json' }
|
||||||
|
)
|
||||||
|
|
||||||
it('handles the request', async function () {
|
expect(headers).to.include({
|
||||||
expect(mockHandleRequest).to.have.been.calledOnce
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
})
|
||||||
|
|
||||||
const { queryParams: serviceQueryParams, handler: requestHandler } =
|
expect(body).to.include({
|
||||||
mockHandleRequest.getCall(0).args[1]
|
|
||||||
expect(serviceQueryParams).to.deep.equal([
|
|
||||||
'queryParamA',
|
|
||||||
'legacyQueryParamA',
|
|
||||||
])
|
|
||||||
|
|
||||||
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, {
|
|
||||||
label: 'cat',
|
label: 'cat',
|
||||||
message: 'Hello namedParamA: bar with queryParamA: ?',
|
message: 'Hello namedParamA: bar with queryParamA: ?',
|
||||||
color: 'lightgrey',
|
|
||||||
style: 'flat',
|
|
||||||
namedLogo: undefined,
|
|
||||||
logo: undefined,
|
|
||||||
logoWidth: undefined,
|
|
||||||
logoPosition: undefined,
|
|
||||||
links: [],
|
|
||||||
labelColor: undefined,
|
|
||||||
cacheLengthSeconds: undefined,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -574,9 +563,7 @@ describe('BaseService', function () {
|
|||||||
},
|
},
|
||||||
private: {},
|
private: {},
|
||||||
},
|
},
|
||||||
{
|
{ namedParamA: 'bar.bar.bar' }
|
||||||
namedParamA: 'bar.bar.bar',
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
).to.deep.equal({
|
).to.deep.equal({
|
||||||
color: 'lightgray',
|
color: 'lightgray',
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import makeBadge from '../../badge-maker/lib/make-badge.js'
|
|
||||||
import { setCacheHeaders } from './cache-headers.js'
|
|
||||||
import { makeSend } from './legacy-result-sender.js'
|
|
||||||
import coalesceBadge from './coalesce-badge.js'
|
|
||||||
|
|
||||||
// These query parameters are available to any badge. They are handled by
|
|
||||||
// `coalesceBadge`.
|
|
||||||
const globalQueryParams = new Set([
|
|
||||||
'label',
|
|
||||||
'style',
|
|
||||||
'link',
|
|
||||||
'logo',
|
|
||||||
'logoColor',
|
|
||||||
'logoPosition',
|
|
||||||
'logoWidth',
|
|
||||||
'link',
|
|
||||||
'colorA',
|
|
||||||
'colorB',
|
|
||||||
'color',
|
|
||||||
'labelColor',
|
|
||||||
])
|
|
||||||
|
|
||||||
function flattenQueryParams(queryParams) {
|
|
||||||
const union = new Set(globalQueryParams)
|
|
||||||
;(queryParams || []).forEach(name => {
|
|
||||||
union.add(name)
|
|
||||||
})
|
|
||||||
return Array.from(union).sort()
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlerOptions can contain:
|
|
||||||
// - handler: The service's request handler function
|
|
||||||
// - queryParams: An array of the field names of any custom query parameters
|
|
||||||
// the service uses
|
|
||||||
// - cacheLength: An optional badge or category-specific cache length
|
|
||||||
// (in number of seconds) to be used in preference to the default
|
|
||||||
//
|
|
||||||
// For safety, the service must declare the query parameters it wants to use.
|
|
||||||
// Only the declared parameters (and the global parameters) are provided to
|
|
||||||
// the service. Consequently, failure to declare a parameter results in the
|
|
||||||
// parameter not working at all (which is undesirable, but easy to debug)
|
|
||||||
// rather than indeterminate behavior that depends on the cache state
|
|
||||||
// (undesirable and hard to debug).
|
|
||||||
//
|
|
||||||
// Pass just the handler function as shorthand.
|
|
||||||
function handleRequest(cacheHeaderConfig, handlerOptions) {
|
|
||||||
if (!cacheHeaderConfig) {
|
|
||||||
throw Error('cacheHeaderConfig is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof handlerOptions === 'function') {
|
|
||||||
handlerOptions = { handler: handlerOptions }
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowedKeys = flattenQueryParams(handlerOptions.queryParams)
|
|
||||||
const { cacheLength: serviceDefaultCacheLengthSeconds } = handlerOptions
|
|
||||||
|
|
||||||
return (queryParams, match, end, ask) => {
|
|
||||||
/*
|
|
||||||
This is here for legacy reasons. The badge server and frontend used to live
|
|
||||||
on two different servers. When we merged them there was a conflict so we
|
|
||||||
did this to avoid moving the endpoint docs to another URL.
|
|
||||||
|
|
||||||
Never ever do this again.
|
|
||||||
*/
|
|
||||||
if (match[0] === '/endpoint' && Object.keys(queryParams).length === 0) {
|
|
||||||
ask.res.statusCode = 301
|
|
||||||
ask.res.setHeader('Location', '/endpoint/')
|
|
||||||
ask.res.end()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// `defaultCacheLengthSeconds` can be overridden by
|
|
||||||
// `serviceDefaultCacheLengthSeconds` (either by category or on a badge-
|
|
||||||
// by-badge basis). Then in turn that can be overridden by
|
|
||||||
// `serviceOverrideCacheLengthSeconds` (which we expect to be used only in
|
|
||||||
// the dynamic badge) but only if `serviceOverrideCacheLengthSeconds` is
|
|
||||||
// longer than `serviceDefaultCacheLengthSeconds` and then the `cacheSeconds`
|
|
||||||
// query param can also override both of those but again only if `cacheSeconds`
|
|
||||||
// is longer.
|
|
||||||
//
|
|
||||||
// When the legacy services have been rewritten, all the code in here
|
|
||||||
// will go away, which should achieve this goal in a simpler way.
|
|
||||||
//
|
|
||||||
// Ref: https://github.com/badges/shields/pull/2755
|
|
||||||
function setCacheHeadersOnResponse(res, serviceOverrideCacheLengthSeconds) {
|
|
||||||
setCacheHeaders({
|
|
||||||
cacheHeaderConfig,
|
|
||||||
serviceDefaultCacheLengthSeconds,
|
|
||||||
serviceOverrideCacheLengthSeconds,
|
|
||||||
queryParams,
|
|
||||||
res,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredQueryParams = {}
|
|
||||||
allowedKeys.forEach(key => {
|
|
||||||
filteredQueryParams[key] = queryParams[key]
|
|
||||||
})
|
|
||||||
|
|
||||||
// In case our vendor servers are unresponsive.
|
|
||||||
let serverUnresponsive = false
|
|
||||||
const serverResponsive = setTimeout(() => {
|
|
||||||
serverUnresponsive = true
|
|
||||||
ask.res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
|
||||||
const badgeData = coalesceBadge(
|
|
||||||
filteredQueryParams,
|
|
||||||
{ label: 'vendor', message: 'unresponsive' },
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
const svg = makeBadge(badgeData)
|
|
||||||
const extension = (match.slice(-1)[0] || '.svg').replace(/^\./, '')
|
|
||||||
setCacheHeadersOnResponse(ask.res)
|
|
||||||
makeSend(extension, ask.res, end)(svg)
|
|
||||||
}, 25000)
|
|
||||||
|
|
||||||
const result = handlerOptions.handler(
|
|
||||||
filteredQueryParams,
|
|
||||||
match,
|
|
||||||
// eslint-disable-next-line mocha/prefer-arrow-callback
|
|
||||||
function sendBadge(format, badgeData) {
|
|
||||||
if (serverUnresponsive) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clearTimeout(serverResponsive)
|
|
||||||
// Add format to badge data.
|
|
||||||
badgeData.format = format
|
|
||||||
const svg = makeBadge(badgeData)
|
|
||||||
setCacheHeadersOnResponse(ask.res, badgeData.cacheLengthSeconds)
|
|
||||||
makeSend(format, ask.res, end)(svg)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
// eslint-disable-next-line promise/prefer-await-to-then
|
|
||||||
if (result && result.catch) {
|
|
||||||
// eslint-disable-next-line promise/prefer-await-to-then
|
|
||||||
result.catch(err => {
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { handleRequest }
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
import { expect } from 'chai'
|
|
||||||
import portfinder from 'portfinder'
|
|
||||||
import Camp from '@shields_io/camp'
|
|
||||||
import got from '../got-test-client.js'
|
|
||||||
import coalesceBadge from './coalesce-badge.js'
|
|
||||||
import { handleRequest } from './legacy-request-handler.js'
|
|
||||||
|
|
||||||
async function performTwoRequests(baseUrl, first, second) {
|
|
||||||
expect((await got(`${baseUrl}${first}`)).statusCode).to.equal(200)
|
|
||||||
expect((await got(`${baseUrl}${second}`)).statusCode).to.equal(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fakeHandler(queryParams, match, sendBadge, request) {
|
|
||||||
const [, someValue, format] = match
|
|
||||||
const badgeData = coalesceBadge(
|
|
||||||
queryParams,
|
|
||||||
{
|
|
||||||
label: 'testing',
|
|
||||||
message: someValue,
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
sendBadge(format, badgeData)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFakeHandlerWithCacheLength(cacheLengthSeconds) {
|
|
||||||
return function fakeHandler(queryParams, match, sendBadge, request) {
|
|
||||||
const [, someValue, format] = match
|
|
||||||
const badgeData = coalesceBadge(
|
|
||||||
queryParams,
|
|
||||||
{
|
|
||||||
label: 'testing',
|
|
||||||
message: someValue,
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
_cacheLength: cacheLengthSeconds,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
sendBadge(format, badgeData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('The request handler', function () {
|
|
||||||
let port, baseUrl
|
|
||||||
beforeEach(async function () {
|
|
||||||
port = await portfinder.getPortPromise()
|
|
||||||
baseUrl = `http://127.0.0.1:${port}`
|
|
||||||
})
|
|
||||||
|
|
||||||
let camp
|
|
||||||
beforeEach(function (done) {
|
|
||||||
camp = Camp.start({ port, hostname: '::' })
|
|
||||||
camp.on('listening', () => done())
|
|
||||||
})
|
|
||||||
afterEach(function (done) {
|
|
||||||
if (camp) {
|
|
||||||
camp.close(() => done())
|
|
||||||
camp = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const standardCacheHeaders = { defaultCacheLengthSeconds: 120 }
|
|
||||||
|
|
||||||
describe('the options object calling style', function () {
|
|
||||||
beforeEach(function () {
|
|
||||||
camp.route(
|
|
||||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
|
||||||
handleRequest(standardCacheHeaders, { handler: fakeHandler })
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return the expected response', async function () {
|
|
||||||
const { statusCode, body } = await got(`${baseUrl}/testing/123.json`, {
|
|
||||||
responseType: 'json',
|
|
||||||
})
|
|
||||||
expect(statusCode).to.equal(200)
|
|
||||||
expect(body).to.deep.equal({
|
|
||||||
name: 'testing',
|
|
||||||
value: '123',
|
|
||||||
label: 'testing',
|
|
||||||
message: '123',
|
|
||||||
color: 'lightgrey',
|
|
||||||
link: [],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('the function shorthand calling style', function () {
|
|
||||||
beforeEach(function () {
|
|
||||||
camp.route(
|
|
||||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
|
||||||
handleRequest(standardCacheHeaders, fakeHandler)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return the expected response', async function () {
|
|
||||||
const { statusCode, body } = await got(`${baseUrl}/testing/123.json`, {
|
|
||||||
responseType: 'json',
|
|
||||||
})
|
|
||||||
expect(statusCode).to.equal(200)
|
|
||||||
expect(body).to.deep.equal({
|
|
||||||
name: 'testing',
|
|
||||||
value: '123',
|
|
||||||
label: 'testing',
|
|
||||||
message: '123',
|
|
||||||
color: 'lightgrey',
|
|
||||||
link: [],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('caching', function () {
|
|
||||||
describe('standard query parameters', function () {
|
|
||||||
function register({ cacheHeaderConfig }) {
|
|
||||||
camp.route(
|
|
||||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
|
||||||
handleRequest(
|
|
||||||
cacheHeaderConfig,
|
|
||||||
(queryParams, match, sendBadge, request) => {
|
|
||||||
fakeHandler(queryParams, match, sendBadge, request)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
it('should set the expires header to current time + defaultCacheLengthSeconds', async function () {
|
|
||||||
register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 900 } })
|
|
||||||
const { headers } = await got(`${baseUrl}/testing/123.json`)
|
|
||||||
const expectedExpiry = new Date(
|
|
||||||
+new Date(headers.date) + 900000
|
|
||||||
).toGMTString()
|
|
||||||
expect(headers.expires).to.equal(expectedExpiry)
|
|
||||||
expect(headers['cache-control']).to.equal('max-age=900, s-maxage=900')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should set the expected cache headers on cached responses', async function () {
|
|
||||||
register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 900 } })
|
|
||||||
|
|
||||||
// Make first request.
|
|
||||||
await got(`${baseUrl}/testing/123.json`)
|
|
||||||
|
|
||||||
const { headers } = await got(`${baseUrl}/testing/123.json`)
|
|
||||||
const expectedExpiry = new Date(
|
|
||||||
+new Date(headers.date) + 900000
|
|
||||||
).toGMTString()
|
|
||||||
expect(headers.expires).to.equal(expectedExpiry)
|
|
||||||
expect(headers['cache-control']).to.equal('max-age=900, s-maxage=900')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should let live service data override the default cache headers with longer value', async function () {
|
|
||||||
camp.route(
|
|
||||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
|
||||||
handleRequest(
|
|
||||||
{ defaultCacheLengthSeconds: 300 },
|
|
||||||
(queryParams, match, sendBadge, request) => {
|
|
||||||
createFakeHandlerWithCacheLength(400)(
|
|
||||||
queryParams,
|
|
||||||
match,
|
|
||||||
sendBadge,
|
|
||||||
request
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const { headers } = await got(`${baseUrl}/testing/123.json`)
|
|
||||||
expect(headers['cache-control']).to.equal('max-age=400, s-maxage=400')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not let live service data override the default cache headers with shorter value', async function () {
|
|
||||||
camp.route(
|
|
||||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
|
||||||
handleRequest(
|
|
||||||
{ defaultCacheLengthSeconds: 300 },
|
|
||||||
(queryParams, match, sendBadge, request) => {
|
|
||||||
createFakeHandlerWithCacheLength(200)(
|
|
||||||
queryParams,
|
|
||||||
match,
|
|
||||||
sendBadge,
|
|
||||||
request
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const { headers } = await got(`${baseUrl}/testing/123.json`)
|
|
||||||
expect(headers['cache-control']).to.equal('max-age=300, s-maxage=300')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should set the expires header to current time + cacheSeconds', async function () {
|
|
||||||
register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 0 } })
|
|
||||||
const { headers } = await got(
|
|
||||||
`${baseUrl}/testing/123.json?cacheSeconds=3600`
|
|
||||||
)
|
|
||||||
const expectedExpiry = new Date(
|
|
||||||
+new Date(headers.date) + 3600000
|
|
||||||
).toGMTString()
|
|
||||||
expect(headers.expires).to.equal(expectedExpiry)
|
|
||||||
expect(headers['cache-control']).to.equal('max-age=3600, s-maxage=3600')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should ignore cacheSeconds when shorter than defaultCacheLengthSeconds', async function () {
|
|
||||||
register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 600 } })
|
|
||||||
const { headers } = await got(
|
|
||||||
`${baseUrl}/testing/123.json?cacheSeconds=300`
|
|
||||||
)
|
|
||||||
const expectedExpiry = new Date(
|
|
||||||
+new Date(headers.date) + 600000
|
|
||||||
).toGMTString()
|
|
||||||
expect(headers.expires).to.equal(expectedExpiry)
|
|
||||||
expect(headers['cache-control']).to.equal('max-age=600, s-maxage=600')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should set Cache-Control: no-cache, no-store, must-revalidate if cache seconds is 0', async function () {
|
|
||||||
register({ cacheHeaderConfig: { defaultCacheLengthSeconds: 0 } })
|
|
||||||
const { headers } = await got(`${baseUrl}/testing/123.json`)
|
|
||||||
expect(headers.expires).to.equal(headers.date)
|
|
||||||
expect(headers['cache-control']).to.equal(
|
|
||||||
'no-cache, no-store, must-revalidate'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('custom query parameters', function () {
|
|
||||||
let handlerCallCount
|
|
||||||
beforeEach(function () {
|
|
||||||
handlerCallCount = 0
|
|
||||||
camp.route(
|
|
||||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
|
||||||
handleRequest(standardCacheHeaders, {
|
|
||||||
queryParams: ['foo'],
|
|
||||||
handler: (queryParams, match, sendBadge, request) => {
|
|
||||||
++handlerCallCount
|
|
||||||
fakeHandler(queryParams, match, sendBadge, request)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should differentiate them', async function () {
|
|
||||||
await performTwoRequests(
|
|
||||||
baseUrl,
|
|
||||||
'/testing/123.svg?foo=1',
|
|
||||||
'/testing/123.svg?foo=2'
|
|
||||||
)
|
|
||||||
expect(handlerCallCount).to.equal(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import stream from 'stream'
|
|
||||||
|
|
||||||
function streamFromString(str) {
|
|
||||||
const newStream = new stream.Readable()
|
|
||||||
newStream._read = () => {
|
|
||||||
newStream.push(str)
|
|
||||||
newStream.push(null)
|
|
||||||
}
|
|
||||||
return newStream
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendSVG(res, askres, end) {
|
|
||||||
askres.setHeader('Content-Type', 'image/svg+xml;charset=utf-8')
|
|
||||||
askres.setHeader('Content-Length', Buffer.byteLength(res, 'utf8'))
|
|
||||||
end(null, { template: streamFromString(res) })
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendJSON(res, askres, end) {
|
|
||||||
askres.setHeader('Content-Type', 'application/json')
|
|
||||||
askres.setHeader('Access-Control-Allow-Origin', '*')
|
|
||||||
askres.setHeader('Content-Length', Buffer.byteLength(res, 'utf8'))
|
|
||||||
end(null, { template: streamFromString(res) })
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSend(format, askres, end) {
|
|
||||||
if (format === 'svg') {
|
|
||||||
return res => sendSVG(res, askres, end)
|
|
||||||
} else if (format === 'json') {
|
|
||||||
return res => sendJSON(res, askres, end)
|
|
||||||
} else {
|
|
||||||
throw Error(`Unrecognized format: ${format}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { makeSend }
|
|
||||||
16
core/base-service/make-json-badge.js
Normal file
16
core/base-service/make-json-badge.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { normalizeColor } from 'badge-maker/lib/color.js'
|
||||||
|
|
||||||
|
export function makeJsonBadge(badgeData) {
|
||||||
|
const { label, message, logoWidth, color, labelColor, links } = badgeData
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
message,
|
||||||
|
logoWidth,
|
||||||
|
color: normalizeColor(color),
|
||||||
|
labelColor: normalizeColor(labelColor),
|
||||||
|
link: links,
|
||||||
|
name: label,
|
||||||
|
value: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
23
core/base-service/make-json-badge.spec.js
Normal file
23
core/base-service/make-json-badge.spec.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { expect } from 'chai'
|
||||||
|
import { makeJsonBadge } from './make-json-badge.js'
|
||||||
|
|
||||||
|
describe('makeJsonBadge()', function () {
|
||||||
|
it('should produce the expected JSON', function () {
|
||||||
|
expect(
|
||||||
|
makeJsonBadge({
|
||||||
|
label: 'cactus',
|
||||||
|
message: 'grown',
|
||||||
|
links: ['https://example.com/', 'https://other.example.com/'],
|
||||||
|
})
|
||||||
|
).to.deep.equal({
|
||||||
|
name: 'cactus',
|
||||||
|
label: 'cactus',
|
||||||
|
value: 'grown',
|
||||||
|
message: 'grown',
|
||||||
|
link: ['https://example.com/', 'https://other.example.com/'],
|
||||||
|
color: undefined,
|
||||||
|
labelColor: undefined,
|
||||||
|
logoWidth: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import url from 'url'
|
||||||
import camelcase from 'camelcase'
|
import camelcase from 'camelcase'
|
||||||
import emojic from 'emojic'
|
import emojic from 'emojic'
|
||||||
import Joi from 'joi'
|
import Joi from 'joi'
|
||||||
@@ -9,7 +10,7 @@ import {
|
|||||||
} from './cache-headers.js'
|
} from './cache-headers.js'
|
||||||
import { isValidCategory } from './categories.js'
|
import { isValidCategory } from './categories.js'
|
||||||
import { MetricHelper } from './metric-helper.js'
|
import { MetricHelper } from './metric-helper.js'
|
||||||
import { isValidRoute, prepareRoute, namedParamsForMatch } from './route.js'
|
import { isValidRoute, prepareRoute, paramsForReq } from './route.js'
|
||||||
import trace from './trace.js'
|
import trace from './trace.js'
|
||||||
|
|
||||||
const attrSchema = Joi.object({
|
const attrSchema = Joi.object({
|
||||||
@@ -54,7 +55,7 @@ export default function redirector(attrs) {
|
|||||||
static route = route
|
static route = route
|
||||||
static examples = examples
|
static examples = examples
|
||||||
|
|
||||||
static register({ camp, metricInstance }, { rasterUrl }) {
|
static register({ app, metricInstance }, { rasterUrl }) {
|
||||||
const { regex, captureNames } = prepareRoute({
|
const { regex, captureNames } = prepareRoute({
|
||||||
...this.route,
|
...this.route,
|
||||||
withPng: Boolean(rasterUrl),
|
withPng: Boolean(rasterUrl),
|
||||||
@@ -65,17 +66,17 @@ export default function redirector(attrs) {
|
|||||||
ServiceClass: this,
|
ServiceClass: this,
|
||||||
})
|
})
|
||||||
|
|
||||||
camp.route(regex, async (queryParams, match, end, ask) => {
|
app.get(regex, async (req, res) => {
|
||||||
if (serverHasBeenUpSinceResourceCached(ask.req)) {
|
if (serverHasBeenUpSinceResourceCached(req)) {
|
||||||
// Send Not Modified.
|
// Send Not Modified.
|
||||||
ask.res.statusCode = 304
|
res.status(304)
|
||||||
ask.res.end()
|
res.end()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const metricHandle = metricHelper.startRequest()
|
const metricHandle = metricHelper.startRequest()
|
||||||
|
|
||||||
const namedParams = namedParamsForMatch(captureNames, match, this)
|
const { namedParams, format } = paramsForReq(captureNames, req, this)
|
||||||
trace.logTrace(
|
trace.logTrace(
|
||||||
'inbound',
|
'inbound',
|
||||||
emojic.arrowHeadingUp,
|
emojic.arrowHeadingUp,
|
||||||
@@ -83,12 +84,12 @@ export default function redirector(attrs) {
|
|||||||
route.base
|
route.base
|
||||||
)
|
)
|
||||||
trace.logTrace('inbound', emojic.ticket, 'Named params', namedParams)
|
trace.logTrace('inbound', emojic.ticket, 'Named params', namedParams)
|
||||||
trace.logTrace('inbound', emojic.crayon, 'Query params', queryParams)
|
trace.logTrace('inbound', emojic.crayon, 'Query params', req.query)
|
||||||
|
|
||||||
const targetPath = encodeURI(transformPath(namedParams))
|
const targetPath = encodeURI(transformPath(namedParams))
|
||||||
trace.logTrace('validate', emojic.dart, 'Target', targetPath)
|
trace.logTrace('validate', emojic.dart, 'Target', targetPath)
|
||||||
|
|
||||||
let urlSuffix = ask.uri.search || ''
|
let urlSuffix = url.parse(req.url).search ?? '' // eslint-disable-line node/no-deprecated-api
|
||||||
|
|
||||||
if (transformQueryParams) {
|
if (transformQueryParams) {
|
||||||
const specifiedParams = queryString.parse(urlSuffix)
|
const specifiedParams = queryString.parse(urlSuffix)
|
||||||
@@ -100,21 +101,18 @@ export default function redirector(attrs) {
|
|||||||
urlSuffix = `?${outQueryString}`
|
urlSuffix = `?${outQueryString}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// The final capture group is the extension.
|
const baseUrl = format === 'png' ? rasterUrl : ''
|
||||||
const format = (match.slice(-1)[0] || '.svg').replace(/^\./, '')
|
const redirectUrl = `${baseUrl}${targetPath}.${format}${urlSuffix}`
|
||||||
const redirectUrl = `${
|
|
||||||
format === 'png' ? rasterUrl : ''
|
|
||||||
}${targetPath}.${format}${urlSuffix}`
|
|
||||||
trace.logTrace('outbound', emojic.shield, 'Redirect URL', redirectUrl)
|
trace.logTrace('outbound', emojic.shield, 'Redirect URL', redirectUrl)
|
||||||
|
|
||||||
ask.res.statusCode = 301
|
res.status(301)
|
||||||
ask.res.setHeader('Location', redirectUrl)
|
res.setHeader('Location', redirectUrl)
|
||||||
|
|
||||||
// To avoid caching mistakes for a long time, and to make this simpler
|
// To avoid caching mistakes for a long time, and to make this simpler
|
||||||
// to reason about, use the same cache semantics as the static badge.
|
// to reason about, use the same cache semantics as the static badge.
|
||||||
setCacheHeadersForStaticResource(ask.res)
|
setCacheHeadersForStaticResource(res)
|
||||||
|
|
||||||
ask.res.end()
|
res.end()
|
||||||
|
|
||||||
metricHandle.noteResponseSent()
|
metricHandle.noteResponseSent()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import Camp from '@shields_io/camp'
|
|
||||||
import portfinder from 'portfinder'
|
|
||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import got from '../got-test-client.js'
|
import { ExpressTestHarness } from '../express-test-harness.js'
|
||||||
import redirector from './redirector.js'
|
import redirector from './redirector.js'
|
||||||
|
|
||||||
describe('Redirector', function () {
|
describe('Redirector', function () {
|
||||||
@@ -63,28 +61,12 @@ describe('Redirector', function () {
|
|||||||
expect(redirector({ ...attrs, examples }).examples).to.equal(examples)
|
expect(redirector({ ...attrs, examples }).examples).to.equal(examples)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('ScoutCamp integration', function () {
|
describe('Express integration', function () {
|
||||||
let port, baseUrl
|
|
||||||
beforeEach(async function () {
|
|
||||||
port = await portfinder.getPortPromise()
|
|
||||||
baseUrl = `http://127.0.0.1:${port}`
|
|
||||||
})
|
|
||||||
|
|
||||||
let camp
|
|
||||||
beforeEach(async function () {
|
|
||||||
camp = Camp.start({ port, hostname: '::' })
|
|
||||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
|
||||||
})
|
|
||||||
afterEach(async function () {
|
|
||||||
if (camp) {
|
|
||||||
await new Promise(resolve => camp.close(resolve))
|
|
||||||
camp = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const transformPath = ({ namedParamA }) => `/new/service/${namedParamA}`
|
const transformPath = ({ namedParamA }) => `/new/service/${namedParamA}`
|
||||||
|
|
||||||
beforeEach(function () {
|
let harness
|
||||||
|
beforeEach(async function () {
|
||||||
|
harness = new ExpressTestHarness()
|
||||||
const ServiceClass = redirector({
|
const ServiceClass = redirector({
|
||||||
category,
|
category,
|
||||||
route,
|
route,
|
||||||
@@ -92,17 +74,20 @@ describe('Redirector', function () {
|
|||||||
dateAdded,
|
dateAdded,
|
||||||
})
|
})
|
||||||
ServiceClass.register(
|
ServiceClass.register(
|
||||||
{ camp },
|
{ app: harness.app },
|
||||||
{ rasterUrl: 'http://raster.example.test' }
|
{ rasterUrl: 'http://raster.example.test' }
|
||||||
)
|
)
|
||||||
|
await harness.start()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async function () {
|
||||||
|
await harness.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should redirect as configured', async function () {
|
it('should redirect as configured', async function () {
|
||||||
const { statusCode, headers } = await got(
|
const { statusCode, headers } = await harness.get(
|
||||||
`${baseUrl}/very/old/service/hello-world.svg`,
|
'/very/old/service/hello-world.svg',
|
||||||
{
|
{ followRedirect: false }
|
||||||
followRedirect: false,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(statusCode).to.equal(301)
|
expect(statusCode).to.equal(301)
|
||||||
@@ -110,11 +95,9 @@ describe('Redirector', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should redirect raster extensions to the canonical path as configured', async function () {
|
it('should redirect raster extensions to the canonical path as configured', async function () {
|
||||||
const { statusCode, headers } = await got(
|
const { statusCode, headers } = await harness.get(
|
||||||
`${baseUrl}/very/old/service/hello-world.png`,
|
'/very/old/service/hello-world.png',
|
||||||
{
|
{ followRedirect: false }
|
||||||
followRedirect: false,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(statusCode).to.equal(301)
|
expect(statusCode).to.equal(301)
|
||||||
@@ -124,11 +107,9 @@ describe('Redirector', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should forward the query params', async function () {
|
it('should forward the query params', async function () {
|
||||||
const { statusCode, headers } = await got(
|
const { statusCode, headers } = await harness.get(
|
||||||
`${baseUrl}/very/old/service/hello-world.svg?color=123&style=flat-square`,
|
'/very/old/service/hello-world.svg?color=123&style=flat-square',
|
||||||
{
|
{ followRedirect: false }
|
||||||
followRedirect: false,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(statusCode).to.equal(301)
|
expect(statusCode).to.equal(301)
|
||||||
@@ -138,11 +119,9 @@ describe('Redirector', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should correctly encode the redirect URL', async function () {
|
it('should correctly encode the redirect URL', async function () {
|
||||||
const { statusCode, headers } = await got(
|
const { statusCode, headers } = await harness.get(
|
||||||
`${baseUrl}/very/old/service/hello%0Dworld.svg?foobar=a%0Db`,
|
'/very/old/service/hello%0Dworld.svg?foobar=a%0Db',
|
||||||
{
|
{ followRedirect: false }
|
||||||
followRedirect: false,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(statusCode).to.equal(301)
|
expect(statusCode).to.equal(301)
|
||||||
@@ -166,15 +145,13 @@ describe('Redirector', function () {
|
|||||||
transformQueryParams,
|
transformQueryParams,
|
||||||
dateAdded,
|
dateAdded,
|
||||||
})
|
})
|
||||||
ServiceClass.register({ camp }, {})
|
ServiceClass.register({ app: harness.app }, {})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should forward the transformed query params', async function () {
|
it('should forward the transformed query params', async function () {
|
||||||
const { statusCode, headers } = await got(
|
const { statusCode, headers } = await harness.get(
|
||||||
`${baseUrl}/another/old/service/token/abc123/hello-world.svg`,
|
'/another/old/service/token/abc123/hello-world.svg',
|
||||||
{
|
{ followRedirect: false }
|
||||||
followRedirect: false,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(statusCode).to.equal(301)
|
expect(statusCode).to.equal(301)
|
||||||
@@ -184,11 +161,9 @@ describe('Redirector', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should forward the specified and transformed query params', async function () {
|
it('should forward the specified and transformed query params', async function () {
|
||||||
const { statusCode, headers } = await got(
|
const { statusCode, headers } = await harness.get(
|
||||||
`${baseUrl}/another/old/service/token/abc123/hello-world.svg?color=123&style=flat-square`,
|
'/another/old/service/token/abc123/hello-world.svg?color=123&style=flat-square',
|
||||||
{
|
{ followRedirect: false }
|
||||||
followRedirect: false,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(statusCode).to.equal(301)
|
expect(statusCode).to.equal(301)
|
||||||
@@ -198,11 +173,9 @@ describe('Redirector', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should use transformed query params on param conflicts by default', async function () {
|
it('should use transformed query params on param conflicts by default', async function () {
|
||||||
const { statusCode, headers } = await got(
|
const { statusCode, headers } = await harness.get(
|
||||||
`${baseUrl}/another/old/service/token/abc123/hello-world.svg?color=123&style=flat-square&token=def456`,
|
'/another/old/service/token/abc123/hello-world.svg?color=123&style=flat-square&token=def456',
|
||||||
{
|
{ followRedirect: false }
|
||||||
followRedirect: false,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(statusCode).to.equal(301)
|
expect(statusCode).to.equal(301)
|
||||||
@@ -224,12 +197,10 @@ describe('Redirector', function () {
|
|||||||
overrideTransformedQueryParams: true,
|
overrideTransformedQueryParams: true,
|
||||||
dateAdded,
|
dateAdded,
|
||||||
})
|
})
|
||||||
ServiceClass.register({ camp }, {})
|
ServiceClass.register({ app: harness.app }, {})
|
||||||
const { statusCode, headers } = await got(
|
const { statusCode, headers } = await harness.get(
|
||||||
`${baseUrl}/override/service/token/abc123/hello-world.svg?style=flat-square&token=def456`,
|
'/override/service/token/abc123/hello-world.svg?style=flat-square&token=def456',
|
||||||
{
|
{ followRedirect: false }
|
||||||
followRedirect: false,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(statusCode).to.equal(301)
|
expect(statusCode).to.equal(301)
|
||||||
|
|||||||
@@ -44,23 +44,29 @@ function prepareRoute({ base, pattern, format, capture, withPng }) {
|
|||||||
return { regex, captureNames }
|
return { regex, captureNames }
|
||||||
}
|
}
|
||||||
|
|
||||||
function namedParamsForMatch(captureNames = [], match, ServiceClass) {
|
function paramsForReq(captureNames = [], req, ServiceClass) {
|
||||||
// Assume the last match is the format, and drop match[0], which is the
|
// In addition to the parameters declared by the service, we have one match
|
||||||
// entire match.
|
// for the format.
|
||||||
const captures = match.slice(1, -1)
|
const expectedNamedParamCount = Object.keys(req.params).length - 1
|
||||||
|
if (captureNames.length !== expectedNamedParamCount) {
|
||||||
if (captureNames.length !== captures.length) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Service ${ServiceClass.name} declares incorrect number of named params ` +
|
`Service ${ServiceClass.name} declares incorrect number of named params ` +
|
||||||
`(expected ${captures.length}, got ${captureNames.length})`
|
`(expected ${expectedNamedParamCount}, got ${captureNames.length})`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = {}
|
const namedParams = {}
|
||||||
captureNames.forEach((name, index) => {
|
captureNames.forEach((name, index) => {
|
||||||
result[name] = captures[index]
|
namedParams[name] = req.params[index]
|
||||||
})
|
})
|
||||||
return result
|
|
||||||
|
// The final capture group is the extension.
|
||||||
|
const format = (req.params[expectedNamedParamCount] || '.svg').replace(
|
||||||
|
/^\./,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
|
||||||
|
return { namedParams, format }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getQueryParamNames({ queryParamSchema }) {
|
function getQueryParamNames({ queryParamSchema }) {
|
||||||
@@ -77,6 +83,6 @@ export {
|
|||||||
isValidRoute,
|
isValidRoute,
|
||||||
assertValidRoute,
|
assertValidRoute,
|
||||||
prepareRoute,
|
prepareRoute,
|
||||||
namedParamsForMatch,
|
paramsForReq,
|
||||||
getQueryParamNames,
|
getQueryParamNames,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import Joi from 'joi'
|
import Joi from 'joi'
|
||||||
import { test, given, forCases } from 'sazerac'
|
import { test, given } from 'sazerac'
|
||||||
import {
|
import { prepareRoute, paramsForReq, getQueryParamNames } from './route.js'
|
||||||
prepareRoute,
|
|
||||||
namedParamsForMatch,
|
function paramsForPath({ regex, captureNames, ServiceClass }, path) {
|
||||||
getQueryParamNames,
|
// Prepare a mock express `req` object.
|
||||||
} from './route.js'
|
const params = {}
|
||||||
|
regex.exec(path).forEach((param, i) => {
|
||||||
|
// regex.exec(path)[0] contains the entire path. We want [1] ... [n].
|
||||||
|
if (i > 0) {
|
||||||
|
params[i - 1] = param
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const req = { params }
|
||||||
|
|
||||||
|
return paramsForReq(captureNames, req, ServiceClass)
|
||||||
|
}
|
||||||
|
|
||||||
describe('Route helpers', function () {
|
describe('Route helpers', function () {
|
||||||
|
const ServiceClass = { name: 'MyService' }
|
||||||
|
|
||||||
context('A `pattern` with a named param is declared', function () {
|
context('A `pattern` with a named param is declared', function () {
|
||||||
const { regex, captureNames } = prepareRoute({
|
const { regex, captureNames } = prepareRoute({
|
||||||
base: 'foo',
|
base: 'foo',
|
||||||
@@ -15,22 +27,31 @@ describe('Route helpers', function () {
|
|||||||
queryParamSchema: Joi.object({ queryParamA: Joi.string() }).required(),
|
queryParamSchema: Joi.object({ queryParamA: Joi.string() }).required(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const regexExec = str => regex.exec(str)
|
const regexExec = path => regex.exec(path)
|
||||||
test(regexExec, () => {
|
test(regexExec, () => {
|
||||||
given('/foo/bar/bar.svg').expect(null)
|
given('/foo/bar/bar.svg').expect(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
const namedParams = str =>
|
const params = path =>
|
||||||
namedParamsForMatch(captureNames, regex.exec(str))
|
paramsForPath({ regex, captureNames, ServiceClass }, path)
|
||||||
test(namedParams, () => {
|
test(params, () => {
|
||||||
forCases([
|
given('/foo/bar.bar.bar.svg').expect({
|
||||||
given('/foo/bar.bar.bar.svg'),
|
namedParams: { namedParamA: 'bar.bar.bar' },
|
||||||
given('/foo/bar.bar.bar.json'),
|
format: 'svg',
|
||||||
]).expect({ namedParamA: 'bar.bar.bar' })
|
})
|
||||||
|
given('/foo/bar.bar.bar.json').expect({
|
||||||
|
namedParams: { namedParamA: 'bar.bar.bar' },
|
||||||
|
format: 'json',
|
||||||
|
})
|
||||||
// This pattern catches bugs related to escaping the extension separator.
|
// This pattern catches bugs related to escaping the extension separator.
|
||||||
given('/foo/bar.bar.bar_svg').expect({ namedParamA: 'bar.bar.bar_svg' })
|
given('/foo/bar.bar.bar_svg').expect({
|
||||||
given('/foo/bar.bar.bar.zip').expect({ namedParamA: 'bar.bar.bar.zip' })
|
namedParams: { namedParamA: 'bar.bar.bar_svg' },
|
||||||
|
format: 'svg',
|
||||||
|
})
|
||||||
|
given('/foo/bar.bar.bar.zip').expect({
|
||||||
|
namedParams: { namedParamA: 'bar.bar.bar.zip' },
|
||||||
|
format: 'svg',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,33 +67,41 @@ describe('Route helpers', function () {
|
|||||||
given('/foo/bar/bar.svg').expect(null)
|
given('/foo/bar/bar.svg').expect(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
const namedParams = str =>
|
const params = path =>
|
||||||
namedParamsForMatch(captureNames, regex.exec(str))
|
paramsForPath({ regex, captureNames, ServiceClass }, path)
|
||||||
test(namedParams, () => {
|
test(params, () => {
|
||||||
forCases([
|
given('/foo/bar.bar.bar.svg').expect({
|
||||||
given('/foo/bar.bar.bar.svg'),
|
namedParams: { namedParamA: 'bar.bar.bar' },
|
||||||
given('/foo/bar.bar.bar.json'),
|
format: 'svg',
|
||||||
]).expect({ namedParamA: 'bar.bar.bar' })
|
})
|
||||||
|
given('/foo/bar.bar.bar.json').expect({
|
||||||
|
namedParams: { namedParamA: 'bar.bar.bar' },
|
||||||
|
format: 'json',
|
||||||
|
})
|
||||||
|
|
||||||
// This pattern catches bugs related to escaping the extension separator.
|
// This pattern catches bugs related to escaping the extension separator.
|
||||||
given('/foo/bar.bar.bar_svg').expect({ namedParamA: 'bar.bar.bar_svg' })
|
given('/foo/bar.bar.bar_svg').expect({
|
||||||
given('/foo/bar.bar.bar.zip').expect({ namedParamA: 'bar.bar.bar.zip' })
|
namedParams: { namedParamA: 'bar.bar.bar_svg' },
|
||||||
|
format: 'svg',
|
||||||
|
})
|
||||||
|
given('/foo/bar.bar.bar.zip').expect({
|
||||||
|
namedParams: { namedParamA: 'bar.bar.bar.zip' },
|
||||||
|
format: 'svg',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
context('No named params are declared', function () {
|
context('No named params are declared', function () {
|
||||||
const { regex, captureNames } = prepareRoute({
|
const { regex, captureNames } = prepareRoute({
|
||||||
base: 'foo',
|
base: 'foo',
|
||||||
format: '(?:[^/]+)',
|
format: '(?:[^/]+?)',
|
||||||
})
|
})
|
||||||
|
|
||||||
const namedParams = str =>
|
const params = path =>
|
||||||
namedParamsForMatch(captureNames, regex.exec(str))
|
paramsForPath({ regex, captureNames, ServiceClass }, path)
|
||||||
test(namedParams, () => {
|
test(params, () => {
|
||||||
forCases([
|
given('/foo/bar.bar.bar.svg').expect({ namedParams: {}, format: 'svg' })
|
||||||
given('/foo/bar.bar.bar.svg'),
|
given('/foo/bar.bar.bar.json').expect({ namedParams: {}, format: 'json' })
|
||||||
given('/foo/bar.bar.bar.json'),
|
|
||||||
]).expect({})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -83,13 +112,13 @@ describe('Route helpers', function () {
|
|||||||
capture: ['namedParamA'],
|
capture: ['namedParamA'],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(() =>
|
it('Throws the expected error', function () {
|
||||||
namedParamsForMatch(captureNames, regex.exec('/foo/bar/baz.svg'), {
|
expect(() =>
|
||||||
name: 'MyService',
|
paramsForPath({ regex, captureNames, ServiceClass }, '/foo/bar/baz.svg')
|
||||||
})
|
).to.throw(
|
||||||
).to.throw(
|
'Service MyService declares incorrect number of named params (expected 2, got 1)'
|
||||||
'Service MyService declares incorrect number of named params (expected 2, got 1)'
|
)
|
||||||
)
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('getQueryParamNames', function () {
|
it('getQueryParamNames', function () {
|
||||||
|
|||||||
37
core/express-test-harness.js
Normal file
37
core/express-test-harness.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import portfinder from 'portfinder'
|
||||||
|
import got from './got-test-client.js'
|
||||||
|
|
||||||
|
export class ExpressTestHarness {
|
||||||
|
constructor() {
|
||||||
|
this.app = express()
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
const port = (this.port = await portfinder.getPortPromise())
|
||||||
|
this.baseUrl = `http://127.0.0.1:${port}`
|
||||||
|
await new Promise(resolve => {
|
||||||
|
this.server = this.app.listen({ host: '::', port }, () => resolve())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
await new Promise(resolve => this.server.close(resolve))
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureStarted() {
|
||||||
|
if (!this.server) {
|
||||||
|
throw Error('Server has not been started')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(url, options) {
|
||||||
|
this.ensureStarted()
|
||||||
|
return got.get(`${this.baseUrl}${url}`, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(url, options) {
|
||||||
|
this.ensureStarted()
|
||||||
|
return got.post(`${this.baseUrl}${url}`, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,12 +37,13 @@ export default class PrometheusMetrics {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerMetricsEndpoint(server) {
|
async registerMetricsEndpoint(app) {
|
||||||
const { register } = this
|
const { register } = this
|
||||||
|
|
||||||
server.route(/^\/metrics$/, async (data, match, end, ask) => {
|
app.get('/metrics', async (req, res) => {
|
||||||
ask.res.setHeader('Content-Type', register.contentType)
|
res.setHeader('Content-Type', register.contentType)
|
||||||
ask.res.end(await register.metrics())
|
res.send(await register.metrics())
|
||||||
|
res.end()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,24 @@
|
|||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import Camp from '@shields_io/camp'
|
import { ExpressTestHarness } from '../express-test-harness.js'
|
||||||
import portfinder from 'portfinder'
|
|
||||||
import got from '../got-test-client.js'
|
|
||||||
import Metrics from './prometheus-metrics.js'
|
import Metrics from './prometheus-metrics.js'
|
||||||
|
|
||||||
describe('Prometheus metrics route', function () {
|
describe('Prometheus metrics route', function () {
|
||||||
let port, baseUrl, camp, metrics
|
let harness, metrics
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
port = await portfinder.getPortPromise()
|
harness = new ExpressTestHarness()
|
||||||
baseUrl = `http://127.0.0.1:${port}`
|
|
||||||
camp = Camp.start({ port, hostname: '::' })
|
metrics = new Metrics()
|
||||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
metrics.registerMetricsEndpoint(harness.app)
|
||||||
|
|
||||||
|
await harness.start()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async function () {
|
afterEach(async function () {
|
||||||
if (metrics) {
|
await harness.stop()
|
||||||
metrics.stop()
|
|
||||||
}
|
|
||||||
if (camp) {
|
|
||||||
await new Promise(resolve => camp.close(resolve))
|
|
||||||
camp = undefined
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns default metrics', async function () {
|
it('returns default metrics', async function () {
|
||||||
metrics = new Metrics()
|
const { statusCode, body } = await harness.get('/metrics')
|
||||||
metrics.registerMetricsEndpoint(camp)
|
|
||||||
|
|
||||||
const { statusCode, body } = await got(`${baseUrl}/metrics`)
|
|
||||||
|
|
||||||
expect(statusCode).to.be.equal(200)
|
expect(statusCode).to.be.equal(200)
|
||||||
expect(body).to.contain('nodejs_version_info')
|
expect(body).to.contain('nodejs_version_info')
|
||||||
|
|||||||
@@ -2,19 +2,20 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import http from 'http'
|
||||||
|
import https from 'https'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import url, { fileURLToPath } from 'url'
|
import url, { fileURLToPath } from 'url'
|
||||||
|
import express from 'express'
|
||||||
import { bootstrap } from 'global-agent'
|
import { bootstrap } from 'global-agent'
|
||||||
import cloudflareMiddleware from 'cloudflare-middleware'
|
import cloudflareMiddleware from 'cloudflare-middleware'
|
||||||
import Camp from '@shields_io/camp'
|
|
||||||
import originalJoi from 'joi'
|
import originalJoi from 'joi'
|
||||||
import makeBadge from '../../badge-maker/lib/make-badge.js'
|
import makeBadge from '../../badge-maker/lib/make-badge.js'
|
||||||
import GithubConstellation from '../../services/github/github-constellation.js'
|
import GithubConstellation from '../../services/github/github-constellation.js'
|
||||||
import LibrariesIoConstellation from '../../services/librariesio/librariesio-constellation.js'
|
import LibrariesIoConstellation from '../../services/librariesio/librariesio-constellation.js'
|
||||||
import { setRoutes } from '../../services/suggest.js'
|
import { setRoutes as setSuggestRoutes } from '../../services/suggest.js'
|
||||||
import { loadServiceClasses } from '../base-service/loader.js'
|
import { loadServiceClasses } from '../base-service/loader.js'
|
||||||
import { makeSend } from '../base-service/legacy-result-sender.js'
|
import { makeJsonBadge } from '../base-service/make-json-badge.js'
|
||||||
import { handleRequest } from '../base-service/legacy-request-handler.js'
|
|
||||||
import { clearResourceCache } from '../base-service/resource-cache.js'
|
import { clearResourceCache } from '../base-service/resource-cache.js'
|
||||||
import { rasterRedirectUrl } from '../badge-urls/make-badge-url.js'
|
import { rasterRedirectUrl } from '../badge-urls/make-badge-url.js'
|
||||||
import { fileSize, nonNegativeInteger } from '../../services/validators.js'
|
import { fileSize, nonNegativeInteger } from '../../services/validators.js'
|
||||||
@@ -140,7 +141,9 @@ const publicConfigSchema = Joi.object({
|
|||||||
weblate: defaultService,
|
weblate: defaultService,
|
||||||
trace: Joi.boolean().required(),
|
trace: Joi.boolean().required(),
|
||||||
}).required(),
|
}).required(),
|
||||||
cacheHeaders: { defaultCacheLengthSeconds: nonNegativeInteger },
|
cacheHeaders: Joi.object({
|
||||||
|
defaultCacheLengthSeconds: nonNegativeInteger,
|
||||||
|
}).required(),
|
||||||
handleInternalErrors: Joi.boolean().required(),
|
handleInternalErrors: Joi.boolean().required(),
|
||||||
fetchLimit: fileSize,
|
fetchLimit: fileSize,
|
||||||
userAgentBase: Joi.string().required(),
|
userAgentBase: Joi.string().required(),
|
||||||
@@ -197,23 +200,11 @@ const privateMetricsInfluxConfigSchema = privateConfigSchema.append({
|
|||||||
influx_password: Joi.string().required(),
|
influx_password: Joi.string().required(),
|
||||||
})
|
})
|
||||||
|
|
||||||
function addHandlerAtIndex(camp, index, handlerFn) {
|
|
||||||
camp.stack.splice(index, 0, handlerFn)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOnHeroku() {
|
|
||||||
return !!process.env.DYNO
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOnFly() {
|
|
||||||
return !!process.env.FLY_APP_NAME
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Server is based on the web framework Scoutcamp. It creates
|
* The Server is based on Express. It creates an http server and sets up helpers
|
||||||
* an http server, sets up helpers for token persistence and monitoring.
|
* for token persistence and monitoring. Then it loads all the services,
|
||||||
* Then it loads all the services, injecting dependencies as it
|
* injecting dependencies, as it asks each one to register its route with
|
||||||
* asks each one to register its route with Scoutcamp.
|
* Express.
|
||||||
*/
|
*/
|
||||||
class Server {
|
class Server {
|
||||||
/**
|
/**
|
||||||
@@ -306,45 +297,25 @@ class Server {
|
|||||||
|
|
||||||
// See https://www.viget.com/articles/heroku-cloudflare-the-right-way/
|
// See https://www.viget.com/articles/heroku-cloudflare-the-right-way/
|
||||||
requireCloudflare() {
|
requireCloudflare() {
|
||||||
// Set `req.ip`, which is expected by `cloudflareMiddleware()`. This is set
|
const { app } = this
|
||||||
// by Express but not Scoutcamp.
|
app.use(cloudflareMiddleware())
|
||||||
addHandlerAtIndex(this.camp, 0, function (req, res, next) {
|
|
||||||
if (isOnHeroku()) {
|
|
||||||
// On Heroku, `req.socket.remoteAddress` is the Heroku router. However,
|
|
||||||
// the router ensures that the last item in the `X-Forwarded-For` header
|
|
||||||
// is the real origin.
|
|
||||||
// https://stackoverflow.com/a/18517550/893113
|
|
||||||
req.ip = req.headers['x-forwarded-for'].split(', ').pop()
|
|
||||||
} else if (isOnFly()) {
|
|
||||||
// On Fly we can use the Fly-Client-IP header
|
|
||||||
// https://fly.io/docs/reference/runtime-environment/#request-headers
|
|
||||||
req.ip = req.headers['fly-client-ip']
|
|
||||||
? req.headers['fly-client-ip']
|
|
||||||
: req.socket.remoteAddress
|
|
||||||
} else {
|
|
||||||
req.ip = req.socket.remoteAddress
|
|
||||||
}
|
|
||||||
next()
|
|
||||||
})
|
|
||||||
addHandlerAtIndex(this.camp, 1, cloudflareMiddleware())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up Scoutcamp routes for 404/not found responses
|
* Set up Express routes for 404/not found responses.
|
||||||
*/
|
*/
|
||||||
registerErrorHandlers() {
|
registerErrorHandlers() {
|
||||||
const { camp, config } = this
|
const { app, config } = this
|
||||||
const {
|
const {
|
||||||
public: { rasterUrl },
|
public: { rasterUrl },
|
||||||
} = config
|
} = config
|
||||||
|
|
||||||
camp.route(/\.(gif|jpg)$/, (query, match, end, request) => {
|
app.get(/\.(gif|jpg)$/, (req, res) => {
|
||||||
const [, format] = match
|
res.status(410)
|
||||||
makeSend(
|
res.setHeader('Content-Type', 'image/svg+xml;charset=utf-8')
|
||||||
'svg',
|
|
||||||
request.res,
|
const format = req.params[0]
|
||||||
end
|
res.send(
|
||||||
)(
|
|
||||||
makeBadge({
|
makeBadge({
|
||||||
label: '410',
|
label: '410',
|
||||||
message: `${format} no longer available`,
|
message: `${format} no longer available`,
|
||||||
@@ -352,41 +323,53 @@ class Server {
|
|||||||
format: 'svg',
|
format: 'svg',
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
res.end()
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!rasterUrl) {
|
if (!rasterUrl) {
|
||||||
camp.route(/\.png$/, (query, match, end, request) => {
|
app.get(/\.png$/, (req, res) => {
|
||||||
makeSend(
|
res.status(404)
|
||||||
'svg',
|
res.setHeader('Content-Type', 'image/svg+xml;charset=utf-8')
|
||||||
request.res,
|
res.send(
|
||||||
end
|
|
||||||
)(
|
|
||||||
makeBadge({
|
makeBadge({
|
||||||
label: '404',
|
label: '404',
|
||||||
message: 'raster badges not available',
|
message: 'raster badges not available',
|
||||||
color: 'lightgray',
|
color: 'lightgray',
|
||||||
format: 'svg',
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
res.end()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
camp.notfound(/(\.svg|\.json|)$/, (query, match, end, request) => {
|
registerNotFoundHandlers() {
|
||||||
const [, extension] = match
|
const { app } = this
|
||||||
const format = (extension || '.svg').replace(/^\./, '')
|
|
||||||
|
|
||||||
makeSend(
|
app.get(/\.json$/, (req, res) => {
|
||||||
format,
|
res.status(404)
|
||||||
request.res,
|
res.setHeader('Content-Type', 'application/json')
|
||||||
end
|
res.json(
|
||||||
)(
|
makeJsonBadge({
|
||||||
|
label: '404',
|
||||||
|
message: 'badge not found',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get(/(?:\.svg|)$/, (req, res) => {
|
||||||
|
res.status(404)
|
||||||
|
res.setHeader('Content-Type', 'image/svg+xml;charset=utf-8')
|
||||||
|
res.send(
|
||||||
makeBadge({
|
makeBadge({
|
||||||
label: '404',
|
label: '404',
|
||||||
message: 'badge not found',
|
message: 'badge not found',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
format,
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
res.end()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,54 +381,62 @@ class Server {
|
|||||||
* to {@link https://shields.io/} )
|
* to {@link https://shields.io/} )
|
||||||
*/
|
*/
|
||||||
registerRedirects() {
|
registerRedirects() {
|
||||||
const { config, camp } = this
|
const { config, app } = this
|
||||||
const {
|
const {
|
||||||
public: { rasterUrl, redirectUrl },
|
public: { rasterUrl, redirectUrl },
|
||||||
} = config
|
} = config
|
||||||
|
|
||||||
if (rasterUrl) {
|
if (rasterUrl) {
|
||||||
// Redirect to the raster server for raster versions of modern badges.
|
// Redirect to the raster server for raster versions of modern badges.
|
||||||
camp.route(/\.png$/, (queryParams, match, end, ask) => {
|
app.get(/\.png$/, (req, res) => {
|
||||||
ask.res.statusCode = 301
|
res.status(301)
|
||||||
ask.res.setHeader(
|
res.setHeader('Location', rasterRedirectUrl({ rasterUrl }, req.url))
|
||||||
'Location',
|
|
||||||
rasterRedirectUrl({ rasterUrl }, ask.req.url)
|
|
||||||
)
|
|
||||||
|
|
||||||
const cacheDuration = (30 * 24 * 3600) | 0 // 30 days.
|
const cacheDuration = (30 * 24 * 3600) | 0 // 30 days.
|
||||||
ask.res.setHeader('Cache-Control', `max-age=${cacheDuration}`)
|
res.setHeader('Cache-Control', `max-age=${cacheDuration}`)
|
||||||
|
|
||||||
ask.res.end()
|
res.end()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
camp.route(/^\/$/, (data, match, end, ask) => {
|
app.get('/', (req, res) => {
|
||||||
ask.res.statusCode = 302
|
res.status(302)
|
||||||
ask.res.setHeader('Location', redirectUrl)
|
res.setHeader('Location', redirectUrl)
|
||||||
ask.res.end()
|
res.end()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
This is here for legacy reasons. The badge server and frontend used to live
|
||||||
|
on two different servers. When we merged them there was a conflict so we did
|
||||||
|
this to avoid moving the endpoint docs to another URL.
|
||||||
|
|
||||||
|
Never ever do this again.
|
||||||
|
*/
|
||||||
|
app.use('/endpoint', (req, res, next) => {
|
||||||
|
if (Object.keys(req.query).length === 0) {
|
||||||
|
res.status(301)
|
||||||
|
res.setHeader('Location', '/endpoint/')
|
||||||
|
res.end()
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iterate all the service classes defined in /services,
|
* Iterate all the service classes defined in /services,
|
||||||
* load each service and register a Scoutcamp route for each service.
|
* load each service and register an Express route for each service.
|
||||||
*/
|
*/
|
||||||
async registerServices() {
|
async registerServices() {
|
||||||
const { config, camp, metricInstance } = this
|
const { app, config, metricInstance } = this
|
||||||
const { apiProvider: githubApiProvider } = this.githubConstellation
|
const { apiProvider: githubApiProvider } = this.githubConstellation
|
||||||
const { apiProvider: librariesIoApiProvider } =
|
const { apiProvider: librariesIoApiProvider } =
|
||||||
this.librariesioConstellation
|
this.librariesioConstellation
|
||||||
;(await loadServiceClasses()).forEach(serviceClass =>
|
;(await loadServiceClasses()).forEach(serviceClass =>
|
||||||
serviceClass.register(
|
serviceClass.register(
|
||||||
{
|
{ app, githubApiProvider, librariesIoApiProvider, metricInstance },
|
||||||
camp,
|
|
||||||
handleRequest,
|
|
||||||
githubApiProvider,
|
|
||||||
librariesIoApiProvider,
|
|
||||||
metricInstance,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
handleInternalErrors: config.public.handleInternalErrors,
|
handleInternalErrors: config.public.handleInternalErrors,
|
||||||
cacheHeaders: config.public.cacheHeaders,
|
cacheHeaders: config.public.cacheHeaders,
|
||||||
@@ -478,11 +469,14 @@ class Server {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the HTTP server:
|
* Start the HTTP server:
|
||||||
* Bootstrap Scoutcamp,
|
* Bootstrap Express,
|
||||||
* Register handlers,
|
* Register handlers,
|
||||||
* Start listening for requests on this.baseUrl()
|
* Start listening for requests on this.baseUrl()
|
||||||
|
*
|
||||||
|
* @param {Function} registerExtras Optional function to register additional
|
||||||
|
* routes, used for testing.
|
||||||
*/
|
*/
|
||||||
async start() {
|
async start(registerExtras) {
|
||||||
const {
|
const {
|
||||||
bind: { port, address: hostname },
|
bind: { port, address: hostname },
|
||||||
ssl: { isSecure: secure, cert, key },
|
ssl: { isSecure: secure, cert, key },
|
||||||
@@ -494,25 +488,17 @@ class Server {
|
|||||||
|
|
||||||
log.log(`Server is starting up: ${this.baseUrl}`)
|
log.log(`Server is starting up: ${this.baseUrl}`)
|
||||||
|
|
||||||
const camp = (this.camp = Camp.create({
|
const app = (this.app = express())
|
||||||
documentRoot: this.config.public.documentRoot,
|
|
||||||
port,
|
|
||||||
hostname,
|
|
||||||
secure,
|
|
||||||
staticMaxAge: 300,
|
|
||||||
cert,
|
|
||||||
key,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (requireCloudflare) {
|
if (requireCloudflare) {
|
||||||
this.requireCloudflare()
|
this.requireCloudflare()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { githubConstellation, metricInstance } = this
|
const { githubConstellation, metricInstance } = this
|
||||||
await githubConstellation.initialize(camp)
|
await githubConstellation.initialize(app)
|
||||||
if (metricInstance) {
|
if (metricInstance) {
|
||||||
if (this.config.public.metrics.prometheus.endpointEnabled) {
|
if (this.config.public.metrics.prometheus.endpointEnabled) {
|
||||||
metricInstance.registerMetricsEndpoint(camp)
|
metricInstance.registerMetricsEndpoint(app)
|
||||||
}
|
}
|
||||||
if (this.influxMetrics) {
|
if (this.influxMetrics) {
|
||||||
this.influxMetrics.startPushingMetrics()
|
this.influxMetrics.startPushingMetrics()
|
||||||
@@ -520,39 +506,47 @@ class Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { apiProvider: githubApiProvider } = this.githubConstellation
|
const { apiProvider: githubApiProvider } = this.githubConstellation
|
||||||
setRoutes(allowedOrigin, githubApiProvider, camp)
|
setSuggestRoutes(allowedOrigin, githubApiProvider, app)
|
||||||
|
|
||||||
// https://github.com/badges/shields/issues/3273
|
// https://github.com/badges/shields/issues/3273
|
||||||
camp.handle((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.registerErrorHandlers()
|
this.registerErrorHandlers()
|
||||||
this.registerRedirects()
|
this.registerRedirects()
|
||||||
await this.registerServices()
|
app.use(
|
||||||
|
express.static(this.config.public.documentRoot, {
|
||||||
camp.timeout = this.config.public.requestTimeoutSeconds * 1000
|
// Since express's `maxAge` parameter sets `Cache-Control: public`, set
|
||||||
if (this.config.public.requestTimeoutSeconds > 0) {
|
// the headers manually insetad.
|
||||||
camp.on('timeout', socket => {
|
cacheControl: false,
|
||||||
const maxAge = this.config.public.requestTimeoutMaxAgeSeconds
|
setHeaders: res =>
|
||||||
socket.write('HTTP/1.1 408 Request Timeout\r\n')
|
res.setHeader('Cache-Control', 'max-age=300, s-maxage=300'),
|
||||||
socket.write('Content-Type: text/html; charset=UTF-8\r\n')
|
|
||||||
socket.write('Content-Encoding: UTF-8\r\n')
|
|
||||||
socket.write(`Cache-Control: max-age=${maxAge}, s-maxage=${maxAge}\r\n`)
|
|
||||||
socket.write('Connection: close\r\n\r\n')
|
|
||||||
socket.write('Request Timeout')
|
|
||||||
socket.end()
|
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
await this.registerServices()
|
||||||
|
if (registerExtras) {
|
||||||
|
registerExtras(app)
|
||||||
}
|
}
|
||||||
camp.listenAsConfigured()
|
this.registerNotFoundHandlers()
|
||||||
|
|
||||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
if (secure) {
|
||||||
|
this.server = https.createServer({ hostname, cert, key }, app)
|
||||||
|
} else {
|
||||||
|
this.server = http.createServer({ hostname }, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.server.setTimeout(this.config.public.requestTimeoutSeconds * 1000)
|
||||||
|
|
||||||
|
await new Promise(resolve =>
|
||||||
|
this.server.listen({ host: hostname, port }, () => resolve())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static resetGlobalState() {
|
static resetGlobalState() {
|
||||||
// This state should be migrated to instance state. When possible, do not add new
|
// TODO: This state should be migrated to instance state. When possible, do
|
||||||
// global state.
|
// not add new global state.
|
||||||
clearResourceCache()
|
clearResourceCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,10 +558,11 @@ class Server {
|
|||||||
* Stop the HTTP server and clean up helpers
|
* Stop the HTTP server and clean up helpers
|
||||||
*/
|
*/
|
||||||
async stop() {
|
async stop() {
|
||||||
if (this.camp) {
|
if (this.server) {
|
||||||
await new Promise(resolve => this.camp.close(resolve))
|
await new Promise(resolve => this.server.close(() => resolve()))
|
||||||
this.camp = undefined
|
this.server = undefined
|
||||||
}
|
}
|
||||||
|
this.app = undefined
|
||||||
|
|
||||||
if (this.cleanupMonitor) {
|
if (this.cleanupMonitor) {
|
||||||
this.cleanupMonitor()
|
this.cleanupMonitor()
|
||||||
|
|||||||
@@ -73,9 +73,7 @@ describe('The server', function () {
|
|||||||
it('should redirect colorscheme PNG badges as configured', async function () {
|
it('should redirect colorscheme PNG badges as configured', async function () {
|
||||||
const { statusCode, headers } = await got(
|
const { statusCode, headers } = await got(
|
||||||
`${baseUrl}:fruit-apple-green.png`,
|
`${baseUrl}:fruit-apple-green.png`,
|
||||||
{
|
{ followRedirect: false }
|
||||||
followRedirect: false,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
expect(statusCode).to.equal(301)
|
expect(statusCode).to.equal(301)
|
||||||
expect(headers.location).to.equal(
|
expect(headers.location).to.equal(
|
||||||
@@ -98,7 +96,7 @@ describe('The server', function () {
|
|||||||
`${baseUrl}:fruit-apple-green.svg`
|
`${baseUrl}:fruit-apple-green.svg`
|
||||||
)
|
)
|
||||||
expect(statusCode).to.equal(200)
|
expect(statusCode).to.equal(200)
|
||||||
expect(headers['content-type']).to.equal('image/svg+xml;charset=utf-8')
|
expect(headers['content-type']).to.equal('image/svg+xml; charset=utf-8')
|
||||||
expect(headers['content-length']).to.equal('1130')
|
expect(headers['content-length']).to.equal('1130')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -112,7 +110,9 @@ describe('The server', function () {
|
|||||||
`${baseUrl}:fruit-apple-green.json`
|
`${baseUrl}:fruit-apple-green.json`
|
||||||
)
|
)
|
||||||
expect(statusCode).to.equal(200)
|
expect(statusCode).to.equal(200)
|
||||||
expect(headers['content-type']).to.equal('application/json')
|
expect(headers['content-type']).to.equal(
|
||||||
|
'application/json; charset=utf-8'
|
||||||
|
)
|
||||||
expect(headers['access-control-allow-origin']).to.equal('*')
|
expect(headers['access-control-allow-origin']).to.equal('*')
|
||||||
expect(headers['content-length']).to.equal('92')
|
expect(headers['content-length']).to.equal('92')
|
||||||
expect(() => JSON.parse(body)).not.to.throw()
|
expect(() => JSON.parse(body)).not.to.throw()
|
||||||
@@ -200,19 +200,12 @@ describe('The server', function () {
|
|||||||
const { statusCode, body } = await got(`${baseUrl}npm/v/express.jpg`, {
|
const { statusCode, body } = await got(`${baseUrl}npm/v/express.jpg`, {
|
||||||
throwHttpErrors: false,
|
throwHttpErrors: false,
|
||||||
})
|
})
|
||||||
// TODO It would be nice if this were 404 or 410.
|
expect(statusCode).to.equal(410)
|
||||||
expect(statusCode).to.equal(200)
|
|
||||||
expect(body)
|
expect(body)
|
||||||
.to.satisfy(isSvg)
|
.to.satisfy(isSvg)
|
||||||
.and.to.include('410')
|
.and.to.include('410')
|
||||||
.and.to.include('jpg no longer available')
|
.and.to.include('jpg no longer available')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return cors header for the request', async function () {
|
|
||||||
const { statusCode, headers } = await got(`${baseUrl}npm/v/express.svg`)
|
|
||||||
expect(statusCode).to.equal(200)
|
|
||||||
expect(headers['access-control-allow-origin']).to.equal('*')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
context('`requireCloudflare` is enabled', function () {
|
context('`requireCloudflare` is enabled', function () {
|
||||||
@@ -245,22 +238,12 @@ describe('The server', function () {
|
|||||||
|
|
||||||
// configure server to time out requests that take >2 seconds
|
// configure server to time out requests that take >2 seconds
|
||||||
server = await createTestServer({ public: { requestTimeoutSeconds: 2 } })
|
server = await createTestServer({ public: { requestTimeoutSeconds: 2 } })
|
||||||
await server.start()
|
await server.start(app => {
|
||||||
|
// /fast returns a 200 OK after a 1 second delay
|
||||||
|
app.get('/fast', (req, res) => setTimeout(() => res.end(), 1000))
|
||||||
|
|
||||||
// /fast returns a 200 OK after a 1 second delay
|
// /slow returns a 200 OK after a 3 second delay
|
||||||
server.camp.route(/^\/fast$/, (data, match, end, ask) => {
|
app.get('/slow', (req, res) => setTimeout(() => res.end(), 3000))
|
||||||
setTimeout(() => {
|
|
||||||
ask.res.statusCode = 200
|
|
||||||
ask.res.end()
|
|
||||||
}, 1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
// /slow returns a 200 OK after a 3 second delay
|
|
||||||
server.camp.route(/^\/slow$/, (data, match, end, ask) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
ask.res.statusCode = 200
|
|
||||||
ask.res.end()
|
|
||||||
}, 3000)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -273,11 +256,9 @@ describe('The server', function () {
|
|||||||
|
|
||||||
it('should time out slow requests', async function () {
|
it('should time out slow requests', async function () {
|
||||||
this.timeout(10000)
|
this.timeout(10000)
|
||||||
const { statusCode, body } = await got(`${server.baseUrl}slow`, {
|
await expect(got(`${server.baseUrl}slow`)).to.be.rejectedWith(
|
||||||
throwHttpErrors: false,
|
'socket hang up'
|
||||||
})
|
)
|
||||||
expect(statusCode).to.be.equal(408)
|
|
||||||
expect(body).to.equal('Request Timeout')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not time out fast requests', async function () {
|
it('should not time out fast requests', async function () {
|
||||||
|
|||||||
@@ -80,29 +80,22 @@ test this kind of logic through unit tests (e.g. of `render()` and
|
|||||||
reporting, loads config, and creates an instance of the server.
|
reporting, loads config, and creates an instance of the server.
|
||||||
|
|
||||||
2. The Server, which is defined in
|
2. The Server, which is defined in
|
||||||
[`core/server/server.js`][core/server/server], is based on the web
|
[`core/server/server.js`][core/server/server], is based on [Express][].
|
||||||
framework [Scoutcamp][]. It creates an http server, sets up helpers for
|
It creates an http server, sets up helpers for token persistence and
|
||||||
token persistence and monitoring. Then it loads all the services,
|
monitoring. Then it loads all the services, injecting dependencies as it
|
||||||
injecting dependencies as it asks each one to register its route
|
asks each one to register its route with the Express app.
|
||||||
with Scoutcamp.
|
|
||||||
|
|
||||||
3. The service registration continues in `BaseService.register`. From its
|
3. The service registration continues in `BaseService.register`. From its
|
||||||
`route` property, it derives a regular expression to match the route
|
`route` property, it derives a regular expression to match the route
|
||||||
path, and invokes `camp.route` with this value.
|
path, and invokes `app.get` with this value.
|
||||||
|
|
||||||
4. At this point the situation gets gnarly and hard to follow. For the
|
4. TODO: Explain what happens here (i.e. now that we've migrated from Scoutcamp
|
||||||
purpose of initialization, suffice it to say that `camp.route` invokes a
|
to Express). `BaseService.invoke` instantiates the service and runs
|
||||||
callback with the four parameters `( queryParams, match, end, ask )` which
|
`BaseService#handle`.
|
||||||
is created in a legacy helper function in
|
|
||||||
[`legacy-request-handler.js`][legacy-request-handler]. This callback
|
|
||||||
delegates to a callback in `BaseService.register` with four different
|
|
||||||
parameters `( queryParams, match, sendBadge )`, which
|
|
||||||
then runs `BaseService.invoke`. `BaseService.invoke` instantiates the
|
|
||||||
service and runs `BaseService#handle`.
|
|
||||||
|
|
||||||
[entrypoint]: https://github.com/badges/shields/blob/master/server.js
|
[entrypoint]: https://github.com/badges/shields/blob/master/server.js
|
||||||
[core/server/server]: https://github.com/badges/shields/blob/master/core/server/server.js
|
[core/server/server]: https://github.com/badges/shields/blob/master/core/server/server.js
|
||||||
[scoutcamp]: https://github.com/espadrine/sc
|
[express]: https://expressjs.com/
|
||||||
[legacy-request-handler]: https://github.com/badges/shields/blob/master/core/base-service/legacy-request-handler.js
|
[legacy-request-handler]: https://github.com/badges/shields/blob/master/core/base-service/legacy-request-handler.js
|
||||||
|
|
||||||
## Downstream caching
|
## Downstream caching
|
||||||
@@ -119,24 +112,15 @@ test this kind of logic through unit tests (e.g. of `render()` and
|
|||||||
|
|
||||||
## How the server makes a badge
|
## How the server makes a badge
|
||||||
|
|
||||||
1. An HTTPS request arrives. Scoutcamp inspects the URL path and matches it
|
1. An HTTPS request arrives. Express inspects the URL path and matches it
|
||||||
against the regexes for all the registered routes until it finds one that
|
against all the registered routes until it finds one that matches. (See
|
||||||
matches. (See *Initialization* above for an explanation of how routes are
|
*Initialization* above for an explanation of how routes are
|
||||||
registered.)
|
registered.)
|
||||||
2. Scoutcamp invokes a callback with the four parameters:
|
2. Invoke the request handler function, defined in `BaseService.register`,
|
||||||
`( queryParams, match, end, ask )`. This callback is defined in
|
which handles the request. It runs `BaseService.invoke`, which instantiates
|
||||||
[`legacy-request-handler`][legacy-request-handler]. A timeout is set to
|
the service, injects more dependencies, and invokes `BaseService.handle`
|
||||||
handle unresponsive service code and the next callback is invoked: the
|
which is implemented by the service subclass.
|
||||||
legacy handler function.
|
3. The job of `handle()`, which should be implemented by each service
|
||||||
3. The legacy handler function receives
|
|
||||||
`( queryParams, match, sendBadge )`. Its job is to extract data
|
|
||||||
from the regex `match` and `queryParams`, and then invoke `sendBadge`
|
|
||||||
with the result.
|
|
||||||
4. The implementation of this function is in `BaseService.register`. It
|
|
||||||
works by running `BaseService.invoke`, which instantiates the service,
|
|
||||||
injects more dependencies, and invokes `BaseService.handle` which is
|
|
||||||
implemented by the service subclass.
|
|
||||||
5. The job of `handle()`, which should be implemented by each service
|
|
||||||
subclass, is to return an object which partially describes a badge or
|
subclass, is to return an object which partially describes a badge or
|
||||||
throw one of the handled error classes. "Partially rendered" most
|
throw one of the handled error classes. "Partially rendered" most
|
||||||
commonly means a non-empty message and an optional color. In the case
|
commonly means a non-empty message and an optional color. In the case
|
||||||
@@ -146,7 +130,7 @@ test this kind of logic through unit tests (e.g. of `render()` and
|
|||||||
Throwing any other error is a programmer error which will be
|
Throwing any other error is a programmer error which will be
|
||||||
[reported][error reporting] and described to the user as a **shields
|
[reported][error reporting] and described to the user as a **shields
|
||||||
internal error**.
|
internal error**.
|
||||||
6. A typical `handle()` function delegates to one or more helpers to
|
4. A typical `handle()` function delegates to one or more helpers to
|
||||||
handle stages of the request:
|
handle stages of the request:
|
||||||
1. **fetch**: load the needed data from the upstream service and
|
1. **fetch**: load the needed data from the upstream service and
|
||||||
validate it
|
validate it
|
||||||
@@ -154,13 +138,13 @@ test this kind of logic through unit tests (e.g. of `render()` and
|
|||||||
into a few properties which will be displayed on the badge
|
into a few properties which will be displayed on the badge
|
||||||
3. **render**: given a few properties, return a message, optional
|
3. **render**: given a few properties, return a message, optional
|
||||||
color, and optional label.
|
color, and optional label.
|
||||||
7. When an error is thrown, BaseService steps in and converts the error
|
5. When an error is thrown, BaseService steps in and converts the error
|
||||||
object to renderable properties: `{ isError, message, color }`.
|
object to renderable properties: `{ isError, message, color }`.
|
||||||
8. The service invokes [`coalesceBadge`][coalescebadge] whose job is to
|
6. The service invokes [`coalesceBadge`][coalescebadge] whose job is to
|
||||||
coalesce query string overrides with values from the service and the
|
coalesce query string overrides with values from the service and the
|
||||||
service’s defaults to produce an object that fully describes the badge to
|
service’s defaults to produce an object that fully describes the badge to
|
||||||
be rendered.
|
be rendered.
|
||||||
9. `sendBadge` is invoked with that object. It does some housekeeping on the
|
7. `sendBadge` is invoked with that object. It does some housekeeping on the
|
||||||
timeout. Then it renders the badge to svg or raster and pushes out the
|
timeout. Then it renders the badge to svg or raster and pushes out the
|
||||||
result over the HTTPS connection.
|
result over the HTTPS connection.
|
||||||
|
|
||||||
|
|||||||
1202
package-lock.json
generated
1202
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,6 @@
|
|||||||
"@fontsource/lekton": "^4.5.6",
|
"@fontsource/lekton": "^4.5.6",
|
||||||
"@renovate/pep440": "^1.0.0",
|
"@renovate/pep440": "^1.0.0",
|
||||||
"@sentry/node": "^6.19.6",
|
"@sentry/node": "^6.19.6",
|
||||||
"@shields_io/camp": "^18.1.1",
|
|
||||||
"badge-maker": "file:badge-maker",
|
"badge-maker": "file:badge-maker",
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"camelcase": "^6.3.0",
|
"camelcase": "^6.3.0",
|
||||||
@@ -37,6 +36,7 @@
|
|||||||
"decamelize": "^3.2.0",
|
"decamelize": "^3.2.0",
|
||||||
"emojic": "^1.1.17",
|
"emojic": "^1.1.17",
|
||||||
"escape-string-regexp": "^4.0.0",
|
"escape-string-regexp": "^4.0.0",
|
||||||
|
"express": "^4.17.3",
|
||||||
"fast-xml-parser": "^4.0.7",
|
"fast-xml-parser": "^4.0.7",
|
||||||
"glob": "^8.0.1",
|
"glob": "^8.0.1",
|
||||||
"global-agent": "^3.0.0",
|
"global-agent": "^3.0.0",
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
"lodash.groupby": "^4.6.0",
|
"lodash.groupby": "^4.6.0",
|
||||||
"lodash.times": "^4.3.2",
|
"lodash.times": "^4.3.2",
|
||||||
"moment": "^2.29.2",
|
"moment": "^2.29.2",
|
||||||
|
"multer": "^1.4.4",
|
||||||
"node-env-flag": "^0.1.0",
|
"node-env-flag": "^0.1.0",
|
||||||
"parse-link-header": "^2.0.0",
|
"parse-link-header": "^2.0.0",
|
||||||
"path-to-regexp": "^6.2.0",
|
"path-to-regexp": "^6.2.0",
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import queryString from 'query-string'
|
import queryString from 'query-string'
|
||||||
|
import multer from 'multer'
|
||||||
import { fetch } from '../../../core/base-service/got.js'
|
import { fetch } from '../../../core/base-service/got.js'
|
||||||
import log from '../../../core/server/log.js'
|
import log from '../../../core/server/log.js'
|
||||||
|
|
||||||
function setRoutes({ server, authHelper, onTokenAccepted }) {
|
function setRoutes({ app, authHelper, onTokenAccepted }) {
|
||||||
const baseUrl = process.env.GATSBY_BASE_URL || 'https://img.shields.io'
|
const baseUrl = process.env.GATSBY_BASE_URL || 'https://img.shields.io'
|
||||||
|
|
||||||
server.route(/^\/github-auth$/, (data, match, end, ask) => {
|
app.post('/github-auth', (req, res) => {
|
||||||
ask.res.statusCode = 302 // Found.
|
res.status(302) // Found.
|
||||||
const query = queryString.stringify({
|
const query = queryString.stringify({
|
||||||
// TODO The `_user` property bypasses security checks in AuthHelper.
|
// TODO The `_user` property bypasses security checks in AuthHelper.
|
||||||
// (e.g: enforceStrictSsl and shouldAuthenticateRequest).
|
// (e.g: enforceStrictSsl and shouldAuthenticateRequest).
|
||||||
@@ -15,56 +16,64 @@ function setRoutes({ server, authHelper, onTokenAccepted }) {
|
|||||||
client_id: authHelper._user,
|
client_id: authHelper._user,
|
||||||
redirect_uri: `${baseUrl}/github-auth/done`,
|
redirect_uri: `${baseUrl}/github-auth/done`,
|
||||||
})
|
})
|
||||||
ask.res.setHeader(
|
res.setHeader(
|
||||||
'Location',
|
'Location',
|
||||||
`https://github.com/login/oauth/authorize?${query}`
|
`https://github.com/login/oauth/authorize?${query}`
|
||||||
)
|
)
|
||||||
end('')
|
res.end()
|
||||||
})
|
})
|
||||||
|
|
||||||
server.route(/^\/github-auth\/done$/, async (data, match, end, ask) => {
|
app.post('/github-auth/done', multer().none(), async (req, res) => {
|
||||||
if (!data.code) {
|
const code = (req.body ?? {}).code
|
||||||
log.log(`GitHub OAuth data: ${JSON.stringify(data)}`)
|
|
||||||
return end('GitHub OAuth authentication failed to provide a code.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = {
|
if (!code) {
|
||||||
method: 'POST',
|
log.log(`GitHub OAuth data: ${JSON.stringify(req.body)}`)
|
||||||
headers: {
|
res.send('GitHub OAuth authentication failed to provide a code.')
|
||||||
'Content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
res.end()
|
||||||
},
|
return
|
||||||
form: {
|
|
||||||
// TODO The `_user` and `_pass` properties bypass security checks in
|
|
||||||
// AuthHelper (e.g: enforceStrictSsl and shouldAuthenticateRequest).
|
|
||||||
// Do not use them elsewhere. It would be better to clean
|
|
||||||
// this up so it's not setting a bad example.
|
|
||||||
client_id: authHelper._user,
|
|
||||||
client_secret: authHelper._pass,
|
|
||||||
code: data.code,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let resp
|
let resp
|
||||||
try {
|
try {
|
||||||
resp = await fetch('https://github.com/login/oauth/access_token', options)
|
resp = await fetch('https://github.com/login/oauth/access_token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
// TODO The `_user` and `_pass` properties bypass security checks in
|
||||||
|
// AuthHelper (e.g: enforceStrictSsl and shouldAuthenticateRequest).
|
||||||
|
// Do not use them elsewhere. It would be better to clean
|
||||||
|
// this up so it's not setting a bad example.
|
||||||
|
client_id: authHelper._user,
|
||||||
|
client_secret: authHelper._pass,
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return end('The connection to GitHub failed.')
|
res.send('The connection to GitHub failed.')
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let content
|
let content
|
||||||
try {
|
try {
|
||||||
content = queryString.parse(resp.buffer)
|
content = queryString.parse(resp.buffer)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return end('The GitHub OAuth token could not be parsed.')
|
res.send('The GitHub OAuth token could not be parsed.')
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { access_token: token } = content
|
const { access_token: token } = content
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return end('The GitHub OAuth process did not return a user token.')
|
res.send('The GitHub OAuth process did not return a user token.')
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ask.res.setHeader('Content-Type', 'text/html')
|
res.setHeader('Content-Type', 'text/html')
|
||||||
end(
|
res.send(
|
||||||
'<p>Shields.io has received your app-specific GitHub user token. ' +
|
'<p>Shields.io has received your app-specific GitHub user token. ' +
|
||||||
'You can revoke it by going to ' +
|
'You can revoke it by going to ' +
|
||||||
'<a href="https://github.com/settings/applications">GitHub</a>.</p>' +
|
'<a href="https://github.com/settings/applications">GitHub</a>.</p>' +
|
||||||
@@ -75,6 +84,7 @@ function setRoutes({ server, authHelper, onTokenAccepted }) {
|
|||||||
'everyone!</p>' +
|
'everyone!</p>' +
|
||||||
'<p><a href="/">Back to the website</a></p>'
|
'<p><a href="/">Back to the website</a></p>'
|
||||||
)
|
)
|
||||||
|
res.end()
|
||||||
|
|
||||||
onTokenAccepted(token)
|
onTokenAccepted(token)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import Camp from '@shields_io/camp'
|
|
||||||
import FormData from 'form-data'
|
import FormData from 'form-data'
|
||||||
import sinon from 'sinon'
|
import sinon from 'sinon'
|
||||||
import portfinder from 'portfinder'
|
|
||||||
import queryString from 'query-string'
|
import queryString from 'query-string'
|
||||||
import nock from 'nock'
|
import nock from 'nock'
|
||||||
import got from '../../../core/got-test-client.js'
|
import { ExpressTestHarness } from '../../../core/express-test-harness.js'
|
||||||
import GithubConstellation from '../github-constellation.js'
|
import GithubConstellation from '../github-constellation.js'
|
||||||
import { setRoutes } from './acceptor.js'
|
import { setRoutes } from './acceptor.js'
|
||||||
|
|
||||||
@@ -17,36 +15,26 @@ describe('Github token acceptor', function () {
|
|||||||
private: { gh_client_id: fakeClientId, gh_client_secret: fakeClientSecret },
|
private: { gh_client_id: fakeClientId, gh_client_secret: fakeClientSecret },
|
||||||
})
|
})
|
||||||
|
|
||||||
let port, baseUrl
|
let harness, onTokenAccepted
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
port = await portfinder.getPortPromise()
|
harness = new ExpressTestHarness()
|
||||||
baseUrl = `http://127.0.0.1:${port}`
|
|
||||||
})
|
|
||||||
|
|
||||||
let camp
|
|
||||||
beforeEach(async function () {
|
|
||||||
camp = Camp.start({ port, hostname: '::' })
|
|
||||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
|
||||||
})
|
|
||||||
afterEach(async function () {
|
|
||||||
if (camp) {
|
|
||||||
await new Promise(resolve => camp.close(resolve))
|
|
||||||
camp = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
let onTokenAccepted
|
|
||||||
beforeEach(function () {
|
|
||||||
onTokenAccepted = sinon.stub()
|
onTokenAccepted = sinon.stub()
|
||||||
setRoutes({
|
setRoutes({
|
||||||
server: camp,
|
app: harness.app,
|
||||||
authHelper: oauthHelper,
|
authHelper: oauthHelper,
|
||||||
onTokenAccepted,
|
onTokenAccepted,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await harness.start()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async function () {
|
||||||
|
await harness.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should start the OAuth process', async function () {
|
it('should start the OAuth process', async function () {
|
||||||
const res = await got(`${baseUrl}/github-auth`, { followRedirect: false })
|
const res = await harness.post('/github-auth', { followRedirect: false })
|
||||||
|
|
||||||
expect(res.statusCode).to.equal(302)
|
expect(res.statusCode).to.equal(302)
|
||||||
|
|
||||||
@@ -61,8 +49,8 @@ describe('Github token acceptor', function () {
|
|||||||
describe('Finishing the OAuth process', function () {
|
describe('Finishing the OAuth process', function () {
|
||||||
context('no code is provided', function () {
|
context('no code is provided', function () {
|
||||||
it('should return an error', async function () {
|
it('should return an error', async function () {
|
||||||
const res = await got(`${baseUrl}/github-auth/done`)
|
const { body } = await harness.post('/github-auth/done')
|
||||||
expect(res.body).to.equal(
|
expect(body).to.equal(
|
||||||
'GitHub OAuth authentication failed to provide a code.'
|
'GitHub OAuth authentication failed to provide a code.'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -111,9 +99,7 @@ describe('Github token acceptor', function () {
|
|||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('code', fakeCode)
|
form.append('code', fakeCode)
|
||||||
|
|
||||||
const res = await got.post(`${baseUrl}/github-auth/done`, {
|
const res = await harness.post('/github-auth/done', { body: form })
|
||||||
body: form,
|
|
||||||
})
|
|
||||||
expect(res.body).to.startWith(
|
expect(res.body).to.startWith(
|
||||||
'<p>Shields.io has received your app-specific GitHub user token.'
|
'<p>Shields.io has received your app-specific GitHub user token.'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class GithubConstellation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(server) {
|
async initialize(app) {
|
||||||
if (!this.apiProvider.withPooling) {
|
if (!this.apiProvider.withPooling) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ class GithubConstellation {
|
|||||||
|
|
||||||
if (this.oauthHelper.isConfigured) {
|
if (this.oauthHelper.isConfigured) {
|
||||||
setAcceptorRoutes({
|
setAcceptorRoutes({
|
||||||
server,
|
app,
|
||||||
authHelper: this.oauthHelper,
|
authHelper: this.oauthHelper,
|
||||||
onTokenAccepted: tokenString => this.onTokenAdded(tokenString),
|
onTokenAccepted: tokenString => this.onTokenAdded(tokenString),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import Camp from '@shields_io/camp'
|
|
||||||
import portfinder from 'portfinder'
|
|
||||||
import config from 'config'
|
import config from 'config'
|
||||||
import got from '../core/got-test-client.js'
|
import { ExpressTestHarness } from '../core/express-test-harness.js'
|
||||||
import { setRoutes } from './suggest.js'
|
import { setRoutes } from './suggest.js'
|
||||||
import GithubApiProvider from './github/github-api-provider.js'
|
import GithubApiProvider from './github/github-api-provider.js'
|
||||||
|
|
||||||
describe('Badge suggestions for', function () {
|
describe('Badge suggestions', function () {
|
||||||
const githubApiBaseUrl = process.env.GITHUB_URL || 'https://api.github.com'
|
const githubApiBaseUrl = process.env.GITHUB_URL || 'https://api.github.com'
|
||||||
|
|
||||||
let token, apiProvider
|
let token, apiProvider
|
||||||
@@ -22,38 +20,27 @@ describe('Badge suggestions for', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
let port, baseUrl
|
|
||||||
before(async function () {
|
|
||||||
port = await portfinder.getPortPromise()
|
|
||||||
baseUrl = `http://127.0.0.1:${port}`
|
|
||||||
})
|
|
||||||
|
|
||||||
let camp
|
|
||||||
before(async function () {
|
|
||||||
camp = Camp.start({ port, hostname: '::' })
|
|
||||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
|
||||||
})
|
|
||||||
after(async function () {
|
|
||||||
if (camp) {
|
|
||||||
await new Promise(resolve => camp.close(resolve))
|
|
||||||
camp = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const origin = 'https://example.test'
|
const origin = 'https://example.test'
|
||||||
before(function () {
|
|
||||||
setRoutes([origin], apiProvider, camp)
|
let harness
|
||||||
|
before(async function () {
|
||||||
|
harness = new ExpressTestHarness()
|
||||||
|
setRoutes([origin], apiProvider, harness.app)
|
||||||
|
await harness.start()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await harness.stop()
|
||||||
|
})
|
||||||
|
|
||||||
describe('GitHub', function () {
|
describe('GitHub', function () {
|
||||||
context('with an existing project', function () {
|
context('with an existing project', function () {
|
||||||
it('returns the expected suggestions', async function () {
|
it('returns the expected suggestions', async function () {
|
||||||
const { statusCode, body } = await got(
|
const { statusCode, body } = await harness.get(
|
||||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(
|
`/$suggest/v1?url=${encodeURIComponent(
|
||||||
'https://github.com/atom/atom'
|
'https://github.com/atom/atom'
|
||||||
)}`,
|
)}`,
|
||||||
{
|
{ responseType: 'json' }
|
||||||
responseType: 'json',
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
expect(statusCode).to.equal(200)
|
expect(statusCode).to.equal(200)
|
||||||
expect(body).to.deep.equal({
|
expect(body).to.deep.equal({
|
||||||
@@ -117,13 +104,11 @@ describe('Badge suggestions for', function () {
|
|||||||
it('returns the expected suggestions', async function () {
|
it('returns the expected suggestions', async function () {
|
||||||
this.timeout(5000)
|
this.timeout(5000)
|
||||||
|
|
||||||
const { statusCode, body } = await got(
|
const { statusCode, body } = await harness.get(
|
||||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(
|
`/$suggest/v1?url=${encodeURIComponent(
|
||||||
'https://github.com/badges/not-a-real-project'
|
'https://github.com/badges/not-a-real-project'
|
||||||
)}`,
|
)}`,
|
||||||
{
|
{ responseType: 'json' }
|
||||||
responseType: 'json',
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
expect(statusCode).to.equal(200)
|
expect(statusCode).to.equal(200)
|
||||||
expect(body).to.deep.equal({
|
expect(body).to.deep.equal({
|
||||||
@@ -187,13 +172,11 @@ describe('Badge suggestions for', function () {
|
|||||||
describe('GitLab', function () {
|
describe('GitLab', function () {
|
||||||
context('with an existing project', function () {
|
context('with an existing project', function () {
|
||||||
it('returns the expected suggestions', async function () {
|
it('returns the expected suggestions', async function () {
|
||||||
const { statusCode, body } = await got(
|
const { statusCode, body } = await harness.get(
|
||||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(
|
`/$suggest/v1?url=${encodeURIComponent(
|
||||||
'https://gitlab.com/gitlab-org/gitlab'
|
'https://gitlab.com/gitlab-org/gitlab'
|
||||||
)}`,
|
)}`,
|
||||||
{
|
{ responseType: 'json' }
|
||||||
responseType: 'json',
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
expect(statusCode).to.equal(200)
|
expect(statusCode).to.equal(200)
|
||||||
expect(body).to.deep.equal({
|
expect(body).to.deep.equal({
|
||||||
@@ -228,8 +211,8 @@ describe('Badge suggestions for', function () {
|
|||||||
|
|
||||||
context('with an nonexisting project', function () {
|
context('with an nonexisting project', function () {
|
||||||
it('returns the expected suggestions', async function () {
|
it('returns the expected suggestions', async function () {
|
||||||
const { statusCode, body } = await got(
|
const { statusCode, body } = await harness.get(
|
||||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(
|
`/$suggest/v1?url=${encodeURIComponent(
|
||||||
'https://gitlab.com/gitlab-org/not-gitlab'
|
'https://gitlab.com/gitlab-org/not-gitlab'
|
||||||
)}`,
|
)}`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ async function findSuggestions(githubApiProvider, url) {
|
|||||||
// - link: target as a string URL
|
// - link: target as a string URL
|
||||||
// - preview: object (optional)
|
// - preview: object (optional)
|
||||||
// - style: string
|
// - style: string
|
||||||
function setRoutes(allowedOrigin, githubApiProvider, server) {
|
function setRoutes(allowedOrigin, githubApiProvider, app) {
|
||||||
server.ajax.on('suggest/v1', (data, end, ask) => {
|
app.get('/[$]suggest/v1', (req, res) => {
|
||||||
// The typical dev and production setups are cross-origin. However, in
|
// The typical dev and production setups are cross-origin. However, in
|
||||||
// Heroku deploys and some self-hosted deploys these requests may come from
|
// Heroku deploys and some self-hosted deploys these requests may come from
|
||||||
// the same host. Chrome does not send an Origin header on same-origin
|
// the same host. Chrome does not send an Origin header on same-origin
|
||||||
@@ -155,23 +155,25 @@ function setRoutes(allowedOrigin, githubApiProvider, server) {
|
|||||||
//
|
//
|
||||||
// It would be better to solve this problem using some well-tested
|
// It would be better to solve this problem using some well-tested
|
||||||
// middleware.
|
// middleware.
|
||||||
const origin = ask.req.headers.origin
|
const origin = req.headers.origin
|
||||||
if (origin) {
|
if (origin) {
|
||||||
let host
|
let host
|
||||||
try {
|
try {
|
||||||
host = new URL(origin).hostname
|
host = new URL(origin).hostname
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ask.res.setHeader('Access-Control-Allow-Origin', 'null')
|
res.setHeader('Access-Control-Allow-Origin', 'null')
|
||||||
end({ err: 'Disallowed' })
|
res.json({ err: 'Disallowed' })
|
||||||
|
res.end()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (host !== ask.req.headers.host) {
|
if (host !== req.headers.host) {
|
||||||
if (allowedOrigin.includes(origin)) {
|
if (allowedOrigin.includes(origin)) {
|
||||||
ask.res.setHeader('Access-Control-Allow-Origin', origin)
|
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||||
} else {
|
} else {
|
||||||
ask.res.setHeader('Access-Control-Allow-Origin', 'null')
|
res.setHeader('Access-Control-Allow-Origin', 'null')
|
||||||
end({ err: 'Disallowed' })
|
res.json({ err: 'Disallowed' })
|
||||||
|
res.end()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,9 +181,10 @@ function setRoutes(allowedOrigin, githubApiProvider, server) {
|
|||||||
|
|
||||||
let url
|
let url
|
||||||
try {
|
try {
|
||||||
url = new URL(data.url)
|
url = new URL(req.query.url)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
end({ err: `${e}` })
|
res.json({ err: `${e}` })
|
||||||
|
res.end()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,11 +192,13 @@ function setRoutes(allowedOrigin, githubApiProvider, server) {
|
|||||||
// This interacts with callback code and can't use async/await.
|
// This interacts with callback code and can't use async/await.
|
||||||
// eslint-disable-next-line promise/prefer-await-to-then
|
// eslint-disable-next-line promise/prefer-await-to-then
|
||||||
.then(suggestions => {
|
.then(suggestions => {
|
||||||
end({ suggestions })
|
res.json({ suggestions })
|
||||||
|
res.end()
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line promise/prefer-await-to-then
|
// eslint-disable-next-line promise/prefer-await-to-then
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
end({ suggestions: [], err })
|
res.json({ suggestions: [], err })
|
||||||
|
res.end()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import Camp from '@shields_io/camp'
|
|
||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import nock from 'nock'
|
import nock from 'nock'
|
||||||
import portfinder from 'portfinder'
|
import { ExpressTestHarness } from '../core/express-test-harness.js'
|
||||||
import got from '../core/got-test-client.js'
|
|
||||||
import { setRoutes, githubLicense } from './suggest.js'
|
import { setRoutes, githubLicense } from './suggest.js'
|
||||||
import GithubApiProvider from './github/github-api-provider.js'
|
import GithubApiProvider from './github/github-api-provider.js'
|
||||||
|
|
||||||
@@ -67,28 +65,20 @@ describe('Badge suggestions', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Scoutcamp integration', function () {
|
describe('Express integration', function () {
|
||||||
let port, baseUrl
|
let harness
|
||||||
before(async function () {
|
beforeEach(async function () {
|
||||||
port = await portfinder.getPortPromise()
|
harness = new ExpressTestHarness()
|
||||||
baseUrl = `http://127.0.0.1:${port}`
|
await harness.start()
|
||||||
})
|
|
||||||
|
|
||||||
let camp
|
|
||||||
before(async function () {
|
|
||||||
camp = Camp.start({ port, hostname: '::' })
|
|
||||||
await new Promise(resolve => camp.on('listening', () => resolve()))
|
|
||||||
})
|
|
||||||
after(async function () {
|
|
||||||
if (camp) {
|
|
||||||
await new Promise(resolve => camp.close(resolve))
|
|
||||||
camp = undefined
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const origin = 'https://example.test'
|
const origin = 'https://example.test'
|
||||||
before(function () {
|
beforeEach(function () {
|
||||||
setRoutes([origin], apiProvider, camp)
|
setRoutes([origin], apiProvider, harness.app)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async function () {
|
||||||
|
await harness.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
context('without an origin header', function () {
|
context('without an origin header', function () {
|
||||||
@@ -106,13 +96,11 @@ describe('Badge suggestions', function () {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const { statusCode, body } = await got(
|
const { statusCode, body } = await harness.get(
|
||||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(
|
`/$suggest/v1?url=${encodeURIComponent(
|
||||||
'https://github.com/atom/atom'
|
'https://github.com/atom/atom'
|
||||||
)}`,
|
)}`,
|
||||||
{
|
{ responseType: 'json' }
|
||||||
responseType: 'json',
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
expect(statusCode).to.equal(200)
|
expect(statusCode).to.equal(200)
|
||||||
expect(body).to.deep.equal({
|
expect(body).to.deep.equal({
|
||||||
|
|||||||
Reference in New Issue
Block a user