nomoreide-daemon 0.20.4

The NoMoreIDE daemon: the local HTTP server, its route registry, and the embedded web dashboard.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
//! Reading a registered database: what it holds, what one object looks like,
//! and the rows themselves.
//!
//! Every route here resolves a connection by name and then asks the engine.
//! Nothing writes, and nothing needs the write-access unlock — the connection
//! string is opened read-only, which is what makes this whole file reachable by
//! an agent while `db-write` is not.
//!
//! Three details are the reference's and worth naming, because each looks like
//! a bug until you see the other side of it:
//!
//! * A **missing query parameter is a 400** with its own sentence, while a
//!   failure from the engine is a **500** carrying the driver's words. The
//!   reference's checks sit outside the dispatcher's try/catch and its driver
//!   calls sit inside, so the status says where the refusal came from rather
//!   than how serious it is. `query` is the exception, and says so at its own
//!   handler.
//! * `catalog/rows` bullets out a column whose *name* looks like a secret;
//!   `rows` does not. One is reached by an opaque key from the browser's own
//!   listing, the other by a table name the caller typed. Both are the
//!   reference's.
//! * A row cap arrives through `Number()`, so `0`, `-0`, and a word all mean
//!   "not given" and fall back to 100. What a *negative* cap then means differs
//!   between the two row routes, and each is clamped where the reference clamps
//!   it.

