add generic service for http requests

This commit is contained in:
Dan Percic 2021-09-26 23:57:29 +03:00
parent 1ba812e2f8
commit 5f5f753583
6 changed files with 216 additions and 15 deletions

View File

@ -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<string>('This is here for compatibility while refactoring things.');
@Injectable()
export class EntitiesService<T extends IListable> {
readonly displayed$: Observable<T[]>;
/**
* E for Entity
* I for Interface
* By default, if no interface is provided, I = E
*/
export class EntitiesService<E extends IListable, I = E> extends GenericService<I> {
readonly displayed$: Observable<E[]>;
readonly displayedLength$: Observable<number>;
readonly noData$: Observable<boolean>;
readonly areAllSelected$: Observable<boolean>;
readonly areSomeSelected$: Observable<boolean>;
readonly notAllSelected$: Observable<boolean>;
readonly selected$: Observable<(string | number)[]>;
readonly selectedEntities$: Observable<T[]>;
readonly selectedEntities$: Observable<E[]>;
readonly selectedLength$: Observable<number>;
readonly all$: Observable<T[]>;
readonly all$: Observable<E[]>;
readonly allLength$: Observable<number>;
private readonly _all$ = new BehaviorSubject<T[]>([]);
private _displayed: T[] = [];
private readonly _filterService = this._injector.get(FilterService);
private readonly _searchService = this._injector.get<SearchService<E>>(SearchService);
private readonly _all$ = new BehaviorSubject<E[]>([]);
private _displayed: E[] = [];
private readonly _selected$ = new BehaviorSubject<(string | number)[]>([]);
constructor(private readonly _filterService: FilterService, private readonly _searchService: SearchService<T>) {
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<T extends IListable> {
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<T[]> {
private get _getDisplayed$(): Observable<E[]> {
const { filterGroups$ } = this._filterService;
const { valueChanges$ } = this._searchService;
@ -96,16 +110,16 @@ export class EntitiesService<T extends IListable> {
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<T extends IListable> {
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]);

View File

@ -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<Entity> {
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<R = Entity>(@RequiredParam() body: unknown | List<[string, string]>, entityPath = this._defaultEntityPath): Observable<R> {
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<R>(`${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<unknown> {
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<R = Entity>(entityPath = this._defaultEntityPath): Observable<R[]> {
console.log(`GET request from ${this.constructor.name}`);
return this._http.get<R[]>(`${this._basePath}/${entityPath}`, {
withCredentials: this._configuration.withCredentials,
headers: this._headers({ contentType: false }),
observe: 'body'
});
}
@Validate()
getOne<R = Entity>(@RequiredParam() entityId: string, entityPath = this._defaultEntityPath): Observable<R> {
const path = `${this._basePath}/${entityPath}/${encodeURIComponent(String(entityId))}`;
console.log(`GET request from ${this.constructor.name} with id ${entityId}`);
return this._http.get<R>(path, {
withCredentials: this._configuration.withCredentials,
headers: this._headers({ contentType: false }),
observe: 'body'
});
}
get<R = Entity>(): Observable<R[]>;
get<R = Entity>(entityId: string, entityPath?: string): Observable<R>;
get<R = Entity>(entityId?: string, entityPath = this._defaultEntityPath): Observable<R[] | R> {
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;
}
}

View File

@ -1,2 +1,3 @@
export * from './toaster.service';
export * from './error-message.service';
export * from './generic.service';

View File

@ -0,0 +1,42 @@
import 'reflect-metadata';
const requiredMetadataKey = Symbol('required');
function getMethodParams(target: Record<string, unknown>, 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<string, unknown>, propertyKey: string, parameterIndex: number): void {
const existingRequiredParameters = <number[]>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<string, unknown>, propertyName);
descriptor.value = function(...args: unknown[]) {
// eslint-disable-next-line @typescript-eslint/ban-types
const requiredParameters = <number[]>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);
};
};
}

View File

@ -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');
}
}

View File

@ -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';