Skip to content

hectwor/api-kit

Repository files navigation

@hectordahv/api-kit

Opinionated Express + TypeScript API foundation and scaffolder, extracted from a production backend. Everything a standardized REST service needs, wired once and reused across apps.

Foundation

  • Standardized responses — one { message, data|error, metadata } envelope on every route, via ResponseBuilder.
  • Error handlingBusinessError taxonomy + global middleware mapping Business/Joi/Prisma errors to the envelope.
  • Auth — JWT verify middleware with an injectable claim extractor, plus TokenPairService for stateless access/refresh issuing and rotation.
  • Structured logging — Winston with request-scoped correlation IDs (AsyncLocalStorage).
  • Validation — Joi body-validation middleware.
  • Rate limiting & idempotency — IP + per-user limiters (optional Redis store) and idempotent replay via a KeyValueStore.
  • App bootstrapcreateApp/finalizeApp assemble the standard middleware stack (helmet, CORS, identity-header stripping, sanitization) in one call.
  • Graceful shutdown & startup taskscreateHttpServer (drain → cleanup → exit) and runStartupTasks.

Productivity

  • Generic CRUD vertical — a user-scoped, soft-delete REST resource (repository → service → controller → routes) in ~30 lines, ORM-agnostic.
  • Live OpenAPI + Swagger UI — generated from the same Joi schemas you validate with and the live route table; no hand-edited JSON.
  • Opt-in modules — activity log (audit trail), parameter catalog (runtime config), health checks.
  • Scaffoldernpx @hectordahv/api-kit new <dir> generates a runnable starter.

Principles

  • No global state — create one ApiKit per app; several can coexist in one process.
  • No ORM/vendor lock-in — Prisma error codes are matched structurally; Redis and Sentry are injected behind tiny interfaces; module persistence is a repository interface your app implements.
  • CJS + ESM, Node >= 20, Express 4, tree-shakeable subpath exports.

Scaffold a new backend

npx @hectordahv/api-kit new my-api
cd my-api && npm install && cp .env.example .env && npm run dev
# → runnable API with a CRUD resource + live Swagger UI at http://localhost:3000/docs

Install (into an existing project)

npm install @hectordahv/api-kit express
# optional, only if you use validateSchema:
npm install joi

Quickstart

import { createApiKit, messagesEs } from "@hectordahv/api-kit";
import { createApp, finalizeApp } from "@hectordahv/api-kit/app";
import { createHealthRoutes } from "@hectordahv/api-kit/health";

const kit = createApiKit({
  service: "my-api",
  environment: process.env.NODE_ENV,
  // messages: messagesEs,                       // default is English
  logger: { pretty: process.env.NODE_ENV === "local" },
  // capture: { exception: Sentry.captureException },
});

const { app, apiRouter } = createApp({
  kit,
  apiPrefix: "/api/v1",
  corsOrigins: [/^http:\/\/localhost(:\d+)?$/],
});

const validateToken = kit.validateToken({ getKey: () => process.env.JWT_KEY });

apiRouter.get("/me", validateToken, (req, res) => {
  kit.responses.sendSuccess(res, { id: req.headers.userId }, "RESOURCE_RETRIEVED", "OK");
});

createHealthRoutes(app, { checks: { db: () => db.ping() }, logger: kit.logger });

finalizeApp(app, { errors: kit.errors });

app.listen(3000);

Every response uses the same envelope:

{
  "message": { "code": "RESOURCE_RETRIEVED", "text": "OK" },
  "data": { "id": "..." },
  "metadata": {
    "timestamp": "2026-07-02T15:30:45.123Z",
    "version": "1.0",
    "statusCode": 200,
    "requestId": "1712686245123-abc123xyz",
    "path": "/api/v1/me",
    "method": "GET",
    "duration": "45ms",
    "environment": "production"
  }
}

What's in the box

