dwara_core/aggregation/
mod.rs

1//! API aggregation (DW-061 spec, DP-09 runtime / #237).
2//!
3//! Multi-upstream composition, JSONPath/CEL fragment shaping,
4//! per-fragment fail-open/closed (decision 10; section 5-Traffic).
5//!
6//! ## Constraint (decision 10, section 12.1)
7//!
8//! The core dataplane never buffers full bodies to support composition
9//! -- only this plugin's own fragment transforms, with explicit size
10//! caps, touch bodies. Composition stays an extension cost, never a
11//! tax on the zero-buffering proxy path everything else uses.
12//!
13//! ## KrakenD-style aggregation
14//!
15//! An aggregation endpoint composes a response from multiple upstreams.
16//! Each fragment specifies:
17//! - An upstream reference (service name + path)
18//! - A JSONPath expression to extract a fragment from the upstream
19//!   response
20//! - A target field in the composed response
21//! - A fail-open/closed policy (fail-open = skip on error, fail-closed
22//!   = return an error)
23//!
24//! The aggregator fetches all fragments in parallel, shapes each, and
25//! combines them into a single JSON response.
26//!
27//! ## Runtime (DP-09, #237)
28//!
29//! [`run_aggregation`] is the async runtime: it fetches all fragments
30//! in parallel via simple async TCP + HTTP/1.1, shapes each through
31//! JSONPath, and composes the result through the pure [`compose`]
32//! function. A [`ServiceResolver`] trait abstracts service-to-endpoint
33//! resolution so the runtime can be wired to the dataplane's current
34//! generation balancers.
35//!
36//! ## Availability
37//!
38//! The module is always compiled into the OSS build.
39
40use std::collections::HashMap;
41
42use serde::{Deserialize, Serialize};
43use serde_json::Value;
44
45// ---------------------------------------------------------------------------
46// Aggregation spec
47// ---------------------------------------------------------------------------
48
49/// An aggregation endpoint spec: composes a response from multiple
50/// upstream fragments.
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct AggregationSpec {
54    /// The endpoint name (for logging/metrics).
55    pub name: String,
56    /// The fragments to compose.
57    pub fragments: Vec<FragmentSpec>,
58    /// The maximum total response size in bytes (default: 1MB).
59    #[serde(default = "default_max_response_bytes")]
60    pub max_response_bytes: usize,
61}
62
63fn default_max_response_bytes() -> usize {
64    1_048_576 // 1 MB
65}
66
67/// A single fragment in an aggregation.
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct FragmentSpec {
71    /// The fragment name (used as the target field key if
72    /// `target_field` is not set).
73    pub name: String,
74    /// The upstream service name.
75    pub service: String,
76    /// The path on the upstream service.
77    pub path: String,
78    /// The HTTP method (default: GET).
79    #[serde(default = "default_method")]
80    pub method: String,
81    /// A JSONPath expression to extract a fragment from the upstream
82    /// response. If empty, the entire response is used.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub jsonpath: Option<String>,
85    /// The target field in the composed response. If empty, the
86    /// fragment name is used.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub target_field: Option<String>,
89    /// The fail policy: fail-open (skip on error) or fail-closed
90    /// (return an error). Default: fail-open.
91    #[serde(default)]
92    pub fail_policy: FailPolicy,
93    /// The maximum fragment size in bytes (default: 256KB).
94    #[serde(default = "default_max_fragment_bytes")]
95    pub max_fragment_bytes: usize,
96}
97
98fn default_method() -> String {
99    "GET".to_string()
100}
101
102fn default_max_fragment_bytes() -> usize {
103    262_144 // 256 KB
104}
105
106/// The fail policy for a fragment.
107#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
108#[serde(rename_all = "snake_case", deny_unknown_fields)]
109pub enum FailPolicy {
110    /// Skip the fragment on error (the composed response omits the
111    /// field). This is the default.
112    #[default]
113    FailOpen,
114    /// Return an error on failure (the entire composed response fails).
115    FailClosed,
116}
117
118// ---------------------------------------------------------------------------
119// Fragment result
120// ---------------------------------------------------------------------------
121
122/// The result of fetching a fragment.
123#[derive(Clone, Debug, PartialEq)]
124pub enum FragmentResult {
125    /// The fragment was fetched successfully.
126    Ok {
127        /// The extracted JSON value (after JSONPath shaping).
128        value: Value,
129        /// The fragment name.
130        name: String,
131        /// The target field in the composed response.
132        target_field: String,
133    },
134    /// The fragment fetch failed.
135    Error {
136        /// The fragment name.
137        name: String,
138        /// The error message.
139        error: String,
140        /// The fail policy for this fragment.
141        fail_policy: FailPolicy,
142    },
143}
144
145// ---------------------------------------------------------------------------
146// Aggregator
147// ---------------------------------------------------------------------------
148
149/// Compose a response from fragment results.
150///
151/// This is the pure composition step: it takes the fragment results
152/// (already fetched + shaped by the plugin runtime) and combines them
153/// into a single JSON object. Fail-open fragments are skipped;
154/// fail-closed fragments cause the entire composition to fail.
155pub fn compose(spec: &AggregationSpec, results: &[FragmentResult]) -> ComposeResult {
156    let mut response = serde_json::Map::new();
157    let mut errors = Vec::new();
158
159    for result in results {
160        match result {
161            FragmentResult::Ok {
162                value,
163                target_field,
164                ..
165            } => {
166                // Check total response size.
167                let current_size = serde_json::to_vec(&Value::Object(response.clone()))
168                    .map(|v| v.len())
169                    .unwrap_or(0);
170                let fragment_size = serde_json::to_vec(value).map(|v| v.len()).unwrap_or(0);
171
172                if current_size + fragment_size > spec.max_response_bytes {
173                    errors.push(format!(
174                        "composed response exceeds max size ({} + {} > {})",
175                        current_size, fragment_size, spec.max_response_bytes
176                    ));
177                    // Skip this fragment (treat as fail-open).
178                    continue;
179                }
180
181                response.insert(target_field.clone(), value.clone());
182            }
183            FragmentResult::Error {
184                name,
185                error,
186                fail_policy,
187            } => {
188                match fail_policy {
189                    FailPolicy::FailOpen => {
190                        // Skip the fragment; record the error as a warning.
191                        errors.push(format!("fragment '{name}' failed (fail-open): {error}"));
192                    }
193                    FailPolicy::FailClosed => {
194                        // The entire composition fails.
195                        return ComposeResult::Error {
196                            error: format!("fragment '{name}' failed (fail-closed): {error}"),
197                            partial: Some(Value::Object(response)),
198                        };
199                    }
200                }
201            }
202        }
203    }
204
205    ComposeResult::Ok {
206        response: Value::Object(response),
207        warnings: errors,
208    }
209}
210
211/// The result of composing fragments.
212#[derive(Clone, Debug, PartialEq)]
213pub enum ComposeResult {
214    /// The composition succeeded.
215    Ok {
216        /// The composed JSON response.
217        response: Value,
218        /// Warnings (e.g. fail-open fragments that were skipped).
219        warnings: Vec<String>,
220    },
221    /// The composition failed (a fail-closed fragment errored).
222    Error {
223        /// The error message.
224        error: String,
225        /// The partial response (fragments that succeeded before the
226        /// failure), if any.
227        partial: Option<Value>,
228    },
229}
230
231// ---------------------------------------------------------------------------
232// JSONPath extraction (simplified)
233// ---------------------------------------------------------------------------
234
235/// Extract a value from a JSON document using a simplified JSONPath
236/// expression.
237///
238/// Supports:
239/// - `$` -- the root object
240/// - `$.field` -- a field access
241/// - `$.field.subfield` -- nested field access
242/// - `$.field[0]` -- array index
243///
244/// This is a minimal implementation for the aggregation plugin. A
245/// full JSONPath implementation (filter expressions, wildcards, etc.)
246/// would use a dedicated crate.
247pub fn extract_jsonpath(document: &Value, path: &str) -> Result<Value, String> {
248    if path.is_empty() || path == "$" {
249        return Ok(document.clone());
250    }
251
252    let path = path
253        .strip_prefix("$.")
254        .or(path.strip_prefix("$"))
255        .unwrap_or(path);
256
257    let mut current = document;
258    for segment in path.split('.') {
259        current = if segment.contains('[') && segment.contains(']') {
260            // Array index: field[0]
261            let bracket_start = segment.find('[').ok_or("invalid array index")?;
262            let field = &segment[..bracket_start];
263            let index_str = &segment[bracket_start + 1..segment.len() - 1];
264            let index: usize = index_str.parse().map_err(|_| "invalid array index")?;
265
266            let obj = current
267                .get(field)
268                .ok_or_else(|| format!("field '{field}' not found"))?;
269            obj.get(index)
270                .ok_or_else(|| format!("index {index} out of bounds"))?
271        } else {
272            current
273                .get(segment)
274                .ok_or_else(|| format!("field '{segment}' not found"))?
275        };
276    }
277
278    Ok(current.clone())
279}
280
281/// Shape a fragment: extract the value using the JSONPath expression
282/// (if any), otherwise return the document as-is.
283pub fn shape_fragment(document: &Value, fragment: &FragmentSpec) -> Result<Value, String> {
284    if let Some(path) = &fragment.jsonpath {
285        extract_jsonpath(document, path)
286    } else {
287        Ok(document.clone())
288    }
289}
290
291/// Create a fragment result from a fetched upstream response.
292pub fn make_fragment_result(fragment: &FragmentSpec, response_body: &str) -> FragmentResult {
293    let target_field = fragment
294        .target_field
295        .clone()
296        .unwrap_or_else(|| fragment.name.clone());
297
298    // Check fragment size.
299    if response_body.len() > fragment.max_fragment_bytes {
300        return FragmentResult::Error {
301            name: fragment.name.clone(),
302            error: format!(
303                "fragment body exceeds max size ({} > {})",
304                response_body.len(),
305                fragment.max_fragment_bytes
306            ),
307            fail_policy: fragment.fail_policy,
308        };
309    }
310
311    // Parse JSON.
312    let document: Value = match serde_json::from_str(response_body) {
313        Ok(v) => v,
314        Err(e) => {
315            return FragmentResult::Error {
316                name: fragment.name.clone(),
317                error: format!("invalid JSON: {e}"),
318                fail_policy: fragment.fail_policy,
319            };
320        }
321    };
322
323    // Shape the fragment.
324    match shape_fragment(&document, fragment) {
325        Ok(value) => FragmentResult::Ok {
326            value,
327            name: fragment.name.clone(),
328            target_field,
329        },
330        Err(e) => FragmentResult::Error {
331            name: fragment.name.clone(),
332            error: format!("jsonpath extraction failed: {e}"),
333            fail_policy: fragment.fail_policy,
334        },
335    }
336}
337
338/// Create an error fragment result (for when the upstream fetch fails).
339pub fn make_error_fragment_result(fragment: &FragmentSpec, error: &str) -> FragmentResult {
340    FragmentResult::Error {
341        name: fragment.name.clone(),
342        error: error.to_string(),
343        fail_policy: fragment.fail_policy,
344    }
345}
346
347// ---------------------------------------------------------------------------
348// Aggregation spec validation
349// ---------------------------------------------------------------------------
350
351/// Validate an aggregation spec.
352pub fn validate_spec(spec: &AggregationSpec) -> Result<(), String> {
353    if spec.name.is_empty() {
354        return Err("aggregation name cannot be empty".to_string());
355    }
356
357    if spec.fragments.is_empty() {
358        return Err("aggregation must have at least one fragment".to_string());
359    }
360
361    if spec.max_response_bytes == 0 {
362        return Err("max_response_bytes cannot be zero".to_string());
363    }
364
365    let mut seen_names = HashMap::new();
366    for (i, fragment) in spec.fragments.iter().enumerate() {
367        if fragment.name.is_empty() {
368            return Err(format!("fragment {i}: name cannot be empty"));
369        }
370
371        if fragment.service.is_empty() {
372            return Err(format!(
373                "fragment '{}': service cannot be empty",
374                fragment.name
375            ));
376        }
377
378        if fragment.path.is_empty() {
379            return Err(format!(
380                "fragment '{}': path cannot be empty",
381                fragment.name
382            ));
383        }
384
385        if fragment.max_fragment_bytes == 0 {
386            return Err(format!(
387                "fragment '{}': max_fragment_bytes cannot be zero",
388                fragment.name
389            ));
390        }
391
392        // Check for duplicate target fields.
393        let target = fragment
394            .target_field
395            .clone()
396            .unwrap_or_else(|| fragment.name.clone());
397        if let Some(prev) = seen_names.insert(target.clone(), i) {
398            return Err(format!(
399                "fragment '{target}' (index {i}) duplicates target field of fragment at index {prev}"
400            ));
401        }
402    }
403
404    Ok(())
405}
406
407// ---------------------------------------------------------------------------
408// Async runtime (DP-09, #237)
409// ---------------------------------------------------------------------------
410
411/// The async aggregation runtime: fetches all fragments, shapes each,
412/// and composes the result.
413///
414/// This is the runtime half of the aggregation module (DW-061 spec +
415/// DP-09 runtime). It uses simple async TCP with manual HTTP/1.1
416/// framing to fetch each fragment from its upstream service, then
417/// feeds the results through the pure [`compose`] function.
418///
419/// Fragments are fetched sequentially (the resolver is a borrowed
420/// reference, so spawning parallel tasks would require `'static`).
421/// A future optimization can wrap the resolver in `Arc` and use
422/// `JoinSet` for parallel fetches.
423pub async fn run_aggregation(
424    spec: &AggregationSpec,
425    resolver: &dyn ServiceResolver,
426) -> ComposeResult {
427    let mut results = Vec::with_capacity(spec.fragments.len());
428    for fragment in &spec.fragments {
429        results.push(fetch_fragment(fragment, resolver).await);
430    }
431    compose(spec, &results)
432}
433
434/// Fetch a single fragment from its upstream service.
435async fn fetch_fragment(fragment: &FragmentSpec, resolver: &dyn ServiceResolver) -> FragmentResult {
436    // Resolve the service to an upstream endpoint.
437    let endpoint = match resolver.resolve(&fragment.service) {
438        Some(e) => e,
439        None => {
440            return make_error_fragment_result(
441                fragment,
442                &format!(
443                    "service '{}' not found or has no endpoint",
444                    fragment.service
445                ),
446            );
447        }
448    };
449
450    // Build the HTTP request.
451    let request = format!(
452        "{} {} HTTP/1.1\r\nHost: {}:{}\r\nConnection: close\r\nAccept: application/json\r\n\r\n",
453        fragment.method, fragment.path, endpoint.host, endpoint.port
454    );
455
456    // Connect and send the request.
457    let addr = format!("{}:{}", endpoint.host, endpoint.port);
458    let mut stream = match tokio::net::TcpStream::connect(&addr).await {
459        Ok(s) => s,
460        Err(e) => {
461            return make_error_fragment_result(fragment, &format!("connect to {addr} failed: {e}"));
462        }
463    };
464
465    use tokio::io::{AsyncReadExt, AsyncWriteExt};
466    if let Err(e) = stream.write_all(request.as_bytes()).await {
467        return make_error_fragment_result(fragment, &format!("write failed: {e}"));
468    }
469
470    // Read the response (bounded by max_fragment_bytes + headers).
471    let mut response = Vec::new();
472    let mut buf = [0u8; 4096];
473    loop {
474        match stream.read(&mut buf).await {
475            Ok(0) => break,
476            Ok(n) => {
477                response.extend_from_slice(&buf[..n]);
478                if response.len() > fragment.max_fragment_bytes + 8192 {
479                    return make_error_fragment_result(
480                        fragment,
481                        "response exceeds max fragment size",
482                    );
483                }
484            }
485            Err(e) => {
486                return make_error_fragment_result(fragment, &format!("read failed: {e}"));
487            }
488        }
489    }
490
491    // Parse the HTTP response: skip headers, extract body.
492    let body = match extract_http_body(&response) {
493        Ok(b) => b,
494        Err(e) => {
495            return make_error_fragment_result(fragment, &format!("http parse failed: {e}"));
496        }
497    };
498
499    make_fragment_result(fragment, &body)
500}
501
502/// Extract the body from an HTTP/1.1 response (skip the status line
503/// and headers, return the body bytes as a string).
504fn extract_http_body(response: &[u8]) -> Result<String, String> {
505    // Find the end of headers (\r\n\r\n).
506    let separator = b"\r\n\r\n";
507    let pos = response
508        .windows(separator.len())
509        .position(|w| w == separator)
510        .ok_or("no header/body separator found")?;
511
512    let body = &response[pos + separator.len()..];
513    String::from_utf8(body.to_vec()).map_err(|e| format!("invalid UTF-8 in body: {e}"))
514}
515
516/// A service resolver: maps a service name to an upstream endpoint.
517/// Implemented by the dataplane (which has the current snapshot's
518/// services + upstreams + balancers).
519pub trait ServiceResolver: Send + Sync {
520    /// Resolve a service name to an upstream endpoint (host, port).
521    fn resolve(&self, service: &str) -> Option<ResolvedEndpoint>;
522}
523
524/// A resolved upstream endpoint.
525#[derive(Debug, Clone)]
526pub struct ResolvedEndpoint {
527    pub host: String,
528    pub port: u16,
529}