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