1use crate::error::InvalidError;
2use crate::filter::FilterGroupIdentity;
3use crate::fork::{ForkError, ForkKind};
4use crate::graph::SourceRef;
5use crate::hello::{BackendDescriptor, FilterAnnounce, OpVersions};
6use crate::kv::KvError;
7use crate::query::{Consistency, QueryError};
8use crate::result::ResultCode;
9use crate::topology::WireTopology;
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::num::NonZeroU128;
13
14pub const CAPABILITIES_PATH: &str = "/agdx/capabilities";
16pub const QUERY_PATH: &str = "/agdx/query";
18pub const PROJECTIONS_PATH: &str = "/agdx/projections";
20pub const BINDINGS_PATH: &str = "/agdx/bindings";
22pub const SCHEMAS_PATH: &str = "/agdx/schemas";
24pub const KV_PATH: &str = "/agdx/kv";
26pub const FORKS_PATH: &str = "/agdx/forks";
28pub const GRAPHS_PATH: &str = "/agdx/graphs";
30pub const GRAPH_PATH: &str = "/agdx/graph";
32pub const CLIENTS_PATH: &str = "/agdx/clients";
36pub const AUTHZ_WHOAMI_PATH: &str = "/agdx/authz/whoami";
38pub const AUTHZ_ROLES_PATH: &str = "/agdx/authz/roles";
40pub const FILTERS_PATH: &str = "/agdx/filters";
42pub const FILTER_METRICS_PATH: &str = "/agdx/filters/metrics";
43pub const FILTER_MUTATIONS_PATH: &str = "/agdx/filters/mutations";
45pub const FILTER_VALIDATE_PATH: &str = "/agdx/filters/validate";
47pub const FILTER_TEST_PATH: &str = "/agdx/filters/test";
49pub const FILTER_PREVIEW_PATH: &str = "/agdx/filters/preview";
51pub const FILTER_BINDINGS_PATH: &str = "/agdx/filter-bindings";
53pub const GROUP_POLICIES_PATH: &str = "/agdx/group-policies";
55pub const SESSIONS_PATH: &str = "/agdx/sessions";
57
58pub fn sessions_path(stream: &str) -> String {
59 format!("{SESSIONS_PATH}/{}", path_segment(stream))
60}
61
62pub fn session_changes_path(stream: &str) -> String {
63 format!("{}/changes", sessions_path(stream))
64}
65
66pub fn session_path(stream: &str, id: crate::agent::ConversationId) -> String {
67 format!(
68 "{}/{}",
69 sessions_path(stream),
70 path_segment(&id.to_string())
71 )
72}
73
74pub fn session_events_path(stream: &str, id: crate::agent::ConversationId) -> String {
75 format!("{}/events", session_path(stream, id))
76}
77
78pub fn session_state_path(stream: &str, id: crate::agent::ConversationId) -> String {
79 format!("{}/state", session_path(stream, id))
80}
81
82pub fn session_links_path(stream: &str, id: crate::agent::ConversationId) -> String {
83 format!("{}/links", session_path(stream, id))
84}
85
86pub fn session_sources_path(stream: &str, id: crate::agent::ConversationId) -> String {
87 format!("{}/sources", session_path(stream, id))
88}
89
90pub fn filter_path(id: u32) -> String {
92 format!("{FILTERS_PATH}/{id}")
93}
94
95pub fn filter_revisions_path(id: u32) -> String {
97 format!("{FILTERS_PATH}/{id}/revisions")
98}
99
100pub fn filter_operation_path(operation_id: u128) -> String {
102 format!("{FILTERS_PATH}/operations/{operation_id}")
103}
104
105pub fn filter_binding_path(stream: &str, topic: &str, group: &str) -> String {
109 format!(
110 "{FILTER_BINDINGS_PATH}/{}/{}/{}",
111 path_segment(stream),
112 path_segment(topic),
113 path_segment(group)
114 )
115}
116
117pub fn group_policy_path(stream: &str, topic: &str, group: &str) -> String {
118 format!(
119 "{GROUP_POLICIES_PATH}/{}/{}/{}",
120 path_segment(stream),
121 path_segment(topic),
122 path_segment(group)
123 )
124}
125
126pub fn path_segment(text: &str) -> String {
129 let mut out = String::with_capacity(text.len());
130 for byte in text.bytes() {
131 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
132 out.push(char::from(byte));
133 } else {
134 out.push_str(&format!("%{byte:02X}"));
135 }
136 }
137 out
138}
139
140pub fn authz_role_path(name: &str) -> String {
143 format!("{AUTHZ_ROLES_PATH}/{}", path_segment(name))
144}
145
146pub fn authz_user_roles_path(user_id: u32) -> String {
148 format!("/agdx/authz/users/{user_id}/roles")
149}
150
151pub fn graph_path(id: &str) -> String {
155 format!("{GRAPHS_PATH}/{}", path_segment(id))
156}
157
158pub fn graph_query_path(name: &str) -> String {
160 format!("{GRAPH_PATH}/{}/query", path_segment(name))
161}
162
163pub fn graph_neighbors_path(name: &str, node: &str) -> String {
165 format!(
166 "{GRAPH_PATH}/{}/neighbors/{}",
167 path_segment(name),
168 path_segment(node)
169 )
170}
171
172pub fn projection_path(id: &str) -> String {
174 format!("{PROJECTIONS_PATH}/{}", path_segment(id))
175}
176
177pub fn schema_path(id: u32) -> String {
179 format!("{SCHEMAS_PATH}/{id}")
180}
181
182pub fn schema_decode_path(id: u32) -> String {
184 format!("{SCHEMAS_PATH}/{id}/decode")
185}
186
187pub fn kv_namespace_path(namespace: &str) -> String {
189 format!("{KV_PATH}/{}", path_segment(namespace))
190}
191
192pub fn kv_entry_path(namespace: &str, key_b64: &str) -> String {
196 format!("{}/{}", kv_namespace_path(namespace), path_segment(key_b64))
197}
198
199pub fn kv_cas_path(namespace: &str, key_b64: &str) -> String {
206 format!("{}/cas", kv_entry_path(namespace, key_b64))
207}
208
209pub fn fork_path(id: &str) -> String {
211 format!("{FORKS_PATH}/{}", path_segment(id))
212}
213
214pub fn fork_promote_path(id: &str) -> String {
216 format!("{}/promote", fork_path(id))
217}
218
219pub fn fork_rows_path(id: &str) -> String {
221 format!("{}/rows", fork_path(id))
222}
223
224#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
231#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
232#[non_exhaustive]
233pub struct Capabilities {
234 pub managed: bool,
238 pub query: QueryCapsView,
241 pub kv: KvCapsView,
243 #[serde(default)]
247 pub graph: bool,
248 pub fork: bool,
250 #[serde(default)]
252 pub sessions: bool,
253 #[serde(default)]
257 pub watch: bool,
258 #[serde(default)]
261 pub authz: bool,
262 #[serde(default)]
265 pub filters: FilterCapsView,
266 pub versions: OpVersions,
267 #[serde(default, skip_serializing_if = "Vec::is_empty")]
272 pub backends: Vec<BackendDescriptor>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub topology: Option<WireTopology>,
280}
281
282#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
285#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
286pub struct QueryCapsView {
287 pub available: bool,
289 pub projections: bool,
291 pub schemas: bool,
293 #[serde(default)]
296 pub consistency: Consistency,
297 #[serde(default)]
301 pub keyword: bool,
302}
303
304#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
308#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
309pub struct FilterCapsView {
310 #[serde(default)]
312 pub native: bool,
313 #[serde(default)]
315 pub group_policy_reads: bool,
316 #[serde(default)]
318 pub preview: bool,
319 #[serde(default)]
321 pub primary_routing: bool,
322 #[serde(default)]
324 pub catalog: bool,
325 #[serde(default)]
327 pub managed_groups: bool,
328 #[serde(default)]
330 pub evaluator_version: u32,
331 #[serde(default, skip_serializing_if = "Vec::is_empty")]
333 pub codecs: Vec<crate::filter::FilterCodec>,
334}
335
336impl FilterCapsView {
337 pub fn served(native: bool, catalog: bool) -> Self {
339 let evaluation = native.then(FilterAnnounce::served);
340 Self {
341 native,
342 group_policy_reads: false,
343 preview: native,
344 primary_routing: native,
345 catalog,
346 managed_groups: catalog,
347 evaluator_version: evaluation
348 .as_ref()
349 .map_or(0, |evaluation| evaluation.evaluator_version),
350 codecs: evaluation
351 .map(|evaluation| evaluation.codecs)
352 .unwrap_or_default(),
353 }
354 }
355
356 #[must_use]
357 pub const fn with_group_policy_reads(mut self, served: bool) -> Self {
358 self.group_policy_reads = served;
359 self
360 }
361
362 #[must_use]
365 pub fn with_evaluation(mut self, evaluation: FilterAnnounce) -> Self {
366 if self.native {
367 self.evaluator_version = evaluation.evaluator_version;
368 self.codecs = evaluation.codecs;
369 }
370 self
371 }
372}
373
374#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
376#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
377pub struct KvCapsView {
378 pub available: bool,
380 #[serde(default)]
384 pub cas: bool,
385 #[serde(default)]
389 pub cas_fenced: bool,
390 #[serde(default)]
393 pub fenced_leases: bool,
394}
395
396impl Capabilities {
397 pub fn new(enabled: bool, versions: OpVersions) -> Self {
404 Self {
405 managed: enabled,
406 query: QueryCapsView {
407 available: enabled,
408 projections: enabled,
409 schemas: enabled,
410 consistency: Consistency::Eventual,
411 keyword: false,
412 },
413 kv: KvCapsView {
414 available: enabled,
415 cas: false,
416 cas_fenced: false,
417 fenced_leases: false,
418 },
419 graph: false,
420 fork: enabled,
421 sessions: false,
422 watch: false,
423 authz: false,
424 filters: FilterCapsView::default(),
425 versions,
426 backends: Vec::new(),
427 topology: None,
428 }
429 }
430
431 #[must_use]
433 pub fn with_filters(mut self, filters: FilterCapsView) -> Self {
434 self.filters = filters;
435 self
436 }
437
438 #[must_use]
441 pub fn with_graph(mut self, value: bool) -> Self {
442 self.graph = value;
443 self
444 }
445
446 #[must_use]
448 pub fn with_query_keyword(mut self, value: bool) -> Self {
449 self.query.keyword = value;
450 self
451 }
452
453 #[must_use]
455 pub fn with_sessions(mut self, value: bool) -> Self {
456 self.sessions = value;
457 self
458 }
459
460 #[must_use]
463 pub fn with_watch(mut self, value: bool) -> Self {
464 self.watch = value;
465 self
466 }
467
468 #[must_use]
470 pub fn with_authz(mut self, value: bool) -> Self {
471 self.authz = value;
472 self
473 }
474
475 #[must_use]
478 pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
479 self.backends = backends;
480 self
481 }
482
483 #[must_use]
486 pub fn with_topology(mut self, topology: WireTopology) -> Self {
487 self.topology = Some(topology);
488 self
489 }
490
491 #[must_use]
494 pub fn with_kv_cas(mut self, on: bool) -> Self {
495 self.kv.cas = on;
496 self
497 }
498
499 #[must_use]
502 pub fn with_kv_cas_fenced(mut self, on: bool) -> Self {
503 self.kv.cas_fenced = on;
504 self
505 }
506
507 #[must_use]
510 pub fn with_kv_fenced_leases(mut self, on: bool) -> Self {
511 self.kv.fenced_leases = on;
512 self.kv.cas_fenced |= on;
513 self
514 }
515
516 #[must_use]
518 pub fn with_query_consistency(mut self, level: Consistency) -> Self {
519 self.query.consistency = level;
520 self
521 }
522
523 pub fn from_versions(enabled: bool, versions: OpVersions) -> Self {
529 use crate::hello::feature;
530 let consistency = if versions.has_feature(feature::READ_YOUR_WRITES) {
531 Consistency::ReadYourWrites
532 } else {
533 Consistency::Eventual
534 };
535 Self::new(enabled, versions)
536 .with_kv_cas(versions.has_feature(feature::KV_CAS))
537 .with_kv_cas_fenced(versions.has_feature(feature::KV_CAS_FENCED))
538 .with_kv_fenced_leases(versions.has_feature(feature::KV_FENCED_LEASES))
539 .with_query_keyword(versions.has_feature(feature::KEYWORD_SEARCH))
540 .with_watch(versions.has_feature(feature::WATCH))
541 .with_sessions(enabled && versions.has_feature(feature::SESSIONS))
542 .with_authz(versions.has_feature(feature::AUTHZ))
543 .with_query_consistency(consistency)
544 .with_graph(enabled && versions.graph > 0)
547 .with_filters(
548 FilterCapsView::served(
549 versions.has_feature(feature::CONSUMER_FILTERS),
550 enabled && versions.filter > 0,
551 )
552 .with_group_policy_reads(versions.has_feature(feature::GROUP_POLICY_READS)),
553 )
554 }
555}
556
557#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
561#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
562pub struct KvEntryView {
563 pub key: String,
564 pub value: String,
565 pub expires_at_micros: Option<u64>,
566 pub version: u64,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub scope: Option<crate::kv::MemoryRowScope>,
575 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub source: Option<crate::graph::SourceRef>,
581}
582
583#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
585#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
586pub struct KvPageView {
587 pub items: Vec<KvEntryView>,
588 pub page: u32,
589 pub total_pages: u32,
590 pub total_results: u32,
591}
592
593#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
595#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
596pub struct DeletedManyView {
597 pub deleted: usize,
598}
599
600#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
605#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
606pub struct ClientMetadataView {
607 pub client_id: u32,
608 pub user_id: Option<u32>,
609 pub transport: u8,
610 pub address: String,
611 pub consumer_groups_count: u32,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
613 pub metadata: Option<String>,
614}
615
616#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
619#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
620pub struct ClientMetadataListView {
621 pub clients: Vec<ClientMetadataView>,
622 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub next_cursor: Option<u32>,
624}
625
626#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
630#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
631pub struct ClientsQuery {
632 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
634 pub with_metadata_only: bool,
635 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub user_id: Option<u32>,
638 #[serde(default, skip_serializing_if = "Option::is_none")]
640 pub after: Option<u32>,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub limit: Option<u32>,
644}
645
646#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
648#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
649pub struct PromotedView {
650 pub rows: usize,
651}
652
653#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
657#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
658pub struct GraphNodeView {
659 pub id: String,
660 #[serde(default, skip_serializing_if = "Vec::is_empty")]
661 pub labels: Vec<String>,
662 #[serde(default, skip_serializing_if = "Vec::is_empty")]
663 pub attrs: Vec<(String, String)>,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
666 pub source: Option<SourceRef>,
667}
668
669#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
672#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
673pub struct GraphEdgeView {
674 pub id: String,
675 pub from: String,
676 pub to: String,
677 pub edge_type: String,
678 pub weight: f32,
679 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub valid_from: Option<u64>,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub valid_to: Option<u64>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub source: Option<SourceRef>,
688}
689
690#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
693#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
694pub struct GraphResultView {
695 #[serde(default, skip_serializing_if = "Vec::is_empty")]
696 pub nodes: Vec<GraphNodeView>,
697 #[serde(default, skip_serializing_if = "Vec::is_empty")]
698 pub edges: Vec<GraphEdgeView>,
699 #[serde(default, skip_serializing_if = "Vec::is_empty")]
700 pub paths: Vec<PathView>,
701}
702
703#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
706#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
707pub struct PathView {
708 pub nodes: Vec<String>,
709 pub edges: Vec<String>,
710}
711
712#[derive(Clone, Debug, Serialize, Deserialize)]
716#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
717pub struct RegisterSchemaBody {
718 pub source: crate::control::SchemaSource,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub name: Option<String>,
721 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub version: Option<u32>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub stream: Option<String>,
727}
728
729#[derive(Clone, Debug, Serialize, Deserialize)]
732#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
733pub struct DecodeRecordBody {
734 pub payload: String,
735}
736
737#[derive(Clone, Debug, Serialize, Deserialize)]
739#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
740pub struct ForkCreateBody {
741 pub fork_id: String,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub parent: Option<String>,
744 #[serde(default)]
745 pub kind: ForkKind,
746 #[serde(default, skip_serializing_if = "Vec::is_empty")]
747 pub tables: Vec<String>,
748}
749
750#[derive(Clone, Debug, Serialize, Deserialize)]
753#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
754pub struct ForkPutBody {
755 pub table: String,
756 pub partition_id: u32,
757 pub offset: u64,
758 #[serde(default)]
759 pub projection_id: String,
760 #[serde(default)]
761 pub projection_version: u32,
762 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
763 pub fields: BTreeMap<String, String>,
764 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
765 pub metadata: BTreeMap<String, String>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub payload_b64: Option<String>,
768 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub embedding: Option<String>,
770 #[serde(default)]
771 pub tombstone: bool,
772}
773
774#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
779#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
780pub struct RemoveBindingBody {
781 pub stream: String,
782 pub topic: String,
783 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub projection_ref: Option<String>,
785}
786
787#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
796#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
797pub struct ErrorBody {
798 pub code: ResultCode,
799 pub message: String,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
801 pub detail: Option<serde_json::Value>,
802}
803
804impl ErrorBody {
805 pub fn new(code: ResultCode, message: impl Into<String>) -> Self {
807 Self {
808 code,
809 message: message.into(),
810 detail: None,
811 }
812 }
813
814 #[must_use]
816 pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
817 self.detail = Some(detail);
818 self
819 }
820
821 pub fn http_status(&self) -> u16 {
824 self.code.http_status()
825 }
826}
827
828impl From<&QueryError> for ErrorBody {
829 fn from(error: &QueryError) -> Self {
830 Self::new(ResultCode::from(error), error.to_string())
831 }
832}
833
834impl From<&crate::filter::FilterError> for ErrorBody {
835 fn from(error: &crate::filter::FilterError) -> Self {
836 Self::new(error.code, error.message.clone())
837 .with_detail(serde_json::json!({ "reason": error.reason }))
838 }
839}
840
841impl From<&KvError> for ErrorBody {
842 fn from(error: &KvError) -> Self {
843 Self::new(ResultCode::from(error), error.to_string())
844 }
845}
846
847impl From<&ForkError> for ErrorBody {
848 fn from(error: &ForkError) -> Self {
849 Self::new(ResultCode::from(error), error.to_string())
850 }
851}
852
853impl From<&crate::authz::AuthzError> for ErrorBody {
854 fn from(error: &crate::authz::AuthzError) -> Self {
855 Self::new(ResultCode::from(error), error.to_string())
856 }
857}
858
859impl From<&crate::session::SessionError> for ErrorBody {
860 fn from(error: &crate::session::SessionError) -> Self {
861 Self::new(ResultCode::from(error), error.to_string())
862 }
863}
864
865pub const PARAM_TOPIC: &str = "topic";
867pub const PARAM_NAME_CONTAINS: &str = "name_contains";
869pub const PARAM_ID_PREFIX: &str = "id_prefix";
871pub const PARAM_STREAM: &str = "stream";
874pub const PARAM_SEARCH: &str = "search";
879pub const PARAM_PREFIX: &str = "prefix";
881pub const PARAM_START: &str = "start";
883pub const PARAM_END: &str = "end";
885pub const PARAM_KEY_CONTAINS: &str = "key_contains";
887pub const PARAM_STATUS: &str = "status";
889pub const PARAM_AGENT: &str = "agent";
891pub const PARAM_TEXT: &str = "text";
893pub const PARAM_ROOT: &str = "root";
895pub const PARAM_LABEL_PREFIX: &str = "label_prefix";
897pub const PARAM_SURFACE: &str = "surface";
899pub const PARAM_AS_OF: &str = "as_of";
902pub const PARAM_SERIES: &str = "series";
905pub const PARAM_PAGE: &str = "page";
907pub const PARAM_RESULTS: &str = "results";
910pub const PARAM_HISTORY_LIMIT: &str = "history_limit";
912pub const PARAM_EXPIRES_AT_MICROS: &str = "expires_at_micros";
914pub const PARAM_EXPECT_VERSION: &str = "expect_version";
917pub const PARAM_EXPECT_ABSENT: &str = "expect_absent";
920
921pub const KV_EXPIRES_AT_MICROS_HEADER: &str = "agdx-expires-at-micros";
927
928pub const KV_VERSION_HEADER: &str = "agdx-version";
931
932pub const ON_BEHALF_OF_HEADER: &str = "agdx-on-behalf-of";
936
937#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
942#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
943pub struct ProjectionListQuery {
944 #[serde(default, skip_serializing_if = "Option::is_none")]
945 pub topic: Option<String>,
946 #[serde(default, skip_serializing_if = "Option::is_none")]
947 pub name_contains: Option<String>,
948 #[serde(default, skip_serializing_if = "Option::is_none")]
949 pub id_prefix: Option<String>,
950 #[serde(default, skip_serializing_if = "Option::is_none")]
953 pub search: Option<String>,
954 #[serde(default, skip_serializing_if = "Option::is_none")]
957 pub stream: Option<String>,
958 #[serde(default, skip_serializing_if = "Option::is_none")]
960 pub page: Option<u32>,
961 #[serde(default, skip_serializing_if = "Option::is_none")]
963 pub results: Option<u32>,
964}
965
966#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
970#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
971pub struct SchemaListQuery {
972 #[serde(default, skip_serializing_if = "Option::is_none")]
973 pub name_contains: Option<String>,
974 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub stream: Option<String>,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub page: Option<u32>,
981 #[serde(default, skip_serializing_if = "Option::is_none")]
983 pub results: Option<u32>,
984}
985
986#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
989#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
990pub struct StreamQuery {
991 #[serde(default, skip_serializing_if = "Option::is_none")]
992 pub stream: Option<String>,
993}
994
995#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
999#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1000pub struct SchemaQuery {
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1002 pub stream: Option<String>,
1003}
1004
1005#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1011#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1012pub struct OperationQuery {
1013 #[serde(default, skip_serializing_if = "Option::is_none")]
1014 pub operation_id: Option<String>,
1015}
1016
1017impl OperationQuery {
1018 pub fn operation_id(&self) -> Result<Option<NonZeroU128>, InvalidOperationId> {
1020 self.operation_id
1021 .as_deref()
1022 .map(|text| {
1023 if !text.bytes().all(|byte| byte.is_ascii_digit()) {
1025 return Err(InvalidOperationId);
1026 }
1027 text.parse::<NonZeroU128>().map_err(|_| InvalidOperationId)
1028 })
1029 .transpose()
1030 }
1031}
1032
1033impl From<NonZeroU128> for OperationQuery {
1034 fn from(operation_id: NonZeroU128) -> Self {
1035 Self {
1036 operation_id: Some(operation_id.to_string()),
1037 }
1038 }
1039}
1040
1041#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
1043#[error("operation_id must be a non-zero decimal u128")]
1044pub struct InvalidOperationId;
1045
1046#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1048#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1049pub struct FilterListQuery {
1050 #[serde(default, skip_serializing_if = "Option::is_none")]
1051 pub name_contains: Option<String>,
1052 #[serde(default, skip_serializing_if = "Option::is_none")]
1053 pub state: Option<crate::filter::FilterState>,
1054 #[serde(default, skip_serializing_if = "Option::is_none")]
1056 pub owner_stream_id: Option<u32>,
1057 #[serde(default, skip_serializing_if = "Option::is_none")]
1058 pub owner_stream_created_at_micros: Option<u64>,
1059 #[serde(default, skip_serializing_if = "Option::is_none")]
1060 pub owner_topic_id: Option<u32>,
1061 #[serde(default, skip_serializing_if = "Option::is_none")]
1062 pub owner_topic_created_at_micros: Option<u64>,
1063 #[serde(default, skip_serializing_if = "Option::is_none")]
1064 pub owner_group_id: Option<u64>,
1065 #[serde(default, skip_serializing_if = "Option::is_none")]
1066 pub page: Option<u32>,
1067 #[serde(default, skip_serializing_if = "Option::is_none")]
1068 pub results: Option<u32>,
1069}
1070
1071impl FilterListQuery {
1072 pub fn owner(&self) -> Result<Option<FilterGroupIdentity>, InvalidError> {
1074 match (
1075 self.owner_stream_id,
1076 self.owner_stream_created_at_micros,
1077 self.owner_topic_id,
1078 self.owner_topic_created_at_micros,
1079 self.owner_group_id,
1080 ) {
1081 (None, None, None, None, None) => Ok(None),
1082 (
1083 Some(stream_id),
1084 Some(stream_created_at_micros),
1085 Some(topic_id),
1086 Some(topic_created_at_micros),
1087 Some(group_id),
1088 ) => Ok(Some(FilterGroupIdentity {
1089 stream_id,
1090 stream_created_at_micros,
1091 topic_id,
1092 topic_created_at_micros,
1093 group_id,
1094 })),
1095 _ => Err(InvalidError::new(
1096 "the owner filter needs owner_stream_id, owner_stream_created_at_micros, owner_topic_id, owner_topic_created_at_micros and owner_group_id together",
1097 )),
1098 }
1099 }
1100}
1101
1102impl From<FilterGroupIdentity> for FilterListQuery {
1103 fn from(identity: FilterGroupIdentity) -> Self {
1104 Self {
1105 owner_stream_id: Some(identity.stream_id),
1106 owner_stream_created_at_micros: Some(identity.stream_created_at_micros),
1107 owner_topic_id: Some(identity.topic_id),
1108 owner_topic_created_at_micros: Some(identity.topic_created_at_micros),
1109 owner_group_id: Some(identity.group_id),
1110 ..Self::default()
1111 }
1112 }
1113}
1114
1115#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1117#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1118pub struct FilterRevisionListQuery {
1119 #[serde(default, skip_serializing_if = "Option::is_none")]
1120 pub page: Option<u32>,
1121 #[serde(default, skip_serializing_if = "Option::is_none")]
1122 pub results: Option<u32>,
1123}
1124
1125#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1127#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1128pub struct FilterBindingListQuery {
1129 #[serde(default, skip_serializing_if = "Option::is_none")]
1130 pub filter_id: Option<u32>,
1131 #[serde(default, skip_serializing_if = "Option::is_none")]
1132 pub stream: Option<String>,
1133 #[serde(default, skip_serializing_if = "Option::is_none")]
1134 pub topic: Option<String>,
1135 #[serde(default, skip_serializing_if = "Option::is_none")]
1136 pub page: Option<u32>,
1137 #[serde(default, skip_serializing_if = "Option::is_none")]
1138 pub results: Option<u32>,
1139}
1140
1141#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1144#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1145pub struct FilterTestBody {
1146 pub filter: crate::filter::FilterRef,
1147 #[serde(default, skip_serializing_if = "Option::is_none")]
1148 pub payload_base64: Option<String>,
1149 #[serde(default, skip_serializing_if = "Option::is_none")]
1150 pub payload_text: Option<String>,
1151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1152 pub headers: Vec<crate::filter::FilterHeader>,
1153}
1154
1155#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1159#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1160pub struct KvScanQuery {
1161 #[serde(default, skip_serializing_if = "Option::is_none")]
1162 pub prefix: Option<String>,
1163 #[serde(default, skip_serializing_if = "Option::is_none")]
1164 pub start: Option<String>,
1165 #[serde(default, skip_serializing_if = "Option::is_none")]
1166 pub end: Option<String>,
1167 #[serde(default, skip_serializing_if = "Option::is_none")]
1168 pub key_contains: Option<String>,
1169 #[serde(default, skip_serializing_if = "Option::is_none")]
1174 pub conversation: Option<String>,
1175 #[serde(default, skip_serializing_if = "Option::is_none")]
1177 pub page: Option<u32>,
1178 #[serde(default, skip_serializing_if = "Option::is_none")]
1180 pub results: Option<u32>,
1181}
1182
1183#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1185#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1186pub struct KvPutQuery {
1187 #[serde(default, skip_serializing_if = "Option::is_none")]
1188 pub expires_at_micros: Option<u64>,
1189}
1190
1191#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1197#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1198pub struct GraphNeighborsQuery {
1199 #[serde(default, skip_serializing_if = "Option::is_none")]
1200 pub dir: Option<String>,
1201 #[serde(default, skip_serializing_if = "Option::is_none")]
1202 pub edge_type: Option<String>,
1203 #[serde(default, skip_serializing_if = "Option::is_none")]
1204 pub depth: Option<u32>,
1205 #[serde(default, skip_serializing_if = "Option::is_none")]
1206 pub limit: Option<u32>,
1207 #[serde(default, skip_serializing_if = "Option::is_none")]
1209 pub as_of: Option<u64>,
1210 #[serde(default, skip_serializing_if = "Option::is_none")]
1214 pub conversation: Option<String>,
1215}
1216
1217#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1222#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1223pub struct SessionsQuery {
1224 #[serde(default, skip_serializing_if = "Option::is_none")]
1225 pub status: Option<String>,
1226 #[serde(default, skip_serializing_if = "Option::is_none")]
1227 pub root: Option<String>,
1228 #[serde(default, skip_serializing_if = "Option::is_none")]
1229 pub label_prefix: Option<String>,
1230 #[serde(default, skip_serializing_if = "Option::is_none")]
1231 pub agent: Option<String>,
1232 #[serde(default, skip_serializing_if = "Option::is_none")]
1233 pub text: Option<String>,
1234 #[serde(default, skip_serializing_if = "Option::is_none")]
1235 pub page: Option<u32>,
1236 #[serde(default, skip_serializing_if = "Option::is_none")]
1237 pub results: Option<u32>,
1238 #[serde(default, skip_serializing_if = "Option::is_none")]
1239 pub as_of: Option<u64>,
1240 #[serde(default, skip_serializing_if = "Option::is_none")]
1241 pub series: Option<u64>,
1242}
1243
1244#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1248#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1249pub struct SessionEventsQuery {
1250 #[serde(default, skip_serializing_if = "Option::is_none")]
1251 pub page: Option<u32>,
1252 #[serde(default, skip_serializing_if = "Option::is_none")]
1253 pub results: Option<u32>,
1254 #[serde(default, skip_serializing_if = "Option::is_none")]
1255 pub as_of: Option<u64>,
1256 #[serde(default, skip_serializing_if = "Option::is_none")]
1257 pub series: Option<u64>,
1258}
1259
1260#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1263#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1264pub struct SessionStateQuery {
1265 #[serde(default, skip_serializing_if = "Option::is_none")]
1266 pub history_limit: Option<u32>,
1267}
1268
1269#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1271#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1272pub struct SessionLinksQuery {
1273 #[serde(default, skip_serializing_if = "Option::is_none")]
1274 pub surface: Option<String>,
1275}
1276
1277#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1281#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1282pub struct SessionChangesQuery {
1283 #[serde(default, skip_serializing_if = "Option::is_none")]
1284 pub page: Option<u32>,
1285 #[serde(default, skip_serializing_if = "Option::is_none")]
1286 pub results: Option<u32>,
1287}
1288
1289#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1294#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1295pub struct KvCasQuery {
1296 #[serde(default, skip_serializing_if = "Option::is_none")]
1297 pub expect_version: Option<u64>,
1298 #[serde(default, skip_serializing_if = "Option::is_none")]
1299 pub expect_absent: Option<bool>,
1300 #[serde(default, skip_serializing_if = "Option::is_none")]
1301 pub expires_at_micros: Option<u64>,
1302}
1303
1304#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1307#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1308pub struct CasCommittedView {
1309 pub version: u64,
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314 use super::*;
1315
1316 #[test]
1317 fn given_a_filter_list_query_when_the_owner_is_complete_or_partial_then_should_resolve_or_refuse()
1318 {
1319 let identity = FilterGroupIdentity {
1320 stream_id: 1,
1321 stream_created_at_micros: 10,
1322 topic_id: 2,
1323 topic_created_at_micros: 20,
1324 group_id: 3,
1325 };
1326 let query = FilterListQuery::from(identity);
1327 assert_eq!(query.owner().expect("complete owner"), Some(identity));
1328 assert_eq!(FilterListQuery::default().owner().expect("no owner"), None);
1329 let partial = FilterListQuery {
1330 owner_group_id: None,
1331 ..query
1332 };
1333 assert!(partial.owner().is_err());
1334 }
1335
1336 #[test]
1337 fn given_path_builders_when_rendered_then_should_match_the_router() {
1338 assert_eq!(CAPABILITIES_PATH, "/agdx/capabilities");
1339 assert_eq!(
1340 projection_path("reading.v1"),
1341 "/agdx/projections/reading.v1"
1342 );
1343 assert_eq!(schema_path(7), "/agdx/schemas/7");
1344 assert_eq!(schema_decode_path(7), "/agdx/schemas/7/decode");
1345 assert_eq!(kv_namespace_path("sessions"), "/agdx/kv/sessions");
1346 assert_eq!(
1347 kv_entry_path("sessions", "dXNlcjo0Mg"),
1348 "/agdx/kv/sessions/dXNlcjo0Mg"
1349 );
1350 assert_eq!(
1351 kv_cas_path("sessions", "dXNlcjo0Mg"),
1352 "/agdx/kv/sessions/dXNlcjo0Mg/cas"
1353 );
1354 assert_eq!(fork_path("f1"), "/agdx/forks/f1");
1355 assert_eq!(fork_promote_path("f1"), "/agdx/forks/f1/promote");
1356 assert_eq!(fork_rows_path("f1"), "/agdx/forks/f1/rows");
1357 }
1358
1359 #[test]
1360 fn given_an_operation_id_when_rendered_and_parsed_then_should_round_trip() {
1361 let operation_id = NonZeroU128::new(u128::MAX).expect("non-zero");
1362 let query = OperationQuery::from(operation_id);
1363 assert_eq!(
1364 query.operation_id.as_deref(),
1365 Some("340282366920938463463374607431768211455")
1366 );
1367 assert_eq!(query.operation_id(), Ok(Some(operation_id)));
1368 assert_eq!(OperationQuery::default().operation_id(), Ok(None));
1369 }
1370
1371 #[test]
1372 fn given_a_malformed_operation_id_when_parsed_then_should_refuse_it() {
1373 for text in [
1374 "",
1375 "0",
1376 "+7",
1377 "-7",
1378 " 7",
1379 "0x7",
1380 "7.0",
1381 "340282366920938463463374607431768211456",
1382 ] {
1383 let query = OperationQuery {
1384 operation_id: Some(text.to_owned()),
1385 };
1386 assert_eq!(query.operation_id(), Err(InvalidOperationId), "{text:?}");
1387 }
1388 }
1389
1390 #[test]
1391 fn given_stream_scoped_names_when_rendered_then_should_encode_each_as_one_segment() {
1392 assert_eq!(
1393 kv_namespace_path("stream:acme/support"),
1394 "/agdx/kv/stream%3Aacme%2Fsupport"
1395 );
1396 assert_eq!(
1397 kv_cas_path("stream:acme/support", "dXNlcjo0Mg"),
1398 "/agdx/kv/stream%3Aacme%2Fsupport/dXNlcjo0Mg/cas"
1399 );
1400 assert_eq!(
1401 graph_neighbors_path("stream:acme/kg", "a/b"),
1402 "/agdx/graph/stream%3Aacme%2Fkg/neighbors/a%2Fb"
1403 );
1404 assert_eq!(
1405 graph_query_path("stream:acme/kg"),
1406 "/agdx/graph/stream%3Aacme%2Fkg/query"
1407 );
1408 assert_eq!(
1409 graph_path("stream:acme/kg"),
1410 "/agdx/graphs/stream%3Aacme%2Fkg"
1411 );
1412 assert_eq!(
1413 projection_path("stream:acme/readings"),
1414 "/agdx/projections/stream%3Aacme%2Freadings"
1415 );
1416 }
1417
1418 #[test]
1419 fn given_the_sessions_feature_when_built_from_versions_then_should_advertise_sessions() {
1420 let mut versions = OpVersions::new(1, 1, 1, 1);
1421 assert!(!Capabilities::from_versions(true, versions).sessions);
1422 versions.features |= crate::hello::feature::SESSIONS;
1423 assert!(Capabilities::from_versions(true, versions).sessions);
1424 assert!(!Capabilities::from_versions(false, versions).sessions);
1425 }
1426
1427 #[test]
1428 fn given_capabilities_when_constructed_then_extended_features_default_off() {
1429 let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1430 assert!(
1431 caps.query.available && caps.kv.available && caps.fork,
1432 "core surfaces track enabled"
1433 );
1434 assert!(
1435 !caps.kv.cas
1436 && !caps.kv.cas_fenced
1437 && !caps.kv.fenced_leases
1438 && caps.query.consistency == Consistency::Eventual,
1439 "sub-features must be opt-in, never on by default"
1440 );
1441 let opted = caps
1442 .with_kv_cas(true)
1443 .with_query_consistency(Consistency::ReadYourWrites);
1444 assert!(opted.kv.cas && opted.query.consistency == Consistency::ReadYourWrites);
1445 }
1446
1447 #[test]
1448 fn given_the_fenced_lease_bit_when_building_http_capabilities_then_should_advertise_it() {
1449 let versions =
1450 OpVersions::new(1, 1, 1, 1).with_features(crate::hello::feature::KV_FENCED_LEASES);
1451 let caps = Capabilities::from_versions(true, versions);
1452 assert!(caps.kv.fenced_leases);
1453 assert!(
1454 caps.kv.cas_fenced,
1455 "the full fenced-lease contract subsumes fenced CAS"
1456 );
1457 }
1458
1459 #[test]
1460 fn given_group_reads_without_evaluation_when_building_http_capabilities_then_should_keep_catalog_available()
1461 {
1462 let versions = OpVersions::new(1, 1, 1, 1)
1463 .with_filter(crate::codes::FILTER_OP_VERSION)
1464 .with_features(crate::hello::feature::GROUP_POLICY_READS);
1465 let caps = Capabilities::from_versions(true, versions);
1466 assert!(caps.filters.group_policy_reads);
1467 assert!(caps.filters.catalog && caps.filters.managed_groups);
1468 assert!(!caps.filters.native && !caps.filters.preview);
1469 assert_eq!(caps.filters.evaluator_version, 0);
1470 assert!(caps.filters.codecs.is_empty());
1471 let absent = Capabilities::from_versions(false, OpVersions::new(1, 1, 1, 1));
1472 assert!(!absent.filters.group_policy_reads && !absent.filters.catalog);
1473 }
1474
1475 #[test]
1476 fn given_capabilities_backends_when_json_round_tripped_then_should_preserve_and_omit_empty() {
1477 use crate::destination::BackendResourceId;
1478 use crate::hello::{BackendDescriptor, BackendImplementation, BackendMode};
1479 let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_backends(vec![
1480 BackendDescriptor::new(
1481 BackendResourceId::from_u128(1),
1482 BackendMode::Operational,
1483 "Embedded",
1484 BackendImplementation {
1485 kind: "embedded".to_owned(),
1486 version: "1.0.0".to_owned(),
1487 },
1488 1,
1489 1,
1490 ),
1491 BackendDescriptor::new(
1492 BackendResourceId::from_u128(2),
1493 BackendMode::Operational,
1494 "Warehouse",
1495 BackendImplementation {
1496 kind: "columnar".to_owned(),
1497 version: "1.0.0".to_owned(),
1498 },
1499 1,
1500 1,
1501 ),
1502 ]);
1503 let json = serde_json::to_string(&caps).expect("serializes");
1504 let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1505 assert_eq!(back.backends.len(), 2);
1506 assert_eq!(
1507 back.backends[1].resource_id,
1508 BackendResourceId::from_u128(2)
1509 );
1510 assert_eq!(back.backends[1].implementation.kind, "columnar");
1511
1512 let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1515 let json = serde_json::to_string(&plain).expect("json");
1516 assert!(!json.contains("backends"), "empty backends omitted: {json}");
1517 }
1518
1519 #[test]
1520 fn given_capabilities_topology_when_json_round_tripped_then_should_preserve_and_omit_absent() {
1521 let custom = WireTopology {
1522 ops_stream: "custom-ops".to_owned(),
1523 ..WireTopology::default()
1524 };
1525 let caps =
1526 Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_topology(custom.clone());
1527 let json = serde_json::to_string(&caps).expect("serializes");
1528 let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1529 assert_eq!(back.topology, Some(custom));
1530
1531 let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1534 let json = serde_json::to_string(&plain).expect("json");
1535 assert!(
1536 !json.contains("topology"),
1537 "absent topology omitted: {json}"
1538 );
1539 }
1540
1541 #[test]
1542 fn given_a_typed_error_when_made_into_a_body_then_should_carry_code_and_message() {
1543 let body = ErrorBody::from(&QueryError::IndexNotFound("readings".to_owned()));
1544 assert_eq!(body.code, ResultCode::NotFound);
1545 assert_eq!(body.http_status(), 404);
1546 assert!(body.message.contains("readings"));
1547 let json = serde_json::to_string(&body).expect("serializes");
1549 let back: ErrorBody = serde_json::from_str(&json).expect("deserializes");
1550 assert_eq!(back, body);
1551 }
1552
1553 #[test]
1554 #[cfg(feature = "http-client")]
1555 fn given_scan_filters_when_url_encoded_then_should_omit_absent_fields() {
1556 let query = KvScanQuery {
1557 prefix: Some("dXNlcjo".to_owned()),
1558 page: Some(2),
1559 results: Some(50),
1560 ..Default::default()
1561 };
1562 let encoded = serde_urlencoded::to_string(&query).expect("encodes");
1563 assert_eq!(encoded, "prefix=dXNlcjo&page=2&results=50");
1564 assert!(encoded.contains(&format!("{PARAM_PREFIX}=")));
1566 assert!(encoded.contains(&format!("{PARAM_PAGE}=")));
1567 assert!(encoded.contains(&format!("{PARAM_RESULTS}=")));
1568 }
1569
1570 #[test]
1571 #[cfg(feature = "http-client")]
1572 fn given_names_with_reserved_characters_when_building_a_binding_path_then_should_encode_each_segment()
1573 {
1574 assert_eq!(
1575 filter_binding_path("orbit", "fleet/changes", "desk?x#1"),
1576 "/agdx/filter-bindings/orbit/fleet%2Fchanges/desk%3Fx%231"
1577 );
1578 }
1579
1580 #[cfg(feature = "http-client")]
1581 #[test]
1582 fn given_list_filters_when_url_encoded_then_field_names_match_the_param_consts() {
1583 let projections = ProjectionListQuery {
1584 name_contains: Some("reading".to_owned()),
1585 id_prefix: Some("reading.".to_owned()),
1586 ..Default::default()
1587 };
1588 let encoded = serde_urlencoded::to_string(&projections).expect("encodes");
1589 assert_eq!(encoded, "name_contains=reading&id_prefix=reading.");
1590 assert!(encoded.contains(&format!("{PARAM_NAME_CONTAINS}=")));
1591 assert!(encoded.contains(&format!("{PARAM_ID_PREFIX}=")));
1592
1593 let schemas = SchemaListQuery {
1594 name_contains: Some("Reading".to_owned()),
1595 ..SchemaListQuery::default()
1596 };
1597 assert_eq!(
1598 serde_urlencoded::to_string(&schemas).expect("encodes"),
1599 "name_contains=Reading"
1600 );
1601 let scoped = SchemaListQuery {
1602 stream: Some("acme".to_owned()),
1603 ..SchemaListQuery::default()
1604 };
1605 assert_eq!(
1606 serde_urlencoded::to_string(&scoped).expect("encodes"),
1607 format!("{PARAM_STREAM}=acme")
1608 );
1609 assert_eq!(
1610 serde_urlencoded::to_string(SchemaQuery::default()).expect("encodes"),
1611 ""
1612 );
1613 assert_eq!(
1614 serde_urlencoded::to_string(SchemaQuery {
1615 stream: Some("acme".to_owned()),
1616 })
1617 .expect("encodes"),
1618 format!("{PARAM_STREAM}=acme")
1619 );
1620 }
1621
1622 #[test]
1623 fn given_a_register_schema_body_without_a_stream_when_encoded_then_should_omit_it() {
1624 let body = RegisterSchemaBody {
1625 source: crate::control::SchemaSource::JsonSchema {
1626 schema: "{}".to_owned(),
1627 },
1628 name: None,
1629 version: None,
1630 stream: None,
1631 };
1632 let json = serde_json::to_string(&body).expect("encodes");
1633 assert!(
1634 !json.contains("stream"),
1635 "an unset stream is omitted: {json}"
1636 );
1637 let scoped: RegisterSchemaBody = serde_json::from_str(
1638 r#"{"source":{"kind":"json_schema","schema":"{}"},"stream":"acme"}"#,
1639 )
1640 .expect("decodes");
1641 assert_eq!(scoped.stream.as_deref(), Some("acme"));
1642 }
1643}