forked from
slices.network/slices
Highly ambitious ATProtocol AppView service and sdks
1//! GraphQL HTTP handler for Axum
2
3use async_graphql::dynamic::Schema;
4use async_graphql::http::{WebSocket as GraphQLWebSocket, WebSocketProtocols, WsMessage};
5use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
6use axum::{
7 extract::{
8 Query, State, WebSocketUpgrade,
9 ws::{Message, WebSocket},
10 },
11 http::{HeaderMap, StatusCode},
12 response::{Html, Response},
13};
14use futures_util::{SinkExt, StreamExt};
15use serde::Deserialize;
16use std::sync::Arc;
17use tokio::sync::RwLock;
18
19use crate::AppState;
20use crate::errors::AppError;
21use crate::graphql::GraphQLContext;
22
23/// Global schema cache (one schema per slice)
24/// This prevents rebuilding the schema on every request
25type SchemaCache = Arc<RwLock<std::collections::HashMap<String, Schema>>>;
26
27lazy_static::lazy_static! {
28 static ref SCHEMA_CACHE: SchemaCache = Arc::new(RwLock::new(std::collections::HashMap::new()));
29}
30
31#[derive(Deserialize, Default)]
32pub struct GraphQLParams {
33 pub slice: Option<String>,
34}
35
36/// GraphQL query handler
37/// Accepts slice URI from either query parameter (?slice=...) or HTTP header (X-Slice-Uri)
38pub async fn graphql_handler(
39 State(state): State<AppState>,
40 Query(params): Query<GraphQLParams>,
41 headers: HeaderMap,
42 req: GraphQLRequest,
43) -> Result<GraphQLResponse, (StatusCode, String)> {
44 // Get slice URI from query param or header
45 let slice_uri = params
46 .slice
47 .or_else(|| {
48 headers
49 .get("x-slice-uri")
50 .and_then(|h| h.to_str().ok())
51 .map(|s| s.to_string())
52 })
53 .ok_or_else(|| {
54 (
55 StatusCode::BAD_REQUEST,
56 "Missing slice parameter. Provide either ?slice=... query parameter or X-Slice-Uri header".to_string(),
57 )
58 })?;
59
60 let schema = match get_or_build_schema(&state, &slice_uri).await {
61 Ok(s) => s,
62 Err(e) => {
63 tracing::error!("Failed to get GraphQL schema: {:?}", e);
64 return Ok(async_graphql::Response::from_errors(vec![
65 async_graphql::ServerError::new(format!("Schema error: {:?}", e), None),
66 ])
67 .into());
68 }
69 };
70
71 // Create GraphQL context with DataLoader
72 let gql_context = GraphQLContext::new(state.database.clone());
73
74 // Execute query with context
75 Ok(schema
76 .execute(req.into_inner().data(gql_context))
77 .await
78 .into())
79}
80
81/// GraphiQL UI handler
82/// Configures GraphiQL with the slice URI in headers
83pub async fn graphql_playground(
84 Query(params): Query<GraphQLParams>,
85) -> Result<Html<String>, (StatusCode, String)> {
86 let slice_uri = params.slice.ok_or_else(|| {
87 (
88 StatusCode::BAD_REQUEST,
89 "Missing slice parameter. Provide ?slice=... query parameter".to_string(),
90 )
91 })?;
92
93 // Create GraphiQL with pre-configured headers using React 19 and modern ESM
94 let graphiql_html = format!(
95 r#"<!doctype html>
96<html lang="en">
97 <head>
98 <meta charset="UTF-8" />
99 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
100 <title>Slices GraphiQL</title>
101 <style>
102 body {{
103 margin: 0;
104 }}
105
106 #graphiql {{
107 height: 100dvh;
108 }}
109
110 .loading {{
111 height: 100%;
112 display: flex;
113 align-items: center;
114 justify-content: center;
115 font-size: 4rem;
116 }}
117 </style>
118 <link rel="stylesheet" href="https://esm.sh/graphiql/dist/style.css" />
119 <link
120 rel="stylesheet"
121 href="https://esm.sh/@graphiql/plugin-explorer/dist/style.css"
122 />
123 <script type="importmap">
124 {{
125 "imports": {{
126 "react": "https://esm.sh/react@19.1.0",
127 "react/": "https://esm.sh/react@19.1.0/",
128
129 "react-dom": "https://esm.sh/react-dom@19.1.0",
130 "react-dom/": "https://esm.sh/react-dom@19.1.0/",
131
132 "graphiql": "https://esm.sh/graphiql?standalone&external=react,react-dom,@graphiql/react,graphql",
133 "graphiql/": "https://esm.sh/graphiql/",
134 "@graphiql/plugin-explorer": "https://esm.sh/@graphiql/plugin-explorer?standalone&external=react,@graphiql/react,graphql",
135 "@graphiql/react": "https://esm.sh/@graphiql/react?standalone&external=react,react-dom,graphql,@graphiql/toolkit,@emotion/is-prop-valid",
136
137 "@graphiql/toolkit": "https://esm.sh/@graphiql/toolkit?standalone&external=graphql",
138 "graphql": "https://esm.sh/graphql@16.11.0",
139 "@emotion/is-prop-valid": "data:text/javascript,"
140 }}
141 }}
142 </script>
143 <script type="module">
144 import React from 'react';
145 import ReactDOM from 'react-dom/client';
146 import {{ GraphiQL, HISTORY_PLUGIN }} from 'graphiql';
147 import {{ createGraphiQLFetcher }} from '@graphiql/toolkit';
148 import {{ explorerPlugin }} from '@graphiql/plugin-explorer';
149 import 'graphiql/setup-workers/esm.sh';
150
151 const fetcher = createGraphiQLFetcher({{
152 url: '/graphql',
153 subscriptionUrl: '/graphql/ws?slice={}',
154 headers: {{
155 'X-Slice-Uri': '{}'
156 }}
157 }});
158 const plugins = [HISTORY_PLUGIN, explorerPlugin()];
159
160 function App() {{
161 return React.createElement(GraphiQL, {{
162 fetcher,
163 plugins,
164 defaultEditorToolsVisibility: true,
165 }});
166 }}
167
168 const container = document.getElementById('graphiql');
169 const root = ReactDOM.createRoot(container);
170 root.render(React.createElement(App));
171 </script>
172 </head>
173 <body>
174 <div id="graphiql">
175 <div class="loading">Loading…</div>
176 </div>
177 </body>
178</html>"#,
179 slice_uri.replace("'", "\\'").replace("\"", "\\\""),
180 slice_uri.replace("'", "\\'").replace("\"", "\\\"")
181 );
182
183 Ok(Html(graphiql_html))
184}
185
186/// GraphQL WebSocket handler for subscriptions
187/// Accepts slice URI from query parameter (?slice=...)
188pub async fn graphql_subscription_handler(
189 State(state): State<AppState>,
190 Query(params): Query<GraphQLParams>,
191 ws: WebSocketUpgrade,
192) -> Result<Response, (StatusCode, String)> {
193 let slice_uri = params.slice.ok_or_else(|| {
194 (
195 StatusCode::BAD_REQUEST,
196 "Missing slice parameter. Provide ?slice=... query parameter".to_string(),
197 )
198 })?;
199
200 let schema = match get_or_build_schema(&state, &slice_uri).await {
201 Ok(s) => s,
202 Err(e) => {
203 tracing::error!("Failed to get GraphQL schema: {:?}", e);
204 return Err((
205 StatusCode::INTERNAL_SERVER_ERROR,
206 format!("Schema error: {:?}", e),
207 ));
208 }
209 };
210
211 // Create GraphQL context with DataLoader
212 let gql_context = GraphQLContext::new(state.database.clone());
213
214 // Upgrade to WebSocket and handle GraphQL subscriptions manually
215 Ok(ws
216 .protocols(["graphql-transport-ws", "graphql-ws"])
217 .on_upgrade(move |socket| handle_graphql_ws(socket, schema, gql_context)))
218}
219
220/// Handle GraphQL WebSocket connection
221async fn handle_graphql_ws(socket: WebSocket, schema: Schema, gql_context: GraphQLContext) {
222 let (ws_sender, ws_receiver) = socket.split();
223
224 // Convert axum WebSocket messages to strings for async-graphql
225 let input = ws_receiver.filter_map(|msg| {
226 futures_util::future::ready(match msg {
227 Ok(Message::Text(text)) => Some(text.to_string()),
228 _ => None, // Ignore other message types
229 })
230 });
231
232 // Create GraphQL WebSocket handler with context
233 let mut stream = GraphQLWebSocket::new(schema.clone(), input, WebSocketProtocols::GraphQLWS)
234 .on_connection_init(move |_| {
235 let gql_ctx = gql_context.clone();
236 async move {
237 let mut data = async_graphql::Data::default();
238 data.insert(gql_ctx);
239 Ok(data)
240 }
241 });
242
243 // Send GraphQL messages back through WebSocket
244 let mut ws_sender = ws_sender;
245 while let Some(msg) = stream.next().await {
246 let axum_msg = match msg {
247 WsMessage::Text(text) => Message::Text(text.into()),
248 WsMessage::Close(code, reason) => Message::Close(Some(axum::extract::ws::CloseFrame {
249 code,
250 reason: reason.into(),
251 })),
252 };
253
254 if ws_sender.send(axum_msg).await.is_err() {
255 break;
256 }
257 }
258}
259
260/// Gets schema from cache or builds it if not cached
261async fn get_or_build_schema(state: &AppState, slice_uri: &str) -> Result<Schema, AppError> {
262 // Check cache first
263 {
264 let cache = SCHEMA_CACHE.read().await;
265 if let Some(schema) = cache.get(slice_uri) {
266 return Ok(schema.clone());
267 }
268 }
269
270 // Build schema
271 let schema =
272 crate::graphql::build_graphql_schema(state.database.clone(), slice_uri.to_string())
273 .await
274 .map_err(|e| AppError::Internal(format!("Failed to build GraphQL schema: {}", e)))?;
275
276 // Cache it
277 {
278 let mut cache = SCHEMA_CACHE.write().await;
279 cache.insert(slice_uri.to_string(), schema.clone());
280 }
281
282 Ok(schema)
283}