Skip to main content

fraiseql_server/
url_guard.rs

1//! Startup-time validation that the database URL scheme matches a known
2//! FraiseQL adapter, with feature-gated dispatch to the matching adapter.
3//!
4//! `fraiseql-server` and `fraiseql run` dispatch on the URL scheme at startup
5//! to pick the right [`DatabaseAdapter`] implementation. PostgreSQL is always
6//! available; MySQL, SQLite, and SQL Server are gated behind matching Cargo
7//! features (`mysql`, `sqlite`, `sqlserver`).
8//!
9//! Without this guard, pointing the binary at a URL whose scheme is unknown
10//! or whose adapter feature is not enabled would produce an opaque error from
11//! deep inside the driver layer (connection refused, protocol mismatch, or
12//! worse). This module fails fast at startup with a diagnostic naming the
13//! observed scheme so an operator can correct the configuration or rebuild
14//! with the required feature flag.
15//!
16//! [`DatabaseAdapter`]: fraiseql_core::db::DatabaseAdapter
17
18/// Operator-facing sentinel embedded in every guard error message.
19///
20/// Tests assert against this prefix so the diagnostic stays grep-able from
21/// logs even if surrounding wording is reflowed.
22pub const GUARD_MESSAGE_PREFIX: &str = "fraiseql-server: unsupported database URL";
23
24/// Database schemes that the `fraiseql-server` binary can dispatch to.
25///
26/// The enum is exhaustive: every variant corresponds to an adapter that
27/// `main()` / `fraiseql run` know how to construct (subject to Cargo feature
28/// flags). New schemes require an explicit code change here and matching
29/// arms in `build_adapter` / `run_once` / `run_watch_loop`.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum DatabaseScheme {
32    /// `postgresql://` or `postgres://` — always available.
33    Postgres,
34    /// `mysql://` — requires `mysql` Cargo feature.
35    MySql,
36    /// `sqlite://` — requires `sqlite` Cargo feature. Read-only (no
37    /// `SupportsMutations` impl); schemas with mutations are rejected at
38    /// startup.
39    Sqlite,
40    /// `sqlserver://` — requires `sqlserver` Cargo feature.
41    SqlServer,
42}
43
44impl DatabaseScheme {
45    /// The Cargo feature flag required to build the matching adapter into the
46    /// server binary, or `None` for adapters that ship in the default feature
47    /// set.
48    #[must_use]
49    pub const fn required_feature(self) -> Option<&'static str> {
50        match self {
51            Self::Postgres => None,
52            Self::MySql => Some("mysql"),
53            Self::Sqlite => Some("sqlite"),
54            Self::SqlServer => Some("sqlserver"),
55        }
56    }
57}
58
59/// Refuse to start a SQLite-backed server when the compiled schema declares
60/// any mutations.
61///
62/// `SqliteAdapter` deliberately does not implement `SupportsMutations` (the
63/// adapter is read-only by design — see `crates/fraiseql-db/src/sqlite/`).
64/// Without this guard the server would start and then fail every mutation
65/// request at runtime; the diagnostic below tells the operator why and names
66/// the first few offending mutations.
67///
68/// # Errors
69///
70/// Returns `anyhow::Error` when the schema contains one or more mutations.
71/// Callers should invoke this *before* constructing a SQLite adapter; the
72/// PostgreSQL / MySQL / SQL Server paths must not call it.
73pub fn guard_sqlite_mutations(
74    schema: &fraiseql_core::schema::CompiledSchema,
75) -> anyhow::Result<()> {
76    if schema.mutations.is_empty() {
77        return Ok(());
78    }
79    let sample: Vec<&str> = schema.mutations.iter().take(3).map(|m| m.name.as_str()).collect();
80    let suffix = if schema.mutations.len() > sample.len() {
81        format!(", … (+{} more)", schema.mutations.len() - sample.len())
82    } else {
83        String::new()
84    };
85    anyhow::bail!(
86        "fraiseql-server: SQLite is a read-only runtime adapter, but the compiled schema declares \
87         {} mutation(s) which cannot be executed against a SQLite database. Use a postgresql:// / \
88         mysql:// / sqlserver:// URL, or remove the mutations from the schema. Affected: {}{}",
89        schema.mutations.len(),
90        sample.join(", "),
91        suffix,
92    )
93}
94
95/// Parse the URL scheme from a database URL and return the matching
96/// [`DatabaseScheme`].
97///
98/// # Errors
99///
100/// Returns `anyhow::Error` whose message starts with [`GUARD_MESSAGE_PREFIX`]
101/// when the URL has no scheme, an empty scheme, or a scheme that is not one
102/// of the supported four. The message names the observed scheme so the
103/// operator can correct their `fraiseql.toml` or `DATABASE_URL`.
104pub fn parse_database_url(url: &str) -> anyhow::Result<DatabaseScheme> {
105    let scheme = url.split("://").next().unwrap_or("");
106    match scheme {
107        "postgresql" | "postgres" => Ok(DatabaseScheme::Postgres),
108        "mysql" => Ok(DatabaseScheme::MySql),
109        "sqlite" => Ok(DatabaseScheme::Sqlite),
110        "sqlserver" => Ok(DatabaseScheme::SqlServer),
111        "" => anyhow::bail!(
112            "{GUARD_MESSAGE_PREFIX} — the URL has no scheme. Expected one of \
113             postgresql:// | postgres:// | mysql:// | sqlite:// | sqlserver://."
114        ),
115        other => anyhow::bail!(
116            "{GUARD_MESSAGE_PREFIX} (observed URL scheme: {other:?}). The \
117             fraiseql-server binary dispatches on the URL scheme and supports \
118             postgresql:// | postgres:// | mysql:// | sqlite:// | sqlserver:// \
119             only."
120        ),
121    }
122}
123
124#[cfg(test)]
125mod tests;