217 lines
7.6 KiB
TypeScript
217 lines
7.6 KiB
TypeScript
/**
|
|
* `createContactsPlugin` — `PluginDescriptor` factory for
|
|
* `@suaveplan/api`. Validates options, constructs the three SurrealDB
|
|
* repositories, registers them in the host `ServiceRegistry` + Hono
|
|
* `ContextVariableMap`, applies the bundled SurrealQL schema, and
|
|
* mounts the `/contacts` + `/groups` route trees via
|
|
* `definePluginRoutes`.
|
|
*/
|
|
|
|
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
import { definePluginRoutes } from "@suaveplan/api-openapi";
|
|
import {
|
|
definePlugin,
|
|
type PluginContext,
|
|
type PluginDescriptor,
|
|
} from "@suaveplan/api-plugin";
|
|
import {
|
|
CONTACTS_API_CODES,
|
|
ContactsApiError,
|
|
} from "../error-codes/error-codes.js";
|
|
import {
|
|
SurrealContactRepository,
|
|
SurrealGroupRepository,
|
|
SurrealMembershipRepository,
|
|
} from "../repositories/index.js";
|
|
import { buildContactRoutes } from "../routes/contacts/build-routes.js";
|
|
import { buildGroupRoutes } from "../routes/groups/build-routes.js";
|
|
import { ContactsPluginOptionsSchema } from "../schemas/options.js";
|
|
import type {
|
|
ContactsPluginConfig,
|
|
ContactsPluginOptions,
|
|
} from "../types/types.js";
|
|
import { applyContactsSchema } from "./schema.js";
|
|
import "../context/context.js";
|
|
import "../services/registry-augmentation.js";
|
|
|
|
/**
|
|
* Factory — returns the typed `PluginDescriptor`.
|
|
*
|
|
* Zod-validates `options` at construction time; invalid options
|
|
* surface as `ContactsApiError(INVALID_INPUT)` with the `ZodError`
|
|
* carried as `cause`. The descriptor declares
|
|
* `dependencies: ["database", "auth"]` so the host topo-sorts after
|
|
* `@suaveplan/api-plugin-database` and `@suaveplan/api-plugin-auth`.
|
|
*/
|
|
export function createContactsPlugin(
|
|
options: ContactsPluginOptions = {}
|
|
): PluginDescriptor<ContactsPluginConfig> {
|
|
const parsed = ContactsPluginOptionsSchema.safeParse(options);
|
|
if (!parsed.success) {
|
|
throw new ContactsApiError(
|
|
CONTACTS_API_CODES.INVALID_INPUT,
|
|
"createContactsPlugin received invalid options",
|
|
{ cause: parsed.error, context: { statusCode: 500 } }
|
|
);
|
|
}
|
|
const resolved: ContactsPluginConfig = {
|
|
routePrefix: parsed.data.routePrefix,
|
|
maxImportBatchSize: parsed.data.maxImportBatchSize,
|
|
maxGroupDepth: parsed.data.maxGroupDepth,
|
|
enforceGroupDepth: parsed.data.enforceGroupDepth,
|
|
applySchemaOnInit: parsed.data.applySchemaOnInit,
|
|
...(parsed.data.logger !== undefined
|
|
? { logger: parsed.data.logger }
|
|
: {}),
|
|
};
|
|
|
|
return definePlugin<ContactsPluginConfig>({
|
|
name: "contacts",
|
|
version: "0.1.0",
|
|
description:
|
|
"Contacts and groups HTTP plugin for @suaveplan/api. Mounts /contacts and /groups on a Hono OpenAPIHono backed by SurrealDB.",
|
|
dependencies: ["database", "auth"],
|
|
defaults: resolved,
|
|
tags: ["contacts", "groups", "audience"],
|
|
onInit: async (ctx) => runOnInit(ctx, resolved),
|
|
});
|
|
}
|
|
|
|
export async function runOnInit(
|
|
ctx: PluginContext<ContactsPluginConfig>,
|
|
config: ContactsPluginConfig
|
|
): Promise<void> {
|
|
const app = ctx.getService("app");
|
|
if (app === undefined) {
|
|
throw new ContactsApiError(
|
|
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
|
|
"contacts plugin requires the `app` service in ServiceRegistry",
|
|
{ context: { missing: ["app"], statusCode: 500 } }
|
|
);
|
|
}
|
|
const db = ctx.getService("db");
|
|
if (db === undefined) {
|
|
throw new ContactsApiError(
|
|
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
|
|
"contacts plugin requires the `db` service — load @suaveplan/api-plugin-database first",
|
|
{ context: { missing: ["db"], statusCode: 500 } }
|
|
);
|
|
}
|
|
const openAPIRegistry = ctx.getService("openAPIRegistry");
|
|
const migrationRunner = ctx.getService("migrationRunner");
|
|
const logger = config.logger ?? ctx.logger;
|
|
|
|
if (config.applySchemaOnInit) {
|
|
await applyContactsSchema({
|
|
...(migrationRunner !== undefined ? { migrationRunner } : {}),
|
|
db,
|
|
});
|
|
}
|
|
|
|
const contactRepository = new SurrealContactRepository(db);
|
|
const groupRepository = new SurrealGroupRepository(db, {
|
|
maxGroupDepth: config.maxGroupDepth,
|
|
enforceGroupDepth: config.enforceGroupDepth,
|
|
});
|
|
const membershipRepository = new SurrealMembershipRepository(db);
|
|
|
|
ctx.registerService("contactRepository", contactRepository);
|
|
ctx.registerService("groupRepository", groupRepository);
|
|
ctx.registerService("membershipRepository", membershipRepository);
|
|
|
|
// Inject the repositories + eventBus into every Hono request context.
|
|
const eventBus = ctx.eventBus;
|
|
const prefix = config.routePrefix;
|
|
const mountPattern = prefix === "/" ? "*" : `${prefix}/*`;
|
|
app.use(mountPattern, async (c, next) => {
|
|
c.set("contactRepository", contactRepository);
|
|
c.set("groupRepository", groupRepository);
|
|
c.set("membershipRepository", membershipRepository);
|
|
c.set("eventBus", eventBus);
|
|
await next();
|
|
});
|
|
|
|
registerRoutes({
|
|
app,
|
|
openAPIRegistry,
|
|
routePrefix: prefix,
|
|
maxImportBatchSize: config.maxImportBatchSize,
|
|
logger,
|
|
});
|
|
}
|
|
|
|
interface RegisterRoutesArgs {
|
|
readonly app: OpenAPIHono;
|
|
readonly openAPIRegistry: ReturnType<
|
|
PluginContext<ContactsPluginConfig>["getService"]
|
|
>;
|
|
readonly routePrefix: string;
|
|
readonly maxImportBatchSize: number;
|
|
readonly logger: {
|
|
warn: (msg: string, ctx?: Readonly<Record<string, unknown>>) => void;
|
|
};
|
|
}
|
|
|
|
function registerRoutes(args: RegisterRoutesArgs): void {
|
|
const { app, openAPIRegistry, routePrefix, maxImportBatchSize, logger } =
|
|
args;
|
|
const entries = [
|
|
...buildContactRoutes({ maxImportBatchSize }),
|
|
...buildGroupRoutes(),
|
|
];
|
|
if (openAPIRegistry !== undefined) {
|
|
const reg = openAPIRegistry as {
|
|
getRoutes: () => Array<{ path: string }>;
|
|
};
|
|
const collisionPrefix =
|
|
routePrefix === "/" ? "/contacts" : `${routePrefix}/contacts`;
|
|
const existing = reg
|
|
.getRoutes()
|
|
.filter((r) => r.path.startsWith(collisionPrefix));
|
|
if (existing.length > 0) {
|
|
throw new ContactsApiError(
|
|
CONTACTS_API_CODES.ROUTE_PREFIX_COLLISION,
|
|
`Route prefix '${routePrefix}' collides with previously registered routes`,
|
|
{
|
|
context: {
|
|
prefix: routePrefix,
|
|
existing: existing.map((r) => r.path),
|
|
statusCode: 500,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
const prefixedEntries = entries.map((entry) => ({
|
|
...entry,
|
|
route: {
|
|
...entry.route,
|
|
path: `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`,
|
|
},
|
|
}));
|
|
definePluginRoutes(prefixedEntries, {
|
|
app,
|
|
registry: openAPIRegistry as Parameters<
|
|
typeof definePluginRoutes
|
|
>[1]["registry"],
|
|
pluginName: "contacts",
|
|
});
|
|
return;
|
|
}
|
|
logger.warn(
|
|
"contacts plugin: openAPIRegistry not registered — routes will not appear in /openapi.json"
|
|
);
|
|
for (const entry of entries) {
|
|
const method = entry.route.method as
|
|
| "get"
|
|
| "post"
|
|
| "put"
|
|
| "patch"
|
|
| "delete";
|
|
const path = `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`;
|
|
app[method](
|
|
path,
|
|
async (c) => (await entry.handler(c as never)) as Response
|
|
);
|
|
}
|
|
}
|