lambda_appsync/
lib.rs

1#![warn(missing_docs)]
2#![warn(rustdoc::missing_crate_level_docs)]
3//! This crate provides procedural macros and types for implementing
4//! AWS AppSync Direct Lambda resolvers.
5//!
6//! It helps convert GraphQL schemas into type-safe Rust code with full AWS Lambda runtime support.
7//! The main functionality is provided through the `appsync_lambda_main` and `appsync_operation` macros.
8//!
9//! # Complete Example
10//!
11//! ```no_run
12//! use lambda_appsync::{appsync_lambda_main, appsync_operation, AppsyncError};
13//!
14//! // 1. First define your GraphQL schema (e.g. `schema.graphql`):
15//! //
16//! // type Query {
17//! //   players: [Player!]!
18//! //   gameStatus: GameStatus!
19//! // }
20//! //
21//! // type Player {
22//! //   id: ID!
23//! //   name: String!
24//! //   team: Team!
25//! // }
26//! //
27//! // enum Team {
28//! //   RUST
29//! //   PYTHON
30//! //   JS
31//! // }
32//! //
33//! // enum GameStatus {
34//! //   STARTED
35//! //   STOPPED
36//! // }
37//!
38//! // 2. Initialize the Lambda runtime with AWS SDK clients in main.rs:
39//!
40//! // Optional hook for custom request validation/auth
41//! async fn verify_request(
42//!     event: &lambda_appsync::AppsyncEvent<Operation>
43//! ) -> Option<lambda_appsync::AppsyncResponse> {
44//!     // Return Some(response) to short-circuit normal execution
45//!     None
46//! }
47//! // Generate types and runtime setup from schema
48//! appsync_lambda_main!(
49//!     "schema.graphql",
50//!     // Initialize DynamoDB client if needed
51//!     dynamodb() -> aws_sdk_dynamodb::Client,
52//!     // Enable validation hook
53//!     hook = verify_request,
54//!     // Enable batch processing
55//!     batch = true
56//! );
57//!
58//! // 3. Implement resolver functions for GraphQL operations:
59//!
60//! #[appsync_operation(query(players))]
61//! async fn get_players() -> Result<Vec<Player>, AppsyncError> {
62//!     let client = dynamodb();
63//!     todo!()
64//! }
65//!
66//! #[appsync_operation(query(gameStatus))]
67//! async fn get_game_status() -> Result<GameStatus, AppsyncError> {
68//!     let client = dynamodb();
69//!     todo!()
70//! }
71//! // The macro ensures the function signature matches the GraphQL schema
72//! // and wires everything up to handle AWS AppSync requests automatically
73//! # mod child {fn main() {}}
74//! ```
75
76mod aws_scalars;
77mod id;
78pub mod subscription_filters;
79
80use std::{collections::HashMap, ops::BitOr};
81
82use aws_smithy_types::error::metadata::ProvideErrorMetadata;
83use serde_json::Value;
84
85use serde::{de::DeserializeOwned, Deserialize, Serialize};
86use thiserror::Error;
87
88pub use aws_scalars::{
89    datetime::{AWSDate, AWSDateTime, AWSTime},
90    email::AWSEmail,
91    phone::AWSPhone,
92    timestamp::AWSTimestamp,
93    url::AWSUrl,
94};
95pub use id::ID;
96pub use lambda_appsync_proc::{appsync_lambda_main, appsync_operation};
97
98// Re-export crates that are mandatory for the proc_macro to succeed
99pub use aws_config;
100pub use env_logger;
101pub use lambda_runtime;
102pub use log;
103pub use serde;
104pub use serde_json;
105pub use tokio;
106
107/// Authorization strategy for AppSync operations.
108///
109/// This enum determines whether operations are allowed or denied based on the
110/// authentication context provided by AWS AppSync. It is typically used by AppSync
111/// itself in conjunction with AWS Cognito user pools or IAM authentication
112/// and usually do not concern the Lambda code.
113#[derive(Debug, Clone, Copy, Deserialize)]
114#[serde(rename_all = "UPPERCASE")]
115pub enum AppsyncAuthStrategy {
116    /// Allows the operation by default if no explicit authorizer is associated to the field
117    Allow,
118    /// Denies the operation by default if no explicit authorizer is associated to the field
119    Deny,
120}
121
122/// Identity information for an authenticated AppSync request.
123///
124/// Contains details about the authenticated user/client making the request,
125/// including their identity attributes from Cognito/IAM, source IP addresses,
126/// group memberships, and any additional claims.
127#[derive(Debug, Deserialize)]
128#[allow(dead_code)]
129pub struct AppsyncIdentity {
130    /// Unique identifier of the authenticated user/client
131    pub sub: String,
132    /// Username of the authenticated user (from Cognito user pools)
133    pub username: String,
134    /// Identity provider that authenticated the request (e.g. Cognito user pool URL)
135    pub issuer: String,
136    /// Default authorization strategy for the authenticated identity
137    #[serde(rename = "defaultAuthStrategy")]
138    pub auth_strategy: AppsyncAuthStrategy,
139    /// Source IP addresses associated with the request
140    #[serde(rename = "sourceIp")]
141    pub source_ip: Vec<String>,
142    /// Groups the authenticated user belongs to
143    pub groups: Option<Vec<String>>,
144    /// Additional claims/attributes associated with the identity
145    pub claims: Value,
146}
147
148/// Metadata about an AppSync GraphQL operation execution.
149///
150/// Contains detailed information about the GraphQL operation being executed,
151/// including the operation type, selected fields, and variables. The type parameter
152/// `O` represents the enum generated by `appsync_lambda_main` that defines all valid
153/// operations for this Lambda resolver.
154#[derive(Debug, Deserialize)]
155#[allow(dead_code)]
156pub struct AppsyncEventInfo<O> {
157    /// The specific GraphQL operation being executed (Query/Mutation)
158    #[serde(flatten)]
159    pub operation: O,
160    /// Raw GraphQL selection set as a string
161    #[serde(rename = "selectionSetGraphQL")]
162    pub selection_set_graphql: String,
163    /// List of selected field paths in the GraphQL query
164    #[serde(rename = "selectionSetList")]
165    pub selection_set_list: Vec<String>,
166    /// Variables passed to the GraphQL operation
167    pub variables: HashMap<String, Value>,
168}
169
170/// Represents a complete AWS AppSync event sent to a Lambda resolver.
171///
172/// Contains all context and data needed to resolve a GraphQL operation, including
173/// authentication details, operation info, and arguments. The type parameter `O`
174/// must match the Operation enum generated by the `appsync_lambda_main` macro.
175///
176/// # Limitations
177/// - Omits the `stash` field used for pipeline resolvers
178/// - Omits the `prev` field as it's not relevant for direct Lambda resolvers
179#[derive(Debug, Deserialize)]
180#[allow(dead_code)]
181pub struct AppsyncEvent<O> {
182    /// Authentication context if request is authenticated
183    pub identity: Option<AppsyncIdentity>,
184    /// Raw request context from AppSync
185    pub request: Value,
186    /// Parent field's resolved value in nested resolvers
187    pub source: Value,
188    /// Metadata about the GraphQL operation
189    pub info: AppsyncEventInfo<O>,
190    /// Arguments passed to the GraphQL field
191    #[serde(rename = "arguments")]
192    pub args: Value,
193    // Should never be usefull in a Direct Lambda Invocation context
194    // pub stash: Value,
195    // pub prev: Value,
196}
197
198/// Response structure returned to AWS AppSync from a Lambda resolver.
199///
200/// Can contain either successful data or error information, but not both.
201/// Should be constructed using From implementations for either [Value] (success)
202/// or [AppsyncError] (failure).
203///
204/// # Examples
205/// ```
206/// # use serde_json::json;
207/// # use lambda_appsync::{AppsyncError, AppsyncResponse};
208/// // Success response
209/// let response: AppsyncResponse = json!({ "id": 123 }).into();
210///
211/// // Error response
212/// let error = AppsyncError::new("NotFound", "Resource not found");
213/// let response: AppsyncResponse = error.into();
214/// ```
215#[derive(Debug, Serialize)]
216pub struct AppsyncResponse {
217    data: Option<Value>,
218    #[serde(flatten, skip_serializing_if = "Option::is_none")]
219    error: Option<AppsyncError>,
220}
221
222impl AppsyncResponse {
223    /// Returns an unauthorized error response
224    ///
225    /// This creates a standard unauthorized error response for when a request
226    /// lacks proper authentication.
227    ///
228    /// # Examples
229    /// ```
230    /// # use lambda_appsync::AppsyncResponse;
231    /// let response = AppsyncResponse::unauthorized();
232    /// ```
233    pub fn unauthorized() -> Self {
234        AppsyncError::new("Unauthorized", "This operation cannot be authorized").into()
235    }
236}
237
238impl From<Value> for AppsyncResponse {
239    fn from(value: Value) -> Self {
240        Self {
241            data: Some(value),
242            error: None,
243        }
244    }
245}
246impl From<AppsyncError> for AppsyncResponse {
247    fn from(value: AppsyncError) -> Self {
248        Self {
249            data: None,
250            error: Some(value),
251        }
252    }
253}
254
255/// Error type for AWS AppSync operations
256///
257/// Multiple errors can be combined in one using the pipe operator
258///
259/// # Example
260/// ```
261/// # use lambda_appsync::AppsyncError;
262/// let combined_error = AppsyncError::new("ValidationError", "Email address is invalid") | AppsyncError::new("DatabaseError", "User not found in database");
263/// // error_type: "ValidationError|DatabaseError"
264/// // error_message: "Email address is invalid\nUser not found in database"
265/// ```
266///
267/// Can be created from any AWS SDK error or directly by the user.
268///
269/// # Example
270/// ```
271/// # use lambda_appsync::AppsyncError;
272/// # use aws_sdk_dynamodb::types::AttributeValue;
273/// struct Item {
274///   id: u64,
275///   data: String
276/// }
277/// async fn store_item(item: Item, client: &aws_sdk_dynamodb::Client) -> Result<(), AppsyncError> {
278///     client.put_item()
279///         .table_name("my-table")
280///         .item("id", AttributeValue::N(item.id.to_string()))
281///         .item("data", AttributeValue::S(item.data))
282///         .send()
283///         .await?;
284///     Ok(())
285/// }
286/// ```
287#[derive(Debug, Error, Serialize)]
288#[serde(rename_all = "camelCase")]
289#[error("{error_type}: {error_message}")]
290pub struct AppsyncError {
291    /// The type/category of error that occurred (e.g. "ValidationError", "NotFound", "DatabaseError")
292    pub error_type: String,
293    /// A detailed message describing the specific error condition
294    pub error_message: String,
295}
296impl AppsyncError {
297    /// Creates a new AppSync error with the specified error type and message
298    ///
299    /// # Arguments
300    /// * `error_type` - The type/category of the error (e.g. "ValidationError", "NotFound")
301    /// * `error_message` - A detailed message describing the error
302    ///
303    /// # Example
304    /// ```
305    /// # use lambda_appsync::AppsyncError;
306    /// let error = AppsyncError::new("NotFound", "User with ID 123 not found");
307    /// ```
308    pub fn new(error_type: impl Into<String>, error_message: impl Into<String>) -> Self {
309        AppsyncError {
310            error_type: error_type.into(),
311            error_message: error_message.into(),
312        }
313    }
314}
315impl<T: ProvideErrorMetadata> From<T> for AppsyncError {
316    fn from(value: T) -> Self {
317        let meta = ProvideErrorMetadata::meta(&value);
318        AppsyncError {
319            error_type: meta.code().unwrap_or("Unknown").to_owned(),
320            error_message: meta.message().unwrap_or_default().to_owned(),
321        }
322    }
323}
324
325impl BitOr for AppsyncError {
326    type Output = AppsyncError;
327    fn bitor(self, rhs: Self) -> Self::Output {
328        AppsyncError {
329            error_type: format!("{}|{}", self.error_type, rhs.error_type),
330            error_message: format!("{}\n{}", self.error_message, rhs.error_message),
331        }
332    }
333}
334
335/// Extracts and deserializes a named argument from a JSON Value into the specified type
336///
337/// # Arguments
338/// * `args` - Mutable reference to a JSON Value containing arguments
339/// * `arg_name` - Name of the argument to extract
340///
341/// # Returns
342/// * `Ok(T)` - Successfully deserialized value of type T
343/// * `Err(AppsyncError)` - Error if argument is missing or invalid format
344///
345/// # Examples
346/// ```
347/// # use serde_json::json;
348/// # use lambda_appsync::arg_from_json;
349/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
350/// let mut args = json!({
351///     "userId": "123",
352///     "count": 5
353/// });
354///
355/// // Extract userId as String
356/// let user_id: String = arg_from_json(&mut args, "userId")?;
357/// assert_eq!(user_id, "123");
358///
359/// // Extract count as i32
360/// let count: i32 = arg_from_json(&mut args, "count")?;
361/// assert_eq!(count, 5);
362///
363/// // Error case: invalid type
364/// let result: Result<String, _> = arg_from_json(&mut args, "count");
365/// assert!(result.is_err());
366///
367/// // Error case: missing argument
368/// let result: Result<String, _> = arg_from_json(&mut args, "missing");
369/// assert!(result.is_err());
370/// # Ok(())
371/// # }
372/// ```
373pub fn arg_from_json<T: DeserializeOwned>(
374    args: &mut serde_json::Value,
375    arg_name: &'static str,
376) -> Result<T, AppsyncError> {
377    serde_json::from_value(
378        args.get_mut(arg_name)
379            .unwrap_or(&mut serde_json::Value::Null)
380            .take(),
381    )
382    .map_err(|e| {
383        AppsyncError::new(
384            "InvalidArgs",
385            format!("Argument \"{arg_name}\" is not the expected format ({e})"),
386        )
387    })
388}
389
390/// Serializes a value into a JSON Value for AppSync responses
391///
392/// # Arguments
393/// * `res` - Value to serialize that implements Serialize
394///
395/// # Returns
396/// JSON Value representation of the input
397///
398/// # Panics
399/// Panics if the value cannot be serialized to JSON. This should never happen
400/// for valid AppSync schema objects as generated by the `appsync_lambda_main` proc macro.
401///
402/// # Examples
403/// ```
404/// # use serde::Serialize;
405/// # use serde_json::json;
406/// # use lambda_appsync::res_to_json;
407/// #[derive(Serialize)]
408/// struct User {
409///     id: String,
410///     name: String
411/// }
412///
413/// let user = User {
414///     id: "123".to_string(),
415///     name: "John".to_string()
416/// };
417///
418/// let json = res_to_json(user);
419/// assert_eq!(json, json!({
420///     "id": "123",
421///     "name": "John"
422/// }));
423///
424/// // Simple types also work
425/// let num = res_to_json(42);
426/// assert_eq!(num, json!(42));
427/// ```
428pub fn res_to_json<T: Serialize>(res: T) -> serde_json::Value {
429    serde_json::to_value(res).expect("Appsync schema objects are JSON compatible")
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use serde_json::json;
436
437    #[test]
438    fn test_appsync_auth_strategy() {
439        let allow: AppsyncAuthStrategy = serde_json::from_str("\"ALLOW\"").unwrap();
440        let deny: AppsyncAuthStrategy = serde_json::from_str("\"DENY\"").unwrap();
441
442        match allow {
443            AppsyncAuthStrategy::Allow => (),
444            _ => panic!("Expected Allow"),
445        }
446
447        match deny {
448            AppsyncAuthStrategy::Deny => (),
449            _ => panic!("Expected Deny"),
450        }
451    }
452
453    #[test]
454    fn test_appsync_identity() {
455        let json = json!({
456            "sub": "user123",
457            "username": "testuser",
458            "issuer": "https://test",
459            "defaultAuthStrategy": "ALLOW",
460            "sourceIp": ["1.2.3.4"],
461            "groups": ["group1"],
462            "claims": {"custom": "value"}
463        });
464
465        let identity: AppsyncIdentity = serde_json::from_value(json).unwrap();
466        assert_eq!(identity.sub, "user123");
467        assert_eq!(identity.username, "testuser");
468        assert_eq!(identity.issuer, "https://test");
469        assert_eq!(identity.source_ip, vec!["1.2.3.4"]);
470        assert_eq!(identity.groups.unwrap(), vec!["group1"]);
471        assert_eq!(identity.claims, json!({"custom": "value"}));
472    }
473
474    #[test]
475    fn test_appsync_response() {
476        let success = AppsyncResponse::from(json!({"field": "value"}));
477        assert!(success.data.is_some());
478        assert!(success.error.is_none());
479
480        let error = AppsyncResponse::from(AppsyncError::new("TestError", "message"));
481        assert!(error.data.is_none());
482        assert!(error.error.is_some());
483    }
484
485    #[test]
486    fn test_appsync_error() {
487        let error = AppsyncError::new("TestError", "message");
488        assert_eq!(error.error_type, "TestError");
489        assert_eq!(error.error_message, "message");
490
491        let error1 = AppsyncError::new("Error1", "msg1");
492        let error2 = AppsyncError::new("Error2", "msg2");
493        let combined = error1 | error2;
494
495        assert_eq!(combined.error_type, "Error1|Error2");
496        assert_eq!(combined.error_message, "msg1\nmsg2");
497    }
498
499    #[test]
500    fn test_arg_from_json() {
501        let mut args = json!({
502            "string": "test",
503            "number": 42,
504            "bool": true
505        });
506
507        let s: String = arg_from_json(&mut args, "string").unwrap();
508        assert_eq!(s, "test");
509
510        let n: i32 = arg_from_json(&mut args, "number").unwrap();
511        assert_eq!(n, 42);
512
513        let b: bool = arg_from_json(&mut args, "bool").unwrap();
514        assert!(b);
515
516        let err: Result<String, _> = arg_from_json(&mut args, "missing");
517        assert!(err.is_err());
518    }
519
520    #[test]
521    fn test_res_to_json() {
522        #[derive(Serialize)]
523        struct Test {
524            field: String,
525        }
526
527        let test = Test {
528            field: "value".to_string(),
529        };
530
531        let json = res_to_json(test);
532        assert_eq!(json, json!({"field": "value"}));
533
534        assert_eq!(res_to_json(42), json!(42));
535        assert_eq!(res_to_json("test"), json!("test"));
536    }
537}