Subpath Contents
@hectordahv/api-kit createApiKit kernel + re-exports of the core subpaths (/errors, /http, /logging, /middleware, /validation, /auth, /routes, /config). /app, /crud, /server, /openapi and the opt-in modules are imported from their own subpath
/errors BusinessError base + NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, UnprocessableError, auth/resource error families
/http ResponseBuilder, HTTP_STATUS, ERROR_CODES/SUCCESS_CODES, message catalogs (messagesEn, messagesEs), pagination helpers
/logging createLogger (Winston, correlation IDs via AsyncLocalStorage), createRequestLogging
/middleware createErrorMiddleware (Business/Joi/Prisma-duck-typed mapping), rate limiters + createRedisRateLimitStore, idempotencyMiddleware, sanitization, KeyValueStore/RedisLike abstractions
/validation createSchemaValidator (Joi)
/auth generateToken/verifyToken, createValidateToken (claim extractor injectable), createRequireUserId, TokenPairService (stateless access/refresh issue + rotation)
/routes CommonRoutesConfig base class, async-handler patching, CRUD interface, BaseDTO
/config validateEnvVars(required[]), getEnv, isProd/isDev
/app createApp/finalizeApp — standard middleware stack in one call
/crud Opt-in generic CRUD vertical: SoftDeleteUserScopedRepository, CrudService, createCrudController, registerCrudRoutes — a user-scoped, soft-delete REST resource in ~30 lines
/server createHttpServer (graceful shutdown: drain, cleanup callbacks, force-exit timeout) + runStartupTasks (uniform logging + fatal/non-fatal policy)
/openapi Live OpenAPI 3.0 + Swagger UI generated from your Joi schemas and routes — OpenApiRegistry, joiToOpenApi, documentCrudResource, collectExpressRoutes, createOpenApiRoutes. No hand-edited JSON
/testing Test helpers for apps built on the kit: createTestKit, silentLogger/spyLogger, issueUserToken/bearer, InMemoryCrudRepository, createMemoryDelegate
/container Tiny typed DI container: createContainer().register(token, provider).resolve(token) — lazy singletons, transients, circular-dependency detection, full inference
/activity-log Opt-in audit trail: middleware factory + fire-and-forget logger + service over your repository
/parameter-catalog Opt-in runtime config catalog (groups → nodes → typed values) with TTL cache; extensible by subclass
/health createHealthRoutes with named readiness checks

Key design points

The kit is an instance, not a singleton

const kit = createApiKit({ service: "billing-api", messages: { NOT_FOUND: "Nope" } });
// kit.logger, kit.responses, kit.errors, kit.requestLogging, kit.sanitization,
// kit.validateSchema, kit.validateToken(opts), kit.requireUserId

Messages are merged over the English defaults, so you can override a single key or pass a whole catalog (messagesEs ships with the package).

Bring your own infrastructure

// Redis (any ioredis-compatible client) — never a hard dependency:
import { createRedisRateLimitStore, redisKeyValueStore, idempotencyMiddleware } from "@hectordahv/api-kit/middleware";
const rateLimitStore = createRedisRateLimitStore(redis);
const idem = idempotencyMiddleware(redisKeyValueStore(redis));

// Sentry (or any APM):
const kit = createApiKit({ service: "x", capture: { exception: Sentry.captureException } });

Opt-in modules own logic, you own persistence

import { ActivityLogService, createActivityLogger, createActivityLogMiddleware } from "@hectordahv/api-kit/activity-log";

class MyActivityLogRepository implements ActivityLogRepository { /* your ORM here */ }

const audit = createActivityLogMiddleware({
  apiPrefix: "/api/v1",
  entitiesBySegment: { account: "account", movement: "movement" },
  skipSubpaths: ["/stats", "/paginated"],
  log: createActivityLogger(new ActivityLogService(new MyActivityLogRepository()), kit.logger),
});

// mount before routes:
const { app, apiRouter } = createApp({ kit, beforeRoutes: [audit] });
import { ParameterCatalogService, nodeValuesToObject } from "@hectordahv/api-kit/parameter-catalog";

