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 astotalin the paginated-list response.findBy(criteria: dict<string, any>): list<T>- filtered list, used when the request carries a?filter=field:valuequery.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>withfind(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.