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 version_key(project: &str) -> String {
format!("graphql/{project}/version")
}
pub(crate) async fn composition_version(kv: &dyn KvStore, project: &str) -> u64 {
match kv.get(&version_key(project)).await {
Ok(Some(bytes)) if bytes.len() == 8 => {
let mut arr = [0u8; 8];
arr.copy_from_slice(&bytes);
u64::from_be_bytes(arr)
}
_ => 0,
}
}
async fn bump_version(kv: &dyn KvStore, project: &str) -> Result<(), String> {
let next = composition_version(kv, project).await.wrapping_add(1);
kv.put(&version_key(project), next.to_be_bytes().to_vec())
.await
.map_err(|e| e.to_string())
}
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())?;
bump_version(kv, project).await
}
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()))?;
bump_version(kv, project)
.await
.map_err(PublishError::Store)?;
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())?;
bump_version(kv, project).await
}
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"]
);
}
const ASYNC_GRAPHQL_V2: &str = r#"type Query {
users: [User!]!
}
type User @key(fields: "id") {
id: ID!
name: String!
}
"""
Directs the executor to include this field or fragment only when the `if` argument is true.
"""
directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
extend schema @link(
url: "https://specs.apollo.dev/federation/v2.5",
import: ["@key", "@tag", "@shareable", "@inaccessible", "@override", "@external", "@provides", "@requires", "@composeDirective", "@interfaceObject"]
)
"#;
#[tokio::test]
async fn publishes_real_async_graphql_v2_sdl_the_documented_way() {
let kv = MemoryKv::new();
let sg = publish(&kv, "acme", "users", ASYNC_GRAPHQL_V2)
.await
.unwrap();
assert_eq!(
sg.root_query.get("users").map(String::as_str),
Some("users")
);
assert!(sg.entities.contains_key("User"), "User entity registered");
let current = supergraph(&kv, "acme").await.unwrap();
assert!(current.root_query.contains_key("users"));
assert_eq!(subgraph_names(&kv, "acme").await, vec!["users".to_string()]);
}
#[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 every_registry_mutation_bumps_the_composition_version() {
let kv = MemoryKv::new();
assert_eq!(composition_version(&kv, "acme").await, 0);
publish(&kv, "acme", "accounts", ACCOUNTS).await.unwrap();
let v1 = composition_version(&kv, "acme").await;
assert_eq!(v1, 1);
put_subgraph_backend(&kv, "acme", "accounts", &SubgraphBackendSpec::Function)
.await
.unwrap();
let v2 = composition_version(&kv, "acme").await;
assert!(v2 > v1, "backend change bumps the version");
unpublish(&kv, "acme", "accounts").await.unwrap();
assert!(composition_version(&kv, "acme").await > v2);
assert_eq!(composition_version(&kv, "other").await, 0);
}
#[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();
}
}