diff --git a/.github/actions/init-npm/action.yaml b/.github/actions/init-npm/action.yaml new file mode 100644 index 0000000..76e1c89 --- /dev/null +++ b/.github/actions/init-npm/action.yaml @@ -0,0 +1,21 @@ +name: init-npm +description: 'Initialize the repo with npm and install all the dependencies' +inputs: + node-version: + description: 'Node.js version' + required: true + default: '20.x' +runs: + using: composite + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: npm + - name: Install TS Project dependencies + shell: bash + run: npm ci + - name: build + shell: bash + run: npm run build diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 039dde1..d78b45e 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -1,9 +1,10 @@ +name: publish + on: workflow_dispatch: release: types: [published] - branches: [master] - + jobs: publish: runs-on: ubuntu-latest @@ -12,11 +13,10 @@ jobs: packages: write # allow GITHUB_TOKEN to publish packages steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Init nodejs + uses: ./.github/actions/init-npm with: - node-version: "20" - - run: npm ci - - run: npm run prepack + node-version: 20.x - uses: JS-DevTools/npm-publish@v3 with: token: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish_package.yml b/.github/workflows/publish_package.yaml similarity index 99% rename from .github/workflows/publish_package.yml rename to .github/workflows/publish_package.yaml index 8c3793b..51583a4 100644 --- a/.github/workflows/publish_package.yml +++ b/.github/workflows/publish_package.yaml @@ -39,7 +39,6 @@ jobs: run: | git config --global user.email "mapcolonies@gmail.com" git config --global user.name "mapcolonies" - - name: Bump new version run: npm run release diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index b0bfd88..5cfafc1 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -1,53 +1,41 @@ name: pull_request - on: [pull_request] - jobs: tests: name: Run Tests runs-on: ubuntu-latest strategy: matrix: - node: [16.x, 18.x] - + node: [16.x, 18.x, 20.x, 22.x] steps: - name: Check out Git repository - uses: actions/checkout@v2 - + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - - name: Install Node.js dependencies run: npm ci - - name: Run tests run: npm run test - - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v4 with: - name: Test Reporters + name: Test Reporters-${{ matrix.node }} path: reports/** - eslint: name: Run TS Project eslint runs-on: ubuntu-latest - steps: - name: Check out TS Project Git repository - uses: actions/checkout@v2 - + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: 16 - - name: Install TS Project dependencies run: npm install - - name: Run TS Project linters - uses: wearerequired/lint-action@v1 + uses: wearerequired/lint-action@v2 with: github_token: ${{ secrets.github_token }} # Enable linters diff --git a/package-lock.json b/package-lock.json index 7fe4a24..eaabfee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "typescript": "^4.2.4" }, "peerDependencies": { - "@map-colonies/error-types": "^1.2.0", + "@map-colonies/error-types": "^1.3.1", "@map-colonies/js-logger": "^1.0.1" } }, @@ -3177,9 +3177,10 @@ } }, "node_modules/@map-colonies/error-types": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@map-colonies/error-types/-/error-types-1.2.0.tgz", - "integrity": "sha512-OS1iNNatEU59XIsT2cLbs89iNAef5CvttiRoUYr60NAE+JTFiF9Lm0QNDRFP9OJDiVcUQUr57xV3hiK4WgP2Xg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@map-colonies/error-types/-/error-types-1.3.1.tgz", + "integrity": "sha512-ZcXiCYcjk4SBhAxO6JGJZ9cmiCInBULpisrnTViPsdxtfk+1a6XG/sKXop5U5se6xQZ77L43ZEUhiwvE7FsaPA==", + "license": "ISC", "peer": true, "dependencies": { "@map-colonies/error-express-handler": "^2.0.0", @@ -17009,9 +17010,9 @@ } }, "@map-colonies/error-types": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@map-colonies/error-types/-/error-types-1.2.0.tgz", - "integrity": "sha512-OS1iNNatEU59XIsT2cLbs89iNAef5CvttiRoUYr60NAE+JTFiF9Lm0QNDRFP9OJDiVcUQUr57xV3hiK4WgP2Xg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@map-colonies/error-types/-/error-types-1.3.1.tgz", + "integrity": "sha512-ZcXiCYcjk4SBhAxO6JGJZ9cmiCInBULpisrnTViPsdxtfk+1a6XG/sKXop5U5se6xQZ77L43ZEUhiwvE7FsaPA==", "peer": true, "requires": { "@map-colonies/error-express-handler": "^2.0.0", diff --git a/package.json b/package.json index 7d9991d..bb1e233 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "ngeohash": "^0.6.3" }, "peerDependencies": { - "@map-colonies/error-types": "^1.2.0", + "@map-colonies/error-types": "^1.3.1", "@map-colonies/js-logger": "^1.0.1" } } diff --git a/src/communication/http/httpClient.ts b/src/communication/http/httpClient.ts index 4b606ad..3458898 100644 --- a/src/communication/http/httpClient.ts +++ b/src/communication/http/httpClient.ts @@ -11,6 +11,8 @@ import { UnauthorizedError, HttpError, MethodNotAllowedError, + ContentTooLarge, + TooManyRequestsError, } from '@map-colonies/error-types'; import { Logger } from '@map-colonies/js-logger'; @@ -266,7 +268,7 @@ export abstract class HttpClient { url, body, targetService: this.targetService, - msg: `invalid request error recieved from service ${this.targetService}.`, + msg: `invalid request error received from service ${this.targetService}.`, msgError: err.message, }); } @@ -278,7 +280,7 @@ export abstract class HttpClient { url, body, targetService: this.targetService, - msg: `not found error recieved from service ${this.targetService}.`, + msg: `not found error received from service ${this.targetService}.`, msgError: err.message, }); } @@ -290,7 +292,7 @@ export abstract class HttpClient { url, body, targetService: this.targetService, - msg: `conflict error recieved from service ${this.targetService}.`, + msg: `conflict error received from service ${this.targetService}.`, msgError: err.message, }); } @@ -302,11 +304,11 @@ export abstract class HttpClient { url, body, targetService: this.targetService, - msg: `forbidden error recieved from service ${this.targetService}.`, + msg: `forbidden error received from service ${this.targetService}.`, msgError: err.message, }); } - throw new ForbiddenError(err, message); + return new ForbiddenError(err, message); case HttpStatus.UNAUTHORIZED: if (!this.disableDebugLogs) { this.logger.debug({ @@ -314,11 +316,11 @@ export abstract class HttpClient { url, body, targetService: this.targetService, - msg: `unauthorized error recieved from service ${this.targetService}.`, + msg: `unauthorized error received from service ${this.targetService}.`, msgError: err.message, }); } - throw new UnauthorizedError(err, message); + return new UnauthorizedError(err, message); case HttpStatus.METHOD_NOT_ALLOWED: if (!this.disableDebugLogs) { this.logger.debug({ @@ -326,18 +328,42 @@ export abstract class HttpClient { url, body, targetService: this.targetService, - msg: `method not allowed error recieved from service ${this.targetService}.`, + msg: `method not allowed error received from service ${this.targetService}.`, msgError: err.message, }); } - throw new MethodNotAllowedError(err, message); + return new MethodNotAllowedError(err, message); + case HttpStatus.REQUEST_TOO_LONG: + if (!this.disableDebugLogs) { + this.logger.debug({ + err, + url, + body, + targetService: this.targetService, + msg: `content too large error received from service ${this.targetService}.`, + msgError: err.message, + }); + } + return new ContentTooLarge(err, message); + case HttpStatus.TOO_MANY_REQUESTS: + if (!this.disableDebugLogs) { + this.logger.debug({ + err, + url, + body, + targetService: this.targetService, + msg: `too many requests error received from service ${this.targetService}.`, + msgError: err.message, + }); + } + return new TooManyRequestsError(err, message); default: this.logger.error({ err, url, body, targetService: this.targetService, - msg: `Internal Server Error recieved from service ${this.targetService}.`, + msg: `Internal Server Error received from service ${this.targetService}.`, msgError: err.message, }); return new InternalServerError(err); diff --git a/tests/unit/communication/http/httpClient.spec.ts b/tests/unit/communication/http/httpClient.spec.ts index 721fb32..ca087ca 100644 --- a/tests/unit/communication/http/httpClient.spec.ts +++ b/tests/unit/communication/http/httpClient.spec.ts @@ -1,4 +1,14 @@ -import { BadRequestError } from '@map-colonies/error-types'; +import { + BadRequestError, + ConflictError, + ForbiddenError, + InternalServerError, + NotFoundError, + UnauthorizedError, + MethodNotAllowedError, + ContentTooLarge, + TooManyRequestsError, +} from '@map-colonies/error-types'; import jsLogger from '@map-colonies/js-logger'; import { AxiosError, AxiosRequestConfig } from 'axios'; import { exponentialDelay, IAxiosRetryConfig } from 'axios-retry'; @@ -536,4 +546,293 @@ describe('HttpClient', function () { expect(axiosMocks.patch).toHaveBeenCalledWith(testUrl, undefined, expect.anything()); }); }); + + describe('Error Handling - wrapError function', () => { + describe('BadRequestError - 400', () => { + it('should throw BadRequestError for GET on 400', async () => { + const badRequestError = { + message: 'bad request', + response: { + status: 400, + }, + }; + axiosMocks.get.mockRejectedValue(badRequestError); + + const action = async () => { + await client.callGet(testUrl); + }; + + await expect(action).rejects.toThrow(BadRequestError); + }); + + it('should throw BadRequestError for POST on 400', async () => { + const badRequestError = { + message: 'bad request', + response: { + status: 400, + }, + }; + axiosMocks.post.mockRejectedValue(badRequestError); + + const action = async () => { + await client.callPost(testUrl); + }; + + await expect(action).rejects.toThrow(BadRequestError); + }); + }); + + describe('UnauthorizedError - 401', () => { + it('should throw UnauthorizedError for GET on 401', async () => { + const unauthorizedError = { + message: 'unauthorized', + response: { + status: 401, + }, + }; + axiosMocks.get.mockRejectedValue(unauthorizedError); + + const action = async () => { + await client.callGet(testUrl); + }; + + await expect(action).rejects.toThrow(UnauthorizedError); + }); + }); + + describe('ForbiddenError - 403', () => { + it('should throw ForbiddenError for GET on 403', async () => { + const forbiddenError = { + message: 'forbidden', + response: { + status: 403, + }, + }; + axiosMocks.get.mockRejectedValue(forbiddenError); + + const action = async () => { + await client.callGet(testUrl); + }; + + await expect(action).rejects.toThrow(ForbiddenError); + }); + }); + + describe('NotFoundError - 404', () => { + it('should throw NotFoundError for GET on 404', async () => { + const notFoundError = { + message: 'not found', + response: { + status: 404, + }, + }; + axiosMocks.get.mockRejectedValue(notFoundError); + + const action = async () => { + await client.callGet(testUrl); + }; + + await expect(action).rejects.toThrow(NotFoundError); + }); + }); + + describe('MethodNotAllowedError - 405', () => { + it('should throw MethodNotAllowedError for GET on 405', async () => { + const methodNotAllowedError = { + message: 'method not allowed', + response: { + status: 405, + }, + }; + axiosMocks.get.mockRejectedValue(methodNotAllowedError); + + const action = async () => { + await client.callGet(testUrl); + }; + + await expect(action).rejects.toThrow(MethodNotAllowedError); + }); + }); + + describe('ConflictError - 409', () => { + it('should throw ConflictError for GET on 409', async () => { + const conflictError = { + message: 'conflict', + response: { + status: 409, + }, + }; + axiosMocks.get.mockRejectedValue(conflictError); + + const action = async () => { + await client.callGet(testUrl); + }; + + await expect(action).rejects.toThrow(ConflictError); + }); + }); + + describe('ContentTooLarge - 413', () => { + it('should throw ContentTooLarge for POST on 413', async () => { + const contentTooLargeError = { + message: 'content too large', + response: { + status: 413, + }, + }; + axiosMocks.post.mockRejectedValue(contentTooLargeError); + + const action = async () => { + await client.callPost(testUrl, testBody); + }; + + await expect(action).rejects.toThrow(ContentTooLarge); + }); + }); + + describe('TooManyRequestsError - 429', () => { + it('should throw TooManyRequestsError for GET on 429', async () => { + const tooManyRequestsError = { + message: 'too many requests', + response: { + status: 429, + }, + }; + axiosMocks.get.mockRejectedValue(tooManyRequestsError); + + const action = async () => { + await client.callGet(testUrl); + }; + + await expect(action).rejects.toThrow(TooManyRequestsError); + }); + }); + + describe('InternalServerError - 500 and other status codes', () => { + it('should throw InternalServerError for GET on 500', async () => { + const internalServerError = { + message: 'internal server error', + response: { + status: 500, + }, + }; + axiosMocks.get.mockRejectedValue(internalServerError); + + const action = async () => { + await client.callGet(testUrl); + }; + + await expect(action).rejects.toThrow(InternalServerError); + }); + + it('should throw InternalServerError for unknown status codes', async () => { + const unknownError = { + message: 'unknown error', + response: { + status: 999, + }, + }; + axiosMocks.get.mockRejectedValue(unknownError); + + const action = async () => { + await client.callGet(testUrl); + }; + + await expect(action).rejects.toThrow(InternalServerError); + }); + }); + + describe('Error message extraction', () => { + it('should extract message from response.data.message', async () => { + const errorWithMessage = { + message: 'axios error message', + response: { + status: 400, + data: { + message: 'custom error message', + }, + }, + }; + axiosMocks.get.mockRejectedValue(errorWithMessage); + + const action = async () => { + await client.callGet(testUrl); + }; + + await expect(action).rejects.toThrow(BadRequestError); + }); + + it('should handle errors without custom message', async () => { + const errorWithoutMessage = { + message: 'axios error message', + response: { + status: 404, + }, + }; + axiosMocks.get.mockRejectedValue(errorWithoutMessage); + + const action = async () => { + await client.callGet(testUrl); + }; + + await expect(action).rejects.toThrow(NotFoundError); + }); + }); + + describe('Error handling across different HTTP methods', () => { + it('should throw BadRequestError for PUT on 400', async () => { + const error = { message: 'test error', response: { status: 400 } }; + axiosMocks.put.mockRejectedValue(error); + await expect(client.callPut(testUrl)).rejects.toThrow(BadRequestError); + }); + + it('should throw UnauthorizedError for PATCH on 401', async () => { + const error = { message: 'test error', response: { status: 401 } }; + axiosMocks.patch.mockRejectedValue(error); + await expect(client.callPatch(testUrl)).rejects.toThrow(UnauthorizedError); + }); + + it('should throw ForbiddenError for DELETE on 403', async () => { + const error = { message: 'test error', response: { status: 403 } }; + axiosMocks.delete.mockRejectedValue(error); + await expect(client.callDelete(testUrl)).rejects.toThrow(ForbiddenError); + }); + + it('should throw NotFoundError for HEAD on 404', async () => { + const error = { message: 'test error', response: { status: 404 } }; + axiosMocks.head.mockRejectedValue(error); + await expect(client.callHead(testUrl)).rejects.toThrow(NotFoundError); + }); + + it('should throw MethodNotAllowedError for OPTIONS on 405', async () => { + const error = { message: 'test error', response: { status: 405 } }; + axiosMocks.options.mockRejectedValue(error); + await expect(client.callOptions(testUrl)).rejects.toThrow(MethodNotAllowedError); + }); + + it('should throw ConflictError for POST on 409', async () => { + const error = { message: 'test error', response: { status: 409 } }; + axiosMocks.post.mockRejectedValue(error); + await expect(client.callPost(testUrl)).rejects.toThrow(ConflictError); + }); + + it('should throw ContentTooLarge for PUT on 413', async () => { + const error = { message: 'test error', response: { status: 413 } }; + axiosMocks.put.mockRejectedValue(error); + await expect(client.callPut(testUrl)).rejects.toThrow(ContentTooLarge); + }); + + it('should throw TooManyRequestsError for POST on 429', async () => { + const error = { message: 'test error', response: { status: 429 } }; + axiosMocks.post.mockRejectedValue(error); + await expect(client.callPost(testUrl)).rejects.toThrow(TooManyRequestsError); + }); + + it('should throw InternalServerError for GET on 500', async () => { + const error = { message: 'test error', response: { status: 500 } }; + axiosMocks.get.mockRejectedValue(error); + await expect(client.callGet(testUrl)).rejects.toThrow(InternalServerError); + }); + }); + }); });