import {
Adapter,
AdapterFlags,
AuthorizationError,
Condition,
ConnectionError,
Context,
ContextualArgs,
FlagsOf,
ForbiddenError,
MaybeContextualArg,
MigrationError,
ObserverError,
Paginator,
PagingError,
PersistenceKeys,
prepared,
PreparedModel,
PreparedStatement,
QueryError,
QueryOptions,
Repository,
Sequence,
SequenceOptions,
Statement,
UnsupportedError,
} from "@decaf-ts/core";
import {
BadRequestError,
BaseError,
ConflictError,
InternalError,
NotFoundError,
OperationKeys,
PrimaryKeyType,
SerializationError,
ValidationError,
wrapMethodWithContext,
} from "@decaf-ts/db-decorators";
import { HttpConfig, HttpFlags } from "./types";
import { Model } from "@decaf-ts/decorator-validation";
import {
apply,
Constructor,
Decoration,
Metadata,
methodMetadata,
} from "@decaf-ts/decoration";
import { RestService } from "./RestService";
import { toKebabCase } from "@decaf-ts/logging";
import { HttpStatement } from "./HttpStatement";
import { HttpPaginator } from "./HttpPaginator";
import { HttpDispatcher } from "./HttpDispatcher";
export function suffixMethod(
obj: any,
before: (...args: any[]) => any,
suffix: (...args: any[]) => any,
beforeName?: string
) {
const name = beforeName ? beforeName : before.name;
obj[name] = new Proxy(obj[name], {
apply: async (target, thisArg, argArray) => {
let results = target.call(thisArg, ...argArray);
if (results instanceof Promise) results = await results;
results = suffix.call(thisArg, results);
if (results instanceof Promise) results = await results;
return results;
},
});
}
/**
* @description Abstract HTTP adapter for REST API interactions
* @summary Provides a base implementation for HTTP adapters with methods for CRUD operations,
* URL construction, and error handling. This class extends the core Adapter class and
* implements the necessary methods for HTTP communication. Concrete implementations
* must provide specific HTTP client functionality.
* @template Y - The native HTTP client type
* @template Q - The query type used by the adapter
* @template F - The HTTP flags type, extending HttpFlags
* @template C - The context type, extending Context<F>
* @param {Y} native - The native HTTP client instance
* @param {HttpConfig} config - Configuration for the HTTP adapter
* @param {string} flavour - The adapter flavor identifier
* @param {string} [alias] - Optional alias for the adapter
* @class HttpAdapter
* @example
* ```typescript
* // Example implementation with Axios
* class AxiosAdapter extends HttpAdapter<AxiosInstance, AxiosRequestConfig> {
* constructor(config: HttpConfig) {
* super(axios.create(), config, 'axios');
* }
*
* async request<V>(details: AxiosRequestConfig): Promise<V> {
* const response = await this.native.request(details);
* return response.data;
* }
*
* // Implement other abstract methods...
* }
* ```
*/
export abstract class HttpAdapter<
CONF extends HttpConfig,
CON,
REQ,
Q extends PreparedStatement<any> = PreparedStatement<any>,
C extends Context<HttpFlags> = Context<HttpFlags>,
> extends Adapter<CONF, CON, Q, C> {
protected constructor(config: CONF, flavour: string, alias?: string) {
super(config, flavour, alias);
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
[
this.create,
this.read,
this.update,
this.delete,
this.createAll,
this.readAll,
this.updateAll,
this.deleteAll,
].forEach((method) => {
suffixMethod(
this,
method,
(res: any) =>
self.parseResponse.call(self, undefined, method.name, res),
method.name
);
});
wrapMethodWithContext(
this,
(...args: any[]) => args,
this.request,
(res: any, ctx: C) => {
const parsers = self.config.parsers;
if (!parsers) return res;
parsers.forEach((p) => p(res, ctx));
return res;
},
this.request.name
);
}
/**
* @description Generates operation flags with HTTP headers
* @summary Extends the base flags method to ensure HTTP headers exist on the flags payload.
* @template M - The model type
* @param {OperationKeys|string} operation - The type of operation being performed
* @param {Constructor | Constructor[]} model - The target model constructor(s)
* @param {Partial<FlagsOf<C>>} overrides - Optional flag overrides
* @param {...any[]} args - Additional arguments forwarded to the base implementation
* @return {Promise<FlagsOf<C>>} The flags object with headers
*/
protected override async flags<M extends Model>(
operation: OperationKeys | string,
model: Constructor<M> | Constructor<M>[],
overrides: Partial<FlagsOf<C>>
): Promise<FlagsOf<C>> {
return super.flags(
operation,
model,
Object.assign(
{
headers: overrides.headers ?? {},
},
overrides
)
);
}
protected override Dispatch(): any {
return new HttpDispatcher();
}
/**
* @description Returns the repository constructor for this adapter
* @summary Provides the RestService class as the repository implementation for this HTTP adapter.
* This method is used to create repository instances that work with this adapter type.
* @template R - Repository subtype working with this adapter
* @return {Constructor<R>} The repository constructor
*/
override repository<
R extends Repository<any, Adapter<CONF, CON, Q, C>>,
>(): Constructor<R> {
return RestService as unknown as Constructor<R>;
}
/**
* @description Prepares a model for persistence
* @summary Converts a model instance into a format suitable for database storage,
* handling column mapping and separating transient properties
* @template M - The model type
* @param {M} model - The model instance to prepare
* @param pk - The primary key property name
* @param args
* @return The prepared data
*/
override prepare<M extends Model>(
model: M,
...args: ContextualArgs<C>
): PreparedModel {
const { log } = this.logCtx(args, this.prepare);
const result = Object.assign({}, model);
if ((model as any)[PersistenceKeys.METADATA]) {
log.silly(
`Passing along persistence metadata for ${(model as any)[PersistenceKeys.METADATA]}`
);
Object.defineProperty(result, PersistenceKeys.METADATA, {
enumerable: false,
writable: false,
configurable: true,
value: (model as any)[PersistenceKeys.METADATA],
});
}
return {
record: model,
id: model[Model.pk(model.constructor as Constructor<M>)] as string,
};
}
/**
* @description Converts database data back into a model instance
* @summary Reconstructs a model instance from database data, handling column mapping
* and reattaching transient properties
* @template M - The model type
* @param obj - The database record
* @param {string|Constructor<M>} clazz - The model class or name
* @param pk - The primary key property name
* @param {string|number|bigint} id - The primary key value
* @return {M} The reconstructed model instance
*/
override revert<M extends Model>(
obj: Record<string, any>,
clazz: string | Constructor<M>,
id: PrimaryKeyType,
...args: ContextualArgs<C>
): M {
const { log } = this.logCtx(args, this.revert);
const ob: Record<string, any> = {};
const m = (
typeof clazz === "string" ? Model.build(ob, clazz) : new clazz(ob)
) as M;
log.silly(`Rebuilding model ${m.constructor.name} id ${id}`);
const constr = typeof clazz === "string" ? Model.get(clazz) : clazz;
if (!constr)
throw new InternalError(
`Failed to retrieve model constructor for ${clazz}`
);
const result = new (constr as Constructor<M>)(obj);
const metadata = obj[PersistenceKeys.METADATA];
if (metadata) {
log.silly(
`Passing along ${this.flavour} persistence metadata for ${m.constructor.name} id ${id}: ${metadata}`
);
Object.defineProperty(result, PersistenceKeys.METADATA, {
enumerable: false,
configurable: false,
writable: false,
value: metadata,
});
}
return result;
}
protected toTableName<M extends Model>(t: string | Constructor<M>) {
return typeof t === "string" ? t : toKebabCase(Model.tableName(t));
}
/**
* @description Constructs a URL for API requests
* @summary Builds a complete URL for API requests using the configured protocol and host,
* the specified table name, and optional query parameters. The method handles URL encoding.
* @param {string | Constructor} tableName - The name of the table or endpoint
* @return {string} The encoded URL string
*/
url<M extends Model>(tableName: string | Constructor<M>): string;
/**
* @description Constructs a URL for API requests
* @summary Builds a complete URL for API requests using the configured protocol and host,
* the specified table name, and optional query parameters. The method handles URL encoding.
* @param {string | Constructor} tableName - The name of the table or endpoint
* @param {string[]} pathParams - Optional query parameters
* @return {string} The encoded URL string
*/
url<M extends Model>(
tableName: string | Constructor<M>,
pathParams: string[]
): string;
/**
* @description Constructs a URL for API requests
* @summary Builds a complete URL for API requests using the configured protocol and host,
* the specified table name, and optional query parameters. The method handles URL encoding.
* @param {string | Constructor} tableName - The name of the table or endpoint
* @param {Record<string, string | number>} queryParams - Optional query parameters
* @return {string} The encoded URL string
*/
url<M extends Model>(
tableName: string | Constructor<M>,
queryParams: Record<string, string | number>
): string;
url<M extends Model>(
tableName: string | Constructor<M>,
pathParams: string[],
queryParams: Record<string, string | number>
): string;
/**
* @description Constructs a URL for API requests
* @summary Builds a complete URL for API requests using the configured protocol and host,
* the specified table name, and optional query parameters. The method handles URL encoding.
* @param {string | Constructor} tableName - The name of the table or endpoint
* @param {string[]} [pathParams] - Optional query parameters
* @param {Record<string, string | number>} [queryParams] - Optional query parameters
* @return {string} The encoded URL string
*/
url<M extends Model>(
tableName: string | Constructor<M>,
pathParams?: string[] | Record<string, string | number>,
queryParams?: Record<string, string | number>
): string {
if (!queryParams) {
if (pathParams && !Array.isArray(pathParams)) {
queryParams = pathParams;
pathParams = [];
}
}
tableName = this.toTableName(tableName);
const url = new URL(
`${this.config.protocol}://${this.config.host}/${tableName}${pathParams && pathParams.length ? `/${(pathParams as string[]).join("/")}` : ""}`
);
if (queryParams)
Object.entries(queryParams).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((v) => url.searchParams.append(key, v.toString()));
} else if (typeof value === "object") {
url.searchParams.append(key, JSON.stringify(value));
} else if (typeof value !== "undefined") {
url.searchParams.append(key, value.toString());
}
});
return url.toString();
}
abstract toRequest(query: Q): REQ;
abstract toRequest(ctx: C): REQ;
abstract toRequest(query: Q, ctx: C): REQ;
abstract toRequest(ctxOrQuery: C | Q, ctx?: C): REQ;
/**
* @description Sends an HTTP request
* @summary Abstract method that must be implemented by subclasses to send HTTP requests
* using the native HTTP client. This is the core method for making API calls.
* @template V - The response value type
* @param {REQ} details - The request details specific to the HTTP client
* @return {Promise<V>} A promise that resolves with the response data
*/
abstract request<V>(details: REQ, ...args: MaybeContextualArg<C>): Promise<V>;
protected extractIdArgs<M extends Model>(
model: Constructor<M> | string,
id: PrimaryKeyType
): string[] {
const idStr = id.toString();
if (typeof model === "string") return [idStr];
const composed = Model.composed(model, Model.pk(model));
if (!composed) return [idStr];
return idStr.split(composed.separator);
}
parseResponse<M extends Model>(
clazz: Constructor<M> | undefined,
method: OperationKeys | string,
res: any
): any {
if (clazz && Paginator.isSerializedPage(res))
return Object.assign({}, res, {
data: res.data.map((d: any) => new clazz(d)),
});
return res;
}
/**
* @description Creates a new resource
* @summary Abstract method that must be implemented by subclasses to create a new resource
* via HTTP. This typically corresponds to a POST request.
* @param {string} tableName - The name of the table or endpoint
* @param {string|number} id - The identifier for the resource
* @param {Record<string, any>} model - The data model to create
* @param {...any[]} args - Additional arguments
* @return {Promise<Record<string, any>>} A promise that resolves with the created resource
*/
abstract override create<M extends Model>(
tableName: Constructor<M>,
id: PrimaryKeyType,
model: Record<string, any>,
...args: ContextualArgs<C>
): Promise<Record<string, any>>;
/**
* @description Retrieves a resource by ID
* @summary Abstract method that must be implemented by subclasses to retrieve a resource
* via HTTP. This typically corresponds to a GET request.
* @param {string} tableName - The name of the table or endpoint
* @param {string|number|bigint} id - The identifier for the resource
* @param {...any[]} args - Additional arguments
* @return {Promise<Record<string, any>>} A promise that resolves with the retrieved resource
*/
abstract override read<M extends Model>(
tableName: Constructor<M>,
id: PrimaryKeyType,
...args: ContextualArgs<C>
): Promise<Record<string, any>>;
/**
* @description Updates an existing resource
* @summary Abstract method that must be implemented by subclasses to update a resource
* via HTTP. This typically corresponds to a PUT or PATCH request.
* @param {string} tableName - The name of the table or endpoint
* @param {string|number} id - The identifier for the resource
* @param {Record<string, any>} model - The updated data model
* @param {...any[]} args - Additional arguments
* @return {Promise<Record<string, any>>} A promise that resolves with the updated resource
*/
abstract override update<M extends Model>(
tableName: Constructor<M>,
id: string | number,
model: Record<string, any>,
...args: ContextualArgs<C>
): Promise<Record<string, any>>;
/**
* @description Deletes a resource by ID
* @summary Abstract method that must be implemented by subclasses to delete a resource
* via HTTP. This typically corresponds to a DELETE request.
* @param {string} tableName - The name of the table or endpoint
* @param {string|number|bigint} id - The identifier for the resource to delete
* @param {...any[]} args - Additional arguments
* @return {Promise<Record<string, any>>} A promise that resolves with the deletion result
*/
abstract override delete<M extends Model>(
tableName: Constructor<M>,
id: PrimaryKeyType,
...args: ContextualArgs<C>
): Promise<Record<string, any>>;
/**
* @description Executes a raw query
* @summary Method for executing raw queries directly with the HTTP client.
* This method is not supported by default in HTTP adapters and throws an UnsupportedError.
* Subclasses can override this method to provide implementation.
* @template R - The result type
* @param {Q} rawInput - The raw query input
* @param {boolean} process - Whether to process the result
* @param {...any[]} args - Additional arguments
* @return {Promise<R>} A promise that resolves with the query result
* @throws {UnsupportedError} Always throws as this method is not supported by default
*/
raw<R>(rawInput: Q, ...args: ContextualArgs<C>): Promise<R> {
const { ctxArgs, ctx } = this.logCtx(args, this.raw);
const req = this.toRequest(rawInput, ctx);
return this.request(req, ...ctxArgs);
}
/**
* @description Creates a sequence
* @summary Method for creating a sequence for generating unique identifiers.
* This method is not supported by default in HTTP adapters and throws an UnsupportedError.
* Subclasses can override this method to provide implementation.
* @param {SequenceOptions} options - Options for creating the sequence
* @return {Promise<Sequence>} A promise that resolves with the created sequence
* @throws {UnsupportedError} Always throws as this method is not supported by default
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
override Sequence(options: SequenceOptions): Promise<Sequence> {
return Promise.reject(
new UnsupportedError(
"Api is not natively available for HttpAdapters. If required, please extends this class"
)
);
}
/**
* @description Creates a statement for querying
* @summary Method for creating a statement for building and executing queries.
* This method is not supported by default in HTTP adapters and throws an UnsupportedError.
* Subclasses can override this method to provide implementation.
* @template M - The model type
* @template ! - The raw query type
* @return {Statement<Q, M, any>} A statement object for building queries
* @throws {UnsupportedError} Always throws as this method is not supported by default
*/
override Statement<M extends Model>(
overrides?: Partial<AdapterFlags>
): Statement<M, Adapter<CONF, CON, Q, C>, any> {
return new HttpStatement(this, overrides);
}
override Paginator<M extends Model>(
query: Q,
size: number,
clazz: Constructor<M>
): Paginator<M, M, Q> {
return new HttpPaginator<M, Q, HttpAdapter<CONF, CON, REQ, Q, C>>(
this,
query,
size,
clazz
) as any;
}
/**
* @description Parses a condition into a query
* @summary Method for parsing a condition object into a query format understood by the HTTP client.
* This method is not supported by default in HTTP adapters and throws an UnsupportedError.
* Subclasses can override this method to provide implementation.
* @param {Condition<any>} condition - The condition to parse
* @return {Q} The parsed query
* @throws {UnsupportedError} Always throws as this method is not supported by default
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
parseCondition(condition: Condition<any>): Q {
throw new UnsupportedError(
"Api is not natively available for HttpAdapters. If required, please extends this class"
);
}
static parseError<E extends BaseError>(
err: Error | string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
...args: any[]
): E {
const msg = typeof err === "string" ? err : err.message;
if (msg.includes(NotFoundError.name) || msg.includes("404"))
return new NotFoundError(err) as E;
if (msg.includes(ConflictError.name) || msg.includes("409"))
return new ConflictError(err) as E;
if (msg.includes(BadRequestError.name) || msg.includes("400"))
return new BadRequestError(err) as E;
if (msg.includes(ValidationError.name) || msg.includes("422"))
return new ValidationError(err) as E;
if (msg.includes(QueryError.name)) return new QueryError(err) as E;
if (msg.includes(PagingError.name)) return new PagingError(err) as E;
if (msg.includes(UnsupportedError.name))
return new UnsupportedError(err) as E;
if (msg.includes(MigrationError.name)) return new MigrationError(err) as E;
if (msg.includes(ObserverError.name)) return new ObserverError(err) as E;
if (msg.includes(AuthorizationError.name))
return new AuthorizationError(err) as E;
if (msg.includes(ForbiddenError.name)) return new ForbiddenError(err) as E;
if (msg.includes(ConnectionError.name))
return new ConnectionError(err) as E;
if (msg.includes(SerializationError.name))
return new SerializationError(err) as E;
return new InternalError(err) as E;
}
static override decoration() {
super.decoration();
function query(options: QueryOptions) {
return function query(obj: object, prop?: any, descriptor?: any) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function innerQuery(options: QueryOptions) {
return function innerQuery(
obj: any,
propertyKey?: any,
descriptor?: any
) {
(descriptor as TypedPropertyDescriptor<any>).value = new Proxy(
(descriptor as TypedPropertyDescriptor<any>).value,
{
async apply(
target: any,
thisArg: any,
args: any[]
): Promise<any> {
const repo = thisArg as Repository<any, any>;
const { log, ctxArgs } = (
await repo["logCtx"](args, OperationKeys.READ, true)
).for(prop);
log.verbose(`Running prepared statement ${target.name}`);
log.debug(`With args: ${JSON.stringify(args, null, 2)}`);
return (thisArg as Repository<any, any>).statement(
target.name,
...ctxArgs
);
},
}
);
};
}
return apply(
methodMetadata(Metadata.key(PersistenceKeys.QUERY, prop), options),
prepared(),
innerQuery(options)
)(obj, prop, descriptor);
};
}
Decoration.for(PersistenceKeys.QUERY)
.define({
decorator: query,
} as any)
.apply();
}
}
HttpAdapter.decoration();
Source