hive-router 0.2.0

GraphQL router for Federation, part of the Hive platform
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
use std::collections::HashMap;

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

type HeaderName = String;
type RegExp = String;

/// Standard hop-by-hop headers that are never forwarded to subgraphs and are
/// filtered from client responses, regardless of rules.
pub const HOP_BY_HOP_HEADERS: &[&str] = &[
    "connection",
    "keep-alive",
    "proxy-authenticate",
    "proxy-authorization",
    "te",
    "trailer",
    "transfer-encoding",
    "upgrade",
    "proxy-connection",
    "host",
    "content-length",
];

/// Headers that must never be comma-joined. If multiple values exist, they
/// are emitted as separate header fields (e.g. multiple `Set-Cookie` lines).
pub static NEVER_JOIN_HEADERS: &[&str] = &["set-cookie", "www-authenticate"];

/// Configuration for how the Router handles HTTP headers.
///
/// ## Scopes & order of evaluation
/// - **Scope precedence:** Rules under `all` apply to every subgraph first.
///   Rules under `subgraphs.<name>` apply **after** and can override results
///   for that specific subgraph.
/// - **Rule ordering:** Within each list, rules are applied **top-to-bottom**.
///   Later rules can overwrite/undo earlier rules (e.g. `propagate` then `remove`).
///
/// ## Case-insensitive names
/// Header names are case-insensitive. Internally they are normalized to lowercase.
///
/// ## Safety
/// Hop-by-hop headers are always stripped. Never-join headers (e.g. `set-cookie`)
/// are never comma-joined. Multiple values are preserved as separate fields.
#[derive(Debug, Default, Deserialize, Serialize, JsonSchema, Clone)]
#[schemars(example = headers_example_1())]
pub struct HeadersConfig {
    /// Rules applied to all subgraphs (global defaults).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub all: Option<HeaderRules>,

    /// Rules applied to individual subgraphs.
    /// Keys are subgraph names as defined in the supergraph schema.
    ///
    /// **Precedence:** These are applied **after** `all`, and therefore can
    /// override the result of global rules for that subgraph.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub subgraphs: Option<HashMap<String, HeaderRules>>,
}

fn headers_example_1() -> HeadersConfig {
    HeadersConfig {
        all: Some(HeaderRules {
            request: Some(vec![
                RequestHeaderRule::Propagate(RequestPropagateRule {
                    spec: MatchSpec {
                        named: Some(OneOrMany::One("Authorization".to_string())),
                        ..Default::default()
                    },
                    ..Default::default()
                }),
                RequestHeaderRule::Remove(RemoveRule {
                    spec: MatchSpec {
                        matching: Some(OneOrMany::One("^x-legacy-.*".to_string())),
                        ..Default::default()
                    },
                }),
                RequestHeaderRule::Insert(RequestInsertRule {
                    name: "x-router".to_string(),
                    source: InsertSource::Value {
                        value: "hive-router".to_string(),
                    },
                }),
            ]),
            response: None,
        }),
        subgraphs: Some(HashMap::from([(
            "accounts".to_string(),
            HeaderRules {
                request: Some(vec![RequestHeaderRule::Propagate(RequestPropagateRule {
                    spec: MatchSpec {
                        named: Some(OneOrMany::One("x-tenant-id".to_string())),
                        ..Default::default()
                    },
                    rename: Some("x-acct-tenant".to_string()),
                    default: Some("unknown".to_string()),
                })]),
                response: None,
            },
        )])),
    }
}

/// Rules for a single scope (global or per-subgraph).
///
/// You can specify independent rule lists for **request** (to subgraphs)
/// and **response** (to clients). Within each list, rules are applied in order.
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)]
pub struct HeaderRules {
    /// Rules that shape the **request** sent from the router to subgraphs.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub request: Option<Vec<RequestHeaderRule>>,

    /// Rules that shape the **response** sent from the router back to the client.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub response: Option<Vec<ResponseHeaderRule>>,
}

/// Request-header rules (applied before sending to a subgraph).
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
#[serde(rename_all = "snake_case")]
pub enum RequestHeaderRule {
    /// Forward headers from the client request into the subgraph request.
    ///
    /// - If `rename` is set, the header is forwarded under the new name.
    /// - If **none** of the matched headers exist, `default` is used (when provided).
    ///
    /// **Order matters:** You can propagate first and then `remove` or `insert`
    /// to refine the final output.
    Propagate(RequestPropagateRule),

