Extending the API
How to add new endpoints to the Frappe/Konsol API layer.
Architecture
The API lives in konsol/api.py within the Frappe app. It uses Frappe's @frappe.whitelist() decorator to expose Python functions as HTTP endpoints.
Excel VBA → HTTP POST → Frappe (Konsol) → ClickHouse SQL → JSON response
Frappe @whitelist Pattern
Basic Endpoint
import frappe
@frappe.whitelist()
def my_endpoint(param1, param2="default"):
"""Docstring shown in API docs."""
# Frappe handles auth — only logged-in users can call this
return {"result": param1}
Accessible at: GET /api/method/konsol.api.my_endpoint?param1=value
Guest Endpoint
@frappe.whitelist(allow_guest=True)
def public_endpoint():
return {"status": "ok"}
POST-Only Endpoint
@frappe.whitelist(methods=["POST"])
def submit_data():
data = frappe.request.get_data(as_text=True)
import json
payload = json.loads(data)
# process payload
return {"received": len(payload)}
ClickHouse Query Pattern
The existing API uses a direct HTTP connection to ClickHouse with parameterized queries. The connection details live in the konsol.clickhouse module — api.py imports them rather than reading EPM Settings directly:
from konsol.clickhouse import connection_url as _ch_url
from konsol.clickhouse import get_connection as _get_ch_connection
Getting a ClickHouse Connection
konsol.clickhouse.get_connection() reads the connection settings from the EPM Settings single and returns a dict with host, port, user, password, secure, and verify:
from konsol.clickhouse import get_connection
ch_settings = get_connection()
# {"host": ..., "port": ..., "user": ..., "password": ...,
# "secure": <bool>, "verify": <bool>}
connection_url(conn) builds the base URL from that dict, choosing https when conn["secure"] is truthy.
Executing a Query
The credentials are passed as an HTTP auth tuple — not concatenated into the URL or appended as query params — and the request honours the connection's verify (TLS) flag:
import requests
from konsol.clickhouse import connection_url as _ch_url
def _clickhouse_query(sql, params, ch_settings):
url = _ch_url(ch_settings)
query_params = dict(params)
query_params["query"] = sql
resp = requests.get(
url,
params=query_params,
auth=(ch_settings["user"], ch_settings["password"]),
timeout=30,
verify=ch_settings.get("verify", True),
)
resp.raise_for_status()
return resp.text.strip()
For statement-style execution (DDL, inserts) the module also exposes konsol.clickhouse.execute(sql, params=None), which POSTs the query with the same auth tuple.
Parameterized Queries (Security)
Always use ClickHouse's parameterized query format to prevent SQL injection:
sql = """
SELECT data_area_id, sum(period_net_amount) as total
FROM epm_gold.gold_trial_balance
WHERE data_area_id = {entity:String}
AND fiscal_year = {year:Int32}
GROUP BY data_area_id
"""
params = {"param_entity": "USMF", "param_year": "2024"}
result = _clickhouse_query(sql, params, ch_settings)
Parameter format: {name:Type} in SQL, param_name=value in query params.
Supported types: String, Int32, UInt16, Float64, etc.
Measure Safety
Measures and other identifiers that come from user input are validated against the registry (see below) and re-checked with a safe-identifier regex before being interpolated into SQL. api.py uses:
import re
_SAFE_IDENTIFIER = re.compile(r'^[a-z][a-z0-9_]*$')
# Fully-qualified ClickHouse table: schema.table, lowercase identifiers only.
_SAFE_TABLE_NAME = re.compile(r'^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$')
if not _SAFE_IDENTIFIER.match(measure):
# reject — never interpolate an unvalidated identifier
...
The table name itself comes from the Fact Table record but is also validated with _SAFE_TABLE_NAME before it reaches the FROM clause, as defence against a tampered or mistyped clickhouse_table value.
Adding a New Scenario / Fact / Measure
Scenarios, source tables, and allowed measures are not hard-coded in api.py. There are no SCENARIO_TABLES or ALLOWED_MEASURES dicts. Validation is registry-driven: it reads from the Fact Table and Measure doctypes at request time. To make a new Gold model queryable through =EPM(), you create records — no code change is required.
The validation flow lives in api.py._resolve_and_validate() (and the matching grouping in _batch_query_clickhouse()):
_get_fact(fact=..., scenario=...)looks up a Fact Table record byfact_name(wins) orscenario_key._get_allowed_measures(fact)parses the fact'smeasuresJSON; the request's measure must be in that set and in_published_measures()(the set ofMeasurerecords withstatus = "Published")._get_fact_dimensions(fact)parses the fact'sdimensionsJSON; every requested dimension must be allowed by the fact.
1. Create the Measure record(s)
Create a Measure doctype record (Module: EPM) for each measure the new fact exposes. Key fields:
measure_name— e.g.forecast_amount(the value clients pass asmeasure=)expression— SQL expression, e.g.sum(forecast_amount)label,cube_type(sum/count/avg)status— must be Published for the measure to validate
2. Create the Fact Table record
Create a Fact Table doctype record (Module: EPM) pointing at the Gold model:
fact_name— e.g.forecast(the value clients can pass asfact=; autoname isfield:fact_name)scenario_key— e.g.forecast(the value clients pass asscenario=)clickhouse_table— e.g.epm_gold.gold_forecast_model(validated as lowercaseschema.table)has_scenario_id— check if the table supportsscenario_idfilteringfact_measures(child table) — add a row per allowed Measure; each row is validated to reference a Published Measure. Saving mirrors these rows into the hiddenmeasuresJSON field thatapi.pyreads.fact_dimensions(child table) — the dimensions this fact exposes; mirrored into the hiddendimensionsJSON field.status— set to Published. Use the record's Publish action to apply schema (ClickHouse DDL + dbt source where applicable) and trigger a rebuild.
Optional reroute fields (reroute_measure / reroute_table / reroute_column) let a single measure resolve against a different table/column.
3. Test
curl "http://localhost:8069/api/method/konsol.api.epm_value?\
entity=USMF&year=2025&period=5&account=401100&\
measure=forecast_amount&scenario=forecast" \
-b cookies.txt
You can also target a fact directly with fact=forecast; fact wins over scenario when both are supplied.
Adding a Standalone Endpoint
For endpoints that don't follow the EPM value pattern:
from konsol.clickhouse import get_connection as _get_ch_connection
@frappe.whitelist()
def get_entities():
"""Return all legal entities."""
ch = _get_ch_connection()
sql = "SELECT DISTINCT data_area_id, entity_name FROM epm_gold.gold_trial_balance ORDER BY data_area_id"
result = _clickhouse_query(sql, {}, ch)
entities = []
for line in result.split('\n'):
if line.strip():
parts = line.split('\t')
entities.append({"id": parts[0], "name": parts[1] if len(parts) > 1 else parts[0]})
return entities
Error Handling
Use Frappe's exception types. To validate a scenario/fact/measure, resolve it against the registry rather than a hard-coded dict — _resolve_and_validate() returns (fact_doc, error_or_None) and already builds the "Allowed: ..." list from the Fact Table / Measure records:
import frappe
@frappe.whitelist()
def validated_endpoint(scenario, measure="period_net_amount"):
fact_doc, err = _resolve_and_validate(None, scenario, measure, dim_names=[])
if err:
# err already lists the valid scenarios/measures from the registry
frappe.throw(err, frappe.ValidationError)
# ... proceed with fact_doc.clickhouse_table
Response Format
Frappe wraps return values in {"message": <your_return_value>}. The VBA module and other clients expect this wrapper.
# In Python:
return {"value": 123.45}
# Client receives:
# {"message": {"value": 123.45}}
Next Steps
- API Reference — Existing endpoint documentation
- Developer Overview — Repo structure
- Contributing — PR process