@intuitionrobotics/db-api-generator
Version:
481 lines • 20.8 kB
JavaScript
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