Repository navigation
SQL Database Support
A database config maps URL routes to SQL queries. API Dock runs each query with DuckDB over files (local disk, S3, GCS, Azure, HTTP(S)) or on PostgreSQL, and returns the rows as JSON.
# api_dock_config/config.yaml
databases:
- users_db# api_dock_config/databases/users_db.yaml
name: users_db
description: User records
tables:
users: s3://my-bucket/users.parquet
permissions: s3://my-bucket/permissions.parquet
routes:
- route: users
sql: SELECT [[users]].* FROM [[users]]
- route: users/{{user_id}}
sql: SELECT [[users]].* FROM [[users]] WHERE [[users]].user_id = {{user_id}}GET /users_db/users/42 runs
SELECT users.* FROM 's3://my-bucket/users.parquet' AS users WHERE users.user_id = ? -- ? = '42'and returns [{"user_id": 42, "name": "...", ...}].
List each database under databases: in the main config. API Dock reads its config from the databases/ folder next to the main config file:
| Layout | URL |
|---|---|
databases/<name>.yaml |
/<name>/<route> |
databases/<name>/<version>.yaml (one file per version) |
/<name>/<version>/<route>, and /<name>/latest/<route>
|
See Versioning for how versions and latest work. A database/version can also be defined inline in the shared databases/config.yaml (slugs); see Shared Database Config.
| Key | Required | Description |
|---|---|---|
name |
no | The database's name, for people reading the config. The URL uses the name listed in the main config. |
description, authors
|
no | Descriptive metadata. |
schema |
no | A shared schema (from databases/config.yaml) that unqualified [[table]] references fall back to. See Shared Database Config. |
tables |
no* | Table definitions: name: <definition>. |
queries |
no | Named SQL queries, referenced as [[query_name]]. |
routes |
yes | List of routes, each with route and sql (and optional query_params). |
query_params |
no | Query params applied to every route in this file. See Query Parameters. |
* Tables can also come from the shared config (global tables and schemas), so a version file may have no tables of its own.
Database configs also take the cookies and authentication settings described in Authentication and Cookies.
A table definition is a URI string, or a mapping with uri (or path) plus storage settings:
tables:
users: s3://my-bucket/users.parquet # S3
events: s3://my-bucket/events/**/*.parquet # glob over many files
permissions: gs://my-bucket/permissions.parquet # Google Cloud Storage
posts: az://my-container/posts.parquet # Azure
regions: https://example.org/data/regions.parquet # HTTP(S)
local_data: data/local_data.parquet # local file
audit_log:
uri: s3://my-private-bucket/audit.parquet
region: us-east-1
public: falseThe storage backend comes from the URI prefix: s3:// (or s3a://), gs://, az:// (or azure://), http:///https://, and anything else is a local path (resolved by DuckDB, relative to the directory API Dock runs in).
File formats. Parquet is the tested format. The URI is handed to DuckDB as '<uri>', so DuckDB picks the reader from the file extension: .csv and .json files also work.
| Key | Backend | Meaning |
|---|---|---|
region |
S3 | AWS region. Defaults to AWS_DEFAULT_REGION or AWS_REGION; without one, DuckDB detects it (which can cause redirects). |
public |
S3 |
true: try anonymous access first, then fall back to the AWS credential chain. |
public |
GCS |
true: read without credentials. |
service_account |
GCS | Path to a service account JSON file. DuckDB reads it from the process-wide GOOGLE_APPLICATION_CREDENTIALS, so it's set only if that variable is unset, and one server uses one service account (a different one on another table is ignored with a warning). |
key_id, secret
|
GCS | HMAC key pair. Without them, the GCS credential chain is used. |
endpoint |
GCS | Custom endpoint, used with key_id/secret. |
bearer_token |
HTTP(S) | Sent as Authorization: Bearer <token>. |
auth_headers |
HTTP(S) | Mapping of extra request headers. |
cookies |
HTTP(S) | Mapping sent as a Cookie header. |
Without settings, S3 uses the AWS credential chain (environment variables, ~/.aws files, IAM roles, SSO) and Azure uses the Azure credential chain (for example AZURE_STORAGE_CONNECTION_STRING).
Each request sets up storage access for the version's tables plus any shared tables the query references. S3 region and public can differ from table to table (each such table gets its own path-scoped secret). GCS and HTTP settings are merged into one set per backend, so tables on the same backend should use the same credentials.
Default settings for every table (for example one region for all) go in the shared config's meta; see Shared Database Config.
A mapping with connection and table (instead of uri) is a PostgreSQL table:
tables:
recordings: {connection: core, table: public.recordings}Connections are defined in the shared databases/config.yaml. See PostgreSQL for connections, which engine runs a route, and SQL differences.
Write [[name]] to refer to a table. How it expands depends on where it is:
| Reference | After FROM / JOIN
|
Anywhere else |
|---|---|---|
[[users]] |
's3://my-bucket/users.parquet' AS users |
users |
[[schema.table]] (shared schema) |
schema.table |
table |
[[*.table]], [[group.table]] (union) |
a UNION ALL BY NAME subquery |
not allowed |
Rules:
-
Don't add an alias after an unqualified reference.
FROM [[users]]already ends inAS users, soFROM [[users]] uis invalid SQL. Refer to the table asusers(or[[users]]). - A qualified
[[schema.table]]has no alias, so you may add one:FROM [[birdnet_2p4.detections]] d. - A union reference must be given an alias:
FROM [[*.detections]] detections. See Cross-Schema Queries. - A reference is a table source when it directly follows
FROMorJOIN(any whitespace, or an opening parenthesis, in between), or follows a comma in aFROMlist. Everywhere else it becomes the table name, soFROM [[a]] JOIN [[b]] ON [[a]].id = [[b]].idworks. (Before 0.10.0 a reference counted as a table source wheneverFROMorJOINappeared in the 20 characters before it, which broke short clauses like that one.)
An unqualified name is looked up first in the version's tables, then in its schema, then in the shared global tables. See Shared Database Config.
Put long SQL in queries and use [[query_name]] as a route's whole sql:
queries:
user_permissions: |
SELECT users.user_id, users.name, permissions.permission_name
FROM [[users]]
JOIN [[permissions]] ON users.user_id = permissions.user_id
WHERE users.user_id = {{user_id}}
routes:
- route: users/{{user_id}}/permissions
sql: "[[user_permissions]]"Quote the value in YAML ("[[...]]"), since a bare [[ starts a YAML list. A named query is only recognized when it is the entire sql (or the entire SQL of a Conditional SQL branch); inside longer SQL, [[name]] is read as a table. Named queries can use [[table]] references and {{variables}}.
Each route has a route pattern and sql. {{name}} in the pattern matches one path segment and makes its value available as {{name}} in the SQL:
routes:
- route: groups/{{group_id}}/items
sql: SELECT [[items]].* FROM [[items]] WHERE [[items]].group_id = {{group_id}}- Leading and trailing slashes in the pattern are ignored.
- A request matches a route when it has the same number of segments and every fixed segment is equal. The first matching route in the list wins (a version's own routes come before shared ones).
-
sqlcan also be a rule list that picks a different query per request; see Conditional SQL. -
query_paramsadd filters, sorting, paging and fixed responses driven by the query string; see Query Parameters.
{{variables}} in SQL can come from path segments, query string values, query param defaults, and cookies ({{cookies.<name>}}, for cookies allowed by the cookies setting). {{self.schema}}, {{self.name}} and {{self.version}} give the database/version being queried (see Cross-Schema Queries). A variable with no value gives a 500 SQL query error.
| Request | Response |
|---|---|
GET /<db>/<version>/ (or /<db>/ for an unversioned database) |
{"routes": ["users", "users/{{user_id}}", ...]} |
GET /<db>/ for a versioned database |
{"versions": ["0.1", "0.2"]} |
The route list includes routes added from the shared config.
API Dock never pastes request values into SQL. Each {{variable}} in sql, queries, and query param sql, multivalue_sql and conditional sql becomes a placeholder, and its value is sent to the database separately (a bound parameter). The database converts the value to the column's type, so number, date and boolean filters work as written. A value that isn't valid for its column, such as ?age=25 OR true against an integer column, is rejected with an error instead of being run as SQL.
Because values are sent separately, write variables without quotes:
| Write | Not |
|---|---|
department = {{department}} |
department = '{{department}}' |
UPPER(name) = UPPER({{name}}) |
UPPER(name) = UPPER('{{name}}') |
name ILIKE '%' || {{name}} || '%' |
name ILIKE '%{{name}}%' |
- For compatibility with configs written for 0.8.x and earlier, a string that is exactly one variable (
'{{department}}') is read as{{department}}. - Any other quoted variable, like
'%{{name}}%', would be literal text, so API Dock refuses to start and prints the fixed form. Build patterns with||as in the last row. - A variable can't be a column name (
"{{column}}"), and can't be inside a SQL comment. - A template can't end inside a
--comment, because API Dock appends WHERE conditions andsql_appendclauses on the same line. Put the comment on its own line or use/* */.
sql_append is the exception. Its values are column names, ASC/DESC or numbers, which can't be sent as bound values, so they are written into the SQL text. A value must be a non-negative integer or a comma-separated list of column names (optionally qualified, each optionally followed by ASC/DESC and NULLS FIRST/LAST); see Query Parameters. Any other value gives a 500 SQL query error. The same check applies to the sql_append of a Conditional SQL branch.
When API Dock starts, it checks every database and version listed in the main config, as requests will see them: merged with the main config and with the shared routes and query params that apply to that version. If anything is wrong it refuses to start and names the database, version, route and template:
ValueError: Database 'owl' version '4.0', route '/detections/{{id}}/overlaps': sql: Table 'all_modelz.detections' not found in database configuration
It checks that:
- the shared
databases/config.yamlis valid (slugs,schema_groups, and the types of its sections) - every route and query param has a valid shape (for example,
sqlis a string or a rule list, and each query param has at least one known key) - no
{{variable}}is inside a quoted string (other than a string that is exactly one variable), a quoted identifier or a comment, and no template ends inside a comment - every
[[table]],[[schema.table]],[[*.table]]and[[group.table]]reference resolves, unions are used only afterFROM/JOIN, andsource_columnsis valid - PostgreSQL tables name a known connection, and a route's
engine(if set) is possible
It does not run the SQL, so SQL syntax errors and missing columns show up on the first request.
A successful query returns 200 with a JSON array, one object per row, keyed by column name ([] when no rows match). Values are converted to JSON:
| Database type | JSON |
|---|---|
| date, time, timestamp | ISO 8601 string |
| decimal | number (float; may lose precision) |
| bytes / blob | base64 string |
| UUID, IP address | string |
| interval | number of seconds |
| list, struct / map | array, object (converted item by item) |
Errors are JSON, usually {"error": "<message>"}.
| Status | When |
|---|---|
| 400 | A Conditional SQL rule list matched nothing ("No matching query configuration for the given parameters"), or a required query param is missing. Both can be customized. |
| 404 | Unknown database, version, or route ("Route 'x' not found in database 'db'"). |
500 SQL query error
|
The SQL couldn't be built: unknown table or named query, a variable with no value, or an sql_append value that fails the character check. |
400 Invalid value for a query parameter
|
A request value can't be converted to the type it's compared with, e.g. ?age=abc for an integer column or a timestamp for a number column. detail has the database's message (value and type, no SQL). Since 0.11.0; before, this was a 500. |
500 Database query error
|
The database rejected the query (bad SQL, unknown column, bad data in a table, a permission error). |
503 Database unavailable
|
A PostgreSQL connection can't be reached. See PostgreSQL. |
Authentication failures return the status and body set in the database's authentication config; see Authentication and Cookies.
-
Prefer
COUNT(*)overCOUNT(DISTINCT ...)when rows are already unique. If a join would duplicate rows, aggregate the joined table first, then join:SELECT detections.common_name, COUNT(*) AS count, COALESCE(SUM(revisions.revcount), 0) AS revcount FROM [[detections]] LEFT JOIN ( SELECT observation_id, COUNT(*) AS revcount FROM [[revisions]] GROUP BY observation_id ) revisions ON revisions.observation_id = detections.id
In a real deployment this cut a histogram query's memory use about 10x compared with
COUNT(DISTINCT detections.id)over the joined rows. -
Prefer
sql_appendforORDER BY,GROUP BYandLIMITthat query params control. Query param filters join the base query's top-levelWHEREand go before a trailingGROUP BY/ORDER BY/LIMITin the base SQL (since 0.10.0; earlier versions appended them to the end, which broke such queries).sql_appendclauses are added at the end. See Query Parameters. -
Queries run in worker threads, each on its own in-memory DuckDB connection. On small servers, set DuckDB's
memory_limitandthreadsand cap concurrent queries withmax_concurrent_queriesundersettings.duckdb; see Configuration. -
Use Parquet, and globs (
**/*.parquet) for partitioned data.
Getting started
Remote APIs
Databases
- SQL Database Support
- Query Parameters
- Conditional SQL
- Shared Database Config
- Cross-Schema Queries
- PostgreSQL
- Lookups
Serving
Developing