use crate::checkpoint::{
CheckpointMutationResult, CheckpointReadConsistency, CheckpointRequestEnvelope,
DestinationCheckpointStatus, PreparedAttemptSummary, RetentionGap,
};
use crate::destination::{
DestinationId, DestinationOperationId, MaterializationDestination, QueryRoute,
};
use crate::fork::{ForkError, ForkKind};
use crate::graph::SourceRef;
use crate::hello::{BackendDescriptor, FilterAnnounce, OpVersions};
use crate::kv::KvError;
use crate::query::{Consistency, QueryError, QueryExecutionId, QueryExecutionStatus, QueryResult};
use crate::result::ResultCode;
use crate::schema::{Digest32, LogicalSchema, TypedValue, UuidValue};
use crate::topology::WireTopology;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const CAPABILITIES_PATH: &str = "/agdx/capabilities";
pub const QUERY_PATH: &str = "/agdx/query";
pub const DESTINATIONS_PATH: &str = "/agdx/destinations";
pub const QUERY_ROUTES_PATH: &str = "/agdx/query-routes";
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 const GRAPHS_PATH: &str = "/agdx/graphs";
pub const GRAPH_PATH: &str = "/agdx/graph";
pub const CLIENTS_PATH: &str = "/agdx/clients";
pub const AUTHZ_WHOAMI_PATH: &str = "/agdx/authz/whoami";
pub const AUTHZ_ROLES_PATH: &str = "/agdx/authz/roles";
pub const FILTERS_PATH: &str = "/agdx/filters";
pub const FILTER_METRICS_PATH: &str = "/agdx/filters/metrics";
pub const FILTER_MUTATIONS_PATH: &str = "/agdx/filters/mutations";
pub const FILTER_VALIDATE_PATH: &str = "/agdx/filters/validate";
pub const FILTER_TEST_PATH: &str = "/agdx/filters/test";
pub const FILTER_PREVIEW_PATH: &str = "/agdx/filters/preview";
pub const FILTER_BINDINGS_PATH: &str = "/agdx/filter-bindings";
pub const GROUP_POLICIES_PATH: &str = "/agdx/group-policies";
pub const SESSIONS_PATH: &str = "/agdx/sessions";
pub fn sessions_path(stream: &str) -> String {
format!("{SESSIONS_PATH}/{}", path_segment(stream))
}
pub fn session_changes_path(stream: &str) -> String {
format!("{}/changes", sessions_path(stream))
}
pub fn session_path(stream: &str, id: crate::agent::ConversationId) -> String {
format!(
"{}/{}",
sessions_path(stream),
path_segment(&id.to_string())
)
}
pub fn session_events_path(stream: &str, id: crate::agent::ConversationId) -> String {
format!("{}/events", session_path(stream, id))
}
pub fn session_state_path(stream: &str, id: crate::agent::ConversationId) -> String {
format!("{}/state", session_path(stream, id))
}
pub fn session_links_path(stream: &str, id: crate::agent::ConversationId) -> String {
format!("{}/links", session_path(stream, id))
}
pub fn session_sources_path(stream: &str, id: crate::agent::ConversationId) -> String {
format!("{}/sources", session_path(stream, id))
}
pub fn filter_path(id: u32) -> String {
format!("{FILTERS_PATH}/{id}")
}
pub fn filter_revisions_path(id: u32) -> String {
format!("{FILTERS_PATH}/{id}/revisions")
}
pub fn filter_operation_path(operation_id: u128) -> String {
format!("{FILTERS_PATH}/operations/{operation_id}")
}
pub fn filter_binding_path(stream: &str, topic: &str, group: &str) -> String {
format!(
"{FILTER_BINDINGS_PATH}/{}/{}/{}",
path_segment(stream),
path_segment(topic),
path_segment(group)
)
}
pub fn group_policy_path(stream: &str, topic: &str, group: &str) -> String {
format!(
"{GROUP_POLICIES_PATH}/{}/{}/{}",
path_segment(stream),
path_segment(topic),
path_segment(group)
)
}
pub fn path_segment(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for byte in text.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
out.push(char::from(byte));
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
pub fn destination_path(id: DestinationId) -> String {
format!("{DESTINATIONS_PATH}/{id}")
}
pub fn destination_enable_path(id: DestinationId) -> String {
format!("{}/{id}/enable", DESTINATIONS_PATH)
}
pub fn destination_disable_path(id: DestinationId) -> String {
format!("{}/{id}/disable", DESTINATIONS_PATH)
}
pub fn destination_status_path(id: DestinationId) -> String {
format!("{}/{id}/status", DESTINATIONS_PATH)
}
pub fn destination_checkpoint_path(id: DestinationId) -> String {
format!("{}/{id}/checkpoint", DESTINATIONS_PATH)
}
pub fn destination_retention_gap_path(id: DestinationId) -> String {
format!("{}/{id}/retention-gap", DESTINATIONS_PATH)
}
pub fn destination_prepared_attempt_path(id: DestinationId) -> String {
format!("{}/{id}/prepared-attempt", DESTINATIONS_PATH)
}
pub fn destination_table_path(id: DestinationId) -> String {
format!("{}/{id}/table", DESTINATIONS_PATH)
}
pub fn destination_table_schema_path(id: DestinationId) -> String {
format!("{}/{id}/table/schema", DESTINATIONS_PATH)
}
pub fn destination_snapshots_path(id: DestinationId) -> String {
format!("{}/{id}/table/snapshots", DESTINATIONS_PATH)
}
pub fn destination_current_snapshot_path(id: DestinationId) -> String {
format!("{}/{id}/table/current-snapshot", DESTINATIONS_PATH)
}
pub fn destination_snapshot_path(id: DestinationId, snapshot_id: i64) -> String {
format!("{}/{id}/table/snapshots/{snapshot_id}", DESTINATIONS_PATH)
}
pub fn destination_files_path(id: DestinationId) -> String {
format!("{}/{id}/table/files", DESTINATIONS_PATH)
}
pub fn destination_metrics_path(id: DestinationId) -> String {
format!("{}/{id}/table/metrics", DESTINATIONS_PATH)
}
pub fn query_execution_path(id: QueryExecutionId) -> String {
format!("{QUERY_PATH}/{id}")
}
pub fn query_page_path(id: QueryExecutionId) -> String {
format!("{QUERY_PATH}/{id}/pages")
}
pub fn query_cancel_path(id: QueryExecutionId) -> String {
format!("{QUERY_PATH}/{id}/cancel")
}
pub fn destination_operation_path(id: DestinationOperationId) -> String {
format!("{DESTINATIONS_PATH}/operations/{id}")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckpointReadQuery {
pub consistency: CheckpointReadConsistency,
}
impl CheckpointReadQuery {
pub const fn new(consistency: CheckpointReadConsistency) -> Self {
Self { consistency }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DestinationListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_stream: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_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 after: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
pub consistency: CheckpointReadConsistency,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DestinationPageView {
pub destinations: Vec<DestinationView>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
pub global_state_revision: u64,
pub consistency: crate::checkpoint::CheckpointReadConsistency,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DestinationView {
pub destination: MaterializationDestination,
pub status: DestinationCheckpointStatus,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DestinationMutationBody {
pub checkpoint: CheckpointRequestEnvelope,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcceptedOperationView {
pub operation_id: DestinationOperationId,
pub request_id: crate::checkpoint::CheckpointRequestId,
pub state: OperationState,
pub submitted_at_micros: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completed_at_micros: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<OperationErrorView>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<CheckpointMutationResult>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationErrorView {
pub code: ResultCode,
pub message: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum OperationState {
Accepted,
Running,
Succeeded,
Failed,
Cancelled,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QueryRouteListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name_contains: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
pub consistency: CheckpointReadConsistency,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QueryRoutePageView {
pub routes: Vec<QueryRoute>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
pub definition_revision: u64,
pub global_state_revision: u64,
pub consistency: CheckpointReadConsistency,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableView {
pub table_uuid: UuidValue,
pub destination_id: DestinationId,
pub destination_generation: u64,
pub namespace: Vec<String>,
pub table: String,
pub current_snapshot_id: i64,
pub current_schema_id: i32,
pub current_partition_spec_id: i32,
pub metadata_identity: String,
pub properties: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableSchemaView {
pub table_uuid: UuidValue,
pub iceberg_schema_id: i32,
pub logical_schema: LogicalSchema,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SnapshotListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_snapshot_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SnapshotPageView {
pub snapshots: Vec<TableSnapshotView>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_before_snapshot_id: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableSnapshotView {
pub snapshot_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_snapshot_id: Option<i64>,
pub sequence_number: i64,
pub committed_at_micros: u64,
pub schema_id: i32,
pub partition_spec_id: i32,
pub materialization_boundary_digest: Digest32,
pub checkpoint_revision: u64,
pub summary: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableFileListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TableFilePageView {
pub files: Vec<TableFileView>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TableFileView {
pub object_identity: String,
pub file_size_bytes: u64,
pub row_count: u64,
pub partition: BTreeMap<String, TypedValue>,
pub lower_bounds: BTreeMap<u32, TypedValue>,
pub upper_bounds: BTreeMap<u32, TypedValue>,
pub null_value_counts: BTreeMap<u32, u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableMetricsView {
pub snapshot_id: i64,
pub data_file_count: u64,
pub delete_file_count: u64,
pub total_rows: u64,
pub total_bytes: u64,
pub partition_count: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QueryPageBody {
pub cursor: String,
pub deadline_micros: u64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct QueryExecutionView {
pub status: QueryExecutionStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<QueryResult>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DestinationIssueView {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retention_gap: Option<RetentionGap>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prepared_attempt: Option<PreparedAttemptSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block: Option<crate::checkpoint::DestinationBlock>,
pub global_state_revision: u64,
pub checkpoint_revision: u64,
pub consistency: CheckpointReadConsistency,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DestinationHttpOperation {
ListDestinations,
GetDestination,
CreateDestination,
EnableDestination,
DisableDestination,
GetOperation,
GetStatus,
GetCheckpoint,
GetRetentionGap,
GetPreparedAttempt,
ListQueryRoutes,
GetTable,
GetTableSchema,
GetCurrentSnapshot,
ListSnapshots,
GetSnapshot,
ListFiles,
GetMetrics,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DestinationHttpRequest {
pub v: u32,
pub operation: DestinationHttpOperation,
pub parameters: Vec<String>,
pub query: Vec<u8>,
pub body: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DestinationHttpReply {
pub status: u16,
pub body: Vec<u8>,
}
pub fn authz_role_path(name: &str) -> String {
format!("{AUTHZ_ROLES_PATH}/{name}")
}
pub fn authz_user_roles_path(user_id: u32) -> String {
format!("/agdx/authz/users/{user_id}/roles")
}
pub fn graph_path(id: &str) -> String {
format!("{GRAPHS_PATH}/{}", path_segment(id))
}
pub fn graph_query_path(name: &str) -> String {
format!("{GRAPH_PATH}/{}/query", path_segment(name))
}
pub fn graph_neighbors_path(name: &str, node: &str) -> String {
format!(
"{GRAPH_PATH}/{}/neighbors/{}",
path_segment(name),
path_segment(node)
)
}
pub fn projection_path(id: &str) -> String {
format!("{PROJECTIONS_PATH}/{}", path_segment(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}/{}", path_segment(namespace))
}
pub fn kv_entry_path(namespace: &str, key_b64: &str) -> String {
format!("{}/{}", kv_namespace_path(namespace), path_segment(key_b64))
}
pub fn kv_cas_path(namespace: &str, key_b64: &str) -> String {
format!("{}/cas", kv_entry_path(namespace, key_b64))
}
pub fn fork_path(id: &str) -> String {
format!("{FORKS_PATH}/{}", path_segment(id))
}
pub fn fork_promote_path(id: &str) -> String {
format!("{}/promote", fork_path(id))
}
pub fn fork_rows_path(id: &str) -> String {
format!("{}/rows", fork_path(id))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Capabilities {
pub managed: bool,
pub query: QueryCapsView,
pub destinations: DestinationCapsView,
pub kv: KvCapsView,
#[serde(default)]
pub graph: bool,
pub fork: bool,
#[serde(default)]
pub sessions: bool,
#[serde(default)]
pub watch: bool,
#[serde(default)]
pub authz: bool,
#[serde(default)]
pub filters: FilterCapsView,
pub versions: OpVersions,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub backends: Vec<BackendDescriptor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topology: Option<WireTopology>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QueryCapsView {
pub available: bool,
pub projections: bool,
pub schemas: bool,
#[serde(default)]
pub consistency: Consistency,
#[serde(default)]
pub keyword: bool,
#[serde(default)]
pub cursor_paging: bool,
#[serde(default)]
pub cancellation: bool,
#[serde(default)]
pub execution_status: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DestinationCapsView {
pub available: bool,
#[serde(default)]
pub lifecycle: bool,
#[serde(default)]
pub checkpoint_status: bool,
#[serde(default)]
pub query_routes: bool,
#[serde(default)]
pub table_schema: bool,
#[serde(default)]
pub snapshots: bool,
#[serde(default)]
pub files: bool,
#[serde(default)]
pub metrics: bool,
#[serde(default)]
pub strongest_consistency: crate::checkpoint::CheckpointReadConsistency,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FilterCapsView {
#[serde(default)]
pub native: bool,
#[serde(default)]
pub group_policy_reads: bool,
#[serde(default)]
pub preview: bool,
#[serde(default)]
pub primary_routing: bool,
#[serde(default)]
pub catalog: bool,
#[serde(default)]
pub managed_groups: bool,
#[serde(default)]
pub evaluator_version: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub codecs: Vec<crate::filter::FilterCodec>,
}
impl FilterCapsView {
pub fn served(native: bool, catalog: bool) -> Self {
let evaluation = native.then(FilterAnnounce::served);
Self {
native,
group_policy_reads: false,
preview: native,
primary_routing: native,
catalog,
managed_groups: catalog,
evaluator_version: evaluation
.as_ref()
.map_or(0, |evaluation| evaluation.evaluator_version),
codecs: evaluation
.map(|evaluation| evaluation.codecs)
.unwrap_or_default(),
}
}
#[must_use]
pub const fn with_group_policy_reads(mut self, served: bool) -> Self {
self.group_policy_reads = served;
self
}
#[must_use]
pub fn with_evaluation(mut self, evaluation: FilterAnnounce) -> Self {
if self.native {
self.evaluator_version = evaluation.evaluator_version;
self.codecs = evaluation.codecs;
}
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KvCapsView {
pub available: bool,
#[serde(default)]
pub cas: bool,
#[serde(default)]
pub cas_fenced: bool,
#[serde(default)]
pub fenced_leases: bool,
}
impl Capabilities {
pub fn new(enabled: bool, versions: OpVersions) -> Self {
Self {
managed: enabled,
query: QueryCapsView {
available: enabled,
projections: enabled,
schemas: enabled,
consistency: Consistency::Eventual,
keyword: false,
cursor_paging: false,
cancellation: false,
execution_status: false,
},
destinations: DestinationCapsView {
available: false,
lifecycle: false,
checkpoint_status: false,
query_routes: false,
table_schema: false,
snapshots: false,
files: false,
metrics: false,
strongest_consistency:
crate::checkpoint::CheckpointReadConsistency::PotentiallyStale,
},
kv: KvCapsView {
available: enabled,
cas: false,
cas_fenced: false,
fenced_leases: false,
},
graph: false,
fork: enabled,
sessions: false,
watch: false,
authz: false,
filters: FilterCapsView::default(),
versions,
backends: Vec::new(),
topology: None,
}
}
#[must_use]
pub fn with_filters(mut self, filters: FilterCapsView) -> Self {
self.filters = filters;
self
}
#[must_use]
pub fn with_graph(mut self, value: bool) -> Self {
self.graph = value;
self
}
#[must_use]
pub fn with_query_keyword(mut self, value: bool) -> Self {
self.query.keyword = value;
self
}
#[must_use]
pub fn with_query_execution(mut self, paging: bool, cancellation: bool, status: bool) -> Self {
self.query.cursor_paging = paging;
self.query.cancellation = cancellation;
self.query.execution_status = status;
self
}
#[must_use]
pub fn with_destinations(mut self, destinations: DestinationCapsView) -> Self {
self.destinations = destinations;
self
}
#[must_use]
pub fn with_sessions(mut self, value: bool) -> Self {
self.sessions = value;
self
}
#[must_use]
pub fn with_watch(mut self, value: bool) -> Self {
self.watch = value;
self
}
#[must_use]
pub fn with_authz(mut self, value: bool) -> Self {
self.authz = value;
self
}
#[must_use]
pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
self.backends = backends;
self
}
#[must_use]
pub fn with_topology(mut self, topology: WireTopology) -> Self {
self.topology = Some(topology);
self
}
#[must_use]
pub fn with_kv_cas(mut self, on: bool) -> Self {
self.kv.cas = on;
self
}
#[must_use]
pub fn with_kv_cas_fenced(mut self, on: bool) -> Self {
self.kv.cas_fenced = on;
self
}
#[must_use]
pub fn with_kv_fenced_leases(mut self, on: bool) -> Self {
self.kv.fenced_leases = on;
self.kv.cas_fenced |= on;
self
}
#[must_use]
pub fn with_query_consistency(mut self, level: Consistency) -> Self {
self.query.consistency = level;
self
}
pub fn from_versions(enabled: bool, versions: OpVersions) -> Self {
use crate::hello::feature;
let consistency = if versions.has_feature(feature::STRONG_CONSISTENCY) {
Consistency::Strong
} else if versions.has_feature(feature::READ_YOUR_WRITES) {
Consistency::ReadYourWrites
} else {
Consistency::Eventual
};
let destinations_available =
enabled && versions.checkpoint > 0 && versions.has_feature(feature::DESTINATIONS);
Self::new(enabled, versions)
.with_kv_cas(versions.has_feature(feature::KV_CAS))
.with_kv_cas_fenced(versions.has_feature(feature::KV_CAS_FENCED))
.with_kv_fenced_leases(versions.has_feature(feature::KV_FENCED_LEASES))
.with_query_keyword(versions.has_feature(feature::KEYWORD_SEARCH))
.with_watch(versions.has_feature(feature::WATCH))
.with_sessions(enabled && versions.has_feature(feature::SESSIONS))
.with_authz(versions.has_feature(feature::AUTHZ))
.with_query_consistency(consistency)
.with_destinations(DestinationCapsView {
available: destinations_available,
lifecycle: destinations_available,
checkpoint_status: destinations_available,
query_routes: destinations_available,
table_schema: false,
snapshots: false,
files: false,
metrics: false,
strongest_consistency:
crate::checkpoint::CheckpointReadConsistency::PotentiallyStale,
})
.with_graph(enabled && versions.graph > 0)
.with_filters(
FilterCapsView::served(
versions.has_feature(feature::CONSUMER_FILTERS),
enabled && versions.filter > 0,
)
.with_group_policy_reads(versions.has_feature(feature::GROUP_POLICY_READS)),
)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KvEntryView {
pub key: String,
pub value: String,
pub expires_at_micros: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<crate::kv::MemoryRowScope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<crate::graph::SourceRef>,
}
#[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 ClientMetadataView {
pub client_id: u32,
pub user_id: Option<u32>,
pub transport: u8,
pub address: String,
pub consumer_groups_count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientMetadataListView {
pub clients: Vec<ClientMetadataView>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<u32>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientsQuery {
#[serde(default, skip_serializing_if = "core::ops::Not::not")]
pub with_metadata_only: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_id: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromotedView {
pub rows: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphNodeView {
pub id: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attrs: Vec<(String, String)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<SourceRef>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphEdgeView {
pub id: String,
pub from: String,
pub to: String,
pub edge_type: String,
pub weight: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_from: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_to: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<SourceRef>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct GraphResultView {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub nodes: Vec<GraphNodeView>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edges: Vec<GraphEdgeView>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub paths: Vec<PathView>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PathView {
pub nodes: Vec<String>,
pub edges: Vec<String>,
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream: Option<String>,
}
#[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<&crate::filter::FilterError> for ErrorBody {
fn from(error: &crate::filter::FilterError) -> Self {
Self::new(error.code, error.message.clone())
.with_detail(serde_json::json!({ "reason": error.reason }))
}
}
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())
}
}
impl From<&crate::authz::AuthzError> for ErrorBody {
fn from(error: &crate::authz::AuthzError) -> Self {
Self::new(ResultCode::from(error), error.to_string())
}
}
impl From<&crate::session::SessionError> for ErrorBody {
fn from(error: &crate::session::SessionError) -> 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_STREAM: &str = "stream";
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_STATUS: &str = "status";
pub const PARAM_AGENT: &str = "agent";
pub const PARAM_TEXT: &str = "text";
pub const PARAM_ROOT: &str = "root";
pub const PARAM_LABEL_PREFIX: &str = "label_prefix";
pub const PARAM_SURFACE: &str = "surface";
pub const PARAM_AFTER: &str = "after";
pub const PARAM_TOTAL: &str = "total";
pub const PARAM_FIXED_FRONTIER: &str = "fixed_frontier";
pub const PARAM_HISTORY_LIMIT: &str = "history_limit";
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchemaQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FilterListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name_contains: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<crate::filter::FilterState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_id: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_size: Option<u32>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FilterRevisionListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_size: Option<u32>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FilterBindingListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter_id: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topic: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_size: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FilterTestBody {
pub filter: crate::filter::FilterRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload_base64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload_text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub headers: Vec<crate::filter::FilterHeader>,
}
#[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 conversation: 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 GraphNeighborsQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edge_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub depth: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub as_of: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionsQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default, rename = "total")]
pub want_total: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionEventsQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default)]
pub fixed_frontier: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionStateQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_limit: Option<u32>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionLinksQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub surface: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionChangesQuery {
#[serde(default)]
pub after: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
#[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!(CAPABILITIES_PATH, "/agdx/capabilities");
assert_eq!(
projection_path("reading.v1"),
"/agdx/projections/reading.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_stream_scoped_names_when_rendered_then_should_encode_each_as_one_segment() {
assert_eq!(
kv_namespace_path("stream:acme/support"),
"/agdx/kv/stream%3Aacme%2Fsupport"
);
assert_eq!(
kv_cas_path("stream:acme/support", "dXNlcjo0Mg"),
"/agdx/kv/stream%3Aacme%2Fsupport/dXNlcjo0Mg/cas"
);
assert_eq!(
graph_neighbors_path("stream:acme/kg", "a/b"),
"/agdx/graph/stream%3Aacme%2Fkg/neighbors/a%2Fb"
);
assert_eq!(
graph_query_path("stream:acme/kg"),
"/agdx/graph/stream%3Aacme%2Fkg/query"
);
assert_eq!(
graph_path("stream:acme/kg"),
"/agdx/graphs/stream%3Aacme%2Fkg"
);
assert_eq!(
projection_path("stream:acme/readings"),
"/agdx/projections/stream%3Aacme%2Freadings"
);
}
#[test]
fn given_the_sessions_feature_when_built_from_versions_then_should_advertise_sessions() {
let mut versions = OpVersions::new(1, 1, 1, 1);
assert!(!Capabilities::from_versions(true, versions).sessions);
versions.features |= crate::hello::feature::SESSIONS;
assert!(Capabilities::from_versions(true, versions).sessions);
assert!(!Capabilities::from_versions(false, versions).sessions);
}
#[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.available && caps.kv.available && caps.fork,
"core surfaces track enabled"
);
assert!(
!caps.kv.cas
&& !caps.kv.cas_fenced
&& !caps.kv.fenced_leases
&& caps.query.consistency == Consistency::Eventual,
"sub-features must be opt-in, never on by default"
);
let opted = caps
.with_kv_cas(true)
.with_query_consistency(Consistency::ReadYourWrites);
assert!(opted.kv.cas && opted.query.consistency == Consistency::ReadYourWrites);
}
#[test]
fn given_the_fenced_lease_bit_when_building_http_capabilities_then_should_advertise_it() {
let versions =
OpVersions::new(1, 1, 1, 1).with_features(crate::hello::feature::KV_FENCED_LEASES);
let caps = Capabilities::from_versions(true, versions);
assert!(caps.kv.fenced_leases);
assert!(
caps.kv.cas_fenced,
"the full fenced-lease contract subsumes fenced CAS"
);
}
#[test]
fn given_group_reads_without_evaluation_when_building_http_capabilities_then_should_keep_catalog_available()
{
let versions = OpVersions::new(1, 1, 1, 1)
.with_filter(crate::codes::FILTER_OP_VERSION)
.with_features(crate::hello::feature::GROUP_POLICY_READS);
let caps = Capabilities::from_versions(true, versions);
assert!(caps.filters.group_policy_reads);
assert!(caps.filters.catalog && caps.filters.managed_groups);
assert!(!caps.filters.native && !caps.filters.preview);
assert_eq!(caps.filters.evaluator_version, 0);
assert!(caps.filters.codecs.is_empty());
let absent = Capabilities::from_versions(false, OpVersions::new(1, 1, 1, 1));
assert!(!absent.filters.group_policy_reads && !absent.filters.catalog);
}
#[test]
fn given_destination_versions_when_building_capabilities_then_the_core_surface_is_available() {
use crate::codes::CHECKPOINT_OP_VERSION;
use crate::hello::feature;
let versions = OpVersions::new(2, 2, 1, 1)
.with_checkpoint(CHECKPOINT_OP_VERSION)
.with_features(feature::DESTINATIONS);
let caps = Capabilities::from_versions(true, versions);
assert!(caps.destinations.available);
assert!(caps.destinations.lifecycle);
assert!(caps.destinations.checkpoint_status);
assert!(caps.destinations.query_routes);
assert!(!caps.destinations.table_schema);
}
#[test]
fn given_capabilities_backends_when_json_round_tripped_then_should_preserve_and_omit_empty() {
use crate::destination::BackendResourceId;
use crate::hello::{BackendDescriptor, BackendImplementation, BackendMode};
let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_backends(vec![
BackendDescriptor::new(
BackendResourceId::from_u128(1),
BackendMode::Operational,
"Embedded",
BackendImplementation {
kind: "embedded".to_owned(),
version: "1.0.0".to_owned(),
},
1,
1,
),
BackendDescriptor::new(
BackendResourceId::from_u128(2),
BackendMode::Lakehouse,
"Warehouse",
BackendImplementation {
kind: "columnar".to_owned(),
version: "1.0.0".to_owned(),
},
1,
1,
),
]);
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].resource_id,
BackendResourceId::from_u128(2)
);
assert_eq!(back.backends[1].implementation.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_capabilities_topology_when_json_round_tripped_then_should_preserve_and_omit_absent() {
let custom = WireTopology {
ops_stream: "custom-ops".to_owned(),
..WireTopology::default()
};
let caps =
Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_topology(custom.clone());
let json = serde_json::to_string(&caps).expect("serializes");
let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
assert_eq!(back.topology, Some(custom));
let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
let json = serde_json::to_string(&plain).expect("json");
assert!(
!json.contains("topology"),
"absent topology 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("readings".to_owned()));
assert_eq!(body.code, ResultCode::NotFound);
assert_eq!(body.http_status(), 404);
assert!(body.message.contains("readings"));
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_names_with_reserved_characters_when_building_a_binding_path_then_should_encode_each_segment()
{
assert_eq!(
filter_binding_path("orbit", "fleet/changes", "desk?x#1"),
"/agdx/filter-bindings/orbit/fleet%2Fchanges/desk%3Fx%231"
);
}
#[cfg(feature = "http-client")]
#[test]
fn given_list_filters_when_url_encoded_then_field_names_match_the_param_consts() {
let projections = ProjectionListQuery {
name_contains: Some("reading".to_owned()),
id_prefix: Some("reading.".to_owned()),
..Default::default()
};
let encoded = serde_urlencoded::to_string(&projections).expect("encodes");
assert_eq!(encoded, "name_contains=reading&id_prefix=reading.");
assert!(encoded.contains(&format!("{PARAM_NAME_CONTAINS}=")));
assert!(encoded.contains(&format!("{PARAM_ID_PREFIX}=")));
let schemas = SchemaListQuery {
name_contains: Some("Reading".to_owned()),
stream: None,
};
assert_eq!(
serde_urlencoded::to_string(&schemas).expect("encodes"),
"name_contains=Reading"
);
let scoped = SchemaListQuery {
name_contains: None,
stream: Some("acme".to_owned()),
};
assert_eq!(
serde_urlencoded::to_string(&scoped).expect("encodes"),
format!("{PARAM_STREAM}=acme")
);
assert_eq!(
serde_urlencoded::to_string(SchemaQuery::default()).expect("encodes"),
""
);
assert_eq!(
serde_urlencoded::to_string(SchemaQuery {
stream: Some("acme".to_owned()),
})
.expect("encodes"),
format!("{PARAM_STREAM}=acme")
);
}
#[test]
fn given_a_register_schema_body_without_a_stream_when_encoded_then_should_omit_it() {
let body = RegisterSchemaBody {
source: crate::control::SchemaSource::JsonSchema {
schema: "{}".to_owned(),
},
name: None,
version: None,
stream: None,
};
let json = serde_json::to_string(&body).expect("encodes");
assert!(
!json.contains("stream"),
"an unset stream is omitted: {json}"
);
let scoped: RegisterSchemaBody = serde_json::from_str(
r#"{"source":{"kind":"json_schema","schema":"{}"},"stream":"acme"}"#,
)
.expect("decodes");
assert_eq!(scoped.stream.as_deref(), Some("acme"));
}
}