Skip to content

LILA-IT/mobx-openapi-stores

Repository files navigation

MobX OpenAPI Stores

npm version License: MIT npm downloads styled with prettier

mobx-openapi-stores provides a set of base MobX stores designed to simplify the integration of OpenAPI-generated API clients into your frontend applications. These stores offer structured ways to manage API interactions, loading states, and common data patterns like single entities, collections, and grouped objects. Errors will automatically be processed and logged to the console.

Features

  • Less Boilerplate: Less code to write and maintain.
  • Type-Safe: Leverages TypeScript and is designed to work seamlessly with types generated by OpenAPI tools.
  • Reactive: Built with MobX for efficient and easy-to-manage state.
  • Modular: Provides distinct store classes for different data management scenarios.
  • Extensible: Base stores can be easily extended to add application-specific logic.
  • Loading State Management: Integrated loading indicators for asynchronous operations.
  • Simplified API Calls: Abstracted apiCall method for interacting with your API client.
  • Error Handling: Errors will automatically be processed and logged to the console.

Installation

npm install mobx-openapi-stores mobx
# or
yarn add mobx-openapi-stores mobx

Core Stores

This package provides the following core store classes:

  1. LoadingStore: Manages a simple boolean loading state.
  2. ApiStore: Manages an API client instance and API call flows.
  3. SingleStore: Manages a single observable entity.
  4. CollectionStore: Manages a collection (array) of observable entities.
  5. CrudCollectionStore: Extends CollectionStore with helper methods for common API-driven CRUD operations.
  6. ObjectStore: Manages a dictionary-like observable object, useful for grouping entities.

1. LoadingStore

The most basic store, providing a simple isLoading observable property and setIsLoading action.

Purpose: To be extended by other stores that need to track loading states for asynchronous operations.

Key Members:

  • isLoading: boolean (computed): True if an operation is in progress.
  • setIsLoading(loading: boolean) (action): Sets the loading state.

Example:

import { LoadingStore } from 'mobx-openapi-stores';
import { makeObservable, action } from 'mobx';

class MyCustomLoadingComponentStore extends LoadingStore {
  constructor() {
    super();
    // makeObservable for custom actions if any
  }

  async performSomeTask() {
    this.setIsLoading(true);
    try {
      // ... your async logic ...
      await new Promise((resolve) => setTimeout(resolve, 1000));
    } finally {
      this.setIsLoading(false);
    }
  }
}

const store = new MyCustomLoadingComponentStore();
// autorun(() => console.log(store.isLoading));
// store.performSomeTask();

2. ApiStore<TApi, TConfig>

Extends LoadingStore. Manages an instance of your OpenAPI-generated API client and provides a standardized way to make API calls.

Purpose: Base for stores that interact with an API. Handles API client initialization and provides an apiCall method that automatically manages loading states.

Generics:

  • TApi: The type of your generated API client (e.g., PetApi).
  • TConfig: The configuration type for your TApi client (e.g., Configuration from the generated client).

Key Members:

  • api: TApi | null (observable): The API client instance.
  • apiIsSet: boolean (computed): True if the API client is initialized.
  • setApi(api: TApi) (action): Sets the API client.
  • initApi(config: TConfig) (action, must be implemented by subclasses): Initializes and sets the API client.
  • apiCall(endpoint: keyof TApi, args: any) (flow): Makes an API call using the specified endpoint method on this.api.

Example:

import { ApiStore } from 'mobx-openapi-stores';
import { makeObservable, action } from 'mobx';
// Assuming you have a PetApi generated by OpenAPI
import { PetApi, Configuration as PetApiConfig, Pet } from './your-api-client';

class PetApiService extends ApiStore<PetApi, PetApiConfig> {
  constructor() {
    super('PetApiService'); // Pass a name for easier debugging
    // initApi is bound, other actions might need makeObservable if added here
  }

  // Implementation of the abstract-like method
  initApi(config: PetApiConfig) {
    this.setApi(new PetApi(config));
  }

  async fetchPetById(petId: number): Promise<Pet | undefined> {
    if (!this.apiIsSet) {
      console.error('API not initialized');
      return undefined;
    }
    // 'getPetById' is a method on your PetApi
    // The second argument matches the parameters of PetApi.getPetById
    return await this.apiCall('getPetById', { petId });
  }
}

// const petStore = new PetApiService();
// petStore.initApi(new PetApiConfig({ basePath: "http://localhost:3000/api" }));
// petStore.fetchPetById(1).then(pet => console.log(pet));

3. SingleStore<TApi, TSingle>

Extends ApiStore. Manages a single observable entity, referred to as current.

Purpose: Useful for scenarios like managing a selected item, user profile, or any standalone piece of data that might be fetched from an API.

