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;