1use crate::error::LaserError;
2use crate::laser::Laser;
3use laser_wire::browse::{
4 BrowseOutcome, BrowseReply, GetProjection, GetSchema, ListProjections, ListSchemas,
5 ProjectionInfo, RegisterSchema, SchemaInfo,
6};
7use laser_wire::codes::{
8 AGDX_GET_PROJECTION_CODE, AGDX_GET_SCHEMA_CODE, AGDX_LIST_PROJECTIONS_CODE,
9 AGDX_LIST_SCHEMAS_CODE, AGDX_REGISTER_SCHEMA_CODE, QUERY_OP_VERSION,
10};
11use laser_wire::control::{
12 ControlCommand, Projection, ProjectionBinding, ProjectionId, ProjectionKind, SchemaSource,
13 SourceSelector,
14};
15use laser_wire::framing::encode_named;
16use laser_wire::query::QueryError;
17use serde::Serialize;
18
19impl Laser {
20 pub fn projections(&self) -> Projections<'_> {
24 Projections { laser: self }
25 }
26
27 pub fn bindings(&self) -> Bindings<'_> {
31 Bindings { laser: self }
32 }
33
34 pub fn schemas(&self) -> Schemas<'_> {
38 Schemas { laser: self }
39 }
40
41 async fn execute_browse(
45 &self,
46 code: u32,
47 request: &impl Serialize,
48 ) -> Result<BrowseOutcome, LaserError> {
49 let capabilities = self.capabilities().await;
50 if !capabilities.managed {
51 return Err(LaserError::unsupported(
52 "projections",
53 "projection browse is not served by this deployment",
54 ));
55 }
56 if let Some(versions) = capabilities.versions
58 && versions.query != QUERY_OP_VERSION
59 {
60 return Err(QueryError::Version {
61 expected: versions.query,
62 got: QUERY_OP_VERSION,
63 }
64 .into());
65 }
66 let payload = encode_named(request)
67 .map_err(|error| LaserError::Codec(format!("encode browse request: {error}")))?;
68 let payload = self.send_raw_with_response(code, payload).await?;
69 match crate::error::decode_managed_reply::<BrowseReply>(&payload)? {
70 BrowseReply::Ok(outcome) => Ok(outcome),
71 BrowseReply::Err(error) => Err(error.into()),
72 _ => Err(LaserError::Protocol(
73 "browse: unknown reply variant".to_owned(),
74 )),
75 }
76 }
77
78 async fn publish_scoped_control(&self, command: ControlCommand) -> Result<(), LaserError> {
80 let stream = self.resource_stream().map(str::to_owned);
81 self.publish_control_in(command, stream.as_deref()).await
82 }
83
84 fn scoped_projection_id(&self, id: &ProjectionId) -> ProjectionId {
85 ProjectionId::new(self.resource_name(id.as_str()))
86 }
87
88 fn local_projection_id(&self, id: ProjectionId) -> ProjectionId {
89 match self.local_resource_name(id.as_str()) {
90 Some(local) if local != id.as_str() => ProjectionId::new(local),
91 _ => id,
92 }
93 }
94
95 fn scoped_projection(&self, mut projection: Projection) -> Projection {
96 projection.id = self.scoped_projection_id(&projection.id);
97 projection
98 }
99
100 fn scoped_binding(&self, mut binding: ProjectionBinding) -> ProjectionBinding {
101 for id in &mut binding.allowed_projections {
102 *id = self.scoped_projection_id(id);
103 }
104 binding.default_projection = binding
105 .default_projection
106 .map(|id| self.scoped_projection_id(&id));
107 binding.index = self.resource_name(&binding.index);
108 binding
109 }
110
111 fn local_binding(&self, mut binding: ProjectionBinding) -> ProjectionBinding {
112 binding.allowed_projections = binding
113 .allowed_projections
114 .into_iter()
115 .map(|id| self.local_projection_id(id))
116 .collect();
117 binding.default_projection = binding
118 .default_projection
119 .map(|id| self.local_projection_id(id));
120 if let Some(local) = self.local_resource_name(&binding.index) {
121 binding.index = local.to_owned();
122 }
123 binding
124 }
125
126 fn local_projection_info(&self, mut info: ProjectionInfo) -> ProjectionInfo {
127 info.projection.id = self.local_projection_id(info.projection.id);
128 info.bindings = info
129 .bindings
130 .into_iter()
131 .map(|binding| self.local_binding(binding))
132 .collect();
133 info
134 }
135}
136
137pub struct Projections<'a> {
144 laser: &'a Laser,
145}
146
147impl<'a> Projections<'a> {
148 pub async fn register(&self, projection: Projection) -> Result<(), LaserError> {
158 if projection.kind == ProjectionKind::Graph {
159 return Err(LaserError::Invalid(format!(
160 "projection `{}` is a graph projection. Register it with `register_graph`",
161 projection.id
162 )));
163 }
164 let projection = self.laser.scoped_projection(projection);
165 self.laser
166 .publish_scoped_control(ControlCommand::RegisterProjection(projection))
167 .await
168 }
169
170 pub async fn drop(&self, id: impl Into<String>) -> Result<(), LaserError> {
174 let id = self.laser.resource_name(&id.into());
175 self.laser
176 .publish_scoped_control(ControlCommand::DropProjection(id))
177 .await
178 }
179
180 pub async fn register_graph(&self, projection: Projection) -> Result<(), LaserError> {
194 if projection.kind != ProjectionKind::Graph || projection.entity_schema.is_none() {
195 return Err(LaserError::Invalid(format!(
196 "projection `{}` is not a graph projection. Build it with `Projection::builder(..).graph(schema)` or register it with `register`",
197 projection.id
198 )));
199 }
200 let projection = self.laser.scoped_projection(projection);
201 self.laser
202 .publish_scoped_control(ControlCommand::RegisterGraph(projection))
203 .await
204 }
205
206 pub async fn drop_graph(&self, id: impl Into<String>) -> Result<(), LaserError> {
210 let id = self.laser.resource_name(&id.into());
211 self.laser
212 .publish_scoped_control(ControlCommand::DropGraph(id))
213 .await
214 }
215
216 pub async fn get(&self, id: impl Into<String>) -> Result<Option<ProjectionInfo>, LaserError> {
220 let request = GetProjection {
221 v: QUERY_OP_VERSION,
222 id: self.laser.resource_name(&id.into()),
223 };
224 match self
225 .laser
226 .execute_browse(AGDX_GET_PROJECTION_CODE, &request)
227 .await?
228 {
229 BrowseOutcome::Projection(info) => {
230 Ok(info.map(|info| self.laser.local_projection_info(info)))
231 }
232 _ => Err(LaserError::Protocol(
233 "get: unexpected browse outcome".to_owned(),
234 )),
235 }
236 }
237
238 pub fn list(&self) -> ProjectionsRequest<'a> {
243 ProjectionsRequest {
244 laser: self.laser,
245 topics: Vec::new(),
246 name_contains: None,
247 id_prefix: None,
248 search: None,
249 }
250 }
251}
252
253pub struct Bindings<'a> {
259 laser: &'a Laser,
260}
261
262impl Bindings<'_> {
263 pub async fn apply(&self, binding: ProjectionBinding) -> Result<(), LaserError> {
268 let binding = self.laser.scoped_binding(binding);
269 self.laser
270 .publish_scoped_control(ControlCommand::ApplyBinding(binding))
271 .await
272 }
273
274 pub async fn remove(
280 &self,
281 source: SourceSelector,
282 projection_ref: Option<String>,
283 ) -> Result<(), LaserError> {
284 let projection_ref = projection_ref.map(|id| self.laser.resource_name(&id));
285 self.laser
286 .publish_scoped_control(ControlCommand::RemoveBinding {
287 source,
288 projection_ref,
289 })
290 .await
291 }
292}
293
294pub struct Schemas<'a> {
300 laser: &'a Laser,
301}
302
303impl Schemas<'_> {
304 pub fn register(&self, source: SchemaSource) -> RegisterSchemaRequest<'_> {
311 RegisterSchemaRequest {
312 laser: self.laser,
313 source,
314 name: None,
315 version: None,
316 }
317 }
318
319 pub async fn drop(&self, id: u32) -> Result<(), LaserError> {
325 self.laser
326 .publish_scoped_control(ControlCommand::DropSchema(id))
327 .await
328 }
329
330 pub async fn get(&self, id: u32) -> Result<Option<SchemaInfo>, LaserError> {
333 let request = GetSchema {
334 v: QUERY_OP_VERSION,
335 id,
336 stream: self.laser.resource_stream().map(str::to_owned),
337 };
338 match self
339 .laser
340 .execute_browse(AGDX_GET_SCHEMA_CODE, &request)
341 .await?
342 {
343 BrowseOutcome::Schema(info) => Ok(info),
344 _ => Err(LaserError::Protocol(
345 "schema: unexpected browse outcome".to_owned(),
346 )),
347 }
348 }
349
350 pub async fn list(&self) -> Result<Vec<SchemaInfo>, LaserError> {
353 let request = ListSchemas {
354 v: QUERY_OP_VERSION,
355 name_contains: None,
356 stream: self.laser.resource_stream().map(str::to_owned),
357 };
358 match self
359 .laser
360 .execute_browse(AGDX_LIST_SCHEMAS_CODE, &request)
361 .await?
362 {
363 BrowseOutcome::Schemas(list) => Ok(list),
364 _ => Err(LaserError::Protocol(
365 "schemas: unexpected browse outcome".to_owned(),
366 )),
367 }
368 }
369}
370
371#[must_use = "call .send().await to register the schema"]
374pub struct RegisterSchemaRequest<'a> {
375 laser: &'a Laser,
376 source: SchemaSource,
377 name: Option<String>,
378 version: Option<u32>,
379}
380
381impl RegisterSchemaRequest<'_> {
382 pub fn name(mut self, name: impl Into<String>) -> Self {
385 self.name = Some(name.into());
386 self
387 }
388
389 pub fn version(mut self, version: u32) -> Self {
392 self.version = Some(version);
393 self
394 }
395
396 pub async fn send(self) -> Result<u32, LaserError> {
400 let request = RegisterSchema {
401 v: QUERY_OP_VERSION,
402 source: self.source,
403 name: self.name,
404 version: self.version,
405 stream: self.laser.resource_stream().map(str::to_owned),
406 };
407 match self
408 .laser
409 .execute_browse(AGDX_REGISTER_SCHEMA_CODE, &request)
410 .await?
411 {
412 BrowseOutcome::SchemaRegistered(id) => Ok(id),
413 _ => Err(LaserError::Protocol(
414 "register schema: unexpected outcome".to_owned(),
415 )),
416 }
417 }
418}
419
420#[must_use = "call .fetch().await to list the projections"]
424pub struct ProjectionsRequest<'a> {
425 laser: &'a Laser,
426 topics: Vec<String>,
427 name_contains: Option<String>,
428 id_prefix: Option<String>,
429 search: Option<String>,
430}
431
432impl<'a> ProjectionsRequest<'a> {
433 pub fn for_topic(mut self, topic: impl Into<String>) -> Self {
435 self.topics.push(topic.into());
436 self
437 }
438
439 pub fn for_topics<S: Into<String>>(mut self, topics: impl IntoIterator<Item = S>) -> Self {
441 self.topics.extend(topics.into_iter().map(Into::into));
442 self
443 }
444
445 pub fn name_contains(mut self, substring: impl Into<String>) -> Self {
447 self.name_contains = Some(substring.into());
448 self
449 }
450
451 pub fn id_prefix(mut self, prefix: impl Into<String>) -> Self {
454 self.id_prefix = Some(prefix.into());
455 self
456 }
457
458 pub fn search(mut self, substring: impl Into<String>) -> Self {
461 self.search = Some(substring.into());
462 self
463 }
464
465 pub async fn fetch(self) -> Result<Vec<ProjectionInfo>, LaserError> {
469 let id_prefix = match self.laser.resource_stream() {
470 Some(stream) => Some(laser_wire::authz::scoped_resource(
471 stream,
472 self.id_prefix.as_deref().unwrap_or_default(),
473 )),
474 None => self.id_prefix,
475 };
476 let request = ListProjections {
477 v: QUERY_OP_VERSION,
478 topics: self.topics,
479 name_contains: self.name_contains,
480 id_prefix,
481 search: self.search,
482 };
483 match self
484 .laser
485 .execute_browse(AGDX_LIST_PROJECTIONS_CODE, &request)
486 .await?
487 {
488 BrowseOutcome::Projections(list) => Ok(list
489 .into_iter()
490 .filter(|info| {
491 self.laser
492 .local_resource_name(info.projection.id.as_str())
493 .is_some()
494 })
495 .map(|info| self.laser.local_projection_info(info))
496 .collect()),
497 _ => Err(LaserError::Protocol(
498 "list: unexpected browse outcome".to_owned(),
499 )),
500 }
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use crate::laser::{Laser, ResourceNaming};
507 use laser_wire::browse::ProjectionInfo;
508 use laser_wire::control::{Projection, ProjectionBinding};
509
510 fn laser() -> Laser {
511 Laser::from_client(crate::iggy::prelude::IggyClient::default()).with_default_stream("acme")
512 }
513
514 fn binding() -> ProjectionBinding {
515 ProjectionBinding::builder()
516 .source("acme", "readings")
517 .allow("reading.v1")
518 .default_projection("reading.v1")
519 .index("readings_rows")
520 .build()
521 }
522
523 #[test]
524 fn given_a_default_stream_when_scoping_a_binding_then_should_scope_refs_and_index() {
525 let scoped = laser().scoped_binding(binding());
526 assert_eq!(
527 scoped.allowed_projections[0].as_str(),
528 "stream:acme/reading.v1"
529 );
530 assert_eq!(
531 scoped.default_projection.as_ref().map(|id| id.as_str()),
532 Some("stream:acme/reading.v1")
533 );
534 assert_eq!(scoped.index, "stream:acme/readings_rows");
535 assert_eq!(scoped.source.stream, "acme", "the source is an Iggy stream");
536 }
537
538 #[test]
539 fn given_a_scoped_projection_info_when_localized_then_should_return_the_caller_names() {
540 let laser = laser();
541 let projection = laser.scoped_projection(Projection::builder("reading.v1").build());
542 assert_eq!(projection.id.as_str(), "stream:acme/reading.v1");
543 let info = laser.local_projection_info(ProjectionInfo {
544 projection,
545 bindings: vec![laser.scoped_binding(binding())],
546 });
547 assert_eq!(info.projection.id.as_str(), "reading.v1");
548 assert_eq!(
549 info.bindings[0].allowed_projections[0].as_str(),
550 "reading.v1"
551 );
552 assert_eq!(info.bindings[0].index, "readings_rows");
553 }
554
555 #[test]
556 fn given_bare_naming_when_scoping_a_binding_then_should_leave_it_unchanged() {
557 let laser = laser().with_resource_naming(ResourceNaming::Bare);
558 assert_eq!(laser.scoped_binding(binding()), binding());
559 }
560}