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}