Tutorial: Build a Blog
This tutorial builds a small but complete blog: posts with comments, a validated JSON API, an integration test, and a Next.js interface. You’ll use generators, migrations, relationships, typed results, and the frontend api() helper.
1. Create the app#
$ master new grayskull-blog$ cd grayskull-blog$ master db migrate # initialize the SQLite database$ master dev # API on :3001 (Swagger UI at /docs), web on :3000
2. Scaffold the Post resource#
$ master g scaffold post title:string body:text published:boolean
This creates the Post model (registered in AppContext.js), a RESTful postsController with validation and typed results, five routes with a typed :id(int) parameter, and a /posts page. Generate the migration and apply it — no enable step needed:
$ master db new AddPosts$ master db migrate
The generated files:
export default class Post {
id(db) { db.integer().primary().auto(); }
title(db) { db.string(); }
body(db) { db.text(); }
published(db) { db.boolean(); }
}router.route('/posts', 'posts#index', 'get');
router.route('/posts/:id(int)', 'posts#show', 'get');
router.route('/posts', 'posts#create', 'post');
router.route('/posts/:id(int)', 'posts#update', 'put');
router.route('/posts/:id(int)', 'posts#destroy', 'delete');export default class PostsController {
static schemas = {
create: { body: { title: { type: 'string', required: true }, body: { type: 'string', required: true }, published: { type: 'boolean' } } },
update: { body: { title: { type: 'string' }, body: { type: 'string' }, published: { type: 'boolean' } } },
};
// static authorize = { create: true, update: true, destroy: true };
constructor(requestObject) { this.requestObject = requestObject; }
async index() { this.ok({ data: await this.db.Post.toList() }); }
async show(obj) {
const item = await this.db.Post.find(obj.params.id);
if (!item) return this.notFound('Post not found');
this.ok({ data: item });
}
async create() {
const item = this.db.Post.new();
Object.assign(item, this.model); // validated body
await this.db.saveChanges();
this.created(`/posts/${item.id}`, { data: item });
}
// update / destroy …
}Already the API validates input (POST /posts with an empty body → 400 with a ValidationProblemDetails body), rejects /posts/abc with 404, and documents itself at /docs.
3. Add comments with a relationship#
$ master g model Comment body:text author:string
Add the association by hand in the two models:
export default class Post {
id(db) { db.integer().primary().auto(); }
title(db) { db.string().notNullable(); }
body(db) { db.text(); }
published(db) { db.boolean().default(false); }
Comments(db) { db.hasMany('Comment'); }
}export default class Comment {
id(db) { db.integer().primary().auto(); }
body(db) { db.text(); }
author(db) { db.string(); }
Post(db) { db.belongsTo('Post'); } // creates post_id
}$ master db new AddComments$ master db migrate
4. Customize the API#
Make index return only published posts, newest first, and show include the comments. Everything goes through this.db — the request’s own AppContext:
// GET /posts — only published, newest first
async index() {
const data = await this.db.Post
.where((p) => p.published == true)
.orderByDescending((p) => p.id)
.toList();
this.ok({ data });
}
// GET /posts/:id(int) — with comments
async show(obj) {
const post = await this.db.Post
.where((p) => p.id == $$, obj.params.id)
.include('Comments')
.single();
if (!post) return this.notFound('Post not found');
this.ok({ data: post });
}Now add a comments endpoint. Generate a controller and give it a schema and a route:
$ master g controller comments create
export default class CommentsController {
static schemas = {
create: { body: { body: { type: 'string', required: true }, author: { type: 'string', required: true } } },
};
constructor(requestObject) { this.requestObject = requestObject; }
// POST /posts/:id(int)/comments
async create(obj) {
const post = await this.db.Post.find(obj.params.id);
if (!post) return this.notFound('Post not found');
const comment = this.db.Comment.new();
Object.assign(comment, this.model, { post_id: post.id });
await this.db.saveChanges();
this.created(`/posts/${post.id}`, { data: comment });
}
}The generator registered GET /comments/create; replace that line in routes.js with the nested route:
router.route('/posts/:id(int)/comments', 'comments#create', 'post');5. Test it#
Scaffolded apps ship with backend/test/health.test.js; add a second file for the blog API. The test boots the real pipeline in-process and registers the context the way server.js does:
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import master from 'mastercontroller';
import AppContext from '../app/models/AppContext.js';
let client;
after(() => client && client.close());
test('create a post, comment on it, read it back', async () => {
process.env.MC_DISABLE_DEFAULT_LOG_PROVIDER = '1';
client = await master.createTestClient({
root: dirname(dirname(fileURLToPath(import.meta.url))),
app: 'app',
environment: 'test',
configure: (m) => { m.addScoped('db', AppContext); },
});
const created = await client.post('/posts', { body: { title: 'I Have the Power', body: '...', published: true } });
assert.equal(created.status, 201);
const id = created.json().data.id;
const commented = await client.post(`/posts/${id}/comments`, { body: { body: 'Nice!', author: 'Orko' } });
assert.equal(commented.status, 201);
const shown = await client.get(`/posts/${id}`);
assert.equal(shown.json().data.Comments.length, 1);
});$ master db migrate --env test # schema for the test database (db/test/)$ master test
6. Build the UI#
import Link from 'next/link';
import { api } from '../lib/api';
interface Post { id: number; title: string }
export default async function PostsPage() {
const { data } = await api<{ data: Post[] }>('/posts');
return (
<main style={{ padding: '2rem' }}>
<h1>Grayskull Blog</h1>
<ul>
{data.map((p) => (
<li key={p.id}><Link href={`/posts/${p.id}`}>{p.title}</Link></li>
))}
</ul>
</main>
);
}import { api } from '../../lib/api';
interface Comment { id: number; author: string; body: string }
interface Post { id: number; title: string; body: string; Comments: Comment[] }
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { data: post } = await api<{ data: Post }>(`/posts/${id}`);
return (
<main style={{ padding: '2rem' }}>
<h1>{post.title}</h1>
<p>{post.body}</p>
<h2>Comments</h2>
<ul>
{post.Comments.map((c) => (
<li key={c.id}><strong>{c.author}</strong>: {c.body}</li>
))}
</ul>
</main>
);
}7. Run it#
$ master dev
Create a post and a comment, then open http://localhost:3000/posts:
curl -X POST http://localhost:3001/posts \
-H 'Content-Type: application/json' \
-d '{"title":"I Have the Power","body":"...","published":true}'
curl -X POST http://localhost:3001/posts/1/comments \
-H 'Content-Type: application/json' \
-d '{"author":"Orko","body":"Nice!"}'8. Lock down writes (optional)#
Give the app a JWT secret and uncomment static authorize in the controllers. Writes now need a bearer token; reads stay public:
$ master secrets set Auth:JwtSecret "$(openssl rand -hex 32)"
static authorize = { create: true, update: true, destroy: true }; // ASP.NET [Authorize]Issuing tokens (a login endpoint) is covered in Authentication & Authorization.