Skip to content

Commit

Permalink
feat: ✨ modularised the query/mutation systems
Browse files Browse the repository at this point in the history
  • Loading branch information
arctic-hen7 committed Jun 22, 2021
1 parent 482c229 commit 2a50470
Show file tree
Hide file tree
Showing 17 changed files with 423 additions and 125 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 22 additions & 45 deletions graphql_server/bin/serverful.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,61 +2,38 @@
// This binary runs a serverful setup with Actix Web, as opposed to a serverless approach (TODO)
// Even so, this system does NOT support subscriptions so we maintain the separity in development that will be present in production

use async_graphql_actix_web::{Request, Response};
use actix_web::{guard, web, App, HttpServer, HttpRequest};
use actix_web::{App, HttpServer};
use lib::{
load_env,
AppSchemaWithoutSubscriptions as AppSchema,
get_schema_without_subscriptions as get_schema,
routes::{
graphiql
},
auth::{
middleware::AuthCheck,
auth_state::{AuthState}
}
};
schemas::users::{Query, Mutation, Subscription},
graphql_utils::Context,
db::DbPool,

const GRAPHIQL_ENDPOINT: &str = "/graphiql"; // For the graphical development playground
const GRAPHQL_ENDPOINT: &str = "/graphql";
create_graphql_server, OptionsBuilder, AuthCheckBlockState
};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
load_env().expect("Error getting environment variables!");
// We get the schema once and then use it for all queries
// If this fails, we can't do anything at all
let schema = get_schema().expect("Failed to fetch schema.");
let opts = OptionsBuilder::new()
.ctx(Context {
pool: DbPool::default()
})
.subscriptions_server_hostname("http://subscriptions-server")
.subscriptions_server_port("6000")
.subscriptions_server_endpoint("/graphql")
.jwt_to_connect_to_subscriptions_server("invalidtoken")
.auth_block_state(AuthCheckBlockState::AllowAll)
.jwt_secret("aninsecuresecret")
.schema(Query {}, Mutation {}, Subscription {})
// Endpoints are set up as `/graphql` and `/graphiql` automatically
.finish().expect("Options building failed!");

let configurer = create_graphql_server(opts).expect("Failed to set up configurer.");

HttpServer::new(move || {
App::new()
.data(schema.clone())
.service(web::resource(GRAPHQL_ENDPOINT)
.guard(guard::Post())
.wrap(AuthCheck::block_unauthenticated())
.to(graphql)
) // POST endpoint for queries/mutations
// This system emulates the serverless one, and thus does not support subscriptions
.service(web::resource(GRAPHIQL_ENDPOINT).guard(guard::Get()).to(graphiql)) // GET endpoint for GraphiQL playground (unauthenticated because it's only for development)
.configure(configurer.clone())
})
.bind("0.0.0.0:7000")? // This stays the same, that port in the container will get forwarded to whatever's configured in `.ports.env`
.run()
.await
}

async fn graphql(
schema: web::Data<AppSchema>,
http_req: HttpRequest,
req: Request,
) -> Response {
// Get the GraphQL request so we can add data to it
let mut query = req.into_inner();
// Get the authorisation data from the request extensions if it exists (it would be set by the middleware)
let extensions = http_req.extensions();
let auth_data = extensions.get::<AuthState>();

// Clone the internal AuthState so we can place the variable into the context (lifetimes...)
let auth_data_for_ctx = auth_data.map(|auth_data| auth_data.clone());
// Add that to the GraphQL request data so we can access it in the resolvers
query = query.data(auth_data_for_ctx);
schema.execute(query).await.into()
}
1 change: 1 addition & 0 deletions lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ error-chain = "0.12.4"
jsonwebtoken = "7.2.0"
chrono = "0.4.19"
maplit = "1.0.2"
actix-service = "1.0.6"

[lib]
name = "lib"
Expand Down
13 changes: 3 additions & 10 deletions lib/src/auth/jwt.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use std::env;
use std::collections::HashMap;
use serde::{Serialize, Deserialize};
use jsonwebtoken::{encode, Algorithm, Header, EncodingKey, decode, DecodingKey, Validation};
Expand All @@ -22,15 +21,9 @@ pub struct JWTSecret<'a> {
}

