use crate::api::{Client, Raw, seg};
use crate::error::Result;
use crate::transport::{Method, Request};
use crate::types::*;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
pub const POSTGRES_PLUGIN: &str = "postgres";
impl Client {
pub fn postgres(&self, namespace: impl Into<String>) -> PostgresClient {
PostgresClient {
client: self.clone(),
namespace: namespace.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewDatabase {
pub name: String,
pub secret: bool,
}
impl NewDatabase {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
secret: true,
}
}
pub fn without_secret(mut self) -> Self {
self.secret = false;
self
}
}
#[derive(Debug, Clone)]
pub struct PostgresClient {
client: Client,
namespace: String,
}
impl PostgresClient {
pub fn namespace(&self) -> &str {
&self.namespace
}
fn path(&self, rest: &str) -> String {
format!(
"/namespaces/{}/plugins/{POSTGRES_PLUGIN}/api/{rest}",
seg(&self.namespace)
)
}
pub async fn databases(&self) -> Result<PostgresDatabases> {
self.databases_as().await
}
async fn databases_as<T: DeserializeOwned>(&self) -> Result<T> {
self.client
.read(
Request::new(Method::Get, self.path("databases")),
"namespace",
&self.namespace,
)
.await
}
pub async fn create_database(&self, new: &NewDatabase) -> Result<CreatedPostgresDatabase> {
self.client
.read(
Request::new(Method::Post, self.path("databases"))
.json(json!({"name": new.name, "secret": new.secret})),
"database",
&new.name,
)
.await
}
pub async fn delete_database(&self, name: &str) -> Result<DeletedPostgresDatabase> {
self.client
.read(
Request::new(
Method::Delete,
self.path(&format!("databases/{}", seg(name))),
),
"database",
name,
)
.await
}
}
impl Raw<'_> {
pub async fn postgres_databases(&self, namespace: &str) -> Result<Value> {
self.0.postgres(namespace).databases_as().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::stub::Stub;
use std::sync::Arc;
fn client(stub: Stub) -> (Client, Arc<Stub>) {
let stub = Arc::new(stub);
(Client::with_transport(stub.clone()), stub)
}
#[tokio::test]
async fn databases_are_managed_through_the_namespace_plugin_prefix() {
let (c, stub) = client(
Stub::new()
.json(
201,
json!({"name": "app", "database": "ns_team_a_app", "user": "ns_team_a_app",
"password": "hunter2", "host": "pg.example", "port": 6432,
"sslmode": "require", "url": "postgres://ns_team_a_app:hunter2@pg.example:6432/ns_team_a_app?sslmode=require",
"secret": "pg-app", "secret_error": null, "status": "provisioning", "note": "once"}),
)
.json(
201,
json!({"name": "scratch", "database": "ns_team_a_scratch", "password": "x",
"secret": null}),
)
.json(
200,
json!({"namespace": "team a", "host": "pg.example", "port": 6432, "limit": 10,
"databases": [{"name": "app", "database": "ns_team_a_app", "live": true}],
"error": null}),
)
.json(
200,
json!({"deleted": "app", "database": "ns_team_a_app", "secret_kept": "pg-app"}),
),
);
let pg = c.postgres("team a");
let db = pg.create_database(&NewDatabase::new("app")).await.unwrap();
assert_eq!(db.password, "hunter2");
assert_eq!(db.secret.as_deref(), Some("pg-app"));
let shown = format!("{db:?}");
assert!(
!shown.contains("hunter2"),
"Debug leaks the credential: {shown}"
);
let bare = pg
.create_database(&NewDatabase::new("scratch").without_secret())
.await
.unwrap();
assert!(bare.secret.is_none());
let list = pg.databases().await.unwrap();
assert_eq!(list.databases[0].name, "app");
assert_eq!(list.databases[0].live, Some(true));
assert_eq!(list.limit, 10);
let gone = pg.delete_database("app").await.unwrap();
assert_eq!(gone.secret_kept.as_deref(), Some("pg-app"));
let calls = stub.calls();
let seen: Vec<(Method, &str)> = calls.iter().map(|c| (c.method, c.path.as_str())).collect();
assert_eq!(
seen,
[
(
Method::Post,
"/namespaces/team%20a/plugins/postgres/api/databases"
),
(
Method::Post,
"/namespaces/team%20a/plugins/postgres/api/databases"
),
(
Method::Get,
"/namespaces/team%20a/plugins/postgres/api/databases"
),
(
Method::Delete,
"/namespaces/team%20a/plugins/postgres/api/databases/app"
),
]
);
assert_eq!(calls[0].body, Some(json!({"name": "app", "secret": true})));
assert_eq!(
calls[1].body,
Some(json!({"name": "scratch", "secret": false}))
);
}
#[tokio::test]
async fn not_installed_is_a_conflict_that_says_how_to_install() {
let (c, _) = client(Stub::new().json(
409,
json!({"error": "the postgres plugin is not installed in namespace \"a\"; install it with `heyctl plugins install postgres -n a`",
"code": "plugin_not_installed"}),
));
let e = c.postgres("a").databases().await.unwrap_err();
assert!(matches!(e, crate::Error::Conflict { .. }), "{e:?}");
assert!(e.to_string().contains("plugins install postgres"), "{e}");
}
}