Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Re-apply 'Migrate request to got (part 1)' #6755

Merged
merged 2 commits into from
Jul 11, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion core/base-service/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
Deprecated,
} from './errors.js'
import { validateExample, transformExample } from './examples.js'
import { fetchFactory } from './got.js'
import {
makeFullUrl,
assertValidRoute,
Expand Down Expand Up @@ -431,6 +432,8 @@ class BaseService {
ServiceClass: this,
})

const fetcher = fetchFactory(fetchLimitBytes)

camp.route(
regex,
handleRequest(cacheHeaderConfig, {
Expand All @@ -441,7 +444,7 @@ class BaseService {
const namedParams = namedParamsForMatch(captureNames, match, this)
const serviceData = await this.invoke(
{
sendAndCacheRequest: request.asPromise,
sendAndCacheRequest: fetcher,
sendAndCacheRequestWithCallbacks: request,
githubApiProvider,
metricHelper,
Expand Down
96 changes: 96 additions & 0 deletions core/base-service/got.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import got from 'got'
import { Inaccessible, InvalidResponse } from './errors.js'

const userAgent = 'Shields.io/2003a'

function requestOptions2GotOptions(options) {
const requestOptions = Object.assign({}, options)
const gotOptions = {}
const interchangableOptions = ['body', 'form', 'headers', 'method', 'url']

interchangableOptions.forEach(function (opt) {
if (opt in requestOptions) {
gotOptions[opt] = requestOptions[opt]
delete requestOptions[opt]
}
})

if ('qs' in requestOptions) {
gotOptions.searchParams = requestOptions.qs
delete requestOptions.qs
}

if ('gzip' in requestOptions) {
gotOptions.decompress = requestOptions.gzip
delete requestOptions.gzip
}

if ('strictSSL' in requestOptions) {
gotOptions.https = {
rejectUnauthorized: requestOptions.strictSSL,
}
delete requestOptions.strictSSL
}

if ('auth' in requestOptions) {
gotOptions.username = requestOptions.auth.user
gotOptions.password = requestOptions.auth.pass
delete requestOptions.auth
}

if (Object.keys(requestOptions).length > 0) {
throw new Error(`Found unrecognised options ${Object.keys(requestOptions)}`)
}

return gotOptions
}

async function sendRequest(gotWrapper, url, options) {
const gotOptions = requestOptions2GotOptions(options)
gotOptions.throwHttpErrors = false
gotOptions.retry = 0
gotOptions.headers = gotOptions.headers || {}
gotOptions.headers['User-Agent'] = userAgent
try {
const resp = await gotWrapper(url, gotOptions)
return { res: resp, buffer: resp.body }
} catch (err) {
if (err instanceof got.CancelError) {
throw new InvalidResponse({
underlyingError: new Error('Maximum response size exceeded'),
})
}
throw new Inaccessible({ underlyingError: err })
}
}

function fetchFactory(fetchLimitBytes) {
const gotWithLimit = got.extend({
handlers: [
(options, next) => {
const promiseOrStream = next(options)
promiseOrStream.on('downloadProgress', progress => {
if (
progress.transferred > fetchLimitBytes &&
// just accept the file if we've already finished downloading
// the entire file before we went over the limit
progress.percent !== 1
) {
/*
TODO: we should be able to pass cancel() a message
https://github.com/sindresorhus/got/blob/main/documentation/advanced-creation.md#examples
but by the time we catch it, err.message is just "Promise was canceled"
*/
promiseOrStream.cancel('Maximum response size exceeded')
}
})

return promiseOrStream
},
],
})

return sendRequest.bind(sendRequest, gotWithLimit)
}

export { requestOptions2GotOptions, fetchFactory }
102 changes: 102 additions & 0 deletions core/base-service/got.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { expect } from 'chai'
import nock from 'nock'
import { requestOptions2GotOptions, fetchFactory } from './got.js'
import { Inaccessible, InvalidResponse } from './errors.js'

describe('requestOptions2GotOptions function', function () {
it('translates valid options', function () {
expect(
requestOptions2GotOptions({
body: 'body',
form: 'form',
headers: 'headers',
method: 'method',
url: 'url',
qs: 'qs',
gzip: 'gzip',
strictSSL: 'strictSSL',
auth: { user: 'user', pass: 'pass' },
})
).to.deep.equal({
body: 'body',
form: 'form',
headers: 'headers',
method: 'method',
url: 'url',
searchParams: 'qs',
decompress: 'gzip',
https: { rejectUnauthorized: 'strictSSL' },
username: 'user',
password: 'pass',
})
})

it('throws if unrecognised options are found', function () {
expect(() =>
requestOptions2GotOptions({ body: 'body', foobar: 'foobar' })
).to.throw(Error, 'Found unrecognised options foobar')
})
})

describe('got wrapper', function () {
it('should not throw an error if the response <= fetchLimitBytes', async function () {
nock('https://www.google.com')
.get('/foo/bar')
.once()
.reply(200, 'x'.repeat(100))
const sendRequest = fetchFactory(100)
const { res } = await sendRequest('https://www.google.com/foo/bar')
expect(res.statusCode).to.equal(200)
})

it('should throw an InvalidResponse error if the response is > fetchLimitBytes', async function () {
nock('https://www.google.com')
.get('/foo/bar')
.once()
.reply(200, 'x'.repeat(101))
const sendRequest = fetchFactory(100)
return expect(
sendRequest('https://www.google.com/foo/bar')
).to.be.rejectedWith(InvalidResponse, 'Maximum response size exceeded')
})

it('should throw an Inaccessible error if the request throws a (non-HTTP) error', async function () {
nock('https://www.google.com').get('/foo/bar').replyWithError('oh no')
const sendRequest = fetchFactory(1024)
return expect(
sendRequest('https://www.google.com/foo/bar')
).to.be.rejectedWith(Inaccessible, 'oh no')
})

it('should throw an Inaccessible error if the host can not be accessed', async function () {
this.timeout(5000)
nock.disableNetConnect()
const sendRequest = fetchFactory(1024)
return expect(
sendRequest('https://www.google.com/foo/bar')
).to.be.rejectedWith(
Inaccessible,
'Nock: Disallowed net connect for "www.google.com:443/foo/bar"'
)
})

it('should pass a custom user agent header', async function () {
nock('https://www.google.com', {
reqheaders: {
'user-agent': function (agent) {
return agent.startsWith('Shields.io')
},
},
})
.get('/foo/bar')
.once()
.reply(200)
const sendRequest = fetchFactory(1024)
await sendRequest('https://www.google.com/foo/bar')
})

afterEach(function () {
nock.cleanAll()
nock.enableNetConnect()
})
})
22 changes: 22 additions & 0 deletions core/server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import path from 'path'
import url, { fileURLToPath } from 'url'
import { bootstrap } from 'global-agent'
import cloudflareMiddleware from 'cloudflare-middleware'
import bytes from 'bytes'
import Camp from '@shields_io/camp'
Expand Down Expand Up @@ -422,6 +423,25 @@ class Server {
)
}

bootstrapAgent() {
/*
Bootstrap global agent.
This allows self-hosting users to configure a proxy with
HTTP_PROXY, HTTPS_PROXY, NO_PROXY variables
*/
if (!('GLOBAL_AGENT_ENVIRONMENT_VARIABLE_NAMESPACE' in process.env)) {
process.env.GLOBAL_AGENT_ENVIRONMENT_VARIABLE_NAMESPACE = ''
}

const proxyPrefix = process.env.GLOBAL_AGENT_ENVIRONMENT_VARIABLE_NAMESPACE
const HTTP_PROXY = process.env[`${proxyPrefix}HTTP_PROXY`] || null
const HTTPS_PROXY = process.env[`${proxyPrefix}HTTPS_PROXY`] || null

if (HTTP_PROXY || HTTPS_PROXY) {
bootstrap()
}
}

/**
* Start the HTTP server:
* Bootstrap Scoutcamp,
Expand All @@ -436,6 +456,8 @@ class Server {
requireCloudflare,
} = this.config.public

this.bootstrapAgent()

log.log(`Server is starting up: ${this.baseUrl}`)

const camp = (this.camp = Camp.create({
Expand Down
Loading