Generics:

  • TApi: Inherited from ApiStore.
  • TSingle: The type of the single entity being managed (must have an id property).

Key Members:

  • _current: TSingle | null (observable): The internal storage for the current entity.
  • current: TSingle | null (computed): Provides access to _current.
  • setCurrent(item: TSingle | null) (action): Sets the current entity.
  • isCurrentSet: boolean (computed): True if _current is not null.
  • clearCurrent() (action): Sets _current to null.
  • editCurrent(updatedFields: Partial<TSingle>) (action): Merges updates into the current entity.

Example:

import { SingleStore } from 'mobx-openapi-stores';
import { makeObservable, action } from 'mobx';
import { UserApi, Configuration as UserApiConfig, User } from './your-api-client'; // Assuming User type

class UserProfileStore extends SingleStore<UserApi, User> {
  constructor() {
    super('UserProfileStore');
    // makeObservable for custom actions
  }

  initApi(config: UserApiConfig) {
    this.setApi(new UserApi(config));
  }

  async fetchUserProfile(userId: string) {
    const user = await this.apiCall('getUserById', { userId }); // Assuming getUserById endpoint
    if (user) {
      this.setCurrent(user as User);
    }
  }

  updateUserName(newName: string) {
    if (this.current) {
      this.editCurrent({ name: newName });
      // Optionally, call API to persist change:
      // this.apiCall('updateUser', { userId: this.current.id, userDto: { name: newName }});
    }
  }
}

// const profileStore = new UserProfileStore();
// profileStore.initApi(new UserApiConfig({ basePath: "/api" }));
// profileStore.fetchUserProfile('user123');

4. CollectionStore<TApi, TSingle, TCollection>

Extends SingleStore. Manages a collection (array) of observable entities. The current item from SingleStore can be used to represent a selected item from this collection.

Purpose: Ideal for managing lists of items, such as products, posts, todos, etc.

Generics:

  • TApi: Inherited.
  • TSingle: The type of individual items in the collection (must have an id).
  • TCollection: The type of the collection itself, defaults to TSingle[].

Key Members:

  • _collection: TCollection (observable): The internal observable array.
  • collection: TCollection (computed): Accessor for _collection.
  • setCollection(newCollection: TCollection) (action): Replaces the entire collection.
  • addItem(newItem: TSingle, setCurrent?: boolean) (action): Adds an item.
  • setItem(item: TSingle, setCurrent?: boolean) (action): Updates an existing item or adds it if not present.
  • editItem(updatedItem: TSingle, setCurrent?: boolean) (action): Merges updates into an existing item by ID.
  • removeItem(id: TSingle['id']) (action): Removes an item by ID.
  • getById(id: TSingle['id']): TSingle | undefined: Retrieves an item by ID (checks current first, then collection).

Example:

import { CollectionStore } from 'mobx-openapi-stores';
import { makeObservable, action } from 'mobx';
import {
  ProductApi,
  Configuration as ProductApiConfig,
  Product,
} from './your-api-client';

class ProductListStore extends CollectionStore<ProductApi, Product> {
  constructor() {
    super('ProductListStore');
    // makeObservable for custom actions
  }

  initApi(config: ProductApiConfig) {
    this.setApi(new ProductApi(config));
  }

  async fetchProducts() {
    // Assuming 'listProducts' is an endpoint that returns Product[]
    const products = await this.apiCall('listProducts', {});
    if (products) {
      this.setCollection(products as Product[]);
    }
  }

  selectProduct(productId: string) {
    const product = this.getById(productId);
    if (product) {
      this.setCurrent(product); // Sets the selected product as 'current'
    }
  }
}

// const productStore = new ProductListStore();
// productStore.initApi(new ProductApiConfig({ basePath: "/api" }));
// productStore.fetchProducts();

5. CrudCollectionStore<TApi, TSingle>

Extends CollectionStore<TApi, TSingle>. This store provides a foundation for collections that require API-driven CRUD (Create, Read, Update, Delete) operations. Instead of direct CRUD methods, it offers protected helper methods (e.g., _fetchAll, _create) that handle the API interaction and state updates. Subclasses implement public-facing methods that call these protected helpers, often using MobX flow for asynchronous actions.

Purpose: Simplifies creating stores for collections that need API-based CRUD, by handling common logic for API calls and local state management (collection and current item updates).

Generics:

  • TApi: The type of your generated API client (e.g., BudgetApi). Inherited from CollectionStore.
  • TSingle: The type of individual items in the collection (must have an id). Inherited from CollectionStore.
  • TCollection: The type of the collection, defaults to TSingle[]. Inherited from CollectionStore.