    /// Remove headers before sending the request to a subgraph.
    ///
    /// Useful to drop sensitive or irrelevant headers, or to undo a previous
    /// `propagate`/`insert`.
    Remove(RemoveRule),

    /// Add or overwrite a header with a static value.
    ///
    /// - For **normal** headers: replaces any existing value.
    /// - For **never-join** headers (e.g. `set-cookie`): **appends** another
    ///   occurrence (multiple lines), never comma-joins.
    Insert(RequestInsertRule),
}

/// Response-header rules (applied before sending back to the client).
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
#[serde(rename_all = "snake_case")]
pub enum ResponseHeaderRule {
    /// Forward headers from subgraph responses into the final client response.
    ///
    /// - If multiple subgraphs provide the same header, `algorithm` controls
    ///   how values are merged.
    /// - If **no** subgraph provides a matching header, `default` is used (when provided).
    /// - If `rename` is set, the header is returned under the new name.
    ///
    /// **Never-join headers** (e.g. `set-cookie`) are never comma-joined:
    /// multiple values are returned as separate header fields regardless of `algorithm`.
    Propagate(ResponsePropagateRule),

    /// Remove headers before sending the response to the client.
    Remove(RemoveRule),

    /// Add or overwrite a header in the response to the client.
    ///
    /// For never-join headers, appends another occurrence (multiple lines).
    Insert(ResponseInsertRule),
}

/// Remove headers matched by the specification.
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
pub struct RemoveRule {
    #[serde(flatten)]
    pub spec: MatchSpec,
}

/// Insert a header with a static value.
///
/// ### Examples
/// ```yaml
/// - insert:
///     name: x-env
///     value: prod
/// ```
///
/// ```yaml
/// - insert:
///     name: set-cookie
///     value: "a=1; Path=/"
/// # If another Set-Cookie exists, this creates another header line (never joined)
/// ```
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
pub struct RequestInsertRule {
    /// Header name to insert or overwrite (case-insensitive).
    pub name: HeaderName,
    /// Where the value comes from (currently static only).
    #[serde(flatten)]
    pub source: InsertSource,
}

/// Insert a header with a static value.
///
/// ### Examples
/// ```yaml
/// - insert:
///     name: x-env
///     value: prod
/// ```
///
/// ```yaml
/// - insert:
///     name: set-cookie
///     value: "a=1; Path=/"
/// # If another Set-Cookie exists, this creates another header line (never joined)
/// ```
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
pub struct ResponseInsertRule {
    /// Header name to insert or overwrite (case-insensitive).
    pub name: HeaderName,
    /// Where the value comes from (currently static only).
    #[serde(flatten)]
    pub source: InsertSource,
    /// How to merge values across multiple subgraph responses.
    /// Default: `Last` (overwrite).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub algorithm: Option<AggregationAlgo>,
}

/// Source for an inserted header value.
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
#[serde(untagged)]
pub enum InsertSource {
    /// Static value provided in the config.
    Value { value: String },
    /// A dynamic value computed by a VRL expression.
    ///
    /// This allows you to generate header values based on the incoming request,
    /// subgraph name, and (for response rules) subgraph response headers.
    /// The expression has access to a context object with `.request`, `.subgraph`,
    /// and `.response.headers` fields.
    ///
    /// For more information on the available functions and syntax, see the
    /// [VRL documentation](https://vrl.dev/).
    ///
    /// ### Example
    /// ```yaml
    /// # Insert a header with a value derived from another header.
    /// - insert:
    ///     name: x-auth-scheme
    ///     expression: 'split(.request.headers.authorization, " ")[0] ?? "none"'
    /// ```
    Expression { expression: String },
}

/// Helper to allow `one` or `many` values for ergonomics (OR semantics).
///
/// ### Examples
/// ```yaml
/// named: Authorization
/// # or
/// named: [Authorization, x-tenant-id]
/// ```
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
#[serde(untagged)]
pub enum OneOrMany<T> {
    One(T),
    Many(Vec<T>),
}

/// Header matching specification used by `propagate` and `remove`.
///
/// **Semantics**
/// - `named`: match by exact name(s), case-insensitive (OR).
/// - `matching`: match header name(s) by regex (OR).
/// - `exclude`: subtract matches by regex (applied **after** `named`/`matching`).
///
/// If `matching` is omitted, it’s treated as “match nothing” unless `named` is set.
/// If both `named` and `matching` are omitted, the rule matches nothing.
///
/// **Safety:** Hop-by-hop headers are never propagated, even if matched here.
///
/// ### Examples
/// ```yaml
/// # Propagate selected exact names
/// named: [Authorization, x-corr-id]
///
/// # Propagate everything starting with x- (except legacy)
/// matching: "^x-.*"
/// exclude: ["^x-legacy-.*"]
/// ```
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)]
pub struct MatchSpec {
    /// Match headers by exact name (OR).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub named: Option<OneOrMany<HeaderName>>,

