cloudfoundry-incubator/stratos

View on GitHub
src/frontend/packages/store/src/entity-service.ts

Summary

Maintainability
A
1 hr
Test Coverage
import { compose, Store } from '@ngrx/store';
import { combineLatest, Observable } from 'rxjs';
import { filter, first, map, publishReplay, refCount, switchMap, tap, withLatestFrom } from 'rxjs/operators';

import { GeneralEntityAppState } from './app-state';
import { entityCatalog } from './entity-catalog/entity-catalog';
import { StratosBaseCatalogEntity } from './entity-catalog/entity-catalog-entity/entity-catalog-entity';
import { EntityActionBuilderEntityConfig } from './entity-catalog/entity-catalog.types';
import { EntityFetch, EntityFetchHandler } from './entity-request-pipeline/entity-request-pipeline.types';
import { EntityMonitor } from './monitors/entity-monitor';
import { RequestInfoState, UpdatingSection } from './reducers/api-request-reducer/types';
import { getEntityUpdateSections, getUpdateSectionById, selectEntity } from './selectors/api.selectors';
import { EntityInfo } from './types/api.types';
import { EntityRequestAction } from './types/request.types';

export function isEntityBlocked(entityRequestInfo: RequestInfoState) {
  if (!entityRequestInfo) {
    return false;
  }
  return entityRequestInfo.fetching ||
    entityRequestInfo.error ||
    entityRequestInfo.deleting.busy ||
    entityRequestInfo.deleting.deleted;
}

type ActionDispatcher<T> = (updatingKey?: string, fetchEntity?: boolean) => EntityFetch<T>;
const dispatcherFactory = <T>(
  store: Store<GeneralEntityAppState>,
  action: EntityRequestAction,
  catalogEntity: StratosBaseCatalogEntity,

): ActionDispatcher<T> =>
  (updatingKey?: string, fetchEntity?: boolean) => {
    // If we're dispatching the action in the updating world ensure the key is set
    const updatedAction = {
      ...action,
      updatingKey
    };

    // Do we have a fetch handler defined by the endpoint/entity?
    const entityFetchHandler: EntityFetchHandler<T> = catalogEntity.getEntityFetchHandler();
    const fetchHandler = entityFetchHandler ?
      entityFetchHandler(store, updatedAction) :
      (entity: T) => store.dispatch(updatedAction);

    // Fetch handler requires the entity, this may be missing or stale to update if required
    return fetchEntity ? (entity: T) => {
      // Entity may be null or stale
      store.select(selectEntity<T>(catalogEntity.entityKey, action.guid)).pipe(first()).subscribe(storeEntity => fetchHandler(storeEntity));
      fetchHandler(entity);
    } : fetchHandler;
  };


/**
 * Designed to be used in a service factory provider
 */
export class EntityService<T = any> {
  public action: EntityRequestAction;
  constructor(
    store: Store<GeneralEntityAppState>,
    public entityMonitor: EntityMonitor<T>,
    actionOrConfig: EntityRequestAction | EntityActionBuilderEntityConfig,
  ) {
    this.action = this.getAction(actionOrConfig);
    const catalogEntity = entityCatalog.getEntity(this.action);

    // Setup Fetch Handler
    this.actionDispatch = dispatcherFactory<T>(store, this.action, catalogEntity);

    // Setup Emit Handler
    const entityEmitHandlerBuilder = catalogEntity.getEntityEmitHandler();
    const entityEmitHandler = entityEmitHandlerBuilder ? entityEmitHandlerBuilder(
      this.action, (action) => store.dispatch(action)
    ) : () => { };


    this.updateEntity = () => {
      this.actionDispatch(this.refreshKey, true)(null);
    };

    this.updatingSection$ = entityMonitor.updatingSection$;
    this.isDeletingEntity$ = entityMonitor.isDeletingEntity$;
    this.isFetchingEntity$ = entityMonitor.isFetchingEntity$;
    this.entityObs$ = this.getEntityObservable(
      entityMonitor,
      this.actionDispatch(),
    ).pipe(
      publishReplay(1),
      refCount(),
      tap(entityEmitHandler)
    );

    this.waitForEntity$ = this.entityObs$.pipe(
      filter((ent) => {
        const { entityRequestInfo, entity } = ent;
        // Note - isEntityAvailable does not block on updating, decision taken to ensure we show entity as soon as possible.
        // This means, in the cf world, entities will be emitted here that are still in the validation process and as such may be missing
        // required relations
        return this.isEntityAvailable(entity, entityRequestInfo);
      }),
      publishReplay(1),
      refCount()
    );
  }

  refreshKey = 'updating';

  private actionDispatch: ActionDispatcher<T>;

  updateEntity: () => void;

  entityObs$: Observable<EntityInfo<T>>;

  isFetchingEntity$: Observable<boolean>;

  isDeletingEntity$: Observable<boolean>;

  waitForEntity$: Observable<EntityInfo<T>>;

  updatingSection$: Observable<UpdatingSection>;
  private getEntityObservable = (
    entityMonitor: EntityMonitor<T>,
    actionDispatch: EntityFetch<T>
  ): Observable<EntityInfo> => {
    const cleanEntityInfo$ = this.getCleanEntityInfoObs(entityMonitor);

    return entityMonitor.entityRequest$.pipe(
      withLatestFrom(entityMonitor.entity$),
      tap(([entityRequestInfo, entity]) => {
        if (this.shouldCallAction(entityRequestInfo, entity)) {
          actionDispatch(entity);
        }
      }),
      first(),
      switchMap(() => cleanEntityInfo$)
    );
  };

  private getCleanEntityInfoObs(entityMonitor: EntityMonitor<T>) {
    return combineLatest(
      entityMonitor.entityRequest$,
      entityMonitor.entity$
    ).pipe(
      filter(([entityRequestInfo]) => {
        return !!entityRequestInfo;
      }),
      map(([entityRequestInfo, entity]) => ({
        entityRequestInfo,
        // If the entity is deleted ensure that we don't pass through a stale state
        entity: entityRequestInfo.deleting && entityRequestInfo.deleting.deleted ? null : entity
      }))
    );
  }

  private isEntityAvailable(entity, entityRequestInfo: RequestInfoState) {
    const isBlocked = isEntityBlocked(entityRequestInfo);
    return entity && !isBlocked;
  }

  private shouldCallAction(entityRequestInfo: RequestInfoState, entity: T) {
    return !entityRequestInfo || (!entity && !isEntityBlocked(entityRequestInfo));
  }

  private getAction(dispatcherConfigOrAction: EntityActionBuilderEntityConfig | EntityRequestAction) {
    const action = dispatcherConfigOrAction as EntityRequestAction;
    if (action.type) {
      return action;
    } else {
      const {
        // TODO: Schema should be passed to the action builders #3846.
        schemaKey,
        entityGuid,
        endpointGuid,
        actionMetadata = {},
        entityType,
        endpointType
      } = dispatcherConfigOrAction as EntityActionBuilderEntityConfig;
      const actionBuilder = entityCatalog.getEntity(endpointType, entityType).actionOrchestrator.getActionBuilder('get');
      return actionBuilder(entityGuid, endpointGuid, actionMetadata);
    }
  }

  /**
   * @param interval - The polling interval in ms.
   * @param updateKey - The store updating key for the poll
   */
  poll(interval = 10000, updateKey = this.refreshKey) {
    return this.entityMonitor.poll(
      interval,
      () => this.actionDispatch(updateKey, true)(null),
      compose(
        getUpdateSectionById(updateKey),
        getEntityUpdateSections
      )
    );
  }
}