Skip to main content

netget/client/graphql/
actions.rs

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}