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, ID};
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}
220impl From<Value> for AppsyncResponse {
221 fn from(value: Value) -> Self {
222 Self {
223 data: Some(value),
224 error: None,
225 }
226 }
227}
228impl From<AppsyncError> for AppsyncResponse {
229 fn from(value: AppsyncError) -> Self {
230 Self {
231 data: None,
232 error: Some(value),
233 }
234 }
235}
236
237/// Error type for AWS AppSync operations
238///
239/// Multiple errors can be combined in one using the pipe operator
240///
241/// # Example
242/// ```
243/// # use lambda_appsync::AppsyncError;
244/// let combined_error = AppsyncError::new("ValidationError", "Email address is invalid") | AppsyncError::new("DatabaseError", "User not found in database");
245/// // error_type: "ValidationError|DatabaseError"
246/// // error_message: "Email address is invalid\nUser not found in database"
247/// ```
248///
249/// Can be created from any AWS SDK error or directly by the user.
250///
251/// # Example
252/// ```
253/// # use lambda_appsync::AppsyncError;
254/// # use aws_sdk_dynamodb::types::AttributeValue;
255/// struct Item {
256/// id: u64,
257/// data: String
258/// }
259/// async fn store_item(item: Item, client: &aws_sdk_dynamodb::Client) -> Result<(), AppsyncError> {
260/// client.put_item()
261/// .table_name("my-table")
262/// .item("id", AttributeValue::N(item.id.to_string()))
263/// .item("data", AttributeValue::S(item.data))
264/// .send()
265/// .await?;
266/// Ok(())
267/// }
268/// ```
269#[derive(Debug, Error, Serialize)]
270#[serde(rename_all = "camelCase")]
271#[error("{error_type}: {error_message}")]
272pub struct AppsyncError {
273 /// The type/category of error that occurred (e.g. "ValidationError", "NotFound", "DatabaseError")
274 pub error_type: String,
275 /// A detailed message describing the specific error condition
276 pub error_message: String,
277}
278impl AppsyncError {
279 /// Creates a new AppSync error with the specified error type and message
280 ///
281 /// # Arguments
282 /// * `error_type` - The type/category of the error (e.g. "ValidationError", "NotFound")
283 /// * `error_message` - A detailed message describing the error
284 ///
285 /// # Example
286 /// ```
287 /// # use lambda_appsync::AppsyncError;
288 /// let error = AppsyncError::new("NotFound", "User with ID 123 not found");
289 /// ```
290 pub fn new(error_type: impl Into<String>, error_message: impl Into<String>) -> Self {
291 AppsyncError {
292 error_type: error_type.into(),
293 error_message: error_message.into(),
294 }
295 }
296}
297impl<T: ProvideErrorMetadata> From<T> for AppsyncError {
298 fn from(value: T) -> Self {
299 let meta = ProvideErrorMetadata::meta(&value);
300 AppsyncError {
301 error_type: meta.code().unwrap_or("Unknown").to_owned(),
302 error_message: meta.message().unwrap_or_default().to_owned(),
303 }
304 }
305}
306
307impl BitOr for AppsyncError {
308 type Output = AppsyncError;
309 fn bitor(self, rhs: Self) -> Self::Output {
310 AppsyncError {
311 error_type: format!("{}|{}", self.error_type, rhs.error_type),
312 error_message: format!("{}\n{}", self.error_message, rhs.error_message),
313 }
314 }
315}
316
317/// Extracts and deserializes a named argument from a JSON Value into the specified type
318///
319/// # Arguments
320/// * `args` - Mutable reference to a JSON Value containing arguments
321/// * `arg_name` - Name of the argument to extract
322///
323/// # Returns
324/// * `Ok(T)` - Successfully deserialized value of type T
325/// * `Err(AppsyncError)` - Error if argument is missing or invalid format
326///
327/// # Examples
328/// ```
329/// # use serde_json::json;
330/// # use lambda_appsync::arg_from_json;
331/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
332/// let mut args = json!({
333/// "userId": "123",
334/// "count": 5
335/// });
336///
337/// // Extract userId as String
338/// let user_id: String = arg_from_json(&mut args, "userId")?;
339/// assert_eq!(user_id, "123");
340///
341/// // Extract count as i32
342/// let count: i32 = arg_from_json(&mut args, "count")?;
343/// assert_eq!(count, 5);
344///
345/// // Error case: invalid type
346/// let result: Result<String, _> = arg_from_json(&mut args, "count");
347/// assert!(result.is_err());
348///
349/// // Error case: missing argument
350/// let result: Result<String, _> = arg_from_json(&mut args, "missing");
351/// assert!(result.is_err());
352/// # Ok(())
353/// # }
354/// ```
355pub fn arg_from_json<T: DeserializeOwned>(
356 args: &mut serde_json::Value,
357 arg_name: &'static str,
358) -> Result<T, AppsyncError> {
359 serde_json::from_value(
360 args.get_mut(arg_name)
361 .unwrap_or(&mut serde_json::Value::Null)
362 .take(),
363 )
364 .map_err(|e| {
365 AppsyncError::new(
366 "InvalidArgs",
367 format!("Argument \"{arg_name}\" is not the expected format ({e})"),
368 )
369 })
370}
371
372/// Serializes a value into a JSON Value for AppSync responses
373///
374/// # Arguments
375/// * `res` - Value to serialize that implements Serialize
376///
377/// # Returns
378/// JSON Value representation of the input
379///
380/// # Panics
381/// Panics if the value cannot be serialized to JSON. This should never happen
382/// for valid AppSync schema objects as generated by the `appsync_lambda_main` proc macro.
383///
384/// # Examples
385/// ```
386/// # use serde::Serialize;
387/// # use serde_json::json;
388/// # use lambda_appsync::res_to_json;
389/// #[derive(Serialize)]
390/// struct User {
391/// id: String,
392/// name: String
393/// }
394///
395/// let user = User {
396/// id: "123".to_string(),
397/// name: "John".to_string()
398/// };
399///
400/// let json = res_to_json(user);
401/// assert_eq!(json, json!({
402/// "id": "123",
403/// "name": "John"
404/// }));
405///
406/// // Simple types also work
407/// let num = res_to_json(42);
408/// assert_eq!(num, json!(42));
409/// ```
410pub fn res_to_json<T: Serialize>(res: T) -> serde_json::Value {
411 serde_json::to_value(res).expect("Appsync schema objects are JSON compatible")
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use serde_json::json;
418
419 #[test]
420 fn test_appsync_auth_strategy() {
421 let allow: AppsyncAuthStrategy = serde_json::from_str("\"ALLOW\"").unwrap();
422 let deny: AppsyncAuthStrategy = serde_json::from_str("\"DENY\"").unwrap();
423
424 match allow {
425 AppsyncAuthStrategy::Allow => (),
426 _ => panic!("Expected Allow"),
427 }
428
429 match deny {
430 AppsyncAuthStrategy::Deny => (),
431 _ => panic!("Expected Deny"),
432 }
433 }
434
435 #[test]
436 fn test_appsync_identity() {
437 let json = json!({
438 "sub": "user123",
439 "username": "testuser",
440 "issuer": "https://test",
441 "defaultAuthStrategy": "ALLOW",
442 "sourceIp": ["1.2.3.4"],
443 "groups": ["group1"],
444 "claims": {"custom": "value"}
445 });
446
447 let identity: AppsyncIdentity = serde_json::from_value(json).unwrap();
448 assert_eq!(identity.sub, "user123");
449 assert_eq!(identity.username, "testuser");
450 assert_eq!(identity.issuer, "https://test");
451 assert_eq!(identity.source_ip, vec!["1.2.3.4"]);
452 assert_eq!(identity.groups, vec!["group1"]);
453 assert_eq!(identity.claims, json!({"custom": "value"}));
454 }
455
456 #[test]
457 fn test_appsync_response() {
458 let success = AppsyncResponse::from(json!({"field": "value"}));
459 assert!(success.data.is_some());
460 assert!(success.error.is_none());
461
462 let error = AppsyncResponse::from(AppsyncError::new("TestError", "message"));
463 assert!(error.data.is_none());
464 assert!(error.error.is_some());
465 }
466
467 #[test]
468 fn test_appsync_error() {
469 let error = AppsyncError::new("TestError", "message");
470 assert_eq!(error.error_type, "TestError");
471 assert_eq!(error.error_message, "message");
472
473 let error1 = AppsyncError::new("Error1", "msg1");
474 let error2 = AppsyncError::new("Error2", "msg2");
475 let combined = error1 | error2;
476
477 assert_eq!(combined.error_type, "Error1|Error2");
478 assert_eq!(combined.error_message, "msg1\nmsg2");
479 }
480
481 #[test]
482 fn test_arg_from_json() {
483 let mut args = json!({
484 "string": "test",
485 "number": 42,
486 "bool": true
487 });
488
489 let s: String = arg_from_json(&mut args, "string").unwrap();
490 assert_eq!(s, "test");
491
492 let n: i32 = arg_from_json(&mut args, "number").unwrap();
493 assert_eq!(n, 42);
494
495 let b: bool = arg_from_json(&mut args, "bool").unwrap();
496 assert!(b);
497
498 let err: Result<String, _> = arg_from_json(&mut args, "missing");
499 assert!(err.is_err());
500 }
501
502 #[test]
503 fn test_res_to_json() {
504 #[derive(Serialize)]
505 struct Test {
506 field: String,
507 }
508
509 let test = Test {
510 field: "value".to_string(),
511 };
512
513 let json = res_to_json(test);
514 assert_eq!(json, json!({"field": "value"}));
515
516 assert_eq!(res_to_json(42), json!(42));
517 assert_eq!(res_to_json("test"), json!("test"));
518 }
519}