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
//! GraphQL endpoint route construction with optional authentication.
use axum::{Router, middleware, routing::get};
use tower_http::compression::{
CompressionLayer,
predicate::{NotForContentType, Predicate as _, SizeAbove},
};
use super::{
super::{Server, graphql_get_handler, graphql_handler, require_json_content_type},
AuthPosture,
};
use crate::routes::graphql::{AppState, handler::graphql_query_method_handler};
impl Server {
/// Build the GraphQL endpoint router with optional auth and compression.
pub(super) fn build_graphql_router(&self, state: &AppState) -> Router {
// Build GraphQL route (possibly with auth + Content-Type enforcement).
// Supports both GET and POST per GraphQL over HTTP spec.
// OIDC and HS256 are mutually exclusive (enforced by ServerConfig::validate).
// Authentication is attached by the shared `attach_auth` helper rather than
// inline here: `/graphql` and the REST transport had independent mount code, and
// REST's omitted the layer entirely (#812). One helper, one posture decision.
// GET and POST are mounted exactly as before. The HTTP QUERY method
// (RFC 10008, #508) is added as a `MethodRouter` **fallback** rather than a
// typed arm, because axum 0.8 has no `MethodFilter::QUERY` yet — a fallback
// keeps GET/POST dispatch byte-for-byte unchanged, where an `any()` +
// manual-match rewrite would put every method through new code.
//
// The fallback also catches PUT/DELETE/…; the handler answers 405 for
// anything that is not QUERY, which is what the router did before.
let mut methods = get(graphql_get_handler).post(graphql_handler);
if self.config.enable_http_query {
methods = methods.fallback(graphql_query_method_handler);
}
let router = self.attach_auth(
Router::new().route(&self.config.graphql_path, methods),
AuthPosture::Authenticated,
"graphql",
);
let graphql_router = if self.config.require_json_content_type {
router
.route_layer(middleware::from_fn(require_json_content_type))
.with_state(state.clone())
} else {
router.with_state(state.clone())
};
// Apply framework-level compression if enabled.
// Disabled by default: in production, prefer reverse-proxy compression
// (Nginx, Caddy, cloud LB) which offloads CPU and supports brotli.
// When enabled, skip responses under 1 KiB — gzip overhead dominates
// on tiny payloads (e.g. short GraphQL results, health responses).
//
// `compress_when` REPLACES tower-http's default predicate, which is what
// normally exempts `text/event-stream`; without re-composing that
// exemption here, a large SSE response (#387) would be buffered by the
// encoder and its events would stop flushing incrementally.
//
// `multipart/mixed` is the same delivery under a different framing (#958)
// and needs the same exemption — an incremental transport that arrives all at
// once is not one. `NotForContentType::SSE` is a `text/event-stream`-only
// constant, so the multipart exemption is spelled out beside it rather than
// assumed.
if self.config.compression_enabled {
graphql_router.layer(
CompressionLayer::new().compress_when(
SizeAbove::new(1024)
.and(NotForContentType::SSE)
.and(NotForContentType::const_new("multipart/mixed")),
),
)
} else {
graphql_router
}
}
}