4 files changed
+1
-1000
crates/smbcloud-auth-sdk-wasm/Cargo.toml
+1
-1
@@ -25,9 +25,9 @@ crate-type = ["cdylib", "rlib"]
25
26
[dependencies]
27
serde-wasm-bindgen = "=0.6.5"
28
+smbcloud-auth-sdk = { workspace = true }
29
smbcloud-network = { workspace = true }
30
smbcloud-networking = { workspace = true }
30
-smbcloud-auth-sdk = { workspace = true }
31
wasm-bindgen = { workspace = true }
32
wasm-bindgen-futures = "0.4.50"
33
crates/smbcloud-gresiq/src/gresiq.rs
-460
@@ -1,461 +1 @@
1
-pub mod rest;
1
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
deleted
-20
@@ -1,20 +0,0 @@
1
-use gresiq::GresiqConfig;
2
-
3
-#[tokio::main]
4
-async fn main() -> Result<(), Box<dyn std::error::Error>> {
5
- let config = GresiqConfig::from_env()?;
6
-
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
-
12
- let connection = config.connect().await?;
13
- let health_check = connection.health_check().await?;
14
-
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
deleted
-519
@@ -1,519 +0,0 @@
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(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
151
- let schema = self.config.schema.as_deref().unwrap_or("public");
152
- req.header("Authorization", format!("Bearer {}", self.config.api_key))
153
- .header("Content-Type", "application/json")
154
- .header("Accept-Profile", schema)
155
- .header("Content-Profile", schema)
156
- }
157
-}
158
-
159
-// --- Ordering ----------------------------------------------------------------
160
-
161
-/// Sort direction for [`QueryBuilder::order`].
162
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163
-pub enum OrderDir {
164
- Asc,
165
- Desc,
166
-}
167
-
168
-impl OrderDir {
169
- /// Returns the PostgREST order string for this direction.
170
- pub fn as_str(self) -> &'static str {
171
- match self {
172
- Self::Asc => "asc",
173
- Self::Desc => "desc",
174
- }
175
- }
176
-}
177
-
178
-// --- Operation ---------------------------------------------------------------
179
-
180
-enum Operation {
181
- Select,
182
- Insert(Value),
183
- Update(Value),
184
- Upsert(Value),
185
- Delete,
186
-}
187
-
188
-// --- QueryBuilder ------------------------------------------------------------
189
-
190
-/// Fluent builder for a single REST request.
191
-///
192
-/// Obtained from [`GresiqRestClient::from`]; call [`QueryBuilder::execute`]
193
-/// to send the request.
194
-pub struct QueryBuilder<'c> {
195
- client: &'c GresiqRestClient,
196
- table: String,
197
- /// Column list sent as the `select` query-param (default `"*"`).
198
- select: String,
199
- /// Accumulated `(column, "op.value")` filter pairs.
200
- filters: Vec<(String, String)>,
201
- /// Accumulated `"col.dir"` order segments.
202
- order: Vec<String>,
203
- limit: Option<usize>,
204
- offset: Option<usize>,
205
- operation: Operation,
206
-}
207
-
208
-impl<'c> QueryBuilder<'c> {
209
- // --- Column selection ----------------------------------------------------
210
-
211
- /// Override the column list returned by the query (default: `"*"`).
212
- pub fn select(mut self, columns: &str) -> Self {
213
- self.select = columns.to_owned();
214
- self
215
- }
216
-
217
- // --- Filters -------------------------------------------------------------
218
-
219
- /// `column = value`
220
- pub fn eq(mut self, column: &str, value: impl ToString) -> Self {
221
- self.filters
222
- .push((column.to_owned(), format!("eq.{}", value.to_string())));
223
- self
224
- }
225
-
226
- /// `column != value`
227
- pub fn neq(mut self, column: &str, value: impl ToString) -> Self {
228
- self.filters
229
- .push((column.to_owned(), format!("neq.{}", value.to_string())));
230
- self
231
- }
232
-
233
- /// `column > value`
234
- pub fn gt(mut self, column: &str, value: impl ToString) -> Self {
235
- self.filters
236
- .push((column.to_owned(), format!("gt.{}", value.to_string())));
237
- self
238
- }
239
-
240
- /// `column >= value`
241
- pub fn gte(mut self, column: &str, value: impl ToString) -> Self {
242
- self.filters
243
- .push((column.to_owned(), format!("gte.{}", value.to_string())));
244
- self
245
- }
246
-
247
- /// `column < value`
248
- pub fn lt(mut self, column: &str, value: impl ToString) -> Self {
249
- self.filters
250
- .push((column.to_owned(), format!("lt.{}", value.to_string())));
251
- self
252
- }
253
-
254
- /// `column <= value`
255
- pub fn lte(mut self, column: &str, value: impl ToString) -> Self {
256
- self.filters
257
- .push((column.to_owned(), format!("lte.{}", value.to_string())));
258
- self
259
- }
260
-
261
- /// `column LIKE pattern` (case-sensitive)
262
- pub fn like(mut self, column: &str, pattern: &str) -> Self {
263
- self.filters
264
- .push((column.to_owned(), format!("like.{pattern}")));
265
- self
266
- }
267
-
268
- /// `column ILIKE pattern` (case-insensitive)
269
- pub fn ilike(mut self, column: &str, pattern: &str) -> Self {
270
- self.filters
271
- .push((column.to_owned(), format!("ilike.{pattern}")));
272
- self
273
- }
274
-
275
- /// `column IN (values...)`
276
- pub fn in_list(mut self, column: &str, values: &[impl ToString]) -> Self {
277
- let joined = values
278
- .iter()
279
- .map(|value| value.to_string())
280
- .collect::<Vec<_>>()
281
- .join(",");
282
- self.filters
283
- .push((column.to_owned(), format!("in.({joined})")));
284
- self
285
- }
286
-
287
- /// `column IS NULL` when `null` is `true`; `column IS NOT NULL` otherwise.
288
- pub fn is_null(mut self, column: &str, null: bool) -> Self {
289
- let filter_value = if null {
290
- "is.null".to_owned()
291
- } else {
292
- "is.not.null".to_owned()
293
- };
294
- self.filters.push((column.to_owned(), filter_value));
295
- self
296
- }
297
-
298
- // --- Ordering, paging ----------------------------------------------------
299
-
300
- /// Append an `ORDER BY column dir` clause.
301
- pub fn order(mut self, column: &str, dir: OrderDir) -> Self {
302
- self.order.push(format!("{}.{}", column, dir.as_str()));
303
- self
304
- }
305
-
306
- /// Limit the number of rows returned.
307
- pub fn limit(mut self, n: usize) -> Self {
308
- self.limit = Some(n);
309
- self
310
- }
311
-
312
- /// Skip the first `n` rows.
313
- pub fn offset(mut self, n: usize) -> Self {
314
- self.offset = Some(n);
315
- self
316
- }
317
-
318
- // --- Mutations -----------------------------------------------------------
319
-
320
- /// Switch to a `POST` (INSERT) request with `rows` as the JSON body.
321
- pub fn insert(mut self, rows: Value) -> Self {
322
- self.operation = Operation::Insert(rows);
323
- self
324
- }
325
-
326
- /// Switch to a `POST` (UPSERT) request. Duplicate rows are merged.
327
- pub fn upsert(mut self, rows: Value) -> Self {
328
- self.operation = Operation::Upsert(rows);
329
- self
330
- }
331
-
332
- /// Switch to a `PATCH` (UPDATE) request with `patch` as the JSON body.
333
- pub fn update(mut self, patch: Value) -> Self {
334
- self.operation = Operation::Update(patch);
335
- self
336
- }
337
-
338
- /// Switch to a `DELETE` request.
339
- pub fn delete(mut self) -> Self {
340
- self.operation = Operation::Delete;
341
- self
342
- }
343
-
344
- // --- Terminal ------------------------------------------------------------
345
-
346
- /// Send the request and deserialise the response body as `T`.
347
- ///
348
- /// - For a `SELECT` that returns rows, use `T = Vec<YourRow>`.
349
- /// - For mutations where you do not need the returned data, use `T = ()`.
350
- pub async fn execute<T: DeserializeOwned>(self) -> Result<T, GresiqRestError> {
351
- let QueryBuilder {
352
- client,
353
- table,
354
- select,
355
- filters,
356
- order,
357
- limit,
358
- offset,
359
- operation,
360
- } = self;
361
-
362
- let url = client.table_url(&table);
363
-
364
- // Determine HTTP method, Prefer header, and optional JSON body from
365
- // the operation variant.
366
- let (http_method, prefer_header, body) = match operation {
367
- Operation::Select => ("GET", "return=representation".to_owned(), None),
368
- Operation::Insert(rows) => ("POST", "return=representation".to_owned(), Some(rows)),
369
- Operation::Update(patch) => ("PATCH", "return=representation".to_owned(), Some(patch)),
370
- Operation::Upsert(rows) => (
371
- "POST",
372
- "return=representation,resolution=merge-duplicates".to_owned(),
373
- Some(rows),
374
- ),
375
- Operation::Delete => ("DELETE", "return=representation".to_owned(), None),
376
- };
377
-
378
- let base_request = match http_method {
379
- "GET" => client.http.get(&url),
380
- "POST" => client.http.post(&url),
381
- "PATCH" => client.http.patch(&url),
382
- "DELETE" => client.http.delete(&url),
383
- _ => unreachable!(),
384
- };
385
-
386
- // Apply standard headers and the Prefer header.
387
- let mut request = client
388
- .apply_headers(base_request)
389
- .header("Prefer", prefer_header);
390
-
391
- // Build query-string parameters.
392
- let mut params: Vec<(String, String)> = Vec::new();
393
- params.push(("select".to_owned(), select));
394
- for (column, filter_value) in filters {
395
- params.push((column, filter_value));
396
- }
397
- if !order.is_empty() {
398
- params.push(("order".to_owned(), order.join(",")));
399
- }
400
- if let Some(n) = limit {
401
- params.push(("limit".to_owned(), n.to_string()));
402
- }
403
- if let Some(n) = offset {
404
- params.push(("offset".to_owned(), n.to_string()));
405
- }
406
- request = request.query(¶ms);
407
-
408
- // Attach JSON body for mutating operations.
409
- if let Some(json_body) = body {
410
- request = request.json(&json_body);
411
- }
412
-
413
- // Send and check for HTTP-level errors.
414
- let response = request
415
- .send()
416
- .await
417
- .map_err(|error| GresiqRestError::Http(error.to_string()))?;
418
-
419
- let status = response.status();
420
-
421
- if !status.is_success() {
422
- let status_code = status.as_u16();
423
- let body_text = response
424
- .text()
425
- .await
426
- .unwrap_or_else(|_| "<unreadable>".to_owned());
427
- return Err(GresiqRestError::Api {
428
- status: status_code,
429
- body: body_text,
430
- });
431
- }
432
-
433
- // Handle 204 No Content — no bytes to deserialise.
434
- if status == StatusCode::NO_CONTENT {
435
- return deserialise_empty();
436
- }
437
-
438
- let bytes = response
439
- .bytes()
440
- .await
441
- .map_err(|error| GresiqRestError::Http(error.to_string()))?;
442
-
443
- if bytes.is_empty() {
444
- return deserialise_empty();
445
- }
446
-
447
- serde_json::from_slice::<T>(&bytes)
448
- .map_err(|error| GresiqRestError::Deserialize(error.to_string()))
449
- }
450
-}
451
-
452
-/// Try sensible fallbacks when the server sends no response body.
453
-///
454
-/// Works for `T = ()` (deserialises `null`) and `T = Vec<_>` (deserialises
455
-/// `[]`). Returns [`GresiqRestError::Deserialize`] if neither succeeds.
456
-fn deserialise_empty<T: DeserializeOwned>() -> Result<T, GresiqRestError> {
457
- if let Ok(value) = serde_json::from_value::<T>(Value::Null) {
458
- return Ok(value);
459
- }
460
- if let Ok(value) = serde_json::from_str::<T>("[]") {
461
- return Ok(value);
462
- }
463
- Err(GresiqRestError::Deserialize("empty response".to_owned()))
464
-}
465
-
466
-// --- Tests -------------------------------------------------------------------
467
-
468
-#[cfg(test)]
469
-mod tests {
470
- use super::*;
471
-
472
- fn make_client() -> GresiqRestClient {
473
- GresiqRestClient::new(GresiqRestConfig {
474
- base_url: "https://example.gresiq.smbcloud.xyz".to_owned(),
475
- api_key: "test-key".to_owned(),
476
- schema: None,
477
- })
478
- .expect("client build should not fail in tests")
479
- }
480
-
481
- #[test]
482
- fn table_url_has_correct_shape() {
483
- let client = make_client();
484
- assert_eq!(
485
- client.table_url("lessons"),
486
- "https://example.gresiq.smbcloud.xyz/rest/v1/lessons"
487
- );
488
- }
489
-
490
- #[test]
491
- fn order_dir_as_str() {
492
- assert_eq!(OrderDir::Asc.as_str(), "asc");
493
- assert_eq!(OrderDir::Desc.as_str(), "desc");
494
- }
495
-
496
- #[test]
497
- fn deserialise_empty_works_for_unit() {
498
- let result: Result<(), GresiqRestError> = deserialise_empty();
499
- assert!(result.is_ok());
500
- }
501
-
502
- #[test]
503
- fn deserialise_empty_works_for_vec() {
504
- let result: Result<Vec<String>, GresiqRestError> = deserialise_empty();
505
- assert!(result.is_ok());
506
- assert!(result.unwrap().is_empty());
507
- }
508
-
509
- #[test]
510
- fn from_env_errors_on_missing_url() {
511
- // Ensure neither variable is set for this test.
512
- unsafe {
513
- env::remove_var("GRESIQ_REST_URL");
514
- env::remove_var("GRESIQ_API_KEY");
515
- }
516
- let result = GresiqRestConfig::from_env();
517
- assert!(matches!(result, Err(GresiqRestError::Config(_))));
518
- }
519
-}