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