Repositories and @ApiResource

The repository pattern abstracts over a persistence backend without hauling in a full ORM. Combined with @ApiResource, a single class declaration generates the six standard CRUD routes for an entity.

The Repository<T> interface

import gebweb.repository as repository;

interface Repository<T> {
    func find(string id): ?T;
    func list(repository.Page page): list<T>;
    func save(T entity): T;
    func delete(string id): void;
}

Repositories are user-implemented:

class WidgetRepo implements repository.Repository<Widget> {
    dict<string, Widget> store;

    func WidgetRepo() {
        this.store = {};
    }

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

    func list(repository.Page page): list<Widget> {
        let keys = this.store.keys();
        list<Widget> out = [];
        for (i in page.offset..<(page.offset + page.size)) {
            if (i >= keys.length()) { break; }
            out = out.push(this.store[keys[i] as string]);
        }
        return out;
    }

    func save(Widget e): Widget {
        if (e.id == null || e.id == "") {
            e.id = "w" + ((this.store.keys().length() + 1) as string);
        }
        this.store[e.id] = e;
        return e;
    }

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

Pagination uses repository.Page (offset, size, sort, direction). Build pages with gebweb.page(offset, size, sort, direction).

Optional repository methods

The framework probes the repository instance for these and uses them when present:

  • count(): int - total count, surfaces as total in the paginated-list response.
  • findBy(criteria: dict<string, any>): list<T> - filtered list, used when the request carries a ?filter=field:value query.
  • listCursor(cursor: Cursor): CursorPage<T> - cursor-based pagination; see below.

Cursor pagination

Page-based listing (/users?page=200) gets slow when the table grows: every page has to count past everything before it. If your tables are big enough to feel that, add a listCursor method to the repository and the framework will switch to "next-link" pagination instead.

class UserRepo {
    func listCursor(gebweb.Cursor c): gebweb.CursorPage<User> {
        let rows = db.query(this.conn,
            "SELECT * FROM users WHERE id > ? ORDER BY id LIMIT ?",
            [c.after, c.size]);
        let next = rows.length() == c.size
            ? repository.encodeCursor((rows[rows.length() - 1] as User).id)
            : null;
        return gebweb.CursorPage(rows, next);
    }
}

Now GET /users returns:

{"items": [...], "nextCursor": "MTAw"}

The client requests the next page with GET /users?after=MTAw. When nextCursor is null, there are no more items. Clients should treat the cursor as opaque. Don't decode it on the client side.

gebweb.Cursor carries after (the cursor from the last request, empty for the first call), size (page size), and an optional sort field. gebweb.CursorPage(items, nextCursor) is the return value the framework JSON-encodes.

@ApiResource

Decorate an entity class with @ApiResource and add a static repositoryClass() method pointing at the repo type. The framework generates six routes:

@ApiResource("/widgets")
class Widget {
    string id;
    string name;
    int weight;

    static func repositoryClass(): any { return WidgetRepo; }
}

let app = gebweb.app([Widget]);
gebweb.register(app, WidgetRepo, func(): WidgetRepo {
    return WidgetRepo();
});
Operation Method Path
LIST GET /widgets
CREATE POST /widgets
GET_ONE GET /widgets/{id}
REPLACE PUT /widgets/{id}
UPDATE PATCH /widgets/{id}
DELETE DELETE /widgets/{id}

A user-declared @Get/@Post/... on the same path overrides the auto-generated route, so you can keep auto-CRUD for five operations and hand-write the sixth.

@ApiResource options

Pass a second-arg options dict to scope the operations or attach serialization groups:

@ApiResource("/widgets", {
    "operations": ["GET", "GET_ONE"],
    "readGroups": ["read", "summary"],
    "writeGroups": ["write"],
})
class Widget { /* ... */ }
  • operations - whitelist of CRUD operations to expose (default: all six).
  • readGroups / writeGroups - default serialization groups for the resource (see Serialization groups).

DI container

Repositories are constructed through the same DI container that wires controllers and the rest of the application. The gebweb.register(app, classRef, factory) shorthand is the typical place to register a repository factory, and gebweb.registerInstance(app, classRef, repo) swaps in a stub for tests:

let repo = WidgetRepo();
let app = gebweb.app([Widget]);
gebweb.registerInstance(app, WidgetRepo, repo);

See Dependency injection for the full container surface: lifecycle model, registration methods, constructor autowiring rules, the @Param("key") decorator, interface-typed parameters, and testing patterns.

Query DSL

Hand-rolling SQL inside a repository is fine for small CRUD, but gets noisy fast. The gebweb.query module (re-exported on the facade) builds parameterised SQL via composable Where and OrderBy builders and a Query chain.

import gebweb;

class UserRepo {
    db.Connection conn;

