From 3c1936ee761594ff993caa93899dc320842cf04f Mon Sep 17 00:00:00 2001 From: ICEatm Date: Fri, 14 Jun 2024 12:49:14 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Added=20an=20robust=20and=20functio?= =?UTF-8?q?nal=20http=20service=20using=20axios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 52 ++++++- package.json | 2 + src/common/services/http.service.spec.ts | 165 +++++++++++++++++++++++ src/common/services/http.service.ts | 70 ++++++++++ 4 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 src/common/services/http.service.spec.ts create mode 100644 src/common/services/http.service.ts diff --git a/package-lock.json b/package-lock.json index 6133ab5..ecfe548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@nestjs/axios": "^3.0.2", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.2", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "axios": "^1.7.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "helmet": "^7.1.0", @@ -1603,6 +1605,16 @@ "node": ">=8" } }, + "node_modules/@nestjs/axios": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.2.tgz", + "integrity": "sha512-Z6GuOUdNQjP7FX+OuV2Ybyamse+/e0BFdTWBX5JxpBDKA+YkdLynDgG6HTF04zy6e9zPa19UX0WA2VDoehwhXQ==", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "axios": "^1.3.1", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.3.2", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.3.2.tgz", @@ -2860,8 +2872,17 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } }, "node_modules/babel-jest": { "version": "29.7.0", @@ -3479,7 +3500,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3752,7 +3772,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -4603,6 +4622,25 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.0.tgz", @@ -4673,7 +4711,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -7027,6 +7064,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index e79ccb2..b151a0b 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,12 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@nestjs/axios": "^3.0.2", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.2", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "axios": "^1.7.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "helmet": "^7.1.0", diff --git a/src/common/services/http.service.spec.ts b/src/common/services/http.service.spec.ts new file mode 100644 index 0000000..2597de0 --- /dev/null +++ b/src/common/services/http.service.spec.ts @@ -0,0 +1,165 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpService } from './http.service'; +import { HttpService as NestHttpService, HttpModule } from '@nestjs/axios'; +import { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import { of, throwError } from 'rxjs'; +import { HttpException } from '@nestjs/common'; + +describe('HttpService', () => { + let service: HttpService; + let httpService: NestHttpService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [HttpModule], + providers: [HttpService], + }).compile(); + + service = module.get(HttpService); + httpService = module.get(NestHttpService); + }); + + describe('get', () => { + it('should return data on successful GET request', async () => { + const result: AxiosResponse = { + data: { message: 'success' }, + status: 200, + statusText: 'OK', + headers: {}, + config: {} as InternalAxiosRequestConfig, + }; + jest.spyOn(httpService, 'get').mockImplementation(() => of(result)); + + expect(await service.get('https://example.com')).toEqual(result); + }); + + it('should throw an HttpException on GET request failure', async () => { + jest.spyOn(httpService, 'get').mockImplementation(() => + throwError({ + response: { + status: 404, + data: 'Not Found', + }, + }), + ); + + await expect(service.get('https://example.com')).rejects.toThrow( + new HttpException( + { + status: 404, + message: 'Not Found', + }, + 404, + ), + ); + }); + }); + + describe('post', () => { + it('should return data on successful POST request', async () => { + const result: AxiosResponse = { + data: { message: 'success' }, + status: 201, + statusText: 'Created', + headers: {}, + config: {} as InternalAxiosRequestConfig, + }; + jest.spyOn(httpService, 'post').mockImplementation(() => of(result)); + + expect(await service.post('https://example.com', {})).toEqual(result); + }); + + it('should throw an HttpException on POST request failure', async () => { + jest.spyOn(httpService, 'post').mockImplementation(() => + throwError({ + response: { + status: 400, + data: 'Bad Request', + }, + }), + ); + + await expect(service.post('https://example.com', {})).rejects.toThrow( + new HttpException( + { + status: 400, + message: 'Bad Request', + }, + 400, + ), + ); + }); + }); + + describe('put', () => { + it('should return data on successful PUT request', async () => { + const result: AxiosResponse = { + data: { message: 'success' }, + status: 200, + statusText: 'OK', + headers: {}, + config: {} as InternalAxiosRequestConfig, + }; + jest.spyOn(httpService, 'put').mockImplementation(() => of(result)); + + expect(await service.put('https://example.com', {})).toEqual(result); + }); + + it('should throw an HttpException on PUT request failure', async () => { + jest.spyOn(httpService, 'put').mockImplementation(() => + throwError({ + response: { + status: 401, + data: 'Unauthorized', + }, + }), + ); + + await expect(service.put('https://example.com', {})).rejects.toThrow( + new HttpException( + { + status: 401, + message: 'Unauthorized', + }, + 401, + ), + ); + }); + }); + + describe('delete', () => { + it('should return data on successful DELETE request', async () => { + const result: AxiosResponse = { + data: { message: 'success' }, + status: 200, + statusText: 'OK', + headers: {}, + config: {} as InternalAxiosRequestConfig, + }; + jest.spyOn(httpService, 'delete').mockImplementation(() => of(result)); + + expect(await service.delete('https://example.com')).toEqual(result); + }); + + it('should throw an HttpException on DELETE request failure', async () => { + jest.spyOn(httpService, 'delete').mockImplementation(() => + throwError({ + response: { + status: 403, + data: 'Forbidden', + }, + }), + ); + + await expect(service.delete('https://example.com')).rejects.toThrow( + new HttpException( + { + status: 403, + message: 'Forbidden', + }, + 403, + ), + ); + }); + }); +}); diff --git a/src/common/services/http.service.ts b/src/common/services/http.service.ts new file mode 100644 index 0000000..7bb9305 --- /dev/null +++ b/src/common/services/http.service.ts @@ -0,0 +1,70 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { HttpService as NestHttpService } from '@nestjs/axios'; +import { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { catchError, lastValueFrom } from 'rxjs'; + +@Injectable() +export class HttpService { + constructor(private readonly httpService: NestHttpService) {} + + async get(url: string, config?: AxiosRequestConfig): Promise { + return this.handleRequest(this.httpService.get(url, config)); + } + + async post( + url: string, + data: any, + config?: AxiosRequestConfig, + ): Promise { + return this.handleRequest(this.httpService.post(url, data, config)); + } + + async put( + url: string, + data: any, + config?: AxiosRequestConfig, + ): Promise { + return this.handleRequest(this.httpService.put(url, data, config)); + } + + async delete( + url: string, + config?: AxiosRequestConfig, + ): Promise { + return this.handleRequest(this.httpService.delete(url, config)); + } + + private async handleRequest(request: any): Promise { + return await lastValueFrom( + request.pipe( + catchError((error) => { + if (error.response) { + throw new HttpException( + { + status: error.response.status, + message: error.response.data, + }, + error.response.status, + ); + } else if (error.request) { + throw new HttpException( + { + status: HttpStatus.SERVICE_UNAVAILABLE, + message: 'No response received from server', + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } else { + throw new HttpException( + { + status: HttpStatus.INTERNAL_SERVER_ERROR, + message: error.message, + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + }), + ), + ); + } +}