Tasksapp

A runnable Geblang example.

Path
gebweb/examples/tasks/tasksapp.gb
Category
Tasks

Source

module tasksapp;

/**
 * The Tasks example app. Owner-scoped CRUD for a Task entity, gated
 * by JWT auth, with an admin-only cross-user view and a multipart
 * file-upload endpoint for task attachments.
 *
 * The module exports `build()` which returns a freshly-wired
 * `GebwebApp`. `main.gb` calls `serve()` to start a real HTTP
 * server; `main_test.gb` drives the same app through a `TestClient`
 * without binding a port.
 */

import datetime;
import gebweb;
import gebweb.app as appmod;
import gebweb.repository as repo;
import gebweb.uploads as uploads;

export const JWT_SECRET = "tasks-demo-secret-not-for-prod";

export class CurrentUser {
    string id;
    string name;
    list<string> roles;

    func CurrentUser(string id, string name, list<string> roles) {
        this.id = id;
        this.name = name;
        this.roles = roles;
    }
}

export class Task {
    string id;
    string ownerId;
    string title;
    bool done;
    int createdAt;

    func Task() {
        this.id = "";
        this.ownerId = "";
        this.title = "";
        this.done = false;
        this.createdAt = 0;
    }
}

export class Attachment {
    string id;
    string taskId;
    string filename;
    string contentType;
    bytes data;

    func Attachment() {
        this.id = "";
        this.taskId = "";
        this.filename = "";
        this.contentType = "";
        this.data = bytes.fromString("");
    }
}

/**
 * In-memory task store with owner-aware helpers. The framework's
 * `@ApiResource` auto-CRUD path takes a plain `Repository<T>`; this
 * repo extends that interface with `findForOwner` / `listForOwner`
 * helpers that controllers call directly.
 */
export class TaskRepo implements repo.Repository<Task> {
    dict<string, Task> store;
    int counter;

    func TaskRepo() {
        this.store = {};
        this.counter = 0;
    }

    func find(string id): ?Task {
        if (this.store.contains(id)) {
            return this.store[id];
        }
        return null;
    }

    func findForOwner(string ownerId, string id): ?Task {
        let t = this.find(id);
        if (t == null) { return null; }
        if ((t as Task).ownerId != ownerId) { return null; }
        return t;
    }

    func list(repo.Page page): list<Task> {
        return this.slice(this.store.keys() as list<any>, page);
    }

    func listForOwner(string ownerId, repo.Page page): list<Task> {
        list<any> keys = [];
        for (k, v in this.store.items()) {
            if ((v as Task).ownerId == ownerId) {
                keys = keys.push(k);
            }
        }
        return this.slice(keys, page);
    }

    func count(): int {
        return this.store.length();
    }

    func countForOwner(string ownerId): int {
        int n = 0;
        for (k, v in this.store.items()) {
            if ((v as Task).ownerId == ownerId) {
                n = n + 1;
            }
        }
        return n;
    }

    func save(Task entity): Task {
        if (entity.id == null || entity.id == "") {
            this.counter = this.counter + 1;
            entity.id = "t-" + (this.counter as string);
        }
        if (entity.createdAt == 0) {
            entity.createdAt = datetime.nowUnix();
        }
        this.store[entity.id] = entity;
        return entity;
    }

    func delete(string id): void {
        if (this.store.contains(id)) {
            this.store.delete(id);
        }
    }

    func slice(list<any> keys, repo.Page page): list<Task> {
        list<Task> out = [];
        int start = page.offset;
        int end = start + page.size;
        if (end > keys.length()) { end = keys.length(); }
        for (i in start..<end) {
            out = out.push(this.store[keys[i] as string]);
        }
        return out;
    }
}

/** In-memory attachment store; attachments are owned via taskId. */
export class AttachmentRepo {
    dict<string, Attachment> store;
    int counter;

    func AttachmentRepo() {
        this.store = {};
        this.counter = 0;
    }

    func find(string id): ?Attachment {
        if (this.store.contains(id)) {
            return this.store[id];
        }
        return null;
    }

    func listForTask(string taskId): list<Attachment> {
        list<Attachment> out = [];
        for (k, v in this.store.items()) {
            if ((v as Attachment).taskId == taskId) {
                out = out.push(v as Attachment);
            }
        }
        return out;
    }

    func save(Attachment a): Attachment {
        if (a.id == null || a.id == "") {
            this.counter = this.counter + 1;
            a.id = "a-" + (this.counter as string);
        }
        this.store[a.id] = a;
        return a;
    }
}

export class TaskCreateDTO {
    string title;
}

export class TaskUpdateDTO {
    ?string title;
    ?bool done;
}

export class LoginRequest {
    string username;
}

/** POST /login - mint a demo JWT for one of two seeded users. */
export class LoginController {
    @Post("/login")
    @Summary("Mint a demo JWT")
    func login(LoginRequest body): dict<string, any> {
        if (body.username == "ada") {
            return {"token": mintToken("u-ada", "Ada Lovelace", ["user"]),
                "expires_in": 3600};
        }
        if (body.username == "carla") {
            return {"token": mintToken("u-carla", "Carla", ["user", "admin"]),
                "expires_in": 3600};
        }
        throw gebweb.unauthorized("unknown user");
    }
}

/** Owner-scoped task CRUD. Every method is `@Auth`-gated via the
 *  class decorator. The handlers read the injected `CurrentUser` and
 *  filter the store through `findForOwner` / `listForOwner`. */
@Auth
export class TaskController {
    TaskRepo repo;

    func TaskController(TaskRepo repo) {
        this.repo = repo;
    }