// A function to get a JWT secret from a given string for both encoding and decoding
// Defaults to the environment variable `JWT_SECRET`
pub fn get_jwt_secret(secret_str: Option<&str>) -> Result<JWTSecret> {
let secret = match secret_str {
Some(secret) => secret.to_string(),
None => env::var("JWT_SECRET")?
};

let encoding_key = EncodingKey::from_base64_secret(&secret)?;
let decoding_key = DecodingKey::from_base64_secret(&secret)?;
pub fn get_jwt_secret<'a>(secret_str: String) -> Result<JWTSecret<'a>> {
let encoding_key = EncodingKey::from_base64_secret(&secret_str)?;
let decoding_key = DecodingKey::from_base64_secret(&secret_str)?;

Ok(
JWTSecret {
Expand Down
50 changes: 32 additions & 18 deletions lib/src/auth/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ use crate::errors::*;
use crate::auth::jwt::{get_jwt_secret, validate_and_decode_jwt};
use crate::auth::auth_state::{AuthState, AuthToken};

// Extracts an authentication state from the token
fn get_token_state_from_req(req: &ServiceRequest) -> Result<AuthState> {
// Extracts an authentication state from the given request
// Needs a JWT secret to validate the client's token
fn get_token_state_from_req(req: &ServiceRequest, secret_str: String) -> Result<AuthState> {
// Get the authorisation header from the request
let raw_auth_header = req
.headers()
Expand Down Expand Up @@ -48,7 +49,7 @@ fn get_token_state_from_req(req: &ServiceRequest) -> Result<AuthState> {
// Decode the bearer token into an authentication state
match bearer_token {
Some(token) => {
let jwt_secret = get_jwt_secret(None)?; // We'll use the environment variable
let jwt_secret = get_jwt_secret(secret_str)?;
let decoded_jwt = validate_and_decode_jwt(&token, &jwt_secret);

match decoded_jwt {
Expand All @@ -64,32 +65,39 @@ fn get_token_state_from_req(req: &ServiceRequest) -> Result<AuthState> {

// The block state chosen may have unforseen security implications, please choose wisely!
#[derive(Debug, Clone, Copy)]
enum AuthCheckBlockState {
pub enum AuthCheckBlockState {
AllowAll, // Allows anything through, adding the auth parameters to the request for later processing
BlockUnauthenticated, // Blocks missing/invalid tokens (all requests must be authenticated)
AllowMissing // Only block if an invalid token is given (if no token, allowed)
}

// Create a factory for authentication middleware
#[derive(Clone)]
pub struct AuthCheck {
token_secret: String,
block_state: AuthCheckBlockState // This defines whether or not we should block requests without a token or with an invalid one
}
impl AuthCheck {
// These functions allow us to initialise the middleware factory (and thus the middleware itself) with custom options
pub fn block_unauthenticated() -> Self {
// Initialises a new instance of the authentication middleware factory
// Needs a JWT to validate client tokens
pub fn new(token_secret: &str) -> Self {
Self {
block_state: AuthCheckBlockState::BlockUnauthenticated, // We block by default
token_secret: token_secret.to_string(),
block_state: AuthCheckBlockState::BlockUnauthenticated // We block by default
}
}
pub fn allow_missing() -> Self {
Self {
block_state: AuthCheckBlockState::AllowMissing,
}
// These functions allow us to initialise the middleware factory (and thus the middleware itself) with custom options
pub fn block_unauthenticated(mut self) -> Self {
self.block_state = AuthCheckBlockState::BlockUnauthenticated;
self
}
pub fn allow_all() -> Self {
Self {
block_state: AuthCheckBlockState::AllowAll
}
pub fn allow_missing(mut self) -> Self {
self.block_state = AuthCheckBlockState::AllowMissing;
self
}
pub fn allow_all(mut self) -> Self {
self.block_state = AuthCheckBlockState::AllowAll;
self
}
}

Expand All @@ -109,14 +117,20 @@ where
type Future = Ready<StdResult<Self::Transform, Self::InitError>>;

// This will be called internally by Actix Web to create our middleware
// All this really does is pass the service itself (handler basically) over to our middleware
// All this really does is pass the service itself (handler basically) over to our middleware (along with additional metadata)
fn new_transform(&self, service: S) -> Self::Future {
ok(AuthCheckMiddleware { service, block_state: self.block_state })
ok(AuthCheckMiddleware {
token_secret: self.token_secret.clone(),
service,
block_state: self.block_state
})
}
}

// The actual middleware
#[derive(Clone)]
pub struct AuthCheckMiddleware<S> {
token_secret: String, // The JWT secret as a string to validate client tokens
service: S,
block_state: AuthCheckBlockState // This will be passed in from whatever is set for the factory
}
Expand All @@ -140,7 +154,7 @@ where

fn call(&mut self, req: ServiceRequest) -> Self::Future {
// Check the token
let token_state = get_token_state_from_req(&req);
let token_state = get_token_state_from_req(&req, self.token_secret.clone());
match token_state {
// We hold `token_state` as the AuthState variant so we don't pointlessly insert a Result into the request extensions
Ok(token_state @ AuthState::Authorised(_)) => {
Expand Down
4 changes: 1 addition & 3 deletions lib/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,9 @@ pub fn get_client() -> Result<MongoClient> {
// The MongoDB crate handles pooling internally, so we don't have to worry about it here
// We just need a struct that exposes methods to get a client
// If extra pooling logic ever needs to be added, it can be done from here
#[derive(Clone, Default)]
pub struct DbPool {}
impl DbPool {
pub fn new() -> Self {
Self {}
}
pub fn get_client(&self) -> Result<MongoClient> {
// Check if we already have a client cached
let client = get_client()?;
Expand Down
5 changes: 5 additions & 0 deletions lib/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ error_chain! {
description("unauthorised access attempt")
display("unable to comply with request due to lack of valid and sufficient authentication")
}

IncompleteBuilderFields {
description("not all required builder fields were instantiated")
display("some required builder fields haven't been instantiated")
}
}
// We work with many external libraries, all of which have their own errors
foreign_links {
Expand Down
64 changes: 54 additions & 10 deletions lib/src/graphql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::schemas::{
}
};

// These will eventually be generated by the function
#[derive(MergedObject, Default)]
pub struct QueryRoot(BaseQuery, UsersQuery);

Expand All @@ -19,13 +20,16 @@ pub struct SubscriptionRoot(UsersSubscription);

// GRAPHQL CODE

use std::any::Any;
use std::sync::Mutex;
use async_graphql::{
MergedObject,
MergedSubscription,
Object as GQLObject,
Schema,
EmptySubscription
EmptySubscription,
ObjectType,
SubscriptionType
};

use crate::errors::*;
Expand Down Expand Up @@ -83,25 +87,65 @@ pub type AppSchemaWithoutSubscriptions = Schema<QueryRoot, MutationRoot, EmptySu
// We need to be able to work out the API version on the subscriptions server, so we still provide the basic queries
pub type AppSchemaForSubscriptions = Schema<BaseQuery, PublishMutation, SubscriptionRoot>;

pub fn get_schema_without_subscriptions() -> Result<AppSchemaWithoutSubscriptions> {
let schema = Schema::build(QueryRoot::default(), MutationRoot::default(), EmptySubscription)
.data(Context {
pool: DbPool::new()
})
// Information about the subscriptions server for the rest of the system
#[derive(Clone)]
pub struct SubscriptionsServerInformation {
pub hostname: String,
pub port: String, // It'll be mixed in to create a URL, may as well start as a string
pub endpoint: String,
pub jwt_to_connect: String // This should be signed with the secret the subscriptions server knows
}

// A type for the schema that the user will submit
#[derive(Clone)]
pub struct UserSchema<Q, M, S>
where
Q: ObjectType + 'static,
M: ObjectType + 'static,
S: SubscriptionType + 'static
{
pub query_root: Q,
pub mutation_root: M,
pub subscription_root: S
}

pub fn get_schema_without_subscriptions<C, Q, M, S>(
user_schema: UserSchema<Q, M, S>,
subscription_server_info: SubscriptionsServerInformation,
user_ctx: C
) -> Result<
Schema<Q, M, EmptySubscription>
>
where
C: Any + Send + Sync,
Q: ObjectType + 'static,
M: ObjectType + 'static,
S: SubscriptionType + 'static
{
// We just use an empty subscription root here because subscriptions are handled by the dedicated subscriptions server
let schema = Schema::build(user_schema.query_root, user_schema.mutation_root, EmptySubscription)
// We add some custom user-defined context (e.g. a database connection pool)
.data(user_ctx)
// We add a publisher so we can communicate with the subscriptions server
.data(
Publisher::new(None, None, None, None)?
) // We add a publisher that can send data to the subscriptions server (you can provide a hostname and port here if you want)
Publisher::new(
subscription_server_info.hostname,
subscription_server_info.port,
subscription_server_info.endpoint,
subscription_server_info.jwt_to_connect
)?
)
.finish();

Ok(schema)
}
pub fn get_schema_for_subscriptions() -> AppSchemaForSubscriptions {
Schema::build(BaseQuery, PublishMutation, SubscriptionRoot::default())
.data(Context {
pool: DbPool::new()
pool: DbPool::default()
})
.data(
Mutex::new(PubSub::new())
Mutex::new(PubSub::default())
) // We add a PubSub instance to internally manage state in the serverful subscriptions system
.finish()
}
Loading

0 comments on commit 2a50470

Please sign in to comment.