Skip to content

Instantly share code, notes, and snippets.

@JaimeStill
Last active December 9, 2021 15:19
Show Gist options
  • Save JaimeStill/9792fc15063e8c9fbd9bafa5650b4398 to your computer and use it in GitHub Desktop.
Save JaimeStill/9792fc15063e8c9fbd9bafa5650b4398 to your computer and use it in GitHub Desktop.
ApiQueryService Overhaul

ApiQueryService Overhaul

The intent behind the code files provided in this repository is to abstract away the need to generate unique source services each time a query source is needed. Instead, a generic QuerySource<T> class is able to be instantiated per API route, and is managed internal to the component that uses it. To prevent from needing to inject all of the dependencies required by QuerySource<T> into the host component, a QueryGenerator service is made available that handles instantiation of QuerySource<T> objects via its generateSource function.

Current Files with Change Notes

// models/query/core-query.ts
import {
ReplaySubject,
Subject,
Subscription,
merge,
throwError
} from 'rxjs';
import {
catchError,
debounceTime,
filter,
switchMap
} from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
import { PageEvent } from '@angular/material/paginator';
import { QueryResult } from '../../models';
import { SnackerService } from '../../services';
export abstract class CoreQuery<T> {
private requestUrl = new Subject<string>();
private forceRefreshUrl = new Subject<string>();
protected queryResult = new ReplaySubject<QueryResult<T>>(1);
protected lastQueryResult?: QueryResult<T>;
protected subs = new Array<Subscription>();
queryResult$ = this.queryResult.asObservable();
pageSizeOptions = [5, 10, 20, 50, 100];
private initUrlStream = (): Subscription =>
merge(this.requestUrl, this.forceRefreshUrl)
.pipe(
debounceTime(0),
switchMap((url: string) =>
this.http.get<QueryResult<T>>(url).pipe(
catchError(er => throwError(() => new Error(er)))
)
),
filter(r => r != null)
)
.subscribe({
next: result => {
this.lastQueryResult = result;
this.queryResult.next(result);
},
error: err => this.snacker.sendErrorMessage(err.error)
});
constructor(
protected http: HttpClient
) {
this.subs.push(
this.initUrlStream(),
/*
This is an array because my internal application
also subscribes to the identity stream on my identity
service to set the user's preferred page size
*/
)
}
unsubscribe = () => this.subs.forEach(sub => sub.unsubscribe());
private _baseUrl: string | null = null;
get baseUrl(): string | null { return this._baseUrl; }
protected set baseUrl(value: string | null) {
if (value !== this._baseUrl) {
this._baseUrl = value;
this.refresh();
}
}
private _page = 1;
get page(): number { return this._page; }
set page(value: number) {
if (value !== this._page) {
this._page = value;
this.refresh();
}
}
private _pageSize = 20;
get pageSize(): number { return this._pageSize; }
set pageSize(value: number) {
if (value !== this._pageSize) {
this._pageSize = value;
this.refresh();
}
}
private _sort: { propertyName: string, isDescending: boolean } | null = null;
get sort(): { propertyName: string, isDescending: boolean } | null {
return this._sort;
}
set sort(value: { propertyName: string, isDescending: boolean } | null) {
this._sort = value;
this.refresh();
}
private _search: string | null = null;
get search(): string | null {
return this._search;
}
set search(value: string | null) {
this._search = value;
this.refresh();
}
private _additionalQueryParams: { [parameter: string]: string } = {};
setQueryParameter(name: string, value: string) {
this._additionalQueryParams[name] = value;
this.refresh();
}
getQueryParameter(parameterName: string): string | null {
return this._additionalQueryParams[parameterName] || null;
}
private refresh = () => {
if (!this.baseUrl) return;
const url = this.buildUrl();
this.requestUrl.next(url);
}
private buildUrl = () => {
let url = `${this.baseUrl}?page=${this.page}&pageSize=${this.pageSize}`;
if (this.sort) {
const sortParam = `sort=${this.sort.propertyName}_${this.sort.isDescending ? 'desc' : 'asc'}`;
url += `&${sortParam}`;
}
if (this.search) {
const searchParam = `search=${this.search}`;
url += `&${searchParam}`;
}
for (const name in this._additionalQueryParams) {
if (this._additionalQueryParams.hasOwnProperty(name)) {
const value = this._additionalQueryParams[name];
if (value) {
url += `&${name}=${value}`;
}
}
}
return url;
}
forceRefresh = () => {
const url = this.buildUrl();
this.forceRefreshUrl.next(url);
}
clearSearch = () => this.search = null;
onPage = (pageEvent: PageEvent) => {
this.page = pageEvent.pageIndex + 1;
this.pageSize = pageEvent.pageSize;
}
onSearch = (event: string) => this.search = event;
onSort = (event: { active: string, direction: string }) => this.sort = event.direction
? { propertyName: event.active, isDescending: event.direction === 'desc' }
: null
}
// models/query/query-source.ts
import { HttpClient } from '@angular/common/http';
import { ServerConfig } from '../../config';
import { CoreQuery } from './core-query';
import { SnackerService } from '../../services';
export class QuerySource<T> extends CoreQuery<T> {
constructor(
protected http: HttpClient,
protected snacker: SnackerService,
private config: ServerConfig,
private propertyName: string,
private url: string = null,
private isDescending: boolean = false
) {
super(http, snacker);
this.sort = { propertyName, isDescending };
this.baseUrl = url
? `${this.config.api}${url}`
: null;
}
setUrl = (url: string) => this.baseUrl = `${this.config.api}/${url}`;
}
// services/query-generator.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { SnackerService } from './snacker.service';
import { ServerConfig } from '../config';
import { QuerySource } from '../models';
@Injectable({
providedIn: 'root'
})
export class QueryGeneratorService {
constructor(
private config: ServerConfig,
private http: HttpClient,
private snacker: SnackerService
) { }
generateSource = <T>(
propertyName: string,
url: string = null,
isDescending: boolean = false
) => new QuerySource<T>(
this.http,
this.snacker,
this.config,
propertyName,
url,
isDescending
);
}
// routes/query-generator/query-generator.route.ts
import {
Component,
OnDestroy,
OnInit
} from '@angular/core';
import {
Armor,
QueryGeneratorService,
QuerySource,
Weapon
} from 'core';
@Component({
selector: 'query-generator-route',
templateUrl: 'query-generator.route.html'
})
export class QueryGeneratorRoute implements OnInit, OnDestroy {
armorSrc: QuerySource<Armor>;
weaponSrc: QuerySource<Weapon>;
constructor(
private generator: QueryGeneratorService
) { }
ngOnInit() {
this.armorSrc = this.generator.generateSource('name', 'item/queryArmor');
this.weaponSrc = this.generator.generateSource('name', 'item/queryWeapons');
}
ngOnDestroy() {
this.armorSrc.unsubscribe();
this.weaponSrc.unsubscribe();
}
}
<!-- routes/query-generator/query-generator.route.html -->
<section fxLayout="row"
fxLayoutAlign="start stretch"
[style.height.%]="100">
<section class="m4 p8 card-outline-accent rounded"
fxLayout="column"
fxLayoutAlign="start stretch"
fxFlex>
<p fxFlexAlign="center" class="mat-title m4">Armor</p>
<ng-template #loading>
<mat-progress-bar mode="indeterminate"
color="accent"></mat-progress-bar>
</ng-template>
<ng-container *ngIf="armorSrc.queryResult$ | async as query else loading">
<section fxLayout="row"
fxLayoutAlign="start center">
<searchbar label="Search Armor"
fxFlex
[minimum]="1"
(search)="armorSrc.onSearch($event)"
(clear)="armorSrc.clearSearch()"></searchbar>
<mat-paginator [length]="query.totalCount"
[pageSize]="query.pageSize"
[pageSizeOptions]="armorSrc.pageSizeOptions"
[pageIndex]="query.page - 1"
(page)="armorSrc.onPage($event)"></mat-paginator>
</section>
<section *ngIf="query.data.length > 0"
fxLayout="row | wrap"
fxLayoutAlign="start start">
<display-card *ngFor="let armor of query.data">
<armor-display [armor]="armor"></armor-display>
</display-card>
</section>
</ng-container>
</section>
<section class="m4 p8 card-outline-accent rounded"
fxLayout="column"
fxLayoutAlign="start stretch"
fxFlex>
<p fxFlexAlign="center"
class="mat-title m4">Weapons</p>
<ng-template #loading>
<mat-progress-bar mode="indeterminate"
color="accent"></mat-progress-bar>
</ng-template>
<ng-container *ngIf="weaponSrc.queryResult$ | async as query else loading">
<section fxLayout="row"
fxLayoutAlign="start center">
<searchbar label="Search Weapons"
fxFlex
[minimum]="1"
(search)="weaponSrc.onSearch($event)"
(clear)="weaponSrc.clearSearch()"></searchbar>
<mat-paginator [length]="query.totalCount"
[pageSize]="query.pageSize"
[pageSizeOptions]="weaponSrc.pageSizeOptions"
[pageIndex]="query.page - 1"
(page)="weaponSrc.onPage($event)"></mat-paginator>
</section>
<section *ngIf="query.data.length > 0"
fxLayout="row | wrap"
fxLayoutAlign="start start">
<display-card *ngFor="let weapon of query.data">
<weapon-display [weapon]="weapon"></weapon-display>
</display-card>
</section>
</ng-container>
</section>
</section>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment