1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::graphql::actions::{action, parameter};
8use crate::server::graphql::engine;
9use anyhow::{bail, ensure, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct GraphqlClientProtocol;
15impl GraphqlClientProtocol {
16 pub fn new() -> Self {
17 Self
18 }
19}
20
21fn query_action() -> ActionDefinition {
22 action(
23 "graphql_query",
24 "Run a GraphQL query or mutation. Rust checks the syntax, sends it per GraphQL over HTTP (POST JSON, or GET for queries when use_get is true) and checks that the answer is a well-formed GraphQL response.",
25 vec![
26 parameter("query", "string", "The GraphQL document, e.g. query($id: ID!) { book(id: $id) { title } }", true),
27 parameter("variables", "object", "Values for the document's variables, keyed by name without the $", false),
28 parameter("operation_name", "string", "Which operation to run when the document holds several", false),
29 parameter("use_get", "boolean", "Send a query as GET with URL parameters instead of POST (mutations are always POST)", false),
30 ],
31 json!({"type":"graphql_query","query":"{ hello }"}),
32 )
33}
34fn subscribe_action() -> ActionDefinition {
35 action(
36 "graphql_subscribe",
37 "Start a subscription over a graphql-transport-ws WebSocket to the same endpoint (opened and initialised on first use). Rust assigns the subscription id; each event arrives as graphql_subscription_event.",
38 vec![
39 parameter("query", "string", "A subscription document, e.g. subscription { bookAdded { title } }", true),
40 parameter("variables", "object", "Values for the document's variables, keyed by name without the $", false),
41 parameter("operation_name", "string", "Which operation to run when the document holds several", false),
42 ],
43 json!({"type":"graphql_subscribe","query":"subscription { bookAdded { title } }"}),
44 )
45}
46fn unsubscribe_action() -> ActionDefinition {
47 action(
48 "graphql_unsubscribe",
49 "Stop an active subscription (sends graphql-transport-ws complete)",
50 vec![parameter(
51 "subscription_id",
52 "string",
53 "The id reported in graphql_subscription_event",
54 true,
55 )],
56 json!({"type":"graphql_unsubscribe","subscription_id":"1"}),
57 )
58}
59fn disconnect_action() -> ActionDefinition {
60 action(
61 "disconnect",
62 "Stop this GraphQL client",
63 vec![],
64 json!({"type":"disconnect"}),
65 )
66}
67fn actions() -> Vec<ActionDefinition> {
68 vec![
69 query_action(),
70 subscribe_action(),
71 unsubscribe_action(),
72 disconnect_action(),
73 ]
74}
75
76pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
77 EventType::new(
78 "graphql_connected",
79 "The client is ready; when introspection is on and the server allows it, the root fields it offers are listed",
80 query_action().example.clone(),
81 )
82 .with_parameters(vec![
83 parameter("url", "string", "GraphQL endpoint URL in use", true),
84 parameter("root_fields", "object", "Root field signatures by operation type: {query: [\"book(id: ID!): Book\"], mutation: [...], subscription: [...]}", false),
85 parameter("introspection_error", "string", "Why introspection gave no schema (disabled, refused or not GraphQL)", false),
86 ])
87 .with_actions(actions())
88});
89pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
90 EventType::new(
91 "graphql_response",
92 "The server's answer to the last operation",
93 disconnect_action().example.clone(),
94 )
95 .with_parameters(vec![
96 parameter("operation_type", "string", "query or mutation", true),
97 parameter(
98 "operation_name",
99 "string",
100 "Name of the operation sent, when it had one",
101 false,
102 ),
103 parameter("status", "number", "HTTP status of the answer", true),
104 parameter(
105 "media_type",
106 "string",
107 "application/graphql-response+json or application/json",
108 false,
109 ),
110 parameter(
111 "data",
112 "object",
113 "The response data (null when the operation failed entirely)",
114 false,
115 ),
116 parameter(
117 "errors",
118 "array",
119 "GraphQL errors [{message, path, locations, extensions}], absent when none",
120 false,
121 ),
122 parameter(
123 "error",
124 "string",
125 "Set when the answer was not a GraphQL response at all (e.g. HTTP 404)",
126 false,
127 ),
128 ])
129 .with_actions(actions())
130});
131
132fn subscription_parameters(
133 extra: Vec<crate::llm::actions::Parameter>,
134) -> Vec<crate::llm::actions::Parameter> {
135 let mut p = vec![
136 parameter(
137 "subscription_id",
138 "string",
139 "Rust-assigned id of the subscription",
140 true,
141 ),
142 parameter(
143 "operation_name",
144 "string",
145 "Name of the subscription operation, when it had one",
146 false,
147 ),
148 ];
149 p.extend(extra);
150 p
151}
152pub static SUBSCRIPTION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
153 EventType::new(
154 "graphql_subscription_event",
155 "One event (`next`) of an active subscription",
156 unsubscribe_action().example.clone(),
157 )
158 .with_parameters(subscription_parameters(vec![
159 parameter(
160 "data",
161 "object",
162 "The event's data shaped by the subscription's selection",
163 false,
164 ),
165 parameter(
166 "errors",
167 "array",
168 "Field errors reported with this event",
169 false,
170 ),
171 ]))
172 .with_actions(actions())
173});
174pub static SUBSCRIPTION_ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
175 EventType::new(
176 "graphql_subscription_error",
177 "The server refused or ended a subscription with errors, or the WebSocket closed under it",
178 subscribe_action().example.clone(),
179 )
180 .with_parameters(subscription_parameters(vec![parameter(
181 "errors",
182 "array",
183 "Errors explaining why: [{message, locations, extensions}]",
184 true,
185 )]))
186 .with_actions(actions())
187});
188pub static SUBSCRIPTION_COMPLETE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
189 EventType::new(
190 "graphql_subscription_complete",
191 "The server ended a subscription normally; no more events will arrive for it",
192 disconnect_action().example.clone(),
193 )
194 .with_parameters(subscription_parameters(vec![]))
195 .with_actions(actions())
196});
197
198impl Protocol for GraphqlClientProtocol {
199 fn protocol_name(&self) -> &'static str {
200 "GraphQL"
201 }
202 fn stack_name(&self) -> &'static str {
203 "ETH>IP>TCP>HTTP>GraphQL"
204 }
205 fn keywords(&self) -> Vec<&'static str> {
206 vec![
207 "graphql",
208 "gql",
209 "graphql client",
210 "graphql subscriptions",
211 "graphql-ws",
212 ]
213 }
214 fn description(&self) -> &'static str {
215 "GraphQL client: introspects the root fields, runs queries and mutations over HTTP and subscriptions over graphql-transport-ws"
216 }
217 fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
218 actions()
219 }
220 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
221 vec![]
222 }
223 fn get_event_types(&self) -> Vec<EventType> {
224 vec![
225 CONNECTED_EVENT.clone(),
226 RESPONSE_EVENT.clone(),
227 SUBSCRIPTION_EVENT.clone(),
228 SUBSCRIPTION_ERROR_EVENT.clone(),
229 SUBSCRIPTION_COMPLETE_EVENT.clone(),
230 ]
231 }
232 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
233 vec![
234 ParameterDefinition {
235 name: "endpoint".into(),
236 type_hint: "string".into(),
237 description: "URL path of the GraphQL endpoint on remote_addr".into(),
238 required: false,
239 example: json!("/api/graphql"),
240 default: Some(json!(crate::server::graphql::DEFAULT_ENDPOINT)),
241 },
242 ParameterDefinition {
243 name: "introspect".into(),
244 type_hint: "boolean".into(),
245 description: "Ask for the schema's root fields on connect so the handler knows what it can query".into(),
246 required: false,
247 example: json!(false),
248 default: Some(json!(super::DEFAULT_INTROSPECT)),
249 },
250 ]
251 }
252 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
253 use crate::protocol::metadata::*;
254 ProtocolMetadataV2::builder()
255 .state(DevelopmentState::Experimental)
256 .privilege_requirement(PrivilegeRequirement::None)
257 .implementation("Shared http_fetch client (reqwest natively, no redirects); GraphQL over HTTP POST/GET with Accept application/graphql-response+json then application/json; graphql-transport-ws subscriptions over tokio-tungstenite; apollo-compiler syntax check; response shape checked")
258 .llm_control("Which queries, mutations and subscriptions to run, with which variables, when to stop a subscription, and what to do with each answer and event")
259 .e2e_testing("tests/client/graphql: strawberry-graphql 0.330.2 (independent) answers introspection, POST and GET queries, variables, a union, a mutation, a field error and a validation error, and serves graphql-transport-ws subscriptions (completion, cancellation, refusal)")
260 .notes("Plain HTTP and ws://; no batching, uploads or persisted queries, no SSE or legacy subscriptions-transport-ws. 1 MiB answers and messages, 30 s per request, 64 subscriptions per socket.")
261 .max_inbound_bytes(engine::MAX_BODY_BYTES)
262 .build()
263 }
264 fn example_prompt(&self) -> &'static str {
265 "Ask the GraphQL API at 127.0.0.1:4000 for its first five books"
266 }
267 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
268 let llm = json!({"type":"open_client","protocol":"graphql","remote_addr":"127.0.0.1:4000","instruction":"List the books and their authors"});
269 let mut static_example = llm.clone();
270 static_example["event_handlers"] = json!([
271 {"event_pattern":"graphql_connected","handler":{"type":"static","actions":[query_action().example]}},
272 {"event_pattern":"graphql_response","handler":{"type":"static","actions":[]}}
273 ]);
274 let mut scripted = static_example.clone();
275 scripted["event_handlers"][1]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[]}))"});
276 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
277 }
278 fn group_name(&self) -> &'static str {
279 "AI & API"
280 }
281}
282
283impl Client for GraphqlClientProtocol {
284 fn connect(
285 &self,
286 ctx: ConnectContext,
287 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
288 {
289 Box::pin(super::connect(ctx))
290 }
291 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
292 match v["type"].as_str() {
293 Some("graphql_query") => {
294 let query = v["query"].as_str().unwrap_or_default();
295 let (op, _) = engine::parse_operation(query, v["operation_name"].as_str())?;
296 ensure!(
297 op != apollo_compiler::executable::OperationType::Subscription,
298 "subscriptions go over the WebSocket: use graphql_subscribe"
299 );
300 if let Some(vars) = v.get("variables").filter(|x| !x.is_null()) {
301 ensure!(vars.is_object(), "variables must be an object");
302 }
303 ensure!(
304 engine::budget_ok(&v),
305 "operation exceeds the GraphQL bounds"
306 );
307 }
308 Some("graphql_subscribe") => {
309 let query = v["query"].as_str().unwrap_or_default();
310 let (op, _) = engine::parse_operation(query, v["operation_name"].as_str())?;
311 ensure!(
312 op == apollo_compiler::executable::OperationType::Subscription,
313 "graphql_subscribe needs a subscription operation; use graphql_query for queries and mutations"
314 );
315 if let Some(vars) = v.get("variables").filter(|x| !x.is_null()) {
316 ensure!(vars.is_object(), "variables must be an object");
317 }
318 ensure!(
319 engine::budget_ok(&v),
320 "operation exceeds the GraphQL bounds"
321 );
322 }
323 Some("graphql_unsubscribe") => {
324 ensure!(
325 v["subscription_id"]
326 .as_str()
327 .is_some_and(|s| !s.is_empty() && s.len() <= 128),
328 "subscription_id is required"
329 );
330 }
331 Some("disconnect") => return Ok(ClientActionResult::Disconnect),
332 _ => bail!("Unknown GraphQL client action"),
333 }
334 Ok(ClientActionResult::Custom {
335 name: v["type"].as_str().unwrap_or_default().into(),
336 data: v,
337 })
338 }
339}