Source

RestService.ts

import {
  BulkCrudOperator,
  Context,
  CrudOperator,
  findPrimaryKey,
  InternalError,
} from "@decaf-ts/db-decorators";
import { Constructor, Model } from "@decaf-ts/decorator-validation";
import { Observable, Observer, Repository } from "@decaf-ts/core";
import { HttpAdapter } from "./adapter";
import { HttpFlags } from "./types";

/**
 * @description Service class for REST API operations
 * @summary Provides a comprehensive implementation for interacting with REST APIs.
 * This class implements CRUD operations for single and bulk operations, as well as
 * the Observable pattern to notify observers of changes. It works with HTTP adapters
 * to perform the actual API requests and handles model conversion.
 * @template M - The model type, extending Model
 * @template Q - The query type used by the adapter
 * @template A - The HTTP adapter type, extending HttpAdapter
 * @template F - The HTTP flags type, extending HttpFlags
 * @template C - The context type, extending Context<F>
 * @param {A} adapter - The HTTP adapter instance
 * @param {Constructor<M>} [clazz] - Optional constructor for the model class
 * @example
 * ```typescript
 * // Create a service for User model with Axios adapter
 * const axiosAdapter = new AxiosAdapter({
 *   protocol: 'https',
 *   host: 'api.example.com'
 * });
 * const userService = new RestService(axiosAdapter, User);
 *
 * // Create a new user
 * const user = new User({ name: 'John Doe', email: 'john@example.com' });
 * const createdUser = await userService.create(user);
 *
 * // Update a user
 * createdUser.name = 'Jane Doe';
 * const updatedUser = await userService.update(createdUser);
 *
 * // Delete a user
 * await userService.delete(updatedUser.id);
 * ```
 * @class
 */
