diff --git a/angular.json b/angular.json index 16f6c7f64e..2ffa2792f9 100644 --- a/angular.json +++ b/angular.json @@ -490,6 +490,31 @@ } } } + }, + "shared-interceptors": { + "projectType": "library", + "root": "libs/shared/interceptors", + "sourceRoot": "libs/shared/interceptors/src", + "prefix": "realworld", + "architect": { + "lint": { + "builder": "@nrwl/linter:eslint", + "options": { + "lintFilePatterns": [ + "libs/shared/interceptors/src/**/*.ts", + "libs/shared/interceptors/src/**/*.html" + ] + } + }, + "test": { + "builder": "@nrwl/jest:jest", + "outputs": ["coverage/libs/shared/interceptors"], + "options": { + "jestConfig": "libs/shared/interceptors/jest.config.js", + "passWithNoTests": true + } + } + } } } } diff --git a/jest.config.js b/jest.config.js index 83a783dabb..5a52f487a5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,5 +15,6 @@ module.exports = { '/libs/shared/directives', '/libs/shared/error-handler', '/libs/shared/foundation', + '/libs/shared/interceptors', ], }; diff --git a/libs/shared/interceptors/.eslintrc.json b/libs/shared/interceptors/.eslintrc.json new file mode 100644 index 0000000000..6d8fdd02e1 --- /dev/null +++ b/libs/shared/interceptors/.eslintrc.json @@ -0,0 +1,31 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "plugin:@nrwl/nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "parserOptions": { + "project": ["libs/shared/interceptors/tsconfig.*?.json"] + }, + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { "type": "attribute", "prefix": "realworld", "style": "camelCase" } + ], + "@angular-eslint/component-selector": [ + "error", + { "type": "element", "prefix": "realworld", "style": "kebab-case" } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nrwl/nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/shared/interceptors/README.md b/libs/shared/interceptors/README.md new file mode 100644 index 0000000000..824b62f5c8 --- /dev/null +++ b/libs/shared/interceptors/README.md @@ -0,0 +1,7 @@ +# shared-interceptors + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test shared-interceptors` to execute the unit tests. diff --git a/libs/shared/interceptors/jest.config.js b/libs/shared/interceptors/jest.config.js new file mode 100644 index 0000000000..0875855e42 --- /dev/null +++ b/libs/shared/interceptors/jest.config.js @@ -0,0 +1,23 @@ +module.exports = { + displayName: 'shared-interceptors', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + globals: { + 'ts-jest': { + tsConfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + astTransformers: { + before: [ + 'jest-preset-angular/build/InlineFilesTransformer', + 'jest-preset-angular/build/StripStylesTransformer', + ], + }, + }, + }, + coverageDirectory: '../../../coverage/libs/shared/interceptors', + snapshotSerializers: [ + 'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js', + 'jest-preset-angular/build/AngularSnapshotSerializer.js', + 'jest-preset-angular/build/HTMLCommentSerializer.js', + ], +}; diff --git a/libs/shared/interceptors/src/index.ts b/libs/shared/interceptors/src/index.ts new file mode 100644 index 0000000000..ffd9a6069f --- /dev/null +++ b/libs/shared/interceptors/src/index.ts @@ -0,0 +1 @@ +export * from './lib/shared-interceptors.module' diff --git a/libs/shared/interceptors/src/lib/caching.interceptor.ts b/libs/shared/interceptors/src/lib/caching.interceptor.ts new file mode 100644 index 0000000000..2990ed7ec9 --- /dev/null +++ b/libs/shared/interceptors/src/lib/caching.interceptor.ts @@ -0,0 +1,18 @@ +import { + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest +} from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +@Injectable() +export class CachingInterceptor implements HttpInterceptor { + constructor() { } + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + // console.info('CachingInterceptor') + return next.handle(request) + } +} diff --git a/libs/shared/interceptors/src/lib/error.interceptor.ts b/libs/shared/interceptors/src/lib/error.interceptor.ts new file mode 100644 index 0000000000..cd5e2b95e4 --- /dev/null +++ b/libs/shared/interceptors/src/lib/error.interceptor.ts @@ -0,0 +1,22 @@ +import { + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest +} from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { HTTP_METHOD } from '@realworld/shared/client-server'; +import { Observable } from 'rxjs'; +import { retry } from 'rxjs/operators'; + +@Injectable() +export class ErrorInterceptor implements HttpInterceptor { + // handling http errors + intercept(request: HttpRequest, next: HttpHandler): Observable> { + // console.info('ErrorInterceptor') + return next.handle(request).pipe( + // retry when an error happens with GET request + retry(request.method === HTTP_METHOD.GET ? 3 : 0), + ) + } +} diff --git a/libs/shared/interceptors/src/lib/loading.interceptor.ts b/libs/shared/interceptors/src/lib/loading.interceptor.ts new file mode 100644 index 0000000000..2d255bbcaa --- /dev/null +++ b/libs/shared/interceptors/src/lib/loading.interceptor.ts @@ -0,0 +1,39 @@ +import { + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest +} from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { ILoadingService } from '@realworld/shared/loading'; +import { Observable } from 'rxjs'; +import { finalize } from 'rxjs/operators'; + + +@Injectable() +export class LoadingInterceptor implements HttpInterceptor { + constructor(private loadingService: ILoadingService) { } + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + // console.info('LoadingInterceptor') + const loadingHeader = 'loading' + if ( + request.headers.has(loadingHeader) && + request.headers.get(loadingHeader) === 'show' + ) { + request = request.clone( + // remove loading header from headers object + {headers: request.headers.delete(loadingHeader)} + ) + + this.loadingService.loading() + + return next.handle(request).pipe( + finalize(() => { + this.loadingService.loaded() + }) + ) + } + return next.handle(request) + } +} diff --git a/libs/shared/interceptors/src/lib/logging.interceptor.ts b/libs/shared/interceptors/src/lib/logging.interceptor.ts new file mode 100644 index 0000000000..3f2a6e2707 --- /dev/null +++ b/libs/shared/interceptors/src/lib/logging.interceptor.ts @@ -0,0 +1,22 @@ +import { + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest +} from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ILoggingService } from '@realworld/shared/logging'; + +@Injectable() +export class LoggingInterceptor implements HttpInterceptor { + constructor(private loggingService: ILoggingService) { } + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + // console.info('LoggingInterceptor') + this.loggingService.info({ + message: `making a ${request.method} request to ${request.url}` + }) + return next.handle(request) + } +} diff --git a/libs/shared/interceptors/src/lib/notification.interceptor.ts b/libs/shared/interceptors/src/lib/notification.interceptor.ts new file mode 100644 index 0000000000..fff8288782 --- /dev/null +++ b/libs/shared/interceptors/src/lib/notification.interceptor.ts @@ -0,0 +1,83 @@ +import { + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, + HttpResponse +} from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { HTTP_METHOD } from '@realworld/shared/client-server'; +import { INotificationService, NotificationType } from '@realworld/shared/notification'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +@Injectable() +export class NotificationInterceptor implements HttpInterceptor { + constructor(private notificationService: INotificationService) { } + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + // console.info('NotificationInterceptor') + + return next.handle(request).pipe( + tap((event: HttpEvent) => { + if (event instanceof HttpResponse) { + this.notify(request, event) + } + }) + ) + } + + private notify( + req: HttpRequest, + res: HttpResponse + ) { + let noti = this.getNotification(req, res) + if (!noti || !noti.message) { + return + } + + switch (noti.type) { + case NotificationType.sussess: + this.notificationService.showSuccess(noti.message) + break + case NotificationType.info: + this.notificationService.showInfo(noti.message) + break + case NotificationType.warning: + this.notificationService.showWarning(noti.message) + break + case NotificationType.error: + this.notificationService.showError(noti.message) + break + } + } + + private getNotification( + req: HttpRequest, + res: HttpResponse + ): {type: NotificationType, message: string} | null { + // errors will be handled in error intercepter + if (res.ok) { + switch (req.method) { + case HTTP_METHOD.DELETE: + return { + type: NotificationType.sussess, + message: res?.body?.message + } + case HTTP_METHOD.POST: + return { + type: NotificationType.sussess, + message: res?.body?.message + } + case HTTP_METHOD.PUT: + case HTTP_METHOD.PATCH: + return { + type: NotificationType.sussess, + message: res?.body?.message + } + default: + return null + } + } + } +} diff --git a/libs/shared/interceptors/src/lib/shared-interceptors.module.ts b/libs/shared/interceptors/src/lib/shared-interceptors.module.ts new file mode 100644 index 0000000000..5d012990b6 --- /dev/null +++ b/libs/shared/interceptors/src/lib/shared-interceptors.module.ts @@ -0,0 +1,24 @@ +import { CommonModule } from '@angular/common'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { NgModule } from '@angular/core'; +import { CachingInterceptor } from './caching.interceptor'; +import { ErrorInterceptor } from './error.interceptor'; +import { LoadingInterceptor } from './loading.interceptor'; +import { LoggingInterceptor } from './logging.interceptor'; +import { NotificationInterceptor } from './notification.interceptor'; +import { TimeoutInterceptor } from './timeout.interceptor'; +import { TokenInterceptor } from './token.interceptor'; + +@NgModule({ + imports: [CommonModule], + providers: [ + { provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: TimeoutInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: CachingInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: LoadingInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: NotificationInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }, + ] +}) +export class SharedInterceptorsModule {} diff --git a/libs/shared/interceptors/src/lib/timeout.interceptor.ts b/libs/shared/interceptors/src/lib/timeout.interceptor.ts new file mode 100644 index 0000000000..a73e656dd0 --- /dev/null +++ b/libs/shared/interceptors/src/lib/timeout.interceptor.ts @@ -0,0 +1,17 @@ +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { timeout } from 'rxjs/operators'; + +export const REQUEST_TIMEOUT = 30000 + +@Injectable() +export class TimeoutInterceptor implements HttpInterceptor { + constructor() { } + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + // console.info('TimeoutInterceptor') + + return next.handle(request).pipe(timeout(REQUEST_TIMEOUT)) + } +} diff --git a/libs/shared/interceptors/src/lib/token.interceptor.ts b/libs/shared/interceptors/src/lib/token.interceptor.ts new file mode 100644 index 0000000000..5643e41f51 --- /dev/null +++ b/libs/shared/interceptors/src/lib/token.interceptor.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { + HttpRequest, + HttpHandler, + HttpEvent, + HttpInterceptor +} from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { HTTP_HEADER } from '@realworld/shared/client-server'; +import { UserStorageUtil } from '@realworld/shared/storage'; + +@Injectable() +export class TokenInterceptor implements HttpInterceptor { + constructor(private userStorageUtil: UserStorageUtil) {} + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + // console.info('TokenInterceptor') + + let token = this.userStorageUtil.token + if (token) { + let newHeaders = request.headers; + // If we have a token, we append it to our new headers + newHeaders = newHeaders.append(HTTP_HEADER.AUTHORIZATION, 'Bearer ' + token); + + // Finally we have to clone our request with our new headers + // This is required because HttpRequests are immutable + const authReq = request.clone({ headers: newHeaders }); + // Then we return an Observable that will run the request + // or pass it to the next interceptor if any + return next.handle(authReq); + } + + return next.handle(request) + } +} diff --git a/libs/shared/interceptors/src/test-setup.ts b/libs/shared/interceptors/src/test-setup.ts new file mode 100644 index 0000000000..8d88704e8f --- /dev/null +++ b/libs/shared/interceptors/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular'; diff --git a/libs/shared/interceptors/tsconfig.json b/libs/shared/interceptors/tsconfig.json new file mode 100644 index 0000000000..667a3463d1 --- /dev/null +++ b/libs/shared/interceptors/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/shared/interceptors/tsconfig.lib.json b/libs/shared/interceptors/tsconfig.lib.json new file mode 100644 index 0000000000..890aaeb555 --- /dev/null +++ b/libs/shared/interceptors/tsconfig.lib.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "target": "es2015", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [], + "lib": ["dom", "es2018"] + }, + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "enableResourceInlining": true + }, + "exclude": ["src/test-setup.ts", "**/*.spec.ts"], + "include": ["**/*.ts"] +} diff --git a/libs/shared/interceptors/tsconfig.spec.json b/libs/shared/interceptors/tsconfig.spec.json new file mode 100644 index 0000000000..fd405a65ef --- /dev/null +++ b/libs/shared/interceptors/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/nx.json b/nx.json index 5752d63890..1210723ced 100644 --- a/nx.json +++ b/nx.json @@ -31,6 +31,7 @@ "shared-configuration": { "tags": ["scope:shared", "type:lib"] }, "shared-directives": { "tags": ["scope:shared", "type:lib"] }, "shared-error-handler": { "tags": ["scope:shared", "type:lib"] }, - "shared-foundation": { "tags": ["scope:shared", "type:lib"] } + "shared-foundation": { "tags": ["scope:shared", "type:lib"] }, + "shared-interceptors": { "tags": ["scope:shared", "type:lib"] } } } diff --git a/tsconfig.base.json b/tsconfig.base.json index 59ef7ff205..da049b30f1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -41,7 +41,10 @@ "@realworld/shared/error-handler": [ "libs/shared/error-handler/src/index.ts" ], - "@realworld/shared/foundation": ["libs/shared/foundation/src/index.ts"] + "@realworld/shared/foundation": ["libs/shared/foundation/src/index.ts"], + "@realworld/shared/interceptors": [ + "libs/shared/interceptors/src/index.ts" + ] } }, "exclude": ["node_modules", "tmp"]