Skip to main content

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}