Ensure you have a posgresql server running add env variables for
z.object({
GITHUB_CLIENT_ID: z.string().min(5),
GITHUB_CLIENT_SECRET: z.string().min(5),
BETTER_AUTH_SECRET: z.string().min(10),
API_URL: z.string().url(),
BETTER_AUTH_URL: z.string().url(),
NODE_ENV: z.string().default("development"),
PORT: z.coerce.number().default(5000),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]),
DATABASE_URL: z.string().url(),
FRONTEND_URL: z.string(),
});
npm run drizzle:push
npm run dev
open api reference UI is on
http://localhost:5000/reference
open api documnetation is on
http://localhost:5000/doc
Uses
- honojs (repress like router)
- hono-zod-openapi for code first apidocs
- drizzle + postgress
- redis for caching
Honojs is just like express witha key diffecrence of having the Request
and Response
be inside the context
import { Hono } from "hono";
const app = new Hono();
app.get("/", (c) => {
// c.req :request
// c.res: response
// c.var: async local storage values
// c.env : enviroment specific methods (nodejs,f=deno,cf workers...)
return c.text("Hono!");
return c.json({ message: "Hono!" });
});
export default app;
The entry point for this app is apps/hono/src/index.ts
which import the actual app setup with routes and middleware , This is to make testing esaiser
export function createApp() {
const app = createRouter();
app.use(
"*",
cors({
origin: [...allowedOrigins],
allowHeaders: ["Content-Type", "Authorization"],
allowMethods: ["POST", "GET", "OPTIONS", "PUT", "DELETE", "PATCH"],
exposeHeaders: ["Content-Length"],
maxAge: 600,
credentials: true,
})
); // enable cors with support for cross site httpOnly cookies
app.use(requestId()); // adds the requset id (for logging)
app.use(pinoLogger()); // logging middleware
app.use(contextStorage()); // initializes async local storage
app.use("/api/users/*", (c, next) => authenticateUserMiddleware(c, next)); // auth gurad to only allow logged in users
app.use("/api/auditlogs/*", (c, next) => authenticateUserMiddleware(c, next, "admin")); // auth gaurd to only allow admin users
app.use(serveEmojiFavicon("đź“ť")); // adds the emoji as favioc
app.notFound(notFound); // global not found handler
app.onError(onHonoError); // global error handler
return app;
}
Drizzle is used to manage the database schemas and migrations
// example schema
import { boolean, decimal, integer, pgTable, text } from "drizzle-orm/pg-core";
import { commonColumns } from "../helpers/columns";
export const helloTable = pgTable("hello", {
...commonColumns,
name: text().notNull(),
email: text().notNull().unique(),
password: text().notNull(),
avatarUrl: text(),
refreshToken: text(),
});
with commands
"drizzle:gen": "drizzle-kit generate",// generates the migratons sql files
"drizzle:migrate": "drizzle-kit migrate ", // runs the migrations
"drizzle:push": "drizzle-kit push ", //push the changes to the database directly
"drizzle:studio": "drizzle-kit studio",// open the drizzle studio to visualize you db
coupled with drizzle-zod
to generate the zod schemas for the tables
export const helloSelectSchema = createSelectSchema(helloTable);
export const helloInsertSchema = createInsertSchema(helloTable);
export const helloUpdateSchema = createUpdateSchema(helloTable);
The zod schemas are the used with hono-zod-openapi to validate requests and responses and generate athe swagger doc
export function configureOpenAPI(app: AppOpenAPI) {
app.doc("/doc", {
openapi: "3.0.0",
info: {
version: packageJSON.version,
title: "Inventory API",
},
});
app.get(
"/reference",
apiReference({
theme: "kepler",
layout: "classic",
defaultHttpClient: {
targetKey: "javascript",
clientKey: "fetch",
},
spec: {
url: "/doc",
},
})
);
}
example route
// index.ts
const route = createRouter().openapi(
createRoute({
tags: ["Home"],
method: "get",
path: "/api/v1",
responses: {
[HttpStatusCodes.OK]: jsonContent(
baseResponseSchema.extend({
result: z.object({
message: z.string(),
}),
error: z.null().optional(),
}),
"Welcome to the Inventory API"
),
},
}),
async (c) => {
return c.json(
{
error: null,
result: {
message: "Welcome to the Inventory API",
},
},
HttpStatusCodes.OK
);
}
);
// app.ts
const app = createApp();
app.route("/", route);
export default app;
// import { serve } from "@hono/node-server";
// import app from "./app";
// import { envVariables } from "./env";
const port = envVariables.PORT;
// eslint-disable-next-line no-console
console.log(`Server is running on port http://localhost:${port}`);
serve({
fetch: app.fetch,
port,
});
uses pino
coupled with the honojs logger middleware which is passed down using hono/context
(a wrapper around nodejs AsyncLocalStorage
which makes avaiable accrioo the app by calling
c.var.logger.info("message");
Most of the data is fetchec through the BaseCrudService
To make pagination and error handling easy the list endpoints respond with
interface SuccessListResponse<T> {
error: null;
result: {
page: number;
perPage: number;
totalItems: number;
totalPages: number;
items: T[];
};
}
interface ErrorResponse<T> {
error: {
messgae: string;
status: number;
data: Array<record<string, any>>;
};
result: null;
}
This resultor error pattern applie to all routes ,
base-crup-service
an abstraction to help quickey scafold api routes of GET
, POST
, PUT
, DELETE
and PATCH
it can be extended for custom behavior
// categories required to be filtered by name or categoryId
export class CategoriesService extends BaseCrudService<
typeof categoriesTable,
z.infer<typeof categoriesInsertSchema>,
z.infer<typeof categoriesUpdateSchema>
> {
constructor() {
super(categoriesTable, entityType.CATEGORY);
}
// Override or add custom methods
override async findAll(query: z.infer<typeof listCategoriesQueryParamsSchema>) {
const { search, ...paginationQuery } = query;
const conditions = or(
search ? ilike(categoriesTable.name, `%${search}%`) : undefined,
search ? ilike(categoriesTable.id, `%${search}%`) : undefined
);
return super.findAll(paginationQuery, conditions);
}
}
Inside this abstraction audit logs and caching is perfoemd to increase DRY
ness
// example of audit logiing,structured logging and caching
export class BaseCrudService<
T extends PgTable<any>,
CreateDTO extends Record<string, any>,
UpdateDTO extends Record<string, any>,
> {
protected table: T;
protected entityType: EntityType;
private auditLogService: AuditLogService;
constructor(table: T, entityType: EntityType) {
this.table = table;
this.entityType = entityType;
this.auditLogService = new AuditLogService();
}
async findById(id: string): Promise<FindOneReturnType<T>["item"]> {
const c = getContext<AppBindings>();
const cacheKey = `findById:${id}`;
const cachedResult = await cacheService.get(cacheKey);
if (cachedResult) {
c.var.logger.info(`Cache hit for ${cacheKey}`);
return JSON.parse(cachedResult);
}
c.var.logger.warn(`Cache miss for ${cacheKey}`);
const item = await db
.select()
.from(this.table)
// TODO : extend type PgTable with a narrower type which always has an ID column
// @ts-expect-error : the type is too genrric but shape matches
.where(eq(this.table.id, id))
.limit(1);
const result = item[0];
await cacheService.set(cacheKey, JSON.stringify(result), 60 * 5); // Cache for 5 minutes
c.var.logger.info(`Cache set for ${cacheKey}`);
return result;
}
async create(data: CreateDTO) {
const ctx = getContext<AppBindings>();
const userId = ctx.var.viewer?.id;
const item = await db
.insert(this.table)
.values(data as any)
.returning();
await this.auditLogService.create({
userId,
action: auditAction.CREATE,
entityType: this.entityType,
entityId: item[0].id,
newData: data,
});
return item[0];
}
}
The structred logs could be vased to disk later on but the audit logs are saved to the DB
The app can run using local nodejs but a docker setup is also possible
Warning
This is an adapted dockerfile from another project and it may not work as expected
Note
These commands should be run from the root directory
- Build the image (current command)
sudo docker build -t hono -f apps/hono/Dockerfile .
- Run the container
sudo docker run -d \
--name hono-api \
-p 5000:80 \
hono
- Verify container is running
sudo docker ps
- Check logs if needed
sudo docker logs hono-api
-
Access the application Open browser at http://localhost:5000
-
Stop and remove the container
sudo docker stop hono-api
sudo docker rm hono-api