Start here
Tutorial: build a real API
A small task-tracking API, end to end: models, relationships, permissions that scope data per user, search, and a scheduled job. About twenty minutes.
Every other page here is reference. This one is a narrative — read it top to bottom and you will have a working API and a feel for how the pieces fit.
1. Start the project
npx parse-server-kit new tasks-api
cd tasks-api
npm run db:up
npm run dev
You now have a running server with a Note example, docs at
/api-docs, and a seeded demo user whose session token is
printed at startup. Keep that token — every write below needs it.
2. The first model
Delete the example and generate your own:
psk g resource Project
Open src/models/Project.ts and fill in the fields:
import {
ParseClass, BaseModel, ParseField, BeforeSave, validateOrThrow,
implementACL, catchError, cloneAcl, CloudFunction, Cron, CronSchedule,
} from 'parse-server-kit';
@ParseClass('Project', {
description: 'A container for tasks',
clp: {
find: { requiresAuthentication: true },
get: { requiresAuthentication: true },
count: { requiresAuthentication: true },
create: { requiresAuthentication: true },
update: { requiresAuthentication: true },
delete: { requiresAuthentication: true },
},
})
export default class Project extends BaseModel {
constructor() { super('Project'); }
@ParseField({ type: 'String', required: true, maxLength: 120 })
declare name: string;
@ParseField({ type: 'String', maxLength: 2000 })
declare description: string;
@ParseField({ type: 'String', enum: ['active', 'archived'] })
declare status: string;
@ParseField({ type: 'Pointer', targetClass: '_User' })
declare owner: any;
@BeforeSave()
static async onBeforeSave(req: Parse.Cloud.BeforeSaveRequest<Project>) {
const project = req.object as Project;
if (!project.status) project.status = 'active';
validateOrThrow(project);
}
}
Restart. The boot output shows the schema being applied — the class, its fields, and the
validator built from maxLength and enum. You wrote no
migration.
3. Make projects private to their owner
The CLP above lets any signed-in user reach the class. The ACL decides which
rows they see. Set it when the project is created — in
src/functions/project.ts:
static async createProject(req: Parse.Cloud.FunctionRequest) {
const user = req.user!;
const project = Project.fromParams(req.params);
// The caller does not get to decide these.
project.set('owner', user);
project.set('status', 'active');
project.setACL(implementACL({
owner: [{ user: user.id, read: true, write: true }],
roleRules: [{ role: 'Admin', read: true, write: true }],
}));
const [err, saved] = await catchError(
project.save(null, { sessionToken: user.getSessionToken() })
);
if (err) throw err;
return saved;
}
listProjects has no equalTo('owner', user). It does not need
one — pass the session token and the database returns only rows this user may read.
Create two users, make a project with each, and list as one: you will see one project.
4. A second model, related to the first
psk g resource Task
@ParseField({ type: 'String', required: true })
declare title: string;
@ParseField({ type: 'Boolean' })
declare done: boolean;
@ParseField({ type: 'Date', index: true })
declare dueAt: Date;
// The relationship. targetClass is mandatory - without it,
// fromParams cannot build the pointer.
@ParseField({ type: 'Pointer', targetClass: 'Project', required: true })
declare project: any;
A client now sends the relationship as an id and fromParams converts it:
{ "title": "Write the docs", "project": { "objectId": "abc123" } }
Tasks should follow their project
A task is as private as the project it belongs to. Do that in the trigger, so it holds on every save path:
@BeforeSave()
static async onBeforeSave(req: Parse.Cloud.BeforeSaveRequest<Task>) {
const task = req.object as Task;
if (!task.existed()) {
const project = await new Parse.Query(Project)
.get(task.project.id, { useMasterKey: true });
const acl = project.getACL();
if (acl) task.setACL(cloneAcl(acl)); // same audience as its project
}
if (task.done === undefined) task.done = false;
validateOrThrow(task);
}
5. A list endpoint that filters and paginates
@CloudFunction({
methods: ['GET'],
validation: { fields: {
project: { type: String },
done: { type: String },
search: { type: String },
page: { type: String },
} },
})
static async listTasks(req: Parse.Cloud.FunctionRequest) {
const sessionToken = req.user?.getSessionToken();
const build = () => {
const q = new Parse.Query(Task);
if (req.params.project) {
q.equalTo('project', Project.pointer(req.params.project));
}
// GET params are strings - convert deliberately.
if (req.params.done !== undefined) {
q.equalTo('done', req.params.done === 'true');
}
if (req.params.search) {
q.matches('title', new RegExp(req.params.search, 'i'));
}
return q;
};
const page = Math.max(Number(req.params.page) || 1, 1);
const rows = build()
.include('project')
.ascending('dueAt')
.limit(20)
.skip((page - 1) * 20);
const [err, results] = await catchError(rows.find({ sessionToken }));
if (err) throw err;
const [, total] = await catchError(build().count({ sessionToken }));
return { results: results as Task[], page, total: total ?? 0 };
}
6. A scheduled job
Create src/cron.ts and import it from app.ts before the registries initialise:
class TaskJobs {
@Cron({ schedule: CronSchedule.DAILY_MIDNIGHT, description: 'Flag overdue tasks' })
static async flagOverdue() {
const query = new Parse.Query(Task);
query.lessThan('dueAt', new Date());
query.equalTo('done', false);
query.equalTo('overdue', false);
query.limit(1000);
const [err, tasks] = await catchError(query.find({ useMasterKey: true }));
if (err) return console.error(err);
for (const task of tasks as Task[]) task.set('overdue', true);
await catchError(Parse.Object.saveAll(tasks!, { useMasterKey: true }));
}
}
A cron job has no caller, so the master key is right here — this is the administrative case.
7. Try it
export APP=tasks-api
export TOK=<the token printed at startup>
curl -X POST localhost:1337/api/projects/createProject \
-H "X-Parse-Application-Id: $APP" -H "X-Parse-Session-Token: $TOK" \
-H "Content-Type: text/plain" -d '{"name":"Launch"}'
curl -X POST localhost:1337/api/tasks/createTask \
-H "X-Parse-Application-Id: $APP" -H "X-Parse-Session-Token: $TOK" \
-H "Content-Type: text/plain" \
-d '{"title":"Write docs","project":{"objectId":""}}'
curl "localhost:1337/api/tasks/listTasks?done=false" \
-H "X-Parse-Application-Id: $APP" -H "X-Parse-Session-Token: $TOK"
And /api-docs now documents all ten endpoints, with no annotations written.
What you built
| Two models with a relationship | schema, indexes and validators derived from them |
| Ten REST endpoints | routed from method names, documented automatically |
| Per-row permissions | enforced by the database, with no filter in any query |
| Inherited permissions | tasks as private as their project |
| A scheduled job | one decorator |
Next: Permissions & ACL for the patterns behind step 3, and Queries for everything step 5 skipped.