use crate::cursor::Cursor;
use crate::error::LaserError;
use crate::message::Message;
use crate::stream::{Cbor, Codec, ContentType, Decoder, Json, PublishRequest, Topic};
use crate::types::MessageId;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::collections::{BTreeMap, VecDeque};
use std::marker::PhantomData;
impl Topic {
pub fn json<T>(&self) -> TypedTopic<T> {
TypedTopic::new(self.clone(), Form::Json)
}
pub fn cbor<T>(&self) -> TypedTopic<T> {
TypedTopic::new(self.clone(), Form::Cbor)
}
#[cfg(feature = "schema-codecs")]
pub async fn schema<T>(&self, schema_id: u32) -> Result<TypedTopic<T>, LaserError> {
let info = self
.laser()
.schemas()
.get(schema_id)
.await?
.ok_or_else(|| LaserError::Invalid(format!("schema {schema_id} is not registered")))?;
let compiled = crate::schema_codecs::CompiledSchema::compile(&info.schema)?;
Ok(TypedTopic::new(
self.clone(),
Form::Schema {
id: schema_id,
compiled,
},
))
}
}
#[derive(Clone)]
pub struct TypedTopic<T> {
topic: Topic,
form: Form,
body: PhantomData<fn() -> T>,
}
impl<T> TypedTopic<T> {
pub fn publish(&self, body: &T) -> Result<PublishRequest<'_>, LaserError>
where
T: Serialize,
{
let (payload, content_type, schema_id) = self.form.encode(body)?;
let mut request = self.topic.publish().raw_bytes(payload, content_type);
if let Some(id) = schema_id {
request = request.schema_id(id);
}
Ok(request)
}
pub fn records(&self, reader_name: &str) -> Result<TypedRecords<T>, LaserError>
where
T: DeserializeOwned,
{
let cursor = self.topic.replay()?.consumer_named(reader_name)?;
Ok(TypedRecords {
cursor,
buffered: VecDeque::new(),
form: self.form.clone(),
body: PhantomData,
})
}
pub fn topic(&self) -> &Topic {
&self.topic
}
fn new(topic: Topic, form: Form) -> Self {
Self {
topic,
form,
body: PhantomData,
}
}
}
pub struct TypedRecords<T> {
cursor: Cursor,
buffered: VecDeque<Message>,
form: Form,
body: PhantomData<fn() -> T>,
}
impl<T: DeserializeOwned> TypedRecords<T> {
#[must_use]
pub fn from_offsets(mut self, offsets: Vec<u64>) -> Self {
self.cursor = self.cursor.from_offsets(offsets);
self
}
#[must_use]
pub fn batch(mut self, batch: u32) -> Self {
self.cursor = self.cursor.batch(batch);
self
}
pub fn offsets(&self) -> &[u64] {
self.cursor.offsets()
}
pub async fn next(&mut self) -> Option<Result<TypedRecord<T>, TypedDecodeError>> {
if self.buffered.is_empty() {
match self.cursor.poll().await {
Ok(messages) => self.buffered.extend(messages),
Err(error) => {
return Some(Err(TypedDecodeError {
position: None,
source: error,
}));
}
}
}
let message = self.buffered.pop_front()?;
Some(decode_message(&self.form, message))
}
pub fn stream(self) -> impl futures::Stream<Item = Result<TypedRecord<T>, TypedDecodeError>> {
futures::stream::unfold(self, |mut records| async move {
records.next().await.map(|item| (item, records))
})
}
pub async fn poll(
&mut self,
) -> Result<Vec<Result<TypedRecord<T>, TypedDecodeError>>, LaserError> {
let mut decoded: Vec<_> = self
.buffered
.drain(..)
.map(|message| decode_message(&self.form, message))
.collect();
if decoded.is_empty() {
decoded = self
.cursor
.poll()
.await?
.into_iter()
.map(|message| decode_message(&self.form, message))
.collect();
}
Ok(decoded)
}
}
#[derive(Clone, Debug)]
pub struct TypedRecord<T> {
pub value: T,
pub position: MessageId,
pub headers: BTreeMap<String, String>,
}
#[derive(Debug, thiserror::Error)]
pub struct TypedDecodeError {
pub position: Option<MessageId>,
pub source: LaserError,
}
impl From<TypedDecodeError> for LaserError {
fn from(error: TypedDecodeError) -> Self {
match error.position {
Some(_) => LaserError::Codec(error.to_string()),
None => error.source,
}
}
}
impl std::fmt::Display for TypedDecodeError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.position {
Some(position) => write!(
formatter,
"record at {position} does not decode: {}",
self.source
),
None => write!(formatter, "typed read failed to poll: {}", self.source),
}
}
}
fn decode_message<T: DeserializeOwned>(
form: &Form,
message: Message,
) -> Result<TypedRecord<T>, TypedDecodeError> {
match form.decode::<T>(&message.payload) {
Ok(value) => Ok(TypedRecord {
value,
position: message.id,
headers: message.headers,
}),
Err(source) => Err(TypedDecodeError {
position: Some(message.id),
source,
}),
}
}
#[derive(Clone)]
enum Form {
Json,
Cbor,
#[cfg(feature = "schema-codecs")]
Schema {
id: u32,
compiled: crate::schema_codecs::CompiledSchema,
},
}
impl Form {
fn encode<T: Serialize>(
&self,
body: &T,
) -> Result<(Vec<u8>, ContentType, Option<u32>), LaserError> {
match self {
Self::Json => Ok((Json::encode(body)?, ContentType::Json, None)),
Self::Cbor => Ok((Cbor::encode(body)?, ContentType::Cbor, None)),
#[cfg(feature = "schema-codecs")]
Self::Schema { id, compiled } => {
use crate::schema_codecs::CompiledSchema;
match compiled {
CompiledSchema::Avro(_) => {
Ok((compiled.encode_avro(body)?, ContentType::Avro, Some(*id)))
}
CompiledSchema::Json(_) => {
let value = serde_json::to_value(body).map_err(|error| {
LaserError::Codec(format!("body does not lower to JSON: {error}"))
})?;
if !compiled.validate_value(&value) {
return Err(LaserError::Codec(format!(
"body fails schema {id}'s JSON Schema"
)));
}
let payload = serde_json::to_vec(&value).map_err(|error| {
LaserError::Codec(format!("encode JSON payload: {error}"))
})?;
Ok((payload, ContentType::Json, Some(*id)))
}
CompiledSchema::Protobuf(_) => Err(LaserError::Invalid(format!(
"schema {id} is Protobuf: encode the body with prost and publish it via \
.raw_bytes(bytes, ContentType::Protobuf).schema_id({id}). The typed \
handle still decodes Protobuf records"
))),
}
}
}
}
fn decode<T: DeserializeOwned>(&self, payload: &[u8]) -> Result<T, LaserError> {
match self {
Self::Json => Ok(<Json as Decoder<T>>::decode(payload)?),
Self::Cbor => Ok(<Cbor as Decoder<T>>::decode(payload)?),
#[cfg(feature = "schema-codecs")]
Self::Schema { compiled, .. } => {
let value = compiled.decode(payload)?;
serde_json::from_value(value).map_err(|error| {
LaserError::Codec(format!(
"decoded record does not fit the body type: {error}"
))
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Order {
customer: String,
amount: i64,
}
fn order() -> Order {
Order {
customer: "alice".to_owned(),
amount: 42,
}
}
#[test]
fn given_the_json_form_when_encoded_then_should_round_trip_with_the_json_tag() {
let form = Form::Json;
let (payload, content_type, schema_id) = form.encode(&order()).expect("body encodes");
assert_eq!(content_type, ContentType::Json);
assert_eq!(schema_id, None);
let back: Order = form.decode(&payload).expect("payload decodes");
assert_eq!(back, order());
}
#[test]
fn given_the_cbor_form_when_encoded_then_should_round_trip_with_the_cbor_tag() {
let form = Form::Cbor;
let (payload, content_type, schema_id) = form.encode(&order()).expect("body encodes");
assert_eq!(content_type, ContentType::Cbor);
assert_eq!(schema_id, None);
let back: Order = form.decode(&payload).expect("payload decodes");
assert_eq!(back, order());
}
#[test]
fn given_a_wrong_payload_when_decoded_then_should_fail_as_codec() {
let form = Form::Json;
let result: Result<Order, _> = form.decode(b"not json");
assert!(matches!(result, Err(LaserError::Codec(_))));
}
#[cfg(feature = "schema-codecs")]
mod schema_bound {
use super::*;
use crate::query::{SchemaDef, SchemaSource};
use crate::schema_codecs::CompiledSchema;
const ORDER_AVRO: &str = r#"{
"type":"record","name":"Order",
"fields":[
{"name":"customer","type":"string"},
{"name":"amount","type":"long"}
]
}"#;
fn schema_form(source: SchemaSource) -> Form {
let compiled = CompiledSchema::compile(&SchemaDef {
id: 7,
source,
name: None,
version: None,
})
.expect("schema compiles");
Form::Schema { id: 7, compiled }
}
#[test]
fn given_an_avro_schema_when_encoded_then_should_round_trip_stamping_the_id() {
let form = schema_form(SchemaSource::Avro {
schema: ORDER_AVRO.to_owned(),
});
let (payload, content_type, schema_id) = form.encode(&order()).expect("body encodes");
assert_eq!(content_type, ContentType::Avro);
assert_eq!(schema_id, Some(7));
let back: Order = form.decode(&payload).expect("datum decodes");
assert_eq!(back, order());
}
#[test]
fn given_a_json_schema_when_the_body_fails_it_then_should_refuse_before_send() {
let form = schema_form(SchemaSource::JsonSchema {
schema: r#"{
"type":"object",
"required":["customer","amount"],
"properties":{"amount":{"type":"integer","minimum":100}}
}"#
.to_owned(),
});
assert!(matches!(form.encode(&order()), Err(LaserError::Codec(_))));
}
#[test]
fn given_a_json_schema_when_the_body_passes_then_should_stamp_json_plus_id() {
let form = schema_form(SchemaSource::JsonSchema {
schema: r#"{"type":"object","required":["customer","amount"]}"#.to_owned(),
});
let (payload, content_type, schema_id) = form.encode(&order()).expect("body encodes");
assert_eq!(content_type, ContentType::Json);
assert_eq!(schema_id, Some(7));
let back: Order = form.decode(&payload).expect("payload decodes");
assert_eq!(back, order());
}
}
#[test]
fn given_a_positioned_failure_when_displayed_then_should_name_the_record() {
let error = TypedDecodeError {
position: Some(MessageId::new(2, 17)),
source: LaserError::Codec("bad".to_owned()),
};
assert!(error.to_string().contains("2:17"));
}
}