Initial Commit
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* `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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user