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/// It 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 and usually do not concern
112/// the application code.
113#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
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 Cognito User Pools authenticated requests.
123#[derive(Debug, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct AppsyncIdentityCognito {
126    /// Unique identifier of the authenticated user/client
127    pub sub: String,
128    /// Username of the authenticated user (from Cognito user pools)
129    pub username: String,
130    /// Identity provider that authenticated the request (e.g. Cognito user pool URL)
131    pub issuer: String,
132    /// Default authorization strategy for the authenticated identity
133    pub default_auth_strategy: AppsyncAuthStrategy,
134    /// Source IP addresses associated with the request
135    pub source_ip: Vec<String>,
136    /// Groups the authenticated user belongs to
137    pub groups: Option<Vec<String>>,
138    /// Additional claims/attributes associated with the identity
139    pub claims: Value,
140}
141
142/// Authentication type in a Cognito Identity Pool
143#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
144#[serde(rename_all = "lowercase")]
145pub enum CognitoIdentityAuthType {
146    /// User is authenticated with an identity provider
147    Authenticated,
148    /// User is an unauthenticated guest
149    Unauthenticated,
150}
151
152/// Cognito Identity Pool information for federated IAM authentication
153#[derive(Debug, Deserialize)]
154pub struct CognitoFederatedIdentity {
155    /// Unique identifier assigned to the authenticated/unauthenticated identity
156    /// within the Cognito Identity Pool
157    #[serde(rename = "cognitoIdentityId")]
158    pub identity_id: String,
159    /// Identifier of the Cognito Identity Pool that is being used for federation.
160    /// In the format of region:pool-id
161    #[serde(rename = "cognitoIdentityPoolId")]
162    pub identity_pool_id: String,
163    /// Indicates whether the identity is authenticated with an identity provider
164    /// or is an unauthenticated guest access
165    #[serde(rename = "cognitoIdentityAuthType")]
166    pub auth_type: CognitoIdentityAuthType,
167    /// For authenticated identities, contains information about the identity provider
168    /// used for authentication. Format varies by provider type
169    #[serde(rename = "cognitoIdentityAuthProvider")]
170    pub auth_provider: String,
171}
172
173/// Identity information for IAM-authenticated requests.
174///
175/// Contains AWS IAM-specific authentication details, including optional Cognito
176/// identity pool information when using federated identities.
177#[derive(Debug, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct AppsyncIdentityIam {
180    /// AWS account ID of the caller
181    pub account_id: String,
182    /// Source IP address(es) of the caller
183    pub source_ip: Vec<String>,
184    /// IAM username of the caller
185    pub username: String,
186    /// Full IAM ARN of the caller
187    pub user_arn: String,
188    /// Federated identity information when using Cognito Identity Pools
189    #[serde(flatten)]
190    pub federated_identity: Option<CognitoFederatedIdentity>,
191}
192
193/// Identity information for OIDC-authenticated requests.
194#[derive(Debug, Deserialize)]
195pub struct AppsyncIdentityOidc {
196    /// The issuer of the token
197    pub iss: String,
198    /// The subject (usually the user identifier)
199    pub sub: String,
200    /// Token audience
201    pub aud: String,
202    /// Expiration time
203    pub exp: i64,
204    /// Issued at time
205    pub iat: i64,
206    /// Additional custom claims from the OIDC provider
207    #[serde(flatten)]
208    pub additional_claims: HashMap<String, serde_json::Value>,
209}
210
211/// Identity information for Lambda-authorized requests.
212#[derive(Debug, Deserialize)]
213pub struct AppsyncIdentityLambda {
214    /// Custom resolver context returned by the Lambda authorizer
215    #[serde(rename = "resolverContext")]
216    pub resolver_context: serde_json::Value,
217}
218
219/// Identity information for an AppSync request.
220///
221/// Represents the identity context of the authenticated user/client making the request to
222/// AWS AppSync. This enum corresponds directly to AppSync's authorization types as defined
223/// in the AWS documentation.
224///
225/// Each variant maps to one of the five supported AWS AppSync authorization modes:
226///
227/// - [Cognito](AppsyncIdentity::Cognito): Uses Amazon Cognito User Pools, providing group-based
228///   access control with JWT tokens containing encoded user information like groups and custom claims.
229///
230/// - [Iam](AppsyncIdentity::Iam): Uses AWS IAM roles and policies through AWS Signature Version 4
231///   signing. Can be used either directly with IAM users/roles or through Cognito Identity Pools
232///   for federated access. Enables fine-grained access control through IAM policies.
233///
234/// - [Oidc](AppsyncIdentity::Oidc): OpenID Connect authentication integrating with any
235///   OIDC-compliant provider.
236///
237/// - [Lambda](AppsyncIdentity::Lambda): Custom authorization through an AWS Lambda function
238///   that evaluates each request.
239///
240/// - [ApiKey](AppsyncIdentity::ApiKey): Simple API key-based authentication using keys
241///   generated and managed by AppSync.
242///
243/// The variant is determined by the authorization configuration of your AppSync API and
244/// the authentication credentials provided in the request. Each variant contains structured
245/// information specific to that authentication mode, which can be used in resolvers for
246/// custom authorization logic.
247///
248/// More information can be found in the [AWS documentation](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html).
249#[derive(Debug, Deserialize)]
250#[serde(untagged)]
251pub enum AppsyncIdentity {
252    /// Amazon Cognito User Pools authentication
253    Cognito(AppsyncIdentityCognito),
254    /// AWS IAM authentication
255    Iam(AppsyncIdentityIam),
256    /// OpenID Connect authentication
257    Oidc(AppsyncIdentityOidc),
258    /// Lambda authorizer authentication
259    Lambda(AppsyncIdentityLambda),
260    /// API Key authentication (represents null identity in JSON)
261    ApiKey,
262}
263
264/// Metadata about an AppSync GraphQL operation execution.
265///
266/// Contains detailed information about the GraphQL operation being executed,
267/// including the operation type, selected fields, and variables. The type parameter
268/// `O` represents the enum generated by [appsync_lambda_main] that defines all valid
269/// operations for this Lambda resolver.
270#[derive(Debug, Deserialize)]
271#[allow(dead_code)]
272pub struct AppsyncEventInfo<O> {
273    /// The specific GraphQL operation being executed (Query/Mutation)
274    #[serde(flatten)]
275    pub operation: O,
276    /// Raw GraphQL selection set as a string
277    #[serde(rename = "selectionSetGraphQL")]
278    pub selection_set_graphql: String,
279    /// List of selected field paths in the GraphQL query
280    #[serde(rename = "selectionSetList")]
281    pub selection_set_list: Vec<String>,
282    /// Variables passed to the GraphQL operation
283    pub variables: HashMap<String, Value>,
284}
285
286/// Represents a complete AWS AppSync event sent to a Lambda resolver.
287///
288/// Contains all context and data needed to resolve a GraphQL operation, including
289/// authentication details, operation info, and arguments. The generics `O`
290/// must be the Operation enum generated by the [appsync_lambda_main] macro.
291///
292/// # Limitations
293/// - Omits the `stash` field used for pipeline resolvers
294/// - Omits the `prev` field as it's not relevant for direct Lambda resolvers
295#[derive(Debug, Deserialize)]
296#[allow(dead_code)]
297pub struct AppsyncEvent<O> {
298    /// Authentication context
299    pub identity: AppsyncIdentity,
300    /// Raw request context from AppSync
301    pub request: Value,
302    /// Parent field's resolved value in nested resolvers
303    pub source: Value,
304    /// Metadata about the GraphQL operation
305    pub info: AppsyncEventInfo<O>,
306    /// Arguments passed to the GraphQL field
307    #[serde(rename = "arguments")]
308    pub args: Value,
309    // Should never be usefull in a Direct Lambda Invocation context
310    // pub stash: Value,
311    // pub prev: Value,
312}
313
314/// Response structure returned to AWS AppSync from a Lambda resolver.
315///
316/// Can contain either successful data or error information, but not both.
317/// Should be constructed using From implementations for either [Value] (success)
318/// or [AppsyncError] (failure).
319///
320/// # Examples
321/// ```
322/// # use serde_json::json;
323/// # use lambda_appsync::{AppsyncError, AppsyncResponse};
324/// // Success response
325/// let response: AppsyncResponse = json!({ "id": 123 }).into();
326///
327/// // Error response
328/// let error = AppsyncError::new("NotFound", "Resource not found");
329/// let response: AppsyncResponse = error.into();
330/// ```
331#[derive(Debug, Serialize)]
332pub struct AppsyncResponse {
333    data: Option<Value>,
334    #[serde(flatten, skip_serializing_if = "Option::is_none")]
335    error: Option<AppsyncError>,
336}
337
338impl AppsyncResponse {
339    /// Returns an unauthorized error response
340    ///
341    /// This creates a standard unauthorized error response for when a request
342    /// lacks proper authentication.
343    ///
344    /// # Examples
345    /// ```
346    /// # use lambda_appsync::AppsyncResponse;
347    /// let response = AppsyncResponse::unauthorized();
348    /// ```
349    pub fn unauthorized() -> Self {
350        AppsyncError::new("Unauthorized", "This operation cannot be authorized").into()
351    }
352}
353
354impl From<Value> for AppsyncResponse {
355    fn from(value: Value) -> Self {
356        Self {
357            data: Some(value),
358            error: None,
359        }
360    }
361}
362impl From<AppsyncError> for AppsyncResponse {
363    fn from(value: AppsyncError) -> Self {
364        Self {
365            data: None,
366            error: Some(value),
367        }
368    }
369}
370
371/// Error type for AWS AppSync operations
372///
373/// Multiple errors can be combined in one using the pipe operator
374///
375/// # Example
376/// ```
377/// # use lambda_appsync::AppsyncError;
378/// let combined_error = AppsyncError::new("ValidationError", "Email address is invalid") | AppsyncError::new("DatabaseError", "User not found in database");
379/// // error_type: "ValidationError|DatabaseError"
380/// // error_message: "Email address is invalid\nUser not found in database"
381/// ```
382///
383/// Can be created from any AWS SDK error or directly by the user.
384///
385/// # Example
386/// ```
387/// # use lambda_appsync::AppsyncError;
388/// # use aws_sdk_dynamodb::types::AttributeValue;
389/// struct Item {
390///   id: u64,
391///   data: String
392/// }
393/// async fn store_item(item: Item, client: &aws_sdk_dynamodb::Client) -> Result<(), AppsyncError> {
394///     client.put_item()
395///         .table_name("my-table")
396///         .item("id", AttributeValue::N(item.id.to_string()))
397///         .item("data", AttributeValue::S(item.data))
398///         .send()
399///         .await?;
400///     Ok(())
401/// }
402/// ```
403#[derive(Debug, Error, Serialize)]
404#[serde(rename_all = "camelCase")]
405#[error("{error_type}: {error_message}")]
406pub struct AppsyncError {
407    /// The type/category of error that occurred (e.g. "ValidationError", "NotFound", "DatabaseError")
408    pub error_type: String,
409    /// A detailed message describing the specific error condition
410    pub error_message: String,
411}
412impl AppsyncError {
413    /// Creates a new AppSync error with the specified error type and message
414    ///
415    /// # Arguments
416    /// * `error_type` - The type/category of the error (e.g. "ValidationError", "NotFound")
417    /// * `error_message` - A detailed message describing the error
418    ///
419    /// # Example
420    /// ```
421    /// # use lambda_appsync::AppsyncError;
422    /// let error = AppsyncError::new("NotFound", "User with ID 123 not found");
423    /// ```
424    pub fn new(error_type: impl Into<String>, error_message: impl Into<String>) -> Self {
425        AppsyncError {
426            error_type: error_type.into(),
427            error_message: error_message.into(),
428        }
429    }
430}
431impl<T: ProvideErrorMetadata> From<T> for AppsyncError {
432    fn from(value: T) -> Self {
433        let meta = ProvideErrorMetadata::meta(&value);
434        AppsyncError {
435            error_type: meta.code().unwrap_or("Unknown").to_owned(),
436            error_message: meta.message().unwrap_or_default().to_owned(),
437        }
438    }
439}
440
441impl BitOr for AppsyncError {
442    type Output = AppsyncError;
443    fn bitor(self, rhs: Self) -> Self::Output {
444        AppsyncError {
445            error_type: format!("{}|{}", self.error_type, rhs.error_type),
446            error_message: format!("{}\n{}", self.error_message, rhs.error_message),
447        }
448    }
449}
450
451/// Extracts and deserializes a named argument from a JSON Value into the specified type
452///
453/// # Arguments
454/// * `args` - Mutable reference to a JSON Value containing arguments
455/// * `arg_name` - Name of the argument to extract
456///
457/// # Returns
458/// * `Ok(T)` - Successfully deserialized value of type T
459/// * `Err(AppsyncError)` - Error if argument is missing or invalid format
460///
461/// # Examples
462/// ```
463/// # use serde_json::json;
464/// # use lambda_appsync::arg_from_json;
465/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
466/// let mut args = json!({
467///     "userId": "123",
468///     "count": 5
469/// });
470///
471/// // Extract userId as String
472/// let user_id: String = arg_from_json(&mut args, "userId")?;
473/// assert_eq!(user_id, "123");
474///
475/// // Extract count as i32
476/// let count: i32 = arg_from_json(&mut args, "count")?;
477/// assert_eq!(count, 5);
478///
479/// // Error case: invalid type
480/// let result: Result<String, _> = arg_from_json(&mut args, "count");
481/// assert!(result.is_err());
482///
483/// // Error case: missing argument
484/// let result: Result<String, _> = arg_from_json(&mut args, "missing");
485/// assert!(result.is_err());
486/// # Ok(())
487/// # }
488/// ```
489pub fn arg_from_json<T: DeserializeOwned>(
490    args: &mut serde_json::Value,
491    arg_name: &'static str,
492) -> Result<T, AppsyncError> {
493    serde_json::from_value(
494        args.get_mut(arg_name)
495            .unwrap_or(&mut serde_json::Value::Null)
496            .take(),
497    )
498    .map_err(|e| {
499        AppsyncError::new(
500            "InvalidArgs",
501            format!("Argument \"{arg_name}\" is not the expected format ({e})"),
502        )
503    })
504}
505
506/// Serializes a value into a JSON Value for AppSync responses
507///
508/// # Arguments
509/// * `res` - Value to serialize that implements Serialize
510///
511/// # Returns
512/// JSON Value representation of the input
513///
514/// # Panics
515/// Panics if the value cannot be serialized to JSON. This should never happen
516/// for valid AppSync schema objects as generated by the `appsync_lambda_main` proc macro.
517///
518/// # Examples
519/// ```
520/// # use serde::Serialize;
521/// # use serde_json::json;
522/// # use lambda_appsync::res_to_json;
523/// #[derive(Serialize)]
524/// struct User {
525///     id: String,
526///     name: String
527/// }
528///
529/// let user = User {
530///     id: "123".to_string(),
531///     name: "John".to_string()
532/// };
533///
534/// let json = res_to_json(user);
535/// assert_eq!(json, json!({
536///     "id": "123",
537///     "name": "John"
538/// }));
539///
540/// // Simple types also work
541/// let num = res_to_json(42);
542/// assert_eq!(num, json!(42));
543/// ```
544pub fn res_to_json<T: Serialize>(res: T) -> serde_json::Value {
545    serde_json::to_value(res).expect("Appsync schema objects are JSON compatible")
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use serde_json::json;
552
553    #[test]
554    fn test_appsync_auth_strategy() {
555        let allow: AppsyncAuthStrategy = serde_json::from_str("\"ALLOW\"").unwrap();
556        let deny: AppsyncAuthStrategy = serde_json::from_str("\"DENY\"").unwrap();
557
558        match allow {
559            AppsyncAuthStrategy::Allow => (),
560            _ => panic!("Expected Allow"),
561        }
562
563        match deny {
564            AppsyncAuthStrategy::Deny => (),
565            _ => panic!("Expected Deny"),
566        }
567    }
568
569    #[test]
570    fn test_appsync_identity_cognito() {
571        let json = json!({
572            "sub": "user123",
573            "username": "testuser",
574            "issuer": "https://cognito-idp.region.amazonaws.com/pool_id",
575            "defaultAuthStrategy": "ALLOW",
576            "sourceIp": ["1.2.3.4"],
577            "groups": ["admin", "users"],
578            "claims": {
579                "email": "user@example.com",
580                "custom:role": "developer"
581            }
582        });
583
584        if let AppsyncIdentity::Cognito(cognito) = serde_json::from_value(json).unwrap() {
585            assert_eq!(cognito.sub, "user123");
586            assert_eq!(cognito.username, "testuser");
587            assert_eq!(
588                cognito.issuer,
589                "https://cognito-idp.region.amazonaws.com/pool_id"
590            );
591            assert_eq!(cognito.default_auth_strategy, AppsyncAuthStrategy::Allow);
592            assert_eq!(cognito.source_ip, vec!["1.2.3.4"]);
593            assert_eq!(
594                cognito.groups,
595                Some(vec!["admin".to_string(), "users".to_string()])
596            );
597            assert_eq!(
598                cognito.claims,
599                json!({
600                    "email": "user@example.com",
601                    "custom:role": "developer"
602                })
603            );
604        } else {
605            panic!("Expected Cognito variant");
606        }
607    }
608
609    #[test]
610    fn test_appsync_identity_iam() {
611        let json = json!({
612            "accountId": "123456789012",
613            "sourceIp": ["1.2.3.4"],
614            "username": "IAMUser",
615            "userArn": "arn:aws:iam::123456789012:user/IAMUser"
616        });
617
618        if let AppsyncIdentity::Iam(iam) = serde_json::from_value(json).unwrap() {
619            assert_eq!(iam.account_id, "123456789012");
620            assert_eq!(iam.source_ip, vec!["1.2.3.4"]);
621            assert_eq!(iam.username, "IAMUser");
622            assert_eq!(iam.user_arn, "arn:aws:iam::123456789012:user/IAMUser");
623            assert!(iam.federated_identity.is_none());
624        } else {
625            panic!("Expected IAM variant");
626        }
627    }
628
629    #[test]
630    fn test_appsync_identity_iam_with_cognito() {
631        let json = json!({
632            "accountId": "123456789012",
633            "sourceIp": ["1.2.3.4"],
634            "username": "IAMUser",
635            "userArn": "arn:aws:iam::123456789012:user/IAMUser",
636            "cognitoIdentityId": "region:id",
637            "cognitoIdentityPoolId": "region:pool_id",
638            "cognitoIdentityAuthType": "authenticated",
639            "cognitoIdentityAuthProvider": "cognito-idp.region.amazonaws.com/pool_id"
640        });
641
642        if let AppsyncIdentity::Iam(iam) = serde_json::from_value(json).unwrap() {
643            assert_eq!(iam.account_id, "123456789012");
644            assert_eq!(iam.source_ip, vec!["1.2.3.4"]);
645            assert_eq!(iam.username, "IAMUser");
646            assert_eq!(iam.user_arn, "arn:aws:iam::123456789012:user/IAMUser");
647
648            let federated = iam.federated_identity.unwrap();
649            assert_eq!(federated.identity_id, "region:id");
650            assert_eq!(federated.identity_pool_id, "region:pool_id");
651            assert!(matches!(
652                federated.auth_type,
653                CognitoIdentityAuthType::Authenticated
654            ));
655            assert_eq!(
656                federated.auth_provider,
657                "cognito-idp.region.amazonaws.com/pool_id"
658            );
659        } else {
660            panic!("Expected IAM variant");
661        }
662    }
663
664    #[test]
665    fn test_appsync_identity_oidc() {
666        let json = json!({
667            "iss": "https://auth.example.com",
668            "sub": "user123",
669            "aud": "client123",
670            "exp": 1714521210,
671            "iat": 1714517610,
672            "name": "John Doe",
673            "email": "john@example.com",
674            "roles": ["admin"],
675            "org_id": "org123",
676            "custom_claim": "value"
677        });
678
679        if let AppsyncIdentity::Oidc(oidc) = serde_json::from_value(json).unwrap() {
680            assert_eq!(oidc.iss, "https://auth.example.com");
681            assert_eq!(oidc.sub, "user123");
682            assert_eq!(oidc.aud, "client123");
683            assert_eq!(oidc.exp, 1714521210);
684            assert_eq!(oidc.iat, 1714517610);
685            assert_eq!(oidc.additional_claims.get("name").unwrap(), "John Doe");
686            assert_eq!(
687                oidc.additional_claims.get("email").unwrap(),
688                "john@example.com"
689            );
690            assert_eq!(
691                oidc.additional_claims.get("roles").unwrap(),
692                &json!(["admin"])
693            );
694            assert_eq!(oidc.additional_claims.get("org_id").unwrap(), "org123");
695            assert_eq!(oidc.additional_claims.get("custom_claim").unwrap(), "value");
696        } else {
697            panic!("Expected OIDC variant");
698        }
699    }
700
701    #[test]
702    fn test_appsync_identity_lambda() {
703        let json = json!({
704            "resolverContext": {
705                "userId": "user123",
706                "permissions": ["read", "write"],
707                "metadata": {
708                    "region": "us-west-2",
709                    "environment": "prod"
710                }
711            }
712        });
713
714        if let AppsyncIdentity::Lambda(lambda) = serde_json::from_value(json).unwrap() {
715            assert_eq!(
716                lambda.resolver_context,
717                json!({
718                    "userId": "user123",
719                    "permissions": ["read", "write"],
720                    "metadata": {
721                        "region": "us-west-2",
722                        "environment": "prod"
723                    }
724                })
725            );
726        } else {
727            panic!("Expected Lambda variant");
728        }
729    }
730
731    #[test]
732    fn test_appsync_identity_api_key() {
733        let json = serde_json::Value::Null;
734
735        if let AppsyncIdentity::ApiKey = serde_json::from_value(json).unwrap() {
736            // Test passes if we get the ApiKey variant
737        } else {
738            panic!("Expected ApiKey variant");
739        }
740    }
741
742    #[test]
743    fn test_appsync_response() {
744        let success = AppsyncResponse::from(json!({"field": "value"}));
745        assert!(success.data.is_some());
746        assert!(success.error.is_none());
747
748        let error = AppsyncResponse::from(AppsyncError::new("TestError", "message"));
749        assert!(error.data.is_none());
750        assert!(error.error.is_some());
751    }
752
753    #[test]
754    fn test_appsync_error() {
755        let error = AppsyncError::new("TestError", "message");
756        assert_eq!(error.error_type, "TestError");
757        assert_eq!(error.error_message, "message");
758
759        let error1 = AppsyncError::new("Error1", "msg1");
760        let error2 = AppsyncError::new("Error2", "msg2");
761        let combined = error1 | error2;
762
763        assert_eq!(combined.error_type, "Error1|Error2");
764        assert_eq!(combined.error_message, "msg1\nmsg2");
765    }
766
767    #[test]
768    fn test_arg_from_json() {
769        let mut args = json!({
770            "string": "test",
771            "number": 42,
772            "bool": true
773        });
774
775        let s: String = arg_from_json(&mut args, "string").unwrap();
776        assert_eq!(s, "test");
777
778        let n: i32 = arg_from_json(&mut args, "number").unwrap();
779        assert_eq!(n, 42);
780
781        let b: bool = arg_from_json(&mut args, "bool").unwrap();
782        assert!(b);
783
784        let err: Result<String, _> = arg_from_json(&mut args, "missing");
785        assert!(err.is_err());
786    }
787
788    #[test]
789    fn test_res_to_json() {
790        #[derive(Serialize)]
791        struct Test {
792            field: String,
793        }
794
795        let test = Test {
796            field: "value".to_string(),
797        };
798
799        let json = res_to_json(test);
800        assert_eq!(json, json!({"field": "value"}));
801
802        assert_eq!(res_to_json(42), json!(42));
803        assert_eq!(res_to_json("test"), json!("test"));
804    }
805}