Highly ambitious ATProtocol AppView service and sdks
at main 283 lines 9.2 kB view raw
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}