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(¶ms);
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
+}