Record APIs
TrailBase’s Record APIs allow you to easily configure endpoints exposing your
TABLE
s and VIEW
s though type-safe restful
CreateReadUpdateDelete operations, composite list queries and
even realtime change subscriptions.
For your data to be exposed via Record APIs:
TABLE
s andVIEW
s need to beSTRICT
ly1 typed to guarantee type-safety all the way from your DB records, over JSON schemas, to your client-side language bindings2.- To allow for stable sorting and thus efficient cursor-based pagination,
TABLE
s are required to have eitherINTEGER
, UUIDv4 or UUIDv7 primary key columns.
Configuration
Section titled “Configuration”Record APIs can be configured via the admin dashboard or direclty in TrailBase’s configuration file.
An example API setup for managing user profiles:
record_apis: [ { name: "profiles" table_name: "profiles" conflict_resolution: REPLACE acl_authenticated: [READ, CREATE] create_access_rule: "_REQ_.user = _USER_.id" },]
A quick explanation:
- The
name
needs to be unique. It’s what is used to access the API viahttp://<host>/api/records/v1/name
table_name
references theTABLE
orVIEW
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.
Note that and TABLE
/VIEW
can be exposed multiple times as different APIs
with different properties.
Permissions
Section titled “Permissions”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 requester provided a secret key and is member
of a group 'mygroup'
:
(_REQ_.secret = 'foo' AND EXISTS( SELECT 1 FROM groups WHERE groups.member = _USER_.id AND groups.name = 'mygroup' ))
_REQ_
is an injected sub-query containing a user request’s fields. It is available in the access rules forCREATE
andUPDATE
operations. Fields that were either absent in the request or explicitly passed asnull
, will beNULL
.- To check for the presence of a specific field in a
CREATE
orUPDATE
request, you can use'field' IN _REQ__FIELDS_
. Note that_REQ_FIELDS_
may only contain fields with a corresponding column in the underlyingTABLE
orVIEW
. - Similarly,
_ROW_
is a sub-query of the target record. It is available in the 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
Section titled “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, 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.
VIEW
-based APIs
Section titled “VIEW-based APIs”VIEW
s can support a variety of use-cases, e.g.: read-only APIs on a subset of
columns, APIs exposing complex joins across many tables, computed values across
many columns, etc.
Note that computed columns require a top-level CAST
expression for TrailBase
to determine the resulting type and satisfy the type-safety requirements for
record APIs, e.g.:
CREATE VIEW profile_view AS SELECT p.*, -- The CAST is needed to determine the type of the result. CAST(IIF(editors.user IS NULL, FALSE, TRUE) AS BOOLEAN) AS editor FROM profiles AS p LEFT JOIN editors ON p.user = editors.user;
Write-only columns
Section titled “Write-only columns”Columns with names starting with an underscore can be written on insert or
updated but are hidden during read/list operations.
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 only allowing create/update
operations on the table and exposing a subset of columns through a readable
VIEW
.
However, this would lead to different API endpoints for read vs create/update.
Access
Section titled “Access”After setting up your API, TrailBase will expose the following main endpoints3:
- Create:
POST /api/records/v1/<api_name>
- Read:
GET /api/records/v1/<api_name>/<url-safe_b64_uuid_or_int>
- Update:
PATCH /api/records/v1/<api_name>/<url-safe_b64_uuid_or_int>
- Delete:
DELETE /api/records/v1/<api_name>/<url-safe_b64_uuid_or_int>
- List:
GET /api/records/v1/<api_name>?<search_params>
- Change Subscriptions:
GET /api/records/v1/<api_name>/subscribe/[*|<url-safe_b64_uuid_or_int>]
- Schema:
GET /api/records/v1/<api_name>/schema
All of the endpoints accept requests that are 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.
Create
Section titled “Create”The create endpoint lets you insert new records and potentially override existing ones depending on conflict resolution strategy.
import type { Client } from "trailbase";
export const create = async (client: Client): Promise<string | number> => await client.records("simple_strict_table").create({ text_not_null: "test" });
import 'package:trailbase/trailbase.dart';
Future<RecordId> create(Client client) async => await client .records('simple_strict_table') .create({'text_not_null': 'test'});
import TrailBase
func create(client: Client) async throws -> RecordId { try await client.records("simple_strict_table").create( record: ["text_not_null": "test"])}
using TrailBase;using System.Text.Json.Nodes;
public partial class Examples { public static async Task<RecordId> Create(Client client) => await client.Records("simple_strict_table").Create( new JsonObject { ["text_not_null"] = "test" });}
use trailbase_client::Client;
pub async fn create(client: &Client) -> anyhow::Result<String> { Ok( client .records("simple_strict_table") .create(serde_json::json!({ "text_not_null": "test", })) .await?, )}
from trailbase import Client, RecordId
def create(client: Client) -> RecordId: return client.records("simple_strict_table").create({"text_not_null": "test"})
curl \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${AUTH_TOKEN}" \ --request POST \ --data '{"text_not_null": "test"}' \ http://localhost:4000/api/records/v1/simple_strict_table
The read endpoint lets you read specific records given their id.
import type { Client } from "trailbase";
export const read = async (client: Client, id: string | number) => await client.records("simple_strict_table").read(id);
import 'package:trailbase/trailbase.dart';
Future<Map<String, dynamic>> read(Client client, RecordId id) async => await client.records('simple_strict_table').read(id);
import TrailBase
func read(client: Client, id: RecordId) async throws -> SimpleStrict { try await client.records("simple_strict_table").read(recordId: id)}
using TrailBase;using System.Text.Json.Nodes;
public partial class Examples { public static async Task<JsonNode?> Read(Client client, RecordId id) => await client.Records("simple_strict_table").Read<JsonNode>(id);}
use trailbase_client::{Client, RecordId};
pub async fn read(client: &Client, id: impl RecordId<'_>) -> anyhow::Result<serde_json::Value> { Ok(client.records("simple_strict_table").read(id).await?)}
from trailbase import Client, RecordId, JSON_OBJECT
def read(client: Client, id: RecordId) -> JSON_OBJECT: return client.records("simple_strict_table").read(id)
curl \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${AUTH_TOKEN}" \ http://localhost:4000/api/records/v1/simple_strict_table/${RECORD_ID}
Update
Section titled “Update”The update endpoint lets you modify, i.e. partially update, existing records given their id
import type { Client } from "trailbase";
export const update = async ( client: Client, id: string | number, record: object,) => await client.records("simple_strict_table").update(id, record);
import 'package:trailbase/trailbase.dart';
Future<void> update(Client client, RecordId id) async => await client .records('simple_strict_table') .update(id, {'text_not_null': 'updated'});
import TrailBase
func update(client: Client, id: RecordId) async throws { try await client.records("simple_strict_table").update( recordId: id, record: ["text_not_null": "updated"])}
using TrailBase;using System.Text.Json.Nodes;
public partial class Examples { public static async Task Update(Client client, RecordId id) => await client.Records("simple_strict_table").Update( id, new JsonObject { ["text_not_null"] = "updated" });}
use trailbase_client::{Client, RecordId};
pub async fn update(client: &Client, id: impl RecordId<'_>) -> anyhow::Result<()> { Ok( client .records("simple_strict_table") .update( id, serde_json::json!({ "text_not_null": "updated", }), ) .await?, )}
from trailbase import Client, RecordId
def update(client: Client, id: RecordId): client.records("simple_strict_table").update( id, { "text_not_null": "updated", }, )
curl \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${AUTH_TOKEN}" \ --request PATCH \ --data '{"text_not_null": "updated"}' \ http://localhost:4000/api/records/v1/simple_strict_table/${RECORD_ID}
Delete
Section titled “Delete”import type { Client } from "trailbase";
export const remove = async (client: Client, id: string | number) => await client.records("simple_strict_table").delete(id);
import 'package:trailbase/trailbase.dart';
Future<void> delete(Client client, RecordId id) async => await client.records('simple_strict_table').delete(id);
import TrailBase
func delete(client: Client, id: RecordId) async throws { try await client.records("simple_strict_table").delete(recordId: id)}
using TrailBase;
public partial class Examples { public static async Task Delete(Client client, RecordId id) => await client.Records("simple_strict_table").Delete(id);}
use trailbase_client::{Client, RecordId};
pub async fn delete(client: &Client, id: impl RecordId<'_>) -> anyhow::Result<()> { Ok(client.records("simple_strict_table").delete(id).await?)}
from trailbase import Client, RecordId
def delete(client: Client, id: RecordId): client.records("simple_strict_table").delete(id)
curl \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${AUTH_TOKEN}" \ --request DELETE \ http://localhost:4000/api/records/v1/simple_strict_table/${RECORD_ID}
The delete endpoints lets you remove a record given its id.
List: Filter, Sort and Paginate
Section titled “List: Filter, Sort and Paginate”Using the GET /api/records/v1/<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 via the following query parameters:
limit=N
, with a built-in default of 50 and a hard limit of 1024 to avoid abuse.cursor=<primary key>
to offset into results using a cursor. Significantly less expensive thanOFFSET
-based pagination.offset=N
to offset into results.count=true
will yield atotal_count
of records in the result. This can be used together withlimit
andcursor
to build pagination UIs.
- Ordering can be controlled using the
order=[[+-]?<column_name>]+
parameter, e.g.order=created,-rank
, which sorts records based on theircreated
column in ascending order first (same as ”+”) and subsequently in descending order by theirrank
column due to the minus prefix. - Filtering can be controlled by passing one or more
filter[<column_name>][op]=<value>
parameters. For example,filter[revenue][$gt]=0
would list records with a positiverevenue
only. - Supported operators are:
- $eq: equal, which is also the default if no explicit operator is
specified, i.e.
?success[$eq]=TRUE
and?success=TRUE
are identical. - $ne: not equal
- $gte: greater-than-equal
- $gt: greater-than
- $lte: less-than-equal
- $lt: less-than
- $is: is null or not null, i.e.
?col[$is]=NULL
or?col[$is]=!NULL
, respectively - $like: SQL
LIKE
operator, e.g.?col[$like]=%something%
- $re: SQL
REGEXP
operator, e.g.?col[$re]=^something$
- $eq: equal, which is also the default if no explicit operator is
specified, i.e.
- Parent records, i.e. records pointed to by foreign key columns, can be
expanded using the
?expand=<col0>,<col
>` parameter, if the respective columns were allow-listed in the API configuration.
For example, to query the top-3 ranked movies with a watch time below 2 hours and “love” in their description:
import type { Client, ListResponse } from "trailbase";
export const list = async (client: Client): Promise<ListResponse<object>> => await client.records("movies").list({ pagination: { limit: 3, }, order: ["rank"], filters: [ { column: "watch_time", op: "lessThan", value: "120", }, { column: "description", op: "like", value: "%love%", }, ], });
import 'package:trailbase/trailbase.dart';
Future<ListResponse> list(Client client) async => await client.records('movies').list( pagination: Pagination(limit: 3), order: ['rank'], filters: [ Filter( column: 'watch_time', op: CompareOp.lessThan, value: '120', ), Filter( column: 'description', op: CompareOp.like, value: '%love%', ), ], );
import Foundationimport TrailBase
func list(client: Client) async throws -> ListResponse<Movie> { try await client .records("movies") .list( pagination: Pagination(limit: 3), order: ["rank"], filters: [ .Filter(column: "watch_time", op: .LessThan, value: "120"), .Filter(column: "description", op: .Like, value: "%love%"), ] )}
using TrailBase;using System.Text.Json.Nodes;
public partial class Examples { public static async Task<ListResponse<JsonObject>> List(Client client) => await client.Records("movies").List( pagination: new Pagination(limit: 3), order: ["rank"], filters: [ new Filter(column:"watch_time", op:CompareOp.LessThan, value:"120"), new Filter(column:"description", op:CompareOp.Like, value:"%love%"), ]);}
use trailbase_client::{Client, CompareOp, Filter, ListArguments, ListResponse, Pagination};
pub async fn list(client: &Client) -> anyhow::Result<ListResponse<serde_json::Value>> { Ok( client .records("movies") .list( ListArguments::new() .with_pagination(Pagination::new().with_limit(3)) .with_order(["rank"]) .with_filters([ Filter::new("watch_time", CompareOp::LessThan, "120"), Filter::new("description", CompareOp::Like, "%love%"), ]), ) .await?, )}
from trailbase import Client, Filter, CompareOp, JSON_OBJECT
def list_movies(client: Client) -> list[JSON_OBJECT]: response = client.records("movies").list( limit=3, order=["rank"], filters=[ Filter(column="watch_time", value="120", op=CompareOp.LESS_THAN), Filter(column="description", value="%love%", op=CompareOp.LIKE), ], )
return response.records
curl --globoff \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${AUTH_TOKEN}" \ --request GET \ 'http://localhost:4000/api/records/v1/movies?limit=3&order=rank&filter[watch_time][$lt]=120&filter[description][$like]=%love%'
Subscribe
Section titled “Subscribe”The streaming subscribe endpoints lets you listen for changes to tables backing an API or specific records given their id. Change events can be insertions, updates, and deletions.
import type { Client } from "trailbase";
export const subscribe = async (client: Client, id: string | number) => await client.records("simple_strict_table").subscribe(id);
export const subscribeAll = async (client: Client) => await client.records("simple_strict_table").subscribe("*");
import 'dart:async';
import 'package:trailbase/trailbase.dart';
Future<Stream<Event>> subscribe(Client client, RecordId id) async => await client.records('simple_strict_table').subscribe(id);
Future<Stream<Event>> subscribeAll(Client client) async => await client.records('simple_strict_table').subscribeAll();
using TrailBase;
public partial class Examples { public static async Task<IAsyncEnumerable<Event>> Subscribe(Client client, RecordId id) => await client.Records("simple_strict_table").Subscribe(id);
public static async Task<IAsyncEnumerable<Event>> SubscribeAll(Client client) => await client.Records("simple_strict_table").SubscribeAll();}
use trailbase_client::{Client, DbEvent, RecordId, Stream};
pub async fn subscribe( client: &Client, id: impl RecordId<'_>,) -> anyhow::Result<impl Stream<Item = DbEvent>> { Ok(client.records("simple_strict_table").subscribe(id).await?)}
pub async fn subscribe_all(client: &Client) -> anyhow::Result<impl Stream<Item = DbEvent>> { Ok(client.records("simple_strict_table").subscribe("*").await?)}
Schema
Section titled “Schema”The schema endpoint allows for reading the APIs JSON schema definition. This can be useful for driving external code generation or introspection in general.
File Uploads
Section titled “File Uploads”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 definition, you instruct TrailBase to store file
metadata as defined by the “std.FileUpload” JSON schema while keeping the contents
in a separate object store.
Files can then be upload by sending their contents as part of your JSON
requests or multipart/form-data
POST requests.
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/records/v1/<api_name>/<url-safe_b64_uuid_or_int>/file/<column_name>
S3 Integration
Section titled “S3 Integration”By default, TrailBase will keep the object store on the local file system under
<data-dir>/uploads
.
Alternatively, one can configure an S3 bucket via the
configuration file,
it’s not yet accessible through the admin dashboard.
If you need support for
other storage backends,
let us know.
Custom JSON Schemas
Section titled “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:
schemas: [ { name: "simple_schema" schema: '{' ' "type": "object",' ' "properties": {' ' "name": { "type": "string" },' ' "obj": { "type": "object" }' ' },' ' "required": ["name"]' '}' }]
Once registered, schemas can be added as column constraints:
CREATE TALE test ( simple TEXT CHECK(jsonschema('simple_schema')),
-- ...) STRICT;
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.
Footnotes
Section titled “Footnotes”-
By default, SQLite is not strictly typed. Column types merely express affinities unless the table is explicitly created as
STRICT
. ↩ -
Views are more tricky to type strictly being the result of an arbitrary
SELECT
statement. For columns that are mapped 1:1 from aSTRICT
ly typedTABLE
the type can be infered. For everything else you can useCAST(col AS <TYPE>)
expressions to assert the types of computed columns. ↩ -
There’s also a few other endpoints, e.g. for downloading files, which will be described further down in the docs. ↩