UNPKG

@intuitionrobotics/db-api-generator

Version:
481 lines 20.8 kB
import {} from "@intuitionrobotics/firebase"; import { __stringify, _keys, addItemToArray, BadImplementationException, batchAction, Day, filterDuplicates, filterInstances, generateHex, isErrorOfType, merge, Module, ThisShouldNotHappenException, validate, validateRegexp, ValidationException, validateEmail as _validateEmail } from "@intuitionrobotics/ts-common"; import { ServerApi_Create, ServerApi_Delete, ServerApi_Query, ServerApi_Unique, ServerApi_Update } from "./apis.js"; import { ApiException, ServerApi } from "@intuitionrobotics/thunderstorm/backend"; import { FirebaseModule, FirestoreCollection, FirestoreInterface, FirestoreTransaction, } from "@intuitionrobotics/firebase/backend"; import { ErrorKey_BadInput } from "../shared/types.js"; const idLength = 32; export const validateId = (length, mandatory = true) => validateRegexp(new RegExp(`^[0-9a-f]{${length}}$`), mandatory); export const validateEmail = _validateEmail; export const validateBucketUrl = (mandatory) => validateRegexp(/gs?:\/\/[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/, mandatory); export const validateGeneralUrl = (mandatory) => validateRegexp(/[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/, mandatory); export const validateUniqueId = validateId(idLength); export const validateOptionalId = validateId(idLength, false); export const validateStringWithDashes = validateRegexp(/^[A-Za-z-]+$/); export const validateStringAndNumbersWithDashes = validateRegexp(/^[0-9A-Za-z-]+$/); export const validator_JavaObjectMemberName = validateRegexp(/^[a-z][a-zA-Z0-9]+$/); export const validateNameWithDashesAndDots = validateRegexp(/^[a-z-.]+$/); export const validator_LowercaseStringWithDashes = validateRegexp(/^[a-z-.]+$/); export const validator_LowerUpperStringWithSpaces = validateRegexp(/^[A-Za-z ]+$/); export const validator_LowerUpperStringWithDashesAndUnderscore = validateRegexp(/^[A-Za-z-_]+$/); export const validator_InternationalPhoneNumber = validateRegexp(/^\+(?:[0-9] ?){6,14}[0-9]$/); /** * An abstract base class used for implementing CRUD operations on a specific collection. * * By default, it exposes API endpoints for creating, deleting, updating, querying and querying for unique document. */ export class BaseDB_ApiGenerator extends Module { _collection; validator; /** * The collection reference, resolved lazily on first access (was init()) — * the admin session/Firestore client only materialize when actually used. */ get collection() { return this._collection ??= FirebaseModule.createAdminSession(this.config?.projectId).getFirestore() .getCollection(this.config.collectionName, this.config.externalFilterKeys); } constructor(collectionName, validator, itemName, moduleName) { super(moduleName); // @ts-expect-error TS struggles with this dynamic typing this.setDefaultConfig({ itemName, collectionName, externalFilterKeys: ["_id"], lockKeys: ["_id"] }); this.validator = validator; } setValidator(validator) { this.validator = validator; } __onFirestoreBackupSchedulerAct() { return [{ backupQuery: this.resolveBackupQuery(), collection: this.collection, keepInterval: 7 * Day, interval: Day, moduleKey: this.config.collectionName }]; } resolveBackupQuery() { return { where: {} }; } // this.setExternalUniqueKeys(["accessLevelIds"]); /** * Sets the external unique keys. External keys are the attributes of a document that must be unique inside the * collection. Default is `_id`. * * @remarks * You can only update the external unique keys before the module is initialized, preferably from its constructor. * * @param keys - The external unique keys. * * @returns * The external filter keys. */ setExternalUniqueKeys(keys) { if (this._collection) throw new BadImplementationException("You can only update the 'externalUniqueKeys' before the collection was resolved.. preferably from the constructor"); // goes through setConfig (not a direct config mutation) so it survives lazy config re-resolution this.setConfig({ externalFilterKeys: keys }); return keys; } /** * Sets the lock keys. Lock keys are the attributes of a document that must not be changed during a patch. * Thr property `_id` is always part of the lock keys. * * @remarks * You can only update the lock keys before the module is initialized, preferably from its constructor. * * @param keys - The lock keys. * * @returns * The lock keys. */ setLockKeys(keys) { if (this._collection) throw new BadImplementationException("You can only update the 'lockKeys' before the collection was resolved.. preferably from the constructor"); const lockKeys = filterDuplicates([...keys, "_id"]); // goes through setConfig (not a direct config mutation) so it survives lazy config re-resolution this.setConfig({ lockKeys }); return lockKeys; } getCollectionName() { return this.config.collectionName; } getItemName() { return this.config.itemName; } async assertExternalQueryUnique(instance, transaction) { const dbInstance = await transaction.queryItem(this.collection, instance); if (!dbInstance) { const uniqueQuery = FirestoreInterface.buildUniqueQuery(this.collection, instance); throw new ApiException(404, `Could not find ${this.config.itemName} with unique query '${__stringify(uniqueQuery)}'`); } return dbInstance; } /** * Asserts the uniqueness of an instance in two steps: * - Executes `this.preUpsertProcessing`. * - Asserts uniqueness based on the internal filters. * * @param transaction - The transaction object. * @param instance - The document for which the uniqueness assertion will occur. */ async assertUniqueness(transaction, instance, request) { await this.preUpsertProcessing(transaction, instance, request); const uniqueQueries = this.internalFilter(instance); if (uniqueQueries.length === 0) return; const dbInstances = await Promise.all(uniqueQueries.map(uniqueQuery => { return transaction.queryUnique(this.collection, { where: uniqueQuery }); })); for (const idx in dbInstances) { const dbInstance = dbInstances[idx]; if (!dbInstance || dbInstance._id === instance._id) continue; const query = uniqueQueries[idx]; const message = _keys(query).reduce((carry, key) => { return carry + "\n" + `${String(key)}: ${query[key]}`; }, `${this.config.itemName} uniqueness violation. There is already a document with`); this.logWarning(message); throw new ApiException(422, message); } } /** * Runs the module's validator for the instance. * * @param instance - The object to be validated. * * @throws `ApiException` for bad implementation or invalid input. */ validateImpl(instance) { try { validate(instance, this.validator); } catch (e) { const badImplementation = isErrorOfType(e, BadImplementationException); if (badImplementation) throw new ApiException(500, badImplementation.message); const error = isErrorOfType(e, ValidationException); if (error) { const errorBody = { type: ErrorKey_BadInput, body: { path: error.path, input: error.input } }; throw new ApiException(400, error.message).setErrorBody(errorBody); } throw e; } } /** * Override this method to return a list of "where" queries that dictate uniqueness inside the collection. * Example return value: [{attribute1: item.attribute1, attribute2: item.attribute2}]. * * @param item - The DB entry that will be used. */ internalFilter(_item) { return []; } /** * Override this method to customize the assertions that should be done before the insertion of the document to the DB. * * @param transaction - The transaction object. * @param dbInstance - The DB entry for which the uniqueness is being asserted. */ async preUpsertProcessing(_transaction, _dbInstance, _request) { } /** * Override this method to provide actions or assertions to be executed before the deletion happens. * * Currently executed only before `deleteUnique()`. * * @param transaction - The transaction object * @param dbInstance - The DB entry that is going to be deleted. */ async assertDeletion(transaction, dbInstance, request) { return (await this.assertDeletion_Read(transaction, dbInstance, request))(); } async assertDeletion_Read(_transaction, _dbInstance, _request) { return async () => { }; } /** * A wrapper of the collections's `runInTransaction`. * * @param processor - The transaction's processor. * * @returns * A promise of the result of the `processor`. */ async runInTransaction(processor) { return this.collection.runInTransaction(processor); } // @ts-expect-error TS struggles with this dynamic typing async deleteCollection() { await this.collection.deleteAll(); } /** * Inserts the `instance` using the `transaction` object. * * @param transaction - The transaction object. * @param instance - The object to be inserted. * @param request - The request in order to possibly obtain more info. * * @returns * A promise of the document that was inserted. */ // private async createImpl(transaction: FirestoreTransaction, instance: DBType, request?: ExpressRequest): Promise<DBType> { // return (await this.createImpl_Read(transaction, instance, request))() // }; async createImpl_Read(transaction, instance, request) { await this.validateImpl(instance); await this.assertUniqueness(transaction, instance, request); return () => transaction.insert(this.collection, instance); } ; /** * Upserts the `instance` using a transaction, after validating it and asserting uniqueness. * * @param instance - The object to be upserted. * @param transaction - OPTIONAL transaction to perform the upsert operation on * @param request - The request in order to possibly obtain more info. * * @returns * A promise of the document that was upserted. */ async upsert(instance, transaction, request) { const processor = async (_transaction) => { return (await this.upsert_Read(instance, _transaction, request))(); }; if (transaction) return processor(transaction); return this.collection.runInTransaction(processor); } async upsert_Read(instance, transaction, request) { if (instance._id === undefined) return this.createImpl_Read(transaction, { ...instance, _id: generateHex(idLength) }, request); return this.upsertImpl_Read(transaction, instance, request); } /** * Upserts a set of objects. Batching is used to circumvent firestore limitations on the number of objects. * * @param instances - The objects to be upserted. * @param request - The request in order to possibly obtain more info. * * @returns * A promise of an array of documents that were upserted. */ async upsertAll_Batched(instances, request) { return batchAction(instances, 500, async (chunked) => this.upsertAll(chunked, undefined, request)); } /** * Upserts the `dbInstances` using the `transaction` object. * * @param transaction - The transaction object. * @param instances - The instances to update. * @param request - The request in order to possibly obtain more info. * * @throws `BadImplementationException` when the instances are more than 500. * * @returns * A promise of the array of documents that were upserted. */ async upsertAll(instances, transaction, request) { if (instances.length > 500) { if (transaction) throw new BadImplementationException('Firestore transaction supports maximum 500 at a time'); return this.upsertAll_Batched(instances, request); } const processor = async (_transaction) => { const writes = await Promise.all(await this.upsertAllImpl_Read(instances, _transaction, request)); return writes.map(write => write()); }; if (transaction) return processor(transaction); return this.collection.runInTransaction(processor); } async upsertAllImpl_Read(instances, transaction, request) { const actions = []; instances.reduce((carry, instance) => { addItemToArray(carry, this.upsert_Read(instance, transaction, request)); return carry; }, actions); return Promise.all(actions); } /** * Upserts the `dbInstance` using the `transaction` transaction object. * * @param transaction - The transaction object. * @param dbInstance - The object to be upserted. * @param request - The request in order to possibly obtain more info. * * @returns * A promise of the document that was upserted. */ async upsertImpl(transaction, dbInstance, request) { return (await this.upsertImpl_Read(transaction, dbInstance, request))(); } ; async upsertImpl_Read(transaction, dbInstance, request) { await this.validateImpl(dbInstance); await this.assertUniqueness(transaction, dbInstance, request); return transaction.upsert_Read(this.collection, dbInstance); } ; /** * Deletes a unique document based on its `_id`. Uses a transaction, after deletion assertions occur. * * @param _id - The _id of the object to be deleted. * @param transaction * @param request - The request in order to possibly obtain more info. * * @throws `ApiException` when the document doesn't exist in the collection. * * @returns * A promise of the document that was deleted. */ async deleteUnique(_id, transaction, request) { if (!_id) throw new BadImplementationException(`No _id for deletion provided.`); const processor = async (_transaction) => { const write = await this.deleteUnique_Read(_id, _transaction, request); if (!write) throw new ApiException(404, `Could not find ${this.config.itemName} with unique id: ${_id}`); return write(); }; if (transaction) return processor(transaction); return this.collection.runInTransaction(processor); } async deleteUnique_Read(_id, transaction, request) { if (!_id) throw new BadImplementationException(`No _id for deletion provided.`); const ourQuery = { where: { _id } }; const dbInstance = await transaction.queryUnique(this.collection, ourQuery); if (!dbInstance) throw new ApiException(404, `Could not find ${this.config.itemName} with unique id: ${_id}`); const write = await this.deleteImpl_Read(transaction, ourQuery, request); return async () => { if (!write) return dbInstance; // Here can do both read an write! await this.assertDeletion(transaction, dbInstance, request); return write(); }; } /** * Uses the `transaction` to delete a unique document, querying with the `ourQuery`. * * @param transaction - The transaction object. * @param ourQuery - The query to be used for the deletion. * @param request - The request in order to possibly obtain more info. * * @returns * A promise of the document that was deleted. */ async deleteImpl_Read(transaction, ourQuery, _request) { const write = await transaction.deleteUnique_Read(this.collection, ourQuery); if (!write) throw new ThisShouldNotHappenException(`I just checked that I had an instance for query: ${__stringify(ourQuery)}`); return write; } /** * Calls the `delete` method of the module's collection. * * @param query - The query to be executed for the deletion. * @param request - The request in order to possibly obtain more info. */ async delete(query, _request) { return this.collection.delete(query); } /** * Queries the database for a specific document in the module's collection. * * @param where - The where clause to be used for querying. * @param request - The request in order to possibly obtain more info. * * @throws `ApiException` if the document is not found. * * @returns * The DB document that was found. */ async queryUnique(where, _request) { const dbItem = await this.collection.queryUnique({ where }); if (!dbItem) throw new ApiException(404, `Could not find ${this.config.itemName} with unique query: ${JSON.stringify(where)}`); return dbItem; } /** * Executes the specified query on the module's collection. * * @param query - The query to be executed. * @param request - The request in order to possibly obtain more info. * * @returns * A promise of an array of documents. */ async query(query, _request) { return await this.collection.query(query); } /** * If propsToPatch is not set, we remove the lock keys from the caller's instance * before merging with the original dbInstance. * If propsToPatch is set, we also remove all of the instance's keys that * are not specified in propsToPatch. * * @param instance - The instance to be upserted. * @param propsToPatch - Properties to patch. * @param request - The request in order to possibly obtain more info. * * @returns * A promise of the patched document. */ async patch(instance, propsToPatch, request) { return this.collection.runInTransaction(async (transaction) => { const dbInstance = await this.assertExternalQueryUnique(instance, transaction); // If the caller has specified props to be changed, make sure the don't conflict with the lockKeys. const wrongKey = propsToPatch?.find(prop => this.config.lockKeys.includes(prop)); if (wrongKey) throw new BadImplementationException(`Key ${String(wrongKey)} is part of the 'lockKeys' and cannot be updated.`); // If the caller has not specified props, we remove the keys from the caller's instance // before merging with the original dbInstance. _keys(instance).forEach(key => { if (this.config.lockKeys.includes(key) || (propsToPatch && !propsToPatch.includes(key))) { delete instance[key]; } }); const mergedObject = merge(dbInstance, instance); await validate(mergedObject, this.validator); await this.assertUniqueness(transaction, mergedObject, request); return this.upsertImpl(transaction, mergedObject, request); }); } apiCreate(pathPart) { return new ServerApi_Create(this, pathPart); } apiQuery(pathPart) { return new ServerApi_Query(this, pathPart); } apiQueryUnique(pathPart) { return new ServerApi_Unique(this, pathPart); } apiUpdate(pathPart) { return new ServerApi_Update(this, pathPart); } apiDelete(pathPart) { return new ServerApi_Delete(this, pathPart); } /** * Override this method, to control which server api endpoints are created automatically. * * @param pathPart - The path part. * * @returns * An array of api endpoints. */ apis(pathPart) { return filterInstances([ this.apiCreate(pathPart), this.apiQuery(pathPart), this.apiQueryUnique(pathPart), this.apiUpdate(pathPart), this.apiDelete(pathPart), ]); } } //# sourceMappingURL=BaseDB_ApiGenerator.js.map