Skip to main content

netget/server/graphql/
actions.rs

1use super::engine;
2use crate::llm::actions::{
3    protocol_trait::{ActionResult, Protocol, Server},
4    ActionDefinition, Parameter, ParameterDefinition,
5};
6use crate::protocol::log_template::LogTemplate;
7use crate::protocol::{EventType, SpawnContext};
8use crate::state::app_state::AppState;
9use anyhow::{bail, ensure, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct GraphqlProtocol;
15impl GraphqlProtocol {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
22    Parameter {
23        name: name.into(),
24        type_hint: kind.into(),
25        description: description.into(),
26        required,
27    }
28}
29
30pub fn action(
31    name: &str,
32    description: &str,
33    parameters: Vec<Parameter>,
34    example: Value,
35) -> ActionDefinition {
36    ActionDefinition {
37        name: name.into(),
38        description: description.into(),
39        parameters,
40        example,
41        log_template: Some(LogTemplate::new().with_info(format!("-> GraphQL {name}"))),
42    }
43}
44
45fn result_action() -> ActionDefinition {
46    action(
47        "graphql_result",
48        "Answer the operation with data shaped like the event's `shape`: an object keyed by the response keys (aliases included), leaves as JSON values of their GraphQL type, objects as nested objects, lists as arrays; give `__typename` for union/interface values. Rust executes the query over this data, so missing nullable fields become null and a wrong type becomes a field error with its path.",
49        vec![
50            parameter("data", "object", "Response data keyed by response key, e.g. {\"book\": {\"title\": \"Dune\"}}", true),
51            parameter("errors", "array", "Optional field errors to report alongside the data: [{message, path: [\"book\", \"author\"], extensions}]", false),
52        ],
53        json!({"type":"graphql_result","data":{"hello":"Hello, world"}}),
54    )
55}
56
57fn error_action() -> ActionDefinition {
58    action(
59        "graphql_error",
60        "Refuse the whole operation (e.g. not authorized): the response carries data null and this one error",
61        vec![
62            parameter("message", "string", "Error message the client sees (1 to 4096 bytes)", true),
63            parameter("extensions", "object", "Optional machine-readable details, e.g. {\"code\": \"FORBIDDEN\"}", false),
64        ],
65        json!({"type":"graphql_error","message":"not authorized","extensions":{"code":"FORBIDDEN"}}),
66    )
67}
68
69fn event_action() -> ActionDefinition {
70    action(
71        "graphql_event",
72        "Push one event to an active subscription (graphql-transport-ws `next`). data is keyed like the subscription's `shape`; Rust executes the subscription's selection over it.",
73        vec![
74            parameter("subscription_id", "string", "The subscription to feed; may be omitted when answering that subscription's own start event", false),
75            parameter("data", "object", "Event data keyed by response key, e.g. {\"bookAdded\": {\"title\": \"Emma\"}}", true),
76            parameter("errors", "array", "Optional field errors for this event: [{message, path, extensions}]", false),
77        ],
78        json!({"type":"graphql_event","subscription_id":"1","data":{"bookAdded":{"title":"Emma"}}}),
79    )
80}
81
82fn complete_action() -> ActionDefinition {
83    action(
84        "graphql_complete",
85        "End an active subscription normally (graphql-transport-ws `complete`); no further events can be sent to it",
86        vec![parameter("subscription_id", "string", "The subscription to end; may be omitted when answering its own start event", false)],
87        json!({"type":"graphql_complete","subscription_id":"1"}),
88    )
89}
90
91fn disconnect_action() -> ActionDefinition {
92    action(
93        "disconnect",
94        "Close this WebSocket connection and every subscription on it",
95        vec![],
96        json!({"type":"disconnect"}),
97    )
98}
99
100pub static SUBSCRIPTION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
101    EventType::new(
102        "graphql_subscription_start",
103        "A subscription over graphql-transport-ws validated against the schema. Answer with zero or more graphql_event (sent in order), optionally graphql_complete, or graphql_error to refuse it; later events can be pushed to the connection with send_to_peer.",
104        json!({"type":"graphql_event","data":{"bookAdded":{"title":"Emma"}}}),
105    )
106    .with_parameters(vec![
107        parameter("subscription_id", "string", "The client's id for this subscription", true),
108        parameter("operation_name", "string", "The operation's name, when it has one", false),
109        parameter("query", "string", "The GraphQL document as sent", true),
110        parameter("variables", "object", "Variables after coercion to their declared types", true),
111        parameter("root_fields", "array", "The subscription's root field: [{response_key, field, arguments}]", true),
112        parameter("shape", "object", "Skeleton of each event's data: response keys mapped to GraphQL types or nested skeletons", true),
113    ])
114    .with_actions(vec![event_action(), complete_action(), error_action()])
115});
116
117pub static OPERATION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
118    EventType::new(
119        "graphql_operation",
120        "A query or mutation that parsed and validated against the schema (introspection is answered by Rust). Supply the data for its root fields.",
121        result_action().example.clone(),
122    )
123    .with_parameters(vec![
124        parameter("operation_type", "string", "query or mutation", true),
125        parameter("operation_name", "string", "The operation's name, when it has one", false),
126        parameter("query", "string", "The GraphQL document as sent", true),
127        parameter("variables", "object", "Variables after coercion to their declared types", true),
128        parameter("root_fields", "array", "Root fields asked for: [{response_key, field, arguments}] with variables substituted", true),
129        parameter("shape", "object", "Skeleton of the data to return: response keys mapped to GraphQL types or nested skeletons", true),
130        parameter("method", "string", "GET or POST, or WS for an operation sent over a graphql-transport-ws socket", true),
131    ])
132    .with_actions(vec![result_action(), error_action()])
133});
134
135fn startup(
136    name: &str,
137    kind: &str,
138    description: &str,
139    example: Value,
140    default: Option<Value>,
141) -> ParameterDefinition {
142    ParameterDefinition {
143        name: name.into(),
144        type_hint: kind.into(),
145        description: description.into(),
146        required: false,
147        example,
148        default,
149    }
150}
151
152impl Protocol for GraphqlProtocol {
153    fn protocol_name(&self) -> &'static str {
154        "GraphQL"
155    }
156    fn stack_name(&self) -> &'static str {
157        "ETH>IP>TCP>HTTP>GraphQL"
158    }
159    fn description(&self) -> &'static str {
160        "GraphQL server over HTTP and graphql-transport-ws: schema-validated queries, mutations and subscriptions, introspection, spec execution over handler data"
161    }
162    fn keywords(&self) -> Vec<&'static str> {
163        vec![
164            "graphql",
165            "gql",
166            "graphql server",
167            "graphql api",
168            "graphql subscriptions",
169            "graphql-ws",
170        ]
171    }
172    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
173        vec![event_action(), complete_action(), disconnect_action()]
174    }
175    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
176        vec![
177            result_action(),
178            error_action(),
179            event_action(),
180            complete_action(),
181        ]
182    }
183    fn get_event_types(&self) -> Vec<EventType> {
184        vec![OPERATION_EVENT.clone(), SUBSCRIPTION_EVENT.clone()]
185    }
186    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
187        vec![
188            startup(
189                "schema",
190                "string",
191                "GraphQL SDL the server validates against and introspects (up to 256 KiB); needs a Query type",
192                json!("type Query { book(id: ID!): Book } type Book { id: ID! title: String! }"),
193                Some(json!(super::DEFAULT_SCHEMA)),
194            ),
195            startup(
196                "endpoint",
197                "string",
198                "URL path that serves GraphQL",
199                json!("/api/graphql"),
200                Some(json!(super::DEFAULT_ENDPOINT)),
201            ),
202            startup(
203                "connection_init_timeout_secs",
204                "integer",
205                "Seconds a graphql-transport-ws socket may wait before connection_init; later it is closed with 4408 (1 to 300)",
206                json!(3),
207                Some(json!(super::ws::CONNECTION_INIT_TIMEOUT.as_secs())),
208            ),
209            startup(
210                "introspection",
211                "boolean",
212                "Answer __schema and __type queries (off: they become field errors)",
213                json!(false),
214                Some(json!(super::DEFAULT_INTROSPECTION)),
215            ),
216        ]
217    }
218    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
219        use crate::protocol::metadata::*;
220        ProtocolMetadataV2::builder()
221            .state(DevelopmentState::Experimental)
222            .privilege_requirement(PrivilegeRequirement::None)
223            .implementation("hyper HTTP/1.1 per the GraphQL-over-HTTP draft (POST JSON and GET, application/graphql-response+json and application/json) plus graphql-transport-ws subscriptions on the same endpoint (tokio-tungstenite after hyper's upgrade); apollo-compiler 1.33 parses, validates, introspects and executes")
224            .llm_control("The data (and field errors) behind every query and mutation, each subscription's events and completion, or a refusal")
225            .e2e_testing("tests/server/graphql: gql 4.4.0 (independent; builds its schema from our introspection and validates locally) runs queries, aliases, fragments on a union, variables, a mutation and field errors over HTTP, and subscriptions over graphql-transport-ws; raw HTTP and WebSocket lifecycle rules")
226            .notes("Subscriptions only over graphql-transport-ws (no SSE, no legacy subscriptions-transport-ws); connection_init is always acknowledged (no auth hook). No batching, persisted queries, file uploads or @defer/@stream. No resolvers in Rust: the handler supplies the data, Rust executes the query over it. 1 MiB bodies and messages, 64 KiB queries, parser depth 64, 64 subscriptions per socket.")
227            .answers_on_failure()
228            .max_inbound_bytes(engine::MAX_BODY_BYTES)
229            .build()
230    }
231    fn example_prompt(&self) -> &'static str {
232        "GraphQL server with books and authors; answer queries with a few classic novels"
233    }
234    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
235        let llm = json!({"type":"open_server","base_stack":"graphql","port":4000,"instruction":"A bookstore API with a few classic novels","startup_params":{"schema":"type Query { books: [Book!]! } type Book { title: String! author: String! }"}});
236        let mut static_example = llm.clone();
237        static_example["event_handlers"] = json!([
238            {"event_pattern":"graphql_operation","handler":{"type":"static","actions":[{"type":"graphql_result","data":{"books":[{"title":"Dune","author":"Frank Herbert"}]}}]}}
239        ]);
240        let mut scripted = static_example.clone();
241        scripted["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[{'type':'graphql_result','data':{'books':[{'title':'Dune','author':'Frank Herbert'}]}}]}))"});
242        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
243    }
244    fn group_name(&self) -> &'static str {
245        "AI & API"
246    }
247}
248
249impl GraphqlProtocol {
250    /// Shape checks for the actions a subscription accepts (`graphql_event`, `graphql_complete`,
251    /// `disconnect`); whether the subscription exists is the connection's business.
252    pub fn check_peer_action(v: &Value) -> Result<()> {
253        ensure!(engine::budget_ok(v), "action exceeds the GraphQL bounds");
254        if let Some(id) = v.get("subscription_id").filter(|x| !x.is_null()) {
255            ensure!(
256                id.as_str().is_some_and(|s| !s.is_empty() && s.len() <= 128),
257                "subscription_id must be 1..128 characters"
258            );
259        }
260        match v["type"].as_str() {
261            Some("graphql_event") => {
262                ensure!(
263                    v["data"].is_object(),
264                    "data must be an object keyed by response key"
265                );
266                engine::handler_errors(v.get("errors"))?;
267            }
268            Some("graphql_complete" | "disconnect") => {}
269            _ => {
270                bail!("a GraphQL connection accepts graphql_event, graphql_complete or disconnect")
271            }
272        }
273        Ok(())
274    }
275}
276
277impl Server for GraphqlProtocol {
278    fn spawn(
279        &self,
280        ctx: SpawnContext,
281    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
282    {
283        Box::pin(super::spawn(ctx))
284    }
285    fn execute_action(&self, v: Value) -> Result<ActionResult> {
286        ensure!(engine::budget_ok(&v), "answer exceeds the GraphQL bounds");
287        match v["type"].as_str() {
288            Some("graphql_result") => {
289                ensure!(
290                    v["data"].is_object(),
291                    "data must be an object keyed by response key"
292                );
293                engine::handler_errors(v.get("errors"))?;
294            }
295            Some("graphql_error") => {
296                ensure!(
297                    v["message"]
298                        .as_str()
299                        .is_some_and(|m| !m.is_empty() && m.len() <= 4096),
300                    "message must be 1..4096 bytes"
301                );
302                if let Some(x) = v.get("extensions").filter(|x| !x.is_null()) {
303                    ensure!(x.is_object(), "extensions must be an object");
304                }
305            }
306            Some("graphql_event" | "graphql_complete") => Self::check_peer_action(&v)?,
307            Some("disconnect") => return Ok(ActionResult::CloseConnection),
308            _ => bail!("Unknown GraphQL server action"),
309        }
310        Ok(ActionResult::Custom {
311            name: v["type"].as_str().unwrap_or_default().into(),
312            data: v,
313        })
314    }
315}