Skip to main content

necrassrs_actix/
lib.rs

1//! Actix extraction and response conversion for application-owned GraphQL handlers.
2//!
3//! JSON syntax errors receive 400, invalid request fields 422, unsupported
4//! content types 415, and oversized bodies 413. Configure body limits through
5//! `web::JsonConfig`. Custom JSON error handlers remain effective.
6//! GraphQL syntax errors receive 400 and other request errors 422. All execution
7//! results use 200, including partial data and root-null results with errors.
8//! Responses negotiate `Accept`; missing headers preserve JSON and unsupported
9//! preferences receive 406 before runtime execution. GraphiQL routes are owned
10//! by the application and remain separate from response negotiation.
11
12use actix_web::{
13    FromRequest, HttpRequest, HttpResponse, Responder,
14    body::BoxBody,
15    error::{InternalError, JsonPayloadError},
16    http::{StatusCode, header},
17    web::Html,
18};
19use necrassrs::{JsonMap, Request, Response};
20use serde::Deserialize;
21
22/// Returns the built-in GraphiQL page for an application-selected endpoint.
23///
24/// Register this on an application-owned GET route. Assets load from pinned CDN
25/// URLs; the endpoint is escaped for its HTML attribute. This helper neither
26/// registers the GraphQL endpoint nor changes runtime introspection settings.
27pub fn graphiql_html(endpoint_url: &str) -> Html {
28    let endpoint_url = endpoint_url
29        .replace('&', "&")
30        .replace('"', """)
31        .replace('\'', "'")
32        .replace('<', "&lt;")
33        .replace('>', "&gt;");
34    Html::new(include_str!("graphiql.html").replace("__NECRASSRS_ENDPOINT__", &endpoint_url))
35}
36
37/// A GraphQL request parsed from an Actix JSON request body.
38///
39/// Extracts `query`, optional `variables`, and optional `operationName`.
40/// GraphQL document validation happens during runtime execution, not extraction.
41/// This consumes the request body; other extractors must not consume it again.
42/// Standard JSON extraction errors are mapped to the adapter's HTTP status codes.
43/// Custom `JsonConfig` handlers returning other error types retain their behavior.
44pub struct GraphQLRequest(
45    /// Runtime request to pass to execution.
46    pub Request,
47);
48
49#[derive(Deserialize)]
50#[serde(rename_all = "camelCase")]
51struct RequestBody {
52    query: String,
53    variables: Option<JsonMap>,
54    operation_name: Option<String>,
55}
56
57impl FromRequest for GraphQLRequest {
58    type Error = actix_web::Error;
59    type Future = std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self, Self::Error>>>>;
60
61    fn from_request(
62        req: &actix_web::HttpRequest,
63        payload: &mut actix_web::dev::Payload,
64    ) -> Self::Future {
65        let negotiation = negotiated_media_type(req);
66        let extraction = actix_web::web::Json::<RequestBody>::from_request(req, payload);
67
68        Box::pin(async move {
69            negotiation?;
70            let body = extraction
71                .await
72                .map_err(|error| {
73                    let status = match error.as_error::<JsonPayloadError>() {
74                        Some(JsonPayloadError::ContentType) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
75                        Some(JsonPayloadError::Deserialize(cause)) if cause.is_data() => {
76                            StatusCode::UNPROCESSABLE_ENTITY
77                        }
78                        _ => return error,
79                    };
80                    let response = HttpResponse::build(status)
81                        .content_type("text/plain; charset=utf-8")
82                        .body(error.to_string());
83                    InternalError::from_response(error, response).into()
84                })?
85                .into_inner();
86            let mut request = Request::new(body.query);
87
88            if let Some(variables) = body.variables {
89                request = request.with_variables(variables);
90            }
91            if let Some(operation_name) = body.operation_name {
92                request = request.with_operation_name(operation_name);
93            }
94
95            Ok(Self(request))
96        })
97    }
98}
99
100/// Converts a runtime response to JSON with HTTP 200 for execution results,
101/// including partial data and root-null results with errors.
102///
103/// GraphQL syntax errors receive 400; other request errors receive 422.
104/// The response media type is selected from the request's `Accept` preferences.
105pub struct GraphQLResponse(
106    /// Completed runtime response.
107    pub Response,
108);
109
110impl Responder for GraphQLResponse {
111    type Body = BoxBody;
112
113    fn respond_to(self, req: &HttpRequest) -> HttpResponse<Self::Body> {
114        let media_type = match negotiated_media_type(req) {
115            Ok(media_type) => media_type,
116            Err(error) => return error.error_response(),
117        };
118        let status = if self.0.is_syntax_error() {
119            StatusCode::BAD_REQUEST
120        } else if self.0.is_request_error() {
121            StatusCode::UNPROCESSABLE_ENTITY
122        } else {
123            StatusCode::OK
124        };
125        let media_type = if status.is_success() {
126            media_type
127        } else {
128            necrassrs_http::GRAPHQL_JSON
129        };
130        HttpResponse::build(status)
131            .insert_header((header::VARY, "Accept"))
132            .content_type(media_type)
133            .json(self.0)
134    }
135}
136
137fn negotiated_media_type(req: &HttpRequest) -> Result<&'static str, actix_web::Error> {
138    let headers = req
139        .headers()
140        .get_all(header::ACCEPT)
141        .map(|value| value.to_str())
142        .collect::<Result<Vec<_>, _>>();
143    headers
144        .ok()
145        .and_then(necrassrs_http::response_media_type)
146        .ok_or_else(|| {
147            InternalError::from_response(
148                "No acceptable GraphQL response media type",
149                HttpResponse::NotAcceptable()
150                    .insert_header((header::VARY, "Accept"))
151                    .finish(),
152            )
153            .into()
154        })
155}