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#[non_exhaustive]
232pub struct Capabilities {
233 pub managed: bool,
237 pub query: QueryCapsView,
240 pub kv: KvCapsView,
242 #[serde(default)]
246 pub graph: bool,
247 pub fork: bool,
249 #[serde(default)]
251 pub sessions: bool,
252 #[serde(default)]
256 pub watch: bool,
257 #[serde(default)]
260 pub authz: bool,
261 #[serde(default)]
264 pub filters: FilterCapsView,
265 pub versions: OpVersions,
266 #[serde(default, skip_serializing_if = "Vec::is_empty")]
271 pub backends: Vec<BackendDescriptor>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub topology: Option<WireTopology>,
279}
280
281#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
284pub struct QueryCapsView {
285 pub available: bool,
287 pub projections: bool,
289 pub schemas: bool,
291 #[serde(default)]
294 pub consistency: Consistency,
295 #[serde(default)]
299 pub keyword: bool,
300}
301
302#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
306pub struct FilterCapsView {
307 #[serde(default)]
309 pub native: bool,
310 #[serde(default)]
312 pub group_policy_reads: bool,
313 #[serde(default)]
315 pub preview: bool,
316 #[serde(default)]
318 pub primary_routing: bool,
319 #[serde(default)]
321 pub catalog: bool,
322 #[serde(default)]
324 pub managed_groups: bool,
325 #[serde(default)]
327 pub evaluator_version: u32,
328 #[serde(default, skip_serializing_if = "Vec::is_empty")]
330 pub codecs: Vec<crate::filter::FilterCodec>,
331}
332
333impl FilterCapsView {
334 pub fn served(native: bool, catalog: bool) -> Self {
336 let evaluation = native.then(FilterAnnounce::served);
337 Self {
338 native,
339 group_policy_reads: false,
340 preview: native,
341 primary_routing: native,
342 catalog,
343 managed_groups: catalog,
344 evaluator_version: evaluation
345 .as_ref()
346 .map_or(0, |evaluation| evaluation.evaluator_version),
347 codecs: evaluation
348 .map(|evaluation| evaluation.codecs)
349 .unwrap_or_default(),
350 }
351 }
352
353 #[must_use]
354 pub const fn with_group_policy_reads(mut self, served: bool) -> Self {
355 self.group_policy_reads = served;
356 self
357 }
358
359 #[must_use]
362 pub fn with_evaluation(mut self, evaluation: FilterAnnounce) -> Self {
363 if self.native {
364 self.evaluator_version = evaluation.evaluator_version;
365 self.codecs = evaluation.codecs;
366 }
367 self
368 }
369}
370
371#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
373pub struct KvCapsView {
374 pub available: bool,
376 #[serde(default)]
380 pub cas: bool,
381 #[serde(default)]
385 pub cas_fenced: bool,
386 #[serde(default)]
389 pub fenced_leases: bool,
390}
391
392impl Capabilities {
393 pub fn new(enabled: bool, versions: OpVersions) -> Self {
400 Self {
401 managed: enabled,
402 query: QueryCapsView {
403 available: enabled,
404 projections: enabled,
405 schemas: enabled,
406 consistency: Consistency::Eventual,
407 keyword: false,
408 },
409 kv: KvCapsView {
410 available: enabled,
411 cas: false,
412 cas_fenced: false,
413 fenced_leases: false,
414 },
415 graph: false,
416 fork: enabled,
417 sessions: false,
418 watch: false,
419 authz: false,
420 filters: FilterCapsView::default(),
421 versions,
422 backends: Vec::new(),
423 topology: None,
424 }
425 }
426
427 #[must_use]
429 pub fn with_filters(mut self, filters: FilterCapsView) -> Self {
430 self.filters = filters;
431 self
432 }
433
434 #[must_use]
437 pub fn with_graph(mut self, value: bool) -> Self {
438 self.graph = value;
439 self
440 }
441
442 #[must_use]
444 pub fn with_query_keyword(mut self, value: bool) -> Self {
445 self.query.keyword = value;
446 self
447 }
448
449 #[must_use]
451 pub fn with_sessions(mut self, value: bool) -> Self {
452 self.sessions = value;
453 self
454 }
455
456 #[must_use]
459 pub fn with_watch(mut self, value: bool) -> Self {
460 self.watch = value;
461 self
462 }
463
464 #[must_use]
466 pub fn with_authz(mut self, value: bool) -> Self {
467 self.authz = value;
468 self
469 }
470
471 #[must_use]
474 pub fn with_backends(mut self, backends: Vec<BackendDescriptor>) -> Self {
475 self.backends = backends;
476 self
477 }
478
479 #[must_use]
482 pub fn with_topology(mut self, topology: WireTopology) -> Self {
483 self.topology = Some(topology);
484 self
485 }
486
487 #[must_use]
490 pub fn with_kv_cas(mut self, on: bool) -> Self {
491 self.kv.cas = on;
492 self
493 }
494
495 #[must_use]
498 pub fn with_kv_cas_fenced(mut self, on: bool) -> Self {
499 self.kv.cas_fenced = on;
500 self
501 }
502
503 #[must_use]
506 pub fn with_kv_fenced_leases(mut self, on: bool) -> Self {
507 self.kv.fenced_leases = on;
508 self.kv.cas_fenced |= on;
509 self
510 }
511
512 #[must_use]
514 pub fn with_query_consistency(mut self, level: Consistency) -> Self {
515 self.query.consistency = level;
516 self
517 }
518
519 pub fn from_versions(enabled: bool, versions: OpVersions) -> Self {
525 use crate::hello::feature;
526 let consistency = if versions.has_feature(feature::READ_YOUR_WRITES) {
527 Consistency::ReadYourWrites
528 } else {
529 Consistency::Eventual
530 };
531 Self::new(enabled, versions)
532 .with_kv_cas(versions.has_feature(feature::KV_CAS))
533 .with_kv_cas_fenced(versions.has_feature(feature::KV_CAS_FENCED))
534 .with_kv_fenced_leases(versions.has_feature(feature::KV_FENCED_LEASES))
535 .with_query_keyword(versions.has_feature(feature::KEYWORD_SEARCH))
536 .with_watch(versions.has_feature(feature::WATCH))
537 .with_sessions(enabled && versions.has_feature(feature::SESSIONS))
538 .with_authz(versions.has_feature(feature::AUTHZ))
539 .with_query_consistency(consistency)
540 .with_graph(enabled && versions.graph > 0)
543 .with_filters(
544 FilterCapsView::served(
545 versions.has_feature(feature::CONSUMER_FILTERS),
546 enabled && versions.filter > 0,
547 )
548 .with_group_policy_reads(versions.has_feature(feature::GROUP_POLICY_READS)),
549 )
550 }
551}
552
553#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
557pub struct KvEntryView {
558 pub key: String,
559 pub value: String,
560 pub expires_at_micros: Option<u64>,
561 pub version: u64,
563 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub scope: Option<crate::kv::MemoryRowScope>,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub source: Option<crate::graph::SourceRef>,
576}
577
578#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
580pub struct KvPageView {
581 pub items: Vec<KvEntryView>,
582 pub page: u32,
583 pub total_pages: u32,
584 pub total_results: u32,
585}
586
587#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
589pub struct DeletedManyView {
590 pub deleted: usize,
591}
592
593#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
598pub struct ClientMetadataView {
599 pub client_id: u32,
600 pub user_id: Option<u32>,
601 pub transport: u8,
602 pub address: String,
603 pub consumer_groups_count: u32,
604 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub metadata: Option<String>,
606}
607
608#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
611pub struct ClientMetadataListView {
612 pub clients: Vec<ClientMetadataView>,
613 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub next_cursor: Option<u32>,
615}
616
617#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
621pub struct ClientsQuery {
622 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
624 pub with_metadata_only: bool,
625 #[serde(default, skip_serializing_if = "Option::is_none")]
627 pub user_id: Option<u32>,
628 #[serde(default, skip_serializing_if = "Option::is_none")]
630 pub after: Option<u32>,
631 #[serde(default, skip_serializing_if = "Option::is_none")]
633 pub limit: Option<u32>,
634}
635
636#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
638pub struct PromotedView {
639 pub rows: usize,
640}
641
642#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
646pub struct GraphNodeView {
647 pub id: String,
648 #[serde(default, skip_serializing_if = "Vec::is_empty")]
649 pub labels: Vec<String>,
650 #[serde(default, skip_serializing_if = "Vec::is_empty")]
651 pub attrs: Vec<(String, String)>,
652 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub source: Option<SourceRef>,
655}
656
657#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
660pub struct GraphEdgeView {
661 pub id: String,
662 pub from: String,
663 pub to: String,
664 pub edge_type: String,
665 pub weight: f32,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
668 pub valid_from: Option<u64>,
669 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub valid_to: Option<u64>,
671 #[serde(default, skip_serializing_if = "Option::is_none")]
674 pub source: Option<SourceRef>,
675}
676
677#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
680pub struct GraphResultView {
681 #[serde(default, skip_serializing_if = "Vec::is_empty")]
682 pub nodes: Vec<GraphNodeView>,
683 #[serde(default, skip_serializing_if = "Vec::is_empty")]
684 pub edges: Vec<GraphEdgeView>,
685 #[serde(default, skip_serializing_if = "Vec::is_empty")]
686 pub paths: Vec<PathView>,
687}
688
689#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
692pub struct PathView {
693 pub nodes: Vec<String>,
694 pub edges: Vec<String>,
695}
696
697#[derive(Clone, Debug, Serialize, Deserialize)]
701pub struct RegisterSchemaBody {
702 pub source: crate::control::SchemaSource,
703 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub name: Option<String>,
705 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub version: Option<u32>,
707 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub stream: Option<String>,
711}
712
713#[derive(Clone, Debug, Serialize, Deserialize)]
716pub struct DecodeRecordBody {
717 pub payload: String,
718}
719
720#[derive(Clone, Debug, Serialize, Deserialize)]
722pub struct ForkCreateBody {
723 pub fork_id: String,
724 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub parent: Option<String>,
726 #[serde(default)]
727 pub kind: ForkKind,
728 #[serde(default, skip_serializing_if = "Vec::is_empty")]
729 pub tables: Vec<String>,
730}
731
732#[derive(Clone, Debug, Serialize, Deserialize)]
735pub struct ForkPutBody {
736 pub table: String,
737 pub partition_id: u32,
738 pub offset: u64,
739 #[serde(default)]
740 pub projection_id: String,
741 #[serde(default)]
742 pub projection_version: u32,
743 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
744 pub fields: BTreeMap<String, String>,
745 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
746 pub metadata: BTreeMap<String, String>,
747 #[serde(default, skip_serializing_if = "Option::is_none")]
748 pub payload_b64: Option<String>,
749 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub embedding: Option<String>,
751 #[serde(default)]
752 pub tombstone: bool,
753}
754
755#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
760pub struct RemoveBindingBody {
761 pub stream: String,
762 pub topic: String,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
764 pub projection_ref: Option<String>,
765}
766
767#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
776pub struct ErrorBody {
777 pub code: ResultCode,
778 pub message: String,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
780 pub detail: Option<serde_json::Value>,
781}
782
783impl ErrorBody {
784 pub fn new(code: ResultCode, message: impl Into<String>) -> Self {
786 Self {
787 code,
788 message: message.into(),
789 detail: None,
790 }
791 }
792
793 #[must_use]
795 pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
796 self.detail = Some(detail);
797 self
798 }
799
800 pub fn http_status(&self) -> u16 {
803 self.code.http_status()
804 }
805}
806
807impl From<&QueryError> for ErrorBody {
808 fn from(error: &QueryError) -> Self {
809 Self::new(ResultCode::from(error), error.to_string())
810 }
811}
812
813impl From<&crate::filter::FilterError> for ErrorBody {
814 fn from(error: &crate::filter::FilterError) -> Self {
815 Self::new(error.code, error.message.clone())
816 .with_detail(serde_json::json!({ "reason": error.reason }))
817 }
818}
819
820impl From<&KvError> for ErrorBody {
821 fn from(error: &KvError) -> Self {
822 Self::new(ResultCode::from(error), error.to_string())
823 }
824}
825
826impl From<&ForkError> for ErrorBody {
827 fn from(error: &ForkError) -> Self {
828 Self::new(ResultCode::from(error), error.to_string())
829 }
830}
831
832impl From<&crate::authz::AuthzError> for ErrorBody {
833 fn from(error: &crate::authz::AuthzError) -> Self {
834 Self::new(ResultCode::from(error), error.to_string())
835 }
836}
837
838impl From<&crate::session::SessionError> for ErrorBody {
839 fn from(error: &crate::session::SessionError) -> Self {
840 Self::new(ResultCode::from(error), error.to_string())
841 }
842}
843
844pub const PARAM_TOPIC: &str = "topic";
846pub const PARAM_NAME_CONTAINS: &str = "name_contains";
848pub const PARAM_ID_PREFIX: &str = "id_prefix";
850pub const PARAM_STREAM: &str = "stream";
853pub const PARAM_SEARCH: &str = "search";
858pub const PARAM_PREFIX: &str = "prefix";
860pub const PARAM_START: &str = "start";
862pub const PARAM_END: &str = "end";
864pub const PARAM_KEY_CONTAINS: &str = "key_contains";
866pub const PARAM_STATUS: &str = "status";
868pub const PARAM_AGENT: &str = "agent";
870pub const PARAM_TEXT: &str = "text";
872pub const PARAM_ROOT: &str = "root";
874pub const PARAM_LABEL_PREFIX: &str = "label_prefix";
876pub const PARAM_SURFACE: &str = "surface";
878pub const PARAM_AS_OF: &str = "as_of";
881pub const PARAM_SERIES: &str = "series";
884pub const PARAM_PAGE: &str = "page";
886pub const PARAM_RESULTS: &str = "results";
889pub const PARAM_HISTORY_LIMIT: &str = "history_limit";
891pub const PARAM_EXPIRES_AT_MICROS: &str = "expires_at_micros";
893pub const PARAM_EXPECT_VERSION: &str = "expect_version";
896pub const PARAM_EXPECT_ABSENT: &str = "expect_absent";
899
900pub const KV_EXPIRES_AT_MICROS_HEADER: &str = "agdx-expires-at-micros";
906
907pub const KV_VERSION_HEADER: &str = "agdx-version";
910
911pub const ON_BEHALF_OF_HEADER: &str = "agdx-on-behalf-of";
915
916#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
921pub struct ProjectionListQuery {
922 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub topic: Option<String>,
924 #[serde(default, skip_serializing_if = "Option::is_none")]
925 pub name_contains: Option<String>,
926 #[serde(default, skip_serializing_if = "Option::is_none")]
927 pub id_prefix: Option<String>,
928 #[serde(default, skip_serializing_if = "Option::is_none")]
931 pub search: Option<String>,
932 #[serde(default, skip_serializing_if = "Option::is_none")]
935 pub stream: Option<String>,
936 #[serde(default, skip_serializing_if = "Option::is_none")]
938 pub page: Option<u32>,
939 #[serde(default, skip_serializing_if = "Option::is_none")]
941 pub results: Option<u32>,
942}
943
944#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
948pub struct SchemaListQuery {
949 #[serde(default, skip_serializing_if = "Option::is_none")]
950 pub name_contains: Option<String>,
951 #[serde(default, skip_serializing_if = "Option::is_none")]
954 pub stream: Option<String>,
955 #[serde(default, skip_serializing_if = "Option::is_none")]
957 pub page: Option<u32>,
958 #[serde(default, skip_serializing_if = "Option::is_none")]
960 pub results: Option<u32>,
961}
962
963#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
966pub struct StreamQuery {
967 #[serde(default, skip_serializing_if = "Option::is_none")]
968 pub stream: Option<String>,
969}
970
971#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
975pub struct SchemaQuery {
976 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub stream: Option<String>,
978}
979
980#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
986pub struct OperationQuery {
987 #[serde(default, skip_serializing_if = "Option::is_none")]
988 pub operation_id: Option<String>,
989}
990
991impl OperationQuery {
992 pub fn operation_id(&self) -> Result<Option<NonZeroU128>, InvalidOperationId> {
994 self.operation_id
995 .as_deref()
996 .map(|text| {
997 if !text.bytes().all(|byte| byte.is_ascii_digit()) {
999 return Err(InvalidOperationId);
1000 }
1001 text.parse::<NonZeroU128>().map_err(|_| InvalidOperationId)
1002 })
1003 .transpose()
1004 }
1005}
1006
1007impl From<NonZeroU128> for OperationQuery {
1008 fn from(operation_id: NonZeroU128) -> Self {
1009 Self {
1010 operation_id: Some(operation_id.to_string()),
1011 }
1012 }
1013}
1014
1015#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
1017#[error("operation_id must be a non-zero decimal u128")]
1018pub struct InvalidOperationId;
1019
1020#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1022pub struct FilterListQuery {
1023 #[serde(default, skip_serializing_if = "Option::is_none")]
1024 pub name_contains: Option<String>,
1025 #[serde(default, skip_serializing_if = "Option::is_none")]
1026 pub state: Option<crate::filter::FilterState>,
1027 #[serde(default, skip_serializing_if = "Option::is_none")]
1029 pub owner_stream_id: Option<u32>,
1030 #[serde(default, skip_serializing_if = "Option::is_none")]
1031 pub owner_stream_created_at_micros: Option<u64>,
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1033 pub owner_topic_id: Option<u32>,
1034 #[serde(default, skip_serializing_if = "Option::is_none")]
1035 pub owner_topic_created_at_micros: Option<u64>,
1036 #[serde(default, skip_serializing_if = "Option::is_none")]
1037 pub owner_group_id: Option<u64>,
1038 #[serde(default, skip_serializing_if = "Option::is_none")]
1039 pub page: Option<u32>,
1040 #[serde(default, skip_serializing_if = "Option::is_none")]
1041 pub results: Option<u32>,
1042}
1043
1044impl FilterListQuery {
1045 pub fn owner(&self) -> Result<Option<FilterGroupIdentity>, InvalidError> {
1047 match (
1048 self.owner_stream_id,
1049 self.owner_stream_created_at_micros,
1050 self.owner_topic_id,
1051 self.owner_topic_created_at_micros,
1052 self.owner_group_id,
1053 ) {
1054 (None, None, None, None, None) => Ok(None),
1055 (
1056 Some(stream_id),
1057 Some(stream_created_at_micros),
1058 Some(topic_id),
1059 Some(topic_created_at_micros),
1060 Some(group_id),
1061 ) => Ok(Some(FilterGroupIdentity {
1062 stream_id,
1063 stream_created_at_micros,
1064 topic_id,
1065 topic_created_at_micros,
1066 group_id,
1067 })),
1068 _ => Err(InvalidError::new(
1069 "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",
1070 )),
1071 }
1072 }
1073}
1074
1075impl From<FilterGroupIdentity> for FilterListQuery {
1076 fn from(identity: FilterGroupIdentity) -> Self {
1077 Self {
1078 owner_stream_id: Some(identity.stream_id),
1079 owner_stream_created_at_micros: Some(identity.stream_created_at_micros),
1080 owner_topic_id: Some(identity.topic_id),
1081 owner_topic_created_at_micros: Some(identity.topic_created_at_micros),
1082 owner_group_id: Some(identity.group_id),
1083 ..Self::default()
1084 }
1085 }
1086}
1087
1088#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1090pub struct FilterRevisionListQuery {
1091 #[serde(default, skip_serializing_if = "Option::is_none")]
1092 pub page: Option<u32>,
1093 #[serde(default, skip_serializing_if = "Option::is_none")]
1094 pub results: Option<u32>,
1095}
1096
1097#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1099pub struct FilterBindingListQuery {
1100 #[serde(default, skip_serializing_if = "Option::is_none")]
1101 pub filter_id: Option<u32>,
1102 #[serde(default, skip_serializing_if = "Option::is_none")]
1103 pub stream: Option<String>,
1104 #[serde(default, skip_serializing_if = "Option::is_none")]
1105 pub topic: Option<String>,
1106 #[serde(default, skip_serializing_if = "Option::is_none")]
1107 pub page: Option<u32>,
1108 #[serde(default, skip_serializing_if = "Option::is_none")]
1109 pub results: Option<u32>,
1110}
1111
1112#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1115pub struct FilterTestBody {
1116 pub filter: crate::filter::FilterRef,
1117 #[serde(default, skip_serializing_if = "Option::is_none")]
1118 pub payload_base64: Option<String>,
1119 #[serde(default, skip_serializing_if = "Option::is_none")]
1120 pub payload_text: Option<String>,
1121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1122 pub headers: Vec<crate::filter::FilterHeader>,
1123}
1124
1125#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1129pub struct KvScanQuery {
1130 #[serde(default, skip_serializing_if = "Option::is_none")]
1131 pub prefix: Option<String>,
1132 #[serde(default, skip_serializing_if = "Option::is_none")]
1133 pub start: Option<String>,
1134 #[serde(default, skip_serializing_if = "Option::is_none")]
1135 pub end: Option<String>,
1136 #[serde(default, skip_serializing_if = "Option::is_none")]
1137 pub key_contains: Option<String>,
1138 #[serde(default, skip_serializing_if = "Option::is_none")]
1143 pub conversation: Option<String>,
1144 #[serde(default, skip_serializing_if = "Option::is_none")]
1146 pub page: Option<u32>,
1147 #[serde(default, skip_serializing_if = "Option::is_none")]
1149 pub results: Option<u32>,
1150}
1151
1152#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1154pub struct KvPutQuery {
1155 #[serde(default, skip_serializing_if = "Option::is_none")]
1156 pub expires_at_micros: Option<u64>,
1157}
1158
1159#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1165pub struct GraphNeighborsQuery {
1166 #[serde(default, skip_serializing_if = "Option::is_none")]
1167 pub dir: Option<String>,
1168 #[serde(default, skip_serializing_if = "Option::is_none")]
1169 pub edge_type: Option<String>,
1170 #[serde(default, skip_serializing_if = "Option::is_none")]
1171 pub depth: Option<u32>,
1172 #[serde(default, skip_serializing_if = "Option::is_none")]
1173 pub limit: Option<u32>,
1174 #[serde(default, skip_serializing_if = "Option::is_none")]
1176 pub as_of: Option<u64>,
1177 #[serde(default, skip_serializing_if = "Option::is_none")]
1181 pub conversation: Option<String>,
1182}
1183
1184#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1189pub struct SessionsQuery {
1190 #[serde(default, skip_serializing_if = "Option::is_none")]
1191 pub status: Option<String>,
1192 #[serde(default, skip_serializing_if = "Option::is_none")]
1193 pub root: Option<String>,
1194 #[serde(default, skip_serializing_if = "Option::is_none")]
1195 pub label_prefix: Option<String>,
1196 #[serde(default, skip_serializing_if = "Option::is_none")]
1197 pub agent: Option<String>,
1198 #[serde(default, skip_serializing_if = "Option::is_none")]
1199 pub text: Option<String>,
1200 #[serde(default, skip_serializing_if = "Option::is_none")]
1201 pub page: Option<u32>,
1202 #[serde(default, skip_serializing_if = "Option::is_none")]
1203 pub results: Option<u32>,
1204 #[serde(default, skip_serializing_if = "Option::is_none")]
1205 pub as_of: Option<u64>,
1206 #[serde(default, skip_serializing_if = "Option::is_none")]
1207 pub series: Option<u64>,
1208}
1209
1210#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1214pub struct SessionEventsQuery {
1215 #[serde(default, skip_serializing_if = "Option::is_none")]
1216 pub page: Option<u32>,
1217 #[serde(default, skip_serializing_if = "Option::is_none")]
1218 pub results: Option<u32>,
1219 #[serde(default, skip_serializing_if = "Option::is_none")]
1220 pub as_of: Option<u64>,
1221 #[serde(default, skip_serializing_if = "Option::is_none")]
1222 pub series: Option<u64>,
1223}
1224
1225#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1228pub struct SessionStateQuery {
1229 #[serde(default, skip_serializing_if = "Option::is_none")]
1230 pub history_limit: Option<u32>,
1231}
1232
1233#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1235pub struct SessionLinksQuery {
1236 #[serde(default, skip_serializing_if = "Option::is_none")]
1237 pub surface: Option<String>,
1238}
1239
1240#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1244pub struct SessionChangesQuery {
1245 #[serde(default, skip_serializing_if = "Option::is_none")]
1246 pub page: Option<u32>,
1247 #[serde(default, skip_serializing_if = "Option::is_none")]
1248 pub results: Option<u32>,
1249}
1250
1251#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1256pub struct KvCasQuery {
1257 #[serde(default, skip_serializing_if = "Option::is_none")]
1258 pub expect_version: Option<u64>,
1259 #[serde(default, skip_serializing_if = "Option::is_none")]
1260 pub expect_absent: Option<bool>,
1261 #[serde(default, skip_serializing_if = "Option::is_none")]
1262 pub expires_at_micros: Option<u64>,
1263}
1264
1265#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1268pub struct CasCommittedView {
1269 pub version: u64,
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274 use super::*;
1275
1276 #[test]
1277 fn given_a_filter_list_query_when_the_owner_is_complete_or_partial_then_should_resolve_or_refuse()
1278 {
1279 let identity = FilterGroupIdentity {
1280 stream_id: 1,
1281 stream_created_at_micros: 10,
1282 topic_id: 2,
1283 topic_created_at_micros: 20,
1284 group_id: 3,
1285 };
1286 let query = FilterListQuery::from(identity);
1287 assert_eq!(query.owner().expect("complete owner"), Some(identity));
1288 assert_eq!(FilterListQuery::default().owner().expect("no owner"), None);
1289 let partial = FilterListQuery {
1290 owner_group_id: None,
1291 ..query
1292 };
1293 assert!(partial.owner().is_err());
1294 }
1295
1296 #[test]
1297 fn given_path_builders_when_rendered_then_should_match_the_router() {
1298 assert_eq!(CAPABILITIES_PATH, "/agdx/capabilities");
1299 assert_eq!(
1300 projection_path("reading.v1"),
1301 "/agdx/projections/reading.v1"
1302 );
1303 assert_eq!(schema_path(7), "/agdx/schemas/7");
1304 assert_eq!(schema_decode_path(7), "/agdx/schemas/7/decode");
1305 assert_eq!(kv_namespace_path("sessions"), "/agdx/kv/sessions");
1306 assert_eq!(
1307 kv_entry_path("sessions", "dXNlcjo0Mg"),
1308 "/agdx/kv/sessions/dXNlcjo0Mg"
1309 );
1310 assert_eq!(
1311 kv_cas_path("sessions", "dXNlcjo0Mg"),
1312 "/agdx/kv/sessions/dXNlcjo0Mg/cas"
1313 );
1314 assert_eq!(fork_path("f1"), "/agdx/forks/f1");
1315 assert_eq!(fork_promote_path("f1"), "/agdx/forks/f1/promote");
1316 assert_eq!(fork_rows_path("f1"), "/agdx/forks/f1/rows");
1317 }
1318
1319 #[test]
1320 fn given_an_operation_id_when_rendered_and_parsed_then_should_round_trip() {
1321 let operation_id = NonZeroU128::new(u128::MAX).expect("non-zero");
1322 let query = OperationQuery::from(operation_id);
1323 assert_eq!(
1324 query.operation_id.as_deref(),
1325 Some("340282366920938463463374607431768211455")
1326 );
1327 assert_eq!(query.operation_id(), Ok(Some(operation_id)));
1328 assert_eq!(OperationQuery::default().operation_id(), Ok(None));
1329 }
1330
1331 #[test]
1332 fn given_a_malformed_operation_id_when_parsed_then_should_refuse_it() {
1333 for text in [
1334 "",
1335 "0",
1336 "+7",
1337 "-7",
1338 " 7",
1339 "0x7",
1340 "7.0",
1341 "340282366920938463463374607431768211456",
1342 ] {
1343 let query = OperationQuery {
1344 operation_id: Some(text.to_owned()),
1345 };
1346 assert_eq!(query.operation_id(), Err(InvalidOperationId), "{text:?}");
1347 }
1348 }
1349
1350 #[test]
1351 fn given_stream_scoped_names_when_rendered_then_should_encode_each_as_one_segment() {
1352 assert_eq!(
1353 kv_namespace_path("stream:acme/support"),
1354 "/agdx/kv/stream%3Aacme%2Fsupport"
1355 );
1356 assert_eq!(
1357 kv_cas_path("stream:acme/support", "dXNlcjo0Mg"),
1358 "/agdx/kv/stream%3Aacme%2Fsupport/dXNlcjo0Mg/cas"
1359 );
1360 assert_eq!(
1361 graph_neighbors_path("stream:acme/kg", "a/b"),
1362 "/agdx/graph/stream%3Aacme%2Fkg/neighbors/a%2Fb"
1363 );
1364 assert_eq!(
1365 graph_query_path("stream:acme/kg"),
1366 "/agdx/graph/stream%3Aacme%2Fkg/query"
1367 );
1368 assert_eq!(
1369 graph_path("stream:acme/kg"),
1370 "/agdx/graphs/stream%3Aacme%2Fkg"
1371 );
1372 assert_eq!(
1373 projection_path("stream:acme/readings"),
1374 "/agdx/projections/stream%3Aacme%2Freadings"
1375 );
1376 }
1377
1378 #[test]
1379 fn given_the_sessions_feature_when_built_from_versions_then_should_advertise_sessions() {
1380 let mut versions = OpVersions::new(1, 1, 1, 1);
1381 assert!(!Capabilities::from_versions(true, versions).sessions);
1382 versions.features |= crate::hello::feature::SESSIONS;
1383 assert!(Capabilities::from_versions(true, versions).sessions);
1384 assert!(!Capabilities::from_versions(false, versions).sessions);
1385 }
1386
1387 #[test]
1388 fn given_capabilities_when_constructed_then_extended_features_default_off() {
1389 let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1390 assert!(
1391 caps.query.available && caps.kv.available && caps.fork,
1392 "core surfaces track enabled"
1393 );
1394 assert!(
1395 !caps.kv.cas
1396 && !caps.kv.cas_fenced
1397 && !caps.kv.fenced_leases
1398 && caps.query.consistency == Consistency::Eventual,
1399 "sub-features must be opt-in, never on by default"
1400 );
1401 let opted = caps
1402 .with_kv_cas(true)
1403 .with_query_consistency(Consistency::ReadYourWrites);
1404 assert!(opted.kv.cas && opted.query.consistency == Consistency::ReadYourWrites);
1405 }
1406
1407 #[test]
1408 fn given_the_fenced_lease_bit_when_building_http_capabilities_then_should_advertise_it() {
1409 let versions =
1410 OpVersions::new(1, 1, 1, 1).with_features(crate::hello::feature::KV_FENCED_LEASES);
1411 let caps = Capabilities::from_versions(true, versions);
1412 assert!(caps.kv.fenced_leases);
1413 assert!(
1414 caps.kv.cas_fenced,
1415 "the full fenced-lease contract subsumes fenced CAS"
1416 );
1417 }
1418
1419 #[test]
1420 fn given_group_reads_without_evaluation_when_building_http_capabilities_then_should_keep_catalog_available()
1421 {
1422 let versions = OpVersions::new(1, 1, 1, 1)
1423 .with_filter(crate::codes::FILTER_OP_VERSION)
1424 .with_features(crate::hello::feature::GROUP_POLICY_READS);
1425 let caps = Capabilities::from_versions(true, versions);
1426 assert!(caps.filters.group_policy_reads);
1427 assert!(caps.filters.catalog && caps.filters.managed_groups);
1428 assert!(!caps.filters.native && !caps.filters.preview);
1429 assert_eq!(caps.filters.evaluator_version, 0);
1430 assert!(caps.filters.codecs.is_empty());
1431 let absent = Capabilities::from_versions(false, OpVersions::new(1, 1, 1, 1));
1432 assert!(!absent.filters.group_policy_reads && !absent.filters.catalog);
1433 }
1434
1435 #[test]
1436 fn given_capabilities_backends_when_json_round_tripped_then_should_preserve_and_omit_empty() {
1437 use crate::destination::BackendResourceId;
1438 use crate::hello::{BackendDescriptor, BackendImplementation, BackendMode};
1439 let caps = Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_backends(vec![
1440 BackendDescriptor::new(
1441 BackendResourceId::from_u128(1),
1442 BackendMode::Operational,
1443 "Embedded",
1444 BackendImplementation {
1445 kind: "embedded".to_owned(),
1446 version: "1.0.0".to_owned(),
1447 },
1448 1,
1449 1,
1450 ),
1451 BackendDescriptor::new(
1452 BackendResourceId::from_u128(2),
1453 BackendMode::Operational,
1454 "Warehouse",
1455 BackendImplementation {
1456 kind: "columnar".to_owned(),
1457 version: "1.0.0".to_owned(),
1458 },
1459 1,
1460 1,
1461 ),
1462 ]);
1463 let json = serde_json::to_string(&caps).expect("serializes");
1464 let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1465 assert_eq!(back.backends.len(), 2);
1466 assert_eq!(
1467 back.backends[1].resource_id,
1468 BackendResourceId::from_u128(2)
1469 );
1470 assert_eq!(back.backends[1].implementation.kind, "columnar");
1471
1472 let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1475 let json = serde_json::to_string(&plain).expect("json");
1476 assert!(!json.contains("backends"), "empty backends omitted: {json}");
1477 }
1478
1479 #[test]
1480 fn given_capabilities_topology_when_json_round_tripped_then_should_preserve_and_omit_absent() {
1481 let custom = WireTopology {
1482 ops_stream: "custom-ops".to_owned(),
1483 ..WireTopology::default()
1484 };
1485 let caps =
1486 Capabilities::new(true, OpVersions::new(1, 1, 1, 1)).with_topology(custom.clone());
1487 let json = serde_json::to_string(&caps).expect("serializes");
1488 let back: Capabilities = serde_json::from_str(&json).expect("deserializes");
1489 assert_eq!(back.topology, Some(custom));
1490
1491 let plain = Capabilities::new(true, OpVersions::new(1, 1, 1, 1));
1494 let json = serde_json::to_string(&plain).expect("json");
1495 assert!(
1496 !json.contains("topology"),
1497 "absent topology omitted: {json}"
1498 );
1499 }
1500
1501 #[test]
1502 fn given_a_typed_error_when_made_into_a_body_then_should_carry_code_and_message() {
1503 let body = ErrorBody::from(&QueryError::IndexNotFound("readings".to_owned()));
1504 assert_eq!(body.code, ResultCode::NotFound);
1505 assert_eq!(body.http_status(), 404);
1506 assert!(body.message.contains("readings"));
1507 let json = serde_json::to_string(&body).expect("serializes");
1509 let back: ErrorBody = serde_json::from_str(&json).expect("deserializes");
1510 assert_eq!(back, body);
1511 }
1512
1513 #[test]
1514 #[cfg(feature = "http-client")]
1515 fn given_scan_filters_when_url_encoded_then_should_omit_absent_fields() {
1516 let query = KvScanQuery {
1517 prefix: Some("dXNlcjo".to_owned()),
1518 page: Some(2),
1519 results: Some(50),
1520 ..Default::default()
1521 };
1522 let encoded = serde_urlencoded::to_string(&query).expect("encodes");
1523 assert_eq!(encoded, "prefix=dXNlcjo&page=2&results=50");
1524 assert!(encoded.contains(&format!("{PARAM_PREFIX}=")));
1526 assert!(encoded.contains(&format!("{PARAM_PAGE}=")));
1527 assert!(encoded.contains(&format!("{PARAM_RESULTS}=")));
1528 }
1529
1530 #[test]
1531 #[cfg(feature = "http-client")]
1532 fn given_names_with_reserved_characters_when_building_a_binding_path_then_should_encode_each_segment()
1533 {
1534 assert_eq!(
1535 filter_binding_path("orbit", "fleet/changes", "desk?x#1"),
1536 "/agdx/filter-bindings/orbit/fleet%2Fchanges/desk%3Fx%231"
1537 );
1538 }
1539
1540 #[cfg(feature = "http-client")]
1541 #[test]
1542 fn given_list_filters_when_url_encoded_then_field_names_match_the_param_consts() {
1543 let projections = ProjectionListQuery {
1544 name_contains: Some("reading".to_owned()),
1545 id_prefix: Some("reading.".to_owned()),
1546 ..Default::default()
1547 };
1548 let encoded = serde_urlencoded::to_string(&projections).expect("encodes");
1549 assert_eq!(encoded, "name_contains=reading&id_prefix=reading.");
1550 assert!(encoded.contains(&format!("{PARAM_NAME_CONTAINS}=")));
1551 assert!(encoded.contains(&format!("{PARAM_ID_PREFIX}=")));
1552
1553 let schemas = SchemaListQuery {
1554 name_contains: Some("Reading".to_owned()),
1555 ..SchemaListQuery::default()
1556 };
1557 assert_eq!(
1558 serde_urlencoded::to_string(&schemas).expect("encodes"),
1559 "name_contains=Reading"
1560 );
1561 let scoped = SchemaListQuery {
1562 stream: Some("acme".to_owned()),
1563 ..SchemaListQuery::default()
1564 };
1565 assert_eq!(
1566 serde_urlencoded::to_string(&scoped).expect("encodes"),
1567 format!("{PARAM_STREAM}=acme")
1568 );
1569 assert_eq!(
1570 serde_urlencoded::to_string(SchemaQuery::default()).expect("encodes"),
1571 ""
1572 );
1573 assert_eq!(
1574 serde_urlencoded::to_string(SchemaQuery {
1575 stream: Some("acme".to_owned()),
1576 })
1577 .expect("encodes"),
1578 format!("{PARAM_STREAM}=acme")
1579 );
1580 }
1581
1582 #[test]
1583 fn given_a_register_schema_body_without_a_stream_when_encoded_then_should_omit_it() {
1584 let body = RegisterSchemaBody {
1585 source: crate::control::SchemaSource::JsonSchema {
1586 schema: "{}".to_owned(),
1587 },
1588 name: None,
1589 version: None,
1590 stream: None,
1591 };
1592 let json = serde_json::to_string(&body).expect("encodes");
1593 assert!(
1594 !json.contains("stream"),
1595 "an unset stream is omitted: {json}"
1596 );
1597 let scoped: RegisterSchemaBody = serde_json::from_str(
1598 r#"{"source":{"kind":"json_schema","schema":"{}"},"stream":"acme"}"#,
1599 )
1600 .expect("decodes");
1601 assert_eq!(scoped.stream.as_deref(), Some("acme"));
1602 }
1603}