initial commit

This commit is contained in:
Bryan MacFarlane 2020-01-09 18:32:59 -05:00
commit 0fc3b75a6d
15 changed files with 6163 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
_out
node_modules
.DS_Store
testoutput.txt

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
Typed Rest Client for Node.js
Copyright (c) GitHub, Inc.
All rights reserved.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

66
README.md Normal file
View File

@ -0,0 +1,66 @@
# Typed HTTP Client with TypeScript Typings
A lightweight HTTP client optimized for use with actions, TypeScript with generics and async await.
## Features
- HTTP client with TypeScript generics and async/await/Promises
- Typings included so no need to acquire separately (great for intellisense and no versioning drift)
- Basic, Bearer and PAT Support out of the box. Extensible handlers for others.
- Proxy support, just works with actions and the runner
- Redirects supported
## Install
```
npm install @actions/http-client --save
```
## Samples
See the [HTTP](./__tests__) tests for detailed examples.
## Errors
### HTTP
The HTTP client does not throw unless truly exceptional.
* A request that successfully executes resulting in a 404, 500 etc... will return a response object with a status code and a body.
* Redirects (3xx) will be followed by default.
See [HTTP tests](./__tests__) for detailed examples.
## Debugging
To enable detailed console logging of all HTTP requests and responses, set the NODE_DEBUG environment varible:
```
export NODE_DEBUG=http
```
or
```
set NODE_DEBUG=http
```
## Node support
The http-client is built using the latest LTS version of Node 12. We also support the latest LTS for Node 6, 8 and Node 10.
## Contributing
We welcome PRs. Please create an issue and if applicable, a design before proceeding with code.
To build:
```bash
$ npm run build
```
To run all tests:
```bash
$ npm test
```

57
__tests__/auth.test.ts Normal file
View File

@ -0,0 +1,57 @@
import * as httpm from '../_out';
import * as path from 'path';
import * as am from '../_out/auth';
describe('auth', () => {
beforeEach(() => {
})
afterEach(() => {
})
it('does basic http get request with basic auth', async() => {
let bh: am.BasicCredentialHandler = new am.BasicCredentialHandler('johndoe', 'password');
let http: httpm.HttpClient = new httpm.HttpClient('http-client-tests', [bh]);
let res: httpm.HttpClientResponse = await http.get('http://httpbin.org/get');
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
let auth: string = obj.headers.Authorization;
let creds: string = Buffer.from(auth.substring('Basic '.length), 'base64').toString();
expect(creds).toBe('johndoe:password');
expect(obj.url).toBe("https://httpbin.org/get");
});
it('does basic http get request with pat token auth', async() => {
let token: string = 'scbfb44vxzku5l4xgc3qfazn3lpk4awflfryc76esaiq7aypcbhs';
let ph: am.PersonalAccessTokenCredentialHandler =
new am.PersonalAccessTokenCredentialHandler(token);
let http: httpm.HttpClient = new httpm.HttpClient('http-client-tests', [ph]);
let res: httpm.HttpClientResponse = await http.get('http://httpbin.org/get');
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
let auth: string = obj.headers.Authorization;
let creds: string = Buffer.from(auth.substring('Basic '.length), 'base64').toString();
expect(creds).toBe('PAT:' + token);
expect(obj.url).toBe("https://httpbin.org/get");
});
it('does basic http get request with pat token auth', async() => {
let token: string = 'scbfb44vxzku5l4xgc3qfazn3lpk4awflfryc76esaiq7aypcbhs';
let ph: am.BearerCredentialHandler =
new am.BearerCredentialHandler(token);
let http: httpm.HttpClient = new httpm.HttpClient('http-client-tests', [ph]);
let res: httpm.HttpClientResponse = await http.get('http://httpbin.org/get');
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
let auth: string = obj.headers.Authorization;
expect(auth).toBe('Bearer ' + token);
expect(obj.url).toBe("https://httpbin.org/get");
});
})

182
__tests__/basics.test.ts Normal file
View File

