Skip to content

Latest commit

 

History

History
756 lines (604 loc) · 16.2 KB

File metadata and controls

756 lines (604 loc) · 16.2 KB

Express Layered API — File Templates

Copy these templates when scaffolding. Adjust names/paths to the target project.


package.json

{
  "name": "backend",
  "version": "1.0.0",
  "type": "module",
  "packageManager": "pnpm@10.26.1",
  "scripts": {
    "dev": "nodemon --watch src --ext ts --exec \"tsx src/index.ts\"",
    "build": "tsc -b",
    "start": "node dist/src/index.js",
    "prisma:generate": "prisma generate",
    "prisma:migrate": "prisma migrate dev"
  },
  "dependencies": {
    "@prisma/adapter-pg": "^7.8.0",
    "@prisma/client": "^7.8.0",
    "@types/cors": "^2.8.19",
    "@types/express": "^5.0.6",
    "cors": "^2.8.6",
    "dotenv": "^17.4.2",
    "express": "^5.2.1",
    "http-status": "^2.1.0",
    "nodemon": "^3.1.14",
    "pg": "^8.21.0",
    "tsx": "^4.22.4",
    "zod": "^4.4.3"
  },
  "devDependencies": {
    "@types/node": "^25.9.2",
    "@types/pg": "^8.20.0",
    "prisma": "^7.8.0",
    "typescript": "^6.0.3"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "outDir": "dist"
  },
  "include": ["src/**/*"]
}

prisma/schema.prisma

generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model SystemPing {
  id        Int      @id @default(autoincrement())
  message   String
  createdAt DateTime @default(now())

  @@map("system_pings")
}

prisma.config.ts

import "dotenv/config";
import { defineConfig } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: process.env["DATABASE_URL"],
  },
});

src/index.ts

import express from "express";
import "dotenv/config";
import cors from "cors";
import routes from "./routes/index.js";

const app = express();
app.set("trust proxy", true);

app.use(
  cors({
    origin: process.env.FRONTEND_URL ?? "http://localhost:3000",
    methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
    credentials: true,
  }),
);

app.use(express.json());
app.use("/api/v1", routes);

const { PORT } = process.env;

app.listen(Number(PORT ?? 4000), () => {
  console.log(`Server running on http://localhost:${PORT ?? 4000}`);
});

src/routes/index.ts

import express, { Router } from "express";
import testRoute from "./test.route.js";

const router: Router = express.Router();

const defaultRoutes = [
  { path: "/test", route: testRoute },
];

defaultRoutes.forEach(({ path, route }) => {
  router.use(path, route);
});

export default router;

src/routes/test.route.ts

import express, { Router } from "express";
import testController from "../controllers/test.controller.js";

const router: Router = express.Router();

router.get("/loggerAPI", testController.loggerFunction);

export default router;

src/controllers/test.controller.ts

import { Request, Response } from "express";
import httpStatus from "http-status";
import { response } from "../utils/reponses.js";
import testService from "../services/test.service.js";
import ApiError from "../utils/api-error.js";

const loggerFunction = async (req: Request, res: Response) => {
  try {
    const answer = await testService.logger();
    return response(res, httpStatus.OK, "All Okay", answer);
  } catch (error) {
    if (error instanceof ApiError) {
      return response(res, error.statusCode, error.message, null);
    }
    console.error("[test.loggerFunction]", error);
    return response(
      res,
      httpStatus.INTERNAL_SERVER_ERROR,
      "Internal server error.",
      null,
    );
  }
};

export default {
  loggerFunction,
};

src/services/test.service.ts

import httpStatus from "http-status";
import { prisma } from "../lib/prisma.js";
import ApiError from "../utils/api-error.js";

const logger = async () => {
  const message = "This Works Test 3 ";

  try {
    await prisma.systemPing.create({ data: { message } });
  } catch (error) {
    console.error("[test.logger] DB write failed:", error);
    throw new ApiError(
      httpStatus.SERVICE_UNAVAILABLE,
      "Database unavailable.",
    );
  }

  return message;
};

export default {
  logger,
};

src/validators/test.validator.ts

import { z } from "zod";

// Add schemas here as features grow. Example:
// const listItems = z.object({
//   query: z.object({
//     page: z.coerce.number().int().positive().optional().default(1),
//   }),
// });

export default {};

src/middlewares/validate.ts

import { ZodObject, ZodError } from "zod";
import { Request, Response, NextFunction } from "express";

export const validate =
  (schema: ZodObject<any>) =>
  (req: Request, res: Response, next: NextFunction) => {
    try {
      schema.parse({
        body: req.body,
        query: req.query,
        params: req.params,
      });
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        return res.status(400).json({
          success: false,
          message: "Validation Error",
          errors: err.issues.map((issue) => issue.message),
        });
      }

      return res.status(500).json({
        success: false,
        message: "Internal Server Error - Zod",
      });
    }
  };