    @Get("/tasks")
    @Summary("List the current user's tasks")
    func list(CurrentUser user): dict<string, any> {
        let page = gebweb.page(0, 50, "createdAt", "desc");
        let items = this.repo.listForOwner(user.id, page);
        return {
            "items": items,
            "total": this.repo.countForOwner(user.id),
        };
    }

    @Post("/tasks")
    @Summary("Create a task owned by the current user")
    func create(CurrentUser user, TaskCreateDTO body): Task {
        let t = Task();
        t.ownerId = user.id;
        t.title = body.title;
        t.done = false;
        return this.repo.save(t);
    }

    @Get("/tasks/{id}")
    @Summary("Read one of the current user's tasks; 404 otherwise")
    func read(CurrentUser user, string id): Task {
        let t = this.repo.findForOwner(user.id, id);
        if (t == null) {
            throw gebweb.notFound("task not found");
        }
        return t as Task;
    }

    @Patch("/tasks/{id}")
    @Summary("Update one of the current user's tasks")
    func update(CurrentUser user, string id, TaskUpdateDTO body): Task {
        let existing = this.repo.findForOwner(user.id, id);
        if (existing == null) {
            throw gebweb.notFound("task not found");
        }
        let t = existing as Task;
        if (body.title != null) { t.title = body.title as string; }
        if (body.done != null) { t.done = body.done as bool; }
        return this.repo.save(t);
    }

    @Delete("/tasks/{id}")
    @Summary("Delete one of the current user's tasks")
    func delete(CurrentUser user, string id): void {
        if (this.repo.findForOwner(user.id, id) == null) {
            throw gebweb.notFound("task not found");
        }
        this.repo.delete(id);
    }
}

/** POST /tasks/{id}/attachments accepts multipart/form-data with one
 *  `file` part. Owner-scoping protects against cross-user upload. */
@Auth
export class AttachmentController {
    TaskRepo tasks;
    AttachmentRepo attachments;

    func AttachmentController(TaskRepo tasks, AttachmentRepo attachments) {
        this.tasks = tasks;
        this.attachments = attachments;
    }

    @Post("/tasks/{id}/attachments")
    @Summary("Upload an attachment for one of the current user's tasks")
    func upload(CurrentUser user, string id,
            dict<string, uploads.UploadedFile> files): dict<string, any> {
        if (this.tasks.findForOwner(user.id, id) == null) {
            throw gebweb.notFound("task not found");
        }
        if (!files.contains("file")) {
            throw gebweb.badRequest("missing 'file' part");
        }
        let f = files["file"];
        let a = Attachment();
        a.taskId = id;
        a.filename = f.filename;
        a.contentType = f.contentType;
        a.data = f.bytes();
        let saved = this.attachments.save(a);
        return {
            "id": saved.id,
            "taskId": saved.taskId,
            "filename": saved.filename,
            "size": f.size(),
        };
    }

    @Get("/tasks/{id}/attachments")
    @Summary("List attachments on one of the current user's tasks")
    func list(CurrentUser user, string id): dict<string, any> {
        if (this.tasks.findForOwner(user.id, id) == null) {
            throw gebweb.notFound("task not found");
        }
        list<dict<string, any>> items = [];
        for (a in this.attachments.listForTask(id)) {
            let real = a as Attachment;
            items = items.push({
                "id": real.id,
                "filename": real.filename,
                "contentType": real.contentType,
                "size": real.data.length,
            });
        }
        return {"items": items};
    }
}

/** Admin-only cross-user view. `@RequiresRole("admin")` implies @Auth. */
export class AdminTaskController {
    TaskRepo repo;

    func AdminTaskController(TaskRepo repo) {
        this.repo = repo;
    }

    @Get("/admin/tasks")
    @Auth
    @RequiresRole("admin")
    @Summary("List every task across all users (admin only)")
    func listAll(): dict<string, any> {
        let page = gebweb.page(0, 100, "createdAt", "desc");
        return {"items": this.repo.list(page), "total": this.repo.count()};
    }

    @Delete("/admin/tasks/{id}")
    @Auth
    @RequiresRole("admin")
    @Summary("Force-delete any task (admin only)")
    func forceDelete(string id): void {
        if (this.repo.find(id) == null) {
            throw gebweb.notFound("task not found");
        }
        this.repo.delete(id);
    }
}

func mintToken(string sub, string name, list<string> roles): string {
    return gebweb.jwtIssue(JWT_SECRET, {
        "sub": sub,
        "name": name,
        "roles": roles,
    }, 3600);
}

func bearerAuthenticator(dict<string, any> request): ?any {
    let headers = (request["headers"] ?? {}) as dict<string, any>;
    let header = (headers["Authorization"] ?? "") as string;
    if (!header.startsWith("Bearer ")) {
        return null;
    }
    let claims = gebweb.jwtVerify(JWT_SECRET, header.substring(7, header.length()));
    if (claims == null) {
        return null;
    }
    let c = claims as dict<string, any>;
    return CurrentUser(
        c["sub"] as string,
        (c["name"] ?? "") as string,
        (c["roles"] ?? []) as list<string>);
}

/**
 * Build and return a fresh app instance. Each call wires a new pair
 * of in-memory repos so callers (tests, the runnable script) get an
 * isolated stack.
 */
export func build(): appmod.GebwebApp {
    let tasks = TaskRepo();
    let attachments = AttachmentRepo();
    let app = gebweb.app([
        LoginController(),
        TaskController(tasks),
        AttachmentController(tasks, attachments),
        AdminTaskController(tasks),
    ]);
    gebweb.setInfo(app, {"title": "Tasks Demo", "version": "0.1.0"});
    gebweb.useAuthenticator(app, CurrentUser, bearerAuthenticator);
    return app;
}