export class RestService<
    M extends Model,
    Q,
    A extends HttpAdapter<any, Q, F, C>,
    F extends HttpFlags = HttpFlags,
    C extends Context<F> = Context<F>,
  >
  implements CrudOperator<M>, BulkCrudOperator<M>, Observable
{
  private readonly _class!: Constructor<M>;
  private _pk!: keyof M;

  /**
   * @description Gets the model class constructor
   * @summary Retrieves the model class constructor associated with this service.
   * Throws an error if no class definition is found.
   * @return {Constructor<M>} The model class constructor
   * @throws {InternalError} If no class definition is found
   */
  get class() {
    if (!this._class)
      throw new InternalError("No class definition found for this repository");
    return this._class;
  }

  /**
   * @description Gets the primary key property name
   * @summary Retrieves the name of the primary key property for the model.
   * If not already determined, it finds the primary key using the model class.
   * @return The primary key property name
   */
  get pk() {
    if (!this._pk) this._pk = findPrimaryKey(new this.class()).id;
    return this._pk;
  }

  protected observers: Observer[] = [];

  private readonly _adapter!: A;
  private _tableName!: string;

  /**
   * @description Gets the HTTP adapter
   * @summary Retrieves the HTTP adapter associated with this service.
   * Throws an error if no adapter is found.
   * @return {A} The HTTP adapter instance
   * @throws {InternalError} If no adapter is found
   */
  protected get adapter(): A {
    if (!this._adapter)
      throw new InternalError(
        "No adapter found for this repository. did you use the @uses decorator or pass it in the constructor?"
      );
    return this._adapter;
  }

  /**
   * @description Gets the table name for the model
   * @summary Retrieves the table name associated with the model class.
   * If not already determined, it gets the table name from the Repository utility.
   * @return {string} The table name
   */
  protected get tableName() {
    if (!this._tableName) this._tableName = Repository.table(this.class);
    return this._tableName;
  }

  /**
   * @description Initializes a new RestService instance
   * @summary Creates a new service instance with the specified adapter and optional model class.
   * The constructor stores the adapter and model class for later use in CRUD operations.
   * @param {A} adapter - The HTTP adapter instance to use for API requests
   * @param {Constructor<M>} [clazz] - Optional constructor for the model class
   */
  constructor(adapter: A, clazz?: Constructor<M>) {
    this._adapter = adapter;
    if (clazz) this._class = clazz;
  }

  /**
   * @description Creates a new resource
   * @summary Creates a new resource in the REST API using the provided model.
   * The method prepares the model for the adapter, sends the create request,
   * and then converts the response back to a model instance.
   * @param {M} model - The model instance to create
   * @param {...any[]} args - Additional arguments to pass to the adapter
   * @return {Promise<M>} A promise that resolves with the created model instance
   */
  async create(model: M, ...args: any[]): Promise<M> {
    // eslint-disable-next-line prefer-const
    let { record, id } = this.adapter.prepare(model, this.pk);
    record = await this.adapter.create(this.tableName, id, record, ...args);
    return this.adapter.revert(record, this.class, this.pk, id);
  }

  /**
   * @description Retrieves a resource by ID
   * @summary Fetches a resource from the REST API using the provided ID.
   * The method sends the read request and converts the response to a model instance.
   * @param {string|number} id - The identifier of the resource to retrieve
   * @param {...any[]} args - Additional arguments to pass to the adapter
   * @return {Promise<M>} A promise that resolves with the retrieved model instance
   */
  async read(id: string | number, ...args: any[]): Promise<M> {
    const m = await this.adapter.read(this.tableName, id, ...args);
    return this.adapter.revert(m, this.class, this.pk, id);
  }

  /**
   * @description Updates an existing resource
   * @summary Updates an existing resource in the REST API using the provided model.
   * The method prepares the model for the adapter, sends the update request,
   * and then converts the response back to a model instance.
   * @param {M} model - The model instance with updated data
   * @param {...any[]} args - Additional arguments to pass to the adapter
   * @return {Promise<M>} A promise that resolves with the updated model instance
   */
  async update(model: M, ...args: any[]): Promise<M> {
    // eslint-disable-next-line prefer-const
    let { record, id } = this.adapter.prepare(model, this.pk);
    record = await this.adapter.update(this.tableName, id, record, ...args);
    return this.adapter.revert(record, this.class, this.pk, id);
  }

  /**
   * @description Deletes a resource by ID
   * @summary Removes a resource from the REST API using the provided ID.
   * The method sends the delete request and converts the response to a model instance.
   * @param {string|number} id - The identifier of the resource to delete
   * @param {...any[]} args - Additional arguments to pass to the adapter
   * @return {Promise<M>} A promise that resolves with the deleted model instance
   */
  async delete(id: string | number, ...args: any[]): Promise<M> {
    const m = await this.adapter.delete(this.tableName, id, ...args);
    return this.adapter.revert(m, this.class, this.pk, id);
  }

  /**
   * @description Creates multiple resources
   * @summary Creates multiple resources in the REST API using the provided models.
   * The method prepares each model for the adapter, sends a bulk create request,
   * and then converts the responses back to model instances.
   * @param {M[]} models - The model instances to create
   * @param {...any[]} args - Additional arguments to pass to the adapter
   * @return {Promise<M[]>} A promise that resolves with an array of created model instances
   */
  async createAll(models: M[], ...args: any[]): Promise<M[]> {
    if (!models.length) return models;
    const prepared = models.map((m) => this.adapter.prepare(m, this.pk));
    const ids = prepared.map((p) => p.id);
    let records = prepared.map((p) => p.record);
    records = await this.adapter.createAll(
      this.tableName,
      ids as (string | number)[],
      records,
      ...args
    );
    return records.map((r, i) =>
      this.adapter.revert(r, this.class, this.pk, ids[i] as string | number)
    );
  }

  /**
   * @description Deletes multiple resources by IDs
   * @summary Removes multiple resources from the REST API using the provided IDs.
   * The method sends a bulk delete request and converts the responses to model instances.
   * @param {string[]|number[]} keys - The identifiers of the resources to delete
   * @param {...any[]} args - Additional arguments to pass to the adapter
   * @return {Promise<M[]>} A promise that resolves with an array of deleted model instances
   */
  async deleteAll(keys: string[] | number[], ...args: any[]): Promise<M[]> {
    const results = await this.adapter.deleteAll(this.tableName, keys, ...args);
    return results.map((r, i) =>
      this.adapter.revert(r, this.class, this.pk, keys[i])
    );
  }

  /**
   * @description Retrieves multiple resources by IDs
   * @summary Fetches multiple resources from the REST API using the provided IDs.
   * The method sends a bulk read request and converts the responses to model instances.
   * @param {string[]|number[]} keys - The identifiers of the resources to retrieve
   * @param {...any[]} args - Additional arguments to pass to the adapter
   * @return {Promise<M[]>} A promise that resolves with an array of retrieved model instances
   */
  async readAll(keys: string[] | number[], ...args: any[]): Promise<M[]> {
    const records = await this.adapter.readAll(this.tableName, keys, ...args);
    return records.map((r, i) =>
      this.adapter.revert(r, this.class, this.pk, keys[i])
    );
  }

  /**
   * @description Updates multiple resources
   * @summary Updates multiple resources in the REST API using the provided models.
   * The method prepares each model for the adapter, sends a bulk update request,
   * and then converts the responses back to model instances.
   * @param {M[]} models - The model instances with updated data
   * @param {...any[]} args - Additional arguments to pass to the adapter
   * @return {Promise<M[]>} A promise that resolves with an array of updated model instances
   */
  async updateAll(models: M[], ...args: any[]): Promise<M[]> {
    const records = models.map((m) => this.adapter.prepare(m, this.pk));
    const updated = await this.adapter.updateAll(
      this.tableName,
      records.map((r) => r.id),
      records.map((r) => r.record),
      ...args
    );
    return updated.map((u, i) =>
      this.adapter.revert(u, this.class, this.pk, records[i].id)
    );
  }

  /**
   * @description Registers an observer
   * @summary Adds an observer to the list of observers that will be notified of changes.
   * Throws an error if the observer is already registered.
   * @param {Observer} observer - The observer to register
   * @return {void}
   * @throws {InternalError} If the observer is already registered
   */
  observe(observer: Observer): void {
    const index = this.observers.indexOf(observer);
    if (index !== -1) throw new InternalError("Observer already registered");
    this.observers.push(observer);
  }

  /**
   * @description Unregisters an observer
   * @summary Removes an observer from the list of observers.
   * Throws an error if the observer is not found.
   * @param {Observer} observer - The observer to unregister
   * @return {void}
   * @throws {InternalError} If the observer is not found
   */
  unObserve(observer: Observer): void {
    const index = this.observers.indexOf(observer);
    if (index === -1) throw new InternalError("Failed to find Observer");
    this.observers.splice(index, 1);
  }

  /**
   * @description Notifies all registered observers
   * @summary Calls the refresh method on all registered observers to update themselves.
   * Any errors during observer refresh are logged as warnings but don't stop the process.
   * @param {...any[]} [args] - Optional arguments to pass to the observer refresh method
   * @return {Promise<void>} A promise that resolves when all observers have been updated
   */
  async updateObservers(...args: any[]): Promise<void> {
    const results = await Promise.allSettled(
      this.observers.map((o) => o.refresh(...args))
    );
    results.forEach((result, i) => {
      if (result.status === "rejected")
        console.warn(
          `Failed to update observable ${this.observers[i]}: ${result.reason}`
        );
    });
  }
}