import "../../shared/overrides";
import {
FabricContextualizedArgs,
FabricContractAdapter,
} from "../ContractAdapter";
import {
Context as Ctx,
Contract,
Object as FabricObject,
} from "fabric-contract-api";
import { Model, Serializer } from "@decaf-ts/decorator-validation";
import {
AllOperationKeys,
AuthorizationError,
Condition,
ContextualArgs,
DirectionLimitOffset,
EventIds,
MaybeContextualArg,
MethodOrOperation,
Observer,
OrderDirection,
PersistenceKeys,
PersistenceObserver,
PreparedStatementKeys,
Repository,
SerializedPage,
} from "@decaf-ts/core";
import { FabricContractRepository } from "../FabricContractRepository";
import { DeterministicSerializer } from "../../shared/DeterministicSerializer";
import { MangoQuery } from "@decaf-ts/for-couchdb";
import { Checkable, healthcheck } from "../../shared/interfaces/Checkable";
import { Constructor } from "@decaf-ts/decoration";
import { FabricContractContext } from "../ContractContext";
import {
BulkCrudOperationKeys,
OperationKeys,
PrimaryKeyType,
InternalError,
} from "@decaf-ts/db-decorators";
import { MissingContextError } from "../../shared/index";
import { extractMspId } from "../../shared/decorators";
import { PACKAGE_NAME, VERSION } from "../../version";
FabricObject()(Date);
/**
* @description Base contract class for CRUD operations in Fabric chaincode
* @summary Provides standard create, read, update, and delete operations for models in Fabric chaincode
* @template M - Type extending Model
* @class FabricCrudContract
* @extends {Contract}
* @example
* ```typescript
* // Define a model
* @table('assets')
* class Asset extends Model {
* @id()
* id: string;
*
* @property()
* data: string;
* }
*
* // Create a contract that extends FabricCrudContract
* export class AssetContract extends FabricCrudContract<Asset> {
* constructor() {
* super('AssetContract', Asset);
* }
*
* // Add custom methods as needed
* async getAssetHistory(ctx: Context, id: string): Promise<any[]> {
* // Custom implementation
* }
* }
* ```
* @mermaid
* sequenceDiagram
* participant Client
* participant Contract
* participant Repository
* participant Adapter
* participant StateDB
*
* Client->>Contract: create(ctx, model)
* Contract->>Repository: repository(ctx)
* Contract->>Repository: create(model, ctx)
* Repository->>Adapter: create(tableName, id, record, transient, ctx)
* Adapter->>StateDB: putState(id, serializedData)
* StateDB-->>Adapter: Success
* Adapter-->>Repository: record
* Repository-->>Contract: model
* Contract-->>Client: model
*/
export abstract class FabricCrudContract<M extends Model>
extends Contract
implements
Checkable,
PersistenceObserver<FabricContractContext>,
Observer<
[
table: Constructor<M> | string,
event: AllOperationKeys,
id: EventIds,
...args: ContextualArgs<FabricContractContext>,
]
>
{
/**
* @description Shared adapter instance for all contract instances
*/
protected static adapter: FabricContractAdapter = new FabricContractAdapter();
protected static readonly serializer = new DeterministicSerializer();
protected initialized: boolean = false;
private _repo?: FabricContractRepository<M>;
protected get repo() {
if (!this._repo) {
this._repo = Repository.forModel(this.clazz);
try {
this.repo.observe(this);
} catch (err: unknown) {
if (
err instanceof InternalError &&
err.message.includes("Observer already registered")
) {
// already registered observer; ignore duplicate registration (during tests)
} else {
throw err;
}
}
}
return this._repo;
}
/**
* @description Creates a new FabricCrudContract instance
* @summary Initializes a contract with a name and model class
* @param {string} name - The name of the contract
* @param {Constructor<M>} clazz - The model constructor
*/
protected constructor(
name: string,
protected readonly clazz: Constructor<M>
) {
super(name);
}
async refresh(
table: Constructor<M> | string,
event: AllOperationKeys,
id: EventIds,
...args: ContextualArgs<FabricContractContext>
): Promise<void> {
const { log } = this.logCtx(args, this.refresh);
log.verbose(`Pushing ${event} event on table ${table}, id(s) ${id}`);
// do nothing. all we needed was an observer to kickstart the process
}
async listBy(
ctx: Ctx | FabricContractContext,
key: string | keyof M,
order: string,
...args: any[]
): Promise<M[] | string> {
const { ctxArgs, log } = (
await this.logCtx([...args, ctx], PreparedStatementKeys.LIST_BY, true)
).for(this.listBy);
log.info(
`Running listBy key ${key as string}, order ${order} and args ${ctxArgs}`
);
return this.repo.listBy(
key as keyof M,
order as OrderDirection,
...ctxArgs
);
}
async paginateBy(
ctx: Ctx | FabricContractContext,
key: string | keyof M,
order: string,
ref: Omit<DirectionLimitOffset, "direction"> | string = {
offset: 1,
limit: 10,
},
...args: any[]
): Promise<SerializedPage<M> | string> {
const { ctxArgs, log } = (
await this.logCtx([...args, ctx], PreparedStatementKeys.PAGE_BY, true)
).for(this.paginateBy);
log.info(
`Running paginateBy key ${key as string}, order ${order} with size ${(ref as any).limit} and args ${ctxArgs}`
);
return this.repo.paginateBy(
key as keyof M,
order as any,
ref as any,
...ctxArgs
);
}
async findOneBy(
ctx: Ctx | FabricContractContext,
key: string | keyof M,
value: any,
...args: any[]
): Promise<M | string> {
const { ctxArgs, log } = (
await this.logCtx([...args, ctx], PreparedStatementKeys.FIND_ONE_BY, true)
).for(this.findOneBy);
log.info(
`Running findOneBy key ${key as string}, value: ${value} with args ${ctxArgs}`
);
return this.repo.findOneBy(key as keyof M, value, ...ctxArgs);
}
async statement(
ctx: Ctx | FabricContractContext,
method: string,
...args: any[]
): Promise<any> {
const { ctxArgs, log } = (
await this.logCtx([...args, ctx], PersistenceKeys.STATEMENT, true)
).for(this.statement);
log.info(`Running statement ${method} with args ${ctxArgs}`);
return this.repo.statement(method, ...ctxArgs);
}
async countOf(
ctx: Ctx | FabricContractContext,
key?: string | keyof M,
...args: any[]
): Promise<number | string> {
const { ctxArgs, log } = (
await this.logCtx([...args, ctx], PreparedStatementKeys.COUNT_OF, true)
).for(this.countOf);
log.info(`Running countOf${key ? ` key ${key as string}` : ""}`);
return this.repo.countOf(key as keyof M | undefined, ...ctxArgs);
}
async maxOf(
ctx: Ctx | FabricContractContext,
key: string | keyof M,
...args: any[]
): Promise<M[keyof M] | string> {
const { ctxArgs, log } = (
await this.logCtx([...args, ctx], PreparedStatementKeys.MAX_OF, true)
).for(this.maxOf);
log.info(`Running maxOf key ${key as string}`);
return this.repo.maxOf(key as keyof M, ...ctxArgs);
}
async minOf(
ctx: Ctx | FabricContractContext,
key: string | keyof M,
...args: any[]
): Promise<M[keyof M] | string> {
const { ctxArgs, log } = (
await this.logCtx([...args, ctx], PreparedStatementKeys.MIN_OF, true)
).for(this.minOf);
log.info(`Running minOf key ${key as string}`);
return this.repo.minOf(key as keyof M, ...ctxArgs);
}
async avgOf(
ctx: Ctx | FabricContractContext,
key: string | keyof M,
...args: any[]
): Promise<number | string> {
const { ctxArgs, log } = (
await this.logCtx([...args, ctx], PreparedStatementKeys.AVG_OF, true)
).for(this.avgOf);
log.info(`Running avgOf key ${key as string}`);
return this.repo.avgOf(key as keyof M, ...ctxArgs);
}
async sumOf(
ctx: Ctx | FabricContractContext,
key: string | keyof M,
...args: any[]
): Promise<number | string> {
const { ctxArgs, log } = (
await this.logCtx([...args, ctx], PreparedStatementKeys.SUM_OF, true)
).for(this.sumOf);
log.info(`Running sumOf key ${key as string}`);
return this.repo.sumOf(key as keyof M, ...ctxArgs);
}
async distinctOf(
ctx: Ctx | FabricContractContext,
key: string | keyof M,
...args: any[]
): Promise<M[keyof M][] | string> {
const { ctxArgs, log } = (
await this.logCtx([...args, ctx], PreparedStatementKeys.DISTINCT_OF, true)
).for(this.distinctOf);
log.info(`Running distinctOf key ${key as string}`);
return this.repo.distinctOf(key as keyof M, ...ctxArgs);
}
async groupOf(
ctx: Ctx | FabricContractContext,
key: string | keyof M,
...args: any[]
): Promise<Record<string, M[]> | string> {
const { ctxArgs, log } = (
await this.logCtx([...args, ctx], PreparedStatementKeys.GROUP_OF, true)
).for(this.groupOf);
log.info(`Running groupOf key ${key as string}`);
return this.repo.groupOf(key as keyof M, ...ctxArgs);
}
/**
* @description Creates a single model in the state database
* @summary Delegates to the repository's create method
* @param {Ctx} ctx - The Fabric chaincode context
* @param {M} model - The model to create
* @param {...any[]} args - Additional arguments
* @return {Promise<M>} Promise resolving to the created model
*/
async create(
context: Ctx | FabricContractContext,
model: string | M,
...args: any[]
): Promise<string | M> {
const { log, ctxArgs, ctx } = (
await this.logCtx([...args, context], OperationKeys.CREATE, true)
).for(this.create);
this.ensureMirrorWritePermissions(ctx);
log.info(`CONTRACT CREATE, ${ctxArgs}`);
if (typeof model === "string") model = this.deserialize<M>(model) as M;
log.info(`Creating model: ${JSON.stringify(model)}`);
const transient = this.getTransientData(ctx);
log.info(`Merging transient data...`);
model = Model.merge(model, transient, this.clazz) as M;
const created = await this.repo.create(model, ...ctxArgs);
const result = new this.clazz(Model.segregate(created).model);
return result;
}
/**
* @description Reads a single model from the state database
* @summary Delegates to the repository's read method
* @param {Ctx} ctx - The Fabric chaincode context
* @param {string | number} key - The key of the model to read
* @param {...any[]} args - Additional arguments
* @return {Promise<M>} Promise resolving to the retrieved model
*/
async read(
ctx: Ctx | FabricContractContext,
key: PrimaryKeyType | string,
...args: any[]
): Promise<M | string> {
const { log, ctxArgs } = (
await this.logCtx([...args, ctx], OperationKeys.READ, true)
).for(this.create);
log.info(`reading entry with pk ${key} `);
return this.repo.read(key, ...ctxArgs);
}
protected getTransientData(ctx: FabricContractContext): any {
const transientMap = ctx.stub.getTransient();
let transient: any = {};
if (transientMap.has(this.repo["tableName"])) {
transient = JSON.parse(
(
transientMap.get(this.repo["tableName"]) as unknown as Buffer
)?.toString("utf8") as string
);
}
if (transientMap.has("__overrides")) {
const overrides = JSON.parse(
(
transientMap.get(this.repo["tableName"]) as unknown as Buffer
)?.toString("utf8") as string
);
ctx.accumulate(overrides);
}
return transient;
}
/**
* @description Updates a single model in the state database
* @summary Delegates to the repository's update method
* @param {Ctx} ctx - The Fabric chaincode context
* @param {M} model - The model to update
* @param {...any[]} args - Additional arguments
* @return {Promise<M>} Promise resolving to the updated model
*/
async update(
context: Ctx | FabricContractContext,
model: string | M,
...args: any[]
): Promise<string | M> {
const { log, ctxArgs, ctx } = (
await this.logCtx([...args, context], OperationKeys.UPDATE, true)
).for(this.update);
this.ensureMirrorWritePermissions(ctx);
if (typeof model === "string") model = this.deserialize<M>(model) as M;
log.info(`Updating model: ${JSON.stringify(model)}`);
const transient = this.getTransientData(ctx);
log.info(`Merging transient data...`);
model = Model.merge(model, transient, this.clazz) as M;
const updated = await this.repo.update(model, ...ctxArgs);
const result = new this.clazz(Model.segregate(updated).model);
return result;
}
/**
* @description Deletes a single model from the state database
* @summary Delegates to the repository's delete method
* @param {Ctx} ctx - The Fabric chaincode context
* @param {string | number} key - The key of the model to delete
* @param {...any[]} args - Additional arguments
* @return {Promise<M>} Promise resolving to the deleted model
*/
async delete(
ctx: Ctx | FabricContractContext,
key: PrimaryKeyType | string,
...args: any[]
): Promise<M | string> {
const {
log,
ctxArgs,
ctx: fabricCtx,
} = (await this.logCtx([...args, ctx], OperationKeys.DELETE, true)).for(
this.delete
);
this.ensureMirrorWritePermissions(fabricCtx);
log.info(`deleting entry with pk ${key} `);
return this.repo.delete(String(key), ...ctxArgs);
}
/**
* @description Deletes multiple models from the state database
* @summary Delegates to the repository's deleteAll method
* @param {string[] | number[]} keys - The keys of the models to delete
* @param {Ctx} ctx - The Fabric chaincode context
* @param {...any[]} args - Additional arguments
* @return {Promise<M[]>} Promise resolving to the deleted models
*/
async deleteAll(
ctx: Ctx | FabricContractContext,
keys: PrimaryKeyType[] | string,
...args: any[]
): Promise<M[] | string> {
const { ctxArgs, ctx: fabricCtx } = (
await this.logCtx([...args, ctx], BulkCrudOperationKeys.DELETE_ALL, true)
).for(this.deleteAll);
this.ensureMirrorWritePermissions(fabricCtx);
if (typeof keys === "string") keys = JSON.parse(keys) as string[];
const deleted = await this.repo.deleteAll(keys, ...ctxArgs);
const result = deleted.map((c) => new this.clazz(Model.segregate(c).model));
return result;
}
/**
* @description Reads multiple models from the state database
* @summary Delegates to the repository's readAll method
* @param {Ctx} ctx - The Fabric chaincode context
* @param {string[] | number[]} keys - The keys of the models to read
* @param {...any[]} args - Additional arguments
* @return {Promise<M[]>} Promise resolving to the retrieved models
*/
async readAll(
ctx: Ctx | FabricContractContext,
keys: PrimaryKeyType[] | string,
...args: any[]
): Promise<M[] | string> {
const { ctxArgs } = (
await this.logCtx([...args, ctx], BulkCrudOperationKeys.READ_ALL, true)
).for(this.create);
if (typeof keys === "string") keys = JSON.parse(keys) as string[];
return this.repo.readAll(keys, ...ctxArgs);
}
/**
* @description Updates multiple models in the state database
* @summary Delegates to the repository's updateAll method
* @param {Ctx} ctx - The Fabric chaincode context
* @param {M[]} models - The models to update
* @param {...any[]} args - Additional arguments
* @return {Promise<M[]>} Promise resolving to the updated models
*/
async updateAll(
context: Ctx | FabricContractContext,
models: string | M[],
...args: any[]
): Promise<string | M[]> {
const { log, ctxArgs, ctx } = (
await this.logCtx(
[...args, context],
BulkCrudOperationKeys.UPDATE_ALL,
true
)
).for(this.updateAll);
this.ensureMirrorWritePermissions(ctx);
if (typeof models === "string")
models = (JSON.parse(models) as [])
.map((m) => this.deserialize(m))
.map((m) => new this.clazz(m)) as any;
const transient = this.getTransientData(ctx);
log.info(`Merging transient data...`);
models = (models as M[]).map((m, i) => {
const t = Array.isArray(transient) ? transient[i] : transient;
return Model.merge(m, t || {}, this.clazz) as M;
});
log.info(`adding ${models.length} entries to the table`);
const updated = await this.repo.updateAll(
models as unknown as M[],
...ctxArgs
);
const result = updated.map((c) => new this.clazz(Model.segregate(c).model));
return result;
}
/**
* @description Executes a query with the specified conditions and options.
* @summary Provides a simplified way to query the database with common query parameters.
* @param {Condition<M>} condition - The condition to filter records.
* @param orderBy - The field to order results by.
* @param {OrderDirection} [order=OrderDirection.ASC] - The sort direction.
* @param {OrderDirection} [order=OrderDirection.ASC] - The sort direction.
* @param {number} [limit] - Optional maximum number of results to return.
* @param {number} [skip] - Optional number of results to skip.
* @return {Promise<M[]>} The query results as model instances.
*/
async query(
context: Ctx | FabricContractContext,
condition: Condition<M> | string,
orderBy: string | keyof M,
order: OrderDirection | string = OrderDirection.ASC,
limit?: number,
skip?: number,
...args: any[]
): Promise<M[] | string> {
const { ctxArgs } = (
await this.logCtx([...args, context], PersistenceKeys.QUERY, true)
).for(this.create);
return this.repo.query(
condition as Condition<M>,
orderBy as keyof M,
order as OrderDirection,
limit,
skip,
...ctxArgs
);
}
/**
* @description Executes a raw query against the state database
* @summary Delegates to the repository's raw method
* @param {Ctx} ctx - The Fabric chaincode context
* @param {any} rawInput - The query to execute
* @param {boolean} docsOnly - Whether to return only documents
* @param {...any[]} args - Additional arguments
* @return {Promise<any>} Promise resolving to the query results
*/
async raw(
ctx: Ctx | FabricContractContext,
rawInput: MangoQuery,
docsOnly: boolean,
...args: any[]
): Promise<any> {
const { ctxArgs } = (await this.logCtx([...args, ctx], "raw", true)).for(
this.raw
);
return FabricCrudContract.adapter.raw(rawInput, docsOnly, ...ctxArgs);
}
protected serialize(model: M): string {
return FabricCrudContract.serializer.serialize(model);
}
protected deserialize<M extends Model>(str: string): M {
return (
FabricCrudContract.serializer as unknown as Serializer<M>
).deserialize(str);
}
protected async init(ctx: Ctx | FabricContractContext): Promise<void> {
const { log } = (
await this.logCtx([ctx], PersistenceKeys.INITIALIZATION, true)
).for(this.init);
log.info(`Running contract ${this.getName()} initialization...`);
this.initialized = true;
log.info(`Contract initialization completed.`);
}
async healthcheck(
ctx: Ctx | FabricContractContext
): Promise<string | healthcheck> {
const { log } = (await this.logCtx([ctx], "healthcheck", true)).for(
this.healthcheck
);
log.info(`Running Healthcheck: ${this.initialized}...`);
return {
healthcheck: this.initialized,
version: VERSION,
package: PACKAGE_NAME,
};
}
/**
* @description Creates multiple models in the state database
* @summary Delegates to the repository's createAll method
* @param {Ctx} ctx - The Fabric chaincode context
* @param {M[]} models - The models to create
* @param {...any[]} args - Additional arguments
* @return {Promise<M[]>} Promise resolving to the created models
*/
async createAll(
context: Ctx | FabricContractContext,
models: string | M[],
...args: any[]
): Promise<string | M[]> {
const { log, ctxArgs, ctx } = (
await this.logCtx(
[...args, context],
BulkCrudOperationKeys.CREATE_ALL,
true
)
).for(this.createAll);
this.ensureMirrorWritePermissions(ctx);
if (typeof models === "string")
models = (JSON.parse(models) as [])
.map((m) => this.deserialize(m))
.map((m) => new this.clazz(m)) as any;
const transient = this.getTransientData(ctx);
log.info(`Merging transient data...`);
models = (models as M[]).map((m, i) => {
const t = Array.isArray(transient) ? transient[i] : transient;
return Model.merge(m, t || {}, this.clazz) as M;
});
log.info(`adding ${models.length} entries to the table`);
const created = await this.repo.createAll(
models as unknown as M[],
...ctxArgs
);
const result = created.map((c) => new this.clazz(Model.segregate(c).model));
return result;
}
protected ensureMirrorWritePermissions(ctx: FabricContractContext): void {
if (!ctx) return;
const mirrorMeta = Model.mirroredAt(this.clazz);
if (!mirrorMeta) return;
const msp = extractMspId(ctx.identity);
if (
msp &&
(msp === mirrorMeta.mspId ||
(mirrorMeta.condition && mirrorMeta.condition(msp)))
) {
throw new AuthorizationError(
`Organization ${msp} is not authorized to modify mirrored data`
);
}
}
protected logCtx<
ARGS extends any[] = any[],
METHOD extends MethodOrOperation = MethodOrOperation,
>(
args: MaybeContextualArg<FabricContractContext, ARGS>,
operation: METHOD
): FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>;
protected logCtx<
ARGS extends any[] = any[],
METHOD extends MethodOrOperation = MethodOrOperation,
>(
args: MaybeContextualArg<FabricContractContext, ARGS>,
operation: METHOD,
allowCreate: false
): FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>;
protected logCtx<
ARGS extends any[] = any[],
METHOD extends MethodOrOperation = MethodOrOperation,
>(
args: MaybeContextualArg<FabricContractContext, ARGS>,
operation: METHOD,
allowCreate: true
): Promise<
FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>
>;
protected logCtx<
ARGS extends any[] = any[],
METHOD extends MethodOrOperation = MethodOrOperation,
>(
args: MaybeContextualArg<FabricContractContext, ARGS>,
operation: METHOD,
allowCreate: boolean = false
):
| Promise<
FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>
>
| FabricContextualizedArgs<ARGS, METHOD extends string ? true : false> {
const ctx = args.pop();
if (!ctx || !ctx.stub) {
throw new MissingContextError(`No valid context provided...`);
}
const contextualized = FabricCrudContract.adapter["logCtx"](
[this.clazz as any, ...args] as any,
operation,
allowCreate as any,
ctx
) as
| FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>
| Promise<
FabricContextualizedArgs<ARGS, METHOD extends string ? true : false>
>;
function squashArgs(ctx: FabricContextualizedArgs) {
ctx.ctxArgs.shift(); // removes added model to args
return ctx as any;
}
if (!(contextualized instanceof Promise)) return squashArgs(contextualized);
return contextualized.then(squashArgs);
}
}
Source