diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index b5f21974..cbabd034 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -5,8 +5,8 @@ on: inputs: package: required: true - description: 'core, artifact, cache, exec, github, glob, io, tool-cache' - + description: 'core, artifact, cache, exec, github, glob, http-client, io, tool-cache' + jobs: test: runs-on: macos-latest @@ -17,40 +17,40 @@ jobs: - name: verify package exists run: ls packages/${{ github.event.inputs.package }} - + - name: Set Node.js 12.x uses: actions/setup-node@v1 with: node-version: 12.x - + - name: npm install run: npm install - + - name: bootstrap run: npm run bootstrap - + - name: build run: npm run build - + - name: test run: npm run test - name: pack run: npm pack working-directory: packages/${{ github.event.inputs.package }} - + - name: upload artifact uses: actions/upload-artifact@v2 with: name: ${{ github.event.inputs.package }} path: packages/${{ github.event.inputs.package }}/*.tgz - + publish: runs-on: macos-latest needs: test environment: npm-publish steps: - + - name: download artifact uses: actions/download-artifact@v2 with: @@ -58,7 +58,7 @@ jobs: - name: setup authentication run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc - env: + env: NPM_TOKEN: ${{ secrets.TOKEN }} - name: publish @@ -68,13 +68,13 @@ jobs: if: failure() run: | curl -X POST -H 'Content-type: application/json' --data '{"text":":pb__failed: Failed to publish a new version of ${{ github.event.inputs.package }}"}' $SLACK_WEBHOOK - env: + env: SLACK_WEBHOOK: ${{ secrets.SLACK }} - + - name: notify slack on success if: success() run: | curl -X POST -H 'Content-type: application/json' --data '{"text":":dance: Successfully published a new version of ${{ github.event.inputs.package }}"}' $SLACK_WEBHOOK - env: + env: SLACK_WEBHOOK: ${{ secrets.SLACK }} - + diff --git a/README.md b/README.md index 7571d1eb..43ee8acd 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,15 @@ $ npm install @actions/glob ```
+:phone: [@actions/http-client](packages/http-client) + +A lightweight HTTP client optimized for building actions. Read more [here](packages/http-client) + +```bash +$ npm install @actions/http-client +``` +
+ :pencil2: [@actions/io](packages/io) Provides disk i/o functions like cp, mv, rmRF, which etc. Read more [here](packages/io) diff --git a/packages/http-client/.gitignore b/packages/http-client/.gitignore new file mode 100644 index 00000000..d481b576 --- /dev/null +++ b/packages/http-client/.gitignore @@ -0,0 +1,2 @@ +testoutput.txt +npm-debug.log diff --git a/packages/http-client/LICENSE b/packages/http-client/LICENSE new file mode 100644 index 00000000..5823a51c --- /dev/null +++ b/packages/http-client/LICENSE @@ -0,0 +1,21 @@ +Actions Http 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. diff --git a/packages/http-client/README.md b/packages/http-client/README.md new file mode 100644 index 00000000..7e06adeb --- /dev/null +++ b/packages/http-client/README.md @@ -0,0 +1,73 @@ +# `@actions/http-client` + +A lightweight HTTP client optimized for building actions. + +## Features + + - HTTP client with TypeScript generics and async/await/Promises + - Typings included! + - [Proxy support](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/about-self-hosted-runners#using-a-proxy-server-with-self-hosted-runners) just works with actions and the runner + - Targets ES2019 (runner runs actions with node 12+). Only supported on node 12+. + - Basic, Bearer and PAT Support out of the box. Extensible handlers for others. + - Redirects supported + +Features and releases [here](./RELEASES.md) + +## Install + +``` +npm install @actions/http-client --save +``` + +## Samples + +See the [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 the [tests](./__tests__) for detailed examples. + +## Debugging + +To enable detailed console logging of all HTTP requests and responses, set the NODE_DEBUG environment varible: + +```shell +export NODE_DEBUG=http +``` + +## Node support + +The http-client is built using the latest LTS version of Node 12. It may work on previous node LTS versions but it's tested and officially supported on Node12+. + +## Support and Versioning + +We follow semver and will hold compatibility between major versions and increment the minor version with new features and capabilities (while holding compat). + +## Contributing + +We welcome PRs. Please create an issue and if applicable, a design before proceeding with code. + +once: + +``` +npm install +``` + +To build: + +``` +npm run build +``` + +To run all tests: + +``` +npm test +``` diff --git a/packages/http-client/RELEASES.md b/packages/http-client/RELEASES.md new file mode 100644 index 00000000..a097a4e0 --- /dev/null +++ b/packages/http-client/RELEASES.md @@ -0,0 +1,36 @@ +## Releases + +## 2.0.0 +- The package is now compiled with TypeScript's [`strict` compiler setting](https://www.typescriptlang.org/tsconfig#strict). To comply with stricter rules: + - Some exported types now include `| null` or `| undefined`, matching their actual behavior. + - Types implementing the method `RequestHandler.handleAuthentication()` now throw an `Error` rather than returning `null` if they do not support handling an HTTP 401 response. Callers can still use `canHandleAuthentication()` to determine if this handling is supported or not. + - Types using `any` have been scoped to more specific types. +- Following TypeScript's naming conventions, exported interfaces no longer begin with the prefix `I-`. +- Delete the `IHttpClientResponse` interface in favor of the `HttpClientResponse` class. +- Delete the `IHeaders` interface in favor of `http.OutgoingHttpHeaders`. +- The source code of the package was moved to build with [actions/toolkit](https://github.com/actions/toolkit). + +## 1.0.11 + +Contains a bug fix where proxy is defined without a user and password. see [PR here](https://github.com/actions/http-client/pull/42) + +## 1.0.9 +Throw HttpClientError instead of a generic Error from the \Json() helper methods when the server responds with a non-successful status code. + +## 1.0.8 +Fixed security issue where a redirect (e.g. 302) to another domain would pass headers. The fix was to strip the authorization header if the hostname was different. More [details in PR #27](https://github.com/actions/http-client/pull/27) + +## 1.0.7 +Update NPM dependencies and add 429 to the list of HttpCodes + +## 1.0.6 +Automatically sends Content-Type and Accept application/json headers for \Json() helper methods if not set in the client or parameters. + +## 1.0.5 +Adds \Json() helper methods for json over http scenarios. + +## 1.0.4 +Started to add \Json() helper methods. Do not use this release for that. Use >= 1.0.5 since there was an issue with types. + +## 1.0.1 to 1.0.3 +Adds proxy support. diff --git a/packages/http-client/__tests__/auth.test.ts b/packages/http-client/__tests__/auth.test.ts new file mode 100644 index 00000000..878fafe9 --- /dev/null +++ b/packages/http-client/__tests__/auth.test.ts @@ -0,0 +1,73 @@ +import * as httpm from '../lib' +import * as am from '../lib/auth' + +describe('auth', () => { + beforeEach(() => {}) + + afterEach(() => {}) + + it('does basic http get request with basic auth', async () => { + const bh: am.BasicCredentialHandler = new am.BasicCredentialHandler( + 'johndoe', + 'password' + ) + const http: httpm.HttpClient = new httpm.HttpClient('http-client-tests', [ + bh + ]) + const res: httpm.HttpClientResponse = await http.get( + 'http://httpbin.org/get' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + const auth: string = obj.headers.Authorization + const creds: string = Buffer.from( + auth.substring('Basic '.length), + 'base64' + ).toString() + expect(creds).toBe('johndoe:password') + expect(obj.url).toBe('http://httpbin.org/get') + }) + + it('does basic http get request with pat token auth', async () => { + const token = 'scbfb44vxzku5l4xgc3qfazn3lpk4awflfryc76esaiq7aypcbhs' + const ph: am.PersonalAccessTokenCredentialHandler = new am.PersonalAccessTokenCredentialHandler( + token + ) + + const http: httpm.HttpClient = new httpm.HttpClient('http-client-tests', [ + ph + ]) + const res: httpm.HttpClientResponse = await http.get( + 'http://httpbin.org/get' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + const auth: string = obj.headers.Authorization + const creds: string = Buffer.from( + auth.substring('Basic '.length), + 'base64' + ).toString() + expect(creds).toBe(`PAT:${token}`) + expect(obj.url).toBe('http://httpbin.org/get') + }) + + it('does basic http get request with pat token auth', async () => { + const token = 'scbfb44vxzku5l4xgc3qfazn3lpk4awflfryc76esaiq7aypcbhs' + const ph: am.BearerCredentialHandler = new am.BearerCredentialHandler(token) + + const http: httpm.HttpClient = new httpm.HttpClient('http-client-tests', [ + ph + ]) + const res: httpm.HttpClientResponse = await http.get( + 'http://httpbin.org/get' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + const auth: string = obj.headers.Authorization + expect(auth).toBe(`Bearer ${token}`) + expect(obj.url).toBe('http://httpbin.org/get') + }) +}) diff --git a/packages/http-client/__tests__/basics.test.ts b/packages/http-client/__tests__/basics.test.ts new file mode 100644 index 00000000..7732264a --- /dev/null +++ b/packages/http-client/__tests__/basics.test.ts @@ -0,0 +1,374 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as httpm from '..' +import * as path from 'path' +import * as fs from 'fs' + +const sampleFilePath: string = path.join(__dirname, 'testoutput.txt') + +interface HttpBinData { + url: string + data: any + json: any + headers: any + args?: any +} + +describe('basics', () => { + let _http: httpm.HttpClient + + beforeEach(() => { + _http = new httpm.HttpClient('http-client-tests') + }) + + afterEach(() => {}) + + it('constructs', () => { + const http: httpm.HttpClient = new httpm.HttpClient('thttp-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 () => { + const res: httpm.HttpClientResponse = await _http.get( + 'http://httpbin.org/get' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.url).toBe('http://httpbin.org/get') + expect(obj.headers['User-Agent']).toBeTruthy() + }) + + it('does basic http get request with no user agent', async () => { + const http: httpm.HttpClient = new httpm.HttpClient() + const res: httpm.HttpClientResponse = await http.get( + 'http://httpbin.org/get' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.url).toBe('http://httpbin.org/get') + expect(obj.headers['User-Agent']).toBeFalsy() + }) + + it('does basic https get request', async () => { + const res: httpm.HttpClientResponse = await _http.get( + 'https://httpbin.org/get' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.url).toBe('https://httpbin.org/get') + }) + + it('does basic http get request with default headers', async () => { + const http: httpm.HttpClient = new httpm.HttpClient( + 'http-client-tests', + [], + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + } + ) + const res: httpm.HttpClientResponse = await http.get( + 'http://httpbin.org/get' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.headers.Accept).toBe('application/json') + expect(obj.headers['Content-Type']).toBe('application/json') + expect(obj.url).toBe('http://httpbin.org/get') + }) + + it('does basic http get request with merged headers', async () => { + const http: httpm.HttpClient = new httpm.HttpClient( + 'http-client-tests', + [], + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + } + ) + const res: httpm.HttpClientResponse = await http.get( + 'http://httpbin.org/get', + { + 'content-type': 'application/x-www-form-urlencoded' + } + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = 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('http://httpbin.org/get') + }) + + it('pipes a get request', async () => { + return new Promise(async resolve => { + const file = fs.createWriteStream(sampleFilePath) + ;(await _http.get('https://httpbin.org/get')).message + .pipe(file) + .on('close', () => { + const body: string = fs.readFileSync(sampleFilePath).toString() + const obj = JSON.parse(body) + expect(obj.url).toBe('https://httpbin.org/get') + resolve() + }) + }) + }) + + it('does basic get request with redirects', async () => { + const res: httpm.HttpClientResponse = await _http.get( + `https://httpbin.org/redirect-to?url=${encodeURIComponent( + 'https://httpbin.org/get' + )}` + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.url).toBe('https://httpbin.org/get') + }) + + it('does basic get request with redirects (303)', async () => { + const 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) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.url).toBe('https://httpbin.org/get') + }) + + it('returns 404 for not found get request on redirect', async () => { + const 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) + await res.readBody() + }) + + it('does not follow redirects if disabled', async () => { + const http: httpm.HttpClient = new httpm.HttpClient( + 'typed-test-client-tests', + undefined, + {allowRedirects: false} + ) + const res: httpm.HttpClientResponse = await http.get( + `https://httpbin.org/redirect-to?url=${encodeURIComponent( + 'https://httpbin.org/get' + )}` + ) + expect(res.message.statusCode).toBe(302) + await res.readBody() + }) + + it('does not pass auth with diff hostname redirects', async () => { + const headers = { + accept: 'application/json', + authorization: 'shhh' + } + const res: httpm.HttpClientResponse = await _http.get( + `https://httpbin.org/redirect-to?url=${encodeURIComponent( + 'https://www.httpbin.org/get' + )}`, + headers + ) + + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + // httpbin "fixes" the casing + expect(obj.headers['Accept']).toBe('application/json') + expect(obj.headers['Authorization']).toBeUndefined() + expect(obj.headers['authorization']).toBeUndefined() + expect(obj.url).toBe('https://www.httpbin.org/get') + }) + + it('does not pass Auth with diff hostname redirects', async () => { + const headers = { + Accept: 'application/json', + Authorization: 'shhh' + } + const res: httpm.HttpClientResponse = await _http.get( + `https://httpbin.org/redirect-to?url=${encodeURIComponent( + 'https://www.httpbin.org/get' + )}`, + headers + ) + + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + // httpbin "fixes" the casing + expect(obj.headers['Accept']).toBe('application/json') + expect(obj.headers['Authorization']).toBeUndefined() + expect(obj.headers['authorization']).toBeUndefined() + expect(obj.url).toBe('https://www.httpbin.org/get') + }) + + it('does basic head request', async () => { + const res: httpm.HttpClientResponse = await _http.head( + 'http://httpbin.org/get' + ) + expect(res.message.statusCode).toBe(200) + }) + + it('does basic http delete request', async () => { + const res: httpm.HttpClientResponse = await _http.del( + 'http://httpbin.org/delete' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + JSON.parse(body) + }) + + it('does basic http post request', async () => { + const b = 'Hello World!' + const res: httpm.HttpClientResponse = await _http.post( + 'http://httpbin.org/post', + b + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.data).toBe(b) + expect(obj.url).toBe('http://httpbin.org/post') + }) + + it('does basic http patch request', async () => { + const b = 'Hello World!' + const res: httpm.HttpClientResponse = await _http.patch( + 'http://httpbin.org/patch', + b + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.data).toBe(b) + expect(obj.url).toBe('http://httpbin.org/patch') + }) + + it('does basic http options request', async () => { + const res: httpm.HttpClientResponse = await _http.options( + 'http://httpbin.org' + ) + expect(res.message.statusCode).toBe(200) + await res.readBody() + }) + + it('returns 404 for not found get request', async () => { + const res: httpm.HttpClientResponse = await _http.get( + 'http://httpbin.org/status/404' + ) + expect(res.message.statusCode).toBe(404) + await res.readBody() + }) + + it('gets a json object', async () => { + const jsonObj = await _http.getJson('https://httpbin.org/get') + expect(jsonObj.statusCode).toBe(200) + expect(jsonObj.result).toBeDefined() + expect(jsonObj.result?.url).toBe('https://httpbin.org/get') + expect(jsonObj.result?.headers['Accept']).toBe( + httpm.MediaTypes.ApplicationJson + ) + expect(jsonObj.headers[httpm.Headers.ContentType]).toBe( + httpm.MediaTypes.ApplicationJson + ) + }) + + it('getting a non existent json object returns null', async () => { + const jsonObj = await _http.getJson( + 'https://httpbin.org/status/404' + ) + expect(jsonObj.statusCode).toBe(404) + expect(jsonObj.result).toBeNull() + }) + + it('posts a json object', async () => { + const res = {name: 'foo'} + const restRes = await _http.postJson( + 'https://httpbin.org/post', + res + ) + expect(restRes.statusCode).toBe(200) + expect(restRes.result).toBeDefined() + expect(restRes.result?.url).toBe('https://httpbin.org/post') + expect(restRes.result?.json.name).toBe('foo') + expect(restRes.result?.headers['Accept']).toBe( + httpm.MediaTypes.ApplicationJson + ) + expect(restRes.result?.headers['Content-Type']).toBe( + httpm.MediaTypes.ApplicationJson + ) + expect(restRes.headers[httpm.Headers.ContentType]).toBe( + httpm.MediaTypes.ApplicationJson + ) + }) + + it('puts a json object', async () => { + const res = {name: 'foo'} + const restRes = await _http.putJson( + 'https://httpbin.org/put', + res + ) + expect(restRes.statusCode).toBe(200) + expect(restRes.result).toBeDefined() + expect(restRes.result?.url).toBe('https://httpbin.org/put') + expect(restRes.result?.json.name).toBe('foo') + + expect(restRes.result?.headers['Accept']).toBe( + httpm.MediaTypes.ApplicationJson + ) + expect(restRes.result?.headers['Content-Type']).toBe( + httpm.MediaTypes.ApplicationJson + ) + expect(restRes.headers[httpm.Headers.ContentType]).toBe( + httpm.MediaTypes.ApplicationJson + ) + }) + + it('patch a json object', async () => { + const res = {name: 'foo'} + const restRes = await _http.patchJson( + 'https://httpbin.org/patch', + res + ) + expect(restRes.statusCode).toBe(200) + expect(restRes.result).toBeDefined() + expect(restRes.result?.url).toBe('https://httpbin.org/patch') + expect(restRes.result?.json.name).toBe('foo') + expect(restRes.result?.headers['Accept']).toBe( + httpm.MediaTypes.ApplicationJson + ) + expect(restRes.result?.headers['Content-Type']).toBe( + httpm.MediaTypes.ApplicationJson + ) + expect(restRes.headers[httpm.Headers.ContentType]).toBe( + httpm.MediaTypes.ApplicationJson + ) + }) +}) diff --git a/packages/http-client/__tests__/headers.test.ts b/packages/http-client/__tests__/headers.test.ts new file mode 100644 index 00000000..0af9563c --- /dev/null +++ b/packages/http-client/__tests__/headers.test.ts @@ -0,0 +1,116 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as httpm from '..' + +describe('headers', () => { + let _http: httpm.HttpClient + + beforeEach(() => { + _http = new httpm.HttpClient('http-client-tests') + }) + + it('preserves existing headers on getJson', async () => { + const additionalHeaders = {[httpm.Headers.Accept]: 'foo'} + let jsonObj = await _http.getJson( + 'https://httpbin.org/get', + additionalHeaders + ) + expect(jsonObj.result.headers['Accept']).toBe('foo') + expect(jsonObj.headers[httpm.Headers.ContentType]).toBe( + httpm.MediaTypes.ApplicationJson + ) + + const httpWithHeaders = new httpm.HttpClient() + httpWithHeaders.requestOptions = { + headers: { + [httpm.Headers.Accept]: 'baz' + } + } + jsonObj = await httpWithHeaders.getJson('https://httpbin.org/get') + expect(jsonObj.result.headers['Accept']).toBe('baz') + expect(jsonObj.headers[httpm.Headers.ContentType]).toBe( + httpm.MediaTypes.ApplicationJson + ) + }) + + it('preserves existing headers on postJson', async () => { + const additionalHeaders = {[httpm.Headers.Accept]: 'foo'} + let jsonObj = await _http.postJson( + 'https://httpbin.org/post', + {}, + additionalHeaders + ) + expect(jsonObj.result.headers['Accept']).toBe('foo') + expect(jsonObj.headers[httpm.Headers.ContentType]).toBe( + httpm.MediaTypes.ApplicationJson + ) + + const httpWithHeaders = new httpm.HttpClient() + httpWithHeaders.requestOptions = { + headers: { + [httpm.Headers.Accept]: 'baz' + } + } + jsonObj = await httpWithHeaders.postJson( + 'https://httpbin.org/post', + {} + ) + expect(jsonObj.result.headers['Accept']).toBe('baz') + expect(jsonObj.headers[httpm.Headers.ContentType]).toBe( + httpm.MediaTypes.ApplicationJson + ) + }) + + it('preserves existing headers on putJson', async () => { + const additionalHeaders = {[httpm.Headers.Accept]: 'foo'} + let jsonObj = await _http.putJson( + 'https://httpbin.org/put', + {}, + additionalHeaders + ) + expect(jsonObj.result.headers['Accept']).toBe('foo') + expect(jsonObj.headers[httpm.Headers.ContentType]).toBe( + httpm.MediaTypes.ApplicationJson + ) + + const httpWithHeaders = new httpm.HttpClient() + httpWithHeaders.requestOptions = { + headers: { + [httpm.Headers.Accept]: 'baz' + } + } + jsonObj = await httpWithHeaders.putJson('https://httpbin.org/put', {}) + expect(jsonObj.result.headers['Accept']).toBe('baz') + expect(jsonObj.headers[httpm.Headers.ContentType]).toBe( + httpm.MediaTypes.ApplicationJson + ) + }) + + it('preserves existing headers on patchJson', async () => { + const additionalHeaders = {[httpm.Headers.Accept]: 'foo'} + let jsonObj = await _http.patchJson( + 'https://httpbin.org/patch', + {}, + additionalHeaders + ) + expect(jsonObj.result.headers['Accept']).toBe('foo') + expect(jsonObj.headers[httpm.Headers.ContentType]).toBe( + httpm.MediaTypes.ApplicationJson + ) + + const httpWithHeaders = new httpm.HttpClient() + httpWithHeaders.requestOptions = { + headers: { + [httpm.Headers.Accept]: 'baz' + } + } + jsonObj = await httpWithHeaders.patchJson( + 'https://httpbin.org/patch', + {} + ) + expect(jsonObj.result.headers['Accept']).toBe('baz') + expect(jsonObj.headers[httpm.Headers.ContentType]).toBe( + httpm.MediaTypes.ApplicationJson + ) + }) +}) diff --git a/packages/http-client/__tests__/keepalive.test.ts b/packages/http-client/__tests__/keepalive.test.ts new file mode 100644 index 00000000..ed55be20 --- /dev/null +++ b/packages/http-client/__tests__/keepalive.test.ts @@ -0,0 +1,73 @@ +import * as httpm from '../lib' + +describe('basics', () => { + let _http: httpm.HttpClient + + beforeEach(() => { + _http = new httpm.HttpClient('http-client-tests', [], {keepAlive: true}) + }) + + afterEach(() => { + _http.dispose() + }) + + it('does basic http get request with keepAlive true', async () => { + const res: httpm.HttpClientResponse = await _http.get( + 'http://httpbin.org/get' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.url).toBe('http://httpbin.org/get') + }) + + it('does basic head request with keepAlive true', async () => { + const 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 () => { + const res: httpm.HttpClientResponse = await _http.del( + 'http://httpbin.org/delete' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + JSON.parse(body) + }) + + it('does basic http post request with keepAlive true', async () => { + const b = 'Hello World!' + const res: httpm.HttpClientResponse = await _http.post( + 'http://httpbin.org/post', + b + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.data).toBe(b) + expect(obj.url).toBe('http://httpbin.org/post') + }) + + it('does basic http patch request with keepAlive true', async () => { + const b = 'Hello World!' + const res: httpm.HttpClientResponse = await _http.patch( + 'http://httpbin.org/patch', + b + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.data).toBe(b) + expect(obj.url).toBe('http://httpbin.org/patch') + }) + + it('does basic http options request with keepAlive true', async () => { + const res: httpm.HttpClientResponse = await _http.options( + 'http://httpbin.org' + ) + expect(res.message.statusCode).toBe(200) + await res.readBody() + }) +}) diff --git a/packages/http-client/__tests__/proxy.test.ts b/packages/http-client/__tests__/proxy.test.ts new file mode 100644 index 00000000..62e8e962 --- /dev/null +++ b/packages/http-client/__tests__/proxy.test.ts @@ -0,0 +1,232 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as http from 'http' +import * as httpm from '../lib/' +import * as pm from '../lib/proxy' +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const proxy = require('proxy') + +let _proxyConnects: string[] +let _proxyServer: http.Server +const _proxyUrl = 'http://127.0.0.1:8080' + +describe('proxy', () => { + beforeAll(async () => { + // Start proxy server + _proxyServer = proxy() + await new Promise(resolve => { + const port = Number(_proxyUrl.split(':')[2]) + _proxyServer.listen(port, () => resolve()) + }) + _proxyServer.on('connect', req => { + _proxyConnects.push(req.url) + }) + }) + + beforeEach(() => { + _proxyConnects = [] + _clearVars() + }) + + afterEach(() => {}) + + afterAll(async () => { + _clearVars() + + // Stop proxy server + await new Promise(resolve => { + _proxyServer.once('close', () => resolve()) + _proxyServer.close() + }) + }) + + it('getProxyUrl does not return proxyUrl if variables not set', () => { + const proxyUrl = pm.getProxyUrl(new URL('https://github.com')) + expect(proxyUrl).toBeUndefined() + }) + + it('getProxyUrl returns proxyUrl if https_proxy set for https url', () => { + process.env['https_proxy'] = 'https://myproxysvr' + const proxyUrl = pm.getProxyUrl(new URL('https://github.com')) + expect(proxyUrl).toBeDefined() + }) + + it('getProxyUrl does not return proxyUrl if http_proxy set for https url', () => { + process.env['http_proxy'] = 'https://myproxysvr' + const proxyUrl = pm.getProxyUrl(new URL('https://github.com')) + expect(proxyUrl).toBeUndefined() + }) + + it('getProxyUrl returns proxyUrl if http_proxy set for http url', () => { + process.env['http_proxy'] = 'http://myproxysvr' + const proxyUrl = pm.getProxyUrl(new URL('http://github.com')) + expect(proxyUrl).toBeDefined() + }) + + it('getProxyUrl does not return proxyUrl if https_proxy set and in no_proxy list', () => { + process.env['https_proxy'] = 'https://myproxysvr' + process.env['no_proxy'] = 'otherserver,myserver,anotherserver:8080' + const proxyUrl = pm.getProxyUrl(new URL('https://myserver')) + expect(proxyUrl).toBeUndefined() + }) + + it('getProxyUrl returns proxyUrl if https_proxy set and not in no_proxy list', () => { + process.env['https_proxy'] = 'https://myproxysvr' + process.env['no_proxy'] = 'otherserver,myserver,anotherserver:8080' + const proxyUrl = pm.getProxyUrl(new URL('https://github.com')) + expect(proxyUrl).toBeDefined() + }) + + it('getProxyUrl does not return proxyUrl if http_proxy set and in no_proxy list', () => { + process.env['http_proxy'] = 'http://myproxysvr' + process.env['no_proxy'] = 'otherserver,myserver,anotherserver:8080' + const proxyUrl = pm.getProxyUrl(new URL('http://myserver')) + expect(proxyUrl).toBeUndefined() + }) + + it('getProxyUrl returns proxyUrl if http_proxy set and not in no_proxy list', () => { + process.env['http_proxy'] = 'http://myproxysvr' + process.env['no_proxy'] = 'otherserver,myserver,anotherserver:8080' + const proxyUrl = pm.getProxyUrl(new URL('http://github.com')) + expect(proxyUrl).toBeDefined() + }) + + it('checkBypass returns true if host as no_proxy list', () => { + process.env['no_proxy'] = 'myserver' + const bypass = pm.checkBypass(new URL('https://myserver')) + expect(bypass).toBeTruthy() + }) + + it('checkBypass returns true if host in no_proxy list', () => { + process.env['no_proxy'] = 'otherserver,myserver,anotherserver:8080' + const bypass = pm.checkBypass(new URL('https://myserver')) + expect(bypass).toBeTruthy() + }) + + it('checkBypass returns true if host in no_proxy list with spaces', () => { + process.env['no_proxy'] = 'otherserver, myserver ,anotherserver:8080' + const bypass = pm.checkBypass(new URL('https://myserver')) + expect(bypass).toBeTruthy() + }) + + it('checkBypass returns true if host in no_proxy list with port', () => { + process.env['no_proxy'] = 'otherserver, myserver:8080 ,anotherserver' + const bypass = pm.checkBypass(new URL('https://myserver:8080')) + expect(bypass).toBeTruthy() + }) + + it('checkBypass returns true if host with port in no_proxy list without port', () => { + process.env['no_proxy'] = 'otherserver, myserver ,anotherserver' + const bypass = pm.checkBypass(new URL('https://myserver:8080')) + expect(bypass).toBeTruthy() + }) + + it('checkBypass returns true if host in no_proxy list with default https port', () => { + process.env['no_proxy'] = 'otherserver, myserver:443 ,anotherserver' + const bypass = pm.checkBypass(new URL('https://myserver')) + expect(bypass).toBeTruthy() + }) + + it('checkBypass returns true if host in no_proxy list with default http port', () => { + process.env['no_proxy'] = 'otherserver, myserver:80 ,anotherserver' + const bypass = pm.checkBypass(new URL('http://myserver')) + expect(bypass).toBeTruthy() + }) + + it('checkBypass returns false if host not in no_proxy list', () => { + process.env['no_proxy'] = 'otherserver, myserver ,anotherserver:8080' + const bypass = pm.checkBypass(new URL('https://github.com')) + expect(bypass).toBeFalsy() + }) + + it('checkBypass returns false if empty no_proxy', () => { + process.env['no_proxy'] = '' + const bypass = pm.checkBypass(new URL('https://github.com')) + expect(bypass).toBeFalsy() + }) + + it('HttpClient does basic http get request through proxy', async () => { + process.env['http_proxy'] = _proxyUrl + const httpClient = new httpm.HttpClient() + const res: httpm.HttpClientResponse = await httpClient.get( + 'http://httpbin.org/get' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.url).toBe('http://httpbin.org/get') + expect(_proxyConnects).toEqual(['httpbin.org:80']) + }) + + it('HttoClient does basic http get request when bypass proxy', async () => { + process.env['http_proxy'] = _proxyUrl + process.env['no_proxy'] = 'httpbin.org' + const httpClient = new httpm.HttpClient() + const res: httpm.HttpClientResponse = await httpClient.get( + 'http://httpbin.org/get' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.url).toBe('http://httpbin.org/get') + expect(_proxyConnects).toHaveLength(0) + }) + + it('HttpClient does basic https get request through proxy', async () => { + process.env['https_proxy'] = _proxyUrl + const httpClient = new httpm.HttpClient() + const res: httpm.HttpClientResponse = await httpClient.get( + 'https://httpbin.org/get' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.url).toBe('https://httpbin.org/get') + expect(_proxyConnects).toEqual(['httpbin.org:443']) + }) + + it('HttpClient does basic https get request when bypass proxy', async () => { + process.env['https_proxy'] = _proxyUrl + process.env['no_proxy'] = 'httpbin.org' + const httpClient = new httpm.HttpClient() + const res: httpm.HttpClientResponse = await httpClient.get( + 'https://httpbin.org/get' + ) + expect(res.message.statusCode).toBe(200) + const body: string = await res.readBody() + const obj = JSON.parse(body) + expect(obj.url).toBe('https://httpbin.org/get') + expect(_proxyConnects).toHaveLength(0) + }) + + it('proxyAuth not set in tunnel agent when authentication is not provided', async () => { + process.env['https_proxy'] = 'http://127.0.0.1:8080' + const httpClient = new httpm.HttpClient() + const agent: any = httpClient.getAgent('https://some-url') + // eslint-disable-next-line no-console + console.log(agent) + expect(agent.proxyOptions.host).toBe('127.0.0.1') + expect(agent.proxyOptions.port).toBe('8080') + expect(agent.proxyOptions.proxyAuth).toBe(undefined) + }) + + it('proxyAuth is set in tunnel agent when authentication is provided', async () => { + process.env['https_proxy'] = 'http://user:password@127.0.0.1:8080' + const httpClient = new httpm.HttpClient() + const agent: any = httpClient.getAgent('https://some-url') + // eslint-disable-next-line no-console + console.log(agent) + expect(agent.proxyOptions.host).toBe('127.0.0.1') + expect(agent.proxyOptions.port).toBe('8080') + expect(agent.proxyOptions.proxyAuth).toBe('user:password') + }) +}) + +function _clearVars(): void { + 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 +} diff --git a/packages/http-client/package-lock.json b/packages/http-client/package-lock.json new file mode 100644 index 00000000..7a2f0f23 --- /dev/null +++ b/packages/http-client/package-lock.json @@ -0,0 +1,332 @@ +{ + "name": "@actions/http-client", + "version": "2.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@actions/http-client", + "version": "2.0.0", + "license": "MIT", + "devDependencies": { + "@types/tunnel": "0.0.3", + "proxy": "^1.0.1", + "tunnel": "0.0.6" + } + }, + "node_modules/@types/node": { + "version": "12.12.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.31.tgz", + "integrity": "sha512-T+wnJno8uh27G9c+1T+a1/WYCHzLeDqtsGJkoEdSp2X8RTh3oOCZQcUnjAx90CS8cmmADX51O0FI/tu9s0yssg==", + "dev": true + }, + "node_modules/@types/tunnel": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.3.tgz", + "integrity": "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/args": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/args/-/args-5.0.1.tgz", + "integrity": "sha512-1kqmFCFsPffavQFGt8OxJdIcETti99kySRUPMpOhaGjL6mRJn8HFU1OxKY5bMqfZKUwTQc1mZkAjmGYaVOHFtQ==", + "dev": true, + "dependencies": { + "camelcase": "5.0.0", + "chalk": "2.4.2", + "leven": "2.1.0", + "mri": "1.1.4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/basic-auth-parser": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/basic-auth-parser/-/basic-auth-parser-0.0.2.tgz", + "integrity": "sha1-zp5xp38jwSee7NJlmypGJEwVbkE=", + "dev": true + }, + "node_modules/camelcase": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz", + "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "node_modules/debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/leven": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", + "integrity": "sha1-wuep93IJTe6dNCAq6KzORoeHVYA=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mri": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz", + "integrity": "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/proxy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/proxy/-/proxy-1.0.2.tgz", + "integrity": "sha512-KNac2ueWRpjbUh77OAFPZuNdfEqNynm9DD4xHT14CccGpW8wKZwEkN0yjlb7X9G9Z9F55N0Q+1z+WfgAhwYdzQ==", + "dev": true, + "dependencies": { + "args": "5.0.1", + "basic-auth-parser": "0.0.2", + "debug": "^4.1.1" + }, + "bin": { + "proxy": "bin/proxy.js" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + } + }, + "dependencies": { + "@types/node": { + "version": "12.12.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.31.tgz", + "integrity": "sha512-T+wnJno8uh27G9c+1T+a1/WYCHzLeDqtsGJkoEdSp2X8RTh3oOCZQcUnjAx90CS8cmmADX51O0FI/tu9s0yssg==", + "dev": true + }, + "@types/tunnel": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.3.tgz", + "integrity": "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "args": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/args/-/args-5.0.1.tgz", + "integrity": "sha512-1kqmFCFsPffavQFGt8OxJdIcETti99kySRUPMpOhaGjL6mRJn8HFU1OxKY5bMqfZKUwTQc1mZkAjmGYaVOHFtQ==", + "dev": true, + "requires": { + "camelcase": "5.0.0", + "chalk": "2.4.2", + "leven": "2.1.0", + "mri": "1.1.4" + } + }, + "basic-auth-parser": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/basic-auth-parser/-/basic-auth-parser-0.0.2.tgz", + "integrity": "sha1-zp5xp38jwSee7NJlmypGJEwVbkE=", + "dev": true + }, + "camelcase": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz", + "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "leven": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", + "integrity": "sha1-wuep93IJTe6dNCAq6KzORoeHVYA=", + "dev": true + }, + "mri": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz", + "integrity": "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "proxy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/proxy/-/proxy-1.0.2.tgz", + "integrity": "sha512-KNac2ueWRpjbUh77OAFPZuNdfEqNynm9DD4xHT14CccGpW8wKZwEkN0yjlb7X9G9Z9F55N0Q+1z+WfgAhwYdzQ==", + "dev": true, + "requires": { + "args": "5.0.1", + "basic-auth-parser": "0.0.2", + "debug": "^4.1.1" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true + } + } +} diff --git a/packages/http-client/package.json b/packages/http-client/package.json new file mode 100644 index 00000000..f9db7e77 --- /dev/null +++ b/packages/http-client/package.json @@ -0,0 +1,46 @@ +{ + "name": "@actions/http-client", + "version": "2.0.0", + "description": "Actions Http Client", + "keywords": [ + "github", + "actions", + "http" + ], + "homepage": "https://github.com/actions/toolkit/tree/main/packages/http-client", + "license": "MIT", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "directories": { + "lib": "lib", + "test": "__tests__" + }, + "files": [ + "lib", + "!.DS_Store" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/actions/toolkit.git", + "directory": "packages/github" + }, + "scripts": { + "audit-moderate": "npm install && npm audit --json --audit-level=moderate > audit.json", + "test": "echo \"Error: run tests from root\" && exit 1", + "build": "tsc", + "format": "prettier --write **/*.ts", + "format-check": "prettier --check **/*.ts", + "tsc": "tsc" + }, + "bugs": { + "url": "https://github.com/actions/toolkit/issues" + }, + "devDependencies": { + "@types/tunnel": "0.0.3", + "proxy": "^1.0.1", + "tunnel": "0.0.6" + } +} diff --git a/packages/http-client/src/auth.ts b/packages/http-client/src/auth.ts new file mode 100644 index 00000000..639adbe2 --- /dev/null +++ b/packages/http-client/src/auth.ts @@ -0,0 +1,86 @@ +import * as http from 'http' +import * as ifm from './interfaces' +import {HttpClientResponse} from './index' + +export class BasicCredentialHandler implements ifm.RequestHandler { + username: string + password: string + + constructor(username: string, password: string) { + this.username = username + this.password = password + } + + prepareRequest(options: http.RequestOptions): void { + if (!options.headers) { + throw Error('The request has no headers') + } + options.headers['Authorization'] = `Basic ${Buffer.from( + `${this.username}:${this.password}` + ).toString('base64')}` + } + + // This handler cannot handle 401 + canHandleAuthentication(): boolean { + return false + } + + async handleAuthentication(): Promise { + throw new Error('not implemented') + } +} + +export class BearerCredentialHandler implements ifm.RequestHandler { + token: string + + constructor(token: string) { + this.token = token + } + + // currently implements pre-authorization + // TODO: support preAuth = false where it hooks on 401 + prepareRequest(options: http.RequestOptions): void { + if (!options.headers) { + throw Error('The request has no headers') + } + options.headers['Authorization'] = `Bearer ${this.token}` + } + + // This handler cannot handle 401 + canHandleAuthentication(): boolean { + return false + } + + async handleAuthentication(): Promise { + throw new Error('not implemented') + } +} + +export class PersonalAccessTokenCredentialHandler + implements ifm.RequestHandler { + token: string + + constructor(token: string) { + this.token = token + } + + // currently implements pre-authorization + // TODO: support preAuth = false where it hooks on 401 + prepareRequest(options: http.RequestOptions): void { + if (!options.headers) { + throw Error('The request has no headers') + } + options.headers['Authorization'] = `Basic ${Buffer.from( + `PAT:${this.token}` + ).toString('base64')}` + } + + // This handler cannot handle 401 + canHandleAuthentication(): boolean { + return false + } + + async handleAuthentication(): Promise { + throw new Error('not implemented') + } +} diff --git a/packages/http-client/src/index.ts b/packages/http-client/src/index.ts new file mode 100644 index 00000000..f02c2754 --- /dev/null +++ b/packages/http-client/src/index.ts @@ -0,0 +1,773 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as http from 'http' +import * as https from 'https' +import * as ifm from './interfaces' +import * as net from 'net' +import * as pm from './proxy' +import * as tunnel from 'tunnel' + +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, + TooManyRequests = 429, + InternalServerError = 500, + NotImplemented = 501, + BadGateway = 502, + ServiceUnavailable = 503, + GatewayTimeout = 504 +} + +export enum Headers { + Accept = 'accept', + ContentType = 'content-type' +} + +export enum MediaTypes { + ApplicationJson = 'application/json' +} + +/** + * Returns the proxy URL, depending upon the supplied url and proxy environment variables. + * @param serverUrl The server URL where the request will be sent. For example, https://api.github.com + */ +export function getProxyUrl(serverUrl: string): string { + const proxyUrl = pm.getProxyUrl(new URL(serverUrl)) + return proxyUrl ? proxyUrl.href : '' +} + +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 HttpClientError extends Error { + constructor(message: string, statusCode: number) { + super(message) + this.name = 'HttpClientError' + this.statusCode = statusCode + Object.setPrototypeOf(this, HttpClientError.prototype) + } + + statusCode: number + result?: any +} + +export class HttpClientResponse { + constructor(message: http.IncomingMessage) { + this.message = message + } + + message: http.IncomingMessage + async readBody(): Promise { + return new Promise(async resolve => { + 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): boolean { + const parsedUrl: URL = new URL(requestUrl) + return parsedUrl.protocol === 'https:' +} + +export class HttpClient { + userAgent: string | undefined + handlers: ifm.RequestHandler[] + requestOptions: ifm.RequestOptions | undefined + + private _ignoreSslError = false + private _socketTimeout: number | undefined + private _allowRedirects = true + private _allowRedirectDowngrade = false + private _maxRedirects = 50 + private _allowRetries = false + private _maxRetries = 1 + private _agent: any + private _proxyAgent: any + private _keepAlive = false + private _disposed = false + + constructor( + userAgent?: string, + handlers?: ifm.RequestHandler[], + requestOptions?: ifm.RequestOptions + ) { + 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 + } + } + } + + async options( + requestUrl: string, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise { + return this.request('OPTIONS', requestUrl, null, additionalHeaders || {}) + } + + async get( + requestUrl: string, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise { + return this.request('GET', requestUrl, null, additionalHeaders || {}) + } + + async del( + requestUrl: string, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise { + return this.request('DELETE', requestUrl, null, additionalHeaders || {}) + } + + async post( + requestUrl: string, + data: string, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise { + return this.request('POST', requestUrl, data, additionalHeaders || {}) + } + + async patch( + requestUrl: string, + data: string, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise { + return this.request('PATCH', requestUrl, data, additionalHeaders || {}) + } + + async put( + requestUrl: string, + data: string, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise { + return this.request('PUT', requestUrl, data, additionalHeaders || {}) + } + + async head( + requestUrl: string, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise { + return this.request('HEAD', requestUrl, null, additionalHeaders || {}) + } + + async sendStream( + verb: string, + requestUrl: string, + stream: NodeJS.ReadableStream, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise { + return this.request(verb, requestUrl, stream, additionalHeaders) + } + + /** + * Gets a typed object from an endpoint + * Be aware that not found returns a null. Other errors (4xx, 5xx) reject the promise + */ + async getJson( + requestUrl: string, + additionalHeaders: http.OutgoingHttpHeaders = {} + ): Promise> { + additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader( + additionalHeaders, + Headers.Accept, + MediaTypes.ApplicationJson + ) + const res: HttpClientResponse = await this.get( + requestUrl, + additionalHeaders + ) + return this._processResponse(res, this.requestOptions) + } + + async postJson( + requestUrl: string, + obj: any, + additionalHeaders: http.OutgoingHttpHeaders = {} + ): Promise> { + const data: string = JSON.stringify(obj, null, 2) + additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader( + additionalHeaders, + Headers.Accept, + MediaTypes.ApplicationJson + ) + additionalHeaders[Headers.ContentType] = this._getExistingOrDefaultHeader( + additionalHeaders, + Headers.ContentType, + MediaTypes.ApplicationJson + ) + const res: HttpClientResponse = await this.post( + requestUrl, + data, + additionalHeaders + ) + return this._processResponse(res, this.requestOptions) + } + + async putJson( + requestUrl: string, + obj: any, + additionalHeaders: http.OutgoingHttpHeaders = {} + ): Promise> { + const data: string = JSON.stringify(obj, null, 2) + additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader( + additionalHeaders, + Headers.Accept, + MediaTypes.ApplicationJson + ) + additionalHeaders[Headers.ContentType] = this._getExistingOrDefaultHeader( + additionalHeaders, + Headers.ContentType, + MediaTypes.ApplicationJson + ) + const res: HttpClientResponse = await this.put( + requestUrl, + data, + additionalHeaders + ) + return this._processResponse(res, this.requestOptions) + } + + async patchJson( + requestUrl: string, + obj: any, + additionalHeaders: http.OutgoingHttpHeaders = {} + ): Promise> { + const data: string = JSON.stringify(obj, null, 2) + additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader( + additionalHeaders, + Headers.Accept, + MediaTypes.ApplicationJson + ) + additionalHeaders[Headers.ContentType] = this._getExistingOrDefaultHeader( + additionalHeaders, + Headers.ContentType, + MediaTypes.ApplicationJson + ) + const res: HttpClientResponse = await this.patch( + requestUrl, + data, + additionalHeaders + ) + return this._processResponse(res, this.requestOptions) + } + + /** + * Makes a raw http request. + * All other methods such as get, post, patch, and request ultimately call this. + * Prefer get, del, post and patch + */ + async request( + verb: string, + requestUrl: string, + data: string | NodeJS.ReadableStream | null, + headers?: http.OutgoingHttpHeaders + ): Promise { + if (this._disposed) { + throw new Error('Client has already been disposed.') + } + + const parsedUrl = new URL(requestUrl) + let info: ifm.RequestInfo = this._prepareRequest(verb, parsedUrl, headers) + + // Only perform retries on reads since writes may not be idempotent. + const maxTries: number = + this._allowRetries && RetryableHttpVerbs.includes(verb) + ? this._maxRetries + 1 + : 1 + let numTries = 0 + + let response: HttpClientResponse | undefined + do { + 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.RequestHandler | undefined + + for (const handler of this.handlers) { + if (handler.canHandleAuthentication(response)) { + authenticationHandler = handler + 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 ( + response.message.statusCode && + HttpRedirectCodes.includes(response.message.statusCode) && + this._allowRedirects && + redirectsRemaining > 0 + ) { + const redirectUrl: string | undefined = + response.message.headers['location'] + if (!redirectUrl) { + // if there's no location to redirect to, we won't + break + } + const parsedRedirectUrl = new URL(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() + + // strip authorization header if redirected to a different hostname + if (parsedRedirectUrl.hostname !== parsedUrl.hostname) { + for (const header in headers) { + // header names are case insensitive + if (header.toLowerCase() === 'authorization') { + delete headers[header] + } + } + } + + // let's make the request with the new redirectUrl + info = this._prepareRequest(verb, parsedRedirectUrl, headers) + response = await this.requestRaw(info, data) + redirectsRemaining-- + } + + if ( + !response.message.statusCode || + !HttpResponseRetryCodes.includes(response.message.statusCode) + ) { + // If not a retry code, return immediately instead of retrying + return response + } + + numTries += 1 + + if (numTries < maxTries) { + await response.readBody() + await this._performExponentialBackoff(numTries) + } + } while (numTries < maxTries) + + return response + } + + /** + * Needs to be called if keepAlive is set to true in request options. + */ + dispose(): void { + if (this._agent) { + this._agent.destroy() + } + + this._disposed = true + } + + /** + * Raw request. + * @param info + * @param data + */ + async requestRaw( + info: ifm.RequestInfo, + data: string | NodeJS.ReadableStream | null + ): Promise { + return new Promise((resolve, reject) => { + function callbackForResult(err?: Error, res?: HttpClientResponse): void { + if (err) { + reject(err) + } else if (!res) { + // If `err` is not passed, then `res` must be passed. + reject(new Error('Unknown error')) + } else { + resolve(res) + } + } + + this.requestRawWithCallback(info, data, callbackForResult) + }) + } + + /** + * Raw request with callback. + * @param info + * @param data + * @param onResult + */ + requestRawWithCallback( + info: ifm.RequestInfo, + data: string | NodeJS.ReadableStream | null, + onResult: (err?: Error, res?: HttpClientResponse) => void + ): void { + if (typeof data === 'string') { + if (!info.options.headers) { + info.options.headers = {} + } + info.options.headers['Content-Length'] = Buffer.byteLength(data, 'utf8') + } + + let callbackCalled = false + function handleResult(err?: Error, res?: HttpClientResponse): void { + if (!callbackCalled) { + callbackCalled = true + onResult(err, res) + } + } + + const req: http.ClientRequest = info.httpModule.request( + info.options, + (msg: http.IncomingMessage) => { + const res: HttpClientResponse = new HttpClientResponse(msg) + handleResult(undefined, res) + } + ) + + let socket: net.Socket + 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}`)) + }) + + req.on('error', function(err) { + // err has statusCode property + // res should have headers + handleResult(err) + }) + + 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() + } + } + + /** + * Gets an http agent. This function is useful when you need an http agent that handles + * routing through a proxy server - depending upon the url and proxy environment variables. + * @param serverUrl The server URL where the request will be sent. For example, https://api.github.com + */ + getAgent(serverUrl: string): http.Agent { + const parsedUrl = new URL(serverUrl) + return this._getAgent(parsedUrl) + } + + private _prepareRequest( + method: string, + requestUrl: URL, + headers?: http.OutgoingHttpHeaders + ): ifm.RequestInfo { + const info: ifm.RequestInfo = {} + + info.parsedUrl = requestUrl + const usingSsl: boolean = info.parsedUrl.protocol === 'https:' + info.httpModule = usingSsl ? https : http + const defaultPort: number = usingSsl ? 443 : 80 + + info.options = {} + 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) { + for (const handler of this.handlers) { + handler.prepareRequest(info.options) + } + } + + return info + } + + private _mergeHeaders( + headers?: http.OutgoingHttpHeaders + ): http.OutgoingHttpHeaders { + if (this.requestOptions && this.requestOptions.headers) { + return Object.assign( + {}, + lowercaseKeys(this.requestOptions.headers), + lowercaseKeys(headers || {}) + ) + } + + return lowercaseKeys(headers || {}) + } + + private _getExistingOrDefaultHeader( + additionalHeaders: http.OutgoingHttpHeaders, + header: string, + _default: string + ): string | number | string[] { + let clientHeader: string | undefined + if (this.requestOptions && this.requestOptions.headers) { + clientHeader = lowercaseKeys(this.requestOptions.headers)[header] + } + return additionalHeaders[header] || clientHeader || _default + } + + private _getAgent(parsedUrl: URL): http.Agent { + let agent + const proxyUrl = pm.getProxyUrl(parsedUrl) + const 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 + } + + // This is `useProxy` again, but we need to check `proxyURl` directly for TypeScripts's flow analysis. + if (proxyUrl && proxyUrl.hostname) { + const agentOptions = { + maxSockets, + keepAlive: this._keepAlive, + proxy: { + ...((proxyUrl.username || proxyUrl.password) && { + proxyAuth: `${proxyUrl.username}:${proxyUrl.password}` + }), + 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} + 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 async _performExponentialBackoff(retryNumber: number): Promise { + retryNumber = Math.min(ExponentialBackoffCeiling, retryNumber) + const ms: number = ExponentialBackoffTimeSlice * Math.pow(2, retryNumber) + return new Promise(resolve => setTimeout(() => resolve(), ms)) + } + + private async _processResponse( + res: HttpClientResponse, + options?: ifm.RequestOptions + ): Promise> { + return new Promise>(async (resolve, reject) => { + const statusCode = res.message.statusCode || 0 + + const response: ifm.TypedResponse = { + statusCode, + result: null, + headers: {} + } + + // not found leads to null obj returned + if (statusCode === HttpCodes.NotFound) { + resolve(response) + } + + // get the result from the body + + function dateTimeDeserializer(key: any, value: any): any { + if (typeof value === 'string') { + const a = new Date(value) + if (!isNaN(a.valueOf())) { + return a + } + } + + return value + } + + let obj: any + let contents: string | undefined + + try { + contents = await res.readBody() + if (contents && contents.length > 0) { + if (options && options.deserializeDates) { + obj = JSON.parse(contents, dateTimeDeserializer) + } else { + obj = JSON.parse(contents) + } + + response.result = obj + } + + response.headers = res.message.headers + } catch (err) { + // Invalid resource (contents not json); leaving result obj null + } + + // note that 3xx redirects are handled by the http layer. + if (statusCode > 299) { + let msg: string + + // if exception/error in body, attempt to get better error + if (obj && obj.message) { + msg = obj.message + } else if (contents && contents.length > 0) { + // it may be the case that the exception is in the body message as string + msg = contents + } else { + msg = `Failed request: (${statusCode})` + } + + const err = new HttpClientError(msg, statusCode) + err.result = response.result + + reject(err) + } else { + resolve(response) + } + }) + } +} + +const lowercaseKeys = (obj: {[index: string]: any}): any => + Object.keys(obj).reduce((c: any, k) => ((c[k.toLowerCase()] = obj[k]), c), {}) diff --git a/packages/http-client/src/interfaces.ts b/packages/http-client/src/interfaces.ts new file mode 100644 index 00000000..96b0fec7 --- /dev/null +++ b/packages/http-client/src/interfaces.ts @@ -0,0 +1,91 @@ +import * as http from 'http' +import * as https from 'https' +import {HttpClientResponse} from './index' + +export interface HttpClient { + options( + requestUrl: string, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise + get( + requestUrl: string, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise + del( + requestUrl: string, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise + post( + requestUrl: string, + data: string, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise + patch( + requestUrl: string, + data: string, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise + put( + requestUrl: string, + data: string, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise + sendStream( + verb: string, + requestUrl: string, + stream: NodeJS.ReadableStream, + additionalHeaders?: http.OutgoingHttpHeaders + ): Promise + request( + verb: string, + requestUrl: string, + data: string | NodeJS.ReadableStream, + headers: http.OutgoingHttpHeaders + ): Promise + requestRaw( + info: RequestInfo, + data: string | NodeJS.ReadableStream + ): Promise + requestRawWithCallback( + info: RequestInfo, + data: string | NodeJS.ReadableStream, + onResult: (err?: Error, res?: HttpClientResponse) => void + ): void +} + +export interface RequestHandler { + prepareRequest(options: http.RequestOptions): void + canHandleAuthentication(response: HttpClientResponse): boolean + handleAuthentication( + httpClient: HttpClient, + requestInfo: RequestInfo, + data: string | NodeJS.ReadableStream | null + ): Promise +} + +export interface RequestInfo { + options: http.RequestOptions + parsedUrl: URL + httpModule: typeof http | typeof https +} + +export interface RequestOptions { + headers?: http.OutgoingHttpHeaders + socketTimeout?: number + ignoreSslError?: boolean + allowRedirects?: boolean + allowRedirectDowngrade?: boolean + maxRedirects?: number + maxSockets?: number + keepAlive?: boolean + deserializeDates?: boolean + // Allows retries only on Read operations (since writes may not be idempotent) + allowRetries?: boolean + maxRetries?: number +} + +export interface TypedResponse { + statusCode: number + result: T | null + headers: http.IncomingHttpHeaders +} diff --git a/packages/http-client/src/proxy.ts b/packages/http-client/src/proxy.ts new file mode 100644 index 00000000..f13409b5 --- /dev/null +++ b/packages/http-client/src/proxy.ts @@ -0,0 +1,60 @@ +export function getProxyUrl(reqUrl: URL): URL | undefined { + const usingSsl = reqUrl.protocol === 'https:' + + if (checkBypass(reqUrl)) { + return undefined + } + + const proxyVar = (() => { + if (usingSsl) { + return process.env['https_proxy'] || process.env['HTTPS_PROXY'] + } else { + return process.env['http_proxy'] || process.env['HTTP_PROXY'] + } + })() + + if (proxyVar) { + return new URL(proxyVar) + } else { + return undefined + } +} + +export function checkBypass(reqUrl: URL): boolean { + if (!reqUrl.hostname) { + return false + } + + const noProxy = process.env['no_proxy'] || process.env['NO_PROXY'] || '' + if (!noProxy) { + return false + } + + // Determine the request port + let reqPort: number | undefined + if (reqUrl.port) { + reqPort = Number(reqUrl.port) + } else if (reqUrl.protocol === 'http:') { + reqPort = 80 + } else if (reqUrl.protocol === 'https:') { + reqPort = 443 + } + + // Format the request hostname and hostname with port + const upperReqHosts = [reqUrl.hostname.toUpperCase()] + if (typeof reqPort === 'number') { + upperReqHosts.push(`${upperReqHosts[0]}:${reqPort}`) + } + + // Compare request host against noproxy + for (const upperNoProxyItem of noProxy + .split(',') + .map(x => x.trim().toUpperCase()) + .filter(x => x)) { + if (upperReqHosts.some(x => x === upperNoProxyItem)) { + return true + } + } + + return false +} diff --git a/packages/http-client/tsconfig.json b/packages/http-client/tsconfig.json new file mode 100644 index 00000000..4abc2b1c --- /dev/null +++ b/packages/http-client/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./src", + "moduleResolution": "node" + }, + "include": [ + "./src" + ] +} \ No newline at end of file diff --git a/scripts/create-package b/scripts/create-package index ed38d73a..29d07feb 100755 --- a/scripts/create-package +++ b/scripts/create-package @@ -9,5 +9,5 @@ if [[ -z "$name" ]]; then exit 1 fi -lerna create @actions/$name -cp packages/toolkit/tsconfig.json packages/$name/tsconfig.json \ No newline at end of file +npx lerna create @actions/$name +cp packages/core/tsconfig.json packages/$name/tsconfig.json \ No newline at end of file