class MyCatalog extends ParameterCatalogService {
  async getFeatureFlags() {
    const group = await this.loadGroup("FEATURE_FLAGS"); // protected, cached, stale-on-error
    return group ? Object.fromEntries(group.parameters.map((n) => [n.code, nodeValuesToObject(n)])) : {};
  }
}

Generic CRUD in ~30 lines

/crud collapses the repeated repository → service → controller → routes stack into configuration. It is ORM-agnostic: a Prisma model delegate is structurally compatible, so you pass prisma.some_table directly and supply one small mapper.

import {
  SoftDeleteUserScopedRepository,
  CrudService,
  createCrudController,
  registerCrudRoutes,
  type RowMapper,
} from "@hectordahv/api-kit/crud";

interface Bank { id?: string; userId?: string; status?: string; name?: string }

// The only schema-specific glue: entity <-> row.
const mapper: RowMapper<Bank> = {
  toDomain: (r) => ({ id: r.id as string, userId: r.user_id as string, status: r.status as string, name: r.name as string }),
  toCreateInput: (b) => ({ id: b.id, user_id: b.userId, status: b.status ?? "active", name: b.name }),
  toUpdateInput: (b) => ({ ...(b.name !== undefined && { name: b.name }) }),
};

const repository = new SoftDeleteUserScopedRepository<Bank>({ delegate: prisma.user_banks, mapper });
const service = new CrudService<Bank>(repository);
const controller = createCrudController<Bank>({ resource: "Bank", service, responses: kit.responses, requireUserId: kit.requireUserId });

registerCrudRoutes(apiRouter, {
  basePath: "/api/v1/banks",
  controller,
  auth: kit.validateToken({ getKey: () => process.env.JWT_KEY! }),
  validate: { create: kit.validateSchema(CreateBankSchema), update: kit.validateSchema(UpdateBankSchema) },
  enablePaginated: true,
});
  • User-scoped by default — operations without a userId return empty/null instead of leaking cross-tenant rows; ownership is verified before update/delete.
  • Soft-delete by defaultremove() flips status to deleted; pass hardDelete: true to actually delete.
  • Filtering, sorting & search — allow-listed, so query params can't reach arbitrary columns:
createCrudController<Bank>({
  resource: "Bank", service, responses: kit.responses, requireUserId: kit.requireUserId,
  listQuery: { allowedFilters: ["currency"], allowedSort: ["name", "createdAt"], searchParam: "q" },
});
new SoftDeleteUserScopedRepository<Bank>({
  delegate: prisma.user_banks, mapper,
  columnMap: { name: "name", currency: "currency_code" }, // domain field → column
  searchColumns: ["name"],                                  // case-insensitive contains
});
// GET /api/v1/banks?currency=USD&sort=name:desc&q=cred&page=1&limit=20
  • Override anything — extend CrudService/SoftDeleteUserScopedRepository for domain rules, or use only/validate/toDTO to shape the surface.

Boot & graceful shutdown

import { createHttpServer, runStartupTasks } from "@hectordahv/api-kit/server";

await runStartupTasks(
  [
    { name: "seed-catalog", run: () => seedCatalog() },        // non-fatal by default
    { name: "db-migrate-check", run: () => assertSchema(), fatal: true },
  ],
  { logger: kit.logger },
);

createHttpServer(app, {
  port: process.env.PORT,
  logger: kit.logger,
  onShutdown: [() => prisma.$disconnect(), () => redis.quit()],
}).listen();

On SIGTERM/SIGINT: stop accepting connections → run cleanup callbacks → exit, with a force-exit timeout if a callback hangs.

Live API docs (no static JSON)

The spec is generated from the same Joi schemas you validate with and the app's route table, and rebuilt on every request — change a DTO or add a route and /openapi.json + Swagger UI reflect it immediately.

import { OpenApiRegistry, documentCrudResource, createOpenApiRoutes } from "@hectordahv/api-kit/openapi";

