import app from '../common/app'; import { FlarumRequestOptions } from './Application'; import Model, { ModelData, SavedModelData } from './Model'; export interface MetaInformation { [key: string]: any; } export interface ApiQueryParamsSingle { fields?: string[]; include?: string; bySlug?: boolean; meta?: MetaInformation; } export interface ApiQueryParamsPlural { fields?: string[]; include?: string; filter?: { q: string; [key: string]: string; }; page?: { offset?: number; number?: number; limit?: number; size?: number; }; sort?: string; meta?: MetaInformation; } export type ApiQueryParams = ApiQueryParamsPlural | ApiQueryParamsSingle; export interface ApiPayloadSingle { data: SavedModelData; included?: SavedModelData[]; meta?: MetaInformation; } export interface ApiPayloadPlural { data: SavedModelData[]; included?: SavedModelData[]; links?: { first: string; next?: string; prev?: string; }; meta?: MetaInformation; } export type ApiPayload = ApiPayloadSingle | ApiPayloadPlural; export type ApiResponseSingle = M & { payload: ApiPayloadSingle }; export type ApiResponsePlural = M[] & { payload: ApiPayloadPlural }; export type ApiResponse = ApiResponseSingle | ApiResponsePlural; interface ApiQueryRequestOptions extends Omit, 'url'> {} interface StoreData { [type: string]: Partial>; } export function payloadIsPlural(payload: ApiPayload): payload is ApiPayloadPlural { return Array.isArray((payload as ApiPayloadPlural).data); } /** * The `Store` class defines a local data store, and provides methods to * retrieve data from the API. */ export default class Store { /** * The local data store. A tree of resource types to IDs, such that * accessing data[type][id] will return the model for that type/ID. */ protected data: StoreData = {}; /** * The model registry. A map of resource types to the model class that * should be used to represent resources of that type. */ models: Record; constructor(models: Record) { this.models = models; } /** * Push resources contained within an API payload into the store. * * @return The model(s) representing the resource(s) contained * within the 'data' key of the payload. */ pushPayload(payload: ApiPayloadSingle): ApiResponseSingle; pushPayload(payload: ApiPayloadPlural): ApiResponseSingle; pushPayload(payload: ApiPayload): ApiResponse> { if (payload.included) payload.included.map(this.pushObject.bind(this)); const models = payload.data instanceof Array ? payload.data.map((o) => this.pushObject(o, false)) : this.pushObject(payload.data, false); const result = models as ApiResponse>; // Attach the original payload to the model that we give back. This is // useful to consumers as it allows them to access meta information // associated with their request. result.payload = payload; return result; } /** * Create a model to represent a resource object (or update an existing one), * and push it into the store. * * @param data The resource object * @return The model, or null if no model class has been * registered for this resource type. */ pushObject(data: SavedModelData): M | null; pushObject(data: SavedModelData, allowUnregistered: false): M; pushObject(data: SavedModelData, allowUnregistered = true): M | null { if (!this.models[data.type]) { if (allowUnregistered) { return null; } throw new Error(`Cannot push object of type ${data.type}, as that type has not yet been registered in the store.`); } const type = (this.data[data.type] = this.data[data.type] || {}); // Necessary for TS to narrow correctly. const curr = type[data.id] as M; const instance = curr ? curr.pushData(data) : this.createRecord(data.type, data); type[data.id] = instance; instance.exists = true; return instance; } /** * Make a request to the API to find record(s) of a specific type. */ async find(type: string, params: ApiQueryParamsSingle): Promise>; async find(type: string, params: ApiQueryParamsPlural): Promise>; async find( type: string, id: string, params?: ApiQueryParamsSingle, options?: ApiQueryRequestOptions ): Promise>; async find( type: string, ids: string[], params?: ApiQueryParamsPlural, options?: ApiQueryRequestOptions ): Promise>; async find( type: string, idOrParams: string | string[] | ApiQueryParams, query: ApiQueryParams = {}, options: ApiQueryRequestOptions ? ApiPayloadPlural : ApiPayloadSingle> = {} ): Promise>> { let params = query; let url = app.forum.attribute('apiUrl') + '/' + type; if (idOrParams instanceof Array) { url += '?filter[id]=' + idOrParams.join(','); } else if (typeof idOrParams === 'object') { params = idOrParams; } else if (idOrParams) { url += '/' + idOrParams; } return app .request ? ApiPayloadPlural : ApiPayloadSingle>({ method: 'GET', url, params, ...options, }) .then((payload) => { if (payloadIsPlural(payload)) { return this.pushPayload[]>(payload); } else { return this.pushPayload>(payload); } }); } /** * Get a record from the store by ID. */ getById(type: string, id: string): M | undefined { return this.data?.[type]?.[id] as M; } /** * Get a record from the store by the value of a model attribute. * * @param type The resource type. * @param key The name of the method on the model. * @param value The value of the model attribute. */ getBy(type: string, key: keyof M, value: T): M | undefined { // @ts-expect-error No way to do this safely, unfortunately. return this.all(type).filter((model) => model[key]() === value)[0] as M; } /** * Get all loaded records of a specific type. */ all(type: string): M[] { const records = this.data[type]; return records ? (Object.values(records) as M[]) : []; } /** * Remove the given model from the store. */ remove(model: Model): void { delete this.data[model.data.type as string][model.id() as string]; } /** * Create a new record of the given type. * * @param type The resource type * @param data Any data to initialize the model with */ createRecord(type: string, data: ModelData = {}): M { data.type = data.type || type; // @ts-expect-error this will complain about initializing abstract models, // but we can safely assume that all models registered with the store are // not abstract. return new this.models[type](data, this); } }