1use crate::cursor::Cursor;
2use crate::error::LaserError;
3use crate::message::Message;
4use crate::stream::{
5 BatchPublishRequest, Cbor, Codec, ContentType, Decoder, Json, PublishRequest, Topic,
6};
7use crate::types::MessageId;
8use serde::Serialize;
9use serde::de::DeserializeOwned;
10use std::collections::{BTreeMap, VecDeque};
11use std::marker::PhantomData;
12
13impl Topic {
14 pub fn json<T>(&self) -> TypedTopic<T> {
19 TypedTopic::new(self.clone(), Form::Json)
20 }
21
22 pub fn cbor<T>(&self) -> TypedTopic<T> {
26 TypedTopic::new(self.clone(), Form::Cbor)
27 }
28
29 #[cfg(feature = "schema-codecs")]
38 pub async fn schema<T>(&self, schema_id: u32) -> Result<TypedTopic<T>, LaserError> {
39 let info = self
40 .laser()
41 .schemas()
42 .get(schema_id)
43 .await?
44 .ok_or_else(|| LaserError::Invalid(format!("schema {schema_id} is not registered")))?;
45 let compiled = crate::schema_codecs::CompiledSchema::compile(&info.schema)?;
46 Ok(TypedTopic::new(
47 self.clone(),
48 Form::Schema {
49 id: schema_id,
50 compiled,
51 },
52 ))
53 }
54}
55
56#[derive(Clone)]
62pub struct TypedTopic<T> {
63 topic: Topic,
64 form: Form,
65 body: PhantomData<fn() -> T>,
66}
67
68impl<T> TypedTopic<T> {
69 pub fn publish(&self, body: &T) -> Result<PublishRequest<'_>, LaserError>
75 where
76 T: Serialize,
77 {
78 let (payload, content_type, schema_id) = self.form.encode(body)?;
79 let mut request = self.topic.publish().raw_bytes(payload, content_type);
80 if let Some(id) = schema_id {
81 request = request.schema_id(id);
82 }
83 Ok(request)
84 }
85
86 pub fn publish_batch<'b>(
92 &self,
93 bodies: impl IntoIterator<Item = &'b T>,
94 ) -> Result<BatchPublishRequest<'_>, LaserError>
95 where
96 T: Serialize + 'b,
97 {
98 let mut request = self.topic.publish_batch();
99 for body in bodies {
100 let (payload, content_type, schema_id) = self.form.encode(body)?;
101 request = request.add_raw_bytes(payload, content_type);
102 if let Some(id) = schema_id {
103 request = request.schema_id(id);
104 }
105 }
106 Ok(request)
107 }
108
109 pub fn records(&self, reader_name: &str) -> Result<TypedRecords<T>, LaserError>
117 where
118 T: DeserializeOwned,
119 {
120 let cursor = self.topic.replay()?.consumer_named(reader_name)?;
121 Ok(TypedRecords {
122 cursor,
123 buffered: VecDeque::new(),
124 form: self.form.clone(),
125 body: PhantomData,
126 })
127 }
128
129 pub fn topic(&self) -> &Topic {
132 &self.topic
133 }
134
135 fn new(topic: Topic, form: Form) -> Self {
136 Self {
137 topic,
138 form,
139 body: PhantomData,
140 }
141 }
142}
143
144pub struct TypedRecords<T> {
156 cursor: Cursor,
157 buffered: VecDeque<Message>,
158 form: Form,
159 body: PhantomData<fn() -> T>,
160}
161
162impl<T: DeserializeOwned> TypedRecords<T> {
163 #[must_use]
166 pub fn from_offsets(mut self, offsets: Vec<u64>) -> Self {
167 self.cursor = self.cursor.from_offsets(offsets);
168 self
169 }
170
171 #[must_use]
173 pub fn batch(mut self, batch: u32) -> Self {
174 self.cursor = self.cursor.batch(batch);
175 self
176 }
177
178 pub fn offsets(&self) -> &[u64] {
181 self.cursor.offsets()
182 }
183
184 pub async fn next(&mut self) -> Option<Result<TypedRecord<T>, TypedDecodeError>> {
190 if self.buffered.is_empty() {
191 match self.cursor.poll().await {
192 Ok(messages) => self.buffered.extend(messages),
193 Err(error) => {
194 return Some(Err(TypedDecodeError {
195 position: None,
196 source: Box::new(error),
197 }));
198 }
199 }
200 }
201 let message = self.buffered.pop_front()?;
202 Some(decode_message(&self.form, message))
203 }
204
205 pub fn stream(self) -> impl futures::Stream<Item = Result<TypedRecord<T>, TypedDecodeError>> {
211 futures::stream::unfold(self, |mut records| async move {
212 records.next().await.map(|item| (item, records))
213 })
214 }
215
216 pub async fn poll(
222 &mut self,
223 ) -> Result<Vec<Result<TypedRecord<T>, TypedDecodeError>>, LaserError> {
224 let mut decoded: Vec<_> = self
225 .buffered
226 .drain(..)
227 .map(|message| decode_message(&self.form, message))
228 .collect();
229 if decoded.is_empty() {
230 decoded = self
231 .cursor
232 .poll()
233 .await?
234 .into_iter()
235 .map(|message| decode_message(&self.form, message))
236 .collect();
237 }
238 Ok(decoded)
239 }
240}
241
242#[derive(Clone, Debug)]
245pub struct TypedRecord<T> {
246 pub value: T,
248 pub position: MessageId,
250 pub headers: BTreeMap<String, String>,
252}
253
254#[derive(Debug, thiserror::Error)]
259pub struct TypedDecodeError {
260 pub position: Option<MessageId>,
262 pub source: Box<LaserError>,
265}
266
267impl From<TypedDecodeError> for LaserError {
270 fn from(error: TypedDecodeError) -> Self {
271 match error.position {
272 Some(_) => LaserError::Codec(error.to_string()),
273 None => *error.source,
274 }
275 }
276}
277
278impl std::fmt::Display for TypedDecodeError {
279 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 match &self.position {
281 Some(position) => write!(
282 formatter,
283 "record at {position} does not decode: {}",
284 self.source
285 ),
286 None => write!(formatter, "typed read failed to poll: {}", self.source),
287 }
288 }
289}
290
291fn decode_message<T: DeserializeOwned>(
292 form: &Form,
293 message: Message,
294) -> Result<TypedRecord<T>, TypedDecodeError> {
295 match form.decode::<T>(&message.payload) {
296 Ok(value) => Ok(TypedRecord {
297 value,
298 position: message.id,
299 headers: message.headers,
300 }),
301 Err(source) => Err(TypedDecodeError {
302 position: Some(message.id),
303 source: Box::new(source),
304 }),
305 }
306}
307
308#[derive(Clone)]
312enum Form {
313 Json,
314 Cbor,
315 #[cfg(feature = "schema-codecs")]
316 Schema {
317 id: u32,
318 compiled: crate::schema_codecs::CompiledSchema,
319 },
320}
321
322impl Form {
323 fn encode<T: Serialize>(
324 &self,
325 body: &T,
326 ) -> Result<(Vec<u8>, ContentType, Option<u32>), LaserError> {
327 match self {
328 Self::Json => Ok((Json::encode(body)?, ContentType::Json, None)),
329 Self::Cbor => Ok((Cbor::encode(body)?, ContentType::Cbor, None)),
330 #[cfg(feature = "schema-codecs")]
331 Self::Schema { id, compiled } => {
332 use crate::schema_codecs::CompiledSchema;
333 match compiled {
334 CompiledSchema::Avro(_) => {
335 Ok((compiled.encode_avro(body)?, ContentType::Avro, Some(*id)))
336 }
337 CompiledSchema::Json(_) => {
338 let value = serde_json::to_value(body).map_err(|error| {
339 LaserError::Codec(format!("body does not lower to JSON: {error}"))
340 })?;
341 if !compiled.validate_value(&value) {
342 return Err(LaserError::Codec(format!(
343 "body fails schema {id}'s JSON Schema"
344 )));
345 }
346 let payload = serde_json::to_vec(&value).map_err(|error| {
347 LaserError::Codec(format!("encode JSON payload: {error}"))
348 })?;
349 Ok((payload, ContentType::Json, Some(*id)))
350 }
351 CompiledSchema::Protobuf(_) => Err(LaserError::Invalid(format!(
352 "schema {id} is Protobuf: encode the body with prost and publish it via \
353 .raw_bytes(bytes, ContentType::Protobuf).schema_id({id}). The typed \
354 handle still decodes Protobuf records"
355 ))),
356 }
357 }
358 }
359 }
360
361 fn decode<T: DeserializeOwned>(&self, payload: &[u8]) -> Result<T, LaserError> {
362 match self {
363 Self::Json => Ok(<Json as Decoder<T>>::decode(payload)?),
364 Self::Cbor => Ok(<Cbor as Decoder<T>>::decode(payload)?),
365 #[cfg(feature = "schema-codecs")]
366 Self::Schema { compiled, .. } => {
367 let value = compiled.decode(payload)?;
368 serde_json::from_value(value).map_err(|error| {
369 LaserError::Codec(format!(
370 "decoded record does not fit the body type: {error}"
371 ))
372 })
373 }
374 }
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use serde::Deserialize;
382
383 #[derive(Debug, PartialEq, Serialize, Deserialize)]
384 struct Reading {
385 host: String,
386 cpu: i64,
387 }
388
389 fn reading() -> Reading {
390 Reading {
391 host: "node-7".to_owned(),
392 cpu: 42,
393 }
394 }
395
396 #[test]
397 fn given_typed_bodies_when_batched_then_should_add_one_record_per_body() {
398 let laser = crate::laser::Laser::from_client(iggy::prelude::IggyClient::default());
399 let readings = laser.topic("readings").json::<Reading>();
400 let batch = readings
401 .publish_batch(&[reading(), reading()])
402 .expect("bodies encode");
403 assert_eq!(batch.len(), 2);
404 assert!(readings.publish_batch(&[]).expect("empty").is_empty());
405 }
406
407 #[test]
408 fn given_the_json_form_when_encoded_then_should_round_trip_with_the_json_tag() {
409 let form = Form::Json;
410 let (payload, content_type, schema_id) = form.encode(&reading()).expect("body encodes");
411 assert_eq!(content_type, ContentType::Json);
412 assert_eq!(schema_id, None);
413 let back: Reading = form.decode(&payload).expect("payload decodes");
414 assert_eq!(back, reading());
415 }
416
417 #[test]
418 fn given_the_cbor_form_when_encoded_then_should_round_trip_with_the_cbor_tag() {
419 let form = Form::Cbor;
420 let (payload, content_type, schema_id) = form.encode(&reading()).expect("body encodes");
421 assert_eq!(content_type, ContentType::Cbor);
422 assert_eq!(schema_id, None);
423 let back: Reading = form.decode(&payload).expect("payload decodes");
424 assert_eq!(back, reading());
425 }
426
427 #[test]
428 fn given_a_wrong_payload_when_decoded_then_should_fail_as_codec() {
429 let form = Form::Json;
430 let result: Result<Reading, _> = form.decode(b"not json");
431 assert!(matches!(result, Err(LaserError::Codec(_))));
432 }
433
434 #[cfg(feature = "schema-codecs")]
435 mod schema_bound {
436 use super::*;
437 use crate::query::{SchemaDef, SchemaSource};
438 use crate::schema_codecs::CompiledSchema;
439
440 const READING_AVRO: &str = r#"{
441 "type":"record","name":"Reading",
442 "fields":[
443 {"name":"host","type":"string"},
444 {"name":"cpu","type":"long"}
445 ]
446 }"#;
447
448 fn schema_form(source: SchemaSource) -> Form {
449 let compiled = CompiledSchema::compile(&SchemaDef {
450 id: 7,
451 source,
452 name: None,
453 version: None,
454 })
455 .expect("schema compiles");
456 Form::Schema { id: 7, compiled }
457 }
458
459 #[test]
460 fn given_an_avro_schema_when_encoded_then_should_round_trip_stamping_the_id() {
461 let form = schema_form(SchemaSource::Avro {
462 schema: READING_AVRO.to_owned(),
463 });
464 let (payload, content_type, schema_id) = form.encode(&reading()).expect("body encodes");
465 assert_eq!(content_type, ContentType::Avro);
466 assert_eq!(schema_id, Some(7));
467 let back: Reading = form.decode(&payload).expect("datum decodes");
468 assert_eq!(back, reading());
469 }
470
471 #[test]
472 fn given_a_json_schema_when_the_body_fails_it_then_should_refuse_before_send() {
473 let form = schema_form(SchemaSource::JsonSchema {
474 schema: r#"{
475 "type":"object",
476 "required":["host","cpu"],
477 "properties":{"cpu":{"type":"integer","minimum":100}}
478 }"#
479 .to_owned(),
480 });
481 assert!(matches!(form.encode(&reading()), Err(LaserError::Codec(_))));
482 }
483
484 #[test]
485 fn given_a_json_schema_when_the_body_passes_then_should_stamp_json_plus_id() {
486 let form = schema_form(SchemaSource::JsonSchema {
487 schema: r#"{"type":"object","required":["host","cpu"]}"#.to_owned(),
488 });
489 let (payload, content_type, schema_id) = form.encode(&reading()).expect("body encodes");
490 assert_eq!(content_type, ContentType::Json);
491 assert_eq!(schema_id, Some(7));
492 let back: Reading = form.decode(&payload).expect("payload decodes");
493 assert_eq!(back, reading());
494 }
495 }
496
497 #[test]
498 fn given_a_positioned_failure_when_displayed_then_should_name_the_record() {
499 let error = TypedDecodeError {
500 position: Some(MessageId::new(2, 17)),
501 source: Box::new(LaserError::Codec("bad".to_owned())),
502 };
503 assert!(error.to_string().contains("2:17"));
504 }
505}