use crate::server::app::AppState;
use crate::server::body::{parse_form, parse_query, percent_decode};
use crate::server::errors::{config_failure, error, method_not_allowed};
use crate::server::query::{js_number, js_number_or};
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{header, HeaderName, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use nomoreide_core::config::{Config, DatabaseDef};
use nomoreide_core::db;
use nomoreide_core::db::{RowBrowseQuery, RowFilter, RowSort};
use nomoreide_core::js_json;
use serde_json::{json, Value};

pub(crate) fn routes() -> Router<AppState> {
    Router::new()
        .route(
            "/api/databases/:name/catalog/capabilities",
            get(capabilities).fallback(method_not_allowed),
        )
        .route(
            "/api/databases/:name/catalog/schemas",
            get(schemas).fallback(method_not_allowed),
        )
        .route(
            "/api/databases/:name/catalog/objects",
            get(objects).fallback(method_not_allowed),
        )
        .route(
            "/api/databases/:name/catalog/details",
            get(details).fallback(method_not_allowed),
        )
        .route(
            "/api/databases/:name/catalog/rows",
            get(catalog_rows).fallback(method_not_allowed),
        )
        .route(
            "/api/databases/:name/catalog/export",
            get(export).fallback(method_not_allowed),
        )
        .route(
            "/api/databases/:name/tables",
            get(tables).fallback(method_not_allowed),
        )
        .route(
            "/api/databases/:name/rows",
            get(rows).fallback(method_not_allowed),
        )
        .route(
            "/api/databases/:name/query",
            post(query).fallback(method_not_allowed),
        )
}

/// Which object kinds this engine has, and which details it can describe.
///
/// Answered from the engine's name alone, so it succeeds for a connection
/// nothing can reach. That is the reference's behaviour and the useful one: the
/// browser draws its shell from this before it has connected to anything.
async fn capabilities(State(state): State<AppState>, uri: Uri) -> Response {
    with_connection(&state, &uri, |database| async move {
        db::capabilities(&database.engine)
            .map(|capabilities| json!({ "ok": true, "capabilities": capabilities }))
    })
    .await
}

async fn schemas(State(state): State<AppState>, uri: Uri) -> Response {
    with_connection(&state, &uri, |database| async move {
        db::peek_schemas(&database).await.map(|schemas| {
            json!({
                "ok": true,
                "schemas": schemas
                    .into_iter()
                    .map(|name| json!({ "name": name }))
                    .collect::<Vec<_>>(),
            })
        })
    })
    .await
}

/// The objects in one schema. A schema this connection does not have is empty
/// rather than an error — the caller asked what is in a place, and the answer
/// is that nothing is.
async fn objects(State(state): State<AppState>, uri: Uri) -> Response {
    let schema = match require_param(&uri, "schema") {
        Ok(schema) => schema,
        Err(message) => return error(StatusCode::BAD_REQUEST, &message),
    };
    with_connection(&state, &uri, |database| async move {
        db::peek_objects(&database, &schema)
            .await
            .map(|objects| json!({ "ok": true, "objects": objects }))
    })
    .await
}

async fn details(State(state): State<AppState>, uri: Uri) -> Response {
    let key = match require_param(&uri, "key") {
        Ok(key) => key,
        Err(message) => return error(StatusCode::BAD_REQUEST, &message),
    };
    with_connection(&state, &uri, |database| async move {
        db::peek_details(&database, &key)
            .await
            .map(|details| json!({ "ok": true, "details": details }))
    })
    .await
}

/// Rows for the browser: reached by an opaque catalog key, filtered and sorted
/// by controls the browser drew, and with any column whose name looks like a
/// secret bulleted out.
async fn catalog_rows(State(state): State<AppState>, uri: Uri) -> Response {
    let key = match require_param(&uri, "key") {
        Ok(key) => key,
        Err(message) => return error(StatusCode::BAD_REQUEST, &message),
    };
    let params = parse_query(&uri);
    // `Number(x) || fallback`: an unreadable cap and a zero cap are the same
    // instruction. A negative one is not, and survives to be clamped by the
    // core, which is where the reference clamps it too.
    let limit = js_number_or(params.get("limit").map(String::as_str), 100.0) as i64;
    let offset = js_number_or(params.get("offset").map(String::as_str), 0.0) as i64;
    let browse = match row_browse_query(&params) {
        Ok(browse) => browse,
        Err(reason) => return throw(&reason),
    };
    with_connection(&state, &uri, |database| async move {
        db::sample_object(&database, &key, Some(limit), Some(offset), Some(browse))
            .await
            .map(|rows| merge(json!({ "ok": true }), json!(rows)))
    })
    .await
}

/// A whole object as a file.
///
/// The only route in the domain that answers with something a browser saves
/// rather than something it renders, which is why it sets four headers rather
/// than none: the format's content type, a `content-disposition` naming the
/// file, `no-store` so a download of live data is never served again from
/// cache, and `nosniff` so a CSV full of `<script>` is not re-read as HTML.
///
/// It is a **read**, so the connection's write unlock has nothing to do with
/// it -- a locked connection exports exactly as an unlocked one does.
///
/// One difference from the reference is deliberate and worth naming: the
/// reference streams rows to the response as it reads them, and this reads the
/// object and then writes it. The bytes are identical, and every case in the
/// parity gate passes either way; what differs is that a table too large to
/// hold in memory is a problem here and not there. Sharing one streaming row
/// source with the desktop app's export is the fix, and it is a change to both
/// surfaces rather than to this route.
async fn export(State(state): State<AppState>, uri: Uri) -> Response {
    let key = match require_param(&uri, "key") {
        Ok(key) => key,
        Err(message) => return error(StatusCode::BAD_REQUEST, &message),
    };
    let format = match parse_query(&uri)
        .get("format")
        .and_then(|value| db::ExportFormat::parse(value))
    {
        Some(format) => format,
        None => return error(StatusCode::BAD_REQUEST, "format must be csv or json"),
    };
    let Some(name) = name_from(&uri) else {
        return error(StatusCode::NOT_FOUND, "Not found");
    };
    let config = match state.config_store.load().await {
        Ok(config) => config,
        Err(reason) => return config_failure(&reason),
    };
    let database = match connection(&config, &name) {
        Ok(database) => database,
        // Everything this route refuses is a 400, including a connection that
        // is not registered: the reference does its resolving inside the same
        // try/catch as the read.
        Err(reason) => return error(StatusCode::BAD_REQUEST, &reason),
    };

    let body = match render_export(&database, &key, format).await {
        Ok(body) => body,
        Err(reason) => return error(StatusCode::BAD_REQUEST, &reason),
    };
    let filename = db::export_filename(&name, &body.object, format, &today());
    (
        [
            (header::CONTENT_TYPE, format.content_type().to_string()),
            (
                header::CONTENT_DISPOSITION,
                db::content_disposition(&filename),
            ),
            (header::CACHE_CONTROL, "no-store".to_string()),
            (
                HeaderName::from_static("x-content-type-options"),
                "nosniff".to_string(),
            ),
        ],
        body.text,
    )
        .into_response()
}

struct Export {
    object: String,
    text: String,
}

async fn render_export(
    database: &nomoreide_core::config::DatabaseDef,
    key: &str,
    format: db::ExportFormat,
) -> Result<Export, String> {
    let object = db::resolve_object(database, key).await?;
    if !matches!(object.kind.as_str(), "table" | "view" | "materializedView") {
        return Err("This database object cannot be exported.".to_string());
    }
    let columns = db::columns_for(database, &object).await?;
    let sql = db::export_sql(database, &object, &columns);
    let result = db::run_query(&database.engine, &database.url, &sql).await?;
    let (mut writer, mut text) = db::ExportWriter::new(format, &columns);
    for row in result.rows {
        text.push_str(&writer.row(row));
    }
    text.push_str(&writer.finish());
    Ok(Export {
        object: object.qualified_name,
        text,
    })
}

/// Today, as the filename spells it.
fn today() -> String {
    chrono::Utc::now().format("%Y-%m-%d").to_string()
}

async fn tables(State(state): State<AppState>, uri: Uri) -> Response {
    with_connection(&state, &uri, |database| async move {
        db::peek_tables(&database)
            .await
            .map(|tables| json!({ "ok": true, "tables": tables }))
    })
    .await
}

/// Rows for a table the caller named. Unlike the browser's reader this one
/// reports what is stored, secret-looking column names included: a caller who
/// typed the table's name is reading their own data, not browsing someone's.
async fn rows(State(state): State<AppState>, uri: Uri) -> Response {
    let table = match require_param(&uri, "table") {
        Ok(table) => table,
        Err(message) => return error(StatusCode::BAD_REQUEST, &message),
    };
    let params = parse_query(&uri);
    let limit = positive_or(params.get("limit").map(String::as_str), 100.0);
    let offset = positive_or(params.get("offset").map(String::as_str), 0.0);
    with_connection(&state, &uri, |database| async move {
        db::peek_sample(&database, &table, limit, offset)
            .await
            .map(|sample| merge(json!({ "ok": true }), sample))
    })
    .await
}

/// One caller-written statement.
///
/// The only route here that answers a failed *read* with a 400 rather than a
/// 500: bad SQL and a read-only violation are things the person typing did, so
/// the reference surfaces them inline in the editor instead of as a server
/// error. A missing `sql` is still a 500, because that check sits outside the
/// same try/catch as everywhere else in this file.
async fn query(State(state): State<AppState>, uri: Uri, body: Bytes) -> Response {
    let form = parse_form(&body);
    let sql = match form
        .get("sql")
        .map(|value| value.trim())
        .filter(|value| !value.is_empty())
    {
        Some(sql) => sql.to_string(),
        None => return throw("sql is required"),
    };
    // Not `js_number_or`: a query's cap refuses a negative outright rather than
    // clamping it, so `Number.isFinite(n) && n > 0` is the whole test.
    let limit = positive_or(form.get("limit").map(String::as_str), 100.0);

    let config = match state.config_store.load().await {
        Ok(config) => config,
        Err(reason) => return config_failure(&reason),
    };
    let Some(name) = name_from(&uri) else {
        return error(StatusCode::NOT_FOUND, "Not found");
    };
    let outcome = match connection(&config, &name) {
        Ok(database) => db::run_capped_query(&database, &sql, limit).await,
        Err(reason) => Err(reason),
    };
    match outcome {
        Ok(result) => Json(merge(json!({ "ok": true }), result)).into_response(),
        Err(reason) => error(StatusCode::BAD_REQUEST, &reason),
    }
}

/// Resolve the connection named in the URI, then answer with it.
///
/// Both failures land where the reference's do: a connection that is not
/// registered and a driver that refused are alike a throw out of the route, so
/// both are a 500 carrying their own sentence.
async fn with_connection<F, Fut>(state: &AppState, uri: &Uri, answer: F) -> Response
where
    F: FnOnce(DatabaseDef) -> Fut,
    Fut: std::future::Future<Output = Result<Value, String>>,
{
    let Some(name) = name_from(uri) else {
        return error(StatusCode::NOT_FOUND, "Not found");
    };
    let config = match state.config_store.load().await {
        Ok(config) => config,
        Err(reason) => return config_failure(&reason),
    };
    let database = match connection(&config, &name) {
        Ok(database) => database,
        Err(reason) => return throw(&reason),
    };
    match answer(database).await {
        Ok(body) => Json(body).into_response(),
        Err(reason) => throw(&reason),
    }
}

fn connection(config: &Config, name: &str) -> Result<DatabaseDef, String> {
    db::peek_connection(&config.databases, name).cloned()
}

/// The browser's filter and sort controls, as they arrive on the URL.
///
/// `filters` is a JSON document in a query parameter, so a malformed one is a
/// parse failure rather than a validation one, and the reference reports the
/// parser's own words. `sortDirection` is checked here and the column is
/// checked against the live catalog later — a direction is knowable without
/// asking the engine anything, and a column is not.
fn row_browse_query(
    params: &std::collections::HashMap<String, String>,
) -> Result<RowBrowseQuery, String> {
    let filters = match params.get("filters").filter(|raw| !raw.is_empty()) {
        Some(raw) => {
            let parsed = js_json::parse(raw)?;
            if !parsed.is_array() {
                return Err("filters must be a JSON array".to_string());
            }
            serde_json::from_value::<Vec<RowFilter>>(parsed).map_err(|error| error.to_string())?
        }
        None => Vec::new(),
    };
    let direction = params
        .get("sortDirection")
        .filter(|value| !value.is_empty());
    if let Some(direction) = direction {
        if direction != "asc" && direction != "desc" {
            return Err("sortDirection must be asc or desc".to_string());
        }
    }
    let sort = params
        .get("sortColumn")
        .filter(|column| !column.is_empty())
        .map(|column| RowSort {
            column: column.clone(),
            // A column with no direction sorts ascending, and so does one whose
            // direction is anything but `desc` -- which cannot happen, because
            // an unreadable direction was refused above.
            direction: if direction.map(String::as_str) == Some("desc") {
                "desc".to_string()
            } else {
                "asc".to_string()
            },
        });
    Ok(RowBrowseQuery { filters, sort })
}

/// `Number.isFinite(n) && n > 0 ? n : fallback`.
fn positive_or(value: Option<&str>, fallback: f64) -> i64 {
    let parsed = js_number(value);
    if parsed.is_finite() && parsed > 0.0 {
        parsed as i64
    } else {
        fallback as i64
    }
}

/// A query parameter the route cannot proceed without.
///
/// Absent and empty are the same thing, because the reference tests the value
/// for truth rather than for presence.
fn require_param(uri: &Uri, key: &str) -> Result<String, String> {
    parse_query(uri)
        .remove(key)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| format!("{key} query param is required"))
}

/// The `:name` segment, decoded once.
fn name_from(uri: &Uri) -> Option<String> {
    let path = uri.path().strip_prefix("/api/databases/")?;
    let segment = path.split('/').next()?;
    if segment.is_empty() {
        return None;
    }
    Some(percent_decode(segment))
}

/// `{ ok: true, ...result }`, with `ok` written first.
fn merge(mut head: Value, tail: Value) -> Value {
    if let (Some(head), Value::Object(tail)) = (head.as_object_mut(), tail) {
        for (key, value) in tail {
            head.insert(key, value);
        }
    }
    head
}

/// What an uncaught throw becomes in the reference's dispatcher.
fn throw(message: &str) -> Response {
    error(StatusCode::INTERNAL_SERVER_ERROR, message)
}