1use crate::error::LaserError;
2use crate::stream::{
3 CONTENT_TYPE, ContentType, IDX_PREFIX, INLINE_PAYLOAD, LOGICAL_SCHEMA_FINGERPRINT,
4 MAX_INDEX_ENTRIES_PER_RECORD, PROJECTION_REF, SCHEMA_ID,
5};
6use iggy::prelude::{HeaderKey, HeaderValue};
7use laser_wire::headers::{HEADER_FRAMING_BYTES, HEADER_SOFT_CAP, HEADER_VALUE_MAX};
8use std::collections::BTreeMap;
9use std::str::FromStr;
10
11#[derive(Clone, Debug, Default, bon::Builder)]
13pub struct Record {
14 #[builder(field)]
16 pub index: Vec<(String, String)>,
17 #[builder(field)]
21 pub metadata: Vec<(String, String)>,
22 #[builder(field)]
29 pub inline_payload: bool,
30 pub content_type: Option<ContentType>,
37 #[builder(into)]
43 pub projection_ref: Option<String>,
44 pub schema_id: Option<u32>,
48 pub logical_schema_fingerprint: Option<laser_wire::schema::SchemaFingerprint>,
50}
51
52impl<S: record_builder::State> RecordBuilder<S> {
53 pub fn index(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
59 self.index.push((key.into(), value.into()));
60 self
61 }
62
63 pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
65 self.metadata.push((key.into(), value.into()));
66 self
67 }
68
69 pub fn inline_payload(mut self) -> Self {
72 self.inline_payload = true;
73 self
74 }
75}
76
77impl TryFrom<&Record> for BTreeMap<HeaderKey, HeaderValue> {
78 type Error = LaserError;
79
80 fn try_from(record: &Record) -> Result<Self, Self::Error> {
81 let mut map: BTreeMap<HeaderKey, HeaderValue> = BTreeMap::new();
82 if let Some(content_type) = record.content_type {
86 map.insert(
87 HeaderKey::from_str(CONTENT_TYPE)?,
88 HeaderValue::from(content_type.code()),
89 );
90 }
91 if let Some(ref projection_ref) = record.projection_ref {
92 put_header(&mut map, PROJECTION_REF, projection_ref)?;
93 }
94 if let Some(schema_id) = record.schema_id {
95 map.insert(
96 HeaderKey::from_str(SCHEMA_ID)?,
97 HeaderValue::from(schema_id),
98 );
99 }
100 if record.content_type == Some(ContentType::Arrow) {
101 let fingerprint = record.logical_schema_fingerprint.as_ref().ok_or_else(|| {
102 LaserError::Invalid(
103 "Arrow IPC records require a logical schema fingerprint".to_owned(),
104 )
105 })?;
106 laser_wire::validate::Validate::validate(fingerprint)?;
107 map.insert(
108 HeaderKey::from_str(LOGICAL_SCHEMA_FINGERPRINT)?,
109 HeaderValue::try_from(fingerprint.as_bytes())?,
110 );
111 } else if record.logical_schema_fingerprint.is_some() {
112 return Err(LaserError::Invalid(
113 "a logical schema fingerprint is valid only for Arrow IPC records".to_owned(),
114 ));
115 }
116 if record.inline_payload {
117 map.insert(
118 HeaderKey::from_str(INLINE_PAYLOAD)?,
119 HeaderValue::from(true),
120 );
121 }
122 if record.index.len() > MAX_INDEX_ENTRIES_PER_RECORD {
123 return Err(LaserError::Invalid(format!(
124 "record has {} indexed scalars, exceeds cap of {MAX_INDEX_ENTRIES_PER_RECORD}",
125 record.index.len()
126 )));
127 }
128 for (key, value) in &record.index {
129 if key.is_empty() {
130 return Err(LaserError::Invalid(
131 "index key must not be empty".to_owned(),
132 ));
133 }
134 if key.starts_with(IDX_PREFIX) {
135 return Err(LaserError::Invalid(format!(
136 "index key `{key}` must not start with the reserved `agdx.idx.` prefix"
137 )));
138 }
139 put_header(&mut map, &format!("{IDX_PREFIX}{key}"), value)?;
140 }
141 for (key, value) in &record.metadata {
142 if key.starts_with(IDX_PREFIX) {
143 return Err(LaserError::Invalid(format!(
144 "metadata header `{key}` collides with the `agdx.idx.` namespace - use .index() instead"
145 )));
146 }
147 if matches!(
148 key.as_str(),
149 CONTENT_TYPE
150 | SCHEMA_ID
151 | LOGICAL_SCHEMA_FINGERPRINT
152 | PROJECTION_REF
153 | INLINE_PAYLOAD
154 ) {
155 return Err(LaserError::Invalid(format!(
156 "metadata header `{key}` is reserved - set it via the dedicated builder method"
157 )));
158 }
159 put_header(&mut map, key, value)?;
160 }
161 let size: usize = map
162 .iter()
163 .map(|(k, v)| k.as_bytes().len() + v.as_bytes().len() + HEADER_FRAMING_BYTES)
164 .sum();
165 if size > HEADER_SOFT_CAP {
166 return Err(LaserError::Invalid(format!(
167 "record headers {size}B exceed soft cap {HEADER_SOFT_CAP}B"
168 )));
169 }
170 Ok(map)
171 }
172}
173
174fn put_header(
175 map: &mut BTreeMap<HeaderKey, HeaderValue>,
176 key: &str,
177 value: &str,
178) -> Result<(), LaserError> {
179 if value.is_empty() {
180 return Err(LaserError::Invalid(format!(
181 "header `{key}` value must not be empty"
182 )));
183 }
184 if value.len() > HEADER_VALUE_MAX {
185 return Err(LaserError::Invalid(format!(
186 "header `{key}` value is {}B, exceeds max {HEADER_VALUE_MAX}B",
187 value.len()
188 )));
189 }
190 map.insert(HeaderKey::from_str(key)?, HeaderValue::from_str(value)?);
191 Ok(())
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn given_a_record_when_lowered_then_should_stamp_compact_typed_headers() {
200 let record = Record::builder()
201 .content_type(ContentType::Json)
202 .schema_id(7)
203 .index("job_id", "123")
204 .index("host_id", "abc")
205 .build();
206 let headers: BTreeMap<HeaderKey, HeaderValue> =
207 (&record).try_into().expect("the record lowers to headers");
208
209 let get = |key: &str| {
210 headers
211 .get(&HeaderKey::from_str(key).expect("the key is valid"))
212 .cloned()
213 };
214 let content_type = get(CONTENT_TYPE).expect("agdx.ct stamped");
217 assert_eq!(
218 content_type.as_uint8().expect("u8 typed value"),
219 ContentType::Json.code(),
220 );
221 let schema_id = get(SCHEMA_ID).expect("agdx.sid stamped");
222 assert_eq!(schema_id.as_uint32().expect("u32 typed value"), 7);
223 let as_str =
224 |key: &str| get(key).map(|value| value.as_str().expect("utf-8 value").to_owned());
225 assert_eq!(as_str("agdx.idx.job_id").as_deref(), Some("123"));
226 assert_eq!(as_str("agdx.idx.host_id").as_deref(), Some("abc"));
227 assert_eq!(get(INLINE_PAYLOAD), None, "off by default");
228 }
229
230 #[test]
231 fn given_a_record_without_content_type_when_lowered_then_should_omit_the_header() {
232 let record = Record::builder().index("host_id", "123").build();
233 let headers: BTreeMap<HeaderKey, HeaderValue> =
234 (&record).try_into().expect("the record lowers to headers");
235 let key = HeaderKey::from_str(CONTENT_TYPE).expect("the key is valid");
236 assert!(
237 !headers.contains_key(&key),
238 "agdx.ct must NOT be stamped when Record.content_type is None",
239 );
240 }
241
242 #[test]
243 fn given_inline_payload_and_metadata_when_lowered_then_should_emit_both() {
244 let record = Record::builder()
245 .index("host_id", "123")
246 .metadata("trace_id", "abc")
247 .metadata("agdx.actor", "metrics")
248 .inline_payload()
249 .build();
250 let headers: BTreeMap<HeaderKey, HeaderValue> =
251 (&record).try_into().expect("the record lowers to headers");
252
253 let inline = headers
254 .get(&HeaderKey::from_str(INLINE_PAYLOAD).expect("the key is valid"))
255 .expect("agdx.inline stamped");
256 assert!(inline.as_bool().expect("bool typed value"));
257 let get = |key: &str| {
258 headers
259 .get(&HeaderKey::from_str(key).expect("the key is valid"))
260 .map(|value| value.as_str().expect("the value is utf-8").to_owned())
261 };
262 assert_eq!(get("trace_id").as_deref(), Some("abc"));
263 assert_eq!(get("agdx.actor").as_deref(), Some("metrics"));
264 }
265
266 #[test]
267 fn given_metadata_in_idx_namespace_when_lowered_then_should_error() {
268 let record = Record::builder().metadata("agdx.idx.smuggled", "x").build();
269 let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
270 assert!(matches!(result, Err(LaserError::Invalid(_))));
271 }
272
273 #[test]
274 fn given_metadata_for_a_reserved_key_when_lowered_then_should_error() {
275 for reserved in [CONTENT_TYPE, SCHEMA_ID, INLINE_PAYLOAD] {
276 let record = Record::builder().metadata(reserved, "smuggled").build();
277 let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
278 assert!(
279 matches!(result, Err(LaserError::Invalid(_))),
280 "reserved key `{reserved}` must not be smuggleable through metadata"
281 );
282 }
283 }
284
285 #[test]
286 fn given_a_record_with_an_empty_index_value_when_lowered_then_should_error() {
287 let record = Record::builder().index("host_id", "").build();
288 let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
289 assert!(matches!(result, Err(LaserError::Invalid(_))));
290 }
291
292 #[test]
293 fn given_a_record_with_an_oversized_index_value_when_lowered_then_should_error() {
294 let record = Record::builder()
295 .index("blob", "x".repeat(HEADER_VALUE_MAX + 1))
296 .build();
297 let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
298 assert!(matches!(result, Err(LaserError::Invalid(_))));
299 }
300
301 #[test]
302 fn given_an_arrow_record_when_lowered_then_should_stamp_the_raw_fingerprint() {
303 let fingerprint = laser_wire::schema::SchemaFingerprint::new([0xab; 32]);
304 let record = Record::builder()
305 .content_type(ContentType::Arrow)
306 .logical_schema_fingerprint(fingerprint.clone())
307 .build();
308 let headers: BTreeMap<HeaderKey, HeaderValue> = (&record)
309 .try_into()
310 .expect("the Arrow record lowers to headers");
311 let value = headers
312 .get(&HeaderKey::from_str(LOGICAL_SCHEMA_FINGERPRINT).expect("header key is valid"))
313 .expect("schema fingerprint is stamped");
314 assert_eq!(value.as_bytes(), fingerprint.as_bytes());
315 }
316
317 #[test]
318 fn given_arrow_content_without_a_fingerprint_when_lowered_then_should_error() {
319 let record = Record::builder().content_type(ContentType::Arrow).build();
320 let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
321 assert!(matches!(result, Err(LaserError::Invalid(_))));
322 }
323
324 #[test]
325 fn given_non_arrow_content_with_a_fingerprint_when_lowered_then_should_error() {
326 let record = Record::builder()
327 .content_type(ContentType::Json)
328 .logical_schema_fingerprint(laser_wire::schema::SchemaFingerprint::new([1; 32]))
329 .build();
330 let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
331 assert!(matches!(result, Err(LaserError::Invalid(_))));
332 }
333
334 #[test]
335 fn given_a_record_with_an_empty_index_key_when_lowered_then_should_error() {
336 let record = Record::builder().index("", "value").build();
337 let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
338 assert!(matches!(result, Err(LaserError::Invalid(_))));
339 }
340
341 #[test]
342 fn given_a_record_with_a_reserved_idx_prefix_key_when_lowered_then_should_error() {
343 let record = Record::builder().index("agdx.idx.shadow", "value").build();
344 let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
345 let message = match result {
346 Err(LaserError::Invalid(message)) => message,
347 other => panic!("expected an invalid error, got {other:?}"),
348 };
349 assert!(
350 message.contains("agdx.idx."),
351 "error mentions the reserved prefix: {message}"
352 );
353 }
354
355 #[test]
356 fn given_a_record_with_more_than_the_index_cap_when_lowered_then_should_error() {
357 let mut builder = Record::builder();
358 for i in 0..(MAX_INDEX_ENTRIES_PER_RECORD + 1) {
359 builder = builder.index(format!("k{i}"), format!("v{i}"));
360 }
361 let record = builder.build();
362 let result: Result<BTreeMap<HeaderKey, HeaderValue>, _> = (&record).try_into();
363 let message = match result {
364 Err(LaserError::Invalid(message)) => message,
365 other => panic!("expected an invalid error, got {other:?}"),
366 };
367 assert!(
368 message.contains(&MAX_INDEX_ENTRIES_PER_RECORD.to_string()),
369 "error mentions the cap: {message}"
370 );
371 }
372}