Best practice for using Aurelia Store/State and canActivate

Help wanted!

We’re migrating to aurelia-store (a success for now). But the thing is, in all our containers we use canActivate() to make sure the selected entity was found or check whether the user has rights to access it. Normal flux is, we need to do an async action (request the API) and based on the result, return TRUE/FALSE to canActivate.

Here is the point, we “normally” (90%) of the time, need this data coming from API, fetched inside the canActivate(), and it normally goes to the State for further use.

So, what should you guys do in this case? I mean, what we’re doing is, we consume the service layer inside the canActivate, then we dispatch an action to te Store, giving the entity we just requested as a parameter, and than we return to canActivate TRUE/FALSE if entity was found or not.

It goes like this:

export class WhatIfContent {
  ...
  private async canActivate(args: IRouteParams): Promise<boolean> {
    const entity = await this.service.getEntity(args.id);
    this.store.dispatch(updateStateWithEntity.name, entity);
    return Boolean(entity);
  }
  ...
}

Is that right? Is there another way of tackling it?

1 Like

This is very good. I see people moving the fetch inside an action and then awaiting the dispatch. With your approach you keep the data aggregation separate from the state logic. What is questionable, but cant say more without additional context, is that you’ll always fetch for every canActivate whereas you could cache the result

2 Likes

I mean, the concept applies for every canActivate, not the same fetch/result.

Anyways, I tried your suggestion (moving the fetch inside the action and return the dispatch) but it’s undefined. Am I doing something wrong?

const dispatch = await this.store.dispatch(fetchViewModelAction.name, args.drumId, args.scenarioId, clearStateMessages);
console.log(dispatch); //undefined

even tried with dispatchify

const fetchViewModel = dispatchify(fetchViewModelAction.name);
const dispatch = await fetchViewModel(args.drumId, args.scenarioId, clearStateMessages);
console.log(dispatch); //undefined

my action looks like:

const schedulingService = new Container().get(SchedulingService) as SchedulingService;

export const fetchViewModelAction = async (state: IWhatIfState, drumId?: number, scenarioId?: number, clearStateMsg: boolean = true): Promise<IWhatIfState> => {
  const viewModel = await schedulingService.getScenarioViewModel(drumId, scenarioId, clearStateMsg);

  return Object.assign({}, state, {
    [nameof<IWhatIfState>("isEditMode")]: false,
    [nameof<IWhatIfState>("scenarios")]: viewModel?.scenarios || INITIAL_STATE.scenarios,
    [nameof<IWhatIfState>("treeViewDataSource")]: viewModel?.drums || INITIAL_STATE.treeViewDataSource,
    [nameof<IWhatIfState>("gridDataSource")]: viewModel?.gridDatasource || INITIAL_STATE.gridDataSource,
    });
};

PS. I didn’t change the name of my stuff this time… it’s copy/past :wink:

Thanks in advance for the help!

1 Like

I’m sorry, I’ve not explained myself well enough. What I meant is that I don’t think it’s a good approach to do that since you start mixing data acquiry with business logic, whereas with your approach you keep the things separated and the action a pure function which just takes an entity and manipulates the store based on it. This in turn makes a simpler means for unit testing the action.

Awaiting dispatch won’t do anything besides waiting for the next state to be available. It returns a promised void. By doing so, you can delay the next action fired until the next microtask.

2 Likes

ok, now I got it…

yeah, like, in my head, what I’ll end up doing is, having a “general purpose” action, an action that receives the State’s property I what to update, and it’s value. And than I’ll use it most of the time. I can even have a helper/util for that using dispatchfy. Whenever I need logic to be done before updating the State, I create a new action, so I “kind of” separate/isolate some logic from the controller.

This way I don’t end up having 100 actions that goes the exact same thing.

What are your thoughts about that?

2 Likes

That makes perfect sense to me. If you need to dispatch that generic one right away together with another action you can also take a look at piped dispatches. It just depends whether you need to really propagate a new state or could do it transient via the Pipe

2 Likes

I’ll work on the helper. I’ll post the result here.

Thanks for now @zewa666

1 Like

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));

  }

}
4 Likes