Record APIs
The easiest and most type-safe path to access you TABLE
s and VIEW
s is to use
TrailBase’s restful CRUD Record APIs.
The only requirements are:
- Tables and views need to be
STRICT
ly1 typed to guarantee type-safety all the way from your records, via JSON schema, to your client-side language bindings 2. - They need to have a sequential primary key column to allow for stable sorting
and thus efficient cursor-based pagination. Either an explicit
INTEGER
or UUIDv7PRIMARY KEY
will do, includingFOREIGN KEY
columns.
Configuring APIs
Record APIs can be configured through the admin dashboard or immediately in TrailBase’s configuration file. Note that there are certain features that aren’t yet exposed in the dashboard, like supporting multiple APIs based on the same table or view. In this case you can drop down to the configuration to set up as many as you like allowing for a lot of extra flexibility around permissions and visibility.
An example API setup for managing user avatars:
A quick explanation:
- The
name
needs to be unique. It’s what is used to access the API via<https://myhost>/api/v1/records/<name>/
. table_name
references the table or view that is being exposed.conflict_resolution
declares what should happen if a newly created record is conflicting with an existing one.autofill_missing_user_id_column
lets you omit fields for columns with a foreign key relationship to_user(id)
. The field will then be filled with the credentials of the authenticated user. In most cases, this should probably be off, this only useful if you cannot explicitly provide the user id yourself, e.g. in a static HTML form.acl_world
andacl_authenticated
define that anyone can read avatars but only authenticated users can modify them. The followingaccess_rules
further narrow mutations to records where theuser
column (or request field for insertions) match. In other words, user X cannot modify user Y’s avatar.
Access Control
Access can be controlled through combination of a simple ACL-based system (a
matrix of who and what) and custom SQL access rules of the nature:
f(req, user, row) -> bool
.
Generally, the ACLs are checked first and then the access rules are evaluated
when present.
For example, to validate that the requestor provided a secret key and is member of a group “mygroup”:
_REQ_
is an injected sub-query containing the request fields. It is available in access rules forCREATE
andUPDATE
operations.- Similarly,
_ROW_
is a sub-query of the target record. It is available in access rules forREAD
,UPDATE
, andDELETE
operations. - Lastly,
_USER_.id
references the id of the currently authenticated user andNULL
otherwise.
Independently, you can use VIEW
s to filter which rows and columns of
your TABLE
s should be accessible.
Building access groups and capabilities
As hinted at by the example above, the SQL access rules can be used to
build higher-level access protection such as group ACLs or capabilities.
What makes the most sense in your case, is very application dependent.
The <repo>/examples/blog
has an “editor” group to control who can write blog
posts.
Somewhat on a tangent and pretty meta, group and capability tables can themselves be exposed via Record APIs. This can be used to programmatically manage permissions, e.g. for building a moderation dashboard. When exposing authorization primitives, make sure the permissions are appropriately tight to avoid permission escalations.
Write-only columns
Columns with names starting with an underscore can be written on insert or update but are hidden on reads. This is meant as a convenient convention to allow for internal data fields, e.g hiding the record owner in an otherwise public data set or hiding a user’s internal credit rating from their profile. A similar effect could otherwise be achieved by exposing a table for inserts and updates only while poxying reads through a VIEW.
Accessing Record APIs
After configuring the APIs and setting up permissions, record APIs expose six main endpoints3:
- Create: endpoint for for inserting new and potentially overriding records
depending on conflict resolution strategy.
POST /api/v1/records/<record_api_name>
- Read: endpoint for reading specific records given the record id.
GET /api/v1/records/<record_api_name>/<url_safe_b64_record_id>
- Update: partial updates to existing records given a record id and subset of fields
PATCH /api/v1/records/<record_api_name>/<url_safe_b64_record_id>
- Delete: endpoints for deleting record given a record id.
DELETE /api/v1/records/<record_api_name>/<url_safe_b64_record_id>
- List: endpoint for listing, filtering and sorting records based on the
configured read access rule and provided filters.
GET /api/v1/records/<record_api_name>?<params>
- Schema: endpoint for reading the APIs JSON schema definition. Can be used for
introspection and to drive code generation.
GET /api/v1/records/<record_api_name>/schema
All of the above endpoints can be interacted with through requests that are
either JSON encoded, url-encoded, or multipart/form-data
encoded, which makes
them accessible via rich client-side applications, progressive web apps, and
static HTML forms alike.
Listing, filtering & sorting records
Using the GET /api/v1/records/<record_api_name>?<params>
endpoint and given
sufficient permissions one can query records based the given read_access_rule
and query parameters.
Parameters:
- Pagination can be controlled with two parameters:
limit=N
(with a hard limit of 1024) andcursor=<primary key>
. - Ordering can be controlled via
order=[[+-]?<column_name>]+
, e.g.order=created,-rank
, which would sort records first by theircreated
column in ascending order (same as ”+”) and then by therank
column in descending order due to the ”-”. - Lastly, one can filter records by matching against one or more columns like
<column_name>[op]=<value>
, e.g.revenue[gt]=0
to request only records with revenue values “greater than” 0. The supported operators are:- equal, is the empty operator, e.g.
?success=TRUE
. - not|ne: not equal
- gte: greater-than-equal
- gt: greater-than
- lte: less-than-equal
- lt: less-than
- like: SQL
LIKE
operator - re: SQL
REGEXP
operator
- equal, is the empty operator, e.g.
For example, to query the 10 highest grossing movies with a watch time less than 2 hours and an actor called John, one could query:
File Upload
Record APIs can also support file uploads and downloads. There’s some special handling in place so that only metadata is stored in the underlying table while the actual files are kept in an object store.
By adding a TEXT
column with a CHECK(jsonschema('std.FileUpload'))
constrained to your TABLE, you instruct TrailBase to store file metadata as
defined by the “std.FileUpload” JSON schema and write the contents off to
object storage.
Files can then be upload by sending the contents as part your JSON or
multipart/form-data
POST request.
Downloading files is slightly different, since reading the column through
record APIs will only yield the metadata. There’s a dedicated GET API endpoint
for file downloads:
/api/v1/records/<record_api_name>/<record_id>/file/<column_name>
Custom JSON Schemas
Akin to std.FileUpload
above, you can register your own nested JSON schemas
to be used with column CHECK
s.
For now, the dashboard only allows viewing all registered JSON schemas, however
you can register schemas using the configuration:
Once registered, schemas can be added as column constraints:
When generating new client-side bindings for a table or view with such nested schemas, they will be included ensuring type-safety all the way to the client-side APIs.
Tangent: Querying JSON
Independent of type-safety and Record APIs, SQLite has first-class support for querying nested properties of columns containing JSON in textual or binary format 4. For example, given a table:
You can query the names of red items:
Note that this requires SQLite to scan all rows and deserialize the JSON. Instead, storing the color of items in a separate, indexed column and filter on it would be a lot more efficient. Yet, using JSON for complex structured or denormalized data can be powerful addition to your toolbox.
There’s also a few other endpoints, e.g. for downloading files as described later in the document.
Footnotes
-
By default, SQLite are not strictly typed. Column types merely express type-affinities. Unless tables are explicitly created as
STRICT
columns can store any data type. ↩ -
Views are more tricky to strictly type, since they’re the result of an arbitrary
SELECT
statement. TrailBase parses theCREATE VIEW
statement and will allow record APIs only on top of a conservative subset, where it can infer the column types. Over time, TrailBase will be able to support larger subsets. Let us know if you have provably strictly typed queries that you think should be supported but aren’t. ↩ - ↩
-
Record APIs only support textual JSON. Binary JSON is more compact and more efficient to parse, however its actual encoding is internal to SQLite and thus opaque to TrailBase. ↩