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}