    func findActive(int limit): list<dict<string, any>> {
        let sql = gebweb.Query("users")
            .where(gebweb.eq("status", "active"))
            .where(gebweb.gt("score", 10))
            .orderBy(gebweb.desc("created_at"))
            .limit(limit)
            .select(["id", "name", "score", "created_at"]);
        return this.conn.query(sql["text"], sql["params"] as list<any>).all();
    }
}

Predicate factories: eq, neq, gt, ge, lt, le, like, in_, isNull, notNull, raw(sql, params). Compose with .and(other), .or(other), .not(). Ordering: asc(field) / desc(field), chain with .then(asc(...)).

The terminal .select(cols) / .count() / .delete() calls return a {text: string, params: list<any>} dict that you hand straight to conn.query(text, params) or conn.exec(text, params). Values are always sent as parameters; the DSL never splices user input into the SQL string.

in_([]) compiles to (1 = 0) so an empty filter set returns zero rows rather than syntax-erroring. The DSL is intentionally small (no joins, no subqueries) - it covers ~80% of repository queries; everything else stays in hand-written SQL via .raw(sql, params) or by calling conn.query directly.

Soft delete

A row that should disappear from default reads but stay in the table for audit or restore uses the soft-delete helpers (1.1.0). The convention is a nullable deleted_at integer column carrying the deletion's Unix time.

class WidgetRepo implements repository.Repository<Widget> {
    db.Conn conn;

    func find(string id): ?Widget {
        let row = this.conn.queryOne(
            "SELECT * FROM widgets WHERE id = ? AND deleted_at IS NULL", id);
        if (row == null) { return null; }
        return Widget(...);
    }

    func list(repository.Page page): list<Widget> {
        let stmt = gebweb.excludeDeleted(query.Query("widgets"))
            .orderBy(query.asc("id"))
            .limit(page.size)
            .offset(page.offset)
            .select([]);
        ...
    }

    func delete(string id): void {
        let w = this.find(id);
        if (w == null) { return; }
        gebweb.markDeleted(w);
        this.conn.exec("UPDATE widgets SET deleted_at = ? WHERE id = ?",
            w.deleted_at, w.id);
    }

    func restore(string id): ?Widget {
        let row = this.conn.queryOne(
            "SELECT * FROM widgets WHERE id = ?", id);
        if (row == null) { return null; }
        let w = Widget(...);
        gebweb.restore(w);
        this.conn.exec("UPDATE widgets SET deleted_at = NULL WHERE id = ?", w.id);
        return w;
    }
}
Helper Behaviour
gebweb.markDeleted(entity) Set entity.deleted_at to now.
gebweb.restore(entity) Set entity.deleted_at to null.
gebweb.isDeleted(entity) Predicate.
gebweb.excludeDeleted(query) Append deleted_at IS NULL to a query.
gebweb.onlyTrashed(query) Append deleted_at IS NOT NULL (admin views).
gebweb.withTrashed(query) No-op pass-through; documents intent at the call site.

Reference

  • Repository interface: gebweb.repository.Repository<T> with find(id), list(page), save(entity), delete(id).
  • Optional repo methods: count(): int, findBy(criteria): list<T>.
  • gebweb.page(offset, size, sort, direction): Page - build a Page.
  • @ApiResource(path, opts?) - decorate the entity class.
  • static func repositoryClass(): any - required by @ApiResource.
  • Query DSL: gebweb.eq / neq / gt / ge / lt / le / like / in_ / isNull / notNull; gebweb.asc / desc; gebweb.Query(table).where(...).orderBy(...).limit(n).offset(m).select(cols), .count(), .delete().
  • DI register / resolve: see Dependency injection for the full reference.