Tutorial: Building a REST API
Build a complete, production-ready REST API end to end with database models, CRUD, access control, and custom actions.
This tutorial walks through building a complete blog API featuring database models, CRUD resources, context resolvers, authorization policies, and custom business actions.
Step 1: Project Setup and Dependencies
Initialize a Bun project and install the Brick-TS packages:
mkdir blog-api && cd blog-api
bun init -y
bun add @brickkit/core @brickkit/crud @brickkit/cliStep 2: Define Database Models
Declare tables for articles and comments using Drizzle schema builders in src/schema.ts:
import { integer, sqliteTable, text } from "@brickkit/core";
export const articles = sqliteTable("articles", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
body: text("body").notNull(),
authorId: text("author_id").notNull(),
views: integer("views").default(0).notNull(),
});
export const comments = sqliteTable("comments", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
targetId: text("target_id").notNull(),
authorId: text("author_id").notNull(),
body: text("body").notNull(),
});Schema
These columns match the Drizzle tables in the example. Defaults marked as application defaults run in Drizzle, rather than in SQL.
Table Name: articles
| Field | Type | Description |
|---|---|---|
id | text | Row identifier. PK Default: crypto.randomUUID() (application) |
title | text | Display title. |
slug | text | URL slug. Unique |
body | text | Text content. |
author_id | text | Author identifier supplied by the application. |
views | integer | Article view count. Default: 0 |
Table Name: comments
| Field | Type | Description |
|---|---|---|
id | text | Row identifier. PK Default: crypto.randomUUID() (application) |
target_id | text | Logical parent article identifier; no SQL foreign-key constraint. |
author_id | text | Author identifier supplied by the application. |
body | text | Text content. |
Step 3: Configure Database and Service Context
Create src/service.ts to configure the database handle and a context resolver that extracts the authenticated user from the HTTP Authorization header:
import { defineDatabase, defineService } from "@brickkit/core";
import { articles, comments } from "./schema";
export interface User {
id: string;
role: "admin" | "author" | "reader";
}
// 1. Create a database handle
export const database = defineDatabase({
tables: { articles, comments },
});
// 2. Define the service with a typed context resolver
export const blogService = defineService("blog", {
database,
context: async (baseCtx) => {
const authHeader = baseCtx.request.headers.get("authorization");
if (!authHeader) return { user: null };
// In a real application, verify JWT or session token here
const token = authHeader.replace("Bearer ", "").trim();
if (token === "admin-token") {
return { user: { id: "usr_admin", role: "admin" as const } };
}
if (token === "author-token") {
return { user: { id: "usr_author", role: "author" as const } };
}
return { user: null };
},
});Step 4: Add CRUD Resources with Access Policies
Attach CRUD operations to articles with field policies, a before-create hook, and operation authorization in src/resources.ts. authorId has no create permission, so clients can't send it; the hook fills it in from the session:
import { ActionExecutionError, eq } from "@brickkit/core";
import { crud } from "@brickkit/crud";
import { articles } from "./schema";
import { blogService } from "./service";
export const articleResource = blogService
.resource({
name: "article",
table: articles,
id: articles.id,
})
.use(
crud({
// CRUD is deny-by-default; an explicit access policy is required
access: {
authorize: ({ operation, ctx }) => {
// Public reads
if (
operation === "list" ||
operation === "get" ||
operation === "findOne"
) {
return true;
}
// Only authenticated authors or admins can write
return !!ctx.user;
},
// Row-level writes: CRUD ANDs this into the WHERE clause of update
// and delete, so authors can only touch their own articles.
// `authorize` above already rejected anonymous callers.
scope: ({ operation, ctx }) => {
if (operation !== "update" && operation !== "delete")
return undefined;
if (ctx.user?.role === "admin") return undefined;
return eq(articles.authorId, ctx.user!.id);
},
},
fields: {
id: { read: true, filter: ["eq", "in"] },
title: {
read: true,
create: true,
update: true,
filter: ["contains"],
sort: true,
},
slug: { read: true, create: true, filter: ["eq"] },
body: { read: true, create: true, update: true },
authorId: { read: true, filter: ["eq"] },
views: { read: true, sort: true },
},
hooks: {
beforeCreate: async ({ data, ctx, error }) => {
if (!ctx.user)
return error.UNAUTHORIZED(
"Authentication required to create articles",
);
// authorId isn't writable by clients; set it from the session
data.authorId = ctx.user.id;
},
},
}),
);Step 5: Add a Custom Business Action
Add a custom action for incrementing article views. Actions provide full TypeBox schema validation and custom error helpers:
import { eq, sql, t } from "@brickkit/core";
import { articles } from "./schema";
import { blogService } from "./service";
export const incrementView = blogService.action({
name: "incrementView",
description: "Atomically increment the view counter for an article",
method: "POST",
path: "/api/blog/articles/:id/view",
input: t.Object({
id: t.String(),
}),
output: t.Object({
id: t.String(),
views: t.Number(),
}),
execute: async ({ input, ctx, error }) => {
const updated = await ctx.db
.update(articles)
.set({ views: sql`${articles.views} + 1` })
.where(eq(articles.id, input.id))
.returning({ id: articles.id, views: articles.views });
if (!updated.length) {
error.NOT_FOUND(`Article with ID '${input.id}' not found`);
}
return updated[0];
},
});Step 6: Start the Server
Create the server entry in src/server.ts, sync the SQLite schema, and start the application:
import { brick, syncSchema } from "@brickkit/core";
import { blogService } from "./service";
import "./resources";
import "./actions";
// Prepare SQLite database tables in development
syncSchema(blogService);
export const app = brick({
services: [blogService],
title: "Blog API Mesh",
version: "1.0.0",
});
export default app;Run the server with the brick CLI:
bunx brick dev src/server.ts --port 4000syncSchema() creates the tables of resources registered on the service (here, articles) with CREATE TABLE IF NOT EXISTS. It's a development helper, not a migration runner.
Step 7: Verify with Requests
CRUD routes are mounted under the resource name (/api/article), while the custom action uses the path you declared.
1. Create an Article (Authorized)
curl -X POST http://localhost:4000/api/article \
-H "Authorization: Bearer author-token" \
-H "Content-Type: application/json" \
-d '{
"title": "Getting Started with Brick-TS",
"slug": "getting-started-with-brick-ts",
"body": "Brick-TS makes building backend services fast and safe."
}'Without the Authorization header, authorize returns false and the request fails with 403 and code FORBIDDEN.
2. Increment Article View (Custom Action)
curl -X POST http://localhost:4000/api/blog/articles/YOUR_ARTICLE_ID/viewThe :id path parameter becomes input.id, so no body is needed.
3. List Articles (Public Read)
curl -X POST http://localhost:4000/api/article/query \
-H "Content-Type: application/json" \
-d '{"limit": 5, "orderBy": [{"field": "views", "direction": "desc"}]}'Related Topics
Services & Actions
Explore the full action definition API and error helpers.
Access Control
Configure role-based access and row scoping for multi-tenant applications.
Relational Data Modeling
Learn patterns for child tables, joins, and parent-child cascades.
Typed Client
Consume this API with end-to-end type safety from React or Next.js.