const openapi = new OpenApiRegistry({ title: "My API", version: "1.0.0" });

// Rich CRUD docs from the same schemas used for validation:
documentCrudResource({
  registry: openapi,
  basePath: "/api/v1/banks",
  tag: "Banks",
  dtoName: "Bank",
  createSchema: CreateBankSchema,   // Joi — becomes the request body schema
  updateSchema: UpdateBankSchema,
  paginated: true,
});

// Serve it. `introspect` surfaces any route not explicitly documented, so
// nothing is silently missing from the spec.
createOpenApiRoutes(app, { registry: openapi, introspect: app });
// → GET /openapi.json (live)   GET /docs (Swagger UI)

joiToOpenApi(schema) converts any Joi schema on its own; OpenApiRegistry.addPath(...) documents individual non-CRUD routes.

Stateless refresh tokens

import { TokenPairService } from "@hectordahv/api-kit/auth";

const tokens = new TokenPairService({
  accessKey: process.env.JWT_KEY!,
  refreshKey: process.env.JWT_REFRESH_KEY!,
  accessTtl: "15m",
  refreshTtl: "7d",
});

const pair = tokens.issue(user.id, { remember: true });   // { accessToken, refreshToken }
const rotated = tokens.refresh(oldRefreshToken);           // verifies + mints a fresh pair

No database or rotation bookkeeping: refresh() verifies the refresh JWT and re-issues, preserving the original remember choice. Claim shape is configurable (idClaim, extractUserId) and supports the legacy { user: { _id } } payload by default.

Typed DI container

import { createContainer } from "@hectordahv/api-kit/container";

const container = createContainer()
  .value("prisma", prisma)
  .register("bankRepo", (c) => new SoftDeleteUserScopedRepository({ delegate: c.resolve("prisma").user_banks, mapper }))
  .register("bankService", (c) => new CrudService(c.resolve("bankRepo")));

container.resolve("bankService"); // fully typed, memoized singleton

Replaces the hand-written service factory: lazy singletons (or { singleton: false } transients), circular-dependency detection, reset() for tests. No decorators or reflect-metadata.

Non-CRUD routes document themselves

Any route using kit.validateSchema(schema) is auto-documented: createOpenApiRoutes({ introspect: app }) reads the schema tagged on the middleware and emits the request body — no documentCrudResource call needed for one-off routes.

app.post("/auth/login", kit.validateSchema(LoginSchema), controller.login);
// → POST /auth/login appears in /openapi.json with LoginSchema as its request body

Cursor pagination

import { parseCursor, cursorData } from "@hectordahv/api-kit/http";

const { limit, cursor } = parseCursor(req.query);          // keyset params
const rows = await repo.pageAfter(cursor, limit + 1);      // fetch one extra
const page = cursorData(rows, limit, (r) => r.id);         // → { items, pagination:{ nextCursor, hasMore } }

Testing apps built on the kit

import { createTestKit, InMemoryCrudRepository, issueUserToken, bearer } from "@hectordahv/api-kit/testing";

const kit = createTestKit();                                   // silent logger, service: "test"
const repo = new InMemoryCrudRepository<Bank>([], { searchFields: ["name"] }); // no DB
// ...build controller/routes with kit + repo, then drive it with supertest:
await request(app).get("/api/v1/banks").set(bearer(issueUserToken("u1")));

createMemoryDelegate() gives an in-memory ModelDelegate (supports filters/search/sort) to test a real SoftDeleteUserScopedRepository without a database. spyLogger() captures log calls for assertions.

Security defaults

createApp wires helmet, CORS, client identity-header stripping (userId can only come from a verified JWT), a global IP rate limiter, and input sanitization. Per-route you add:

import { makeUserLimiter } from "@hectordahv/api-kit/middleware";
const loginLimiter = makeUserLimiter({ name: "login", windowMs: 15 * 60 * 1000, max: 10, store: rateLimitStore });

Development

npm run typecheck && npm test && npm run build && npm run check-exports

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors