diff --git a/Cargo.toml b/Cargo.toml index 6a64be0..005839d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,12 +11,15 @@ license = "MIT/Apache-2.0" exclude = [".gitignore", "builder/**", "examples/**", "test/**"] [dependencies] -serde = "1.0" -serde_json = "1.0" +chrono = { version = "0.4", features = ["serde"] } cpython = { version = "0.1", default-features = false } cpython-json = { version = "0.2", default-features = false } error-chain = { version = "0.11.0", optional = true } +serde = "1.0" +serde_derive = "1.0" +serde_json = "1.0" +serde_qs = "0.4" +serde-aux = "0.5" [features] default = ["cpython/python3-sys"] - diff --git a/src/data/apigateway/auth.rs b/src/data/apigateway/auth.rs new file mode 100644 index 0000000..82c0dad --- /dev/null +++ b/src/data/apigateway/auth.rs @@ -0,0 +1,53 @@ +/// API Gateway Custom Authorizer Events +use super::*; + +use data::apigateway::HttpEventRequestContext; + +use std::fmt; + +/// The type of an `AuthEvent`. +#[serde(rename_all="SCREAMING_SNAKE_CASE")] +#[derive(Debug,Eq,PartialEq,Serialize,Deserialize)] +pub enum AuthEventType { + Request, + Token +} + +/// An API Gateway authorization event for a custom Lambda authorizer. +#[serde(rename_all="camelCase")] +#[derive(Serialize,Deserialize)] +pub struct AuthEvent { + #[serde(default)] + pub headers: BTreeMap, + pub http_method: String, + pub method_arn: String, + pub path: String, + #[serde(default)] + pub path_parameters: BTreeMap, + #[serde(default)] + pub query_string_parameters: BTreeMap, + pub resource: String, + pub request_context: HttpEventRequestContext, + #[serde(default)] + pub stage_variables: BTreeMap, + #[serde(rename="type")] + pub event_type: AuthEventType, +} + +/// The response effect for a given `AuthEvent`. +#[derive(Serialize,Deserialize)] +pub enum AuthEffect { + /// Allow access to the underlying resource. + Allow, + /// Reject access to the underlying resource. + Deny +} + +impl fmt::Display for AuthEffect { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + AuthEffect::Allow => write!(f, "Allow"), + AuthEffect::Deny => write!(f, "Deny") + } + } +} diff --git a/src/data/apigateway/fixtures/authorize.json b/src/data/apigateway/fixtures/authorize.json new file mode 100644 index 0000000..9b39b30 --- /dev/null +++ b/src/data/apigateway/fixtures/authorize.json @@ -0,0 +1,26 @@ +{ + "headers": null, + "httpMethod": "GET", + "methodArn": "arn:aws:execute-api:us-east-1:111111111111:rest-api-id/null/GET/", + "path": "/", + "pathParameters": {}, + "queryStringParameters": {}, + "requestContext": { + "accountId": "111111111111", + "apiId": "rest-api-id", + "httpMethod": "GET", + "identity": { + "apiKey": "test-invoke-api-key", + "apiKeyId": "test-invoke-api-key-id", + "sourceIp": "test-invoke-source-ip" + }, + "path": "/", + "requestId": "test-invoke-request", + "resourceId": "test-invoke-resource-id", + "resourcePath": "/", + "stage": "test-invoke-stage" + }, + "resource": "/", + "stageVariables": {}, + "type": "REQUEST" +} diff --git a/src/data/apigateway/fixtures/request.json b/src/data/apigateway/fixtures/request.json new file mode 100644 index 0000000..8a79408 --- /dev/null +++ b/src/data/apigateway/fixtures/request.json @@ -0,0 +1,53 @@ +{ + "body": null, + "headers": { + "Accept": "*/*", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-Country": "US", + "Host": "example.com", + "User-Agent": "curl/7.47.0", + "Via": "1.1 deadbeefcafebabebeefbeefdeaddead.cloudfront.net (CloudFront)", + "X-Amz-Cf-Id": "J0YMU-brgNv9hzadv_rjnICwfLCd4-IYjVz55KdZS6fuGB6xkU65WA==", + "X-Amzn-Trace-Id": "Root=1-5a9095d0-2459dc8c2eead5b445840ca0", + "X-Forwarded-For": "127.0.0.1, 127.0.0.1", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "path": "/echo.json", + "pathParameters": null, + "queryStringParameters": null, + "requestContext": { + "accountId": "123456789012", + "apiId": "smddwihyy9", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "sourceIp": "127.0.0.1", + "user": null, + "userAgent": "curl/7.47.0", + "userArn": null + }, + "path": "/echo.json", + "protocol": "HTTP/1.1", + "requestId": "30e9177b-7253-43c4-95b4-b7c275ad037f", + "requestTime": "23/Feb/2018:22:29:36 +0000", + "requestTimeEpoch": 1519424976415, + "resourceId": "abcdef", + "resourcePath": "/echo.json", + "stage": "production" + }, + "resource": "/echo.json", + "stageVariables": null +} diff --git a/src/data/apigateway/fixtures/test.json b/src/data/apigateway/fixtures/test.json new file mode 100644 index 0000000..a2ca432 --- /dev/null +++ b/src/data/apigateway/fixtures/test.json @@ -0,0 +1,36 @@ +{ + "body": null, + "headers": null, + "httpMethod": "DELETE", + "isBase64Encoded": false, + "path": "/user/session.json", + "pathParameters": null, + "queryStringParameters": null, + "requestContext": { + "accountId": "123456789012", + "apiId": "rest-api-id", + "httpMethod": "DELETE", + "identity": { + "accessKey": "AWS_ACCESS_KEY_ID", + "accountId": "123456789012", + "apiKey": "test-invoke-api-key", + "apiKeyId": "test-invoke-api-key-id", + "caller": "AWS_CALLER_ID", + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "sourceIp": "test-invoke-source-ip", + "user": "AIDAJDXSYAWOHYRSW7IPO", + "userAgent": "Apache-HttpClient/4.5.x (Java/1.8.0_144)", + "userArn": "arn:aws:iam::123456789012:user/example" + }, + "path": "/user/session.json", + "requestId": "test-invoke-request", + "resourceId": "abcdef", + "resourcePath": "/user/session.json", + "stage": "test-invoke-stage" + }, + "resource": "/user/session.json", + "stageVariables": null +} diff --git a/src/data/apigateway/mod.rs b/src/data/apigateway/mod.rs new file mode 100644 index 0000000..4b7ac71 --- /dev/null +++ b/src/data/apigateway/mod.rs @@ -0,0 +1,280 @@ +/// Data-types for Amazon API Gateway. + +mod auth; +#[cfg(test)] +mod tests; + +pub use self::auth::AuthEvent; +pub use self::auth::AuthEventType; +pub use self::auth::AuthEffect; + +use super::*; + +use chrono::DateTime; +use chrono::TimeZone; +use chrono::Utc; + +use std::fmt; + +use serde_qs as qs; + +/// A generic API Gateway event, either an `AuthEvent` or a `HttpEvent`. +pub enum Event { + /// An authorization request from API Gateway requesting access to a given resource. + Authorize(AuthEvent), + /// An API Gateway Lambda Proxy request. + Request(HttpEvent), +} + +/// An enumeration over HTTP methods. +#[derive(Debug,Eq,PartialEq,Serialize,Deserialize)] +pub enum HttpMethod { + /// A HTTP HEAD request. + HEAD, + /// A HTTP GET request. + GET, + /// A HTTP POST request. + POST, + /// A HTTP PUT request. + PUT, + /// A HTTP OPTIONS request. + OPTIONS, + /// A HTTP DELETE request. + DELETE, +} + +impl fmt::Display for HttpMethod { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", match *self { + HttpMethod::HEAD => "HEAD", + HttpMethod::GET => "GET", + HttpMethod::POST => "POST", + HttpMethod::PUT => "PUT", + HttpMethod::OPTIONS => "OPTIONS", + HttpMethod::DELETE => "DELETE" + }) + } +} + +/// An event type representing an API Gateway Proxy Request as sent to a Lambda function. +#[derive(Serialize,Deserialize)] +pub struct HttpEvent { + /// The optional body of the request. + /// If `is_base64_encoded` is `true`, this will be a base-64 encoded binary payload. + pub body: Option, + /// A map of HTTP headers present with the request. + #[serde(default)] + pub headers: BTreeMap, + /// The HTTP method used in the request. + #[serde(rename="httpMethod")] + pub http_method: HttpMethod, + /// Whether the `body` is plaintext or base-64 encoded binary data. + #[serde(rename="isBase64Encoded")] + pub is_base64_encoded: bool, + /// The request path. + pub path: String, + /// A map of path parameters defined by the API Gateway Resource. + #[serde(default,rename="pathParameters")] + pub path_parameters: BTreeMap, + /// A map of query string parameters extracted from the request. + #[serde(default,rename="queryStringParameters")] + pub query_string_parameters: BTreeMap, + /// The API Gateway Resource id for the current resource being requested. + pub resource: String, + /// The HTTP request context for this request. + #[serde(rename="requestContext")] + pub request_context: HttpEventRequestContext, + /// A map of API Gateway Stage Variables for this request. + #[serde(default,rename="stageVariables")] + pub stage_variables: BTreeMap, +} + +impl HttpEvent { + + /// Fetch a header from this event, if present. + pub fn header(&self, key: &str) -> Option<&str> { + self.headers.get(key).map(|s| s.as_str()) + } +} + +impl fmt::Display for HttpEvent { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{protocol} {method} {path}{querystring} (body? {body})", + protocol = match self.request_context.protocol { + Some(ref s) => s.as_str(), + None => "(unknown)" + }, + method = self.http_method, + path = self.path, + querystring = qs::to_string(&self.query_string_parameters).unwrap_or(String::new()), + body = match self.body { + Some(_) => true, + None => false + } + ) + } +} + +/// A HTTP event request context containing metadata about the request. +#[derive(Serialize,Deserialize)] +pub struct HttpEventRequestContext { + /// The AWS account id of the current AWS account. + #[serde(rename="accountId")] + pub account_id: String, + /// The API Gateway Rest API id for this request. + #[serde(rename="apiId")] + pub api_id: String, + /// The HTTP method of the request. + #[serde(rename="httpMethod")] + pub http_method: HttpMethod, + /// Identity information relevant to the currently executing Lambda function. + /// Some keys may be null. + pub identity: BTreeMap>, + /// The HTTP path of the request. + pub path: String, + /// The protocol used in this request. + pub protocol: Option, + /// The AWS request id. + #[serde(rename="requestId")] + pub request_id: String, + /// The request timestamp. + #[serde(rename="requestTime")] + pub request_time: Option, + /// The request timestamp as a `u64`. + #[serde(rename="requestTimeEpoch")] + pub request_time_epoch: Option, + /// The API Gateway Resource id. + #[serde(rename="resourceId")] + pub resource_id: String, + /// The path to this API Gateway Resource. + #[serde(rename="resourcePath")] + pub resource_path: String, + /// The API Gateway Stage name. + pub stage: String, +} + +impl HttpEventRequestContext { + + /// Returns the request time as a `chrono::DateTime`. + /// + /// This value is parsed from `request_time_epoch`, which is the number of milliseconds since the + /// Unix epoch. + pub fn time(&self) -> Option> { + match self.request_time_epoch { + Some(ts) => Some(Utc.timestamp((ts / 1_000) as i64, ((ts % 1000) * 1_000_000) as u32)), + None => None, + } + } +} + +/// An enumeration over standard HTTP status codes. +serde_aux_enum_number_declare!( +HttpStatus { + Continue = 100, + SwitchingProtocols = 101, + Processing = 102, + EarlyHints = 103, + + Ok = 200, + Created = 201, + Accepted = 202, + NonAuthoritativeInformation = 203, + NoContent = 204, + ResetContent = 205, + PartialContent = 206, + MultiStatus = 207, + AlreadyReported = 208, + IMUsed = 226, + + MultipleChoices = 300, + MovedPermanently = 301, + Found = 302, + SeeOther = 303, + NotModified = 304, + UseProxy = 305, + SwitchProxy = 306, + TemporaryRedirect = 307, + PermanentRedirect = 308, + + BadRequest = 400, + Unauthorized = 401, + PaymentRequired = 402, + Forbidden = 403, + NotFound = 404, + MethodNotAllowed = 405, + NotAcceptable = 406, + ProxyAuthenticationRequired = 407, + RequestTimeout = 408, + Conflict = 409, + Gone = 410, + LengthRequired = 411, + PreconditionFailed = 412, + PayloadTooLarge = 413, + URITooLong = 414, + UnsupportedMediaType = 415, + RangeNotSatisfiable = 416, + ExpectationFailed = 417, + ImATeapot = 418, + MisdirectedRequest = 421, + UnprocessableEntity = 422, + Locked = 423, + FailedDependency = 424, + UpgradeRequired = 426, + PreconditionRequired = 428, + TooManyRequests = 429, + RequestHeaderFieldsTooLarge = 431, + UnavailableForLegalReasons = 451, + + InternalServerError = 500, + NotImplemented = 501, + BadGateway = 502, + ServiceUnavailable = 503, + GatewayTimeout = 504, + HttpVersionNotSupported = 505, + VariantAlsoNegotiates = 506, + InsufficientStorage = 507, + LoopDetected = 508, + NotExtended = 510, + NetworkAuthenticationRequest = 511, +}); + +/// A HTTP response to be sent back to a client. +#[derive(Serialize,Deserialize)] +pub struct HttpResponse { + #[serde(rename="statusCode")] + pub status: HttpStatus, + pub headers: BTreeMap, + pub body: Option, + #[serde(default)] + pub is_base64_encoded: bool, +} + +impl HttpResponse { + + pub fn empty(status: HttpStatus) -> Self { + HttpResponse { + status: status, + headers: BTreeMap::new(), + body: None, + is_base64_encoded: false, + } + } + + pub fn with_body>(status: HttpStatus, body: S) -> Self { + HttpResponse { + status: status, + headers: BTreeMap::new(), + body: Some(body.into()), + is_base64_encoded: false, + } + + } + + pub fn set_header(&mut self, key: &str, value: &str) { + self.headers.insert(String::from(key), String::from(value)); + } + + pub fn success(&self) -> bool { + return self.status >= HttpStatus::Ok && self.status < HttpStatus::BadRequest + } +} diff --git a/src/data/apigateway/tests.rs b/src/data/apigateway/tests.rs new file mode 100644 index 0000000..d27a5e4 --- /dev/null +++ b/src/data/apigateway/tests.rs @@ -0,0 +1,42 @@ +use super::*; + +use serde_json; + +#[test] +fn test_auth_payload() { + let event: AuthEvent = serde_json::from_str(include_str!("fixtures/authorize.json")) + .expect("unable to parse json"); + + assert_eq!("GET", event.http_method); + assert_eq!("/", event.path); + assert_eq!("/", event.resource); + assert_eq!("arn:aws:execute-api:us-east-1:961179389914:smddwihyy9/null/GET/", + event.method_arn); + assert_eq!(AuthEventType::Request, event.event_type); +} + +#[test] +fn test_https_payload() { + let event: HttpEvent = serde_json::from_str(include_str!("fixtures/request.json")) + .expect("unable to parse json"); + + // fields themselves + assert_eq!(None, event.body); + assert_eq!(HttpMethod::GET , event.http_method); + assert_eq!(false, event.is_base64_encoded); + assert_eq!("/echo.json", event.path); + assert_eq!("/echo.json", event.resource); + + // subfields + assert_eq!("961179389914", event.request_context.account_id); + assert_eq!("smddwihyy9", event.request_context.api_id); + assert_eq!(HttpMethod::GET, event.request_context.http_method); + assert_eq!("/echo.json", event.request_context.path); + assert_eq!("HTTP/1.1", event.request_context.protocol.unwrap()); + assert_eq!("07535b6a-18e9-11e8-bcc6-397efc46efd2", event.request_context.request_id); + assert_eq!("23/Feb/2018:22:29:36 +0000", event.request_context.request_time.unwrap()); + assert_eq!(1519424976415, event.request_context.request_time_epoch.unwrap()); + assert_eq!("lig9h2", event.request_context.resource_id); + assert_eq!("/echo.json", event.request_context.resource_path); + assert_eq!("production", event.request_context.stage); +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 0000000..be5666f --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1,22 @@ +/// Data types for Amazon API Gateway. +/// +/// These types are defined [in the Amazon API Gateway docs][apigateway-data-types]. +/// +/// [apigateway-data-types]: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html +pub mod apigateway; + +use serde; +use serde::de::Deserializer; + +use std::collections::BTreeMap; + +// fn deserialize_empty() -> T where T: Default { +// T::default() +// } + +// fn deserialize_with_default<'de, D, T>(deserializer: D) -> Result +// where D: Deserializer<'de> { +// let result = deserializer.deserialize_any(); +// +// result +// } diff --git a/src/lib.rs b/src/lib.rs index ce4f83b..fc42d35 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,10 +84,16 @@ //! cpython = { version = "0.1", default-features = false, features = ["python27-sys"] } //! ``` +extern crate chrono; extern crate cpython; extern crate cpython_json; extern crate serde; +#[macro_use] +extern crate serde_derive; +#[macro_use] +extern crate serde_aux; extern crate serde_json; +extern crate serde_qs; #[cfg(feature = "error-chain")] #[macro_use] @@ -108,6 +114,9 @@ mod errors { } } } + +pub mod data; + #[cfg(feature = "error-chain")] pub use errors::ErrorKind::{PyException, RustError}; #[cfg(feature = "error-chain")]