@smbcloud / cli / commits / 4e817ac

wip gresiq

Seto Elkahfi committed Apr 6, 2026 at 15:11 UTC 4e817ace9a5061eba3b6492a98dda3ff74c0e8e0
8 files changed +1294 -24
.agents/skills/smbcloud-gresiq/SKILL.md new
+148
@@ -0,0 +1,148 @@
1 +---
2 +name: smbcloud-gresiq
3 +description: Use when building, debugging, or extending GresIQ, smbCloud's managed PostgreSQL service, especially the Rust crate in `crates/smbcloud-gresiq`, managed connection bootstrap, branch-aware database config, TLS-aware `tokio-postgres` setup, environment-variable loading, and future Neon or Prisma-style service capabilities such as preview branches, pooled connections, and ephemeral credentials.
4 +---
5 +
6 +# smbCloud GresIQ
7 +
8 +Use this skill when the task is about GresIQ, smbCloud's managed PostgreSQL service.
9 +
10 +Applies to:
11 +
12 +- `crates/smbcloud-gresiq/Cargo.toml`
13 +- `crates/smbcloud-gresiq/src/gresiq.rs`
14 +- `crates/smbcloud-gresiq/src/main.rs`
15 +- `crates/smbcloud-gresiq/README.md`
16 +- workspace wiring for new GresIQ dependencies
17 +- managed Postgres bootstrap design, connection policy, and product-shape changes
18 +
19 +## Scope boundary
20 +
21 +This skill is for the managed Postgres service layer and its Rust bootstrap crate.
22 +
23 +Do not use it for:
24 +
25 +- smbCloud auth flows unless the database service change directly affects auth integration
26 +- generic deploy logic
27 +- unrelated CLI packaging or release automation
28 +
29 +Use the relevant smbCloud auth, deploy, or release skills when those are the actual center of gravity.
30 +
31 +## Source of truth
32 +
33 +Start from these files:
34 +
35 +- `crates/smbcloud-gresiq/src/gresiq.rs`
36 +- `crates/smbcloud-gresiq/src/main.rs`
37 +- `crates/smbcloud-gresiq/Cargo.toml`
38 +- `crates/smbcloud-gresiq/README.md`
39 +- workspace `Cargo.toml` when dependency or member wiring changes
40 +
41 +Treat the Rust crate as the source of truth. Keep future CLI, wasm, Tauri, npm, or native wrappers thin.
42 +
43 +## Current service model
44 +
45 +The current GresIQ bootstrap models:
46 +
47 +- smbCloud environments: `development`, `preview`, `production`
48 +- branch-aware database targets
49 +- endpoint host, port, and SSL mode
50 +- credentials and database name
51 +- connection policy such as compute tier, application name, and connect timeout
52 +- redacted connection-string output
53 +- a connectivity health check using `tokio-postgres`
54 +
55 +Preserve these properties unless the task explicitly changes the product model.
56 +
57 +## Product direction
58 +
59 +Take inspiration from Neon, Prisma Postgres, and similar managed database products, but fit the smbCloud platform rather than copying their APIs blindly.
60 +
61 +Good directions:
62 +
63 +- preview branches per app environment
64 +- autoscaling or burst compute defaults
65 +- pooled or proxy-backed connection paths for serverless runtimes
66 +- safe credential handling and redacted logs
67 +- ephemeral or tenant-scoped credentials
68 +- operational checks that surface actionable failures
69 +
70 +Avoid adding speculative product features with no clear interface or storage model.
71 +
72 +## Implementation guidance
73 +
74 +### Crate shape
75 +
76 +Prefer a library-first design:
77 +
78 +- reusable Rust API in `src/gresiq.rs`
79 +- thin binary in `src/main.rs`
80 +
81 +If additional surfaces are added later, bind to the existing Rust API rather than duplicating logic.
82 +
83 +### Configuration
84 +
85 +Make environment loading explicit and typed.
86 +
87 +Expected config areas:
88 +
89 +- smbCloud environment
90 +- project slug
91 +- branch name
92 +- region
93 +- host and port
94 +- SSL mode
95 +- username, password, and database name
96 +- compute or connection policy
97 +
98 +Do not hardcode localhost defaults except in intentionally local examples.
99 +
100 +### Connections
101 +
102 +Prefer correctness over cleverness.
103 +
104 +- use TLS when the endpoint requires it
105 +- keep connection-string generation available for integration points
106 +- provide a redacted form for logs and CLI output
107 +- propagate errors instead of hiding them behind generic failures
108 +
109 +If adding pooling, document whether the crate owns pooling or whether it only models direct connections.
110 +
111 +### Errors
112 +
113 +Use typed error enums for:
114 +
115 +- invalid configuration
116 +- invalid environment variable values
117 +- TLS setup failures
118 +- PostgreSQL connection or query failures
119 +
120 +Do not use `unwrap()` in the service path.
121 +
122 +## Validation
123 +
124 +Use the smallest relevant checks first.
125 +
126 +### Rust
127 +
128 +- `cargo fmt --package gresiq`
129 +- `cargo check -p gresiq`
130 +- `cargo test -p gresiq`
131 +
132 +### Runtime
133 +
134 +If the task changes live connection behavior, validate with a real managed endpoint when credentials are available:
135 +
136 +- set `GRESIQ_*` environment variables
137 +- run `cargo run -p gresiq`
138 +
139 +Do not claim runtime validation happened unless a real endpoint was exercised.
140 +
141 +## Common mistakes
142 +
143 +- treating GresIQ like a one-off example binary instead of a reusable service crate
144 +- logging raw passwords or full unredacted connection strings
145 +- hardcoding insecure defaults for production-facing paths
146 +- mixing up smbCloud environment semantics with database branch semantics
147 +- adding platform wrappers that duplicate the Rust logic
148 +- introducing pooling, proxy, or branch-management claims in the README without implementing the underlying API
.github/workflows/release-homebrew.yml
+1 -1
@@ -52,7 +52,7 @@ jobs:
52 echo "AMD64 SHA256: ${AMD64_SHA}"
53
54 - name: Checkout Homebrew tap
55 - uses: actions/checkout@v4
55 + uses: actions/checkout@v6
56 with:
57 repository: smbcloudXYZ/homebrew-tap
58 token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
Cargo.lock
+53 -2
@@ -647,6 +647,12 @@ dependencies = [
647 name = "gresiq"
648 version = "0.3.34"
649 dependencies = [
650 + "native-tls",
651 + "postgres-native-tls",
652 + "reqwest",
653 + "serde",
654 + "serde_json",
655 + "thiserror 2.0.18",
656 "tokio",
657 "tokio-postgres",
658 ]
@@ -1202,6 +1208,23 @@ dependencies = [
1208 "windows-sys 0.61.2",
1209 ]
1210
1211 +[[package]]
1212 +name = "native-tls"
1213 +version = "0.2.18"
1214 +source = "registry+https://github.com/rust-lang/crates.io-index"
1215 +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
1216 +dependencies = [
1217 + "libc",
1218 + "log",
1219 + "openssl",
1220 + "openssl-probe 0.2.1",
1221 + "openssl-sys",
1222 + "schannel",
1223 + "security-framework",
1224 + "security-framework-sys",
1225 + "tempfile",
1226 +]
1227 +
1228 [[package]]
1229 name = "nu-ansi-term"
1230 version = "0.50.3"
@@ -1257,9 +1280,15 @@ dependencies = [
1280
1281 [[package]]
1282 name = "openssl"
1283 +<<<<<<< HEAD
1284 version = "0.10.75"
1285 source = "registry+https://github.com/rust-lang/crates.io-index"
1286 checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
1287 +=======
1288 +version = "0.10.76"
1289 +source = "registry+https://github.com/rust-lang/crates.io-index"
1290 +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
1291 +>>>>>>> f35f2fe (gresiq wip)
1292 dependencies = [
1293 "bitflags 2.10.0",
1294 "cfg-if",
@@ -1304,9 +1333,9 @@ dependencies = [
1333
1334 [[package]]
1335 name = "openssl-sys"
1307 -version = "0.9.111"
1336 +version = "0.9.112"
1337 source = "registry+https://github.com/rust-lang/crates.io-index"
1309 -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
1338 +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
1339 dependencies = [
1340 "cc",
1341 "libc",
@@ -1410,6 +1439,18 @@ version = "1.13.1"
1439 source = "registry+https://github.com/rust-lang/crates.io-index"
1440 checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
1441
1442 +[[package]]
1443 +name = "postgres-native-tls"
1444 +version = "0.5.3"
1445 +source = "registry+https://github.com/rust-lang/crates.io-index"
1446 +checksum = "fef4de47bb81477e0c3deaf153a1b10ae176484713ff1640969f4cb96b653ebc"
1447 +dependencies = [
1448 + "native-tls",
1449 + "tokio",
1450 + "tokio-native-tls",
1451 + "tokio-postgres",
1452 +]
1453 +
1454 [[package]]
1455 name = "postgres-protocol"
1456 version = "0.6.10"
@@ -2603,6 +2644,16 @@ dependencies = [
2644 "syn 2.0.114",
2645 ]
2646
2647 +[[package]]
2648 +name = "tokio-native-tls"
2649 +version = "0.3.1"
2650 +source = "registry+https://github.com/rust-lang/crates.io-index"
2651 +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
2652 +dependencies = [
2653 + "native-tls",
2654 + "tokio",
2655 +]
2656 +
2657 [[package]]
2658 name = "tokio-postgres"
2659 version = "0.7.16"
crates/smbcloud-gresiq/Cargo.toml
+9
@@ -17,6 +17,15 @@ categories = [
17 "wasm",
18 ]
19
20 +[lib]
21 +path = "src/gresiq.rs"
22 +
23 [dependencies]
24 +native-tls = "0.2"
25 +postgres-native-tls = "0.5"
26 +reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] }
27 +serde = { workspace = true, features = ["derive"] }
28 +serde_json = { workspace = true }
29 +thiserror = { workspace = true }
30 tokio = { workspace = true, features = ["full"] }
31 tokio-postgres = { workspace = true }
crates/smbcloud-gresiq/README.md
+81 -2
@@ -1,3 +1,82 @@
1 -# smbCloud GresIQ
1 +# GresIQ
2
3 -GresIQ is a serverless PostgreSQL client for the smbCloud platform.
3 +GresIQ is smbCloud's managed PostgreSQL bootstrap crate for small and medium businesses. It is designed around the product shape teams expect from Neon, Prisma Postgres, and similar services:
4 +
5 +- branch-aware environments
6 +- autoscaling-friendly connection defaults
7 +- safe connection-string handling
8 +- a small Rust API that can sit underneath CLI, desktop, web, or future bindings
9 +
10 +This crate is the Rust source of truth for bootstrapping a GresIQ connection and validating that a managed database endpoint is reachable.
11 +
12 +## What the bootstrap includes
13 +
14 +- `GresiqConfig` for loading a managed Postgres target from environment variables
15 +- explicit smbCloud environment handling for `development`, `preview`, and `production`
16 +- branch metadata for project, branch, and region aware workloads
17 +- TLS-aware `tokio-postgres` connection setup
18 +- redacted connection-string output for logs and CLI diagnostics
19 +- a health-check query that returns database name, user, and server version
20 +
21 +## Environment variables
22 +
23 +`GresiqConfig::from_env()` reads:
24 +
25 +```text
26 +GRESIQ_PROJECT_SLUG
27 +GRESIQ_HOST
28 +GRESIQ_USERNAME
29 +GRESIQ_PASSWORD
30 +GRESIQ_BRANCH=main
31 +GRESIQ_REGION=eu-north-1
32 +GRESIQ_DATABASE=postgres
33 +GRESIQ_PORT=5432
34 +GRESIQ_SSL_MODE=require
35 +GRESIQ_COMPUTE_TIER=autoscale
36 +GRESIQ_APPLICATION_NAME=gresiq
37 +GRESIQ_CONNECT_TIMEOUT_SECONDS=10
38 +SMBCLOUD_ENVIRONMENT=development
39 +```
40 +
41 +## Example
42 +
43 +```rust
44 +use gresiq::GresiqConfig;
45 +
46 +#[tokio::main]
47 +async fn main() -> Result<(), Box<dyn std::error::Error>> {
48 + let config = GresiqConfig::from_env()?;
49 + let connection = config.connect().await?;
50 + let health_check = connection.health_check().await?;
51 +
52 + println!("{}", config.redacted_connection_string());
53 + println!("{}", health_check.server_version);
54 +
55 + Ok(())
56 +}
57 +```
58 +
59 +## CLI bootstrap
60 +
61 +Run the crate binary directly to validate a managed endpoint:
62 +
63 +```bash
64 +cargo run -p gresiq
65 +```
66 +
67 +The binary prints:
68 +
69 +- branch and region summary
70 +- selected compute tier
71 +- redacted endpoint URL
72 +- database health-check details
73 +
74 +## Product direction
75 +
76 +The current bootstrap focuses on connection modeling and operational correctness. The next logical layer for GresIQ is:
77 +
78 +- branch lifecycle APIs for preview environments
79 +- pooled or proxy-based serverless connection routing
80 +- passwordless or ephemeral credentials
81 +- metrics, query insights, and tenant-aware usage controls
82 +- adapters for smbCloud CLI, Tauri, wasm, npm, and native SDKs
crates/smbcloud-gresiq/src/gresiq.rs new
+461
@@ -0,0 +1,461 @@
1 +pub mod rest;
2 +
3 +use native_tls::TlsConnector;
4 +use postgres_native_tls::MakeTlsConnector;
5 +use serde::{Deserialize, Serialize};
6 +use std::{env, fmt, num::ParseIntError};
7 +use thiserror::Error;
8 +use tokio::task::JoinHandle;
9 +use tokio_postgres::{
10 + Client, Config as PostgresConfig, Error as PostgresError, NoTls, Row,
11 + config::SslMode as PostgresSslMode,
12 +};
13 +
14 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15 +pub enum GresiqEnvironment {
16 + Development,
17 + Preview,
18 + Production,
19 +}
20 +
21 +impl GresiqEnvironment {
22 + pub fn as_str(self) -> &'static str {
23 + match self {
24 + Self::Development => "development",
25 + Self::Preview => "preview",
26 + Self::Production => "production",
27 + }
28 + }
29 +}
30 +
31 +impl fmt::Display for GresiqEnvironment {
32 + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33 + formatter.write_str(self.as_str())
34 + }
35 +}
36 +
37 +impl std::str::FromStr for GresiqEnvironment {
38 + type Err = GresiqConfigError;
39 +
40 + fn from_str(value: &str) -> Result<Self, Self::Err> {
41 + match value.trim().to_ascii_lowercase().as_str() {
42 + "development" | "dev" => Ok(Self::Development),
43 + "preview" | "staging" => Ok(Self::Preview),
44 + "production" | "prod" => Ok(Self::Production),
45 + _ => Err(GresiqConfigError::InvalidEnvironment(value.to_owned())),
46 + }
47 + }
48 +}
49 +
50 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51 +pub enum ComputeTier {
52 + Burst,
53 + Autoscale,
54 + Dedicated,
55 +}
56 +
57 +impl ComputeTier {
58 + pub fn as_str(self) -> &'static str {
59 + match self {
60 + Self::Burst => "burst",
61 + Self::Autoscale => "autoscale",
62 + Self::Dedicated => "dedicated",
63 + }
64 + }
65 +}
66 +
67 +impl fmt::Display for ComputeTier {
68 + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69 + formatter.write_str(self.as_str())
70 + }
71 +}
72 +
73 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74 +pub struct BranchTarget {
75 + pub project_slug: String,
76 + pub branch_name: String,
77 + pub region: String,
78 + pub environment: GresiqEnvironment,
79 +}
80 +
81 +impl BranchTarget {
82 + pub fn summary(&self) -> String {
83 + format!(
84 + "{}/{}/{} ({})",
85 + self.project_slug, self.branch_name, self.region, self.environment
86 + )
87 + }
88 +}
89 +
90 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91 +pub struct Credentials {
92 + pub username: String,
93 + pub password: String,
94 + pub database_name: String,
95 +}
96 +
97 +impl Credentials {
98 + pub fn redacted_password(&self) -> &'static str {
99 + "********"
100 + }
101 +}
102 +
103 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104 +pub struct Endpoint {
105 + pub host: String,
106 + pub port: u16,
107 + pub ssl_mode: SslMode,
108 +}
109 +
110 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111 +pub enum SslMode {
112 + Disable,
113 + Prefer,
114 + Require,
115 +}
116 +
117 +impl SslMode {
118 + pub fn as_str(self) -> &'static str {
119 + match self {
120 + Self::Disable => "disable",
121 + Self::Prefer => "prefer",
122 + Self::Require => "require",
123 + }
124 + }
125 +}
126 +
127 +impl fmt::Display for SslMode {
128 + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129 + formatter.write_str(self.as_str())
130 + }
131 +}
132 +
133 +impl std::str::FromStr for SslMode {
134 + type Err = GresiqConfigError;
135 +
136 + fn from_str(value: &str) -> Result<Self, Self::Err> {
137 + match value.trim().to_ascii_lowercase().as_str() {
138 + "disable" => Ok(Self::Disable),
139 + "prefer" => Ok(Self::Prefer),
140 + "require" => Ok(Self::Require),
141 + _ => Err(GresiqConfigError::InvalidSslMode(value.to_owned())),
142 + }
143 + }
144 +}
145 +
146 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147 +pub struct ConnectionPolicy {
148 + pub compute_tier: ComputeTier,
149 + pub application_name: String,
150 + pub connect_timeout_seconds: u64,
151 +}
152 +
153 +impl Default for ConnectionPolicy {
154 + fn default() -> Self {
155 + Self {
156 + compute_tier: ComputeTier::Autoscale,
157 + application_name: "gresiq".to_owned(),
158 + connect_timeout_seconds: 10,
159 + }
160 + }
161 +}
162 +
163 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164 +pub struct GresiqConfig {
165 + pub branch_target: BranchTarget,
166 + pub endpoint: Endpoint,
167 + pub credentials: Credentials,
168 + pub connection_policy: ConnectionPolicy,
169 +}
170 +
171 +impl GresiqConfig {
172 + pub fn from_env() -> Result<Self, GresiqConfigError> {
173 + let environment = env::var("SMBCLOUD_ENVIRONMENT")
174 + .unwrap_or_else(|_| "development".to_owned())
175 + .parse()?;
176 +
177 + let ssl_mode = env::var("GRESIQ_SSL_MODE")
178 + .unwrap_or_else(|_| "require".to_owned())
179 + .parse()?;
180 +
181 + let port = read_optional_port("GRESIQ_PORT")?.unwrap_or(5432);
182 +
183 + let connect_timeout_seconds =
184 + read_optional_u64("GRESIQ_CONNECT_TIMEOUT_SECONDS")?.unwrap_or(10);
185 +
186 + let compute_tier = match env::var("GRESIQ_COMPUTE_TIER")
187 + .unwrap_or_else(|_| "autoscale".to_owned())
188 + .trim()
189 + .to_ascii_lowercase()
190 + .as_str()
191 + {
192 + "burst" => ComputeTier::Burst,
193 + "autoscale" => ComputeTier::Autoscale,
194 + "dedicated" => ComputeTier::Dedicated,
195 + other => {
196 + return Err(GresiqConfigError::InvalidComputeTier(other.to_owned()));
197 + }
198 + };
199 +
200 + Ok(Self {
201 + branch_target: BranchTarget {
202 + project_slug: read_required_env("GRESIQ_PROJECT_SLUG")?,
203 + branch_name: env::var("GRESIQ_BRANCH").unwrap_or_else(|_| "main".to_owned()),
204 + region: env::var("GRESIQ_REGION").unwrap_or_else(|_| "eu-north-1".to_owned()),
205 + environment,
206 + },
207 + endpoint: Endpoint {
208 + host: read_required_env("GRESIQ_HOST")?,
209 + port,
210 + ssl_mode,
211 + },
212 + credentials: Credentials {
213 + username: read_required_env("GRESIQ_USERNAME")?,
214 + password: read_required_env("GRESIQ_PASSWORD")?,
215 + database_name: env::var("GRESIQ_DATABASE")
216 + .unwrap_or_else(|_| "postgres".to_owned()),
217 + },
218 + connection_policy: ConnectionPolicy {
219 + compute_tier,
220 + application_name: env::var("GRESIQ_APPLICATION_NAME")
221 + .unwrap_or_else(|_| "gresiq".to_owned()),
222 + connect_timeout_seconds,
223 + },
224 + })
225 + }
226 +
227 + pub fn connection_string(&self) -> String {
228 + format!(
229 + "postgresql://{}:{}@{}:{}/{}?sslmode={}&application_name={}",
230 + self.credentials.username,
231 + self.credentials.password,
232 + self.endpoint.host,
233 + self.endpoint.port,
234 + self.credentials.database_name,
235 + self.endpoint.ssl_mode,
236 + self.connection_policy.application_name
237 + )
238 + }
239 +
240 + pub fn redacted_connection_string(&self) -> String {
241 + format!(
242 + "postgresql://{}:{}@{}:{}/{}?sslmode={}&application_name={}",
243 + self.credentials.username,
244 + self.credentials.redacted_password(),
245 + self.endpoint.host,
246 + self.endpoint.port,
247 + self.credentials.database_name,
248 + self.endpoint.ssl_mode,
249 + self.connection_policy.application_name
250 + )
251 + }
252 +
253 + pub fn postgres_config(&self) -> PostgresConfig {
254 + let mut config = PostgresConfig::new();
255 + config.host(&self.endpoint.host);
256 + config.port(self.endpoint.port);
257 + config.user(&self.credentials.username);
258 + config.password(&self.credentials.password);
259 + config.dbname(&self.credentials.database_name);
260 + config.application_name(&self.connection_policy.application_name);
261 + config.connect_timeout(std::time::Duration::from_secs(
262 + self.connection_policy.connect_timeout_seconds,
263 + ));
264 + config.ssl_mode(match self.endpoint.ssl_mode {
265 + SslMode::Disable => PostgresSslMode::Disable,
266 + SslMode::Prefer => PostgresSslMode::Prefer,
267 + SslMode::Require => PostgresSslMode::Require,
268 + });
269 + config
270 + }
271 +
272 + pub async fn connect(&self) -> Result<GresiqConnection, GresiqConnectError> {
273 + let postgres_config = self.postgres_config();
274 +
275 + match self.endpoint.ssl_mode {
276 + SslMode::Disable => {
277 + let (client, connection) = postgres_config.connect(NoTls).await?;
278 + let connection_task = tokio::spawn(async move { connection.await });
279 +
280 + Ok(GresiqConnection {
281 + client,
282 + connection_task: Some(connection_task),
283 + })
284 + }
285 + SslMode::Prefer | SslMode::Require => {
286 + let tls_connector = TlsConnector::builder().build()?;
287 + let tls_connector = MakeTlsConnector::new(tls_connector);
288 + let (client, connection) = postgres_config.connect(tls_connector).await?;
289 + let connection_task = tokio::spawn(async move { connection.await });
290 +
291 + Ok(GresiqConnection {
292 + client,
293 + connection_task: Some(connection_task),
294 + })
295 + }
296 + }
297 + }
298 +}
299 +
300 +#[derive(Debug)]
301 +pub struct GresiqConnection {
302 + client: Client,
303 + connection_task: Option<JoinHandle<Result<(), PostgresError>>>,
304 +}
305 +
306 +impl GresiqConnection {
307 + pub fn client(&self) -> &Client {
308 + &self.client
309 + }
310 +
311 + pub async fn health_check(&self) -> Result<GresiqHealthCheck, GresiqConnectError> {
312 + let row = self
313 + .client
314 + .query_one("select current_database(), current_user, version()", &[])
315 + .await?;
316 +
317 + Ok(GresiqHealthCheck::from_row(&row))
318 + }
319 +
320 + pub async fn close(mut self) -> Result<(), GresiqConnectError> {
321 + if let Some(connection_task) = self.connection_task.take() {
322 + connection_task.await??;
323 + }
324 + Ok(())
325 + }
326 +}
327 +
328 +impl Drop for GresiqConnection {
329 + fn drop(&mut self) {
330 + if let Some(connection_task) = self.connection_task.take() {
331 + connection_task.abort();
332 + }
333 + }
334 +}
335 +
336 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337 +pub struct GresiqHealthCheck {
338 + pub database_name: String,
339 + pub username: String,
340 + pub server_version: String,
341 +}
342 +
343 +impl GresiqHealthCheck {
344 + fn from_row(row: &Row) -> Self {
345 + Self {
346 + database_name: row.get(0),
347 + username: row.get(1),
348 + server_version: row.get(2),
349 + }
350 + }
351 +}
352 +
353 +#[derive(Debug, Error)]
354 +pub enum GresiqConfigError {
355 + #[error("missing required environment variable `{0}`")]
356 + MissingEnvVar(&'static str),
357 + #[error("environment variable `{0}` contains invalid unicode")]
358 + InvalidEnvUnicode(&'static str),
359 + #[error("failed to parse environment variable `{name}` as an integer")]
360 + InvalidNumber {
361 + name: &'static str,
362 + #[source]
363 + source: ParseIntError,
364 + },
365 + #[error("invalid smbCloud environment `{0}`")]
366 + InvalidEnvironment(String),
367 + #[error("invalid GresIQ ssl mode `{0}`")]
368 + InvalidSslMode(String),
369 + #[error("invalid GresIQ compute tier `{0}`")]
370 + InvalidComputeTier(String),
371 +}
372 +
373 +#[derive(Debug, Error)]
374 +pub enum GresiqConnectError {
375 + #[error(transparent)]
376 + Tls(#[from] native_tls::Error),
377 + #[error(transparent)]
378 + Postgres(#[from] PostgresError),
379 + #[error("postgres connection task failed")]
380 + Join(#[from] tokio::task::JoinError),
381 +}
382 +
383 +fn read_required_env(name: &'static str) -> Result<String, GresiqConfigError> {
384 + env::var(name).map_err(|_| GresiqConfigError::MissingEnvVar(name))
385 +}
386 +
387 +fn read_optional_port(name: &'static str) -> Result<Option<u16>, GresiqConfigError> {
388 + read_optional_env(name)?
389 + .map(|value| {
390 + value
391 + .parse()
392 + .map_err(|source| GresiqConfigError::InvalidNumber { name, source })
393 + })
394 + .transpose()
395 +}
396 +
397 +fn read_optional_u64(name: &'static str) -> Result<Option<u64>, GresiqConfigError> {
398 + read_optional_env(name)?
399 + .map(|value| {
400 + value
401 + .parse()
402 + .map_err(|source| GresiqConfigError::InvalidNumber { name, source })
403 + })
404 + .transpose()
405 +}
406 +
407 +fn read_optional_env(name: &'static str) -> Result<Option<String>, GresiqConfigError> {
408 + match env::var(name) {
409 + Ok(value) => Ok(Some(value)),
410 + Err(env::VarError::NotPresent) => Ok(None),
411 + Err(env::VarError::NotUnicode(_)) => Err(GresiqConfigError::InvalidEnvUnicode(name)),
412 + }
413 +}
414 +
415 +#[cfg(test)]
416 +mod tests {
417 + use super::*;
418 +
419 + #[test]
420 + fn redacted_connection_string_hides_the_password() {
421 + let config = GresiqConfig {
422 + branch_target: BranchTarget {
423 + project_slug: "acme-store".to_owned(),
424 + branch_name: "main".to_owned(),
425 + region: "eu-north-1".to_owned(),
426 + environment: GresiqEnvironment::Production,
427 + },
428 + endpoint: Endpoint {
429 + host: "main.acme-store.gresiq.smbcloud.xyz".to_owned(),
430 + port: 5432,
431 + ssl_mode: SslMode::Require,
432 + },
433 + credentials: Credentials {
434 + username: "postgres".to_owned(),
435 + password: "super-secret".to_owned(),
436 + database_name: "app".to_owned(),
437 + },
438 + connection_policy: ConnectionPolicy::default(),
439 + };
440 +
441 + assert!(!config.redacted_connection_string().contains("super-secret"));
442 + assert!(config.redacted_connection_string().contains("********"));
443 + }
444 +
445 + #[test]
446 + fn branch_summary_matches_expected_shape() {
447 + let branch_target = BranchTarget {
448 + project_slug: "acme-store".to_owned(),
449 + branch_name: "preview".to_owned(),
450 + region: "eu-west-1".to_owned(),
451 + environment: GresiqEnvironment::Preview,
452 + };
453 +
454 + assert_eq!(
455 + branch_target.summary(),
456 + "acme-store/preview/eu-west-1 (preview)"
457 + );
458 + }
459 +}
460 +
461 +pub use rest::{GresiqRestClient, GresiqRestConfig, GresiqRestError, OrderDir, QueryBuilder};
crates/smbcloud-gresiq/src/main.rs
+12 -19
@@ -1,27 +1,20 @@
1 -use tokio_postgres::{Error, NoTls};
1 +use gresiq::GresiqConfig;
2
3 #[tokio::main]
4 -async fn main() -> Result<(), Error> {
5 - println!("Hello, GresIQ client!");
4 +async fn main() -> Result<(), Box<dyn std::error::Error>> {
5 + let config = GresiqConfig::from_env()?;
6
7 - // Connect to the database.
8 - let (client, connection) =
9 - tokio_postgres::connect("host=localhost user=postgres", NoTls).await?;
7 + println!("GresIQ bootstrap");
8 + println!(" branch: {}", config.branch_target.summary());
9 + println!(" compute: {}", config.connection_policy.compute_tier);
10 + println!(" endpoint: {}", config.redacted_connection_string());
11
11 - // The connection object performs the actual communication with the database,
12 - // so spawn it off to run on its own.
13 - tokio::spawn(async move {
14 - if let Err(e) = connection.await {
15 - eprintln!("connection error: {}", e);
16 - }
17 - });
12 + let connection = config.connect().await?;
13 + let health_check = connection.health_check().await?;
14
19 - // Now we can execute a simple statement that just returns its parameter.
20 - let rows = client.query("SELECT $1::TEXT", &[&"hello world"]).await?;
21 -
22 - // And then check that we got back the same string we sent over.
23 - let value: &str = rows[0].get(0);
24 - assert_eq!(value, "hello world");
15 + println!(" database: {}", health_check.database_name);
16 + println!(" user: {}", health_check.username);
17 + println!(" server: {}", health_check.server_version);
18
19 Ok(())
20 }
crates/smbcloud-gresiq/src/rest.rs new
+529
@@ -0,0 +1,529 @@
1 +//! GresIQ REST client — PostgREST-compatible HTTP interface.
2 +//!
3 +//! Provides a fluent query builder so application code never writes SQL.
4 +//! Talks to the GresIQ REST gateway that smbCloud runs alongside each
5 +//! managed database.
6 +//!
7 +//! # Design
8 +//!
9 +//! Every query is built with [`GresiqRestClient::from`], which returns a
10 +//! [`QueryBuilder`]. Filters, ordering, and pagination are added through
11 +//! chainable methods. Calling a terminal mutation method (`.insert()`,
12 +//! `.update()`, `.upsert()`, `.delete()`) switches the HTTP method.
13 +//! [`QueryBuilder::execute`] fires the HTTP request and deserialises the
14 +//! response.
15 +//!
16 +//! # Example
17 +//!
18 +//! ```no_run
19 +//! use gresiq::rest::{GresiqRestClient, GresiqRestConfig, OrderDir};
20 +//! use serde::Deserialize;
21 +//!
22 +//! #[derive(Debug, Deserialize)]
23 +//! struct Lesson { id: i32, title_en: String }
24 +//!
25 +//! # async fn example() -> Result<(), gresiq::rest::GresiqRestError> {
26 +//! let config = GresiqRestConfig {
27 +//! base_url: "https://main.my-app.gresiq.smbcloud.xyz".to_owned(),
28 +//! api_key: "my-api-key".to_owned(),
29 +//! schema: None,
30 +//! };
31 +//! let client = GresiqRestClient::new(config)?;
32 +//!
33 +//! // SELECT * FROM lessons ORDER BY sort_order ASC
34 +//! let lessons: Vec<Lesson> = client
35 +//! .from("lessons")
36 +//! .select("*")
37 +//! .order("sort_order", OrderDir::Asc)
38 +//! .execute()
39 +//! .await?;
40 +//! # Ok(()) }
41 +//! ```
42 +
43 +use reqwest::StatusCode;
44 +use serde::de::DeserializeOwned;
45 +use serde_json::Value;
46 +use std::env;
47 +use thiserror::Error;
48 +
49 +// --- Error -------------------------------------------------------------------
50 +
51 +/// Errors that can arise while using the GresIQ REST client.
52 +#[derive(Debug, Error)]
53 +pub enum GresiqRestError {
54 + #[error("configuration error: {0}")]
55 + Config(String),
56 + #[error("HTTP error: {0}")]
57 + Http(String),
58 + #[error("API error {status}: {body}")]
59 + Api { status: u16, body: String },
60 + #[error("deserialisation error: {0}")]
61 + Deserialize(String),
62 +}
63 +
64 +// --- Configuration -----------------------------------------------------------
65 +
66 +/// Configuration for the GresIQ REST gateway.
67 +///
68 +/// Construct directly or load from environment variables with
69 +/// [`GresiqRestConfig::from_env`].
70 +#[derive(Debug, Clone)]
71 +pub struct GresiqRestConfig {
72 + /// Base URL of the GresIQ REST gateway,
73 + /// e.g. `https://main.my-app.gresiq.smbcloud.xyz`.
74 + pub base_url: String,
75 + /// API key (Bearer token) used in the `Authorization` header.
76 + pub api_key: String,
77 + /// PostgreSQL schema to target (default: `public`).
78 + pub schema: Option<String>,
79 +}
80 +
81 +impl GresiqRestConfig {
82 + /// Load configuration from environment variables.
83 + ///
84 + /// | Variable | Required | Description |
85 + /// |-------------------|----------|--------------------------|
86 + /// | `GRESIQ_REST_URL` | yes | Base URL of the gateway |
87 + /// | `GRESIQ_API_KEY` | yes | Bearer API key |
88 + /// | `GRESIQ_SCHEMA` | no | Target schema (default: `public`) |
89 + pub fn from_env() -> Result<Self, GresiqRestError> {
90 + let base_url = env::var("GRESIQ_REST_URL").map_err(|_| {
91 + GresiqRestError::Config(
92 + "missing required environment variable `GRESIQ_REST_URL`".to_owned(),
93 + )
94 + })?;
95 + let api_key = env::var("GRESIQ_API_KEY").map_err(|_| {
96 + GresiqRestError::Config(
97 + "missing required environment variable `GRESIQ_API_KEY`".to_owned(),
98 + )
99 + })?;
100 + let schema = env::var("GRESIQ_SCHEMA").ok();
101 + Ok(Self {
102 + base_url,
103 + api_key,
104 + schema,
105 + })
106 + }
107 +}
108 +
109 +// --- Client ------------------------------------------------------------------
110 +
111 +/// HTTP client for the GresIQ REST gateway.
112 +///
113 +/// Create once and reuse; the inner [`reqwest::Client`] maintains a
114 +/// connection pool.
115 +#[derive(Debug, Clone)]
116 +pub struct GresiqRestClient {
117 + http: reqwest::Client,
118 + config: GresiqRestConfig,
119 +}
120 +
121 +impl GresiqRestClient {
122 + /// Build a new client from the supplied configuration.
123 + pub fn new(config: GresiqRestConfig) -> Result<Self, GresiqRestError> {
124 + let http = reqwest::Client::builder()
125 + .build()
126 + .map_err(|error| GresiqRestError::Http(error.to_string()))?;
127 + Ok(Self { http, config })
128 + }
129 +
130 + /// Start a fluent query against `table`.
131 + pub fn from(&self, table: &str) -> QueryBuilder<'_> {
132 + QueryBuilder {
133 + client: self,
134 + table: table.to_owned(),
135 + select: "*".to_owned(),
136 + filters: Vec::new(),
137 + order: Vec::new(),
138 + limit: None,
139 + offset: None,
140 + operation: Operation::Select,
141 + }
142 + }
143 +
144 + /// Returns `"{base_url}/rest/v1/{table}"`.
145 + pub(crate) fn table_url(&self, table: &str) -> String {
146 + format!("{}/rest/v1/{}", self.config.base_url, table)
147 + }
148 +
149 + /// Attaches the standard request headers required by the GresIQ gateway.
150 + pub(crate) fn apply_headers(
151 + &self,
152 + req: reqwest::RequestBuilder,
153 + ) -> reqwest::RequestBuilder {
154 + let schema = self.config.schema.as_deref().unwrap_or("public");
155 + req.header(
156 + "Authorization",
157 + format!("Bearer {}", self.config.api_key),
158 + )
159 + .header("Content-Type", "application/json")
160 + .header("Accept-Profile", schema)
161 + .header("Content-Profile", schema)
162 + }
163 +}
164 +
165 +// --- Ordering ----------------------------------------------------------------
166 +
167 +/// Sort direction for [`QueryBuilder::order`].
168 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169 +pub enum OrderDir {
170 + Asc,
171 + Desc,
172 +}
173 +
174 +impl OrderDir {
175 + /// Returns the PostgREST order string for this direction.
176 + pub fn as_str(self) -> &'static str {
177 + match self {
178 + Self::Asc => "asc",
179 + Self::Desc => "desc",
180 + }
181 + }
182 +}
183 +
184 +// --- Operation ---------------------------------------------------------------
185 +
186 +enum Operation {
187 + Select,
188 + Insert(Value),
189 + Update(Value),
190 + Upsert(Value),
191 + Delete,
192 +}
193 +
194 +// --- QueryBuilder ------------------------------------------------------------
195 +
196 +/// Fluent builder for a single REST request.
197 +///
198 +/// Obtained from [`GresiqRestClient::from`]; call [`QueryBuilder::execute`]
199 +/// to send the request.
200 +pub struct QueryBuilder<'c> {
201 + client: &'c GresiqRestClient,
202 + table: String,
203 + /// Column list sent as the `select` query-param (default `"*"`).
204 + select: String,
205 + /// Accumulated `(column, "op.value")` filter pairs.
206 + filters: Vec<(String, String)>,
207 + /// Accumulated `"col.dir"` order segments.
208 + order: Vec<String>,
209 + limit: Option<usize>,
210 + offset: Option<usize>,
211 + operation: Operation,
212 +}
213 +
214 +impl<'c> QueryBuilder<'c> {
215 + // --- Column selection ----------------------------------------------------
216 +
217 + /// Override the column list returned by the query (default: `"*"`).
218 + pub fn select(mut self, columns: &str) -> Self {
219 + self.select = columns.to_owned();
220 + self
221 + }
222 +
223 + // --- Filters -------------------------------------------------------------
224 +
225 + /// `column = value`
226 + pub fn eq(mut self, column: &str, value: impl ToString) -> Self {
227 + self.filters
228 + .push((column.to_owned(), format!("eq.{}", value.to_string())));
229 + self
230 + }
231 +
232 + /// `column != value`
233 + pub fn neq(mut self, column: &str, value: impl ToString) -> Self {
234 + self.filters
235 + .push((column.to_owned(), format!("neq.{}", value.to_string())));
236 + self
237 + }
238 +
239 + /// `column > value`
240 + pub fn gt(mut self, column: &str, value: impl ToString) -> Self {
241 + self.filters
242 + .push((column.to_owned(), format!("gt.{}", value.to_string())));
243 + self
244 + }
245 +
246 + /// `column >= value`
247 + pub fn gte(mut self, column: &str, value: impl ToString) -> Self {
248 + self.filters
249 + .push((column.to_owned(), format!("gte.{}", value.to_string())));
250 + self
251 + }
252 +
253 + /// `column < value`
254 + pub fn lt(mut self, column: &str, value: impl ToString) -> Self {
255 + self.filters
256 + .push((column.to_owned(), format!("lt.{}", value.to_string())));
257 + self
258 + }
259 +
260 + /// `column <= value`
261 + pub fn lte(mut self, column: &str, value: impl ToString) -> Self {
262 + self.filters
263 + .push((column.to_owned(), format!("lte.{}", value.to_string())));
264 + self
265 + }
266 +
267 + /// `column LIKE pattern` (case-sensitive)
268 + pub fn like(mut self, column: &str, pattern: &str) -> Self {
269 + self.filters
270 + .push((column.to_owned(), format!("like.{pattern}")));
271 + self
272 + }
273 +
274 + /// `column ILIKE pattern` (case-insensitive)
275 + pub fn ilike(mut self, column: &str, pattern: &str) -> Self {
276 + self.filters
277 + .push((column.to_owned(), format!("ilike.{pattern}")));
278 + self
279 + }
280 +
281 + /// `column IN (values...)`
282 + pub fn in_list(mut self, column: &str, values: &[impl ToString]) -> Self {
283 + let joined = values
284 + .iter()
285 + .map(|value| value.to_string())
286 + .collect::<Vec<_>>()
287 + .join(",");
288 + self.filters
289 + .push((column.to_owned(), format!("in.({joined})")));
290 + self
291 + }
292 +
293 + /// `column IS NULL` when `null` is `true`; `column IS NOT NULL` otherwise.
294 + pub fn is_null(mut self, column: &str, null: bool) -> Self {
295 + let filter_value = if null {
296 + "is.null".to_owned()
297 + } else {
298 + "is.not.null".to_owned()
299 + };
300 + self.filters.push((column.to_owned(), filter_value));
301 + self
302 + }
303 +
304 + // --- Ordering, paging ----------------------------------------------------
305 +
306 + /// Append an `ORDER BY column dir` clause.
307 + pub fn order(mut self, column: &str, dir: OrderDir) -> Self {
308 + self.order.push(format!("{}.{}", column, dir.as_str()));
309 + self
310 + }
311 +
312 + /// Limit the number of rows returned.
313 + pub fn limit(mut self, n: usize) -> Self {
314 + self.limit = Some(n);
315 + self
316 + }
317 +
318 + /// Skip the first `n` rows.
319 + pub fn offset(mut self, n: usize) -> Self {
320 + self.offset = Some(n);
321 + self
322 + }
323 +
324 + // --- Mutations -----------------------------------------------------------
325 +
326 + /// Switch to a `POST` (INSERT) request with `rows` as the JSON body.
327 + pub fn insert(mut self, rows: Value) -> Self {
328 + self.operation = Operation::Insert(rows);
329 + self
330 + }
331 +
332 + /// Switch to a `POST` (UPSERT) request. Duplicate rows are merged.
333 + pub fn upsert(mut self, rows: Value) -> Self {
334 + self.operation = Operation::Upsert(rows);
335 + self
336 + }
337 +
338 + /// Switch to a `PATCH` (UPDATE) request with `patch` as the JSON body.
339 + pub fn update(mut self, patch: Value) -> Self {
340 + self.operation = Operation::Update(patch);
341 + self
342 + }
343 +
344 + /// Switch to a `DELETE` request.
345 + pub fn delete(mut self) -> Self {
346 + self.operation = Operation::Delete;
347 + self
348 + }
349 +
350 + // --- Terminal ------------------------------------------------------------
351 +
352 + /// Send the request and deserialise the response body as `T`.
353 + ///
354 + /// - For a `SELECT` that returns rows, use `T = Vec<YourRow>`.
355 + /// - For mutations where you do not need the returned data, use `T = ()`.
356 + pub async fn execute<T: DeserializeOwned>(self) -> Result<T, GresiqRestError> {
357 + let QueryBuilder {
358 + client,
359 + table,
360 + select,
361 + filters,
362 + order,
363 + limit,
364 + offset,
365 + operation,
366 + } = self;
367 +
368 + let url = client.table_url(&table);
369 +
370 + // Determine HTTP method, Prefer header, and optional JSON body from
371 + // the operation variant.
372 + let (http_method, prefer_header, body) = match operation {
373 + Operation::Select => ("GET", "return=representation".to_owned(), None),
374 + Operation::Insert(rows) => {
375 + ("POST", "return=representation".to_owned(), Some(rows))
376 + }
377 + Operation::Update(patch) => {
378 + ("PATCH", "return=representation".to_owned(), Some(patch))
379 + }
380 + Operation::Upsert(rows) => (
381 + "POST",
382 + "return=representation,resolution=merge-duplicates".to_owned(),
383 + Some(rows),
384 + ),
385 + Operation::Delete => ("DELETE", "return=representation".to_owned(), None),
386 + };
387 +
388 + let base_request = match http_method {
389 + "GET" => client.http.get(&url),
390 + "POST" => client.http.post(&url),
391 + "PATCH" => client.http.patch(&url),
392 + "DELETE" => client.http.delete(&url),
393 + _ => unreachable!(),
394 + };
395 +
396 + // Apply standard headers and the Prefer header.
397 + let mut request = client
398 + .apply_headers(base_request)
399 + .header("Prefer", prefer_header);
400 +
401 + // Build query-string parameters.
402 + let mut params: Vec<(String, String)> = Vec::new();
403 + params.push(("select".to_owned(), select));
404 + for (column, filter_value) in filters {
405 + params.push((column, filter_value));
406 + }
407 + if !order.is_empty() {
408 + params.push(("order".to_owned(), order.join(",")));
409 + }
410 + if let Some(n) = limit {
411 + params.push(("limit".to_owned(), n.to_string()));
412 + }
413 + if let Some(n) = offset {
414 + params.push(("offset".to_owned(), n.to_string()));
415 + }
416 + request = request.query(&params);
417 +
418 + // Attach JSON body for mutating operations.
419 + if let Some(json_body) = body {
420 + request = request.json(&json_body);
421 + }
422 +
423 + // Send and check for HTTP-level errors.
424 + let response = request
425 + .send()
426 + .await
427 + .map_err(|error| GresiqRestError::Http(error.to_string()))?;
428 +
429 + let status = response.status();
430 +
431 + if !status.is_success() {
432 + let status_code = status.as_u16();
433 + let body_text = response
434 + .text()
435 + .await
436 + .unwrap_or_else(|_| "<unreadable>".to_owned());
437 + return Err(GresiqRestError::Api {
438 + status: status_code,
439 + body: body_text,
440 + });
441 + }
442 +
443 + // Handle 204 No Content — no bytes to deserialise.
444 + if status == StatusCode::NO_CONTENT {
445 + return deserialise_empty();
446 + }
447 +
448 + let bytes = response
449 + .bytes()
450 + .await
451 + .map_err(|error| GresiqRestError::Http(error.to_string()))?;
452 +
453 + if bytes.is_empty() {
454 + return deserialise_empty();
455 + }
456 +
457 + serde_json::from_slice::<T>(&bytes)
458 + .map_err(|error| GresiqRestError::Deserialize(error.to_string()))
459 + }
460 +}
461 +
462 +/// Try sensible fallbacks when the server sends no response body.
463 +///
464 +/// Works for `T = ()` (deserialises `null`) and `T = Vec<_>` (deserialises
465 +/// `[]`). Returns [`GresiqRestError::Deserialize`] if neither succeeds.
466 +fn deserialise_empty<T: DeserializeOwned>() -> Result<T, GresiqRestError> {
467 + if let Ok(value) = serde_json::from_value::<T>(Value::Null) {
468 + return Ok(value);
469 + }
470 + if let Ok(value) = serde_json::from_str::<T>("[]") {
471 + return Ok(value);
472 + }
473 + Err(GresiqRestError::Deserialize("empty response".to_owned()))
474 +}
475 +
476 +// --- Tests -------------------------------------------------------------------
477 +
478 +#[cfg(test)]
479 +mod tests {
480 + use super::*;
481 +
482 + fn make_client() -> GresiqRestClient {
483 + GresiqRestClient::new(GresiqRestConfig {
484 + base_url: "https://example.gresiq.smbcloud.xyz".to_owned(),
485 + api_key: "test-key".to_owned(),
486 + schema: None,
487 + })
488 + .expect("client build should not fail in tests")
489 + }
490 +
491 + #[test]
492 + fn table_url_has_correct_shape() {
493 + let client = make_client();
494 + assert_eq!(
495 + client.table_url("lessons"),
496 + "https://example.gresiq.smbcloud.xyz/rest/v1/lessons"
497 + );
498 + }
499 +
500 + #[test]
501 + fn order_dir_as_str() {
502 + assert_eq!(OrderDir::Asc.as_str(), "asc");
503 + assert_eq!(OrderDir::Desc.as_str(), "desc");
504 + }
505 +
506 + #[test]
507 + fn deserialise_empty_works_for_unit() {
508 + let result: Result<(), GresiqRestError> = deserialise_empty();
509 + assert!(result.is_ok());
510 + }
511 +
512 + #[test]
513 + fn deserialise_empty_works_for_vec() {
514 + let result: Result<Vec<String>, GresiqRestError> = deserialise_empty();
515 + assert!(result.is_ok());
516 + assert!(result.unwrap().is_empty());
517 + }
518 +
519 + #[test]
520 + fn from_env_errors_on_missing_url() {
521 + // Ensure neither variable is set for this test.
522 + unsafe {
523 + env::remove_var("GRESIQ_REST_URL");
524 + env::remove_var("GRESIQ_API_KEY");
525 + }
526 + let result = GresiqRestConfig::from_env();
527 + assert!(matches!(result, Err(GresiqRestError::Config(_))));
528 + }
529 +}