Skip to main content

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.

FieldDescription
typemysql, postgres, mariadb, or clickhouse
machine[].hostDatabase host
machine[].portDatabase port
usernameDatabase username
passwordDatabase password
nameDatabase name
timeout_secsConnection timeout
idle_timeout_secsIdle timeout
max_open_connectionsMax open connections
max_idle_connectionsMax idle connections

http

Defines the live endpoint that Minimal registers.

FieldRequiredDescription
uri✅ YesCustom path — becomes the callable endpoint
method✅ YesHTTP method — get, post, put, delete
version✅ YesSemantic version — used for rollback
input_type✅ Yesjson or yaml
output_type❌ Nojson (default) or yaml or csv or xml or bson
override_output_type❌ NoIf 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
Step References

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

FieldDescription
rollback_versionVersion to roll back to if this deployment fails
coalesceIf true, merges multi-row results into a single object
cache_resultIf 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
Step parameter binding

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

HeaderDescription
X-Org-IdOrganisation identifier
X-Project-IdProject identifier
X-Space-IdSpace identifier
X-User-IdAuthenticated user's identifier
X-User-RolesComma-separated roles (e.g., users,admin)
Content-TypeMust be application/json

Common Errors

CodeCause
400Malformed YAML body or missing required section
401Missing X-User-Id header
403Caller's roles not in permission list
404Definition not found on PUT — check uri and space
422Starlark syntax error in a lark step
500SQL execution error — check query and database config