dwara_core/dataplane/
translation.rs

1//! Protocol translation framework (DW-100).
2//!
3//! The gateway can sit between clients and upstreams that speak different
4//! protocols and translate the request and response bodies on the fly.
5//! This module defines the shared, protocol-agnostic seam every
6//! translator implements: the [`ProtocolTranslator`] trait, the
7//! [`TranslatedRequest`] / [`TranslatedResponse`] carrying the converted
8//! bytes, the [`TranslationError`] enum, and a [`TranslationRegistry`]
9//! that maps content-type pairs to translators.
10//!
11//! ## Translators
12//!
13//! Three translators compile into every build (no cargo features):
14//!
15//! - **REST <-> gRPC** (DW-101, shared): the gRPC-Web transcoding engine
16//!   in [`super::grpc_web`] already translates JSON <-> protobuf; this
17//!   seam reuses it through the shared trait.
18//! - **REST <-> GraphQL** ([`super::translation_graphql`]): translates a
19//!   REST JSON body to a GraphQL query using a config-supplied query
20//!   template, and a GraphQL response back to a REST JSON body.
21//! - **SOAP/XML** ([`super::translation_soap`]): translates SOAP XML
22//!   envelopes to REST JSON and back, using a minimal hand-rolled XML
23//!   parser (no external XML crate, to avoid deny.toml review and binary
24//!   size).
25//!
26//! ## Why a shared trait
27//!
28//! Every translator does the same two things on a route: convert the
29//! inbound request body to the upstream's wire format, and convert the
30//! upstream's response body back to the client's wire format. Factoring
31//! that into one trait lets the dataplane dispatch by content-type pair
32//! through a single registry, and lets a new translator (e.g. a future
33//! Thrift or AMQP bridge) plug in without touching the request path.
34//!
35//! ## Feature gating
36//!
37//! This module compiles only when the `protocol_translation` cargo
38//! feature is enabled. The config schema (`Translation`,
39//! `TranslationKind`, `GraphqlTranslation`, `SoapTranslation`) is always
40//! present so configs round-trip without the feature; when the feature
41//! is off the block is accepted but inert (validation warns, the runtime
42//! translation does not run). The SOAP translator is further gated
43//! behind the `soap` feature for binary size.
44
45use std::collections::HashMap;
46use std::pin::Pin;
47use std::sync::Arc;
48use std::task::{Context, Poll};
49
50use bytes::Bytes;
51use hyper::body::Frame;
52use hyper::{HeaderMap, Method, Request, Response, StatusCode};
53
54// ---------------------------------------------------------------------------
55// Body type
56// ---------------------------------------------------------------------------
57
58/// A complete, buffered body used by the translation seam.
59///
60/// The [`ProtocolTranslator`] trait is synchronous: a translator reads
61/// the inbound body and writes the converted body in one pass, with no
62/// streaming. This type wraps a [`Bytes`] buffer and implements
63/// [`hyper::body::Body`] so a translated body can be handed to the rest
64/// of the dataplane (which speaks the `Body` trait). The bytes are
65/// exposed synchronously via [`as_bytes`] / [`into_bytes`] so the
66/// translators do not need to await a body collection.
67///
68/// [`as_bytes`]: TranslationBody::as_bytes
69/// [`into_bytes`]: TranslationBody::into_bytes
70#[derive(Debug, Clone, Default)]
71pub struct TranslationBody(Bytes);
72
73impl TranslationBody {
74    /// Wrap a buffer into a translation body.
75    pub fn new(bytes: Bytes) -> Self {
76        TranslationBody(bytes)
77    }
78
79    /// An empty body.
80    pub fn empty() -> Self {
81        TranslationBody(Bytes::new())
82    }
83
84    /// The buffered bytes, borrowed.
85    pub fn as_bytes(&self) -> &Bytes {
86        &self.0
87    }
88
89    /// The buffered bytes, owned.
90    pub fn into_bytes(self) -> Bytes {
91        self.0
92    }
93}
94
95impl hyper::body::Body for TranslationBody {
96    type Data = Bytes;
97    type Error = std::convert::Infallible;
98
99    fn poll_frame(
100        self: Pin<&mut Self>,
101        _cx: &mut Context<'_>,
102    ) -> Poll<Option<Result<Frame<Bytes>, Self::Error>>> {
103        let this = self.get_mut();
104        if this.0.is_empty() {
105            return Poll::Ready(None);
106        }
107        // Yield the entire buffer in one frame and drain it so the next
108        // poll reports end-of-stream. A translator body is fully
109        // buffered, so one frame is the correct framing.
110        let chunk = std::mem::take(&mut this.0);
111        Poll::Ready(Some(Ok(Frame::data(chunk))))
112    }
113
114    fn is_end_stream(&self) -> bool {
115        self.0.is_empty()
116    }
117
118    fn size_hint(&self) -> hyper::body::SizeHint {
119        let mut hint = hyper::body::SizeHint::new();
120        hint.set_exact(self.0.len() as u64);
121        hint
122    }
123}
124
125// ---------------------------------------------------------------------------
126// Translated request / response
127// ---------------------------------------------------------------------------
128
129/// The converted request to send upstream: the method, path, headers,
130/// and body the translator produced from the inbound request.
131#[derive(Debug, Clone)]
132pub struct TranslatedRequest {
133    /// The HTTP method to send upstream (a GraphQL upstream expects POST;
134    /// a gRPC upstream expects POST; a SOAP upstream expects POST).
135    pub method: Method,
136    /// The path to send upstream (the translator may rewrite it, e.g. a
137    /// REST-to-GraphQL translator points every request at `/graphql`).
138    pub path: String,
139    /// The headers to send upstream. The translator sets the
140    /// `Content-Type` to [`ProtocolTranslator::content_type_out`]; the
141    /// caller merges these with the route's forwarded headers.
142    pub headers: HeaderMap,
143    /// The converted body bytes.
144    pub body: Bytes,
145}
146
147/// The converted response to send the client: the status, headers, and
148/// body the translator produced from the upstream response.
149#[derive(Debug, Clone)]
150pub struct TranslatedResponse {
151    /// The HTTP status to send the client.
152    pub status: StatusCode,
153    /// The headers to send the client. The translator sets the
154    /// `Content-Type` to [`ProtocolTranslator::content_type_in`].
155    pub headers: HeaderMap,
156    /// The converted body bytes.
157    pub body: Bytes,
158}
159
160// ---------------------------------------------------------------------------
161// Error
162// ---------------------------------------------------------------------------
163
164/// Errors from protocol translation.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum TranslationError {
167    /// The inbound body could not be parsed (malformed JSON, malformed
168    /// XML, a SOAP envelope without a Body, etc.).
169    InvalidBody(String),
170    /// The referenced schema/template/descriptor was not found (e.g. a
171    /// GraphQL translation without a query template, or a gRPC method
172    /// not in the loaded descriptors).
173    SchemaNotFound(String),
174    /// The translation itself failed (a template variable the request
175    /// body did not supply, an XML element the converter could not map,
176    /// etc.).
177    TranslationFailed(String),
178}
179
180impl std::fmt::Display for TranslationError {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        match self {
183            TranslationError::InvalidBody(m) => {
184                write!(f, "invalid body for translation: {m}")
185            }
186            TranslationError::SchemaNotFound(m) => {
187                write!(f, "schema not found for translation: {m}")
188            }
189            TranslationError::TranslationFailed(m) => {
190                write!(f, "translation failed: {m}")
191            }
192        }
193    }
194}
195
196impl std::error::Error for TranslationError {}
197
198// ---------------------------------------------------------------------------
199// ProtocolTranslator trait
200// ---------------------------------------------------------------------------
201
202/// Translate a request/response pair between two wire protocols.
203///
204/// A translator is constructed at config publish from a route's
205/// `translation` block and held by the request path for the duration of
206/// one request. [`translate_request`] converts the inbound request body
207/// to the upstream's wire format; [`translate_response`] converts the
208/// upstream's response body back to the client's wire format.
209/// [`content_type_in`] is the media type the client sends (and expects
210/// back); [`content_type_out`] is the media type the upstream expects
211/// (and sends back).
212///
213/// The trait is synchronous and operates on fully-buffered bodies
214/// ([`TranslationBody`]): protocol translation is an explicitly buffering
215/// step (the same posture as the DW-028 body transform and the DW-061
216/// aggregation plugin), never a tax on the zero-buffering proxy path.
217/// A route that configures translation opts its requests into the
218/// buffer-and-convert path; a route without the block streams untouched.
219///
220/// [`translate_request`]: ProtocolTranslator::translate_request
221/// [`translate_response`]: ProtocolTranslator::translate_response
222/// [`content_type_in`]: ProtocolTranslator::content_type_in
223/// [`content_type_out`]: ProtocolTranslator::content_type_out
224pub trait ProtocolTranslator: Send + Sync {
225    /// Convert the inbound request to the upstream's wire format. The
226    /// translator reads `req`'s body (via [`TranslationBody::as_bytes`])
227    /// and returns the converted method, path, headers, and body to
228    /// send upstream.
229    fn translate_request(
230        &self,
231        req: &Request<TranslationBody>,
232    ) -> Result<TranslatedRequest, TranslationError>;
233
234    /// Convert the upstream response to the client's wire format. The
235    /// translator reads `resp`'s body and returns the converted status,
236    /// headers, and body to send the client.
237    fn translate_response(
238        &self,
239        resp: &Response<TranslationBody>,
240    ) -> Result<TranslatedResponse, TranslationError>;
241
242    /// The media type the CLIENT sends and expects back (e.g.
243    /// `application/json` for a REST client, `text/xml` for a SOAP
244    /// client).
245    fn content_type_in(&self) -> &str;
246
247    /// The media type the UPSTREAM expects and sends back (e.g.
248    /// `application/graphql` for a GraphQL upstream, `application/xml`
249    /// for a SOAP upstream, `application/grpc` for a gRPC upstream).
250    fn content_type_out(&self) -> &str;
251}
252
253// ---------------------------------------------------------------------------
254// Registry
255// ---------------------------------------------------------------------------
256
257/// A registry of protocol translators keyed by the (in, out) content-type
258/// pair. Built at config publish; held behind an `Arc` by the request
259/// path. A route's `translation.kind` resolves to one entry here.
260#[derive(Clone, Default)]
261pub struct TranslationRegistry {
262    /// `(content_type_in, content_type_out)` -> translator.
263    entries: HashMap<(String, String), Arc<dyn ProtocolTranslator>>,
264}
265
266impl std::fmt::Debug for TranslationRegistry {
267    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268        // The trait object is not Debug, so report the registered
269        // content-type pairs instead of the translators themselves.
270        f.debug_struct("TranslationRegistry")
271            .field("pairs", &self.entries.keys().collect::<Vec<_>>())
272            .finish()
273    }
274}
275
276impl TranslationRegistry {
277    /// An empty registry.
278    pub fn new() -> Self {
279        TranslationRegistry {
280            entries: HashMap::new(),
281        }
282    }
283
284    /// Register a translator for a content-type pair. The pair is
285    /// normalized to lowercase (media types are case-insensitive).
286    pub fn register(
287        &mut self,
288        content_type_in: &str,
289        content_type_out: &str,
290        translator: Arc<dyn ProtocolTranslator>,
291    ) {
292        let key = (
293            content_type_in.to_ascii_lowercase(),
294            content_type_out.to_ascii_lowercase(),
295        );
296        self.entries.insert(key, translator);
297    }
298
299    /// Look up the translator for a content-type pair. Matching is
300    /// case-insensitive on both media types.
301    pub fn get(
302        &self,
303        content_type_in: &str,
304        content_type_out: &str,
305    ) -> Option<Arc<dyn ProtocolTranslator>> {
306        let key = (
307            content_type_in.to_ascii_lowercase(),
308            content_type_out.to_ascii_lowercase(),
309        );
310        self.entries.get(&key).cloned()
311    }
312
313    /// The number of registered translators.
314    pub fn len(&self) -> usize {
315        self.entries.len()
316    }
317
318    /// Whether the registry is empty.
319    pub fn is_empty(&self) -> bool {
320        self.entries.is_empty()
321    }
322}