use crate::fork::{ForkError, ForkKind};
use crate::hello::{BackendDescriptor, OpVersions};
use crate::kv::KvError;
use crate::query::QueryError;
use crate::result::ResultCode;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const CAPABILITIES_PATH: &str = "/agdx/capabilities";
pub const QUERY_PATH: &str = "/agdx/query";
pub const PROJECTIONS_PATH: &str = "/agdx/projections";
pub const BINDINGS_PATH: &str = "/agdx/bindings";
pub const SCHEMAS_PATH: &str = "/agdx/schemas";
pub const KV_PATH: &str = "/agdx/kv";
pub const FORKS_PATH: &str = "/agdx/forks";
pub fn projection_path(id: &str) -> String {
format!("{PROJECTIONS_PATH}/{id}")
}
pub fn schema_path(id: u32) -> String {
format!("{SCHEMAS_PATH}/{id}")
}
pub fn schema_decode_path(id: u32) -> String {
format!("{SCHEMAS_PATH}/{id}/decode")
}
pub fn kv_namespace_path(namespace: &str) -> String {
format!("{KV_PATH}/{namespace}")
}
pub fn kv_entry_path(namespace: &str, key_b64: &str) -> String {
format!("{KV_PATH}/{namespace}/{key_b64}")
}
pub fn kv_cas_path(namespace: &str, key_b64: &str) -> String {
format!("{KV_PATH}/{namespace}/{key_b64}/cas")
}
pub fn fork_path(id: &str) -> String {
format!("{FORKS_PATH}/{id}")
}
pub fn fork_promote_path(id: &str) -> String {
format!("{FORKS_PATH}/{id}/promote")
}
pub fn fork_rows_path(id: &str) -> String {
format!("{FORKS_PATH}/{id}/rows")
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Capabilities {
pub managed: bool,
pub query: bool,
pub projections: bool,
pub schemas: bool,
pub kv: bool,
pub fork: bool,
#[serde(default)]
pub kv_cas: bool,
#[serde(default)]
pub read_your_writes: bool,
#[serde(default)]
pub strong_consistency: bool,
pub versions: OpVersions,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub backends: Vec<BackendDescriptor>,
}
impl Capabilities {
pub fn new(enabled: bool, versions: OpVersions) -> Self {
Self {
managed: enabled,
query: enabled,
projections: enabled,
schemas: enabled,
kv: enabled,
fork: enabled,
kv_cas: false,
read_your_writes: false,
strong_consistency: false,
versions,
backends: Vec::new(),
}
}
#[must_use]
pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
self.backends = backends;
self
}
#[must_use]
pub fn with_kv_cas(mut self, on: bool) -> Self {
self.kv_cas = on;
self
}
#[must_use]
pub fn with_read_your_writes(mut self, on: bool) -> Self {
self.read_your_writes = on;
self
}
#[must_use]
pub fn with_strong_consistency(mut self, on: bool) -> Self {
self.strong_consistency = on;
self
}
pub fn from_versions(enabled: bool, versions: OpVersions) -> Self {
Self::new(enabled, versions)
.with_kv_cas(versions.has_feature(crate::hello::feature::KV_CAS))
.with_read_your_writes(versions.has_feature(crate::hello::feature::READ_YOUR_WRITES))
.with_strong_consistency(
versions.has_feature(crate::hello::feature::STRONG_CONSISTENCY),
)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KvEntryView {
pub key: String,
pub value: String,
pub expires_at_micros: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KvPageView {
pub entries: Vec<KvEntryView>,
pub cursor: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeletedManyView {
pub deleted: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromotedView {
pub rows: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RegisterSchemaBody {
pub source: crate::control::SchemaSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DecodeRecordBody {
pub payload: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ForkCreateBody {
pub fork_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(default)]
pub kind: ForkKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tables: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ForkPutBody {
pub table: String,
pub partition_id: u32,
pub offset: u64,
#[serde(default)]
pub projection_id: String,
#[serde(default)]
pub projection_version: u32,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub fields: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload_b64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedding: Option<String>,
#[serde(default)]
pub tombstone: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoveBindingBody {
pub stream: String,
pub topic: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projection_ref: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorBody {
pub code: ResultCode,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<serde_json::Value>,
}
impl ErrorBody {
pub fn new(code: ResultCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
detail: None,
}
}
#[must_use]
pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
self.detail = Some(detail);
self
}
pub fn http_status(&self) -> u16 {
self.code.http_status()
}
}
impl From<&QueryError> for ErrorBody {
fn from(error: &QueryError) -> Self {
Self::new(ResultCode::from(error), error.to_string())
}
}
impl From<&KvError> for ErrorBody {
fn from(error: &KvError) -> Self {
Self::new(ResultCode::from(error), error.to_string())
}
}
impl From<&ForkError> for ErrorBody {
fn from(error: &ForkError) -> Self {
Self::new(ResultCode::from(error), error.to_string())
}
}
pub const PARAM_TOPIC: &str = "topic";
pub const PARAM_NAME_CONTAINS: &str = "name_contains";
pub const PARAM_ID_PREFIX: &str = "id_prefix";
pub const PARAM_SEARCH: &str = "search";
pub const PARAM_PREFIX: &str = "prefix";
pub const PARAM_START: &str = "start";
pub const PARAM_END: &str = "end";
pub const PARAM_KEY_CONTAINS: &str = "key_contains";
pub const PARAM_LIMIT: &str = "limit";
pub const PARAM_CURSOR: &str = "cursor";
pub const PARAM_EXPIRES_AT_MICROS: &str = "expires_at_micros";
pub const PARAM_EXPECT_VERSION: &str = "expect_version";
pub const PARAM_EXPECT_ABSENT: &str = "expect_absent";
pub const KV_EXPIRES_AT_MICROS_HEADER: &str = "agdx-expires-at-micros";
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectionListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topic: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name_contains: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name_contains: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct KvScanQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_contains: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct KvPutQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at_micros: Option<u64>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct KvCasQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expect_version: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expect_absent: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at_micros: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CasCommittedView {
pub version: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn given_path_builders_when_rendered_then_should_match_the_router() {
assert_eq!(projection_path("order.v1"), "/agdx/projections/order.v1");
assert_eq!(schema_path(7), "/agdx/schemas/7");
assert_eq!(schema_decode_path(7), "/agdx/schemas/7/decode");
assert_eq!(kv_namespace_path("sessions"), "/agdx/kv/sessions");
assert_eq!(
kv_entry_path("sessions", "dXNlcjo0Mg"),
"/agdx/kv/sessions/dXNlcjo0Mg"
);
assert_eq!(
kv_cas_path("sessions", "dXNlcjo0Mg"),
"/agdx/kv/sessions/dXNlcjo0Mg/cas"
);
assert_eq!(fork_path("f1"), "/agdx/forks/f1");
assert_eq!(fork_promote_path("f1"), "/agdx/forks/f1/promote");
assert_eq!(fork_rows_path("f1"), "/agdx/forks/f1/rows");
}
#[test]
fn given_capabilities_when_constructed_then_extended_features_default_off() {
let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
assert!(
caps.query && caps.kv && caps.fork,
"core surfaces track enabled"
);
assert!(
!caps.kv_cas && !caps.read_your_writes && !caps.strong_consistency,
"extended features must be opt-in, never on by default"
);
let opted = caps.with_kv_cas(true).with_read_your_writes(true);
assert!(opted.kv_cas && opted.read_your_writes && !opted.strong_consistency);
}
#[test]
fn given_capabilities_backends_when_json_round_tripped_then_should_preserve_and_omit_empty() {
use crate::hello::BackendDescriptor;
let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_backends(vec![
BackendDescriptor::new("embedded", "embedded"),
BackendDescriptor::new("warehouse", "columnar"),
]);
let json = serde_json::to_string(&caps).expect("serializes");
let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
assert_eq!(back.backends.len(), 2);
assert_eq!(back.backends[1].id, "warehouse");
assert_eq!(back.backends[1].kind, "columnar");
let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
let json = serde_json::to_string(&plain).expect("json");
assert!(!json.contains("backends"), "empty backends omitted: {json}");
}
#[test]
fn given_a_typed_error_when_made_into_a_body_then_should_carry_code_and_message() {
let body = ErrorBody::from(&QueryError::IndexNotFound("orders".to_owned()));
assert_eq!(body.code, ResultCode::NotFound);
assert_eq!(body.http_status(), 404);
assert!(body.message.contains("orders"));
let json = serde_json::to_string(&body).expect("serializes");
let back: ErrorBody = serde_json::from_str(&json).expect("deserializes");
assert_eq!(back, body);
}
#[test]
#[cfg(feature = "http-client")]
fn given_scan_filters_when_url_encoded_then_should_omit_absent_fields() {
let query = KvScanQuery {
prefix: Some("dXNlcjo".to_owned()),
limit: Some(50),
..Default::default()
};
let encoded = serde_urlencoded::to_string(&query).expect("encodes");
assert_eq!(encoded, "prefix=dXNlcjo&limit=50");
assert!(encoded.contains(&format!("{PARAM_PREFIX}=")));
assert!(encoded.contains(&format!("{PARAM_LIMIT}=")));
}
#[test]
#[cfg(feature = "http-client")]
fn given_list_filters_when_url_encoded_then_field_names_match_the_param_consts() {
let projections = ProjectionListQuery {
name_contains: Some("order".to_owned()),
id_prefix: Some("order.".to_owned()),
..Default::default()
};
let encoded = serde_urlencoded::to_string(&projections).expect("encodes");
assert_eq!(encoded, "name_contains=order&id_prefix=order.");
assert!(encoded.contains(&format!("{PARAM_NAME_CONTAINS}=")));
assert!(encoded.contains(&format!("{PARAM_ID_PREFIX}=")));
let schemas = SchemaListQuery {
name_contains: Some("Order".to_owned()),
};
assert_eq!(
serde_urlencoded::to_string(&schemas).expect("encodes"),
"name_contains=Order"
);
}
}