@ -0,0 +1,182 @@
import * as httpm from '../_out';
import * as path from 'path';
import * as am from '../_out/auth';
import * as fs from 'fs';
let sampleFilePath: string = path.join(__dirname, 'testoutput.txt');
describe('basics', () => {
let _http: httpm.HttpClient;
let _httpbin: httpm.HttpClient;
beforeEach(() => {
_http = new httpm.HttpClient('http-client-tests');
})
afterEach(() => {
})
it('constructs', () => {
let http: httpm.HttpClient = new httpm.HttpClient('typed-test-client-tests');
expect(http).toBeDefined();
});
// responses from httpbin return something like:
// {
// "args": {},
// "headers": {
// "Connection": "close",
// "Host": "httpbin.org",
// "User-Agent": "typed-test-client-tests"
// },
// "origin": "173.95.152.44",
// "url": "https://httpbin.org/get"
// }
it('does basic http get request', async() => {
let res: httpm.HttpClientResponse = await _http.get('http://httpbin.org/get');
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj: any = JSON.parse(body);
expect(obj.url).toBe("https://httpbin.org/get");
expect(obj.headers["User-Agent"]).toBeTruthy();
});
it('does basic http get request with no user agent', async() => {
let http: httpm.HttpClient = new httpm.HttpClient();
let res: httpm.HttpClientResponse = await http.get('http://httpbin.org/get');
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj: any = JSON.parse(body);
expect(obj.url).toBe("https://httpbin.org/get");
expect(obj.headers["User-Agent"]).toBeFalsy();
});
it('does basic https get request', async() => {
let res: httpm.HttpClientResponse = await _http.get('https://httpbin.org/get');
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj: any = JSON.parse(body);
expect(obj.url).toBe("https://httpbin.org/get");
});
it('does basic http get request with default headers', async() => {
let http: httpm.HttpClient = new httpm.HttpClient('http-client-tests', [], {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
let res: httpm.HttpClientResponse = await http.get('http://httpbin.org/get');
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
expect(obj.headers.Accept).toBe('application/json');
expect(obj.headers['Content-Type']).toBe('application/json');
expect(obj.url).toBe("https://httpbin.org/get");
});
it('does basic http get request with merged headers', async() => {
let http: httpm.HttpClient = new httpm.HttpClient('http-client-tests', [], {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
let res: httpm.HttpClientResponse = await http.get('http://httpbin.org/get', {
'content-type': 'application/x-www-form-urlencoded'
});
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
expect(obj.headers.Accept).toBe('application/json');
expect(obj.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
expect(obj.url).toBe("https://httpbin.org/get");
});
it('pipes a get request', () => {
return new Promise<string>(async (resolve, reject) => {
let file: NodeJS.WritableStream = fs.createWriteStream(sampleFilePath);
(await _http.get('https://httpbin.org/get')).message.pipe(file).on('close', () => {
let body: string = fs.readFileSync(sampleFilePath).toString();
let obj:any = JSON.parse(body);
expect(obj.url).toBe("https://httpbin.org/get");
resolve();
});
});
});
it('does basic get request with redirects', async() => {
let res: httpm.HttpClientResponse = await _http.get("https://httpbin.org/redirect-to?url=" + encodeURIComponent("https://httpbin.org/get"))
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
expect(obj.url).toBe("https://httpbin.org/get");
});
it('does basic get request with redirects (303)', async() => {
let res: httpm.HttpClientResponse = await _http.get("https://httpbin.org/redirect-to?url=" + encodeURIComponent("https://httpbin.org/get") + '&status_code=303')
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
expect(obj.url).toBe("https://httpbin.org/get");
});
it('returns 404 for not found get request on redirect', async() => {
let res: httpm.HttpClientResponse = await _http.get("https://httpbin.org/redirect-to?url=" + encodeURIComponent("https://httpbin.org/status/404") + '&status_code=303')
expect(res.message.statusCode).toBe(404);
let body: string = await res.readBody();
});
it('does not follow redirects if disabled', async() => {
let http: httpm.HttpClient = new httpm.HttpClient('typed-test-client-tests', null, { allowRedirects: false });
let res: httpm.HttpClientResponse = await http.get("https://httpbin.org/redirect-to?url=" + encodeURIComponent("https://httpbin.org/get"))
expect(res.message.statusCode).toBe(302);
let body: string = await res.readBody();
});
it('does basic head request', async() => {
let res: httpm.HttpClientResponse = await _http.head('http://httpbin.org/get');
expect(res.message.statusCode).toBe(200);
});
it('does basic http delete request', async() => {
let res: httpm.HttpClientResponse = await _http.del('http://httpbin.org/delete');
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
});
it('does basic http post request', async() => {
let b: string = 'Hello World!';
let res: httpm.HttpClientResponse = await _http.post('http://httpbin.org/post', b);
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
expect(obj.data).toBe(b);
expect(obj.url).toBe("https://httpbin.org/post");
});
it('does basic http patch request', async() => {
let b: string = 'Hello World!';
let res: httpm.HttpClientResponse = await _http.patch('http://httpbin.org/patch', b);
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
expect(obj.data).toBe(b);
expect(obj.url).toBe("https://httpbin.org/patch");
});
it('does basic http options request', async() => {
let res: httpm.HttpClientResponse = await _http.options('http://httpbin.org');
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
});
it('returns 404 for not found get request', async() => {
let res: httpm.HttpClientResponse = await _http.get('http://httpbin.org/status/404');
expect(res.message.statusCode).toBe(404);
let body: string = await res.readBody();
});
})

View File

@ -0,0 +1,65 @@
import * as httpm from '../_out';
import * as path from 'path';
import * as am from '../_out/auth';
import * as fs from 'fs';
let sampleFilePath: string = path.join(__dirname, 'testoutput.txt');
describe('basics', () => {
let _http: httpm.HttpClient;
let _httpbin: httpm.HttpClient;
beforeEach(() => {
_http = new httpm.HttpClient('typed-test-client-tests', [], { keepAlive: true });
})
afterEach(() => {
})
it('does basic http get request with keepAlive true', async() => {
let res: httpm.HttpClientResponse = await _http.get('http://httpbin.org/get');
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
expect(obj.url).toBe("https://httpbin.org/get");
});
it('does basic head request with keepAlive true', async() => {
let res: httpm.HttpClientResponse = await _http.head('http://httpbin.org/get');
expect(res.message.statusCode).toBe(200);
});
it('does basic http delete request with keepAlive true', async() => {
let res: httpm.HttpClientResponse = await _http.del('http://httpbin.org/delete');
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
});
it('does basic http post request with keepAlive true', async() => {
let b: string = 'Hello World!';
let res: httpm.HttpClientResponse = await _http.post('http://httpbin.org/post', b);
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
expect(obj.data).toBe(b);
expect(obj.url).toBe("https://httpbin.org/post");
});
it('does basic http patch request with keepAlive true', async() => {
let b: string = 'Hello World!';
let res: httpm.HttpClientResponse = await _http.patch('http://httpbin.org/patch', b);
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
let obj:any = JSON.parse(body);
expect(obj.data).toBe(b);
expect(obj.url).toBe("https://httpbin.org/patch");
});
it('does basic http options request with keepAlive true', async() => {
let res: httpm.HttpClientResponse = await _http.options('http://httpbin.org');
expect(res.message.statusCode).toBe(200);
let body: string = await res.readBody();
});
});

96
__tests__/proxy.test.ts Normal file
View File

@ -0,0 +1,96 @@
import * as pm from '../_out/proxy';
import * as url from 'url';
describe('proxy', () => {
beforeEach(() => {
})
afterEach(() => {
})
it('does not return proxyUrl if variables not set', () => {
_clearVars();
let proxyUrl = pm.getProxyUrl(url.parse('https://github.com'));
expect(proxyUrl).toBeUndefined();
})
it('returns proxyUrl if https_proxy set for https url', () => {
_clearVars();
process.env["https_proxy"] = "https://myproxysvr";
let proxyUrl = pm.getProxyUrl(url.parse('https://github.com'));
expect(proxyUrl).toBeDefined();
})
it('does not return proxyUrl if http_proxy set for https url', () => {
_clearVars();
process.env["http_proxy"] = "https://myproxysvr";
let proxyUrl = pm.getProxyUrl(url.parse('https://github.com'));
expect(proxyUrl).toBeUndefined();
})
it('returns proxyUrl if http_proxy set for http url', () => {
_clearVars();
process.env["http_proxy"] = "http://myproxysvr";
let proxyUrl = pm.getProxyUrl(url.parse('http://github.com'));
expect(proxyUrl).toBeDefined();
})
it('does not return proxyUrl if only host as no_proxy list', () => {
_clearVars();
process.env["https_proxy"] = "https://myproxysvr";
process.env["no_proxy"] = "myserver"
let proxyUrl = pm.getProxyUrl(url.parse('https://myserver'));
expect(proxyUrl).toBeUndefined();
})
it('does not return proxyUrl if host in no_proxy list', () => {
_clearVars();
process.env["https_proxy"] = "https://myproxysvr";
process.env["no_proxy"] = "otherserver,myserver,anotherserver:8080"
let proxyUrl = pm.getProxyUrl(url.parse('https://myserver'));
expect(proxyUrl).toBeUndefined();
})
it('does not return proxyUrl if host in no_proxy list with spaces', () => {
_clearVars();
process.env["https_proxy"] = "https://myproxysvr";
process.env["no_proxy"] = "otherserver, myserver ,anotherserver:8080"
let proxyUrl = pm.getProxyUrl(url.parse('https://myserver'));
expect(proxyUrl).toBeUndefined();
})
it('does not return proxyUrl if host in no_proxy list with ports', () => {
_clearVars();
process.env["https_proxy"] = "https://myproxysvr";
process.env["no_proxy"] = "otherserver, myserver:8080 ,anotherserver"
let proxyUrl = pm.getProxyUrl(url.parse('https://myserver:8080'));
expect(proxyUrl).toBeUndefined();
})
it('returns proxyUrl if https_proxy set and not in no_proxy list', () => {
_clearVars();
process.env["https_proxy"] = "https://myproxysvr";
process.env["no_proxy"] = "otherserver, myserver ,anotherserver:8080"
let proxyUrl = pm.getProxyUrl(url.parse('https://github.com'));
expect(proxyUrl).toBeDefined();
})
it('returns proxyUrl if https_proxy set empty no_proxy set', () => {
_clearVars();
process.env["https_proxy"] = "https://myproxysvr";
process.env["no_proxy"] = ""
let proxyUrl = pm.getProxyUrl(url.parse('https://github.com'));
expect(proxyUrl).toBeDefined();
})
})
function _clearVars() {
delete process.env.http_proxy;
delete process.env.HTTP_PROXY;
delete process.env.https_proxy;
delete process.env.HTTPS_PROXY;
delete process.env.no_proxy;
delete process.env.NO_PROXY;
}

71
auth.ts Normal file
View File

@ -0,0 +1,71 @@
import ifm = require('./interfaces');
export class BasicCredentialHandler implements ifm.IRequestHandler {
username: string;
password: string;
constructor(username: string, password: string) {
this.username = username;
this.password = password;
}
prepareRequest(options:any): void {
options.headers['Authorization'] = 'Basic ' + new Buffer(this.username + ':' + this.password).toString('base64');
}
// This handler cannot handle 401
canHandleAuthentication(response: ifm.IHttpClientResponse): boolean {
return false;
}
handleAuthentication(httpClient: ifm.IHttpClient, requestInfo: ifm.IRequestInfo, objs): Promise<ifm.IHttpClientResponse> {
return null;
}
}
export class BearerCredentialHandler implements ifm.IRequestHandler {
token: string;
constructor(token: string) {
this.token = token;
}
// currently implements pre-authorization
// TODO: support preAuth = false where it hooks on 401
prepareRequest(options:any): void {
options.headers['Authorization'] = 'Bearer ' + this.token;
}
// This handler cannot handle 401
canHandleAuthentication(response: ifm.IHttpClientResponse): boolean {
return false;
}
handleAuthentication(httpClient: ifm.IHttpClient, requestInfo: ifm.IRequestInfo, objs): Promise<ifm.IHttpClientResponse> {
return null;
}
}
export class PersonalAccessTokenCredentialHandler implements ifm.IRequestHandler {
token: string;
constructor(token: string) {
this.token = token;
}
// currently implements pre-authorization
// TODO: support preAuth = false where it hooks on 401
prepareRequest(options:any): void {
options.headers['Authorization'] = 'Basic ' + new Buffer('PAT:' + this.token).toString('base64');
}
// This handler cannot handle 401
canHandleAuthentication(response: ifm.IHttpClientResponse): boolean {
return false;
}
handleAuthentication(httpClient: ifm.IHttpClient, requestInfo: ifm.IRequestInfo, objs): Promise<ifm.IHttpClientResponse> {
return null;
}
}

456
index.ts Normal file
View File

@ -0,0 +1,456 @@
import url = require("url");
import http = require("http");
import https = require("https");
import ifm = require('./interfaces');
import pm = require('./proxy');
let fs: any;
let tunnel: any;
export enum HttpCodes {
OK = 200,
MultipleChoices = 300,
MovedPermanently = 301,
ResourceMoved = 302,
SeeOther = 303,
NotModified = 304,
UseProxy = 305,
SwitchProxy = 306,
TemporaryRedirect = 307,
PermanentRedirect = 308,
BadRequest = 400,
Unauthorized = 401,
PaymentRequired = 402,
Forbidden = 403,
NotFound = 404,
MethodNotAllowed = 405,
NotAcceptable = 406,
ProxyAuthenticationRequired = 407,
RequestTimeout = 408,
Conflict = 409,
Gone = 410,
InternalServerError = 500,
NotImplemented = 501,
BadGateway = 502,
ServiceUnavailable = 503,
GatewayTimeout = 504,
}
const HttpRedirectCodes: number[] = [HttpCodes.MovedPermanently, HttpCodes.ResourceMoved, HttpCodes.SeeOther, HttpCodes.TemporaryRedirect, HttpCodes.PermanentRedirect];
const HttpResponseRetryCodes: number[] = [HttpCodes.BadGateway, HttpCodes.ServiceUnavailable, HttpCodes.GatewayTimeout];
const RetryableHttpVerbs: string[] = ['OPTIONS', 'GET', 'DELETE', 'HEAD'];
const ExponentialBackoffCeiling = 10;
const ExponentialBackoffTimeSlice = 5;
export class HttpClientResponse implements ifm.IHttpClientResponse {
constructor(message: http.IncomingMessage) {
this.message = message;
}
public message: http.IncomingMessage;
readBody(): Promise<string> {
return new Promise<string>(async (resolve, reject) => {
let output = Buffer.alloc(0);
this.message.on('data', (chunk: Buffer) => {
output = Buffer.concat([output, chunk]);
});
this.message.on('end', () => {
resolve(output.toString());
});
});
}
}
export function isHttps(requestUrl: string) {
let parsedUrl: url.Url = url.parse(requestUrl);
return parsedUrl.protocol === 'https:';
}
export class HttpClient {
userAgent: string | null | undefined;
handlers: ifm.IRequestHandler[];
requestOptions: ifm.IRequestOptions;
private _ignoreSslError: boolean = false;
private _socketTimeout: number;
private _allowRedirects: boolean = true;
private _allowRedirectDowngrade: boolean = false;
private _maxRedirects: number = 50;
private _allowRetries: boolean = false;
private _maxRetries: number = 1;
private _agent;
private _proxyAgent;
private _keepAlive: boolean = false;
private _disposed: boolean = false;
constructor(userAgent?: string, handlers?: ifm.IRequestHandler[], requestOptions?: ifm.IRequestOptions) {
this.userAgent = userAgent;
this.handlers = handlers || [];
this.requestOptions = requestOptions;
if (requestOptions) {
if (requestOptions.ignoreSslError != null) {
this._ignoreSslError = requestOptions.ignoreSslError;
}
this._socketTimeout = requestOptions.socketTimeout;
if (requestOptions.allowRedirects != null) {
this._allowRedirects = requestOptions.allowRedirects;
}
if (requestOptions.allowRedirectDowngrade != null) {
this._allowRedirectDowngrade = requestOptions.allowRedirectDowngrade;
}
if (requestOptions.maxRedirects != null) {
this._maxRedirects = Math.max(requestOptions.maxRedirects, 0);
}
if (requestOptions.keepAlive != null) {
this._keepAlive = requestOptions.keepAlive;
}
if (requestOptions.allowRetries != null) {
this._allowRetries = requestOptions.allowRetries;
}
if (requestOptions.maxRetries != null) {
this._maxRetries = requestOptions.maxRetries;
}
}
}
public options(requestUrl: string, additionalHeaders?: ifm.IHeaders): Promise<ifm.IHttpClientResponse> {
return this.request('OPTIONS', requestUrl, null, additionalHeaders || {});
}
public get(requestUrl: string, additionalHeaders?: ifm.IHeaders): Promise<ifm.IHttpClientResponse> {
return this.request('GET', requestUrl, null, additionalHeaders || {});
}
public del(requestUrl: string, additionalHeaders?: ifm.IHeaders): Promise<ifm.IHttpClientResponse> {
return this.request('DELETE', requestUrl, null, additionalHeaders || {});
}
public post(requestUrl: string, data: string, additionalHeaders?: ifm.IHeaders): Promise<ifm.IHttpClientResponse> {
return this.request('POST', requestUrl, data, additionalHeaders || {});
}
public patch(requestUrl: string, data: string, additionalHeaders?: ifm.IHeaders): Promise<ifm.IHttpClientResponse> {
return this.request('PATCH', requestUrl, data, additionalHeaders || {});
}
public put(requestUrl: string, data: string, additionalHeaders?: ifm.IHeaders): Promise<ifm.IHttpClientResponse> {
return this.request('PUT', requestUrl, data, additionalHeaders || {});
}
public head(requestUrl: string, additionalHeaders?: ifm.IHeaders): Promise<ifm.IHttpClientResponse> {
return this.request('HEAD', requestUrl, null, additionalHeaders || {});
}
public sendStream(verb: string, requestUrl: string, stream: NodeJS.ReadableStream, additionalHeaders?: ifm.IHeaders): Promise<ifm.IHttpClientResponse> {
return this.request(verb, requestUrl, stream, additionalHeaders);
}
/**
* Makes a raw http request.
* All other methods such as get, post, patch, and request ultimately call this.
* Prefer get, del, post and patch
*/
public async request(verb: string, requestUrl: string, data: string | NodeJS.ReadableStream, headers: ifm.IHeaders): Promise<ifm.IHttpClientResponse> {
if (this._disposed) {
throw new Error("Client has already been disposed.");
}
let parsedUrl = url.parse(requestUrl);
let info: ifm.IRequestInfo = this._prepareRequest(verb, parsedUrl, headers);
// Only perform retries on reads since writes may not be idempotent.
let maxTries: number = (this._allowRetries && RetryableHttpVerbs.indexOf(verb) != -1) ? this._maxRetries + 1 : 1;
let numTries: number = 0;
let response: HttpClientResponse;
while (numTries < maxTries) {
response = await this.requestRaw(info, data);
// Check if it's an authentication challenge
if (response && response.message && response.message.statusCode === HttpCodes.Unauthorized) {
let authenticationHandler: ifm.IRequestHandler;
for (let i = 0; i < this.handlers.length; i++) {
if (this.handlers[i].canHandleAuthentication(response)) {
authenticationHandler = this.handlers[i];
break;
}
}
if (authenticationHandler) {
return authenticationHandler.handleAuthentication(this, info, data);
}
else {
// We have received an unauthorized response but have no handlers to handle it.
// Let the response return to the caller.
return response;
}
}
let redirectsRemaining: number = this._maxRedirects;
while (HttpRedirectCodes.indexOf(response.message.statusCode) != -1
&& this._allowRedirects
&& redirectsRemaining > 0) {
const redirectUrl: string | null = response.message.headers["location"];
if (!redirectUrl) {
// if there's no location to redirect to, we won't
break;
}
let parsedRedirectUrl = url.parse(redirectUrl);
if (parsedUrl.protocol == 'https:' && parsedUrl.protocol != parsedRedirectUrl.protocol && !this._allowRedirectDowngrade) {
throw new Error("Redirect from HTTPS to HTTP protocol. This downgrade is not allowed for security reasons. If you want to allow this behavior, set the allowRedirectDowngrade option to true.");
}
// we need to finish reading the response before reassigning response
// which will leak the open socket.
await response.readBody();
// let's make the request with the new redirectUrl
info = this._prepareRequest(verb, parsedRedirectUrl, headers);
response = await this.requestRaw(info, data);
redirectsRemaining--;
}
if (HttpResponseRetryCodes.indexOf(response.message.statusCode) == -1) {
// If not a retry code, return immediately instead of retrying
return response;
}
numTries += 1;
if (numTries < maxTries) {
await response.readBody();
await this._performExponentialBackoff(numTries);
}
}
return response;
}
/**
* Needs to be called if keepAlive is set to true in request options.
*/
public dispose() {
if (this._agent) {
this._agent.destroy();
}
this._disposed = true;
}
/**
* Raw request.
* @param info
* @param data
*/
public requestRaw(info: ifm.IRequestInfo, data: string | NodeJS.ReadableStream): Promise<ifm.IHttpClientResponse> {
return new Promise<ifm.IHttpClientResponse>((resolve, reject) => {
let callbackForResult = function (err: any, res: ifm.IHttpClientResponse) {
if (err) {
reject(err);
}
resolve(res);
};
this.requestRawWithCallback(info, data, callbackForResult);
});
}
/**
* Raw request with callback.
* @param info
* @param data
* @param onResult
*/
public requestRawWithCallback(info: ifm.IRequestInfo, data: string | NodeJS.ReadableStream, onResult: (err: any, res: ifm.IHttpClientResponse) => void): void {
let socket;
let isDataString = typeof (data) === 'string';
if (typeof (data) === 'string') {
info.options.headers["Content-Length"] = Buffer.byteLength(data, 'utf8');
}
let callbackCalled: boolean = false;
let handleResult = (err: any, res: HttpClientResponse) => {
if (!callbackCalled) {
callbackCalled = true;
onResult(err, res);
}
};
let req: http.ClientRequest = info.httpModule.request(info.options, (msg: http.IncomingMessage) => {
let res: HttpClientResponse = new HttpClientResponse(msg);
handleResult(null, res);
});
req.on('socket', (sock) => {
socket = sock;
});
// If we ever get disconnected, we want the socket to timeout eventually
req.setTimeout(this._socketTimeout || 3 * 60000, () => {
if (socket) {
socket.end();
}
handleResult(new Error('Request timeout: ' + info.options.path), null);
});
req.on('error', function (err) {
// err has statusCode property
// res should have headers
handleResult(err, null);
});
if (data && typeof (data) === 'string') {
req.write(data, 'utf8');
}
if (data && typeof (data) !== 'string') {
data.on('close', function () {
req.end();
});
data.pipe(req);
}
else {
req.end();
}
}
private _prepareRequest(method: string, requestUrl: url.Url, headers: ifm.IHeaders): ifm.IRequestInfo {
const info: ifm.IRequestInfo = <ifm.IRequestInfo>{};
info.parsedUrl = requestUrl;
const usingSsl: boolean = info.parsedUrl.protocol === 'https:';
info.httpModule = usingSsl ? https : http;
const defaultPort: number = usingSsl ? 443 : 80;
info.options = <http.RequestOptions>{};
info.options.host = info.parsedUrl.hostname;
info.options.port = info.parsedUrl.port ? parseInt(info.parsedUrl.port) : defaultPort;
info.options.path = (info.parsedUrl.pathname || '') + (info.parsedUrl.search || '');
info.options.method = method;
info.options.headers = this._mergeHeaders(headers);
if (this.userAgent != null) {
info.options.headers["user-agent"] = this.userAgent;
}
info.options.agent = this._getAgent(info.parsedUrl);
// gives handlers an opportunity to participate
if (this.handlers) {
this.handlers.forEach((handler) => {
handler.prepareRequest(info.options);
});
}
return info;
}
private _mergeHeaders(headers: ifm.IHeaders) : ifm.IHeaders {
const lowercaseKeys = obj => Object.keys(obj).reduce((c, k) => (c[k.toLowerCase()] = obj[k], c), {});
if (this.requestOptions && this.requestOptions.headers) {
return Object.assign(
{},
lowercaseKeys(this.requestOptions.headers),
lowercaseKeys(headers)
);
}
return lowercaseKeys(headers || {});
}
private _getAgent(parsedUrl: url.Url) {
let agent;
let proxyUrl: url.Url = pm.getProxyUrl(parsedUrl);
let useProxy = proxyUrl && proxyUrl.hostname;
if (this._keepAlive && useProxy) {
agent = this._proxyAgent;
}
if (this._keepAlive && !useProxy) {
agent = this._agent;
}
// if agent is already assigned use that agent.
if (!!agent) {
return agent;
}
const usingSsl = parsedUrl.protocol === 'https:';
let maxSockets = 100;
if (!!this.requestOptions) {
maxSockets = this.requestOptions.maxSockets || http.globalAgent.maxSockets
}
if (useProxy) {
// If using proxy, need tunnel
if (!tunnel) {
tunnel = require('tunnel');
}
const agentOptions = {
maxSockets: maxSockets,
keepAlive: this._keepAlive,
proxy: {
proxyAuth: proxyUrl.auth,
host: proxyUrl.hostname,
port: proxyUrl.port
},
};
let tunnelAgent: Function;
const overHttps = proxyUrl.protocol === 'https:';
if (usingSsl) {
tunnelAgent = overHttps ? tunnel.httpsOverHttps : tunnel.httpsOverHttp;
} else {
tunnelAgent = overHttps ? tunnel.httpOverHttps : tunnel.httpOverHttp;
}
agent = tunnelAgent(agentOptions);
this._proxyAgent = agent;
}
// if reusing agent across request and tunneling agent isn't assigned create a new agent
if (this._keepAlive && !agent) {
const options = { keepAlive: this._keepAlive, maxSockets: maxSockets };
agent = usingSsl ? new https.Agent(options) : new http.Agent(options);
this._agent = agent;
}
// if not using private agent and tunnel agent isn't setup then use global agent
if (!agent) {
agent = usingSsl ? https.globalAgent : http.globalAgent;
}
if (usingSsl && this._ignoreSslError) {
// we don't want to set NODE_TLS_REJECT_UNAUTHORIZED=0 since that will affect request for entire process
// http.RequestOptions doesn't expose a way to modify RequestOptions.agent.options
// we have to cast it to any and change it directly
agent.options = Object.assign(agent.options || {}, { rejectUnauthorized: false });
}
return agent;
}
private _performExponentialBackoff(retryNumber: number): Promise<void> {
retryNumber = Math.min(ExponentialBackoffCeiling, retryNumber);
const ms: number = ExponentialBackoffTimeSlice*Math.pow(2, retryNumber);
return new Promise(resolve => setTimeout(()=>resolve(), ms));
}
}

48
interfaces.ts Normal file
View File

@ -0,0 +1,48 @@
import http = require("http");
import url = require("url");
export interface IHeaders { [key: string]: any };
export interface IHttpClient {
options(requestUrl: string, additionalHeaders?: IHeaders): Promise<IHttpClientResponse>;
get(requestUrl: string, additionalHeaders?: IHeaders): Promise<IHttpClientResponse>;
del(requestUrl: string, additionalHeaders?: IHeaders): Promise<IHttpClientResponse>;
post(requestUrl: string, data: string, additionalHeaders?: IHeaders): Promise<IHttpClientResponse>;
patch(requestUrl: string, data: string, additionalHeaders?: IHeaders): Promise<IHttpClientResponse>;
put(requestUrl: string, data: string, additionalHeaders?: IHeaders): Promise<IHttpClientResponse>;
sendStream(verb: string, requestUrl: string, stream: NodeJS.ReadableStream, additionalHeaders?: IHeaders): Promise<IHttpClientResponse>;
request(verb: string, requestUrl: string, data: string | NodeJS.ReadableStream, headers: IHeaders): Promise<IHttpClientResponse>;
requestRaw(info: IRequestInfo, data: string | NodeJS.ReadableStream): Promise<IHttpClientResponse>;
requestRawWithCallback(info: IRequestInfo, data: string | NodeJS.ReadableStream, onResult: (err: any, res: IHttpClientResponse) => void): void;
}
export interface IRequestHandler {
prepareRequest(options: http.RequestOptions): void;
canHandleAuthentication(response: IHttpClientResponse): boolean;
handleAuthentication(httpClient: IHttpClient, requestInfo: IRequestInfo, objs): Promise<IHttpClientResponse>;
}
export interface IHttpClientResponse {
message: http.IncomingMessage;
readBody(): Promise<string>;
}
export interface IRequestInfo {
options: http.RequestOptions;
parsedUrl: url.Url;
httpModule: any;
}
export interface IRequestOptions {
headers?: IHeaders;
socketTimeout?: number;
ignoreSslError?: boolean;
allowRedirects?: boolean;
allowRedirectDowngrade?: boolean;
maxRedirects?: number;
maxSockets?: number;
keepAlive?: boolean;
// Allows retries only on Read operations (since writes may not be idempotent)
allowRetries?: boolean;
maxRetries?: number;
}

10
jest.config.js Normal file
View File

@ -0,0 +1,10 @@
module.exports = {
clearMocks: true,
moduleFileExtensions: ['js', 'ts'],
testEnvironment: 'node',
testMatch: ['**/__tests__/*.test.ts'],
transform: {
'^.+\\.ts$': 'ts-jest'
},
verbose: true
}

4995
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "http-client",
"version": "0.5.0",
"description": "Actions Http Client",
"main": "index.js",
"scripts": {
"build": "rm -Rf ./_out && tsc && cp package*.json ./_out && cp *.md ./_out && cp LICENSE ./_out",
"test": "jest"
},
"repository": {
"type": "git",
"url": "git+https://github.com/actions/http-client.git"
},
"keywords": [
"Actions",
"Http"
],
"author": "GitHub, Inc.",
"license": "MIT",
"bugs": {
"url": "https://github.com/actions/http-client/issues"
},
"homepage": "https://github.com/actions/http-client#readme",
"devDependencies": {
"@types/jest": "^24.0.25",
"@types/node": "^13.1.5",
"@types/shelljs": "^0.8.6",
"jest": "^24.9.0",
"nock": "^11.7.2",
"shelljs": "^0.8.3",
"ts-jest": "^24.3.0",
"typescript": "^3.7.4"
}
}

43
proxy.ts Normal file
View File

@ -0,0 +1,43 @@
import * as url from 'url';
export function getProxyUrl(reqUrl: url.Url): url.Url {
let usingSsl = reqUrl.protocol === 'https:';
let noProxy: string = process.env["no_proxy"] ||
process.env["NO_PROXY"];
let bypass: boolean;
if (noProxy && typeof noProxy === 'string') {
let bypassList = noProxy.split(',');
for (let i=0; i < bypassList.length; i++) {
let item = bypassList[i];
if (item &&
typeof item === "string" &&
reqUrl.host.toLocaleLowerCase() == item.trim().toLocaleLowerCase()) {
bypass = true;
break;
}
}
}
let proxyUrl: url.Url;
if (bypass) {
return proxyUrl;
}
let proxyVar: string;
if (usingSsl) {
proxyVar = process.env["https_proxy"] ||
process.env["HTTPS_PROXY"];
} else {
proxyVar = process.env["http_proxy"] ||
process.env["HTTP_PROXY"];
}
if (proxyVar) {
proxyUrl = url.parse(proxyVar);
}
return proxyUrl;
}

15
tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2015",
"module": "commonjs",
"moduleResolution": "node",
"typeRoots": [ "node_modules/@types" ],
"declaration": true,
"outDir": "_out",
"forceConsistentCasingInFileNames": true
},
"files": [
"index.ts",
"auth.ts"
]
}