    /// Match headers by regex pattern(s) (OR).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub matching: Option<OneOrMany<RegExp>>,

    /// Exclude headers matching these regexes, applied after `matching`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub exclude: Option<Vec<RegExp>>,
}

/// Propagate headers from the client request to subgraph requests.
///
/// **Behavior**
/// - If `rename` is provided, forwarded under that name.
/// - If **none** of the matched headers are present, `default` (when present)
///   is used under `rename` (if set) or the **first** `named` header.
///
/// ### Examples
/// ```yaml
/// # Forward a specific header, but rename it per subgraph
/// propagate:
///   named: x-tenant-id
///   rename: x-acct-tenant
///
/// # Forward all x- headers except legacy ones
/// propagate:
///   matching: "^x-.*"
///   exclude: ["^x-legacy-.*"]
///
/// # If Authorization is missing, inject a default token for this subgraph
/// propagate:
///   named: Authorization
///   default: "Bearer test-token"
/// ```
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Default)]
pub struct RequestPropagateRule {
    #[serde(flatten)]
    pub spec: MatchSpec,

    /// Optionally rename the header when forwarding.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rename: Option<HeaderName>,

    /// If the header is missing, set a default value.
    /// Applied only when **none** of the matched headers exist.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default: Option<String>,
}

/// How to merge response header values from multiple subgraphs.
///
/// For never-join headers (e.g. `Set-Cookie`), the router always emits multiple
/// header fields regardless of the algorithm.
///
/// Using `first` or `last` on `cache-control` is a **compile-time error**.
/// See [`AggregationAlgo::Append`] for the cache-control merge semantics.
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum AggregationAlgo {
    /// Take the first value encountered and ignore later ones.
    First,
    /// Overwrite with the last value encountered.
    Last,
    /// Collect all values. For most headers they are comma-joined into a single
    /// field. For never-join headers (e.g. `Set-Cookie`) they are emitted as
    /// separate header fields.
    ///
    /// **`cache-control` special case:** Instead of comma-joining, the router
    /// applies a restrictive merge across all subgraph values:
    /// - `no-store`, `no-cache`, or `private` from any subgraph poisons the result.
    /// - `max-age` takes the minimum across all subgraphs that provide it.
    /// - `public` is only kept if every provided `Cache-Control` value carries it.
    /// - `must-revalidate` is set if any subgraph carries it.
    ///
    /// The following conditions force `no-store, no-cache, must-revalidate`
    /// regardless of subgraph values:
    /// - Any subgraph executor error (network failure, bad status, etc.)
    /// - Any GraphQL-level error in a subgraph response (`errors` array non-empty)
    /// - The operation is a mutation
    ///
    /// If no subgraph sends `Cache-Control` and no `default` is configured,
    /// the router leaves the header absent from the client response.
    Append,
}

/// Propagate headers from subgraph responses to the final client response.
///
/// - If multiple subgraphs return the header, values are merged using `algorithm`.
///   Never-join headers are **never** comma-joined.
/// - If **no** subgraph returns a match, `default` (if set) is emitted.
/// - If `rename` is set, the outgoing header uses the new name.
///
/// For `cache-control` propagation, `algorithm` must be `append`. See
/// [`AggregationAlgo::Append`] for the full merge semantics.
///
/// ### Examples
/// ```yaml
/// # Forward X-Request-ID from whichever subgraph supplies it (last wins)
/// propagate:
///   named: X-Request-ID
///   algorithm: last
///
/// # Combine list-valued headers
/// propagate:
///   named: vary
///   algorithm: append
///
/// # Ensure a fallback header is always present
/// propagate:
///   named: x-backend
///   algorithm: append
///   default: unknown
///
/// # Propagate cache-control with restrictive merge across all subgraphs
/// propagate:
///   named: cache-control
///   algorithm: append
/// ```
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
pub struct ResponsePropagateRule {
    #[serde(flatten)]
    pub spec: MatchSpec,

    /// Optionally rename the header when returning it to the client.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rename: Option<HeaderName>,

    /// If no subgraph returns the header, set this default value.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default: Option<String>,

    /// How to merge values across multiple subgraph responses.
    pub algorithm: AggregationAlgo,
}