1pub mod actions;
5pub mod engine;
6pub mod ws;
7
8use crate::llm::action_helper::call_llm;
9use crate::llm::actions::protocol_trait::ActionResult;
10use crate::logging::emit::Log;
11use crate::protocol::{Event, SpawnContext};
12use crate::server::accept_bounded::{accept_bounded, ConnectionLimiter, DEFAULT_MAX_CONNECTIONS};
13use crate::server::connection::ConnectionId;
14use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
15use apollo_compiler::{executable::OperationType, validation::Valid, Schema};
16use bytes::Bytes;
17use engine::RequestErrors;
18use http_body_util::{BodyExt, Full, Limited};
19use hyper::{
20 body::Incoming, header, server::conn::http1, service::service_fn, Method, Request, Response,
21 StatusCode,
22};
23use hyper_util::rt::{TokioIo, TokioTimer};
24use serde_json::{json, Map, Value};
25use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
26
27pub const DEFAULT_SCHEMA: &str = "type Query { hello(name: String): String! }";
28pub const DEFAULT_ENDPOINT: &str = "/graphql";
29pub const DEFAULT_INTROSPECTION: bool = true;
30const HEADER_TIMEOUT: Duration = Duration::from_secs(30);
31const BODY_TIMEOUT: Duration = Duration::from_secs(30);
32pub const GRAPHQL_RESPONSE: &str = "application/graphql-response+json";
33
34type UpgradeSlot = Arc<std::sync::Mutex<Option<hyper::upgrade::OnUpgrade>>>;
35
36struct Shared {
37 ctx: SpawnContext,
38 schema: Valid<Schema>,
39 endpoint: String,
40 introspection: bool,
41 init_timeout: Duration,
42}
43
44pub async fn spawn(ctx: SpawnContext) -> anyhow::Result<SocketAddr> {
45 let p = ctx.startup_params.as_ref();
46 let sdl = p
47 .map(|p| p.get_optional_string("schema"))
48 .transpose()?
49 .flatten()
50 .unwrap_or_else(|| DEFAULT_SCHEMA.to_owned());
51 let schema = engine::load_schema(&sdl)?;
52 let endpoint = p
53 .map(|p| p.get_optional_string("endpoint"))
54 .transpose()?
55 .flatten()
56 .unwrap_or_else(|| DEFAULT_ENDPOINT.to_owned());
57 anyhow::ensure!(
58 endpoint.starts_with('/')
59 && endpoint.len() <= 256
60 && !endpoint.contains(['?', '#', ' '])
61 && !crate::utils::sanitize::has_controls(&endpoint),
62 "endpoint must be an absolute path without query or fragment"
63 );
64 let introspection = p
65 .map(|p| p.get_optional_bool("introspection"))
66 .transpose()?
67 .flatten()
68 .unwrap_or(DEFAULT_INTROSPECTION);
69 let init_timeout = p
70 .map(|p| p.get_optional_u64("connection_init_timeout_secs"))
71 .transpose()?
72 .flatten()
73 .unwrap_or(ws::CONNECTION_INIT_TIMEOUT.as_secs());
74 anyhow::ensure!(
75 (1..=300).contains(&init_timeout),
76 "connection_init_timeout_secs must be 1..=300"
77 );
78 let listener = tokio::net::TcpListener::bind(ctx.legacy_listen_addr()).await?;
79 let local = listener.local_addr()?;
80 Log::new(Some(&ctx.status_tx)).info(format!(
81 "GraphQL server at http://{local}{endpoint} (introspection {})",
82 if introspection { "on" } else { "off" }
83 ));
84 let shared = Arc::new(Shared {
85 ctx: ctx.clone(),
86 schema,
87 endpoint,
88 introspection,
89 init_timeout: Duration::from_secs(init_timeout),
90 });
91 let server_id = ctx.server_id;
92 let accept = tokio::spawn(async move {
93 let limiter = ConnectionLimiter::new(DEFAULT_MAX_CONNECTIONS);
94 loop {
95 let (stream, peer, permit) = match accept_bounded(&listener, &limiter, b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", "GraphQL", Some(&shared.ctx.status_tx)).await {
96 Ok(v) => v,
97 Err(_) => break,
98 };
99 let id = ConnectionId::new(shared.ctx.state.get_next_unified_id().await);
100 let now = crate::utils::clock::Instant::now();
101 shared
102 .ctx
103 .state
104 .add_connection_to_server(
105 server_id,
106 ConnectionState {
107 id,
108 remote_addr: peer,
109 local_addr: local,
110 bytes_sent: 0,
111 bytes_received: 0,
112 packets_sent: 0,
113 packets_received: 0,
114 last_activity: now,
115 status: ConnectionStatus::Active,
116 status_changed_at: now,
117 protocol_info: ProtocolConnectionInfo::empty(),
118 },
119 )
120 .await;
121 let child = shared.clone();
122 shared
123 .ctx
124 .state
125 .spawn_server_task(server_id, async move {
126 let _permit = permit;
127 let svc_shared = child.clone();
128 let upgrade: UpgradeSlot = Arc::default();
129 let svc_upgrade = upgrade.clone();
130 let service = service_fn(move |req| {
131 let shared = svc_shared.clone();
132 let upgrade = svc_upgrade.clone();
133 async move { Ok::<_, Infallible>(handle(&shared, id, req, &upgrade).await) }
134 });
135 let mut builder = http1::Builder::new();
136 builder
137 .timer(TokioTimer::new())
138 .header_read_timeout(HEADER_TIMEOUT)
139 .max_headers(64)
140 .max_buf_size(32 * 1024);
141 if let Err(e) = builder
142 .serve_connection(TokioIo::new(stream), service)
143 .with_upgrades()
144 .await
145 {
146 Log::new(Some(&child.ctx.status_tx))
147 .debug(format!("GraphQL connection {id}: {e}"));
148 }
149 let pending = upgrade.lock().ok().and_then(|mut u| u.take());
152 if let Some(on_upgrade) = pending {
153 if let Ok(upgraded) = on_upgrade.await {
154 let socket = tokio_tungstenite::WebSocketStream::from_raw_socket(
155 TokioIo::new(upgraded),
156 tokio_tungstenite::tungstenite::protocol::Role::Server,
157 Some(ws::ws_config()),
158 )
159 .await;
160 ws::session(child.clone(), id, socket).await;
161 }
162 }
163 child
164 .ctx
165 .state
166 .update_connection_status(server_id, id, ConnectionStatus::Closed)
167 .await;
168 let _ = child.ctx.status_tx.send("__UPDATE_UI__".into());
169 })
170 .await;
171 }
172 });
173 ctx.state.register_server_task(server_id, accept).await;
174 Ok(local)
175}
176
177#[derive(Clone, Copy, PartialEq, Debug)]
178pub enum Media {
179 GraphqlResponse,
180 Json,
181}
182
183impl Media {
184 fn content_type(self) -> &'static str {
185 match self {
186 Media::GraphqlResponse => "application/graphql-response+json; charset=utf-8",
187 Media::Json => "application/json; charset=utf-8",
188 }
189 }
190}
191
192pub fn negotiate(accept: Option<&str>) -> Option<Media> {
196 let Some(accept) = accept.filter(|a| !a.trim().is_empty()) else {
197 return Some(Media::Json);
198 };
199 let mut best: Option<(f32, u8, Media)> = None;
200 for item in accept.split(',').take(32) {
201 let mut parts = item.split(';');
202 let ty = parts.next().unwrap_or("").trim().to_ascii_lowercase();
203 let q = parts
204 .filter_map(|p| p.trim().strip_prefix("q="))
205 .next()
206 .and_then(|q| q.trim().parse::<f32>().ok())
207 .unwrap_or(1.0);
208 if q <= 0.0 {
209 continue;
210 }
211 let (media, specificity) = match ty.as_str() {
212 GRAPHQL_RESPONSE => (Media::GraphqlResponse, 2),
213 "application/json" => (Media::Json, 2),
214 "application/*" | "*/*" => (Media::GraphqlResponse, 1),
215 _ => continue,
216 };
217 let better = match best {
218 None => true,
219 Some((bq, bs, bm)) => {
220 q > bq
221 || (q == bq && specificity > bs)
222 || (q == bq
223 && specificity == bs
224 && media == Media::GraphqlResponse
225 && bm == Media::Json)
226 }
227 };
228 if better {
229 best = Some((q, specificity, media));
230 }
231 }
232 best.map(|(_, _, m)| m)
233}
234
235fn respond(status: u16, content_type: &'static str, body: Vec<u8>) -> Response<Full<Bytes>> {
236 let mut r = Response::new(Full::new(Bytes::from(body)));
237 *r.status_mut() = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
238 r.headers_mut().insert(
239 header::CONTENT_TYPE,
240 header::HeaderValue::from_static(content_type),
241 );
242 r
243}
244
245fn graphql(status: u16, media: Media, body: &Value) -> Response<Full<Bytes>> {
246 respond(
247 status,
248 media.content_type(),
249 serde_json::to_vec(body).unwrap_or_default(),
250 )
251}
252
253fn request_error(media: Media, well_formed: bool, errors: &RequestErrors) -> Response<Full<Bytes>> {
256 let status = if media == Media::Json && well_formed {
257 200
258 } else {
259 400
260 };
261 graphql(status, media, &errors.body())
262}
263
264fn not_allowed(allow: &'static str) -> Response<Full<Bytes>> {
265 let mut r = respond(405, "text/plain", b"method not allowed".to_vec());
266 r.headers_mut()
267 .insert(header::ALLOW, header::HeaderValue::from_static(allow));
268 r
269}
270
271fn outcome(ctx: &SpawnContext, id: ConnectionId, operation: &str, decision: &str) {
272 let summary = format!("GraphQL connection {id} operation={operation} decision={decision}");
273 let log = Log::new(Some(&ctx.status_tx));
274 if decision.starts_with("fail_closed_") || decision == "model_silent" {
275 log.error(summary);
276 } else {
277 log.info(summary);
278 }
279}
280
281async fn handle(
282 shared: &Shared,
283 id: ConnectionId,
284 req: Request<Incoming>,
285 upgrade: &UpgradeSlot,
286) -> Response<Full<Bytes>> {
287 let ctx = &shared.ctx;
288 ctx.state
289 .update_connection_stats(
290 ctx.server_id,
291 id,
292 Some(req.uri().to_string().len() as u64),
293 None,
294 Some(1),
295 None,
296 )
297 .await;
298 let response = route(shared, id, req, upgrade).await;
299 let sent = hyper::body::Body::size_hint(response.body())
300 .exact()
301 .unwrap_or(0);
302 ctx.state
303 .update_connection_stats(ctx.server_id, id, None, Some(sent), None, Some(1))
304 .await;
305 response
306}
307
308struct GraphqlRequest {
309 query: String,
310 operation_name: Option<String>,
311 variables: Map<String, Value>,
312}
313
314fn percent_decode(s: &str) -> Option<String> {
316 let bytes = s.as_bytes();
317 let mut out = Vec::with_capacity(bytes.len());
318 let mut i = 0;
319 while i < bytes.len() {
320 match bytes[i] {
321 b'+' => out.push(b' '),
322 b'%' => {
323 let hex = s.get(i + 1..i + 3)?;
324 out.push(u8::from_str_radix(hex, 16).ok()?);
325 i += 2;
326 }
327 b => out.push(b),
328 }
329 i += 1;
330 }
331 String::from_utf8(out).ok()
332}
333
334fn decode_params(
335 query: Option<Value>,
336 operation_name: Option<Value>,
337 variables: Option<Value>,
338) -> Result<GraphqlRequest, &'static str> {
339 let query = match query {
340 Some(Value::String(q)) if !q.trim().is_empty() => q,
341 _ => return Err("query is required and must be a non-empty string"),
342 };
343 let operation_name = match operation_name {
344 None | Some(Value::Null) => None,
345 Some(Value::String(n)) if n.len() <= 256 => Some(n),
346 _ => return Err("operationName must be a string"),
347 };
348 let variables = match variables {
349 None | Some(Value::Null) => Map::new(),
350 Some(Value::Object(m)) => m,
351 _ => return Err("variables must be an object"),
352 };
353 Ok(GraphqlRequest {
354 query,
355 operation_name,
356 variables,
357 })
358}
359
360fn from_query_string(qs: &str) -> Result<GraphqlRequest, &'static str> {
361 let (mut query, mut name, mut vars) = (None, None, None);
362 for pair in qs.split('&').filter(|p| !p.is_empty()).take(16) {
363 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
364 let v = percent_decode(v).ok_or("query string is not valid UTF-8")?;
365 match k {
366 "query" => query = Some(Value::String(v)),
367 "operationName" if !v.is_empty() => name = Some(Value::String(v)),
368 "variables" if !v.is_empty() => {
369 vars = Some(serde_json::from_str(&v).map_err(|_| "variables is not JSON")?)
370 }
371 _ => {}
372 }
373 }
374 decode_params(query, name, vars)
375}
376
377async fn route(
378 shared: &Shared,
379 id: ConnectionId,
380 mut req: Request<Incoming>,
381 upgrade: &UpgradeSlot,
382) -> Response<Full<Bytes>> {
383 if req.uri().path() != shared.endpoint {
384 return respond(404, "text/plain", b"not found".to_vec());
385 }
386 if ws::is_upgrade(&req) {
387 return match ws::handshake(&req) {
388 Ok(accepted) => {
389 if let Ok(mut slot) = upgrade.lock() {
390 *slot = Some(hyper::upgrade::on(&mut req));
391 }
392 accepted.map(|()| Full::new(Bytes::new()))
393 }
394 Err((status, why)) => respond(status, "text/plain", why.as_bytes().to_vec()),
395 };
396 }
397 let method = req.method().clone();
398 if method != Method::GET && method != Method::POST {
399 return not_allowed("GET, POST");
400 }
401 let accept = req
402 .headers()
403 .get(header::ACCEPT)
404 .and_then(|v| v.to_str().ok())
405 .map(str::to_owned);
406 let Some(media) = negotiate(accept.as_deref()) else {
407 return respond(
408 406,
409 "text/plain",
410 b"accept application/graphql-response+json or application/json".to_vec(),
411 );
412 };
413 let parsed = if method == Method::GET {
414 from_query_string(req.uri().query().unwrap_or(""))
415 } else {
416 let content_type = req
417 .headers()
418 .get(header::CONTENT_TYPE)
419 .and_then(|v| v.to_str().ok())
420 .unwrap_or("")
421 .split(';')
422 .next()
423 .unwrap_or("")
424 .trim()
425 .to_ascii_lowercase();
426 if content_type != "application/json" {
427 return respond(
428 415,
429 "text/plain",
430 b"POST bodies must be application/json".to_vec(),
431 );
432 }
433 let body = match tokio::time::timeout(
434 BODY_TIMEOUT,
435 Limited::new(req.into_body(), engine::MAX_BODY_BYTES).collect(),
436 )
437 .await
438 {
439 Ok(Ok(b)) => b.to_bytes(),
440 _ => {
441 return request_error(
442 media,
443 false,
444 &RequestErrors::one("request body unreadable or over 1 MiB"),
445 )
446 }
447 };
448 match serde_json::from_slice::<Value>(&body) {
449 Ok(Value::Object(mut m)) if engine::budget_ok(&Value::Object(m.clone())) => {
450 decode_params(
451 m.remove("query"),
452 m.remove("operationName"),
453 m.remove("variables"),
454 )
455 }
456 _ => Err("body must be a JSON object"),
457 }
458 };
459 let request = match parsed {
460 Ok(r) => r,
461 Err(e) => return request_error(media, false, &RequestErrors::one(e)),
462 };
463 let prepared = match engine::prepare(
464 &shared.schema,
465 &request.query,
466 request.operation_name.as_deref(),
467 &request.variables,
468 ) {
469 Ok(p) => p,
470 Err(errors) => {
471 outcome(&shared.ctx, id, "request", "protocol_refusal");
472 return request_error(media, true, &errors);
473 }
474 };
475 match prepared.operation_type {
476 OperationType::Mutation if method == Method::GET => return not_allowed("POST"),
477 OperationType::Subscription => {
478 return request_error(
479 media,
480 true,
481 &RequestErrors::one(
482 "subscriptions need a WebSocket using the graphql-transport-ws subprotocol",
483 ),
484 )
485 }
486 _ => {}
487 }
488 match answer_operation(shared, id, &prepared, &request.query, method.as_str()).await {
489 Ok(body) => graphql(200, media, &body),
490 Err(e) => unavailable(media, &e),
491 }
492}
493
494fn collect_answers(results: Vec<ActionResult>, allowed: &[&str]) -> Vec<Value> {
497 let mut out = Vec::new();
498 for r in results {
499 match r {
500 ActionResult::Custom { name, data } if allowed.contains(&name.as_str()) => {
501 out.push(data)
502 }
503 ActionResult::CloseConnection => out.push(json!({"type": "disconnect"})),
504 ActionResult::Multiple(items) => out.extend(collect_answers(items, allowed)),
505 _ => {}
506 }
507 }
508 out
509}
510
511async fn ask(
514 shared: &Shared,
515 id: ConnectionId,
516 event: Event,
517 op_type: &str,
518 allowed: &[&str],
519) -> anyhow::Result<Vec<Value>> {
520 let ctx = &shared.ctx;
521 let result = match call_llm(
522 &ctx.llm_client,
523 &ctx.state,
524 ctx.server_id,
525 Some(id),
526 &event,
527 &actions::GraphqlProtocol,
528 )
529 .await
530 {
531 Ok(r) => r,
532 Err(e) => {
533 outcome(ctx, id, op_type, "fail_closed_llm_error");
534 return Err(e);
535 }
536 };
537 if !result.failures.is_empty() {
538 outcome(ctx, id, op_type, "fail_closed_invalid_reply");
539 anyhow::bail!("invalid handler answer");
540 }
541 Ok(collect_answers(result.protocol_results, allowed))
542}
543
544async fn answer_operation(
547 shared: &Shared,
548 id: ConnectionId,
549 prepared: &engine::Prepared,
550 query: &str,
551 method: &str,
552) -> anyhow::Result<Value> {
553 if prepared.is_introspection() {
554 let r = prepared.execute(&shared.schema, &json!({}), shared.introspection)?;
555 return Ok(serde_json::to_value(r)?);
556 }
557 let op_type = engine::operation_type_name(prepared.operation_type);
558 let event = Event::new(
559 &actions::OPERATION_EVENT,
560 json!({
561 "operation_type": op_type,
562 "operation_name": prepared.operation_name,
563 "query": query,
564 "variables": prepared.variables_json(),
565 "root_fields": prepared.root_fields(),
566 "shape": prepared.shape(&shared.schema),
567 "method": method,
568 }),
569 );
570 let ctx = &shared.ctx;
571 let mut answers = ask(
572 shared,
573 id,
574 event,
575 op_type,
576 &["graphql_result", "graphql_error"],
577 )
578 .await?;
579 let answer = match answers.len() {
580 1 => answers.remove(0),
581 0 => {
582 outcome(ctx, id, op_type, "model_silent");
583 anyhow::bail!("no handler answer");
584 }
585 _ => {
586 outcome(ctx, id, op_type, "fail_closed_invalid_reply");
587 anyhow::bail!("more than one handler answer");
588 }
589 };
590 if answer["type"] == "graphql_error" {
591 outcome(ctx, id, op_type, "model_reject");
592 return Ok(json!({"errors": [refusal(&answer)], "data": null}));
593 }
594 match execute_answer(shared, prepared, &answer) {
595 Ok(body) => {
596 outcome(ctx, id, op_type, "model_answer");
597 Ok(body)
598 }
599 Err(e) => {
600 outcome(ctx, id, op_type, "fail_closed_invalid_reply");
601 Err(e)
602 }
603 }
604}
605
606fn refusal(answer: &Value) -> Value {
608 let mut error = json!({"message": answer["message"]});
609 if let Some(x) = answer.get("extensions").filter(|x| x.is_object()) {
610 error["extensions"] = x.clone();
611 }
612 error
613}
614
615fn execute_answer(
618 shared: &Shared,
619 prepared: &engine::Prepared,
620 answer: &Value,
621) -> anyhow::Result<Value> {
622 let mut r = prepared.execute(&shared.schema, &answer["data"], shared.introspection)?;
623 r.errors
624 .extend(engine::handler_errors(answer.get("errors"))?);
625 Ok(serde_json::to_value(r)?)
626}
627
628fn unavailable(media: Media, e: &anyhow::Error) -> Response<Full<Bytes>> {
631 let failure = crate::utils::wire_failure::WireFailure::classify(e);
632 graphql(
633 if failure.is_overloaded() { 503 } else { 500 },
634 media,
635 &json!({"errors": [{"message": failure.text()}]}),
636 )
637}