Skip to content

Commit

Permalink
feat: ✨ added aws-specific serverless function invoker
Browse files Browse the repository at this point in the history
  • Loading branch information
arctic-hen7 committed Jun 25, 2021
1 parent 96825bb commit 2616733
Show file tree
Hide file tree
Showing 5 changed files with 124 additions and 149 deletions.
70 changes: 7 additions & 63 deletions graphql_serverless/bin/serverless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,27 @@
use netlify_lambda_http::{
handler,
lambda::{Context as LambdaCtx, run as run_lambda},
IntoResponse, Request, Response
IntoResponse, Request
};
use aws_lambda_events::encodings::Body;

use lib::{
AuthCheckBlockState,
OptionsBuilder,
ServerlessResponse,
run_serverless_req
AuthCheckBlockState,
AwsError,
run_aws_req
};
use dev_utils::{
ctx::Context,
db::DbPool,
schemas::users::{Query, Mutation, Subscription}
};

type Error = Box<dyn std::error::Error + Send + Sync + 'static>;

#[tokio::main]
async fn main() -> Result<(), Error> {
async fn main() -> Result<(), AwsError> {
run_lambda(handler(graphql)).await?;
Ok(())
}

// TODO move this into the `lib` crate and import it here
async fn graphql(req: Request, _: LambdaCtx) -> Result<impl IntoResponse, Error> {
async fn graphql(req: Request, _: LambdaCtx) -> Result<impl IntoResponse, AwsError> {
let opts = OptionsBuilder::new()
.ctx(Context {
pool: DbPool::default()
Expand All @@ -43,57 +38,6 @@ async fn graphql(req: Request, _: LambdaCtx) -> Result<impl IntoResponse, Error>
// Endpoints are set up as `/graphql` and `/graphiql` automatically
.finish().expect("Options building failed!");

// Get the request body (query/mutation) as a string
// Any errors are returned gracefully as HTTP responses
let body = req.body();
let body = match body {
Body::Text(body_str) => body_str.to_string(),
Body::Binary(_) => {
let res = Response::builder()
.status(400) // Invalid request
.body("Found binary body, expected string".to_string())?;
return Ok(res);
},
Body::Empty => {
let res = Response::builder()
.status(400) // Invalid request
.body("Found empty body, expected string".to_string())?;
return Ok(res);
},
};
// Get the authorisation header as a string
// Any errors are returned gracefully as HTTP responses
let auth_header = req.headers().get("Authorization");
let auth_header = match auth_header {
Some(auth_header) => {
let header_str = auth_header.to_str();
match header_str {
Ok(header_str) => Some(header_str),
Err(_) => {
let res = Response::builder()
.status(400) // Invalid request
.body("Couldn't parse authorization header as string".to_string())?;
return Ok(res)
}
}
},
None => None
};

// Run the serverless request with those options
// We convert the result to an appropriate HTTP response
let res = run_serverless_req(body, auth_header, opts).await; // FIXME
let res = match res {
ServerlessResponse::Success(gql_res_str) => Response::builder()
.status(200) // GraphQL will handle any errors within it through JSON
.body(gql_res_str)?,
ServerlessResponse::Blocked => Response::builder()
.status(403) // Unauthorised
.body("Request blocked due to invalid or insufficient authentication".to_string())?,
ServerlessResponse::Error => Response::builder()
.status(500) // Internal server error
.body("An internal server error occurred".to_string())?
};

let res = run_aws_req(req, opts).await?;
Ok(res)
}
Binary file modified graphql_serverless/functions/serverless
Binary file not shown.
111 changes: 111 additions & 0 deletions lib/src/aws_serverless.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// This file contains serverless logic unique to AWS Lambda and its derivatives (e.g. Netlify)

use std::any::Any;
use netlify_lambda_http::{
Request, Response
};
use aws_lambda_events::encodings::Body;
use async_graphql::{
ObjectType, SubscriptionType
};

use crate::options::Options;
use crate::serverless::{ServerlessResponse, run_serverless_req};

// A generic error type that the lambda will accept
pub type AwsError = Box<dyn std::error::Error + Send + Sync + 'static>;

// This allows us to propagate error HTTP responses more easily
enum AwsReqData {
Valid((String, Option<String>)),
Invalid(Response<String>) // For some reason
}

// Gets the stringified body and authentication header from an AWS request
// We use a generic error type rather than the crate's `error_chain` logic here for AWS' benefit
fn get_data_from_aws_req(req: Request) -> Result<AwsReqData, AwsError> {
// Get the request body (query/mutation) as a string
// Any errors are returned gracefully as HTTP responses
let body = req.body();
let body = match body {
Body::Text(body_str) => body_str.to_string(),
Body::Binary(_) => {
let res = Response::builder()
.status(400) // Invalid request
.body("Found binary body, expected string".to_string())?;
return Ok(AwsReqData::Invalid(res));
},
Body::Empty => {
let res = Response::builder()
.status(400) // Invalid request
.body("Found empty body, expected string".to_string())?;
return Ok(AwsReqData::Invalid(res));
},
};
// Get the authorisation header as a string
// Any errors are returned gracefully as HTTP responses
let auth_header = req.headers().get("Authorization");
let auth_header = match auth_header {
Some(auth_header) => {
let header_str = auth_header.to_str();
match header_str {
Ok(header_str) => Some(header_str.to_string()),
Err(_) => {
let res = Response::builder()
.status(400) // Invalid request
.body("Couldn't parse authorization header as string".to_string())?;
return Ok(AwsReqData::Invalid(res))
}
}
},
None => None
};

Ok(AwsReqData::Valid((
body,
auth_header
)))
}

// Parses the response from `run_serverless_req` into HTTP responses that AWS Lambda (or derivatives) can handle
fn parse_aws_res(res: ServerlessResponse) -> Result<Response<String>, AwsError> {
let res = match res {
ServerlessResponse::Success(gql_res_str) => Response::builder()
.status(200) // GraphQL will handle any errors within it through JSON
.body(gql_res_str)?,
ServerlessResponse::Blocked => Response::builder()
.status(403) // Unauthorised
.body("Request blocked due to invalid or insufficient authentication".to_string())?,
ServerlessResponse::Error => Response::builder()
.status(500) // Internal server error
.body("An internal server error occurred".to_string())?
};

Ok(res)
}

pub async fn run_aws_req<C, Q, M, S>(
req: Request,
opts: Options<C, Q, M, S>
) -> Result<Response<String>, AwsError>
where
C: Any + Send + Sync + Clone,
Q: Clone + ObjectType + 'static,
M: Clone + ObjectType + 'static,
S: Clone + SubscriptionType + 'static
{
// Process the request data into what's needed
let req_data = get_data_from_aws_req(req)?;
let (body, auth_header) = match req_data {
AwsReqData::Valid(data) => data,
AwsReqData::Invalid(http_res) => return Ok(http_res), // Propagate any HTTP responses for errors
};

// Run the serverless request with the extracted data and the user's given options
// We convert the Option<String> to Option<&str> with `.as_deref()`
let res = run_serverless_req(body, auth_header.as_deref(), opts).await;

// Convert the result to an appropriate HTTP response
let http_res = parse_aws_res(res)?;
Ok(http_res)
}
2 changes: 2 additions & 0 deletions lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ mod options;
mod graphql_server;
mod subscriptions_server;
mod serverless;
mod aws_serverless;
mod auth;
mod routes;

// Public exports accessible from the root (everything the user will need)
pub use crate::graphql_server::create_graphql_server;
pub use crate::subscriptions_server::create_subscriptions_server;
pub use crate::serverless::{ServerlessResponse, run_serverless_req};
pub use crate::aws_serverless::{AwsError, run_aws_req};
pub use crate::options::{Options, OptionsBuilder, AuthCheckBlockState};
pub use crate::pubsub::Publisher;

Expand Down
90 changes: 4 additions & 86 deletions lib/src/serverless.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
// This file contains all the logic for setting up a serverless system for queries/mutations
// This is designed to be used with the `netlify_lambda_http` crate, but it exposes more generic primitives as well
// This exposes more generic primitives that the serverless systems not derived from AWS Lambda can depend on
// If a user wants to use some fancy new serverless system that doesn't even have requests, they can use this as long as they have a request body and auth header!

use std::any::Any;
use async_graphql::{
ObjectType, SubscriptionType, EmptySubscription,
http::{playground_source, GraphQLPlaygroundConfig},
ObjectType, SubscriptionType,
Request
};
use actix_web::{
web::{self, ServiceConfig}, guard, HttpResponse,
};

use crate::graphql::{get_schema_without_subscriptions};
use crate::auth::middleware::{AuthCheck, AuthVerdict, get_token_state_from_header, get_auth_verdict};
use crate::routes::{graphql};
use crate::options::{Options, AuthCheckBlockState};
use crate::errors::*;
use crate::auth::middleware::{AuthVerdict, get_token_state_from_header, get_auth_verdict};
use crate::options::{Options};

pub enum ServerlessResponse {
Success(String),
Expand Down Expand Up @@ -76,79 +70,3 @@ where
AuthVerdict::Error => ServerlessResponse::Error
}
}

pub fn create_graphql_server<C, Q, M, S>(opts: Options<C, Q, M, S>) -> Result<
impl FnOnce(&mut ServiceConfig) + Clone
>
where
C: Any + Send + Sync + Clone,
Q: Clone + ObjectType + 'static,
M: Clone + ObjectType + 'static,
S: Clone + SubscriptionType + 'static
{
// Get the schema (this also creates a publisher to the subscriptions server and inserts context)
let schema = get_schema_without_subscriptions(opts.schema, opts.subscriptions_server_data, opts.ctx)?;
// Get the appropriate authentication middleware set up with the JWT secret
// This will wrap the GraphQL endpoint itself
let auth_middleware = match opts.authentication_block_state {
AuthCheckBlockState::AllowAll => AuthCheck::new(&opts.jwt_secret).allow_all(),
AuthCheckBlockState::AllowMissing => AuthCheck::new(&opts.jwt_secret).allow_missing(),
AuthCheckBlockState::BlockUnauthenticated => AuthCheck::new(&opts.jwt_secret).block_unauthenticated()
};

let graphql_endpoint = opts.graphql_endpoint;
let playground_endpoint = opts.playground_endpoint;

// Actix Web allows us to configure apps with `.configure()`, which is what the user will do
// Now we create the closure that will configure the user's app to support a GraphQL server
let configurer = move |cfg: &mut ServiceConfig| {
// Add everything except for the playground endpoint (which may not even exist)
cfg
.data(schema.clone()) // Clone the full schema we got before and provide it here
// The primary GraphQL endpoint for queries and mutations
.service(web::resource(&graphql_endpoint)
.guard(guard::Post()) // Should accept POST requests
.wrap(auth_middleware.clone()) // Should be authenticated
.to(graphql::<Q, M, EmptySubscription>) // The handler function it should use
);

// Define the closure for the GraphiQL endpoint
// We don't do this in routes because of annoying type annotations
let graphql_endpoint_for_closure = graphql_endpoint; // We need this because moving
let graphiql_closure = move || {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(playground_source(
GraphQLPlaygroundConfig::new(&graphql_endpoint_for_closure).subscription_endpoint(&graphql_endpoint_for_closure),
))
};

// Set up the endpoint for the GraphQL playground
match playground_endpoint {
// If we're in development and it's enabled, set it up without authentication
Some(playground_endpoint) if cfg!(debug_assertions) => {
cfg
.service(web::resource(playground_endpoint)
.guard(guard::Get())
.to(graphiql_closure) // The playground needs to know where to send its queries
);
},
// If we're in production and it's enabled, set it up with authentication
// The playground doesn't process the auth headers, so the token just needs to be valid (no further access control yet)
Some(playground_endpoint) => {
cfg
.service(web::resource(playground_endpoint)
.guard(guard::Get())
// TODO by request, the JWT secret and block level can be different here
.wrap(auth_middleware.clone())
.to(graphiql_closure) // The playground needs to know where to send its queries
);
},
None => ()
};
// This closure works entirely with side effects, so we don't need to return anything here
};


Ok(configurer)
}

0 comments on commit 2616733

Please sign in to comment.