Key Protected Helper Methods (to be called by subclass methods):

  • _fetchAll(endpoint: keyof TApi, params: any): Promise<TSingle[] | undefined>: Fetches all items using the specified API endpoint and params. Updates this.collection on success.
  • _fetch(endpoint: keyof TApi, params: any): Promise<TSingle | undefined>: Fetches a single item by ID using the specified API endpoint and params. Sets this.current and updates the item in this.collection on success.
  • _create(endpoint: keyof TApi, params: any): Promise<TSingle | undefined>: Creates an item using the specified API endpoint and params. Adds the new item to this.collection and sets it as this.current on success.
  • _update(endpoint: keyof TApi, params: any): Promise<TSingle | undefined>: Updates an item using the specified API endpoint and params. Updates the item in this.collection and this.current (if it's the same item) on success.
  • _delete(endpoint: keyof TApi, params: any): Promise<void | undefined>: Deletes an item using the specified API endpoint and params. Removes the item from this.collection and clears this.current if it was the deleted item.

ApiResult<TApi, TEndpointName> Utility Type: This package exports an ApiResult utility type: type ApiResult<TApi extends ApiType, TEndpointName extends keyof TApi> = ThenArg<ReturnType<TApi[TEndpointName]>>; (where ThenArg extracts the resolved type of a Promise). It's used to strongly type the return values of your flow-wrapped API calls, representing the data returned by the specific API endpoint.

Example (BudgetStore):

import {
  Configuration,
  CreateBudgetDto,
  Budget,
  BudgetApi,
  UpdateBudgetDto,
} from './your-api-client'; // Assuming these types from your generated API client
import { CrudCollectionStore, type ApiResult } from 'mobx-openapi-stores';
import { action, flow, makeObservable } from 'mobx';
import { toFlowGeneratorFunction } from 'to-flow-generator-function'; // Utility to help with MobX flow typing

// Define TApi for convenience if used multiple times
type Api = BudgetApi;

export class BudgetStore extends CrudCollectionStore<Api, Budget> {
  constructor() {
    super('BudgetStore'); // Name for debugging
    makeObservable(this, {
      // initApi is action.bound in ApiStore, no need to repeat unless overriding
      // Public facing methods are MobX flows
      fetchAll: flow,
      fetchById: flow,
      createBudget: flow,
      updateBudget: flow,
      deleteBudget: flow,
    });
  }

  // Must be implemented by subclass of ApiStore
  initApi(config: Configuration) {
    this.setApi(new BudgetApi(config));
  }

  fetchAll = flow<ApiResult<Api, 'budgetControllerFindAllV1'>, []>(
    toFlowGeneratorFunction(async () => {
      // 'budgetControllerFindAllV1' is the method name on your BudgetApi
      // Second argument is the parameters object for that endpoint
      return await this._fetchAll('budgetControllerFindAllV1', {});
    }),
  );

  fetchById = flow<ApiResult<Api, 'budgetControllerFindOneV1'>, [id: Budget['id']]>(
    toFlowGeneratorFunction(async (id: Budget['id']) => {
      return await this._fetch('budgetControllerFindOneV1', { id });
    }),
  );

  createBudget = flow<
    ApiResult<Api, 'budgetControllerCreateV1'>,
    [budgetDto: CreateBudgetDto]
  >(
    toFlowGeneratorFunction(async (budgetDto: CreateBudgetDto) => {
      return await this._create('budgetControllerCreateV1', {
        createBudgetDto: budgetDto, // Parameter name must match the API client method
      });
    }),
  );

  updateBudget = flow<
    ApiResult<Api, 'budgetControllerUpdateV1'>,
    [id: Budget['id'], budgetDto: UpdateBudgetDto]
  >(
    toFlowGeneratorFunction(async (id: Budget['id'], budgetDto: UpdateBudgetDto) => {
      return await this._update('budgetControllerUpdateV1', {
        id, // Parameter name must match
        updateBudgetDto: budgetDto, // Parameter name must match
      });
    }),
  );

  deleteBudget = flow<ApiResult<Api, 'budgetControllerRemoveV1'>, [id: Budget['id']]>(
    toFlowGeneratorFunction(async (id: Budget['id']) => {
      return await this._delete('budgetControllerRemoveV1', { id });
    }),
  );
}

// const budgetStore = new BudgetStore();
// budgetStore.initApi(new Configuration({ basePath: "/api" }));
// budgetStore.fetchAll();

Note on flow Type Annotations: Explicit type annotations for flow (e.g., flow<ReturnType, [Arg1Type, Arg2Type]>) along with toFlowGeneratorFunction are recommended for complex asynchronous actions. They enhance type safety and editor autocompletion, especially when TypeScript's type inference for generator functions might be incomplete. However, for simpler flows or if type inference works reliably, they might not always be strictly necessary.


6. ObjectStore<TApi, TKey, TTarget, TType, TObject>

Extends SingleStore (where TSingle from SingleStore corresponds to TTarget here). Manages a dictionary-like observable object (_object) where each key maps to either a single entity or a collection of entities.

Purpose: Useful for grouping items by a common key, e.g., tasks by project ID, comments by post ID. The current item from SingleStore refers to an individual TTarget item.

Generics:

  • TApi: Inherited.
  • TKey: The type for the keys of the main observable object (e.g., string, number).
  • TTarget: The type of the individual items being stored (must have an id).
  • TType: 'single' | 'collection' - Specifies if entries are single TTarget items or TTarget[]. Defaults to 'collection'.
  • TObject: The overall shape of the observable object.

Key Members:

  • _object: TObject (observable): The internal observable object map.
  • object: TObject (computed): Accessor for _object.
  • getEntryById(id: TKey): TObject[TKey] | undefined: Retrieves the entry (item or collection) for a key.
  • setEntry(id: TKey, item: TObject[TKey]): Sets or updates an entry.
  • removeEntry(id: TKey): Removes an entry.
  • entryIsSet(id: TKey): boolean: Checks if an entry exists.
  • If TType is 'collection':
    • addItem(entryId: TKey, item: TTarget): Adds item to the collection at entryId.
    • editItem(itemId: TTarget['id'], itemUpdateData: TTarget): Updates item within its collection.
    • removeItem(itemId: TTarget['id'], entryId?: TKey): Removes item from its collection.
    • getItemById(itemId: TTarget['id']): TTarget | undefined: Finds an item across all collections.
    • getEntryIdByItemId(itemId: TTarget['id']): TKey | undefined: Finds the entry key for an item.

Example (TType = 'collection'):

import { ObjectStore } from 'mobx-openapi-stores';
import { makeObservable, action } from 'mobx';
import {
  CommentApi,
  Configuration as CommentApiConfig,
  Comment,
  Post,
} from './your-api-client'; // Assuming types

// Store to manage comments grouped by post ID
class PostCommentsStore extends ObjectStore<
  CommentApi,
  Post['id'],
  Comment,
  'collection'
> {
  constructor() {
    super('PostCommentsStore');
    // makeObservable for custom actions
  }

  initApi(config: CommentApiConfig) {
    this.setApi(new CommentApi(config));
  }

  async fetchCommentsForPost(postId: Post['id']) {
    // Assuming 'listCommentsByPostId' endpoint
    const comments = await this.apiCall('listCommentsByPostId', { postId });
    if (comments) {
      this.setEntry(postId, comments as Comment[]);
    }
  }

  async addComment(postId: Post['id'], commentData: { text: string }) {
    // Assuming 'createComment' endpoint that takes postId and comment data
    const newComment = await this.apiCall('createComment', {
      postId,
      commentDto: commentData,
    });
    if (newComment) {
      if (!this.entryIsSet(postId)) {
        this.setEntry(postId, []); // Initialize if first comment for this post
      }
      this.addItem(postId, newComment as Comment);
    }
  }

  // current (from SingleStore) can be used to manage a currently selected/focused comment
  selectComment(comment: Comment) {
    this.setCurrent(comment);
  }
}

// const commentsStore = new PostCommentsStore();
// commentsStore.initApi(new CommentApiConfig({ basePath: "/api" }));
// commentsStore.fetchCommentsForPost('post123');

General Principles

  • makeObservable: Remember to call makeObservable in your subclasses for any new action, computed, flow, or observable properties you define. Base store properties are already made observable.
  • initApi: Subclasses extending ApiStore (or stores derived from it) must implement initApi(config: TConfig) to instantiate and set their specific API client.
  • Error Handling: The apiCall method (and protected helpers in CrudCollectionStore) include basic error handling for API client initialization and manage loading states. You may want to add more specific error handling in your consuming code.
  • Naming: Pass a name string to the constructor of stores extending ApiStore (e.g., super('MyStoreName')). This is used in error messages for easier debugging (e.g., "MyStoreName Api is not set").

TypeScript and Generics

These stores are heavily reliant on TypeScript generics. Understanding how to provide the correct types for TApi, TSingle, TConfig, etc., is crucial for leveraging their type-safety benefits. Refer to your OpenAPI-generated client for the specific types to use.

Contributing

Contributions are welcome! Please feel free to submit issues and pull requests.

License

This project is licensed under the MIT License - see the LICENSE.md.

About

A MobX-based store implementation for OpenAPI generated clients

Resources

License

Code of conduct

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published