diff --git a/src/lib/listing/services/entities.service.ts b/src/lib/listing/services/entities.service.ts index 0353d70..3a38e53 100644 --- a/src/lib/listing/services/entities.service.ts +++ b/src/lib/listing/services/entities.service.ts @@ -1,31 +1,45 @@ -import { Injectable } from '@angular/core'; +import { Inject, Injectable, InjectionToken, Injector, Optional } from '@angular/core'; import { BehaviorSubject, combineLatest, Observable, pipe } from 'rxjs'; import { distinctUntilChanged, map, tap } from 'rxjs/operators'; import { FilterService, getFilteredEntities } from '../../filtering'; import { SearchService } from '../../search'; import { IListable } from '../models'; +import { GenericService } from '../../services'; const toLengthValue = (entities: unknown[]) => entities?.length ?? 0; const getLength = pipe(map(toLengthValue), distinctUntilChanged()); +/** + * This should be removed when refactoring is done + */ +const ENTITY_PATH = new InjectionToken('This is here for compatibility while refactoring things.'); + @Injectable() -export class EntitiesService { - readonly displayed$: Observable; +/** + * E for Entity + * I for Interface + * By default, if no interface is provided, I = E + */ +export class EntitiesService extends GenericService { + readonly displayed$: Observable; readonly displayedLength$: Observable; readonly noData$: Observable; readonly areAllSelected$: Observable; readonly areSomeSelected$: Observable; readonly notAllSelected$: Observable; readonly selected$: Observable<(string | number)[]>; - readonly selectedEntities$: Observable; + readonly selectedEntities$: Observable; readonly selectedLength$: Observable; - readonly all$: Observable; + readonly all$: Observable; readonly allLength$: Observable; - private readonly _all$ = new BehaviorSubject([]); - private _displayed: T[] = []; + private readonly _filterService = this._injector.get(FilterService); + private readonly _searchService = this._injector.get>(SearchService); + private readonly _all$ = new BehaviorSubject([]); + private _displayed: E[] = []; private readonly _selected$ = new BehaviorSubject<(string | number)[]>([]); - constructor(private readonly _filterService: FilterService, private readonly _searchService: SearchService) { + constructor(protected readonly _injector: Injector, @Optional() @Inject(ENTITY_PATH) protected readonly _defaultEntityPath = '') { + super(_injector, _defaultEntityPath); this.all$ = this._all$.asObservable(); this.allLength$ = this._all$.pipe(getLength); @@ -42,16 +56,16 @@ export class EntitiesService { this.notAllSelected$ = this._notAllSelected$; } - get all(): T[] { + get all(): E[] { return Object.values(this._all$.getValue()); } - get selected(): T[] { + get selected(): E[] { const selectedIds = Object.values(this._selected$.getValue()); return this.all.filter(a => selectedIds.indexOf(a.id) !== -1); } - private get _getDisplayed$(): Observable { + private get _getDisplayed$(): Observable { const { filterGroups$ } = this._filterService; const { valueChanges$ } = this._searchService; @@ -96,16 +110,16 @@ export class EntitiesService { return this._displayed.length !== 0 && this._displayed.length === this.selected.length; } - setEntities(newEntities: T[]): void { + setEntities(newEntities: E[]): void { this._all$.next(newEntities); } - setSelected(newEntities: T[]): void { + setSelected(newEntities: E[]): void { const selectedIds = newEntities.map(e => e.id); this._selected$.next(selectedIds); } - isSelected(entity: T): boolean { + isSelected(entity: E): boolean { return this.selected.indexOf(entity) !== -1; } @@ -116,7 +130,7 @@ export class EntitiesService { this.setSelected(this._displayed); } - select(entity: T): void { + select(entity: E): void { const currentEntityIdx = this.selected.indexOf(entity); if (currentEntityIdx === -1) { return this.setSelected([...this.selected, entity]); diff --git a/src/lib/services/generic.service.ts b/src/lib/services/generic.service.ts new file mode 100644 index 0000000..19ae0a1 --- /dev/null +++ b/src/lib/services/generic.service.ts @@ -0,0 +1,124 @@ +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { BASE_PATH, Configuration, List } from '@redaction/red-ui-http'; +import { Injector } from '@angular/core'; +import { Observable } from 'rxjs'; +import { CustomHttpUrlEncodingCodec, RequiredParam, Validate } from '../utils'; + +export interface HeaderOptions { + readonly authorization?: boolean; + readonly accept?: boolean; + readonly contentType?: boolean; +} + +export abstract class GenericService { + protected readonly _defaultHeaders = new HttpHeaders(); + protected readonly _configuration = this._injector.get(Configuration, new Configuration()); + protected readonly _http = this._injector.get(HttpClient); + protected readonly _basePath = this._injector.get(BASE_PATH, this._configuration.basePath); + + protected constructor(protected readonly _injector: Injector, protected readonly _defaultEntityPath: string) {} + + @Validate() + post(@RequiredParam() body: unknown | List<[string, string]>, entityPath = this._defaultEntityPath): Observable { + let queryParams; + + if (Array.isArray(body) && Array.isArray(body[0]) && body[0].length === 2) { + queryParams = this._queryParams(body); + } + + console.log(`POST request from ${this.constructor.name} with body `, body); + return this._http.post(`${this._basePath}/${entityPath}`, body, { + withCredentials: this._configuration.withCredentials, + params: queryParams, + headers: this._headers(), + observe: 'body' + }); + } + + @Validate() + delete(@RequiredParam() body: string | List<[string, string]>, entityPath = this._defaultEntityPath): Observable { + let path = `${this._basePath}/${entityPath}`; + let queryParams; + + if (typeof body === 'string') { + path += `/${encodeURIComponent(body)}`; + } else { + queryParams = this._queryParams(body); + } + + console.log(`DELETE request from ${this.constructor.name} with body `, body); + return this._http.delete(path, { + withCredentials: this._configuration.withCredentials, + params: queryParams, + headers: this._headers({ contentType: false }), + observe: 'body' + }); + } + + @Validate() + getAll(entityPath = this._defaultEntityPath): Observable { + console.log(`GET request from ${this.constructor.name}`); + return this._http.get(`${this._basePath}/${entityPath}`, { + withCredentials: this._configuration.withCredentials, + headers: this._headers({ contentType: false }), + observe: 'body' + }); + } + + @Validate() + getOne(@RequiredParam() entityId: string, entityPath = this._defaultEntityPath): Observable { + const path = `${this._basePath}/${entityPath}/${encodeURIComponent(String(entityId))}`; + + console.log(`GET request from ${this.constructor.name} with id ${entityId}`); + return this._http.get(path, { + withCredentials: this._configuration.withCredentials, + headers: this._headers({ contentType: false }), + observe: 'body' + }); + } + + get(): Observable; + get(entityId: string, entityPath?: string): Observable; + get(entityId?: string, entityPath = this._defaultEntityPath): Observable { + if (entityId) { + return this.getOne(entityId, entityPath); + } + return this.getAll(entityPath); + } + + protected _queryParams(queryParams: List<[string, string]>): HttpParams { + let queryParameters = new HttpParams({ encoder: new CustomHttpUrlEncodingCodec() }); + for (const [key, value] of queryParams) { + queryParameters = queryParameters.set(key, value); + } + + return queryParameters; + } + + protected _headers(options?: HeaderOptions): HttpHeaders { + let headers = this._defaultHeaders; + + // authentication (RED-OAUTH) required + if ((options?.authorization === undefined || options.authorization) && this._configuration.accessToken) { + const accessToken = + typeof this._configuration.accessToken === 'function' ? this._configuration.accessToken() : this._configuration.accessToken; + headers = headers.set('Authorization', 'Bearer ' + accessToken); + } + + if (options?.accept === undefined || options.accept) { + const httpHeaderAcceptSelected = this._configuration.selectHeaderAccept(['application/json']); + if (httpHeaderAcceptSelected !== undefined) { + headers = headers.set('Accept', httpHeaderAcceptSelected); + } + } + + if (options?.contentType === undefined || options.contentType) { + const httpContentTypeSelected = this._configuration.selectHeaderContentType(['application/json']); + if (httpContentTypeSelected !== undefined) { + headers = headers.set('Content-Type', httpContentTypeSelected); + } + } + + return headers; + } +} diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 819e2f9..6b2e67d 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -1,2 +1,3 @@ export * from './toaster.service'; export * from './error-message.service'; +export * from './generic.service'; diff --git a/src/lib/utils/decorators/required-param.decorator.ts b/src/lib/utils/decorators/required-param.decorator.ts new file mode 100644 index 0000000..f6536f8 --- /dev/null +++ b/src/lib/utils/decorators/required-param.decorator.ts @@ -0,0 +1,42 @@ +import 'reflect-metadata'; + +const requiredMetadataKey = Symbol('required'); + +function getMethodParams(target: Record, propertyKey: string): string[] { + const functionParams = String(target[propertyKey]).split('(')[1].split(')')[0]; + return functionParams.split(',').map(param => param.replace(' ', '')); +} + +export function RequiredParam() { + return function _required(target: Record, propertyKey: string, parameterIndex: number): void { + const existingRequiredParameters = Reflect.getOwnMetadata(requiredMetadataKey, target, propertyKey) || []; + existingRequiredParameters.push(parameterIndex); + Reflect.defineMetadata(requiredMetadataKey, existingRequiredParameters, target, propertyKey); + }; +} + +export function Validate() { + return function _validate(target: unknown, propertyName: string, descriptor: PropertyDescriptor): void { + const method = descriptor.value as (...args: unknown[]) => unknown; + const methodParams = getMethodParams(target as Record, propertyName); + + descriptor.value = function(...args: unknown[]) { + // eslint-disable-next-line @typescript-eslint/ban-types + const requiredParameters = Reflect.getOwnMetadata(requiredMetadataKey, target as Object, propertyName); + + if (!requiredParameters) { + return method?.apply(this, args); + } + + for (const parameterIndex of requiredParameters) { + if (parameterIndex >= args.length || args[parameterIndex] === undefined) { + throw new Error( + `Required parameter ${methodParams[parameterIndex]} was null or undefined when calling ${propertyName}` + ); + } + } + + return method?.apply(this, args); + }; + }; +} diff --git a/src/lib/utils/http-encoder.ts b/src/lib/utils/http-encoder.ts new file mode 100644 index 0000000..17a753b --- /dev/null +++ b/src/lib/utils/http-encoder.ts @@ -0,0 +1,18 @@ +import { HttpUrlEncodingCodec } from "@angular/common/http"; + +/** + * CustomHttpUrlEncodingCodec + * Fix plus sign (+) not encoding, so sent as blank space + * See: https://github.com/angular/angular/issues/11058#issuecomment-247367318 + */ +export class CustomHttpUrlEncodingCodec extends HttpUrlEncodingCodec { + encodeKey(k: string): string { + k = super.encodeKey(k); + return k.replace(/\+/gi, '%2B'); + } + + encodeValue(v: string): string { + v = super.encodeValue(v); + return v.replace(/\+/gi, '%2B'); + } +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index f721eff..eacedbc 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -7,5 +7,7 @@ export * from './types/utility-types'; export * from './types/tooltip-positions.type'; export * from './decorators/bind.decorator'; export * from './decorators/required.decorator'; +export * from './decorators/required-param.decorator'; export * from './decorators/debounce.decorator'; export * from './decorators/on-change.decorator'; +export * from './http-encoder';