@smbcloud / cli / commits / 6a09421

Remove gresiq binary and REST client, keep only module export

keypair34 committed Apr 18, 2026 at 12:35 UTC 6a094212436c5a26bac28251f6d7c06027bd8429
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(&params);
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 -}