use crate::graphql_federation::{compose, CompositionError, Supergraph};
use boatramp_core::config::HandlerGraphqlDataConfig;
use boatramp_core::kv::KvStore;
use std::collections::BTreeMap;
fn subgraph_prefix(project: &str) -> String {
format!("graphql/{project}/subgraph/")
}
fn subgraph_key(project: &str, name: &str) -> String {
format!("{}{name}", subgraph_prefix(project))
}
fn backend_prefix(project: &str) -> String {
format!("graphql/{project}/subgraph-backend/")
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub(crate) enum SubgraphBackendSpec {
Function,
Sql {
site: String,
config: HandlerGraphqlDataConfig,
},
}
pub(crate) async fn put_subgraph_backend(
kv: &dyn KvStore,
project: &str,
name: &str,
spec: &SubgraphBackendSpec,
) -> Result<(), String> {
let bytes = serde_json::to_vec(spec).map_err(|e| e.to_string())?;
kv.put(&backend_key(project, name), bytes)
.await
.map_err(|e| e.to_string())
}
fn backend_key(project: &str, name: &str) -> String {
format!("{}{name}", backend_prefix(project))
}
pub(crate) async fn sql_subgraphs(
kv: &dyn KvStore,
project: &str,
) -> BTreeMap<String, (String, HandlerGraphqlDataConfig)> {
let prefix = backend_prefix(project);
let mut out = BTreeMap::new();
for key in kv.list_prefix(&prefix).await.unwrap_or_default() {
let Ok(Some(bytes)) = kv.get(&key).await else {
continue;
};
if let Ok(SubgraphBackendSpec::Sql { site, config }) = serde_json::from_slice(&bytes) {
let name = key.strip_prefix(&prefix).unwrap_or(&key).to_string();
out.insert(name, (site, config));
}
}
out
}
#[derive(Debug)]
pub(crate) enum PublishError {
Composition(CompositionError),
Store(String),
}
async fn load_subgraphs(kv: &dyn KvStore, project: &str) -> Vec<(String, String)> {
let prefix = subgraph_prefix(project);
let mut out = Vec::new();
for key in kv.list_prefix(&prefix).await.unwrap_or_default() {
if let Ok(Some(bytes)) = kv.get(&key).await {
if let Ok(sdl) = String::from_utf8(bytes) {
let name = key.strip_prefix(&prefix).unwrap_or(&key).to_string();
out.push((name, sdl));
}
}
}
out
}
pub(crate) async fn publish(
kv: &dyn KvStore,
project: &str,
name: &str,
sdl: &str,
) -> Result<Supergraph, PublishError> {
let mut subgraphs = load_subgraphs(kv, project).await;
subgraphs.retain(|(n, _)| n != name);
subgraphs.push((name.to_string(), sdl.to_string()));
let sg = compose(&subgraphs).map_err(PublishError::Composition)?;
kv.put(&subgraph_key(project, name), sdl.as_bytes().to_vec())
.await
.map_err(|e| PublishError::Store(e.to_string()))?;
Ok(sg)
}
pub(crate) async fn supergraph(
kv: &dyn KvStore,
project: &str,
) -> Result<Supergraph, CompositionError> {
compose(&load_subgraphs(kv, project).await)
}
pub(crate) async fn is_registered_subgraph(kv: &dyn KvStore, project: &str, name: &str) -> bool {
matches!(kv.get(&subgraph_key(project, name)).await, Ok(Some(_)))
}
pub(crate) async fn unpublish(kv: &dyn KvStore, project: &str, name: &str) -> Result<(), String> {
kv.delete(&subgraph_key(project, name))
.await
.map_err(|e| e.to_string())?;
kv.delete(&backend_key(project, name))
.await
.map_err(|e| e.to_string())
}
pub(crate) async fn subgraph_names(kv: &dyn KvStore, project: &str) -> Vec<String> {
let prefix = subgraph_prefix(project);
kv.list_prefix(&prefix)
.await
.unwrap_or_default()
.into_iter()
.map(|k| k.strip_prefix(&prefix).unwrap_or(&k).to_string())
.collect()
}
pub(crate) fn summary_json(sg: &Supergraph, subgraphs: &[String]) -> serde_json::Value {
let entities: serde_json::Map<String, serde_json::Value> = sg
.entities
.iter()
.map(|(ty, e)| {
(
ty.clone(),
serde_json::json!({ "key": e.key, "subgraphs": e.subgraphs }),
)
})
.collect();
serde_json::json!({
"subgraphs": subgraphs,
"entities": entities,
"rootQuery": sg.root_query,
"rootMutation": sg.root_mutation,
})
}
#[cfg(test)]
mod tests {
use super::*;
use boatramp_core::kv::MemoryKv;
const ACCOUNTS: &str = r#"
type Query { me: User }
type User @key(fields: "id") { id: ID! name: String }
"#;
const REVIEWS: &str = r#"
type Query { topReviews: [Review] }
type Review { id: ID! body: String author: User }
extend type User @key(fields: "id") { id: ID! @external reviews: [Review] }
"#;
#[tokio::test]
async fn publish_composes_stores_and_recomposes() {
let kv = MemoryKv::new();
publish(&kv, "acme", "accounts", ACCOUNTS).await.unwrap();
let sg = publish(&kv, "acme", "reviews", REVIEWS).await.unwrap();
assert!(sg.entities.contains_key("User"));
let current = supergraph(&kv, "acme").await.unwrap();
assert_eq!(current.root_query.len(), 2);
assert_eq!(
subgraph_names(&kv, "acme").await,
vec!["accounts", "reviews"]
);
}
#[tokio::test]
async fn an_incompatible_publish_is_rejected_and_not_stored() {
let kv = MemoryKv::new();
publish(&kv, "acme", "a", "type Query { x: Int } type T { f: Int }")
.await
.unwrap();
let err = publish(&kv, "acme", "b", "type T { f: Int }").await;
assert!(matches!(err, Err(PublishError::Composition(_))));
assert_eq!(subgraph_names(&kv, "acme").await, vec!["a".to_string()]);
}
#[tokio::test]
async fn projects_are_isolated() {
let kv = MemoryKv::new();
publish(&kv, "acme", "s", "type Query { x: Int }")
.await
.unwrap();
assert!(subgraph_names(&kv, "other").await.is_empty());
}
#[tokio::test]
async fn is_registered_subgraph_reflects_registration_and_unpublish_removes_it() {
let kv = MemoryKv::new();
assert!(!is_registered_subgraph(&kv, "acme", "accounts").await);
publish(&kv, "acme", "accounts", ACCOUNTS).await.unwrap();
put_subgraph_backend(&kv, "acme", "accounts", &SubgraphBackendSpec::Function)
.await
.unwrap();
assert!(is_registered_subgraph(&kv, "acme", "accounts").await);
unpublish(&kv, "acme", "accounts").await.unwrap();
assert!(!is_registered_subgraph(&kv, "acme", "accounts").await);
assert!(subgraph_names(&kv, "acme").await.is_empty());
unpublish(&kv, "acme", "accounts").await.unwrap();
}
}