diff --git a/examples/web_app_graphql/.env.example.txt b/examples/web_app_graphql/.env.example.txt new file mode 100644 index 00000000..9bc1ad78 --- /dev/null +++ b/examples/web_app_graphql/.env.example.txt @@ -0,0 +1,4 @@ +DATABASE_URL=postgres://[username]:[password]@localhost/[database_name] +MEILISEARCH_HOST=http://localhost:7700 +MEILISEARCH_API_KEY=[your-master-key] +MIGRATIONS_DIR_PATH=migrations \ No newline at end of file diff --git a/examples/web_app_graphql/.gitignore b/examples/web_app_graphql/.gitignore new file mode 100644 index 00000000..2966ec73 --- /dev/null +++ b/examples/web_app_graphql/.gitignore @@ -0,0 +1,4 @@ +/target +/Cargo.lock + +.env \ No newline at end of file diff --git a/examples/web_app_graphql/Cargo.toml b/examples/web_app_graphql/Cargo.toml new file mode 100644 index 00000000..04156299 --- /dev/null +++ b/examples/web_app_graphql/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "meilisearch-ex" +version = "0.1.0" +edition = "2021" +authors = ["Eugene Korir "] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix-cors = "0.6.5" +actix-web = "4.4.0" +async-graphql = "6.0.11" +async-graphql-actix-web = "6.0.11" +diesel = { version = "2.1.4", features = ["postgres"] } +diesel-async = { version = "0.4.1", features = ["postgres", "deadpool"] } +diesel_migrations = "2.1.0" +dotenvy = "0.15.7" +env_logger = "0.10.1" +envy = "0.4.2" +futures = "0.3.29" +log = "0.4.20" +meilisearch-sdk = "0.24.3" +serde = { version = "1.0.192", features = ["derive"] } +serde_json = "1.0.108" +thiserror = "1.0.51" +validator = { version = "0.16.1", features = ["derive"] } diff --git a/examples/web_app_graphql/README.md b/examples/web_app_graphql/README.md new file mode 100644 index 00000000..b4b6803b --- /dev/null +++ b/examples/web_app_graphql/README.md @@ -0,0 +1,60 @@ +# Meilisearch example with graphql using `diesel`, `async_graphql` and `postgres` + +## Contents + +Setting up a graphql server using `async_graphql` and `actix-web` + +Using `diesel` to query the database + +Using `meilisearch-sdk` to search for records that match a given criteria + +## Running the example + +The meilisearch server needs to be running. You can run it by the command below + +```bash +meilisearch --master-key +``` + +Then you can run the application by simply running + +```bash +cargo run --release +``` + +The above command will display a link to your running instance and you can simply proceed by clicking the link or navigating to your browser. + +### Running the resolvers + +On your browser, you will see a graphql playground in which you can use to run some queries + +You can use the `searchUsers` query as follows: + +```gpl +query { + users{ + search(queryString: "Eugene"){ + lastName + firstName + email + } + } +} +``` + +### Errors + +Incase you run into the following error: + +```bash += note: ld: library not found for -lpq + clang: error: linker command failed with exit code 1 (use -v to see invocation) +``` + +Run: + +```bash +sudo apt install libpq-dev +``` + +This should fix the error diff --git a/examples/web_app_graphql/diesel.toml b/examples/web_app_graphql/diesel.toml new file mode 100644 index 00000000..c028f4a6 --- /dev/null +++ b/examples/web_app_graphql/diesel.toml @@ -0,0 +1,9 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" +custom_type_derives = ["diesel::query_builder::QueryId"] + +[migrations_directory] +dir = "migrations" diff --git a/examples/web_app_graphql/migrations/.keep b/examples/web_app_graphql/migrations/.keep new file mode 100644 index 00000000..e69de29b diff --git a/examples/web_app_graphql/migrations/00000000000000_diesel_initial_setup/down.sql b/examples/web_app_graphql/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 00000000..a9f52609 --- /dev/null +++ b/examples/web_app_graphql/migrations/00000000000000_diesel_initial_setup/down.sql @@ -0,0 +1,6 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + +DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); +DROP FUNCTION IF EXISTS diesel_set_updated_at(); diff --git a/examples/web_app_graphql/migrations/00000000000000_diesel_initial_setup/up.sql b/examples/web_app_graphql/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 00000000..d68895b1 --- /dev/null +++ b/examples/web_app_graphql/migrations/00000000000000_diesel_initial_setup/up.sql @@ -0,0 +1,36 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + + + + +-- Sets up a trigger for the given table to automatically set a column called +-- `updated_at` whenever the row is modified (unless `updated_at` was included +-- in the modified columns) +-- +-- # Example +-- +-- ```sql +-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); +-- +-- SELECT diesel_manage_updated_at('users'); +-- ``` +CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ +BEGIN + EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at + ) THEN + NEW.updated_at := current_timestamp; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/examples/web_app_graphql/migrations/2023-12-20-105441_users/down.sql b/examples/web_app_graphql/migrations/2023-12-20-105441_users/down.sql new file mode 100644 index 00000000..f910f702 --- /dev/null +++ b/examples/web_app_graphql/migrations/2023-12-20-105441_users/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +drop table if exists users; \ No newline at end of file diff --git a/examples/web_app_graphql/migrations/2023-12-20-105441_users/up.sql b/examples/web_app_graphql/migrations/2023-12-20-105441_users/up.sql new file mode 100644 index 00000000..b5dacea3 --- /dev/null +++ b/examples/web_app_graphql/migrations/2023-12-20-105441_users/up.sql @@ -0,0 +1,7 @@ +-- Your SQL goes here +create table if not exists users( + id serial primary key, + first_name varchar not null, + last_name varchar not null, + email varchar not null unique +); \ No newline at end of file diff --git a/examples/web_app_graphql/src/app_env_vars.rs b/examples/web_app_graphql/src/app_env_vars.rs new file mode 100644 index 00000000..72999ae2 --- /dev/null +++ b/examples/web_app_graphql/src/app_env_vars.rs @@ -0,0 +1,10 @@ +use serde::Deserialize; + +//Environment variables required for the app to run +#[derive(Deserialize, Debug, Clone)] +pub struct AppEnvVars { + pub meilisearch_api_key: String, + pub meilisearch_host: String, + pub database_url: String, + pub migrations_dir_path: String, +} diff --git a/examples/web_app_graphql/src/errors/mod.rs b/examples/web_app_graphql/src/errors/mod.rs new file mode 100644 index 00000000..a172f0ce --- /dev/null +++ b/examples/web_app_graphql/src/errors/mod.rs @@ -0,0 +1,25 @@ +use diesel::ConnectionError; +use diesel_async::pooled_connection::deadpool::{BuildError, PoolError}; +use diesel_migrations::MigrationError; +use serde_json::Error as SerdeError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ApplicationError { + #[error("Missing environment variable")] + Envy(#[from] envy::Error), + #[error("Input/Output error")] + Io(#[from] std::io::Error), + #[error("Database error")] + Diesel(#[from] diesel::result::Error), + #[error("Deadpool build error")] + DeadpoolBuild(#[from] BuildError), + #[error("Migration error")] + Migration(#[from] MigrationError), + #[error("Connection error")] + DieselConnection(#[from] ConnectionError), + #[error("Pool Error")] + Pool(#[from] PoolError), + #[error("Serde json error")] + SerDe(#[from] SerdeError), +} diff --git a/examples/web_app_graphql/src/graphql_schema/mod.rs b/examples/web_app_graphql/src/graphql_schema/mod.rs new file mode 100644 index 00000000..e78fe6bc --- /dev/null +++ b/examples/web_app_graphql/src/graphql_schema/mod.rs @@ -0,0 +1,15 @@ +use async_graphql::SimpleObject; +pub mod users; + +use users::mutation::UsersMut; +use users::query::UsersQuery; + +#[derive(Default, SimpleObject)] +pub struct Query { + users: UsersQuery, +} + +#[derive(Default, SimpleObject)] +pub struct Mutation { + users: UsersMut, +} diff --git a/examples/web_app_graphql/src/graphql_schema/users/mod.rs b/examples/web_app_graphql/src/graphql_schema/users/mod.rs new file mode 100644 index 00000000..90886b5e --- /dev/null +++ b/examples/web_app_graphql/src/graphql_schema/users/mod.rs @@ -0,0 +1,2 @@ +pub mod mutation; +pub mod query; diff --git a/examples/web_app_graphql/src/graphql_schema/users/mutation/add_user.rs b/examples/web_app_graphql/src/graphql_schema/users/mutation/add_user.rs new file mode 100644 index 00000000..ff10ee1b --- /dev/null +++ b/examples/web_app_graphql/src/graphql_schema/users/mutation/add_user.rs @@ -0,0 +1,68 @@ +use async_graphql::{Context, InputObject, Object, Result}; +use diesel_async::RunQueryDsl; +use validator::Validate; + +use crate::{ + models::{NewUser, User}, + validate_input, GraphQlData, +}; + +#[derive(Default)] +pub struct AddUser; + +#[derive(InputObject, Validate)] +pub struct IAddUser { + #[validate(length(min = 1))] + pub first_name: String, + #[validate(length(min = 1))] + pub last_name: String, + #[validate(email)] + pub email: String, +} + +#[Object] +impl AddUser { + ///Resolver for creating a new user and storing that data in the database + /// + /// The mutation can be run as follows + /// ```gpl + /// mutation AddUser{ + /// users { + /// signup(input: {firstName: "",lastName: "",email: ""}){ + /// id + /// firstName + /// lastName + /// email + /// } + /// } + /// } + pub async fn signup(&self, ctx: &Context<'_>, input: IAddUser) -> Result { + validate_input(&input)?; + + use crate::schema::users::dsl::users; + + let GraphQlData { pool, .. } = ctx.data().map_err(|e| { + log::error!("Failed to get app data: {:?}", e); + e + })?; + + let mut connection = pool.get().await?; + + let value = NewUser { + first_name: input.first_name, + last_name: input.last_name, + email: input.email, + }; + + let result = diesel::insert_into(users) + .values(&value) + .get_result::(&mut connection) + .await + .map_err(|e| { + log::error!("Could not create new user: {:#?}", e); + e + })?; + + Ok(result) + } +} diff --git a/examples/web_app_graphql/src/graphql_schema/users/mutation/mod.rs b/examples/web_app_graphql/src/graphql_schema/users/mutation/mod.rs new file mode 100644 index 00000000..d1910348 --- /dev/null +++ b/examples/web_app_graphql/src/graphql_schema/users/mutation/mod.rs @@ -0,0 +1,8 @@ +pub mod add_user; + +use add_user::AddUser; +use async_graphql::MergedObject; + +//Combines user queries into one struct +#[derive(Default, MergedObject)] +pub struct UsersMut(pub AddUser); diff --git a/examples/web_app_graphql/src/graphql_schema/users/query/get_users.rs b/examples/web_app_graphql/src/graphql_schema/users/query/get_users.rs new file mode 100644 index 00000000..fe685ef0 --- /dev/null +++ b/examples/web_app_graphql/src/graphql_schema/users/query/get_users.rs @@ -0,0 +1,26 @@ +use async_graphql::{Context, Object, Result}; +use diesel_async::RunQueryDsl; + +use crate::{models::User, GraphQlData}; + +#[derive(Default)] +pub struct GetUsers; + +#[Object] +impl GetUsers { + //Resolver for querying the database for user records + pub async fn get_users(&self, ctx: &Context<'_>) -> Result> { + use crate::schema::users::dsl::users; + + let GraphQlData { pool, .. } = ctx.data().map_err(|e| { + log::error!("Failed to get app data: {:?}", e); + e + })?; + + let mut connection = pool.get().await?; + + let list_users = users.load::(&mut connection).await?; + + Ok(list_users) + } +} diff --git a/examples/web_app_graphql/src/graphql_schema/users/query/mod.rs b/examples/web_app_graphql/src/graphql_schema/users/query/mod.rs new file mode 100644 index 00000000..f5ed29e0 --- /dev/null +++ b/examples/web_app_graphql/src/graphql_schema/users/query/mod.rs @@ -0,0 +1,10 @@ +pub mod get_users; +pub mod search; + +use async_graphql::MergedObject; +use get_users::GetUsers; +use search::SearchUsers; + +//Combines user queries into one struct +#[derive(Default, MergedObject)] +pub struct UsersQuery(pub GetUsers, pub SearchUsers); diff --git a/examples/web_app_graphql/src/graphql_schema/users/query/search.rs b/examples/web_app_graphql/src/graphql_schema/users/query/search.rs new file mode 100644 index 00000000..51751578 --- /dev/null +++ b/examples/web_app_graphql/src/graphql_schema/users/query/search.rs @@ -0,0 +1,62 @@ +use async_graphql::{Context, Object, Result}; +use diesel_async::RunQueryDsl; +use meilisearch_sdk::search::{SearchQuery, SearchResults}; + +use crate::{models::User, GraphQlData}; + +#[derive(Default)] +pub struct SearchUsers; + +#[Object] +impl SearchUsers { + async fn search(&self, ctx: &Context<'_>, query_string: String) -> Result> { + use crate::schema::users::dsl::users; + + let GraphQlData { pool, client } = ctx.data().map_err(|e| { + log::error!("Failed to get app data: {:?}", e); + e + })?; + + let mut connection = pool.get().await?; + + let list_users = users.load::(&mut connection).await?; + + match client.get_index("users").await { + //If getting the index is successful, we add documents to it + Ok(index) => { + index.add_documents(&list_users, Some("id")).await?; + } + + //If getting the index fails, we create it and then add documents to the new index + Err(_) => { + let task = client.create_index("users", Some("id")).await?; + let task = task.wait_for_completion(client, None, None).await?; + let index = task.try_make_index(client).unwrap(); + + index.add_documents(&list_users, Some("id")).await?; + } + } + + let index = client.get_index("users").await?; + + //We build the query + let query = SearchQuery::new(&index).with_query(&query_string).build(); + + let results: SearchResults = index.execute_query(&query).await?; + + //Tranform the results into a type that implements OutputType + //Required for return types to implement this trait + let search_results: Vec = results + .hits + .into_iter() + .map(|hit| User { + id: hit.result.id, + email: hit.result.email, + first_name: hit.result.first_name, + last_name: hit.result.last_name, + }) + .collect(); + + Ok(search_results) + } +} diff --git a/examples/web_app_graphql/src/lib.rs b/examples/web_app_graphql/src/lib.rs new file mode 100644 index 00000000..bda57d8c --- /dev/null +++ b/examples/web_app_graphql/src/lib.rs @@ -0,0 +1,70 @@ +pub mod app_env_vars; +pub mod errors; +mod graphql_schema; +mod models; +mod schema; + +use actix_web::{web, HttpResponse, Result}; +use app_env_vars::AppEnvVars; +use async_graphql::{ + http::GraphiQLSource, EmptySubscription, Error, Result as GraphqlResult, Schema, +}; +use async_graphql_actix_web::{GraphQLRequest, GraphQLResponse}; +use diesel_async::{ + pooled_connection::{deadpool::Pool, AsyncDieselConnectionManager}, + AsyncPgConnection, +}; +use errors::ApplicationError; +use graphql_schema::{Mutation, Query}; +use meilisearch_sdk::Client as SearchClient; +use validator::Validate; + +pub type ApplicationSchema = Schema; + +/// Represents application data passed to graphql resolvers +pub struct GraphQlData { + pub pool: Pool, + pub client: SearchClient, +} + +pub async fn index_graphiql() -> Result { + Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(GraphiQLSource::build().endpoint("/").finish())) +} + +pub async fn index(schema: web::Data, req: GraphQLRequest) -> GraphQLResponse { + let req_inner = req.into_inner(); + + schema.execute(req_inner).await.into() +} + +/// We build the graphql schema and any data required to be passed to all resolvers +pub fn build_schema(app_env_vars: &AppEnvVars) -> Result { + let client = SearchClient::new( + &app_env_vars.meilisearch_host, + Some(&app_env_vars.meilisearch_api_key), + ); + + let config = AsyncDieselConnectionManager::::new(&app_env_vars.database_url); + let pool = Pool::builder(config).build()?; + + let schema_data = GraphQlData { pool, client }; + + Ok( + Schema::build(Query::default(), Mutation::default(), EmptySubscription) + .data(schema_data) + .finish(), + ) +} + +/// Helper function for returning an error if inputs do not match the set conditions +pub fn validate_input(input: &T) -> GraphqlResult<()> { + if let Err(e) = input.validate() { + log::error!("Validation error: {}", e); + let err = serde_json::to_string(&e).unwrap(); + let err = Error::from(err); + return Err(err); + } + Ok(()) +} diff --git a/examples/web_app_graphql/src/main.rs b/examples/web_app_graphql/src/main.rs new file mode 100644 index 00000000..f7d218f1 --- /dev/null +++ b/examples/web_app_graphql/src/main.rs @@ -0,0 +1,57 @@ +use actix_cors::Cors; +use actix_web::middleware::Logger; +use actix_web::web; +use actix_web::{guard, App, HttpServer}; +use diesel::migration::MigrationSource; +use diesel::{Connection, PgConnection}; +use diesel_migrations::FileBasedMigrations; +use meilisearch_ex::{ + app_env_vars::AppEnvVars, build_schema, errors::ApplicationError, index, index_graphiql, +}; + +#[actix_web::main] +async fn main() -> Result<(), ApplicationError> { + let _ = dotenvy::dotenv(); + + let app_env_vars = envy::from_env::()?; + + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + //Run migrations on app start + let mut db_connection = PgConnection::establish(&app_env_vars.database_url)?; + let mut migrations = FileBasedMigrations::from_path(&app_env_vars.migrations_dir_path)? + .migrations() + .unwrap(); + + migrations.sort_by_key(|m| m.name().to_string()); + + for migration in migrations { + migration.run(&mut db_connection).unwrap(); + } + + let schema = build_schema(&app_env_vars)?; + + println!("GraphiQL IDE: http://localhost:8081"); + + HttpServer::new(move || { + App::new() + .wrap(Logger::default()) + .wrap( + Cors::default() + .allow_any_origin() + .allow_any_method() + .allow_any_header() + .max_age(3600) + .supports_credentials(), + ) + //Add schema to application `Data` extractor + .app_data(web::Data::new(schema.clone())) + .service(web::resource("/").guard(guard::Post()).to(index)) + .service(web::resource("/").guard(guard::Get()).to(index_graphiql)) + }) + .bind("0.0.0.0:8081")? + .run() + .await?; + + Ok(()) +} diff --git a/examples/web_app_graphql/src/models.rs b/examples/web_app_graphql/src/models.rs new file mode 100644 index 00000000..c3e4a06d --- /dev/null +++ b/examples/web_app_graphql/src/models.rs @@ -0,0 +1,23 @@ +use async_graphql::SimpleObject; +use diesel::{prelude::Insertable, Queryable, Selectable}; +use serde::{Deserialize, Serialize}; + +use crate::schema::users; + +//Struct that corresponds to our database structure for users table +#[derive(SimpleObject, Deserialize, Serialize, Queryable, Selectable, Debug)] +#[diesel(table_name = users)] +pub struct User { + pub id: i32, + pub first_name: String, + pub last_name: String, + pub email: String, +} + +#[derive(Insertable, Debug)] +#[diesel(table_name = users)] +pub struct NewUser { + pub first_name: String, + pub last_name: String, + pub email: String, +} diff --git a/examples/web_app_graphql/src/schema.rs b/examples/web_app_graphql/src/schema.rs new file mode 100644 index 00000000..8955b674 --- /dev/null +++ b/examples/web_app_graphql/src/schema.rs @@ -0,0 +1,10 @@ +// @generated automatically by Diesel CLI. + +diesel::table! { + users (id) { + id -> Int4, + first_name -> Varchar, + last_name -> Varchar, + email -> Varchar, + } +}