Custom API Definitions
Custom API definitions let you create any HTTP endpoint backed by a sequence of SQL queries and Starlark (Lark) logic steps. Each definition is a YAML document that describes the endpoint URI, database, permissions, and a multi-step logic pipeline.
How It Works
A definition is a YAML document posted to /minimal/rest/definition/v1.
Minimal registers the http.uri as a live endpoint and executes the logic steps in order when it is called.
POST /minimal/rest/definition/v1 — create a new definition
PUT /minimal/rest/definition/v1 — update an existing definition (matched by uri + space)
Each logic step receives the output of the previous step as step_0, step_1, step_N.
Steps marked omit: true pass their output forward but are not returned to the caller.
The last step with omit: false (or the final step) is returned as the API response.
YAML Schema
identifiers
Identifies the org, project, feature, and space the definition belongs to. These can differ from the request headers — headers identify who is deploying; identifiers say where it is deployed.
identifiers:
org_id: <ulid>
project_id: <ulid>
feature_id: <ulid>
space_id: <ulid>
user_id: <ulid>
permission
List of roles allowed to call this endpoint. Requests from users not in this list are rejected with 403.
permission:
[ power, admin ]
database
Database connection used for all sql steps in this definition.
| Field | Description |
|---|---|
type | mysql, postgres, mariadb, or clickhouse |
machine[].host | Database host |
machine[].port | Database port |
username | Database username |
password | Database password |
name | Database name |
timeout_secs | Connection timeout |
idle_timeout_secs | Idle timeout |
max_open_connections | Max open connections |
max_idle_connections | Max idle connections |
http
Defines the live endpoint that Minimal registers.
| Field | Required | Description |
|---|---|---|
uri | ✅ Yes | Custom path — becomes the callable endpoint |
method | ✅ Yes | HTTP method — get, post, put, delete |
version | ✅ Yes | Semantic version — used for rollback |
input_type | ✅ Yes | json or yaml |
output_type | ❌ No | json (default) or yaml or csv or xml or bson |
override_output_type | ❌ No | If true, forces the declared output_type regardless of caller Accept header |
logic
An ordered array of steps. Each step is one of three types:
sql step — executes a SQL query against the configured database:
- sql: |
SELECT * FROM users LIMIT 5
omit: true
lark step — runs inline Starlark code. Previous step output is available as step_0, step_1, etc.:
- lark: step_name
omit: false
code: |
result = []
for row in step_0:
result.append(row)
lark step with load() — imports functions from a saved Lark Module:
- lark: step_name
code: |
load("math", "clamp")
load("strings", "slugify")
result = [slugify(row["name"]) for row in step_0]
expr step — evaluates a CEL-like expression. Useful for lightweight transformations:
- expr: step_name
code: |
{
"total": len(step_0),
"has_data": len(step_0) > 0
}
SQL parameter binding — inject values from a previous Lark step using ?:fieldname:
- sql: |
INSERT INTO users VALUES(?:id, '?:name', '?:email', now('UTC'))
omit: true
Steps are referenced by index — step_0 is the first step, step_1 the second, and so on, regardless of step type.
An omit: true step still increments the index.
config
| Field | Description |
|---|---|
rollback_version | Version to roll back to if this deployment fails |
coalesce | If true, merges multi-row results into a single object |
cache_result | If true, caches the response for repeated calls |
Create or Update a Definition
Examples
The examples below show increasing complexity — start with Example 1 and work up.
Example 1 — SQL + Inline Lark
Fetch all users, mask emails with inline Lark code:
http:
uri: harish/users/all/lark
method: get
version: 0.1
input_type: json
output_type: xml
override_output_type: true
logic:
- sql: |
SELECT * FROM users
omit: true
- lark: enhance_data
code: |
def mask_email(email):
parts = email.split("@")
return parts[0][0] + "***@" + parts[1]
result = []
for row in step_0:
result.append({
"id": row["id"],
"name": row["name"],
"email": mask_email(row["email"]),
"created_at": row["created_at"],
})
Example 2 — Lark with load()
Same as Example 1 but imports clamp from the saved math module:
http:
uri: harish/users/all/lark/load
method: get
version: 0.1
input_type: json
logic:
- sql: |
SELECT * FROM users
omit: true
- lark: enhance_data
code: |
load("math", "clamp")
def mask_email(email):
parts = email.split("@")
return parts[0][0] + "***@" + parts[1]
result = []
for row in step_0:
id = clamp(row["id"], 0, row["id"])
result.append({
"id": id,
"name": row["name"],
"email": mask_email(row["email"]),
"created_at": row["created_at"],
})
Example 3 — Multiple Module Loads
Loads both math.upby10 and strings.slugify:
http:
uri: harish/users/all/lark/load
method: get
version: 0.1
input_type: json
logic:
- sql: |
SELECT * FROM users ORDER BY id DESC
omit: true
- lark: enhance_data
omit: false
code: |
load("math", "upby10")
load("strings", "slugify")
def mask_email(email):
parts = email.split("@")
return parts[0][0] + "***@" + parts[1]
result = []
for row in step_0:
result.append({
"id": upby10(row["id"]),
"name": slugify(row["name"]),
"email": mask_email(row["email"]),
"created_at": row["created_at"],
})
Example 4 — Multi-Step Pipeline with SQL Write
Four chained steps: fetch → transform → build insert row → write → read back.
All intermediate steps are omit: true; only the final SELECT is returned:
http:
uri: harish/users/all/lark/load/complex
method: get
version: 0.1
input_type: json
logic:
- sql: |
SELECT * FROM users ORDER BY id DESC
omit: true # step_0
- lark: enhance_data
omit: true # step_1
code: |
load("math", "upby10")
load("strings", "slugify")
result = []
for row in step_0:
result.append({
"id": upby10(row["id"]),
"name": slugify(row["name"]),
"email": row["email"],
})
- lark: new_row
omit: true # step_2 — picks first row from step_0
code: |
load("math", "upby10")
load("strings", "slugify")
def mask_email(email):
parts = email.split("@")
return parts[0][0] + "***@" + parts[1]
row = step_0[0]
result = {}
result["id"] = upby10(row["id"])
result["name"] = slugify(row["name"])
result["email"] = mask_email(row["email"])
- sql: |
INSERT INTO users VALUES(?:id, '?:name', '?:email', now('UTC'))
omit: true # step_3 — binds from step_2 result
- sql: |
SELECT * FROM users # step_4 — final response
In SQL steps, ?:fieldname binds the value of fieldname from the previous Lark step's result object.
In Example 4, step_3 binds id, name, and email from step_2.
Example 5 — Inline Lark with main() function
Wraps logic in a main() function for cleaner scoping — useful for longer transformations:
logic:
- sql: |
SELECT * FROM users
omit: true
- lark: enhance_data
code: |
def main():
def mask_email(email):
parts = email.split("@")
return parts[0][0] + "***@" + parts[1]
return [
{
"id": row["id"],
"name": row["name"],
"email": mask_email(row["email"]),
"created_at": row["created_at"],
}
for row in step_0
]
result = main()
Example 6 — expr Step
Uses an expr step to produce a lightweight summary object without writing Lark functions:
http:
uri: harish/users/all/expr
method: get
version: 0.1
input_type: json
output_type: json
logic:
- sql: |
SELECT * FROM users
- expr: enhance_data
code: |
{
"version": input.version,
"total_users": len(step_0),
"has_data": len(step_0) > 0
}
Required Headers
| Header | Description |
|---|---|
X-Org-Id | Organisation identifier |
X-Project-Id | Project identifier |
X-Space-Id | Space identifier |
X-User-Id | Authenticated user's identifier |
X-User-Roles | Comma-separated roles (e.g., users,admin) |
Content-Type | Must be application/json |
Common Errors
| Code | Cause |
|---|---|
400 | Malformed YAML body or missing required section |
401 | Missing X-User-Id header |
403 | Caller's roles not in permission list |
404 | Definition not found on PUT — check uri and space |
422 | Starlark syntax error in a lark step |
500 | SQL execution error — check query and database config |