ruststream_sqlx/header_column.rs
1//! The type of a field playing `headers`, shared by the inbox and the outbox.
2
3#[cfg(feature = "json")]
4use std::collections::BTreeMap;
5#[cfg(feature = "json")]
6use std::mem;
7
8use ruststream::HeaderMap;
9#[cfg(feature = "json")]
10use sqlx::types::Json;
11
12/// The type of a field playing `headers`: what the delivery's header map holds.
13///
14/// `sqlx::types::Json<BTreeMap<String, String>>` implements it under the `json` feature (a
15/// `jsonb` or `json` column of string values), and so does an `Option` of any implementation.
16/// A publish carrying a header the column cannot hold byte for byte is refused before the
17/// service's [`Publish`](crate::Publish) runs: a header never reaches the table changed.
18///
19/// # Examples
20///
21/// ```
22/// # #[cfg(all(feature = "inbox", feature = "postgres"))]
23/// # mod demo {
24/// use ruststream::HeaderMap;
25/// use ruststream_sqlx::prelude::*;
26/// use ruststream_sqlx::HeaderColumn;
27/// use serde::Deserialize;
28/// use sqlx::PgPool;
29///
30/// /// Headers stored one per line, `name: value`, in a `TEXT` column.
31/// #[derive(sqlx::Type)]
32/// #[sqlx(transparent)]
33/// pub struct Lines(String);
34///
35/// impl HeaderColumn for Lines {
36/// fn take_headers(&mut self) -> HeaderMap {
37/// let headers = self
38/// .0
39/// .lines()
40/// .filter_map(|line| line.split_once(": "))
41/// .map(|(name, value)| (name.to_owned(), value.to_owned()))
42/// .collect();
43/// self.0.clear();
44/// headers
45/// }
46///
47/// fn from_headers(headers: &HeaderMap) -> Self {
48/// let lines: Vec<String> = headers
49/// .iter()
50/// .map(|(name, value)| format!("{name}: {}", String::from_utf8_lossy(value)))
51/// .collect();
52/// Self(lines.join("\n"))
53/// }
54///
55/// fn unfit(headers: &HeaderMap) -> Option<&str> {
56/// // A value comes back as it went in when it is text on one line.
57/// let fits = |value: &[u8]| str::from_utf8(value).is_ok_and(|text| !text.contains('\n'));
58/// headers
59/// .iter()
60/// .find(|(_, value)| !fits(value))
61/// .map(|(name, _)| name)
62/// }
63/// }
64///
65/// #[derive(Inbox, sqlx::FromRow)]
66/// #[inbox(table = "jobs")]
67/// pub struct Job {
68/// #[field(id, generated)]
69/// id: i64,
70/// #[field(headers)]
71/// headers: Lines,
72/// #[field(payload)]
73/// payload: Vec<u8>,
74/// }
75///
76/// #[derive(Deserialize)]
77/// struct Task {
78/// n: u32,
79/// }
80///
81/// // The delivery's headers come from `headers`, so the service's middleware reads `x-tenant`
82/// // there as on any broker.
83/// #[subscriber(InboxQueue::<Job>::new("tasks"))]
84/// async fn run(task: &Task) -> HandlerOutcome {
85/// tracing::info!(task.n, "running");
86/// HandlerOutcome::ack()
87/// }
88///
89/// pub fn app(pool: PgPool) -> RustStream {
90/// RustStream::new(AppInfo::new("worker", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
91/// b.include(run);
92/// })
93/// }
94/// # }
95/// # fn main() {}
96/// ```
97pub trait HeaderColumn {
98 /// Moves the headers the column holds into a header map, leaving the column empty.
99 ///
100 /// A delivery takes them once, when its row is claimed, so a column that owns its strings
101 /// hands them over without a copy.
102 fn take_headers(&mut self) -> HeaderMap;
103
104 /// The column value that holds `headers`, for a service's `Publish`.
105 ///
106 /// # Examples
107 ///
108 /// ```
109 /// # #[cfg(all(feature = "postgres", feature = "json"))]
110 /// # mod demo {
111 /// use std::collections::BTreeMap;
112 ///
113 /// use ruststream::OutgoingMessage;
114 /// use ruststream_sqlx::{HeaderColumn, Inbox, Publish};
115 /// use sqlx::types::Json;
116 /// use sqlx::{PgConnection, Postgres};
117 ///
118 /// #[derive(Inbox, sqlx::FromRow)]
119 /// #[inbox(table = "jobs")]
120 /// pub struct Job {
121 /// #[field(id, generated)]
122 /// id: i64,
123 /// #[field(headers)]
124 /// headers: Json<BTreeMap<String, String>>,
125 /// #[field(payload)]
126 /// payload: Vec<u8>,
127 /// }
128 ///
129 /// impl Publish<Postgres> for Job {
130 /// async fn publish(
131 /// conn: &mut PgConnection,
132 /// message: &OutgoingMessage<'_>,
133 /// ) -> Result<(), sqlx::Error> {
134 /// // The `jsonb` column holds the message's headers as the delivery will read them back.
135 /// let headers = Json::<BTreeMap<String, String>>::from_headers(message.headers());
136 /// sqlx::query("INSERT INTO jobs (headers, payload) VALUES ($1, $2)")
137 /// .bind(headers)
138 /// .bind(message.payload())
139 /// .execute(conn)
140 /// .await?;
141 /// Ok(())
142 /// }
143 /// }
144 /// # }
145 /// # fn main() {}
146 /// ```
147 #[must_use]
148 fn from_headers(headers: &HeaderMap) -> Self;
149
150 /// The first of `headers` the column cannot hold byte for byte, if any: the broker refuses a
151 /// publish that carries it.
152 fn unfit(headers: &HeaderMap) -> Option<&str>;
153}
154
155/// A record or a row whose headers live in one column: the field holding the column, which the
156/// table's description names with its `headers` setter.
157///
158/// `#[derive(Inbox)]` and `#[derive(Outbox)]` implement it for the field playing `headers`. A queue
159/// table described by hand implements it where its `InboxSpec` sets `spec::Headers`: a delivery
160/// takes the header map out of the field when its row is claimed, so the row a handler reads holds
161/// the column empty. A record described by hand implements it where its `OutboxSpec` sets
162/// `outbox::spec::Headers`, and a republished record carries the headers it took out of the field.
163///
164/// # Examples
165///
166/// A queue table whose headers live in a `jsonb` column:
167///
168/// ```
169/// # #[cfg(all(feature = "inbox", feature = "json", feature = "postgres"))]
170/// # mod demo {
171/// use std::collections::BTreeMap;
172///
173/// use ruststream_sqlx::dialect::Column;
174/// use ruststream_sqlx::prelude::*;
175/// use ruststream_sqlx::spec::{Headers, Payload};
176/// use ruststream_sqlx::{HeaderRow, InboxSpec, InboxTable, PayloadRow};
177/// use serde::Deserialize;
178/// use sqlx::PgPool;
179/// use sqlx::types::Json;
180///
181/// #[derive(sqlx::FromRow)]
182/// pub struct Job {
183/// id: i64,
184/// headers: Json<BTreeMap<String, String>>,
185/// payload: Vec<u8>,
186/// }
187///
188/// impl InboxTable for Job {
189/// type Id = i64;
190/// type Table = InboxSpec<(Headers, Payload)>;
191/// const TABLE: Self::Table = InboxSpec::new("jobs", Column::new("id").generated())
192/// .headers(Column::new("headers"))
193/// .payload(Column::new("payload"));
194///
195/// fn id(&self) -> &i64 {
196/// &self.id
197/// }
198/// }
199///
200/// impl HeaderRow for Job {
201/// type Column = Json<BTreeMap<String, String>>;
202///
203/// fn headers_mut(&mut self) -> &mut Self::Column {
204/// &mut self.headers
205/// }
206/// }
207///
208/// impl PayloadRow for Job {
209/// type Column = Vec<u8>;
210///
211/// fn payload(&self) -> &[u8] {
212/// &self.payload
213/// }
214/// }
215///
216/// #[derive(Deserialize)]
217/// struct Task {
218/// n: u32,
219/// }
220///
221/// // The delivery's headers come from the `headers` column, as on any broker.
222/// #[subscriber(InboxQueue::<Job>::new("tasks"))]
223/// async fn run(task: &Task, ctx: &mut Context<'_>) -> HandlerOutcome {
224/// let tenant = ctx.headers().get_str("x-tenant").unwrap_or_default();
225/// tracing::info!(task.n, tenant, "running");
226/// HandlerOutcome::ack()
227/// }
228///
229/// pub fn app(pool: PgPool) -> RustStream {
230/// RustStream::new(AppInfo::new("worker", "0.1.0")).with_broker(SqlxBroker::new(pool), |b| {
231/// b.include(run);
232/// })
233/// }
234/// # }
235/// # fn main() {}
236/// ```
237///
238/// An outbox record that keeps the headers a message carried:
239///
240/// ```
241/// # #[cfg(all(feature = "outbox", feature = "json", feature = "postgres"))]
242/// # mod demo {
243/// # use ruststream::OutgoingMessage;
244/// use std::collections::BTreeMap;
245///
246/// use ruststream_sqlx::dialect::Column;
247/// use ruststream_sqlx::outbox::spec::Headers;
248/// use ruststream_sqlx::outbox::{self, OutboxSpec, OutboxTable};
249/// use ruststream_sqlx::{HeaderColumn, HeaderRow};
250/// use sqlx::types::Json;
251/// use sqlx::{PgConnection, Postgres};
252///
253/// #[derive(sqlx::FromRow)]
254/// pub struct OrderEvent {
255/// id: i64,
256/// name: String,
257/// payload: Vec<u8>,
258/// headers: Option<Json<BTreeMap<String, String>>>,
259/// }
260///
261/// impl HeaderRow for OrderEvent {
262/// type Column = Option<Json<BTreeMap<String, String>>>;
263///
264/// fn headers_mut(&mut self) -> &mut Self::Column {
265/// &mut self.headers
266/// }
267/// }
268///
269/// impl OutboxTable for OrderEvent {
270/// type Id = i64;
271/// type Table = OutboxSpec<(Headers,)>;
272/// const TABLE: Self::Table = OutboxSpec::new(
273/// "outbox",
274/// Column::new("id"),
275/// Column::new("name"),
276/// Column::new("payload"),
277/// )
278/// .headers(Column::new("headers"));
279///
280/// fn id(&self) -> &i64 {
281/// &self.id
282/// }
283///
284/// fn name(&self) -> &str {
285/// &self.name
286/// }
287///
288/// fn payload(&self) -> &[u8] {
289/// &self.payload
290/// }
291/// }
292///
293/// // The record keeps what the message carried, so a republish sends the headers again.
294/// impl outbox::Publish<Postgres> for OrderEvent {
295/// async fn publish(conn: &mut PgConnection, msg: &OutgoingMessage<'_>) -> sqlx::Result<i64> {
296/// sqlx::query_scalar(
297/// "INSERT INTO outbox (name, payload, headers) VALUES ($1, $2, $3) RETURNING id",
298/// )
299/// .bind(msg.name())
300/// .bind(msg.payload())
301/// .bind(Option::<Json<BTreeMap<String, String>>>::from_headers(msg.headers()))
302/// .fetch_one(conn)
303/// .await
304/// }
305/// }
306/// # }
307/// # fn main() {}
308/// ```
309#[diagnostic::on_unimplemented(
310 message = "`{Self}` sets a `headers` column and does not hand its header map over",
311 label = "no `HeaderRow` for this type",
312 note = "implement `HeaderRow` for `{Self}`, handing out the field that holds the headers \
313 column, or drop `.headers(..)` and `Headers` from its description"
314)]
315pub trait HeaderRow {
316 /// The column's type.
317 type Column: HeaderColumn + 'static;
318
319 /// The field holding the column, which the headers are taken out of.
320 fn headers_mut(&mut self) -> &mut Self::Column;
321}
322
323impl<T: HeaderColumn> HeaderColumn for Option<T> {
324 fn take_headers(&mut self) -> HeaderMap {
325 self.as_mut()
326 .map_or_else(HeaderMap::new, HeaderColumn::take_headers)
327 }
328
329 fn from_headers(headers: &HeaderMap) -> Self {
330 (!headers.is_empty()).then(|| T::from_headers(headers))
331 }
332
333 fn unfit(headers: &HeaderMap) -> Option<&str> {
334 T::unfit(headers)
335 }
336}
337
338/// A JSON object of strings. A value that is not UTF-8 does not fit: the broker refuses a publish
339/// that carries one, so the replacement `from_headers` would write never reaches the table.
340#[cfg(feature = "json")]
341impl HeaderColumn for Json<BTreeMap<String, String>> {
342 fn take_headers(&mut self) -> HeaderMap {
343 mem::take(&mut self.0).into_iter().collect()
344 }
345
346 fn from_headers(headers: &HeaderMap) -> Self {
347 Self(
348 headers
349 .iter()
350 .map(|(name, value)| (name.to_owned(), String::from_utf8_lossy(value).into_owned()))
351 .collect(),
352 )
353 }
354
355 fn unfit(headers: &HeaderMap) -> Option<&str> {
356 headers
357 .iter()
358 .find(|(_, value)| str::from_utf8(value).is_err())
359 .map(|(name, _)| name)
360 }
361}