add generic service for http requests
This commit is contained in:
parent
1ba812e2f8
commit
5f5f753583
@ -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]);
|
||||
|
||||
124
src/lib/services/generic.service.ts
Normal file
124
src/lib/services/generic.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './toaster.service';
|
||||
export * from './error-message.service';
|
||||
export * from './generic.service';
|
||||
|
||||
42
src/lib/utils/decorators/required-param.decorator.ts
Normal file
42
src/lib/utils/decorators/required-param.decorator.ts
Normal 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);
|
||||
};
|
||||
};
|
||||
}
|
||||
18
src/lib/utils/http-encoder.ts
Normal file
18
src/lib/utils/http-encoder.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user