1use crate::checkpoint::{
2 CheckpointMutationResult, CheckpointReadConsistency, CheckpointRequestEnvelope,
3 DestinationCheckpointStatus, PreparedAttemptSummary, RetentionGap,
4};
5use crate::destination::{
6 DestinationId, DestinationOperationId, MaterializationDestination, QueryRoute,
7};
8use crate::fork::{ForkError, ForkKind};
9use crate::graph::SourceRef;
10use crate::hello::{BackendDescriptor, FilterAnnounce, OpVersions};
11use crate::kv::KvError;
12use crate::query::{Consistency, QueryError, QueryExecutionId, QueryExecutionStatus, QueryResult};
13use crate::result::ResultCode;
14use crate::schema::{Digest32, LogicalSchema, TypedValue, UuidValue};
15use crate::topology::WireTopology;
16use serde::{Deserialize, Serialize};
17use std::collections::BTreeMap;
18
19pub const CAPABILITIES_PATH: &str = "/agdx/capabilities";
21pub const QUERY_PATH: &str = "/agdx/query";
23pub const DESTINATIONS_PATH: &str = "/agdx/destinations";
25pub const QUERY_ROUTES_PATH: &str = "/agdx/query-routes";
27pub const PROJECTIONS_PATH: &str = "/agdx/projections";
29pub const BINDINGS_PATH: &str = "/agdx/bindings";
31pub const SCHEMAS_PATH: &str = "/agdx/schemas";
33pub const KV_PATH: &str = "/agdx/kv";
35pub const FORKS_PATH: &str = "/agdx/forks";
37pub const GRAPHS_PATH: &str = "/agdx/graphs";
39pub const CLIENTS_PATH: &str = "/agdx/clients";
43pub const RUNS_PATH: &str = "/agdx/runs";
47pub const AUTHZ_WHOAMI_PATH: &str = "/agdx/authz/whoami";
49pub const AUTHZ_ROLES_PATH: &str = "/agdx/authz/roles";
51pub const FILTERS_PATH: &str = "/agdx/filters";
53pub const FILTER_METRICS_PATH: &str = "/agdx/filters/metrics";
54pub const FILTER_MUTATIONS_PATH: &str = "/agdx/filters/mutations";
56pub const FILTER_VALIDATE_PATH: &str = "/agdx/filters/validate";
58pub const FILTER_TEST_PATH: &str = "/agdx/filters/test";
60pub const FILTER_PREVIEW_PATH: &str = "/agdx/filters/preview";
62pub const FILTER_BINDINGS_PATH: &str = "/agdx/filter-bindings";
64
65pub fn filter_path(id: u32) -> String {
67 format!("{FILTERS_PATH}/{id}")
68}
69
70pub fn filter_revisions_path(id: u32) -> String {
72 format!("{FILTERS_PATH}/{id}/revisions")
73}
74
75pub fn filter_operation_path(operation_id: u128) -> String {
77 format!("{FILTERS_PATH}/operations/{operation_id}")
78}
79
80pub fn filter_binding_path(stream: &str, topic: &str, group: &str) -> String {
84 format!(
85 "{FILTER_BINDINGS_PATH}/{}/{}/{}",
86 path_segment(stream),
87 path_segment(topic),
88 path_segment(group)
89 )
90}
91
92pub fn path_segment(text: &str) -> String {
95 let mut out = String::with_capacity(text.len());
96 for byte in text.bytes() {
97 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
98 out.push(char::from(byte));
99 } else {
100 out.push_str(&format!("%{byte:02X}"));
101 }
102 }
103 out
104}
105
106pub fn destination_path(id: DestinationId) -> String {
107 format!("{DESTINATIONS_PATH}/{id}")
108}
109
110pub fn destination_enable_path(id: DestinationId) -> String {
111 format!("{}/{id}/enable", DESTINATIONS_PATH)
112}
113
114pub fn destination_disable_path(id: DestinationId) -> String {
115 format!("{}/{id}/disable", DESTINATIONS_PATH)
116}
117
118pub fn destination_status_path(id: DestinationId) -> String {
119 format!("{}/{id}/status", DESTINATIONS_PATH)
120}
121
122pub fn destination_checkpoint_path(id: DestinationId) -> String {
123 format!("{}/{id}/checkpoint", DESTINATIONS_PATH)
124}
125
126pub fn destination_retention_gap_path(id: DestinationId) -> String {
127 format!("{}/{id}/retention-gap", DESTINATIONS_PATH)
128}
129
130pub fn destination_prepared_attempt_path(id: DestinationId) -> String {
131 format!("{}/{id}/prepared-attempt", DESTINATIONS_PATH)
132}
133
134pub fn destination_table_path(id: DestinationId) -> String {
135 format!("{}/{id}/table", DESTINATIONS_PATH)
136}
137
138pub fn destination_table_schema_path(id: DestinationId) -> String {
139 format!("{}/{id}/table/schema", DESTINATIONS_PATH)
140}
141
142pub fn destination_snapshots_path(id: DestinationId) -> String {
143 format!("{}/{id}/table/snapshots", DESTINATIONS_PATH)
144}
145
146pub fn destination_current_snapshot_path(id: DestinationId) -> String {
147 format!("{}/{id}/table/current-snapshot", DESTINATIONS_PATH)
148}
149
150pub fn destination_snapshot_path(id: DestinationId, snapshot_id: i64) -> String {
151 format!("{}/{id}/table/snapshots/{snapshot_id}", DESTINATIONS_PATH)
152}
153
154pub fn destination_files_path(id: DestinationId) -> String {
155 format!("{}/{id}/table/files", DESTINATIONS_PATH)
156}
157
158pub fn destination_metrics_path(id: DestinationId) -> String {
159 format!("{}/{id}/table/metrics", DESTINATIONS_PATH)
160}
161
162pub fn query_execution_path(id: QueryExecutionId) -> String {
163 format!("{QUERY_PATH}/{id}")
164}
165
166pub fn query_page_path(id: QueryExecutionId) -> String {
167 format!("{QUERY_PATH}/{id}/pages")
168}
169
170pub fn query_cancel_path(id: QueryExecutionId) -> String {
171 format!("{QUERY_PATH}/{id}/cancel")
172}
173
174pub fn destination_operation_path(id: DestinationOperationId) -> String {
175 format!("{DESTINATIONS_PATH}/operations/{id}")
176}
177
178#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
179pub struct CheckpointReadQuery {
180 pub consistency: CheckpointReadConsistency,
181}
182
183impl CheckpointReadQuery {
184 pub const fn new(consistency: CheckpointReadConsistency) -> Self {
185 Self { consistency }
186 }
187}
188
189#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
190pub struct DestinationListQuery {
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub source_stream: Option<String>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub source_topic: Option<String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub name_contains: Option<String>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub after: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub limit: Option<usize>,
201 pub consistency: CheckpointReadConsistency,
202}
203
204#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
205pub struct DestinationPageView {
206 pub destinations: Vec<DestinationView>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub next_cursor: Option<String>,
209 pub global_state_revision: u64,
210 pub consistency: crate::checkpoint::CheckpointReadConsistency,
211}
212
213#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
214pub struct DestinationView {
215 pub destination: MaterializationDestination,
216 pub status: DestinationCheckpointStatus,
217}
218
219#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
220pub struct DestinationMutationBody {
221 pub checkpoint: CheckpointRequestEnvelope,
222}
223
224#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
225pub struct AcceptedOperationView {
226 pub operation_id: DestinationOperationId,
227 pub request_id: crate::checkpoint::CheckpointRequestId,
228 pub state: OperationState,
229 pub submitted_at_micros: u64,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub completed_at_micros: Option<u64>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub error: Option<OperationErrorView>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub result: Option<CheckpointMutationResult>,
236}
237
238#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
239pub struct OperationErrorView {
240 pub code: ResultCode,
241 pub message: String,
242}
243
244#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246#[non_exhaustive]
247pub enum OperationState {
248 Accepted,
249 Running,
250 Succeeded,
251 Failed,
252 Cancelled,
253}
254
255#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
256pub struct QueryRouteListQuery {
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub name_contains: Option<String>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub after: Option<String>,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub limit: Option<usize>,
263 pub consistency: CheckpointReadConsistency,
264}
265
266#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
267pub struct QueryRoutePageView {
268 pub routes: Vec<QueryRoute>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub next_cursor: Option<String>,
271 pub definition_revision: u64,
272 pub global_state_revision: u64,
273 pub consistency: CheckpointReadConsistency,
274}
275
276#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
277pub struct TableView {
278 pub table_uuid: UuidValue,
279 pub destination_id: DestinationId,
280 pub destination_generation: u64,
281 pub namespace: Vec<String>,
282 pub table: String,
283 pub current_snapshot_id: i64,
284 pub current_schema_id: i32,
285 pub current_partition_spec_id: i32,
286 pub metadata_identity: String,
287 pub properties: BTreeMap<String, String>,
288}
289
290#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
291pub struct TableSchemaView {
292 pub table_uuid: UuidValue,
293 pub iceberg_schema_id: i32,
294 pub logical_schema: LogicalSchema,
295}
296
297#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
298pub struct SnapshotListQuery {
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub before_snapshot_id: Option<i64>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub limit: Option<usize>,
303}
304
305#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
306pub struct SnapshotPageView {
307 pub snapshots: Vec<TableSnapshotView>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub next_before_snapshot_id: Option<i64>,
310}
311
312#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
313pub struct TableSnapshotView {
314 pub snapshot_id: i64,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub parent_snapshot_id: Option<i64>,
317 pub sequence_number: i64,
318 pub committed_at_micros: u64,
319 pub schema_id: i32,
320 pub partition_spec_id: i32,
321 pub materialization_boundary_digest: Digest32,
322 pub checkpoint_revision: u64,
323 pub summary: BTreeMap<String, String>,
324}
325
326#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
327pub struct TableFileListQuery {
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub snapshot_id: Option<i64>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub after: Option<String>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub limit: Option<usize>,
334}
335
336#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
337pub struct TableFilePageView {
338 pub files: Vec<TableFileView>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub next_cursor: Option<String>,
341}
342
343#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
344pub struct TableFileView {
345 pub object_identity: String,
346 pub file_size_bytes: u64,
347 pub row_count: u64,
348 pub partition: BTreeMap<String, TypedValue>,
349 pub lower_bounds: BTreeMap<u32, TypedValue>,
350 pub upper_bounds: BTreeMap<u32, TypedValue>,
351 pub null_value_counts: BTreeMap<u32, u64>,
352}
353
354#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
355pub struct TableMetricsView {
356 pub snapshot_id: i64,
357 pub data_file_count: u64,
358 pub delete_file_count: u64,
359 pub total_rows: u64,
360 pub total_bytes: u64,
361 pub partition_count: u64,
362}
363
364#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
365pub struct QueryPageBody {
366 pub cursor: String,
367 pub deadline_micros: u64,
368}
369
370#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
371pub struct QueryExecutionView {
372 pub status: QueryExecutionStatus,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub result: Option<QueryResult>,
375}
376
377#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
378pub struct DestinationIssueView {
379 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub retention_gap: Option<RetentionGap>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub prepared_attempt: Option<PreparedAttemptSummary>,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub block: Option<crate::checkpoint::DestinationBlock>,
385 pub global_state_revision: u64,
386 pub checkpoint_revision: u64,
387 pub consistency: CheckpointReadConsistency,
388}
389
390#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(rename_all = "snake_case")]
393#[non_exhaustive]
394pub enum DestinationHttpOperation {
395 ListDestinations,
396 GetDestination,
397 CreateDestination,
398 EnableDestination,
399 DisableDestination,
400 GetOperation,
401 GetStatus,
402 GetCheckpoint,
403 GetRetentionGap,
404 GetPreparedAttempt,
405 ListQueryRoutes,
406 GetTable,
407 GetTableSchema,
408 GetCurrentSnapshot,
409 ListSnapshots,
410 GetSnapshot,
411 ListFiles,
412 GetMetrics,
413}
414
415#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
421pub struct DestinationHttpRequest {
422 pub v: u32,
423 pub operation: DestinationHttpOperation,
424 pub parameters: Vec<String>,
425 pub query: Vec<u8>,
426 pub body: Vec<u8>,
427}
428
429#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
431pub struct DestinationHttpReply {
432 pub status: u16,
433 pub body: Vec<u8>,
434}
435
436pub fn authz_role_path(name: &str) -> String {
438 format!("{AUTHZ_ROLES_PATH}/{name}")
439}
440
441pub fn authz_user_roles_path(user_id: u32) -> String {
443 format!("/agdx/authz/users/{user_id}/roles")
444}
445
446pub fn graph_path(id: &str) -> String {
448 format!("{GRAPHS_PATH}/{id}")
449}
450
451pub fn graph_query_path(name: &str) -> String {
453 format!("/agdx/graph/{name}/query")
454}
455
456pub fn graph_neighbors_path(name: &str, node: &str) -> String {
458 format!("/agdx/graph/{name}/neighbors/{node}")
459}
460
461pub fn projection_path(id: &str) -> String {
463 format!("{PROJECTIONS_PATH}/{id}")
464}
465
466pub fn schema_path(id: u32) -> String {
468 format!("{SCHEMAS_PATH}/{id}")
469}
470
471pub fn schema_decode_path(id: u32) -> String {
473 format!("{SCHEMAS_PATH}/{id}/decode")
474}
475
476pub fn kv_namespace_path(namespace: &str) -> String {
478 format!("{KV_PATH}/{namespace}")
479}
480
481pub fn kv_entry_path(namespace: &str, key_b64: &str) -> String {
485 format!("{KV_PATH}/{namespace}/{key_b64}")
486}
487
488pub fn kv_cas_path(namespace: &str, key_b64: &str) -> String {
495 format!("{KV_PATH}/{namespace}/{key_b64}/cas")
496}
497
498pub fn fork_path(id: &str) -> String {
500 format!("{FORKS_PATH}/{id}")
501}
502
503pub fn fork_promote_path(id: &str) -> String {
505 format!("{FORKS_PATH}/{id}/promote")
506}
507
508pub fn fork_rows_path(id: &str) -> String {
510 format!("{FORKS_PATH}/{id}/rows")
511}
512
513pub fn run_path(id: &str) -> String {
515 format!("{RUNS_PATH}/{id}")
516}
517
518pub fn run_cancel_path(id: &str) -> String {
520 format!("{RUNS_PATH}/{id}/cancel")
521}
522
523#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
530#[non_exhaustive]
531pub struct Capabilities {
532 pub managed: bool,
536 pub query: QueryCapsView,
539 pub destinations: DestinationCapsView,
541 pub kv: KvCapsView,
543 #[serde(default)]
547 pub graph: bool,
548 pub fork: bool,
550 #[serde(default)]
553 pub agent_workflow: bool,
554 #[serde(default)]
558 pub watch: bool,
559 #[serde(default)]
562 pub authz: bool,
563 #[serde(default)]
566 pub filters: FilterCapsView,
567 pub versions: OpVersions,
568 #[serde(default, skip_serializing_if = "Vec::is_empty")]
573 pub backends: Vec<BackendDescriptor>,
574 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub topology: Option<WireTopology>,
581}
582
583#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
586pub struct QueryCapsView {
587 pub available: bool,
589 pub projections: bool,
591 pub schemas: bool,
593 #[serde(default)]
597 pub consistency: Consistency,
598 #[serde(default)]
602 pub keyword: bool,
603 #[serde(default)]
605 pub cursor_paging: bool,
606 #[serde(default)]
608 pub cancellation: bool,
609 #[serde(default)]
611 pub execution_status: bool,
612}
613
614#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
615pub struct DestinationCapsView {
616 pub available: bool,
617 #[serde(default)]
618 pub lifecycle: bool,
619 #[serde(default)]
620 pub checkpoint_status: bool,
621 #[serde(default)]
622 pub query_routes: bool,
623 #[serde(default)]
624 pub table_schema: bool,
625 #[serde(default)]
626 pub snapshots: bool,
627 #[serde(default)]
628 pub files: bool,
629 #[serde(default)]
630 pub metrics: bool,
631 #[serde(default)]
632 pub strongest_consistency: crate::checkpoint::CheckpointReadConsistency,
633}
634
635#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
639pub struct FilterCapsView {
640 #[serde(default)]
642 pub native: bool,
643 #[serde(default)]
645 pub preview: bool,
646 #[serde(default)]
648 pub primary_routing: bool,
649 #[serde(default)]
651 pub catalog: bool,
652 #[serde(default)]
654 pub managed_groups: bool,
655 #[serde(default)]
657 pub evaluator_version: u32,
658 #[serde(default, skip_serializing_if = "Vec::is_empty")]
660 pub codecs: Vec<crate::filter::FilterCodec>,
661}
662
663impl FilterCapsView {
664 pub fn served(native: bool, catalog: bool) -> Self {
667 let evaluation = native.then(FilterAnnounce::served);
668 Self {
669 native,
670 preview: native,
671 primary_routing: native,
672 catalog: native && catalog,
673 managed_groups: native && catalog,
674 evaluator_version: evaluation
675 .as_ref()
676 .map_or(0, |evaluation| evaluation.evaluator_version),
677 codecs: evaluation
678 .map(|evaluation| evaluation.codecs)
679 .unwrap_or_default(),
680 }
681 }
682
683 #[must_use]
686 pub fn with_evaluation(mut self, evaluation: FilterAnnounce) -> Self {
687 if self.native {
688 self.evaluator_version = evaluation.evaluator_version;
689 self.codecs = evaluation.codecs;
690 }
691 self
692 }
693}
694
695#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
697pub struct KvCapsView {
698 pub available: bool,
700 #[serde(default)]
704 pub cas: bool,
705 #[serde(default)]
709 pub cas_fenced: bool,
710 #[serde(default)]
713 pub fenced_leases: bool,
714}
715
716impl Capabilities {
717 pub fn new(enabled: bool, versions: OpVersions) -> Self {
724 Self {
725 managed: enabled,
726 query: QueryCapsView {
727 available: enabled,
728 projections: enabled,
729 schemas: enabled,
730 consistency: Consistency::Eventual,
731 keyword: false,
732 cursor_paging: false,
733 cancellation: false,
734 execution_status: false,
735 },
736 destinations: DestinationCapsView {
737 available: false,
738 lifecycle: false,
739 checkpoint_status: false,
740 query_routes: false,
741 table_schema: false,
742 snapshots: false,
743 files: false,
744 metrics: false,
745 strongest_consistency:
746 crate::checkpoint::CheckpointReadConsistency::PotentiallyStale,
747 },
748 kv: KvCapsView {
749 available: enabled,
750 cas: false,
751 cas_fenced: false,
752 fenced_leases: false,
753 },
754 graph: false,
755 fork: enabled,
756 agent_workflow: false,
757 watch: false,
758 authz: false,
759 filters: FilterCapsView::default(),
760 versions,
761 backends: Vec::new(),
762 topology: None,
763 }
764 }
765
766 #[must_use]
768 pub fn with_filters(mut self, filters: FilterCapsView) -> Self {
769 self.filters = filters;
770 self
771 }
772
773 #[must_use]
776 pub fn with_graph(mut self, value: bool) -> Self {
777 self.graph = value;
778 self
779 }
780
781 #[must_use]
784 pub fn with_agent_workflow(mut self, value: bool) -> Self {
785 self.agent_workflow = value;
786 self
787 }
788
789 #[must_use]
791 pub fn with_query_keyword(mut self, value: bool) -> Self {
792 self.query.keyword = value;
793 self
794 }
795
796 #[must_use]
797 pub fn with_query_execution(mut self, paging: bool, cancellation: bool, status: bool) -> Self {
798 self.query.cursor_paging = paging;
799 self.query.cancellation = cancellation;
800 self.query.execution_status = status;
801 self
802 }
803
804 #[must_use]
805 pub fn with_destinations(mut self, destinations: DestinationCapsView) -> Self {
806 self.destinations = destinations;
807 self
808 }
809
810 #[must_use]
813 pub fn with_watch(mut self, value: bool) -> Self {
814 self.watch = value;
815 self
816 }
817
818 #[must_use]
820 pub fn with_authz(mut self, value: bool) -> Self {
821 self.authz = value;
822 self
823 }
824
825 #[must_use]
828 pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
829 self.backends = backends;
830 self
831 }
832
833 #[must_use]
836 pub fn with_topology(mut self, topology: WireTopology) -> Self {
837 self.topology = Some(topology);
838 self
839 }
840
841 #[must_use]
844 pub fn with_kv_cas(mut self, on: bool) -> Self {
845 self.kv.cas = on;
846 self
847 }
848
849 #[must_use]
852 pub fn with_kv_cas_fenced(mut self, on: bool) -> Self {
853 self.kv.cas_fenced = on;
854 self
855 }
856
857 #[must_use]
860 pub fn with_kv_fenced_leases(mut self, on: bool) -> Self {
861 self.kv.fenced_leases = on;
862 self.kv.cas_fenced |= on;
863 self
864 }
865
866 #[must_use]
868 pub fn with_query_consistency(mut self, level: Consistency) -> Self {
869 self.query.consistency = level;
870 self
871 }
872
873 pub fn from_versions(enabled: bool, versions: OpVersions) -> Self {
879 use crate::hello::feature;
880 let consistency = if versions.has_feature(feature::STRONG_CONSISTENCY) {
881 Consistency::Strong
882 } else if versions.has_feature(feature::READ_YOUR_WRITES) {
883 Consistency::ReadYourWrites
884 } else {
885 Consistency::Eventual
886 };
887 let destinations_available =
888 enabled && versions.checkpoint > 0 && versions.has_feature(feature::DESTINATIONS);
889 Self::new(enabled, versions)
890 .with_kv_cas(versions.has_feature(feature::KV_CAS))
891 .with_kv_cas_fenced(versions.has_feature(feature::KV_CAS_FENCED))
892 .with_kv_fenced_leases(versions.has_feature(feature::KV_FENCED_LEASES))
893 .with_agent_workflow(versions.has_feature(feature::AGENT_WORKFLOW))
894 .with_query_keyword(versions.has_feature(feature::KEYWORD_SEARCH))
895 .with_watch(versions.has_feature(feature::WATCH))
896 .with_authz(versions.has_feature(feature::AUTHZ))
897 .with_query_consistency(consistency)
898 .with_destinations(DestinationCapsView {
899 available: destinations_available,
900 lifecycle: destinations_available,
901 checkpoint_status: destinations_available,
902 query_routes: destinations_available,
903 table_schema: false,
904 snapshots: false,
905 files: false,
906 metrics: false,
907 strongest_consistency:
908 crate::checkpoint::CheckpointReadConsistency::PotentiallyStale,
909 })
910 .with_graph(enabled && versions.graph > 0)
913 .with_filters(FilterCapsView::served(
914 versions.has_feature(feature::CONSUMER_FILTERS),
915 enabled && versions.filter > 0,
916 ))
917 }
918}
919
920#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
924pub struct KvEntryView {
925 pub key: String,
926 pub value: String,
927 pub expires_at_micros: Option<u64>,
928 #[serde(default, skip_serializing_if = "Option::is_none")]
933 pub scope: Option<crate::kv::MemoryRowScope>,
934 #[serde(default, skip_serializing_if = "Option::is_none")]
939 pub source: Option<crate::graph::SourceRef>,
940}
941
942#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
944pub struct KvPageView {
945 pub entries: Vec<KvEntryView>,
946 pub cursor: Option<String>,
947}
948
949#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
954pub struct RunPageView {
955 pub runs: Vec<crate::agent_workflow::AgentRunInfo>,
956 pub cursor: Option<String>,
957}
958
959#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
961pub struct DeletedManyView {
962 pub deleted: usize,
963}
964
965#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
970pub struct ClientMetadataView {
971 pub client_id: u32,
972 pub user_id: Option<u32>,
973 pub transport: u8,
974 pub address: String,
975 pub consumer_groups_count: u32,
976 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub metadata: Option<String>,
978}
979
980#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
983pub struct ClientMetadataListView {
984 pub clients: Vec<ClientMetadataView>,
985 #[serde(default, skip_serializing_if = "Option::is_none")]
986 pub next_cursor: Option<u32>,
987}
988
989#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
993pub struct ClientsQuery {
994 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
996 pub with_metadata_only: bool,
997 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub user_id: Option<u32>,
1000 #[serde(default, skip_serializing_if = "Option::is_none")]
1002 pub after: Option<u32>,
1003 #[serde(default, skip_serializing_if = "Option::is_none")]
1005 pub limit: Option<u32>,
1006}
1007
1008#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1010pub struct PromotedView {
1011 pub rows: usize,
1012}
1013
1014#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1018pub struct GraphNodeView {
1019 pub id: String,
1020 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1021 pub labels: Vec<String>,
1022 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1023 pub attrs: Vec<(String, String)>,
1024 #[serde(default, skip_serializing_if = "Option::is_none")]
1026 pub source: Option<SourceRef>,
1027}
1028
1029#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1032pub struct GraphEdgeView {
1033 pub id: String,
1034 pub from: String,
1035 pub to: String,
1036 pub edge_type: String,
1037 pub weight: f32,
1038 #[serde(default, skip_serializing_if = "Option::is_none")]
1040 pub valid_from: Option<u64>,
1041 #[serde(default, skip_serializing_if = "Option::is_none")]
1042 pub valid_to: Option<u64>,
1043 #[serde(default, skip_serializing_if = "Option::is_none")]
1046 pub source: Option<SourceRef>,
1047}
1048
1049#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1052pub struct GraphResultView {
1053 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1054 pub nodes: Vec<GraphNodeView>,
1055 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1056 pub edges: Vec<GraphEdgeView>,
1057 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1058 pub paths: Vec<PathView>,
1059}
1060
1061#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1064pub struct PathView {
1065 pub nodes: Vec<String>,
1066 pub edges: Vec<String>,
1067}
1068
1069#[derive(Clone, Debug, Serialize, Deserialize)]
1073pub struct RegisterSchemaBody {
1074 pub source: crate::control::SchemaSource,
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1076 pub name: Option<String>,
1077 #[serde(default, skip_serializing_if = "Option::is_none")]
1078 pub version: Option<u32>,
1079}
1080
1081#[derive(Clone, Debug, Serialize, Deserialize)]
1084pub struct DecodeRecordBody {
1085 pub payload: String,
1086}
1087
1088#[derive(Clone, Debug, Serialize, Deserialize)]
1090pub struct ForkCreateBody {
1091 pub fork_id: String,
1092 #[serde(default, skip_serializing_if = "Option::is_none")]
1093 pub parent: Option<String>,
1094 #[serde(default)]
1095 pub kind: ForkKind,
1096 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1097 pub tables: Vec<String>,
1098}
1099
1100#[derive(Clone, Debug, Serialize, Deserialize)]
1103pub struct ForkPutBody {
1104 pub table: String,
1105 pub partition_id: u32,
1106 pub offset: u64,
1107 #[serde(default)]
1108 pub projection_id: String,
1109 #[serde(default)]
1110 pub projection_version: u32,
1111 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1112 pub fields: BTreeMap<String, String>,
1113 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1114 pub metadata: BTreeMap<String, String>,
1115 #[serde(default, skip_serializing_if = "Option::is_none")]
1116 pub payload_b64: Option<String>,
1117 #[serde(default, skip_serializing_if = "Option::is_none")]
1118 pub embedding: Option<String>,
1119 #[serde(default)]
1120 pub tombstone: bool,
1121}
1122
1123#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1128pub struct RemoveBindingBody {
1129 pub stream: String,
1130 pub topic: String,
1131 #[serde(default, skip_serializing_if = "Option::is_none")]
1132 pub projection_ref: Option<String>,
1133}
1134
1135#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1144pub struct ErrorBody {
1145 pub code: ResultCode,
1146 pub message: String,
1147 #[serde(default, skip_serializing_if = "Option::is_none")]
1148 pub detail: Option<serde_json::Value>,
1149}
1150
1151impl ErrorBody {
1152 pub fn new(code: ResultCode, message: impl Into<String>) -> Self {
1154 Self {
1155 code,
1156 message: message.into(),
1157 detail: None,
1158 }
1159 }
1160
1161 #[must_use]
1163 pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
1164 self.detail = Some(detail);
1165 self
1166 }
1167
1168 pub fn http_status(&self) -> u16 {
1171 self.code.http_status()
1172 }
1173}
1174
1175impl From<&QueryError> for ErrorBody {
1176 fn from(error: &QueryError) -> Self {
1177 Self::new(ResultCode::from(error), error.to_string())
1178 }
1179}
1180
1181impl From<&crate::filter::FilterError> for ErrorBody {
1182 fn from(error: &crate::filter::FilterError) -> Self {
1183 Self::new(error.code, error.message.clone())
1184 .with_detail(serde_json::json!({ "reason": error.reason }))
1185 }
1186}
1187
1188impl From<&KvError> for ErrorBody {
1189 fn from(error: &KvError) -> Self {
1190 Self::new(ResultCode::from(error), error.to_string())
1191 }
1192}
1193
1194impl From<&ForkError> for ErrorBody {
1195 fn from(error: &ForkError) -> Self {
1196 Self::new(ResultCode::from(error), error.to_string())
1197 }
1198}
1199
1200impl From<&crate::agent_workflow::AgentError> for ErrorBody {
1201 fn from(error: &crate::agent_workflow::AgentError) -> Self {
1202 Self::new(ResultCode::from(error), error.to_string())
1203 }
1204}
1205
1206pub const PARAM_TOPIC: &str = "topic";
1208pub const PARAM_NAME_CONTAINS: &str = "name_contains";
1210pub const PARAM_ID_PREFIX: &str = "id_prefix";
1212pub const PARAM_SEARCH: &str = "search";
1217pub const PARAM_PREFIX: &str = "prefix";
1219pub const PARAM_START: &str = "start";
1221pub const PARAM_END: &str = "end";
1223pub const PARAM_KEY_CONTAINS: &str = "key_contains";
1225pub const PARAM_LIMIT: &str = "limit";
1227pub const PARAM_CURSOR: &str = "cursor";
1229pub const PARAM_EXPIRES_AT_MICROS: &str = "expires_at_micros";
1231pub const PARAM_EXPECT_VERSION: &str = "expect_version";
1234pub const PARAM_EXPECT_ABSENT: &str = "expect_absent";
1237
1238pub const KV_EXPIRES_AT_MICROS_HEADER: &str = "agdx-expires-at-micros";
1244
1245#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1250pub struct ProjectionListQuery {
1251 #[serde(default, skip_serializing_if = "Option::is_none")]
1252 pub topic: Option<String>,
1253 #[serde(default, skip_serializing_if = "Option::is_none")]
1254 pub name_contains: Option<String>,
1255 #[serde(default, skip_serializing_if = "Option::is_none")]
1256 pub id_prefix: Option<String>,
1257 #[serde(default, skip_serializing_if = "Option::is_none")]
1260 pub search: Option<String>,
1261}
1262
1263#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1267pub struct SchemaListQuery {
1268 #[serde(default, skip_serializing_if = "Option::is_none")]
1269 pub name_contains: Option<String>,
1270}
1271
1272#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1274pub struct FilterListQuery {
1275 #[serde(default, skip_serializing_if = "Option::is_none")]
1276 pub name_contains: Option<String>,
1277 #[serde(default, skip_serializing_if = "Option::is_none")]
1278 pub state: Option<crate::filter::FilterState>,
1279 #[serde(default, skip_serializing_if = "Option::is_none")]
1281 pub before_id: Option<u32>,
1282 #[serde(default, skip_serializing_if = "Option::is_none")]
1283 pub page: Option<u32>,
1284 #[serde(default, skip_serializing_if = "Option::is_none")]
1285 pub page_size: Option<u32>,
1286}
1287
1288#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1290pub struct FilterRevisionListQuery {
1291 #[serde(default, skip_serializing_if = "Option::is_none")]
1292 pub page: Option<u32>,
1293 #[serde(default, skip_serializing_if = "Option::is_none")]
1294 pub page_size: Option<u32>,
1295}
1296
1297#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1299pub struct FilterBindingListQuery {
1300 #[serde(default, skip_serializing_if = "Option::is_none")]
1301 pub filter_id: Option<u32>,
1302 #[serde(default, skip_serializing_if = "Option::is_none")]
1303 pub stream: Option<String>,
1304 #[serde(default, skip_serializing_if = "Option::is_none")]
1305 pub topic: Option<String>,
1306 #[serde(default, skip_serializing_if = "Option::is_none")]
1307 pub page: Option<u32>,
1308 #[serde(default, skip_serializing_if = "Option::is_none")]
1309 pub page_size: Option<u32>,
1310}
1311
1312#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1315pub struct FilterTestBody {
1316 pub filter: crate::filter::FilterRef,
1317 #[serde(default, skip_serializing_if = "Option::is_none")]
1318 pub payload_base64: Option<String>,
1319 #[serde(default, skip_serializing_if = "Option::is_none")]
1320 pub payload_text: Option<String>,
1321 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1322 pub headers: Vec<crate::filter::FilterHeader>,
1323}
1324
1325#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1329pub struct KvScanQuery {
1330 #[serde(default, skip_serializing_if = "Option::is_none")]
1331 pub prefix: Option<String>,
1332 #[serde(default, skip_serializing_if = "Option::is_none")]
1333 pub start: Option<String>,
1334 #[serde(default, skip_serializing_if = "Option::is_none")]
1335 pub end: Option<String>,
1336 #[serde(default, skip_serializing_if = "Option::is_none")]
1337 pub key_contains: Option<String>,
1338 #[serde(default, skip_serializing_if = "Option::is_none")]
1343 pub conversation: Option<String>,
1344 #[serde(default, skip_serializing_if = "Option::is_none")]
1345 pub limit: Option<usize>,
1346 #[serde(default, skip_serializing_if = "Option::is_none")]
1347 pub cursor: Option<String>,
1348}
1349
1350#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1352pub struct KvPutQuery {
1353 #[serde(default, skip_serializing_if = "Option::is_none")]
1354 pub expires_at_micros: Option<u64>,
1355}
1356
1357#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1363pub struct GraphNeighborsQuery {
1364 #[serde(default, skip_serializing_if = "Option::is_none")]
1365 pub dir: Option<String>,
1366 #[serde(default, skip_serializing_if = "Option::is_none")]
1367 pub edge_type: Option<String>,
1368 #[serde(default, skip_serializing_if = "Option::is_none")]
1369 pub depth: Option<u32>,
1370 #[serde(default, skip_serializing_if = "Option::is_none")]
1371 pub limit: Option<usize>,
1372 #[serde(default, skip_serializing_if = "Option::is_none")]
1374 pub as_of: Option<u64>,
1375 #[serde(default, skip_serializing_if = "Option::is_none")]
1379 pub conversation: Option<String>,
1380}
1381
1382#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1389pub struct RunsQuery {
1390 #[serde(default, skip_serializing_if = "Option::is_none")]
1391 pub agent_id: Option<String>,
1392 #[serde(default, skip_serializing_if = "Option::is_none")]
1393 pub state: Option<String>,
1394 #[serde(default, skip_serializing_if = "Option::is_none")]
1395 pub limit: Option<u32>,
1396 #[serde(default, skip_serializing_if = "Option::is_none")]
1397 pub cursor: Option<String>,
1398}
1399
1400#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1405pub struct KvCasQuery {
1406 #[serde(default, skip_serializing_if = "Option::is_none")]
1407 pub expect_version: Option<u64>,
1408 #[serde(default, skip_serializing_if = "Option::is_none")]
1409 pub expect_absent: Option<bool>,
1410 #[serde(default, skip_serializing_if = "Option::is_none")]
1411 pub expires_at_micros: Option<u64>,
1412}
1413
1414#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1417pub struct CasCommittedView {
1418 pub version: u64,
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423 use super::*;
1424
1425 #[test]
1426 fn given_path_builders_when_rendered_then_should_match_the_router() {
1427 assert_eq!(CAPABILITIES_PATH, "/agdx/capabilities");
1428 assert_eq!(projection_path("order.v1"), "/agdx/projections/order.v1");
1429 assert_eq!(schema_path(7), "/agdx/schemas/7");
1430 assert_eq!(schema_decode_path(7), "/agdx/schemas/7/decode");
1431 assert_eq!(kv_namespace_path("sessions"), "/agdx/kv/sessions");
1432 assert_eq!(
1433 kv_entry_path("sessions", "dXNlcjo0Mg"),
1434 "/agdx/kv/sessions/dXNlcjo0Mg"
1435 );
1436 assert_eq!(
1437 kv_cas_path("sessions", "dXNlcjo0Mg"),
1438 "/agdx/kv/sessions/dXNlcjo0Mg/cas"
1439 );
1440 assert_eq!(fork_path("f1"), "/agdx/forks/f1");
1441 assert_eq!(fork_promote_path("f1"), "/agdx/forks/f1/promote");
1442 assert_eq!(fork_rows_path("f1"), "/agdx/forks/f1/rows");
1443 }
1444
1445 #[test]
1446 fn given_capabilities_when_constructed_then_extended_features_default_off() {
1447 let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1448 assert!(
1449 caps.query.available && caps.kv.available && caps.fork,
1450 "core surfaces track enabled"
1451 );
1452 assert!(
1453 !caps.kv.cas
1454 && !caps.kv.cas_fenced
1455 && !caps.kv.fenced_leases
1456 && caps.query.consistency == Consistency::Eventual,
1457 "sub-features must be opt-in, never on by default"
1458 );
1459 let opted = caps
1460 .with_kv_cas(true)
1461 .with_query_consistency(Consistency::ReadYourWrites);
1462 assert!(opted.kv.cas && opted.query.consistency == Consistency::ReadYourWrites);
1463 }
1464
1465 #[test]
1466 fn given_the_fenced_lease_bit_when_building_http_capabilities_then_should_advertise_it() {
1467 let versions =
1468 OpVersions::new(1, 1, 1, 1).with_features(crate::hello::feature::KV_FENCED_LEASES);
1469 let caps = Capabilities::from_versions(true, versions);
1470 assert!(caps.kv.fenced_leases);
1471 assert!(
1472 caps.kv.cas_fenced,
1473 "the full fenced-lease contract subsumes fenced CAS"
1474 );
1475 }
1476
1477 #[test]
1478 fn given_destination_versions_when_building_capabilities_then_the_core_surface_is_available() {
1479 use crate::codes::CHECKPOINT_OP_VERSION;
1480 use crate::hello::feature;
1481
1482 let versions = OpVersions::new(2, 2, 1, 1)
1483 .with_checkpoint(CHECKPOINT_OP_VERSION)
1484 .with_features(feature::DESTINATIONS);
1485 let caps = Capabilities::from_versions(true, versions);
1486
1487 assert!(caps.destinations.available);
1488 assert!(caps.destinations.lifecycle);
1489 assert!(caps.destinations.checkpoint_status);
1490 assert!(caps.destinations.query_routes);
1491 assert!(!caps.destinations.table_schema);
1492 }
1493
1494 #[test]
1495 fn given_capabilities_backends_when_json_round_tripped_then_should_preserve_and_omit_empty() {
1496 use crate::destination::BackendResourceId;
1497 use crate::hello::{BackendDescriptor, BackendImplementation, BackendMode};
1498 let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_backends(vec![
1499 BackendDescriptor::new(
1500 BackendResourceId::from_u128(1),
1501 BackendMode::Operational,
1502 "Embedded",
1503 BackendImplementation {
1504 kind: "embedded".to_owned(),
1505 version: "1.0.0".to_owned(),
1506 },
1507 1,
1508 1,
1509 ),
1510 BackendDescriptor::new(
1511 BackendResourceId::from_u128(2),
1512 BackendMode::Lakehouse,
1513 "Warehouse",
1514 BackendImplementation {
1515 kind: "columnar".to_owned(),
1516 version: "1.0.0".to_owned(),
1517 },
1518 1,
1519 1,
1520 ),
1521 ]);
1522 let json = serde_json::to_string(&caps).expect("serializes");
1523 let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1524 assert_eq!(back.backends.len(), 2);
1525 assert_eq!(
1526 back.backends[1].resource_id,
1527 BackendResourceId::from_u128(2)
1528 );
1529 assert_eq!(back.backends[1].implementation.kind, "columnar");
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!(!json.contains("backends"), "empty backends omitted: {json}");
1536 }
1537
1538 #[test]
1539 fn given_capabilities_topology_when_json_round_tripped_then_should_preserve_and_omit_absent() {
1540 let custom = WireTopology {
1541 ops_stream: "custom-ops".to_owned(),
1542 ..WireTopology::default()
1543 };
1544 let caps =
1545 Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_topology(custom.clone());
1546 let json = serde_json::to_string(&caps).expect("serializes");
1547 let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1548 assert_eq!(back.topology, Some(custom));
1549
1550 let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1553 let json = serde_json::to_string(&plain).expect("json");
1554 assert!(
1555 !json.contains("topology"),
1556 "absent topology omitted: {json}"
1557 );
1558 }
1559
1560 #[test]
1561 fn given_a_typed_error_when_made_into_a_body_then_should_carry_code_and_message() {
1562 let body = ErrorBody::from(&QueryError::IndexNotFound("orders".to_owned()));
1563 assert_eq!(body.code, ResultCode::NotFound);
1564 assert_eq!(body.http_status(), 404);
1565 assert!(body.message.contains("orders"));
1566 let json = serde_json::to_string(&body).expect("serializes");
1568 let back: ErrorBody = serde_json::from_str(&json).expect("deserializes");
1569 assert_eq!(back, body);
1570 }
1571
1572 #[test]
1573 #[cfg(feature = "http-client")]
1574 fn given_scan_filters_when_url_encoded_then_should_omit_absent_fields() {
1575 let query = KvScanQuery {
1576 prefix: Some("dXNlcjo".to_owned()),
1577 limit: Some(50),
1578 ..Default::default()
1579 };
1580 let encoded = serde_urlencoded::to_string(&query).expect("encodes");
1581 assert_eq!(encoded, "prefix=dXNlcjo&limit=50");
1582 assert!(encoded.contains(&format!("{PARAM_PREFIX}=")));
1584 assert!(encoded.contains(&format!("{PARAM_LIMIT}=")));
1585 }
1586
1587 #[test]
1588 #[cfg(feature = "http-client")]
1589 fn given_names_with_reserved_characters_when_building_a_binding_path_then_should_encode_each_segment()
1590 {
1591 assert_eq!(
1592 filter_binding_path("orbit", "fleet/changes", "desk?x#1"),
1593 "/agdx/filter-bindings/orbit/fleet%2Fchanges/desk%3Fx%231"
1594 );
1595 }
1596
1597 #[cfg(feature = "http-client")]
1598 #[test]
1599 fn given_list_filters_when_url_encoded_then_field_names_match_the_param_consts() {
1600 let projections = ProjectionListQuery {
1601 name_contains: Some("order".to_owned()),
1602 id_prefix: Some("order.".to_owned()),
1603 ..Default::default()
1604 };
1605 let encoded = serde_urlencoded::to_string(&projections).expect("encodes");
1606 assert_eq!(encoded, "name_contains=order&id_prefix=order.");
1607 assert!(encoded.contains(&format!("{PARAM_NAME_CONTAINS}=")));
1608 assert!(encoded.contains(&format!("{PARAM_ID_PREFIX}=")));
1609
1610 let schemas = SchemaListQuery {
1611 name_contains: Some("Order".to_owned()),
1612 };
1613 assert_eq!(
1614 serde_urlencoded::to_string(&schemas).expect("encodes"),
1615 "name_contains=Order"
1616 );
1617 }
1618}