I don’t know if this helps, but I have been successful in my apps with the following structure. All my services extend fetch-service
.
The aspnet core backend wraps all get, delete and post responses in a ServerResponse
so that I have a uniform way to handle the data and any errors that are returned from the server
ServerResponse
using System;
using AmberwoodCore.Interfaces;
namespace AmberwoodCore.Responses
{
public class ServerResponse
{
public ServerResponse(Exception e)
{
if (e == null) throw new ArgumentNullException(nameof(e));
Message = e.Message;
StackTrace = e.StackTrace;
IsError = true;
}
public ServerResponse(string message)
{
Message = message;
}
public string Message { get; }
public string StackTrace { get; set; }
public bool IsError { get; }
}
public class ServerResponse<T> : ServerResponse
{
public ServerResponse(Exception e) : base(e) { }
public ServerResponse(T dto, string message = null) : base(message)
{
if (dto == null) throw new ArgumentNullException(nameof(dto));
Dto = dto;
}
public T Dto { get; }
public string Id => (Dto is IIdentity identity) ? identity.Id : null;
}
}
fetch-service.ts
This includes a “hack” to get a single data value (CompanyId) from the store
import { HttpClient, json } from "aurelia-fetch-client";
import { autoinject, Container } from "aurelia-framework";
import { Router } from "aurelia-router";
import { Reducer, Store } from "aurelia-store";
import { Subscription } from "rxjs";
import { log } from "../core/log";
import { IEntityCompany } from "../interfaces/IEntity";
import { IFormDataModel } from "../interfaces/IFormDataModel";
import { IServerResponse } from "../interfaces/IServerResponse";
import { LOCAL_STORAGE } from "../localStorage-consts";
import { QueryId } from "../models/QueryId";
import { FetchRoute } from "../requests/FetchRoute";
import { IState } from "../store/state";
import { GetUrlService } from "./get-url-service";
import { LanguageService } from "./language-service";
import { serverErrorMessageAction, ServerMessageService } from "./server-message-service";
export type RequestMethod = "get" | "delete" | "post" | "getMany";
@autoinject
export class FetchService {
private getUrlService: GetUrlService;
private serverMessageService: ServerMessageService;
private languageService: LanguageService;
private authToken: string;
constructor(
private readonly baseUrl: string,
protected readonly http: HttpClient,
public store: Store<IState>,
private router: Router
) {
this.serverMessageService = new ServerMessageService(store);
this.languageService = Container.instance.get(LanguageService);
const accessToken = localStorage.getItem(LOCAL_STORAGE.aurelia_authentication);
if (accessToken) {
this.authToken = `Bearer ${JSON.parse(accessToken).access_token}`;
}
if (!baseUrl.startsWith("api/")) {
throw new Error("The baseUrl should start with api/");
}
this.getUrlService = new GetUrlService(baseUrl, this.languageService);
}
public getStateCurrentCompanyId() {
let localState: IState;
const subscription: Subscription = this.store.state.subscribe(state => localState = state);
subscription.unsubscribe();
return localState.currentCompanyId;
}
public navigateBack(router: Router) {
const fragment = (router.history as any)._getFragment();
if (!fragment || !fragment.includes("/notauthorized")) {
router.navigateBack();
}
}
public get isRequesting() {
return this.http.isRequesting;
}
protected async create<T>(reducer: Reducer<IState, any[]>) {
const url = `${this.baseUrl}/create`;
await this.serverMessageService.clearMessages();
try {
const response = await this.http.fetch(url);
try {
const data = (await response.json()) as IServerResponse<T>;
if (!data.isError) {
(data.dto as any).companyId = this.getStateCurrentCompanyId();
await this.store.dispatch(reducer, data.dto);
} else {
await this.serverMessageService.setErrorMessage(data.message);
await this.store.dispatch(reducer, undefined);
}
} catch (ex) {
log.error(ex);
}
} catch (ex) {
await this.handleErrorResponse(ex as Response);
}
}
protected async get(id: string | QueryId[], action: string, reducer: Reducer<IState, any[]>) {
const params = typeof (id) === "string" ? [new QueryId("id", id)] : id;
const url = this.getUrlService.getUrl(new FetchRoute(params, action));
const response = await this.http.fetch(url);
return this.handleResponse(response, reducer);
}
protected async delete<T>(id: string | QueryId[], action: string, reducer: Reducer<IState, any[]>) {
const params = typeof (id) === "string" ? [new QueryId("id", id)] : id;
const url = this.getUrlService.getUrl(new FetchRoute(params, action));
const response = await this.http.fetch(url, { method: "DELETE" });
return this.handleResponse<T>(response, reducer);
}
protected async post<T>(model: any, action: string, reducer: Reducer<IState, any[]>) {
const url = this.getUrlService.getPostUrl(action);
model.companyId = this.getStateCurrentCompanyId();
const response = await this.http.fetch(url, { method: "POST", body: json(model) });
return this.handleResponse<T>(response, reducer);
}
protected async postFormData<T>(formData: FormData, formDataAppends: IFormDataModel[], action: string, reducer: Reducer<IState, any[]>) {
formDataAppends.forEach(model => {
if ((model.data as IEntityCompany).companyId) {
model.data.companyId = this.getStateCurrentCompanyId();
}
formData.append(model.name, JSON.stringify(model.data));
});
const url = this.getUrlService.getPostUrl(action);
const response = await this.http.fetch(
url, {
method: "post",
body: formData,
headers: {
Accept: "application/json",
Authorization: this.authToken
}
});
return this.handleResponse<T>(response, reducer);
}
protected async getMany<TListItem>(queryParams: QueryId[], action: string, reducer: Reducer<IState, any[]>) {
const url = this.getUrlService.getUrl(new FetchRoute(queryParams, action));
const response = await this.http.fetch(url);
return this.handleResponse<TListItem[]>(response, reducer);
}
private async handleResponse<T>(response: Response, reducer: Reducer<IState, any[]>) {
this.serverMessageService.clearMessages();
if (!response.ok) {
this.handleErrorResponse(response);
return this.store.dispatch(reducer, undefined);
}
let data: T;
const serverResponse = (await response.json()) as IServerResponse<T>;
if (serverResponse.isError) {
await this.serverMessageService.setErrorMessage(serverResponse.message);
data = undefined;
}
if (!serverResponse.isError && serverResponse.message) {
await this.serverMessageService.setMessage(serverResponse.message);
}
data = serverResponse.dto
? serverResponse.dto
: serverResponse as unknown as T;
return this.store.dispatch(reducer, data as T);
}
private async handleErrorResponse(response: Response) {
if (!response.status) {
throw response;
}
switch (response.status) {
case 400:
const text = (await response.text()) as any;
try {
const parsed = JSON.parse(text);
this.store.dispatch(serverErrorMessageAction, parsed.message);
log.error(parsed.message + "\n" + parsed.stackTrace);
} catch (e) {
log.error(text);
throw e;
}
break;
case 401:
this.router.navigateToRoute("login", { trigger: true, replace: false });
break;
case 403:
this.router.navigateToRoute("notAuthorized", { trigger: true, replace: false });
break;
default:
const serverError = (await response.text()) as any;
this.store.dispatch(serverErrorMessageAction, serverError);
log.error(serverError);
throw serverError;
}
}
}
finally a typical service that extends fetch-service.ts
. I found that I have to use _.cloneDeep(state) rather than {… state} because all of the models in the state are deep objects.
customer-service.ts
import { HttpClient } from "aurelia-fetch-client";
import { autoinject } from "aurelia-framework";
import { Router } from "aurelia-router";
import { Store } from "aurelia-store";
import _ from "lodash";
import { ICustomer, ICustomerListItem } from "../interfaces/Interfaces for dictionary items";
import { QueryId } from "../models/QueryId";
import { IState } from "../store/state";
import { FetchService } from "./fetch-service";
import { IFetchList } from "./products-service";
@autoinject
export class CustomerService extends FetchService implements IFetchList {
constructor(
http: HttpClient,
store: Store<IState>,
router: Router
) {
super("api/customer", http, store, router);
store.registerAction("customerAction", customerAction);
store.registerAction("customersListAction", customersListAction);
}
public async loadCustomer(id: string) {
return super.get(id, "loadCustomer", customerAction);
}
public async createCustomer() {
return super.create(customerAction);
}
public async saveCustomer(model: ICustomer) {
return super.post<ICustomer>(model, "saveCustomer", customerAction);
}
public async loadList() {
return this.loadCustomersList();
}
public async loadCustomersList() {
return super.getMany<ICustomerListItem>([new QueryId("companyId", super.getStateCurrentCompanyId())], "loadCustomersList", customersListAction);
}
}
export function customerAction(state: IState, response: ICustomer) {
const newState = _.cloneDeep(state);
response.companyId = state.currentCompanyId;
newState.customer.current = response;
return newState;
}
export function customersListAction(state: IState, response: ICustomerListItem[]) {
const newState = _.cloneDeep(state);
newState.customer.list = response;
return newState;
}
In the viewModel, I just use @connectTo, and in bind() always do
this.model=_.cloneDeep(this.state.customer.current).
I only bind the view to the model, and only update the state using the customer-service ‘post/save’ method. This way I don’t accidentally overwrite anything in state - which is remarkably easy to do!
Any messages that come back from the server are set using the serverMessageService
which just sends an action to set state.serverMessages.message' and
state.serverMessage.errorMessage`.
And just for completeness the getUrlService
which always appends the culture to all requests to the server
import { autoinject } from "aurelia-framework";
import { QueryId } from "../models/QueryId";
import { FetchRoute } from "./../requests/FetchRoute";
import { LanguageService } from "./language-service";
@autoinject
export class GetUrlService {
constructor(
private baseUrl: string,
private languageService: LanguageService
) {
}
public setBaseUrl(baseUrl: string) {
this.baseUrl = baseUrl;
}
public get culture() {
const culture = LanguageService.culture;
return culture;
}
public getPostUrl(action: string) {
return this.getUrl(new FetchRoute([], action));
}
public getUrl(route: FetchRoute): string {
const url = this.baseUrl;
let action = "";
let seperator = "?";
let params = "";
if (!route.params && !route.action) {
return url;
}
if (route.action) {
action += "/" + route.action;
}
if (route.params) {
const parseValue = (value: any) => value === undefined || value === null ? "" : value.toString();
const reducer = (current: string, next: QueryId) => current + (next.value ? `${next.name}=${parseValue(next.value)}&` : "");
params = "?" + route.params.reduce(reducer, "");
seperator = "";
}
const culture = `${seperator}culture=${this.culture}`;
return url + action + params + culture;
}
public isValidRavenId(id: string) {
const r = new RegExp(".[-||\\/]\\d*-[A,B,C,a,b,c]");
return r.test(id);
}
public validateInputId(id: string) {
const arr = id.split("/");
if (arr.length > 2) {
return false;
}
if (arr.length === 2) {
const lastTest = arr[0].split("-");
if (lastTest.length === 3) {
return false;
}
return this.isValidRavenId(id);
}
return arr.every(c => this.isValidRavenId(c));
}
}