src/middlewares/auth-middleware.ts

// TODO: Wire to Better Auth / JWT — replace stub implementation.
import { NextFunction, Request, Response } from "express";

export interface UserPayload {
  id: string;
  email: string;
  name: string;
  role: string;
  isActive: boolean;
}

export const isAuthenticated = async (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  // STUB: Remove this block when wiring real auth.
  if (process.env.AUTH_STUB_ENABLED === "true") {
    req.user = {
      id: "stub-user-id",
      email: "stub@example.com",
      name: "Stub User",
      role: "ADMIN",
      isActive: true,
    };
    return next();
  }

  return res.status(401).json({ error: "Unauthorized" });
};

export const requireRole = (...roles: string[]) => {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: "Unauthorized" });
    }
    if (!roles.includes((req.user as UserPayload).role)) {
      return res.status(403).json({ error: "Forbidden: insufficient role." });
    }
    next();
  };
};

src/lib/prisma.ts

import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../../generated/prisma/client.js";

const connectionString = process.env.DATABASE_URL!;
const adapter = new PrismaPg({ connectionString });

const prisma = new PrismaClient({ adapter });

export { prisma };

src/utils/api-error.ts

class ApiError extends Error {
  statusCode: number;
  isOperational: boolean;

  constructor(statusCode: number, message: string, options?: ErrorOptions) {
    super(message, options);
    this.statusCode = statusCode;
    this.isOperational = true;
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

export default ApiError;

src/utils/reponses.ts

import { Response } from "express";
import httpStatus from "http-status";

const response = (
  res: Response,
  statusCode: number,
  message: string,
  data = {} as any,
) => {
  return res.status(statusCode).json({
    message,
    data,
  });
};

const internalServerError = (res: Response, message: string) => {
  return response(res, httpStatus.INTERNAL_SERVER_ERROR, message);
};

export { internalServerError, response };

src/types/express/index.d.ts

import { UserPayload } from "../../middlewares/auth-middleware.js";

declare global {
  namespace Express {
    interface Request {
      user?: UserPayload;
    }
  }
}

export {};

.env.example

PORT=4000
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
FRONTEND_URL=http://localhost:3000
AUTH_STUB_ENABLED=true

.gitignore

node_modules/
dist/
generated/
.env

Add API templates

Use these when extending an existing project (Mode 2).

New domain file — route (src/routes/{domain}.route.ts)

import express, { Router } from "express";
import {
  isAuthenticated,
  requireRole,
} from "../middlewares/auth-middleware.js";
import { validate } from "../middlewares/validate.js";
import productController from "../controllers/product.controller.js";
import productValidator from "../validators/product.validator.js";

const router: Router = express.Router();

// POST /api/v1/products
router.post(
  "/",
  isAuthenticated,
  requireRole("ADMIN"),
  validate(productValidator.createProduct),
  productController.create,
);

// GET /api/v1/products
router.get(
  "/",
  isAuthenticated,
  requireRole("ADMIN"),
  validate(productValidator.listProducts),
  productController.list,
);

// GET /api/v1/products/:id
router.get(
  "/:id",
  isAuthenticated,
  validate(productValidator.getProductById),
  productController.getById,
);

export default router;

New domain file — validator (src/validators/{domain}.validator.ts)

import { z } from "zod";

const createProduct = z.object({
  body: z.object({
    name: z.string().min(1, { message: "name is required" }),
    price: z.coerce.number().positive({ message: "price must be positive" }),
  }),
});

const listProducts = z.object({
  query: z.object({
    page: z.coerce.number().int().positive().optional().default(1),
    limit: z.coerce.number().int().positive().max(100).optional().default(20),
  }),
});

const getProductById = z.object({
  params: z.object({
    id: z.coerce
      .number({ message: "id must be a valid number" })
      .int({ message: "id must be an integer" })
      .positive({ message: "id must be a positive number" }),
  }),
});

export default {
  createProduct,
  listProducts,
  getProductById,
};

New domain file — service (src/services/{domain}.service.ts)

import httpStatus from "http-status";
import { prisma } from "../lib/prisma.js";
import ApiError from "../utils/api-error.js";

interface CreateProductInput {
  name: string;
  price: number;
}

const createProduct = async (input: CreateProductInput) => {
  return prisma.product.create({ data: input });
};

const listProducts = async (page: number, limit: number) => {
  const skip = (page - 1) * limit;

  const [items, total] = await prisma.$transaction([
    prisma.product.findMany({ skip, take: limit, orderBy: { createdAt: "desc" } }),
    prisma.product.count(),
  ]);

  return { items, total, page, limit };
};

const getProductById = async (id: number) => {
  const product = await prisma.product.findUnique({ where: { id } });
  if (!product) {
    throw new ApiError(httpStatus.NOT_FOUND, "Product not found.");
  }
  return product;
};

export default {
  createProduct,
  listProducts,
  getProductById,
};

New domain file — controller (src/controllers/{domain}.controller.ts)

import { Request, Response } from "express";
import httpStatus from "http-status";
import { response } from "../utils/reponses.js";
import ApiError from "../utils/api-error.js";
import productService from "../services/product.service.js";

const create = async (req: Request, res: Response) => {
  try {
    const { name, price } = req.body;
    const product = await productService.createProduct({ name, price });
    return response(res, httpStatus.CREATED, "Product created successfully.", product);
  } catch (error) {
    if (error instanceof ApiError) {
      return response(res, error.statusCode, error.message, null);
    }
    console.error("[product.create]", error);
    return response(res, httpStatus.INTERNAL_SERVER_ERROR, "Internal server error.", null);
  }
};

const list = async (req: Request, res: Response) => {
  try {
    const page = Number(req.query.page ?? 1);
    const limit = Number(req.query.limit ?? 20);
    const result = await productService.listProducts(page, limit);
    return response(res, httpStatus.OK, "Products fetched successfully.", result);
  } catch (error) {
    if (error instanceof ApiError) {
      return response(res, error.statusCode, error.message, null);
    }
    console.error("[product.list]", error);
    return response(res, httpStatus.INTERNAL_SERVER_ERROR, "Internal server error.", null);
  }
};

const getById = async (req: Request, res: Response) => {
  try {
    const id = Number(req.params.id);
    const product = await productService.getProductById(id);
    return response(res, httpStatus.OK, "Product fetched successfully.", product);
  } catch (error) {
    if (error instanceof ApiError) {
      return response(res, error.statusCode, error.message, null);
    }
    console.error("[product.getById]", error);
    return response(res, httpStatus.INTERNAL_SERVER_ERROR, "Internal server error.", null);
  }
};

export default {
  create,
  list,
  getById,
};

Register new domain in src/routes/index.ts

import productRoute from "./product.route.js";

const defaultRoutes = [
  { path: "/test", route: testRoute },
  { path: "/products", route: productRoute }, // add this line
];

Extending an existing domain (add one endpoint)

When product.route.ts already exists, only append to each file:

validator — add schema + export:

const updateProduct = z.object({
  params: z.object({ id: z.coerce.number().int().positive() }),
  body: z.object({
    name: z.string().min(1).optional(),
    price: z.coerce.number().positive().optional(),
  }).refine((b) => b.name !== undefined || b.price !== undefined, {
    message: "At least one field is required",
  }),
});
// add updateProduct to export default { ... }

service — add function + export:

const updateProduct = async (id: number, data: { name?: string; price?: number }) => {
  await getProductById(id); // reuse 404 check
  return prisma.product.update({ where: { id }, data });
};

controller — add handler + export:

const update = async (req: Request, res: Response) => { /* same try/catch pattern */ };

route — add route line (PATCH before DELETE if both use /:id):

router.patch("/:id", isAuthenticated, requireRole("ADMIN"), validate(productValidator.updateProduct), productController.update);

Public endpoint (no auth)

Omit isAuthenticated and requireRole:

router.get("/health", healthController.check);

PATCH body validation pattern

Always use .refine() to require at least one field on partial updates:

body: z.object({
  fieldA: z.string().optional(),
  fieldB: z.number().optional(),
}).strict().refine(
  (b) => Object.values(b).some((v) => v !== undefined),
  { message: "At least one field is required" },
),

Example: adding a protected route with validation

validator

const getItemById = z.object({
  params: z.object({
    id: z.coerce
      .number({ message: "id must be a valid number" })
      .int({ message: "id must be an integer" })
      .positive({ message: "id must be a positive number" }),
  }),
});

export default { getItemById };

route

router.get(
  "/:id",
  isAuthenticated,
  requireRole("ADMIN"),
  validate(itemValidator.getItemById),
  itemController.getById,
);

service

const getItemById = async (id: number) => {
  const item = await prisma.item.findUnique({ where: { id } });
  if (!item) {
    throw new ApiError(httpStatus.NOT_FOUND, "Item not found.");
  }
  return item;
};

controller

const getById = async (req: Request, res: Response) => {
  try {
    const id = Number(req.params.id);
    const item = await itemService.getItemById(id);
    return response(res, httpStatus.OK, "Item fetched successfully.", item);
  } catch (error) {
    if (error instanceof ApiError) {
      return response(res, error.statusCode, error.message, null);
    }
    console.error("[item.getById]", error);
    return response(res, httpStatus.INTERNAL_SERVER_ERROR, "Internal server error.", null);
  }
};