From 625a4407f0429b1c3f0da1636a173a5308029d64 Mon Sep 17 00:00:00 2001 From: st170001 Date: Fri, 26 Jan 2024 22:04:00 +0100 Subject: [PATCH 01/11] Start of initial setup --- .dapr/components/pubsub.yaml | 8 + .dapr/dapr-config-minimal.yaml | 5 + .devcontainer/devcontainer.json | 43 ++++ .devcontainer/docker-compose.yml | 25 +++ .dockerignore | 1 + .github/workflows/publish-image.yaml | 50 +++++ .github/workflows/test-update-schema.yaml | 24 ++ .../update-infrastructure-docker.yaml | 29 +++ .github/workflows/update-schema.yaml | 32 +++ Cargo.toml | 26 +++ README.md | 33 +++ base-dockerfile | 29 +++ compose.yaml | 36 +++ dev-dockerfile | 8 + devcontainer-dockerfile | 5 + docker-compose-base.yaml | 49 ++++ docker-compose-dev.yaml | 36 +++ src/app_callback_service.rs | 144 ++++++++++++ src/base_connection.rs | 37 +++ src/main.rs | 145 ++++++++++++ src/mutation.rs | 210 ++++++++++++++++++ src/mutation_input_structs.rs | 29 +++ src/order_datatypes.rs | 118 ++++++++++ src/product_variant.rs | 43 ++++ src/query.rs | 118 ++++++++++ src/review.rs | 37 +++ src/review_connection.rs | 28 +++ src/user.rs | 60 +++++ 28 files changed, 1408 insertions(+) create mode 100644 .dapr/components/pubsub.yaml create mode 100644 .dapr/dapr-config-minimal.yaml create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml create mode 100644 .dockerignore create mode 100644 .github/workflows/publish-image.yaml create mode 100644 .github/workflows/test-update-schema.yaml create mode 100644 .github/workflows/update-infrastructure-docker.yaml create mode 100644 .github/workflows/update-schema.yaml create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 base-dockerfile create mode 100644 compose.yaml create mode 100644 dev-dockerfile create mode 100644 devcontainer-dockerfile create mode 100644 docker-compose-base.yaml create mode 100644 docker-compose-dev.yaml create mode 100644 src/app_callback_service.rs create mode 100644 src/base_connection.rs create mode 100644 src/main.rs create mode 100644 src/mutation.rs create mode 100644 src/mutation_input_structs.rs create mode 100644 src/order_datatypes.rs create mode 100644 src/product_variant.rs create mode 100644 src/query.rs create mode 100644 src/review.rs create mode 100644 src/review_connection.rs create mode 100644 src/user.rs diff --git a/.dapr/components/pubsub.yaml b/.dapr/components/pubsub.yaml new file mode 100644 index 0000000..f7c308b --- /dev/null +++ b/.dapr/components/pubsub.yaml @@ -0,0 +1,8 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: pubsub +spec: + type: pubsub.in-memory + version: v1 + metadata: [] \ No newline at end of file diff --git a/.dapr/dapr-config-minimal.yaml b/.dapr/dapr-config-minimal.yaml new file mode 100644 index 0000000..e097002 --- /dev/null +++ b/.dapr/dapr-config-minimal.yaml @@ -0,0 +1,5 @@ +apiVersion: dapr.io/v1alpha1 +kind: Configuration +metadata: + name: daprConfig + namespace: default \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..bc2eb01 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,43 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose +{ + "name": "Existing Docker Compose (Extend)", + + // Update the 'dockerComposeFile' list if you have more compose files or use different names. + // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make. + "dockerComposeFile": ["../compose.yaml", "docker-compose.yml"], + + // The 'service' property is the name of the service for the container that VS Code should + // use. Update this value and .devcontainer/docker-compose.yml to the real service name. + "service": "review", + + // The optional 'workspaceFolder' property is the path VS Code should open by default when + // connected. This is typically a file mount in .devcontainer/docker-compose.yml + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "customizations": { + "vscode": { + "extensions": ["rust-lang.rust-analyzer"] + } + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Uncomment the next line if you want start specific services in your Docker Compose config. + // "runServices": [], + + // Uncomment the next line if you want to keep your containers running after VS Code shuts down. + // "shutdownAction": "none", + + // Uncomment the next line to run commands after the container is created. + // "postCreateCommand": "cat /etc/os-release", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "devcontainer" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..9d03990 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,25 @@ +version: "3.8" +services: + # Update this to the name of the service you want to work with in your docker-compose.yml file + review: + # Uncomment if you want to override the service's Dockerfile to one in the .devcontainer + # folder. Note that the path of the Dockerfile and context is relative to the *primary* + # docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile" + # array). The sample below assumes your primary file is in the root of your project. + # + # build: + # context: . + # dockerfile: .devcontainer/Dockerfile + + volumes: + # Update this to wherever you want VS Code to mount the folder of your project + - ..:/workspaces:cached + + # Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust. + # cap_add: + # - SYS_PTRACE + # security_opt: + # - seccomp:unconfined + + # Overrides default command so things don't shut down after the process ends. + command: /bin/sh -c "while sleep 1000; do :; done" diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1de5659 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +target \ No newline at end of file diff --git a/.github/workflows/publish-image.yaml b/.github/workflows/publish-image.yaml new file mode 100644 index 0000000..69e4f01 --- /dev/null +++ b/.github/workflows/publish-image.yaml @@ -0,0 +1,50 @@ +name: Create and publish a Docker image on Release + +on: + push: + branches: + - "main" + tags: + - "v*" + pull_request: + branches: + - "main" + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to the Container registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: base-dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/test-update-schema.yaml b/.github/workflows/test-update-schema.yaml new file mode 100644 index 0000000..e7840c5 --- /dev/null +++ b/.github/workflows/test-update-schema.yaml @@ -0,0 +1,24 @@ +name: Test update GraphQL schema on pull request + +on: + pull_request: + +jobs: + schema: + name: Test update GraphQL schema on pull request + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install latest stable + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + - uses: actions/checkout@v4 + with: + repository: "misarch/schemas" + path: "schemas" + - name: Save graphql schemas + run: | + sudo apt install -y protobuf-compiler + cargo run -- --generate-schema diff --git a/.github/workflows/update-infrastructure-docker.yaml b/.github/workflows/update-infrastructure-docker.yaml new file mode 100644 index 0000000..ef8274a --- /dev/null +++ b/.github/workflows/update-infrastructure-docker.yaml @@ -0,0 +1,29 @@ +name: Update infrastructure-docker submodule + +on: + push: + branches: + - main + +jobs: + schema: + name: Update infrastructure-docker submodule + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + repository: "misarch/infrastructure-docker" + submodules: true + - name: Update submodule + run: | + cd review + git checkout ${{ github.sha }} + - uses: peter-evans/create-pull-request@v5 + with: + commit-message: Update review schema + branch: update/review + token: ${{ secrets.INFRASTRUCTURE_DOCKER_PUSH_SECRET }} + - name: Set to auto merge + run: gh pr merge update/review --auto --merge -R misarch/infrastructure-docker + env: + GH_TOKEN: ${{ secrets.INFRASTRUCTURE_DOCKER_PUSH_SECRET }} diff --git a/.github/workflows/update-schema.yaml b/.github/workflows/update-schema.yaml new file mode 100644 index 0000000..b8e0209 --- /dev/null +++ b/.github/workflows/update-schema.yaml @@ -0,0 +1,32 @@ +name: Update GraphQL schema + +on: + push: + branches: + - main + +jobs: + schema: + name: Update GraphQL schema + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install latest stable + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + - uses: actions/checkout@v4 + with: + repository: "misarch/schemas" + path: "schemas" + - name: Save graphql schemas + run: | + sudo apt install -y protobuf-compiler + cargo run -- --generate-schema + - uses: peter-evans/create-pull-request@v5 + with: + path: ./schemas + commit-message: Update review schema + branch: update/review + token: ${{ secrets.SCHEMAS_PUSH_SECRET }} diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f233148 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "misarch-review" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +async-graphql = { version = "6.0.11", features = ["bson", "chrono", "uuid", "log"] } +async-graphql-axum = "6.0.11" +tokio = { version = "1.8", features = ["macros", "rt-multi-thread"] } +hyper = "1.0.1" +axum = { version = "0.6.0", features = ["headers"] } +slab = "0.4.2" +mongodb = "2.8.0" +serde = "1.0.193" +futures = "0.3.30" +bson = "2.8.1" +clap = { version = "4.4.13", features = ["derive"] } +uuid = { version = "1.6.1", features = ["v4", "serde"] } +mongodb-cursor-pagination = "0.3.2" +dapr = "0.13.0" +tonic = "0.8" +json = "0.12.4" +log = "0.4.20" +simple_logger = "4.3.3" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2ab58e --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Review service for MiSArch + +### Quickstart (DevContainer) + +1. Open VSCode Development Container +2. `cargo run` starts the GraphQL service + GraphiQL on port `8080` + +### Quickstart (Docker Compose) + +1. `docker compose -f docker-compose-dev.yaml up --build` in the repository root directory. **IMPORTANT:** MongoDB credentials should be configured for production. + +### What it can do + +- CRUD reviews: + + ```rust + pub struct Review { + pub id: Uuid, + pub user_id: Uuid, + pub product_variants: HashSet, + pub name: String, + pub created_at: DateTime, + pub last_updated_at: DateTime, + } + + /// Foreign ProductVariant + pub struct ProductVariant{ + id: Uuid + } + ``` + +- Validates all UUIDs input as strings +- Error prop to GraphQL diff --git a/base-dockerfile b/base-dockerfile new file mode 100644 index 0000000..cb7554b --- /dev/null +++ b/base-dockerfile @@ -0,0 +1,29 @@ +# Source: https://github.com/LukeMathWalker/cargo-chef + +FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef +WORKDIR /misarch-review + +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder +COPY --from=planner /misarch-review/recipe.json recipe.json + +RUN apt update && apt install -y protobuf-compiler && rm -rf /var/lib/apt/lists/* + +# Build dependencies - this is the caching Docker layer! +RUN cargo chef cook --release --recipe-path recipe.json +# Build application +COPY . . + +RUN cargo build --release --bin misarch-review + +# We do not need the Rust toolchain to run the binary! +FROM debian:bookworm-slim AS runtime + +RUN apt update && apt install -y protobuf-compiler && rm -rf /var/lib/apt/lists/* + +WORKDIR /misarch-review +COPY --from=builder /misarch-review/target/release/misarch-review /usr/local/bin +ENTRYPOINT ["/usr/local/bin/misarch-review"] \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..2c82959 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,36 @@ +services: + review: + extends: + file: docker-compose-base.yaml + service: review + build: + context: . + dockerfile: devcontainer-dockerfile + ports: + - 8080:8080 + review-db: + extends: + file: docker-compose-base.yaml + service: review-db + review-mongoexpress: + image: mongo-express + ports: + - 8081:8081 + depends_on: + - review-db + environment: + ME_CONFIG_MONGODB_URL: mongodb://review-db:27017 + review-dapr: + extends: + file: docker-compose-base.yaml + service: review-dapr + volumes: + - "./.dapr/dapr-config-minimal.yaml:/config.yaml" + - "./.dapr/components:/components" + placement: + image: "daprio/dapr" + command: ["./placement", "-port", "50006"] + ports: + - 50006:50006 +volumes: + review-db-data: diff --git a/dev-dockerfile b/dev-dockerfile new file mode 100644 index 0000000..8358685 --- /dev/null +++ b/dev-dockerfile @@ -0,0 +1,8 @@ +FROM rust:1.75-slim-bookworm + +RUN apt update && apt install -y protobuf-compiler && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/misarch-review + +COPY . . +CMD ["cargo", "run"] diff --git a/devcontainer-dockerfile b/devcontainer-dockerfile new file mode 100644 index 0000000..a386e1e --- /dev/null +++ b/devcontainer-dockerfile @@ -0,0 +1,5 @@ +FROM rust:1.75-slim-bookworm + +RUN apt update && apt install -y protobuf-compiler && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/misarch-review \ No newline at end of file diff --git a/docker-compose-base.yaml b/docker-compose-base.yaml new file mode 100644 index 0000000..9c661ed --- /dev/null +++ b/docker-compose-base.yaml @@ -0,0 +1,49 @@ +services: + review: + restart: unless-stopped + build: + context: . + dockerfile: base-dockerfile + healthcheck: + test: wget http://localhost:8080/graphiql || exit 1 + interval: 1s + timeout: 10s + retries: 20 + start_period: 3s + depends_on: + review-db: + condition: service_healthy + environment: + MONGODB_URI: mongodb://review-db:27017 + review-db: + image: mongo + volumes: + - review-db-data:/data/db + healthcheck: + test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet + interval: 10s + timeout: 5s + retries: 3 + review-dapr: + image: "daprio/daprd:edge" + command: + [ + "./daprd", + "--app-id", + "review", + "--app-port", + "50051", + "--app-protocol", + "grpc", + "--dapr-http-port", + "3500", + "-placement-host-address", + "placement:50006", + "--config", + "/config.yaml", + "--resources-path", + "/components", + ] + network_mode: "service:review" +volumes: + review-db-data: diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml new file mode 100644 index 0000000..0eecf0b --- /dev/null +++ b/docker-compose-dev.yaml @@ -0,0 +1,36 @@ +services: + review: + extends: + file: docker-compose-base.yaml + service: review + build: + context: . + dockerfile: dev-dockerfile + ports: + - 8080:8080 + review-db: + extends: + file: docker-compose-base.yaml + service: review-db + review-mongoexpress: + image: mongo-express + ports: + - 8081:8081 + depends_on: + - review-db + environment: + ME_CONFIG_MONGODB_URL: mongodb://review-db:27017 + review-dapr: + extends: + file: docker-compose-base.yaml + service: review-dapr + volumes: + - "./.dapr/dapr-config-minimal.yaml:/config.yaml" + - "./.dapr/components:/components" + placement: + image: "daprio/dapr" + command: ["./placement", "-port", "50006"] + ports: + - 50006:50006 +volumes: + review-db-data: diff --git a/src/app_callback_service.rs b/src/app_callback_service.rs new file mode 100644 index 0000000..c11fb45 --- /dev/null +++ b/src/app_callback_service.rs @@ -0,0 +1,144 @@ +use json::JsonValue; +use log::info; +use mongodb::Collection; +use tonic::{Request, Response, Status}; + +use bson::Uuid; +use dapr::{appcallback::*, dapr::dapr::proto::runtime::v1::app_callback_server::AppCallback}; + +use crate::{product_variant::ProductVariant, user::User}; + +pub struct AppCallbackService { + pub product_variant_collection: Collection, + pub user_collection: Collection, +} + +impl AppCallbackService { + /// Add a newly created product variant to MongoDB. + pub async fn add_product_variant_to_mongodb(&self, id: Uuid) -> Result<(), Status> { + let product_variant = ProductVariant { _id: id }; + match self + .product_variant_collection + .insert_one(product_variant, None) + .await + { + Ok(_) => Ok(()), + Err(_) => Err(Status::internal( + "Adding product variant failed in MongoDB.", + )), + } + } + + /// Add a newly created user to MongoDB. + pub async fn add_user_to_mongodb(&self, id: Uuid) -> Result<(), Status> { + let user = User { _id: id }; + match self.user_collection.insert_one(user, None).await { + Ok(_) => Ok(()), + Err(_) => Err(Status::internal("Adding user failed in MongoDB.")), + } + } +} + +#[tonic::async_trait] +impl AppCallback for AppCallbackService { + /// Invokes service method with InvokeRequest. + async fn on_invoke( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(InvokeResponse::default())) + } + + /// Lists all topics subscribed by this app. + /// + /// NOTE: Dapr runtime will call this method to get + /// the list of topics the app wants to subscribe to. + /// In this example, the app is subscribing to topic `A`. + async fn list_topic_subscriptions( + &self, + _request: Request<()>, + ) -> Result, Status> { + let product_variant_topic = "catalog/product-variant/created".to_string(); + let user_topic = "user/user/created".to_string(); + let pubsub_name = "pubsub".to_string(); + let user_topic_subscription = TopicSubscription::new(pubsub_name.clone(), user_topic, None); + + let mut list_subscriptions = + ListTopicSubscriptionsResponse::topic(pubsub_name, product_variant_topic); + list_subscriptions + .subscriptions + .push(user_topic_subscription); + + Ok(Response::new(list_subscriptions)) + } + + /// Subscribes events from Pubsub. + async fn on_topic_event( + &self, + request: Request, + ) -> Result, Status> { + let r: dapr::dapr::dapr::proto::runtime::v1::TopicEventRequest = request.into_inner(); + let data = &r.data; + + let message = String::from_utf8_lossy(data); + let error_message = format!("Expected message to be parsable JSON, got: {}", message); + let message_json = json::parse(&message).map_err(|_| Status::internal(error_message))?; + let id_json_value = &message_json["id"]; + let id = parse_id(id_json_value)?; + + info!("Event with message was received: {}", &message); + + match r.topic.as_str() { + "catalog/product-variant/created" => self.add_product_variant_to_mongodb(id).await?, + "user/user/created" => self.add_user_to_mongodb(id).await?, + _ => { + let message = format!( + "Event of topic: `{}` is not a handable by this service.", + r.topic.as_str() + ); + Err(Status::internal(message))?; + } + } + + Ok(Response::new(TopicEventResponse::default())) + } + + /// Lists all input bindings subscribed by this app. + async fn list_input_bindings( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(ListInputBindingsResponse::default())) + } + + /// Listens events from the input bindings. + async fn on_binding_event( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(BindingEventResponse::default())) + } +} + +/// Parses Uuid from JsonValue containing a String. +fn parse_id(id_json_value: &JsonValue) -> Result { + match id_json_value { + json::JsonValue::String(id_string) => match Uuid::parse_str(id_string) { + Ok(id_uuid) => Ok(id_uuid), + Err(_) => { + let error_message = format!( + "String value in `id` field cannot be parsed as bson::Uuid, got: {}", + id_string + ); + Err(Status::internal(error_message))? + } + }, + _ => { + let error_message = format!( + "`id` field does not exist or does not contain a String value, got: {}", + id_json_value + ); + Err(Status::internal(error_message))? + } + } +} diff --git a/src/base_connection.rs b/src/base_connection.rs new file mode 100644 index 0000000..4498d4d --- /dev/null +++ b/src/base_connection.rs @@ -0,0 +1,37 @@ +use async_graphql::{OutputType, SimpleObject}; + +/// A base connection for an OutputType. +#[derive(SimpleObject)] +#[graphql(shareable)] +pub struct BaseConnection { + /// The resulting entities. + pub nodes: Vec, + /// Whether this connection has a next page. + pub has_next_page: bool, + /// The total amount of items in this connection. + pub total_count: u64, +} + +use mongodb_cursor_pagination::FindResult; + +pub struct FindResultWrapper(pub FindResult); + +/// Object that writes total count of items in a query, regardless of pagination. +#[derive(SimpleObject)] +pub struct AdditionalFields { + total_count: u64, +} + +/// Implementation of conversion from MongoDB pagination to GraphQL Connection. +impl From> for BaseConnection +where + Node: OutputType, +{ + fn from(value: FindResultWrapper) -> Self { + BaseConnection { + nodes: value.0.items, + has_next_page: value.0.page_info.has_next_page, + total_count: value.0.total_count, + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4cb9442 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,145 @@ +use std::{env, fs::File, io::Write}; + +use async_graphql::{ + extensions::Logger, http::GraphiQLSource, EmptySubscription, SDLExportOptions, Schema, +}; +use async_graphql_axum::GraphQL; +use axum::{ + response::{self, IntoResponse}, + routing::get, + Router, Server, +}; +use clap::{arg, command, Parser}; +use simple_logger::SimpleLogger; + +use log::info; +use mongodb::{options::ClientOptions, Client, Database}; + +use dapr::dapr::dapr::proto::runtime::v1::app_callback_server::AppCallbackServer; +use tonic::transport::Server as TonicServer; + +use review::Review; + +mod review; + +mod query; +use query::Query; + +mod mutation; +use mutation::Mutation; + +mod app_callback_service; +use app_callback_service::AppCallbackService; + +mod user; +use user::User; + +use product_variant::ProductVariant; +mod product_variant; + +mod base_connection; +mod mutation_input_structs; +mod order_datatypes; +mod review_connection; + +/// Builds the GraphiQL frontend. +async fn graphiql() -> impl IntoResponse { + response::Html(GraphiQLSource::build().endpoint("/").finish()) +} + +/// Establishes database connection and returns the client. +async fn db_connection() -> Client { + let uri = match env::var_os("MONGODB_URI") { + Some(uri) => uri.into_string().unwrap(), + None => panic!("$MONGODB_URI is not set."), + }; + + // Parse a connection string into an options struct. + let mut client_options = ClientOptions::parse(uri).await.unwrap(); + + // Manually set an option. + client_options.app_name = Some("Review".to_string()); + + // Get a handle to the deployment. + Client::with_options(client_options).unwrap() +} + +/// Establishes connection to Dapr. +/// +/// Adds AppCallbackService which defines pub/sub interaction with Dapr. +async fn dapr_connection(db_client: Database) { + let addr = "[::]:50051".parse().unwrap(); + let product_variant_collection: mongodb::Collection = + db_client.collection::("product_variants"); + let user_collection: mongodb::Collection = db_client.collection::("users"); + + let callback_service = AppCallbackService { + product_variant_collection, + user_collection, + }; + + info!("AppCallback server listening on: {}", addr); + // Create a gRPC server with the callback_service. + TonicServer::builder() + .add_service(AppCallbackServer::new(callback_service)) + .serve(addr) + .await + .unwrap(); +} + +/// Command line argument to toggle schema generation instead of service execution. +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Generates GraphQL schema in `./schemas/review.graphql`. + #[arg(long)] + generate_schema: bool, +} + +/// Activates logger and parses argument for optional schema generation. Otherwise starts gRPC and GraphQL server. +#[tokio::main] +async fn main() -> std::io::Result<()> { + SimpleLogger::new().init().unwrap(); + + let args = Args::parse(); + if args.generate_schema { + let schema = Schema::build(Query, Mutation, EmptySubscription).finish(); + let mut file = File::create("./schemas/review.graphql")?; + let sdl_export_options = SDLExportOptions::new().federation(); + let schema_sdl = schema.sdl_with_options(sdl_export_options); + file.write_all(schema_sdl.as_bytes())?; + info!("GraphQL schema: ./schemas/review.graphql was successfully generated!"); + } else { + start_service().await; + } + Ok(()) +} + +/// Starts review service on port 8000. +async fn start_service() { + let client = db_connection().await; + let db_client: Database = client.database("review-database"); + + let schema = Schema::build(Query, Mutation, EmptySubscription) + .extension(Logger) + .data(db_client.clone()) + .enable_federation() + .finish(); + + let app = Router::new().route("/", get(graphiql).post_service(GraphQL::new(schema))); + + let t1 = tokio::spawn(async { + info!("GraphiQL IDE: http://0.0.0.0:8080"); + Server::bind(&"0.0.0.0:8080".parse().unwrap()) + .serve(app.into_make_service()) + .await + .unwrap(); + }); + + let t2 = tokio::spawn(async { + dapr_connection(db_client).await; + }); + + t1.await.unwrap(); + t2.await.unwrap(); +} diff --git a/src/mutation.rs b/src/mutation.rs new file mode 100644 index 0000000..a7595fa --- /dev/null +++ b/src/mutation.rs @@ -0,0 +1,210 @@ +use std::collections::HashSet; + +use async_graphql::{Context, Error, Object, Result}; +use bson::Bson; +use bson::Uuid; +use futures::TryStreamExt; +use mongodb::{ + bson::{doc, DateTime}, + Collection, Database, +}; + +use crate::product_variant::ProductVariant; +use crate::query::query_user; +use crate::user::User; +use crate::{ + mutation_input_structs::{AddReviewInput, UpdateReviewInput}, + query::query_review, + review::Review, +}; + +/// Describes GraphQL review mutations. +pub struct Mutation; + +#[Object] +impl Mutation { + /// Adds a review. + async fn add_review<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "AddReviewInput")] input: AddReviewInput, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("reviews"); + validate_input(db_client, &input).await?; + let current_timestamp = DateTime::now(); + let review = Review { + _id: Uuid::new(), + user: User { _id: input.user_id }, + product_variant: ProductVariant { _id: input.product_variant_id }, + body: input.body, + rating: input.rating, + created_at: current_timestamp, + is_visible: input.is_visible.unwrap_or(true), + }; + match collection.insert_one(review, None).await { + Ok(result) => { + let id = uuid_from_bson(result.inserted_id)?; + query_review(&collection, id).await + } + Err(_) => Err(Error::new("Adding review failed in MongoDB.")), + } + } + + /// Updates a specific review referenced with an id. + async fn update_review<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "UpdateReviewInput")] input: UpdateReviewInput, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("reviews"); + let product_variant_collection: Collection = + db_client.collection::("product_variants"); + let current_timestamp = DateTime::now(); + update_body( + &collection, + &product_variant_collection, + &input, + ¤t_timestamp, + ) + .await?; + update_rating(&collection, &input, ¤t_timestamp).await?; + let review = query_review(&collection, input.id).await?; + Ok(review) + } + + /// Deletes review of id. + async fn delete_review<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "UUID of review to delete.")] id: Uuid, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("reviews"); + if let Err(_) = collection.delete_one(doc! {"_id": id }, None).await { + let message = format!("Deleting review of id: `{}` failed in MongoDB.", id); + return Err(Error::new(message)); + } + Ok(true) + } +} + +/// Extracts UUID from Bson. +/// +/// Adding a review returns a UUID in a Bson document. This function helps to extract the UUID. +fn uuid_from_bson(bson: Bson) -> Result { + match bson { + Bson::Binary(id) => Ok(id.to_uuid()?), + _ => { + let message = format!( + "Returned id: `{}` needs to be a Binary in order to be parsed as a Uuid", + bson + ); + Err(Error::new(message)) + } + } +} + +/// Updates product variant ids of a review. +/// +/// * `collection` - MongoDB collection to update. +/// * `input` - `UpdateReviewInput`. +async fn update_product_variant_ids( + collection: &Collection, + product_variant_collection: &Collection, + input: &UpdateReviewInput, + current_timestamp: &DateTime, +) -> Result<()> { + if let Some(definitely_product_variant_ids) = &input.product_variant_ids { + validate_product_variant_ids(&product_variant_collection, definitely_product_variant_ids) + .await?; + let normalized_product_variants: Vec = definitely_product_variant_ids + .iter() + .map(|id| ProductVariant { _id: id.clone() }) + .collect(); + if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"internal_product_variants": normalized_product_variants, "last_updated_at": current_timestamp}}, None).await { + let message = format!("Updating product_variant_ids of review of id: `{}` failed in MongoDB.", input.id); + return Err(Error::new(message)) + } + } + Ok(()) +} + +/// Updates name of a review. +/// +/// * `collection` - MongoDB collection to update. +/// * `input` - `UpdateReviewInput`. +async fn update_name( + collection: &Collection, + input: &UpdateReviewInput, + current_timestamp: &DateTime, +) -> Result<()> { + if let Some(definitely_name) = &input.name { + let result = collection + .update_one( + doc! {"_id": input.id }, + doc! {"$set": {"name": definitely_name, "last_updated_at": current_timestamp}}, + None, + ) + .await; + if let Err(_) = result { + let message = format!( + "Updating name of review of id: `{}` failed in MongoDB.", + input.id + ); + return Err(Error::new(message)); + } + } + Ok(()) +} + +/// Checks if product variants and user in AddReviewInput are in the system (MongoDB database populated with events). +async fn validate_input(db_client: &Database, input: &AddReviewInput) -> Result<()> { + let product_variant_collection: Collection = + db_client.collection::("product_variants"); + let user_collection: Collection = db_client.collection::("users"); + validate_product_variant_ids(&product_variant_collection, &input.product_variant_ids).await?; + validate_user(&user_collection, input.user_id).await?; + Ok(()) +} + +/// Checks if product variants are in the system (MongoDB database populated with events). +/// +/// Used before adding or modifying product variants / reviews. +async fn validate_product_variant_ids( + collection: &Collection, + product_variant_ids: &HashSet, +) -> Result<()> { + let product_variant_ids_vec: Vec = product_variant_ids.clone().into_iter().collect(); + match collection + .find(doc! {"_id": { "$in": &product_variant_ids_vec } }, None) + .await + { + Ok(cursor) => { + let product_variants: Vec = cursor.try_collect().await?; + product_variant_ids_vec.iter().fold(Ok(()), |_, p| { + match product_variants.contains(&ProductVariant { _id: *p }) { + true => Ok(()), + false => { + let message = format!( + "Product variant with the UUID: `{}` is not present in the system.", + p + ); + Err(Error::new(message)) + } + } + }) + } + Err(_) => Err(Error::new( + "Product variants with the specified UUIDs are not present in the system.", + )), + } +} + +/// Checks if user is in the system (MongoDB database populated with events). +/// +/// Used before adding reviews. +async fn validate_user(collection: &Collection, id: Uuid) -> Result<()> { + query_user(&collection, id).await.map(|_| ()) +} diff --git a/src/mutation_input_structs.rs b/src/mutation_input_structs.rs new file mode 100644 index 0000000..257457a --- /dev/null +++ b/src/mutation_input_structs.rs @@ -0,0 +1,29 @@ +use async_graphql::{InputObject, SimpleObject}; +use bson::Uuid; +use crate::review::Rating; + +#[derive(SimpleObject, InputObject)] +pub struct AddReviewInput { + /// UUID of user owning the review. + pub user_id: Uuid, + /// UUID of product variant in review. + pub product_variant_id: Uuid, + /// Body of Review. + pub body: String, + /// Rating of Review in 1-5 stars. + pub rating: Rating, + /// Flag if review is visible, by default set to true. + pub is_visible: Option, +} + +#[derive(SimpleObject, InputObject)] +pub struct UpdateReviewInput { + /// UUID of review to update. + pub id: Uuid, + /// Body of Review to update. + pub body: Option, + /// Rating of Review in 1-5 stars to update. + pub rating: Option, + /// Flag if review is visible. + pub is_visible: Option, +} diff --git a/src/order_datatypes.rs b/src/order_datatypes.rs new file mode 100644 index 0000000..87482ad --- /dev/null +++ b/src/order_datatypes.rs @@ -0,0 +1,118 @@ +use async_graphql::{Enum, InputObject, SimpleObject}; + +/// GraphQL order direction. +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +pub enum OrderDirection { + /// Ascending order direction. + Asc, + /// Descending order direction. + Desc, +} + +impl Default for OrderDirection { + fn default() -> Self { + Self::Asc + } +} + +/// Implements conversion to i32 for MongoDB document sorting. +impl From for i32 { + fn from(value: OrderDirection) -> Self { + match value { + OrderDirection::Asc => 1, + OrderDirection::Desc => -1, + } + } +} + +/// Describes the fields that a review can be ordered by. +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +pub enum ReviewOrderField { + /// Orders by "id". + Id, + /// Orders by "user_id". + UserId, + /// Orders by "product_variant". + ProductVariant, + /// Orders by "rating". + Rating, + /// Orders by "created_at". + CreatedAt, +} + +impl ReviewOrderField { + pub fn as_str(&self) -> &'static str { + match self { + ReviewOrderField::Id => "_id", + ReviewOrderField::UserId => "user", + ReviewOrderField::ProductVariant => "product_variant", + ReviewOrderField::Rating => "rating", + ReviewOrderField::CreatedAt => "last_updated_at", + } + } +} + +impl Default for ReviewOrderField { + fn default() -> Self { + Self::Id + } +} + +/// Specifies the order of reviews. +#[derive(SimpleObject, InputObject)] +pub struct ReviewOrderInput { + /// Order direction of reviews. + pub direction: Option, + /// Field that reviews should be ordered by. + pub field: Option, +} + +impl Default for ReviewOrderInput { + fn default() -> Self { + Self { + direction: Some(Default::default()), + field: Some(Default::default()), + } + } +} + +/// Describes the fields that a foreign types can be ordered by. +/// +/// Only the Id valid at the moment. +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +pub enum CommonOrderField { + /// Orders by "id". + Id, +} + +impl CommonOrderField { + pub fn as_str(&self) -> &'static str { + match self { + CommonOrderField::Id => "_id", + } + } +} + +impl Default for CommonOrderField { + fn default() -> Self { + Self::Id + } +} + +/// Specifies the order of foreign types. +#[derive(SimpleObject, InputObject)] +pub struct CommonOrderInput { + /// Order direction of reviews. + pub direction: Option, + /// Field that reviews should be ordered by. + pub field: Option, +} + +impl Default for CommonOrderInput { + fn default() -> Self { + Self { + direction: Some(Default::default()), + field: Some(Default::default()), + } + } +} diff --git a/src/product_variant.rs b/src/product_variant.rs new file mode 100644 index 0000000..e40cf1d --- /dev/null +++ b/src/product_variant.rs @@ -0,0 +1,43 @@ +use async_graphql::{ComplexObject, Context, SimpleObject, Result}; +use bson::{Uuid, doc, Bson}; +use serde::{Deserialize, Serialize}; + +use crate::{order_datatypes::ReviewOrderInput, review_connection::ReviewConnection}; +use std::{cmp::Ordering, hash::Hash}; + +#[derive(Debug, Serialize, Deserialize, Hash, Eq, PartialEq, Copy, Clone, SimpleObject)] +#[graphql(complex)] +pub struct ProductVariant { + /// UUID of the product variant. + pub _id: Uuid, +} + +#[ComplexObject] +impl ProductVariant { + /// Retrieves reviews of product variant. + async fn reviews<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "Describes that the `first` N reviews should be retrieved.")] + first: Option, + #[graphql(desc = "Describes how many reviews should be skipped at the beginning.")] + skip: Option, + #[graphql(desc = "Specifies the order in which reviews are retrieved.")] order_by: Option< + ReviewOrderInput, + >, + ) -> Result { + todo!(); + } +} + +impl PartialOrd for ProductVariant { + fn partial_cmp(&self, other: &Self) -> Option { + self._id.partial_cmp(&other._id) + } +} + +impl From for Bson { + fn from(value: ProductVariant) -> Self { + Bson::Document(doc!("_id": value._id)) + } +} \ No newline at end of file diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..02a8116 --- /dev/null +++ b/src/query.rs @@ -0,0 +1,118 @@ +use crate::{product_variant::ProductVariant, user::User, Review}; +use async_graphql::{Context, Error, Object, Result}; + +use bson::Uuid; +use mongodb::{bson::doc, Collection, Database}; + +/// Describes GraphQL review queries. +pub struct Query; + +#[Object] +impl Query { + /// Entity resolver for user of specific id. + #[graphql(entity)] + async fn user_entity_resolver<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "UUID of user to retrieve.")] id: Uuid, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("users"); + query_user(&collection, id).await + } + + /// Entity resolver for product variant of specific id. + #[graphql(entity)] + async fn product_variant_entity_resolver<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "UUID of product variant to retrieve.")] id: Uuid, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("product_variants"); + query_product_variant(&collection, id).await + } + + /// Retrieves review of specific id. + async fn review<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "UUID of review to retrieve.")] id: Uuid, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("reviews"); + query_review(&collection, id).await + } + + /// Entity resolver for review of specific id. + #[graphql(entity)] + async fn review_entity_resolver<'a>( + &self, + ctx: &Context<'a>, + #[graphql(key, desc = "UUID of review to retrieve.")] id: Uuid, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("reviews"); + query_review(&collection, id).await + } +} + +/// Shared function to query a review from a MongoDB collection of reviews +/// +/// * `connection` - MongoDB database connection. +/// * `id` - UUID of review. +pub async fn query_review(collection: &Collection, id: Uuid) -> Result { + match collection.find_one(doc! {"_id": id }, None).await { + Ok(maybe_review) => match maybe_review { + Some(review) => Ok(review), + None => { + let message = format!("Review with UUID id: `{}` not found.", id); + Err(Error::new(message)) + } + }, + Err(_) => { + let message = format!("Review with UUID id: `{}` not found.", id); + Err(Error::new(message)) + } + } +} + +/// Shared function to query a user from a MongoDB collection of users. +/// +/// * `connection` - MongoDB database connection. +/// * `id` - UUID of user. +pub async fn query_user(collection: &Collection, id: Uuid) -> Result { + match collection.find_one(doc! {"_id": id }, None).await { + Ok(maybe_user) => match maybe_user { + Some(user) => Ok(user), + None => { + let message = format!("User with UUID id: `{}` not found.", id); + Err(Error::new(message)) + } + }, + Err(_) => { + let message = format!("User with UUID id: `{}` not found.", id); + Err(Error::new(message)) + } + } +} + +/// Shared function to query a product variant from a MongoDB collection of product variants. +/// +/// * `connection` - MongoDB database connection. +/// * `id` - UUID of product variant. +pub async fn query_product_variant(collection: &Collection, id: Uuid) -> Result { + match collection.find_one(doc! {"_id": id }, None).await { + Ok(maybe_product_variant) => match maybe_product_variant { + Some(product_variant) => Ok(product_variant), + None => { + let message = format!("ProductVariant with UUID id: `{}` not found.", id); + Err(Error::new(message)) + } + }, + Err(_) => { + let message = format!("ProductVariant with UUID id: `{}` not found.", id); + Err(Error::new(message)) + } + } +} diff --git a/src/review.rs b/src/review.rs new file mode 100644 index 0000000..e4512c7 --- /dev/null +++ b/src/review.rs @@ -0,0 +1,37 @@ +use async_graphql::{Enum, SimpleObject}; +use bson::datetime::DateTime; +use bson::Uuid; +use serde::{Deserialize, Serialize}; + +use crate::{ + product_variant::ProductVariant, user::User +}; + +/// The Review of a user. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, SimpleObject)] +pub struct Review { + /// Review UUID. + pub _id: Uuid, + /// User. + pub user: User, + /// Product variant that review is about. + pub product_variant: ProductVariant, + /// Body of Review. + pub body: String, + /// Rating of Review in 1-5 stars. + pub rating: Rating, + /// Timestamp when Review was created. + pub created_at: DateTime, + /// Flag if review is visible, + pub is_visible: bool, + +} + +#[derive(Enum, Copy, Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +pub enum Rating { + OneStars = 1, + TwoStars = 2, + ThreeStars = 3, + FourStars = 4, + FiveStars = 5, +} \ No newline at end of file diff --git a/src/review_connection.rs b/src/review_connection.rs new file mode 100644 index 0000000..62b8708 --- /dev/null +++ b/src/review_connection.rs @@ -0,0 +1,28 @@ +use async_graphql::SimpleObject; + +use crate::{base_connection::BaseConnection, review::Review}; + +/// A connection of Reviews. +#[derive(SimpleObject)] +#[graphql(shareable)] +pub struct ReviewConnection { + /// The resulting entities. + pub nodes: Vec, + /// Whether this connection has a next page. + pub has_next_page: bool, + /// The total amount of items in this connection. + pub total_count: u64, +} + +/// Implementation of conversion from BaseConnection to ReviewConnection. +/// +/// Prevents GraphQL naming conflicts. +impl From> for ReviewConnection { + fn from(value: BaseConnection) -> Self { + Self { + nodes: value.nodes, + has_next_page: value.has_next_page, + total_count: value.total_count, + } + } +} diff --git a/src/user.rs b/src/user.rs new file mode 100644 index 0000000..db8ed5b --- /dev/null +++ b/src/user.rs @@ -0,0 +1,60 @@ +use async_graphql::{ComplexObject, Context, Error, Result, SimpleObject}; +use bson::{doc, Document, Uuid}; +use mongodb::{options::FindOptions, Collection, Database}; +use mongodb_cursor_pagination::{error::CursorError, FindResult, PaginatedCursor}; +use serde::{Deserialize, Serialize}; + +use crate::{ + base_connection::{BaseConnection, FindResultWrapper}, + order_datatypes::ReviewOrderInput, + review::Review, + review_connection::ReviewConnection, +}; + +/// Type of a user owning reviews. +#[derive(Debug, Serialize, Deserialize, Hash, Eq, PartialEq, Clone, SimpleObject)] +#[graphql(complex)] +pub struct User { + /// UUID of the user. + pub _id: Uuid, +} + +#[ComplexObject] +impl User { + /// Retrieves reviews of user. + async fn reviews<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "Describes that the `first` N reviews should be retrieved.")] + first: Option, + #[graphql(desc = "Describes how many reviews should be skipped at the beginning.")] + skip: Option, + #[graphql(desc = "Specifies the order in which reviews are retrieved.")] order_by: Option< + ReviewOrderInput, + >, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("reviews"); + let review_order = order_by.unwrap_or_default(); + let sorting_doc = doc! {review_order.field.unwrap_or_default().as_str(): i32::from(review_order.direction.unwrap_or_default())}; + let find_options = FindOptions::builder() + .skip(skip) + .limit(first.map(|v| i64::from(v))) + .sort(sorting_doc) + .build(); + let document_collection = collection.clone_with_type::(); + let filter = doc! {"user._id": self._id}; + let maybe_find_results: Result, CursorError> = + PaginatedCursor::new(Some(find_options.clone()), None, None) + .find(&document_collection, Some(&filter)) + .await; + match maybe_find_results { + Ok(find_results) => { + let find_result_wrapper = FindResultWrapper(find_results); + let connection = Into::>::into(find_result_wrapper); + Ok(Into::::into(connection)) + } + Err(_) => return Err(Error::new("Retrieving reviews failed in MongoDB.")), + } + } +} From 64fed0d815e199f5663ab319fb1dd52e63d19ccd Mon Sep 17 00:00:00 2001 From: st170001 Date: Tue, 6 Feb 2024 17:10:55 +0100 Subject: [PATCH 02/11] Working queries and mutations --- src/main.rs | 3 ++ src/mutation.rs | 110 ++++++++++++++++++++++-------------------------- src/review.rs | 25 +++++++++-- src/user.rs | 2 +- 4 files changed, 76 insertions(+), 64 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4cb9442..e1667b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,6 +78,9 @@ async fn dapr_connection(db_client: Database) { user_collection, }; + //callback_service.add_user_to_mongodb(bson::Uuid::new()).await.unwrap(); + //callback_service.add_product_variant_to_mongodb(bson::Uuid::new()).await.unwrap(); + info!("AppCallback server listening on: {}", addr); // Create a gRPC server with the callback_service. TonicServer::builder() diff --git a/src/mutation.rs b/src/mutation.rs index a7595fa..d8c759f 100644 --- a/src/mutation.rs +++ b/src/mutation.rs @@ -1,9 +1,6 @@ -use std::collections::HashSet; - use async_graphql::{Context, Error, Object, Result}; use bson::Bson; use bson::Uuid; -use futures::TryStreamExt; use mongodb::{ bson::{doc, DateTime}, Collection, Database, @@ -59,17 +56,15 @@ impl Mutation { ) -> Result { let db_client = ctx.data_unchecked::(); let collection: Collection = db_client.collection::("reviews"); - let product_variant_collection: Collection = - db_client.collection::("product_variants"); let current_timestamp = DateTime::now(); update_body( &collection, - &product_variant_collection, &input, ¤t_timestamp, ) .await?; update_rating(&collection, &input, ¤t_timestamp).await?; + update_visibility(&collection, &input, ¤t_timestamp).await?; let review = query_review(&collection, input.id).await?; Ok(review) } @@ -106,54 +101,55 @@ fn uuid_from_bson(bson: Bson) -> Result { } } -/// Updates product variant ids of a review. +/// Updates body of a review. /// /// * `collection` - MongoDB collection to update. /// * `input` - `UpdateReviewInput`. -async fn update_product_variant_ids( +async fn update_body( collection: &Collection, - product_variant_collection: &Collection, input: &UpdateReviewInput, current_timestamp: &DateTime, ) -> Result<()> { - if let Some(definitely_product_variant_ids) = &input.product_variant_ids { - validate_product_variant_ids(&product_variant_collection, definitely_product_variant_ids) - .await?; - let normalized_product_variants: Vec = definitely_product_variant_ids - .iter() - .map(|id| ProductVariant { _id: id.clone() }) - .collect(); - if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"internal_product_variants": normalized_product_variants, "last_updated_at": current_timestamp}}, None).await { - let message = format!("Updating product_variant_ids of review of id: `{}` failed in MongoDB.", input.id); + if let Some(definitely_body) = &input.body { + if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"body": definitely_body, "last_updated_at": current_timestamp}}, None).await { + let message = format!("Updating body of review of id: `{}` failed in MongoDB.", input.id); return Err(Error::new(message)) } } Ok(()) } -/// Updates name of a review. +/// Updates rating of a review. /// /// * `collection` - MongoDB collection to update. /// * `input` - `UpdateReviewInput`. -async fn update_name( +async fn update_rating( collection: &Collection, input: &UpdateReviewInput, current_timestamp: &DateTime, ) -> Result<()> { - if let Some(definitely_name) = &input.name { - let result = collection - .update_one( - doc! {"_id": input.id }, - doc! {"$set": {"name": definitely_name, "last_updated_at": current_timestamp}}, - None, - ) - .await; - if let Err(_) = result { - let message = format!( - "Updating name of review of id: `{}` failed in MongoDB.", - input.id - ); - return Err(Error::new(message)); + if let Some(definitely_rating) = &input.rating { + if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"rating": definitely_rating, "last_updated_at": current_timestamp}}, None).await { + let message = format!("Updating rating of review of id: `{}` failed in MongoDB.", input.id); + return Err(Error::new(message)) + } + } + Ok(()) +} + +/// Updates visibility of a review. +/// +/// * `collection` - MongoDB collection to update. +/// * `input` - `UpdateReviewInput`. +async fn update_visibility( + collection: &Collection, + input: &UpdateReviewInput, + current_timestamp: &DateTime, +) -> Result<()> { + if let Some(definitely_is_visible) = &input.is_visible { + if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"is_visible": definitely_is_visible, "last_updated_at": current_timestamp}}, None).await { + let message = format!("Updating visibility of review of id: `{}` failed in MongoDB.", input.id); + return Err(Error::new(message)) } } Ok(()) @@ -164,47 +160,41 @@ async fn validate_input(db_client: &Database, input: &AddReviewInput) -> Result< let product_variant_collection: Collection = db_client.collection::("product_variants"); let user_collection: Collection = db_client.collection::("users"); - validate_product_variant_ids(&product_variant_collection, &input.product_variant_ids).await?; + validate_product_variant_id(&product_variant_collection, input.product_variant_id).await?; validate_user(&user_collection, input.user_id).await?; Ok(()) } -/// Checks if product variants are in the system (MongoDB database populated with events). +/// Checks if product variant in is in the system (MongoDB database populated with events). /// -/// Used before adding or modifying product variants / reviews. -async fn validate_product_variant_ids( +/// Used before adding reviews. +async fn validate_product_variant_id( collection: &Collection, - product_variant_ids: &HashSet, + product_variant_id: Uuid, ) -> Result<()> { - let product_variant_ids_vec: Vec = product_variant_ids.clone().into_iter().collect(); + let message = format!( + "Product variant with the UUID: `{}` is not present in the system.", + product_variant_id + ); match collection - .find(doc! {"_id": { "$in": &product_variant_ids_vec } }, None) + .find_one( + doc! {"_id": product_variant_id }, + None, + ) .await { - Ok(cursor) => { - let product_variants: Vec = cursor.try_collect().await?; - product_variant_ids_vec.iter().fold(Ok(()), |_, p| { - match product_variants.contains(&ProductVariant { _id: *p }) { - true => Ok(()), - false => { - let message = format!( - "Product variant with the UUID: `{}` is not present in the system.", - p - ); - Err(Error::new(message)) - } - } - }) - } - Err(_) => Err(Error::new( - "Product variants with the specified UUIDs are not present in the system.", - )), + Ok(maybe_product_variant) => match maybe_product_variant { + Some(_) => Ok(()), + None => Err(Error::new(message)), + }, + Err(_) => Err(Error::new(message)), } } + /// Checks if user is in the system (MongoDB database populated with events). /// /// Used before adding reviews. async fn validate_user(collection: &Collection, id: Uuid) -> Result<()> { query_user(&collection, id).await.map(|_| ()) -} +} \ No newline at end of file diff --git a/src/review.rs b/src/review.rs index e4512c7..178b375 100644 --- a/src/review.rs +++ b/src/review.rs @@ -1,6 +1,6 @@ use async_graphql::{Enum, SimpleObject}; -use bson::datetime::DateTime; -use bson::Uuid; +use bson::{datetime::DateTime, Bson}; +use bson::{doc, Uuid}; use serde::{Deserialize, Serialize}; use crate::{ @@ -34,4 +34,23 @@ pub enum Rating { ThreeStars = 3, FourStars = 4, FiveStars = 5, -} \ No newline at end of file +} + +impl Rating { + pub fn to_string(&self) -> String { + match self { + Rating::OneStars => "OneStars".to_string(), + Rating::TwoStars => "TwoStars".to_string(), + Rating::ThreeStars => "ThreeStars".to_string(), + Rating::FourStars => "FourStarst".to_string(), + Rating::FiveStars => "FiveStars".to_string(), + } + } +} + +impl From for Bson { + fn from(value: Rating) -> Self { + Bson::String(value.to_string()) + } +} + diff --git a/src/user.rs b/src/user.rs index db8ed5b..c7c3933 100644 --- a/src/user.rs +++ b/src/user.rs @@ -57,4 +57,4 @@ impl User { Err(_) => return Err(Error::new("Retrieving reviews failed in MongoDB.")), } } -} +} \ No newline at end of file From 002b4bd815c767ff770562d496d6835b71fb2d55 Mon Sep 17 00:00:00 2001 From: st170001 Date: Tue, 6 Feb 2024 17:50:23 +0100 Subject: [PATCH 03/11] Review queries from root and product variants, moved review mutations behind user resolver --- src/main.rs | 2 + src/mutation.rs | 182 ++----------------------------- src/mutation_input_structs.rs | 2 - src/product_variant.rs | 40 ++++++- src/query.rs | 53 ++++++--- src/review.rs | 2 + src/user_mutation.rs | 195 ++++++++++++++++++++++++++++++++++ 7 files changed, 282 insertions(+), 194 deletions(-) create mode 100644 src/user_mutation.rs diff --git a/src/main.rs b/src/main.rs index e1667b8..e40317a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,8 @@ use app_callback_service::AppCallbackService; mod user; use user::User; +mod user_mutation; + use product_variant::ProductVariant; mod product_variant; diff --git a/src/mutation.rs b/src/mutation.rs index d8c759f..8873d35 100644 --- a/src/mutation.rs +++ b/src/mutation.rs @@ -9,6 +9,7 @@ use mongodb::{ use crate::product_variant::ProductVariant; use crate::query::query_user; use crate::user::User; +use crate::user_mutation::UserMutation; use crate::{ mutation_input_structs::{AddReviewInput, UpdateReviewInput}, query::query_review, @@ -20,181 +21,16 @@ pub struct Mutation; #[Object] impl Mutation { - /// Adds a review. - async fn add_review<'a>( + /// Entity resolver for user of specific id. + //#[graphql(entity)] + async fn user<'a>( &self, ctx: &Context<'a>, - #[graphql(desc = "AddReviewInput")] input: AddReviewInput, - ) -> Result { + #[graphql(desc = "UUID of user to retrieve.")] id: Uuid, + ) -> Result { let db_client = ctx.data_unchecked::(); - let collection: Collection = db_client.collection::("reviews"); - validate_input(db_client, &input).await?; - let current_timestamp = DateTime::now(); - let review = Review { - _id: Uuid::new(), - user: User { _id: input.user_id }, - product_variant: ProductVariant { _id: input.product_variant_id }, - body: input.body, - rating: input.rating, - created_at: current_timestamp, - is_visible: input.is_visible.unwrap_or(true), - }; - match collection.insert_one(review, None).await { - Ok(result) => { - let id = uuid_from_bson(result.inserted_id)?; - query_review(&collection, id).await - } - Err(_) => Err(Error::new("Adding review failed in MongoDB.")), - } + let collection: Collection = db_client.collection::("users"); + let user = query_user(&collection, id).await?; + Ok(UserMutation { _id: user._id }) } - - /// Updates a specific review referenced with an id. - async fn update_review<'a>( - &self, - ctx: &Context<'a>, - #[graphql(desc = "UpdateReviewInput")] input: UpdateReviewInput, - ) -> Result { - let db_client = ctx.data_unchecked::(); - let collection: Collection = db_client.collection::("reviews"); - let current_timestamp = DateTime::now(); - update_body( - &collection, - &input, - ¤t_timestamp, - ) - .await?; - update_rating(&collection, &input, ¤t_timestamp).await?; - update_visibility(&collection, &input, ¤t_timestamp).await?; - let review = query_review(&collection, input.id).await?; - Ok(review) - } - - /// Deletes review of id. - async fn delete_review<'a>( - &self, - ctx: &Context<'a>, - #[graphql(desc = "UUID of review to delete.")] id: Uuid, - ) -> Result { - let db_client = ctx.data_unchecked::(); - let collection: Collection = db_client.collection::("reviews"); - if let Err(_) = collection.delete_one(doc! {"_id": id }, None).await { - let message = format!("Deleting review of id: `{}` failed in MongoDB.", id); - return Err(Error::new(message)); - } - Ok(true) - } -} - -/// Extracts UUID from Bson. -/// -/// Adding a review returns a UUID in a Bson document. This function helps to extract the UUID. -fn uuid_from_bson(bson: Bson) -> Result { - match bson { - Bson::Binary(id) => Ok(id.to_uuid()?), - _ => { - let message = format!( - "Returned id: `{}` needs to be a Binary in order to be parsed as a Uuid", - bson - ); - Err(Error::new(message)) - } - } -} - -/// Updates body of a review. -/// -/// * `collection` - MongoDB collection to update. -/// * `input` - `UpdateReviewInput`. -async fn update_body( - collection: &Collection, - input: &UpdateReviewInput, - current_timestamp: &DateTime, -) -> Result<()> { - if let Some(definitely_body) = &input.body { - if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"body": definitely_body, "last_updated_at": current_timestamp}}, None).await { - let message = format!("Updating body of review of id: `{}` failed in MongoDB.", input.id); - return Err(Error::new(message)) - } - } - Ok(()) -} - -/// Updates rating of a review. -/// -/// * `collection` - MongoDB collection to update. -/// * `input` - `UpdateReviewInput`. -async fn update_rating( - collection: &Collection, - input: &UpdateReviewInput, - current_timestamp: &DateTime, -) -> Result<()> { - if let Some(definitely_rating) = &input.rating { - if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"rating": definitely_rating, "last_updated_at": current_timestamp}}, None).await { - let message = format!("Updating rating of review of id: `{}` failed in MongoDB.", input.id); - return Err(Error::new(message)) - } - } - Ok(()) -} - -/// Updates visibility of a review. -/// -/// * `collection` - MongoDB collection to update. -/// * `input` - `UpdateReviewInput`. -async fn update_visibility( - collection: &Collection, - input: &UpdateReviewInput, - current_timestamp: &DateTime, -) -> Result<()> { - if let Some(definitely_is_visible) = &input.is_visible { - if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"is_visible": definitely_is_visible, "last_updated_at": current_timestamp}}, None).await { - let message = format!("Updating visibility of review of id: `{}` failed in MongoDB.", input.id); - return Err(Error::new(message)) - } - } - Ok(()) -} - -/// Checks if product variants and user in AddReviewInput are in the system (MongoDB database populated with events). -async fn validate_input(db_client: &Database, input: &AddReviewInput) -> Result<()> { - let product_variant_collection: Collection = - db_client.collection::("product_variants"); - let user_collection: Collection = db_client.collection::("users"); - validate_product_variant_id(&product_variant_collection, input.product_variant_id).await?; - validate_user(&user_collection, input.user_id).await?; - Ok(()) -} - -/// Checks if product variant in is in the system (MongoDB database populated with events). -/// -/// Used before adding reviews. -async fn validate_product_variant_id( - collection: &Collection, - product_variant_id: Uuid, -) -> Result<()> { - let message = format!( - "Product variant with the UUID: `{}` is not present in the system.", - product_variant_id - ); - match collection - .find_one( - doc! {"_id": product_variant_id }, - None, - ) - .await - { - Ok(maybe_product_variant) => match maybe_product_variant { - Some(_) => Ok(()), - None => Err(Error::new(message)), - }, - Err(_) => Err(Error::new(message)), - } -} - - -/// Checks if user is in the system (MongoDB database populated with events). -/// -/// Used before adding reviews. -async fn validate_user(collection: &Collection, id: Uuid) -> Result<()> { - query_user(&collection, id).await.map(|_| ()) } \ No newline at end of file diff --git a/src/mutation_input_structs.rs b/src/mutation_input_structs.rs index 257457a..1cc2fca 100644 --- a/src/mutation_input_structs.rs +++ b/src/mutation_input_structs.rs @@ -4,8 +4,6 @@ use crate::review::Rating; #[derive(SimpleObject, InputObject)] pub struct AddReviewInput { - /// UUID of user owning the review. - pub user_id: Uuid, /// UUID of product variant in review. pub product_variant_id: Uuid, /// Body of Review. diff --git a/src/product_variant.rs b/src/product_variant.rs index e40cf1d..53c3387 100644 --- a/src/product_variant.rs +++ b/src/product_variant.rs @@ -1,9 +1,17 @@ -use async_graphql::{ComplexObject, Context, SimpleObject, Result}; -use bson::{Uuid, doc, Bson}; +use std::cmp::Ordering; + +use async_graphql::{ComplexObject, Context, Error, Result, SimpleObject}; +use bson::{doc, Bson, Document, Uuid}; +use mongodb::{options::FindOptions, Collection, Database}; +use mongodb_cursor_pagination::{error::CursorError, FindResult, PaginatedCursor}; use serde::{Deserialize, Serialize}; -use crate::{order_datatypes::ReviewOrderInput, review_connection::ReviewConnection}; -use std::{cmp::Ordering, hash::Hash}; +use crate::{ + base_connection::{BaseConnection, FindResultWrapper}, + order_datatypes::ReviewOrderInput, + review::Review, + review_connection::ReviewConnection, +}; #[derive(Debug, Serialize, Deserialize, Hash, Eq, PartialEq, Copy, Clone, SimpleObject)] #[graphql(complex)] @@ -26,7 +34,29 @@ impl ProductVariant { ReviewOrderInput, >, ) -> Result { - todo!(); + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("reviews"); + let review_order = order_by.unwrap_or_default(); + let sorting_doc = doc! {review_order.field.unwrap_or_default().as_str(): i32::from(review_order.direction.unwrap_or_default())}; + let find_options = FindOptions::builder() + .skip(skip) + .limit(first.map(|v| i64::from(v))) + .sort(sorting_doc) + .build(); + let document_collection = collection.clone_with_type::(); + let filter = doc! {"product_variant._id": self._id}; + let maybe_find_results: Result, CursorError> = + PaginatedCursor::new(Some(find_options.clone()), None, None) + .find(&document_collection, Some(&filter)) + .await; + match maybe_find_results { + Ok(find_results) => { + let find_result_wrapper = FindResultWrapper(find_results); + let connection = Into::>::into(find_result_wrapper); + Ok(Into::::into(connection)) + } + Err(_) => return Err(Error::new("Retrieving reviews failed in MongoDB.")), + } } } diff --git a/src/query.rs b/src/query.rs index 02a8116..b0353b3 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,8 +1,9 @@ -use crate::{product_variant::ProductVariant, user::User, Review}; +use crate::{base_connection::{BaseConnection, FindResultWrapper}, order_datatypes::ReviewOrderInput, product_variant::ProductVariant, review_connection::ReviewConnection, user::User, Review}; use async_graphql::{Context, Error, Object, Result}; -use bson::Uuid; -use mongodb::{bson::doc, Collection, Database}; +use bson::{Document, Uuid}; +use mongodb::{bson::doc, options::FindOptions, Collection, Database}; +use mongodb_cursor_pagination::{error::CursorError, FindResult, PaginatedCursor}; /// Describes GraphQL review queries. pub struct Query; @@ -10,7 +11,7 @@ pub struct Query; #[Object] impl Query { /// Entity resolver for user of specific id. - #[graphql(entity)] + //#[graphql(entity)] async fn user_entity_resolver<'a>( &self, ctx: &Context<'a>, @@ -22,7 +23,7 @@ impl Query { } /// Entity resolver for product variant of specific id. - #[graphql(entity)] + //#[graphql(entity)] async fn product_variant_entity_resolver<'a>( &self, ctx: &Context<'a>, @@ -33,23 +34,47 @@ impl Query { query_product_variant(&collection, id).await } - /// Retrieves review of specific id. - async fn review<'a>( + /// Retrieves all reviews. + async fn reviews<'a>( &self, ctx: &Context<'a>, - #[graphql(desc = "UUID of review to retrieve.")] id: Uuid, - ) -> Result { + #[graphql(desc = "Describes that the `first` N reviews should be retrieved.")] + first: Option, + #[graphql(desc = "Describes how many reviews should be skipped at the beginning.")] + skip: Option, + #[graphql(desc = "Specifies the order in which reviews are retrieved.")] order_by: Option< + ReviewOrderInput, + >, + ) -> Result { let db_client = ctx.data_unchecked::(); let collection: Collection = db_client.collection::("reviews"); - query_review(&collection, id).await + let review_order = order_by.unwrap_or_default(); + let sorting_doc = doc! {review_order.field.unwrap_or_default().as_str(): i32::from(review_order.direction.unwrap_or_default())}; + let find_options = FindOptions::builder() + .skip(skip) + .limit(first.map(|v| i64::from(v))) + .sort(sorting_doc) + .build(); + let document_collection = collection.clone_with_type::(); + let maybe_find_results: Result, CursorError> = + PaginatedCursor::new(Some(find_options.clone()), None, None) + .find(&document_collection, None) + .await; + match maybe_find_results { + Ok(find_results) => { + let find_result_wrapper = FindResultWrapper(find_results); + let connection = Into::>::into(find_result_wrapper); + Ok(Into::::into(connection)) + } + Err(_) => return Err(Error::new("Retrieving reviews failed in MongoDB.")), + } } - /// Entity resolver for review of specific id. - #[graphql(entity)] - async fn review_entity_resolver<'a>( + /// Retrieves review of specific id. + async fn review<'a>( &self, ctx: &Context<'a>, - #[graphql(key, desc = "UUID of review to retrieve.")] id: Uuid, + #[graphql(desc = "UUID of review to retrieve.")] id: Uuid, ) -> Result { let db_client = ctx.data_unchecked::(); let collection: Collection = db_client.collection::("reviews"); diff --git a/src/review.rs b/src/review.rs index 178b375..3689543 100644 --- a/src/review.rs +++ b/src/review.rs @@ -22,6 +22,8 @@ pub struct Review { pub rating: Rating, /// Timestamp when Review was created. pub created_at: DateTime, + /// Timestamp when Review was created. + pub last_updated_at: DateTime, /// Flag if review is visible, pub is_visible: bool, diff --git a/src/user_mutation.rs b/src/user_mutation.rs new file mode 100644 index 0000000..a6c5e3d --- /dev/null +++ b/src/user_mutation.rs @@ -0,0 +1,195 @@ +use async_graphql::{ComplexObject, Context, Error, Result, SimpleObject}; +use bson::{doc, Bson, DateTime, Uuid}; +use mongodb::{Collection, Database}; + +use crate::{mutation_input_structs::{AddReviewInput, UpdateReviewInput}, product_variant::ProductVariant, query::{query_review, query_user}, review::Review, user::User}; + +/// Type of a user owning reviews. +#[derive(SimpleObject)] +#[graphql(complex)] +pub struct UserMutation { + /// UUID of the user. + pub _id: Uuid, +} + +#[ComplexObject] +impl UserMutation { + /// Adds a review. + async fn add_review<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "AddReviewInput")] input: AddReviewInput, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("reviews"); + validate_input(db_client, &input).await?; + let current_timestamp = DateTime::now(); + let review = Review { + _id: Uuid::new(), + user: User { _id: self._id }, + product_variant: ProductVariant { _id: input.product_variant_id }, + body: input.body, + rating: input.rating, + created_at: current_timestamp, + last_updated_at: current_timestamp, + is_visible: input.is_visible.unwrap_or(true), + }; + match collection.insert_one(review, None).await { + Ok(result) => { + let id = uuid_from_bson(result.inserted_id)?; + query_review(&collection, id).await + } + Err(_) => Err(Error::new("Adding review failed in MongoDB.")), + } + } + + /// Updates a specific review referenced with an id. + async fn update_review<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "UpdateReviewInput")] input: UpdateReviewInput, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("reviews"); + let current_timestamp = DateTime::now(); + update_body( + &collection, + &input, + ¤t_timestamp, + ) + .await?; + update_rating(&collection, &input, ¤t_timestamp).await?; + update_visibility(&collection, &input, ¤t_timestamp).await?; + let review = query_review(&collection, input.id).await?; + Ok(review) + } + + /// Deletes review of id. + async fn delete_review<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "UUID of review to delete.")] id: Uuid, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("reviews"); + if let Err(_) = collection.delete_one(doc! {"_id": id }, None).await { + let message = format!("Deleting review of id: `{}` failed in MongoDB.", id); + return Err(Error::new(message)); + } + Ok(true) + } +} + +/// Extracts UUID from Bson. +/// +/// Adding a review returns a UUID in a Bson document. This function helps to extract the UUID. +fn uuid_from_bson(bson: Bson) -> Result { + match bson { + Bson::Binary(id) => Ok(id.to_uuid()?), + _ => { + let message = format!( + "Returned id: `{}` needs to be a Binary in order to be parsed as a Uuid", + bson + ); + Err(Error::new(message)) + } + } +} + +/// Updates body of a review. +/// +/// * `collection` - MongoDB collection to update. +/// * `input` - `UpdateReviewInput`. +async fn update_body( + collection: &Collection, + input: &UpdateReviewInput, + current_timestamp: &DateTime, +) -> Result<()> { + if let Some(definitely_body) = &input.body { + if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"body": definitely_body, "last_updated_at": current_timestamp}}, None).await { + let message = format!("Updating body of review of id: `{}` failed in MongoDB.", input.id); + return Err(Error::new(message)) + } + } + Ok(()) +} + +/// Updates rating of a review. +/// +/// * `collection` - MongoDB collection to update. +/// * `input` - `UpdateReviewInput`. +async fn update_rating( + collection: &Collection, + input: &UpdateReviewInput, + current_timestamp: &DateTime, +) -> Result<()> { + if let Some(definitely_rating) = &input.rating { + if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"rating": definitely_rating, "last_updated_at": current_timestamp}}, None).await { + let message = format!("Updating rating of review of id: `{}` failed in MongoDB.", input.id); + return Err(Error::new(message)) + } + } + Ok(()) +} + +/// Updates visibility of a review. +/// +/// * `collection` - MongoDB collection to update. +/// * `input` - `UpdateReviewInput`. +async fn update_visibility( + collection: &Collection, + input: &UpdateReviewInput, + current_timestamp: &DateTime, +) -> Result<()> { + if let Some(definitely_is_visible) = &input.is_visible { + if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"is_visible": definitely_is_visible, "last_updated_at": current_timestamp}}, None).await { + let message = format!("Updating visibility of review of id: `{}` failed in MongoDB.", input.id); + return Err(Error::new(message)) + } + } + Ok(()) +} + +/// Checks if product variants and user in AddReviewInput are in the system (MongoDB database populated with events). +async fn validate_input(db_client: &Database, input: &AddReviewInput) -> Result<()> { + let product_variant_collection: Collection = + db_client.collection::("product_variants"); + let user_collection: Collection = db_client.collection::("users"); + validate_product_variant_id(&product_variant_collection, input.product_variant_id).await?; + //validate_user(&user_collection, input.user_id).await?; + Ok(()) +} + +/// Checks if product variant in is in the system (MongoDB database populated with events). +/// +/// Used before adding reviews. +async fn validate_product_variant_id( + collection: &Collection, + product_variant_id: Uuid, +) -> Result<()> { + let message = format!( + "Product variant with the UUID: `{}` is not present in the system.", + product_variant_id + ); + match collection + .find_one( + doc! {"_id": product_variant_id }, + None, + ) + .await + { + Ok(maybe_product_variant) => match maybe_product_variant { + Some(_) => Ok(()), + None => Err(Error::new(message)), + }, + Err(_) => Err(Error::new(message)), + } +} + + +/// Checks if user is in the system (MongoDB database populated with events). +/// +/// Used before adding reviews. +async fn validate_user(collection: &Collection, id: Uuid) -> Result<()> { + query_user(&collection, id).await.map(|_| ()) +} \ No newline at end of file From 1ea99402eb7ca9289b5e252962174675567db5c8 Mon Sep 17 00:00:00 2001 From: st170001 Date: Tue, 6 Feb 2024 18:11:21 +0100 Subject: [PATCH 04/11] Mutations directly accessible, not behind user resolver --- src/main.rs | 2 - src/mutation.rs | 189 ++++++++++++++++++++++++++++++-- src/mutation_input_structs.rs | 2 + src/user_mutation.rs | 195 ---------------------------------- 4 files changed, 185 insertions(+), 203 deletions(-) delete mode 100644 src/user_mutation.rs diff --git a/src/main.rs b/src/main.rs index e40317a..e1667b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,8 +34,6 @@ use app_callback_service::AppCallbackService; mod user; use user::User; -mod user_mutation; - use product_variant::ProductVariant; mod product_variant; diff --git a/src/mutation.rs b/src/mutation.rs index 8873d35..28aa133 100644 --- a/src/mutation.rs +++ b/src/mutation.rs @@ -9,7 +9,6 @@ use mongodb::{ use crate::product_variant::ProductVariant; use crate::query::query_user; use crate::user::User; -use crate::user_mutation::UserMutation; use crate::{ mutation_input_structs::{AddReviewInput, UpdateReviewInput}, query::query_review, @@ -22,15 +21,193 @@ pub struct Mutation; #[Object] impl Mutation { /// Entity resolver for user of specific id. - //#[graphql(entity)] - async fn user<'a>( + #[graphql(entity)] + async fn user_entity_resolver<'a>( &self, ctx: &Context<'a>, #[graphql(desc = "UUID of user to retrieve.")] id: Uuid, - ) -> Result { + ) -> Result { let db_client = ctx.data_unchecked::(); let collection: Collection = db_client.collection::("users"); - let user = query_user(&collection, id).await?; - Ok(UserMutation { _id: user._id }) + query_user(&collection, id).await } + + /// Adds a review. + async fn add_review<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "AddReviewInput")] input: AddReviewInput, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("reviews"); + validate_input(db_client, &input).await?; + let current_timestamp = DateTime::now(); + let review = Review { + _id: Uuid::new(), + user: User { _id: input.user_id }, + product_variant: ProductVariant { _id: input.product_variant_id }, + body: input.body, + rating: input.rating, + created_at: current_timestamp, + last_updated_at: current_timestamp, + is_visible: input.is_visible.unwrap_or(true), + }; + match collection.insert_one(review, None).await { + Ok(result) => { + let id = uuid_from_bson(result.inserted_id)?; + query_review(&collection, id).await + } + Err(_) => Err(Error::new("Adding review failed in MongoDB.")), + } + } + + /// Updates a specific review referenced with an id. + async fn update_review<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "UpdateReviewInput")] input: UpdateReviewInput, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("reviews"); + let current_timestamp = DateTime::now(); + update_body( + &collection, + &input, + ¤t_timestamp, + ) + .await?; + update_rating(&collection, &input, ¤t_timestamp).await?; + update_visibility(&collection, &input, ¤t_timestamp).await?; + let review = query_review(&collection, input.id).await?; + Ok(review) + } + + /// Deletes review of id. + async fn delete_review<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "UUID of review to delete.")] id: Uuid, + ) -> Result { + let db_client = ctx.data_unchecked::(); + let collection: Collection = db_client.collection::("reviews"); + if let Err(_) = collection.delete_one(doc! {"_id": id }, None).await { + let message = format!("Deleting review of id: `{}` failed in MongoDB.", id); + return Err(Error::new(message)); + } + Ok(true) + } +} + +/// Extracts UUID from Bson. +/// +/// Adding a review returns a UUID in a Bson document. This function helps to extract the UUID. +fn uuid_from_bson(bson: Bson) -> Result { + match bson { + Bson::Binary(id) => Ok(id.to_uuid()?), + _ => { + let message = format!( + "Returned id: `{}` needs to be a Binary in order to be parsed as a Uuid", + bson + ); + Err(Error::new(message)) + } + } +} + +/// Updates body of a review. +/// +/// * `collection` - MongoDB collection to update. +/// * `input` - `UpdateReviewInput`. +async fn update_body( + collection: &Collection, + input: &UpdateReviewInput, + current_timestamp: &DateTime, +) -> Result<()> { + if let Some(definitely_body) = &input.body { + if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"body": definitely_body, "last_updated_at": current_timestamp}}, None).await { + let message = format!("Updating body of review of id: `{}` failed in MongoDB.", input.id); + return Err(Error::new(message)) + } + } + Ok(()) +} + +/// Updates rating of a review. +/// +/// * `collection` - MongoDB collection to update. +/// * `input` - `UpdateReviewInput`. +async fn update_rating( + collection: &Collection, + input: &UpdateReviewInput, + current_timestamp: &DateTime, +) -> Result<()> { + if let Some(definitely_rating) = &input.rating { + if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"rating": definitely_rating, "last_updated_at": current_timestamp}}, None).await { + let message = format!("Updating rating of review of id: `{}` failed in MongoDB.", input.id); + return Err(Error::new(message)) + } + } + Ok(()) +} + +/// Updates visibility of a review. +/// +/// * `collection` - MongoDB collection to update. +/// * `input` - `UpdateReviewInput`. +async fn update_visibility( + collection: &Collection, + input: &UpdateReviewInput, + current_timestamp: &DateTime, +) -> Result<()> { + if let Some(definitely_is_visible) = &input.is_visible { + if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"is_visible": definitely_is_visible, "last_updated_at": current_timestamp}}, None).await { + let message = format!("Updating visibility of review of id: `{}` failed in MongoDB.", input.id); + return Err(Error::new(message)) + } + } + Ok(()) +} + +/// Checks if product variants and user in AddReviewInput are in the system (MongoDB database populated with events). +async fn validate_input(db_client: &Database, input: &AddReviewInput) -> Result<()> { + let product_variant_collection: Collection = + db_client.collection::("product_variants"); + let user_collection: Collection = db_client.collection::("users"); + validate_product_variant_id(&product_variant_collection, input.product_variant_id).await?; + validate_user(&user_collection, input.user_id).await?; + Ok(()) +} + +/// Checks if product variant in is in the system (MongoDB database populated with events). +/// +/// Used before adding reviews. +async fn validate_product_variant_id( + collection: &Collection, + product_variant_id: Uuid, +) -> Result<()> { + let message = format!( + "Product variant with the UUID: `{}` is not present in the system.", + product_variant_id + ); + match collection + .find_one( + doc! {"_id": product_variant_id }, + None, + ) + .await + { + Ok(maybe_product_variant) => match maybe_product_variant { + Some(_) => Ok(()), + None => Err(Error::new(message)), + }, + Err(_) => Err(Error::new(message)), + } +} + + +/// Checks if user is in the system (MongoDB database populated with events). +/// +/// Used before adding reviews. +async fn validate_user(collection: &Collection, id: Uuid) -> Result<()> { + query_user(&collection, id).await.map(|_| ()) } \ No newline at end of file diff --git a/src/mutation_input_structs.rs b/src/mutation_input_structs.rs index 1cc2fca..257457a 100644 --- a/src/mutation_input_structs.rs +++ b/src/mutation_input_structs.rs @@ -4,6 +4,8 @@ use crate::review::Rating; #[derive(SimpleObject, InputObject)] pub struct AddReviewInput { + /// UUID of user owning the review. + pub user_id: Uuid, /// UUID of product variant in review. pub product_variant_id: Uuid, /// Body of Review. diff --git a/src/user_mutation.rs b/src/user_mutation.rs deleted file mode 100644 index a6c5e3d..0000000 --- a/src/user_mutation.rs +++ /dev/null @@ -1,195 +0,0 @@ -use async_graphql::{ComplexObject, Context, Error, Result, SimpleObject}; -use bson::{doc, Bson, DateTime, Uuid}; -use mongodb::{Collection, Database}; - -use crate::{mutation_input_structs::{AddReviewInput, UpdateReviewInput}, product_variant::ProductVariant, query::{query_review, query_user}, review::Review, user::User}; - -/// Type of a user owning reviews. -#[derive(SimpleObject)] -#[graphql(complex)] -pub struct UserMutation { - /// UUID of the user. - pub _id: Uuid, -} - -#[ComplexObject] -impl UserMutation { - /// Adds a review. - async fn add_review<'a>( - &self, - ctx: &Context<'a>, - #[graphql(desc = "AddReviewInput")] input: AddReviewInput, - ) -> Result { - let db_client = ctx.data_unchecked::(); - let collection: Collection = db_client.collection::("reviews"); - validate_input(db_client, &input).await?; - let current_timestamp = DateTime::now(); - let review = Review { - _id: Uuid::new(), - user: User { _id: self._id }, - product_variant: ProductVariant { _id: input.product_variant_id }, - body: input.body, - rating: input.rating, - created_at: current_timestamp, - last_updated_at: current_timestamp, - is_visible: input.is_visible.unwrap_or(true), - }; - match collection.insert_one(review, None).await { - Ok(result) => { - let id = uuid_from_bson(result.inserted_id)?; - query_review(&collection, id).await - } - Err(_) => Err(Error::new("Adding review failed in MongoDB.")), - } - } - - /// Updates a specific review referenced with an id. - async fn update_review<'a>( - &self, - ctx: &Context<'a>, - #[graphql(desc = "UpdateReviewInput")] input: UpdateReviewInput, - ) -> Result { - let db_client = ctx.data_unchecked::(); - let collection: Collection = db_client.collection::("reviews"); - let current_timestamp = DateTime::now(); - update_body( - &collection, - &input, - ¤t_timestamp, - ) - .await?; - update_rating(&collection, &input, ¤t_timestamp).await?; - update_visibility(&collection, &input, ¤t_timestamp).await?; - let review = query_review(&collection, input.id).await?; - Ok(review) - } - - /// Deletes review of id. - async fn delete_review<'a>( - &self, - ctx: &Context<'a>, - #[graphql(desc = "UUID of review to delete.")] id: Uuid, - ) -> Result { - let db_client = ctx.data_unchecked::(); - let collection: Collection = db_client.collection::("reviews"); - if let Err(_) = collection.delete_one(doc! {"_id": id }, None).await { - let message = format!("Deleting review of id: `{}` failed in MongoDB.", id); - return Err(Error::new(message)); - } - Ok(true) - } -} - -/// Extracts UUID from Bson. -/// -/// Adding a review returns a UUID in a Bson document. This function helps to extract the UUID. -fn uuid_from_bson(bson: Bson) -> Result { - match bson { - Bson::Binary(id) => Ok(id.to_uuid()?), - _ => { - let message = format!( - "Returned id: `{}` needs to be a Binary in order to be parsed as a Uuid", - bson - ); - Err(Error::new(message)) - } - } -} - -/// Updates body of a review. -/// -/// * `collection` - MongoDB collection to update. -/// * `input` - `UpdateReviewInput`. -async fn update_body( - collection: &Collection, - input: &UpdateReviewInput, - current_timestamp: &DateTime, -) -> Result<()> { - if let Some(definitely_body) = &input.body { - if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"body": definitely_body, "last_updated_at": current_timestamp}}, None).await { - let message = format!("Updating body of review of id: `{}` failed in MongoDB.", input.id); - return Err(Error::new(message)) - } - } - Ok(()) -} - -/// Updates rating of a review. -/// -/// * `collection` - MongoDB collection to update. -/// * `input` - `UpdateReviewInput`. -async fn update_rating( - collection: &Collection, - input: &UpdateReviewInput, - current_timestamp: &DateTime, -) -> Result<()> { - if let Some(definitely_rating) = &input.rating { - if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"rating": definitely_rating, "last_updated_at": current_timestamp}}, None).await { - let message = format!("Updating rating of review of id: `{}` failed in MongoDB.", input.id); - return Err(Error::new(message)) - } - } - Ok(()) -} - -/// Updates visibility of a review. -/// -/// * `collection` - MongoDB collection to update. -/// * `input` - `UpdateReviewInput`. -async fn update_visibility( - collection: &Collection, - input: &UpdateReviewInput, - current_timestamp: &DateTime, -) -> Result<()> { - if let Some(definitely_is_visible) = &input.is_visible { - if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"is_visible": definitely_is_visible, "last_updated_at": current_timestamp}}, None).await { - let message = format!("Updating visibility of review of id: `{}` failed in MongoDB.", input.id); - return Err(Error::new(message)) - } - } - Ok(()) -} - -/// Checks if product variants and user in AddReviewInput are in the system (MongoDB database populated with events). -async fn validate_input(db_client: &Database, input: &AddReviewInput) -> Result<()> { - let product_variant_collection: Collection = - db_client.collection::("product_variants"); - let user_collection: Collection = db_client.collection::("users"); - validate_product_variant_id(&product_variant_collection, input.product_variant_id).await?; - //validate_user(&user_collection, input.user_id).await?; - Ok(()) -} - -/// Checks if product variant in is in the system (MongoDB database populated with events). -/// -/// Used before adding reviews. -async fn validate_product_variant_id( - collection: &Collection, - product_variant_id: Uuid, -) -> Result<()> { - let message = format!( - "Product variant with the UUID: `{}` is not present in the system.", - product_variant_id - ); - match collection - .find_one( - doc! {"_id": product_variant_id }, - None, - ) - .await - { - Ok(maybe_product_variant) => match maybe_product_variant { - Some(_) => Ok(()), - None => Err(Error::new(message)), - }, - Err(_) => Err(Error::new(message)), - } -} - - -/// Checks if user is in the system (MongoDB database populated with events). -/// -/// Used before adding reviews. -async fn validate_user(collection: &Collection, id: Uuid) -> Result<()> { - query_user(&collection, id).await.map(|_| ()) -} \ No newline at end of file From f870d2cf45fac90dc9f30fe9d51b67ad3e5151a6 Mon Sep 17 00:00:00 2001 From: st170001 Date: Tue, 6 Feb 2024 18:31:18 +0100 Subject: [PATCH 05/11] Review is already written check --- src/mutation.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/mutation.rs b/src/mutation.rs index 28aa133..739f9b9 100644 --- a/src/mutation.rs +++ b/src/mutation.rs @@ -46,12 +46,13 @@ impl Mutation { _id: Uuid::new(), user: User { _id: input.user_id }, product_variant: ProductVariant { _id: input.product_variant_id }, - body: input.body, + body: input.body.clone(), rating: input.rating, created_at: current_timestamp, last_updated_at: current_timestamp, is_visible: input.is_visible.unwrap_or(true), }; + review_is_already_written_by_user(&collection, &input).await?; match collection.insert_one(review, None).await { Ok(result) => { let id = uuid_from_bson(result.inserted_id)?; @@ -210,4 +211,27 @@ async fn validate_product_variant_id( /// Used before adding reviews. async fn validate_user(collection: &Collection, id: Uuid) -> Result<()> { query_user(&collection, id).await.map(|_| ()) +} + + +/// Throws an error if user has already written a review for the product variant. +async fn review_is_already_written_by_user(collection: &Collection, input: &AddReviewInput) -> Result<()> { + let message = format!( + "User of UUID: `{}` has already written a review for product variant of UUID: `{}`.", + input.user_id, + input.product_variant_id + ); + match collection + .find_one( + doc! {"_id": input.product_variant_id, "user._id": input.user_id }, + None, + ) + .await + { + Ok(maybe_product_variant) => match maybe_product_variant { + Some(_) => Err(Error::new(message)), + None => Ok(()), + }, + Err(_) => Err(Error::new(message)), + } } \ No newline at end of file From 0f0782fa938b1cc4ce31e815238f733ac708380e Mon Sep 17 00:00:00 2001 From: st170001 Date: Tue, 6 Feb 2024 18:34:02 +0100 Subject: [PATCH 06/11] Fixed MongoDB field access --- src/mutation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mutation.rs b/src/mutation.rs index 739f9b9..42960ee 100644 --- a/src/mutation.rs +++ b/src/mutation.rs @@ -223,7 +223,7 @@ async fn review_is_already_written_by_user(collection: &Collection, inpu ); match collection .find_one( - doc! {"_id": input.product_variant_id, "user._id": input.user_id }, + doc! {"product_variant._id": input.product_variant_id, "user._id": input.user_id }, None, ) .await From 8f7b1dc5896d2ee644f396138f97d06a06949cbf Mon Sep 17 00:00:00 2001 From: st170001 Date: Tue, 6 Feb 2024 18:35:30 +0100 Subject: [PATCH 07/11] cargo fmt --- src/mutation.rs | 60 +++++++++++++++++++++-------------- src/mutation_input_structs.rs | 2 +- src/product_variant.rs | 2 +- src/query.rs | 17 ++++++++-- src/review.rs | 6 +--- src/user.rs | 2 +- 6 files changed, 55 insertions(+), 34 deletions(-) diff --git a/src/mutation.rs b/src/mutation.rs index 42960ee..a931cfc 100644 --- a/src/mutation.rs +++ b/src/mutation.rs @@ -45,7 +45,9 @@ impl Mutation { let review = Review { _id: Uuid::new(), user: User { _id: input.user_id }, - product_variant: ProductVariant { _id: input.product_variant_id }, + product_variant: ProductVariant { + _id: input.product_variant_id, + }, body: input.body.clone(), rating: input.rating, created_at: current_timestamp, @@ -71,12 +73,7 @@ impl Mutation { let db_client = ctx.data_unchecked::(); let collection: Collection = db_client.collection::("reviews"); let current_timestamp = DateTime::now(); - update_body( - &collection, - &input, - ¤t_timestamp, - ) - .await?; + update_body(&collection, &input, ¤t_timestamp).await?; update_rating(&collection, &input, ¤t_timestamp).await?; update_visibility(&collection, &input, ¤t_timestamp).await?; let review = query_review(&collection, input.id).await?; @@ -125,9 +122,19 @@ async fn update_body( current_timestamp: &DateTime, ) -> Result<()> { if let Some(definitely_body) = &input.body { - if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"body": definitely_body, "last_updated_at": current_timestamp}}, None).await { - let message = format!("Updating body of review of id: `{}` failed in MongoDB.", input.id); - return Err(Error::new(message)) + if let Err(_) = collection + .update_one( + doc! {"_id": input.id }, + doc! {"$set": {"body": definitely_body, "last_updated_at": current_timestamp}}, + None, + ) + .await + { + let message = format!( + "Updating body of review of id: `{}` failed in MongoDB.", + input.id + ); + return Err(Error::new(message)); } } Ok(()) @@ -143,9 +150,19 @@ async fn update_rating( current_timestamp: &DateTime, ) -> Result<()> { if let Some(definitely_rating) = &input.rating { - if let Err(_) = collection.update_one(doc!{"_id": input.id }, doc!{"$set": {"rating": definitely_rating, "last_updated_at": current_timestamp}}, None).await { - let message = format!("Updating rating of review of id: `{}` failed in MongoDB.", input.id); - return Err(Error::new(message)) + if let Err(_) = collection + .update_one( + doc! {"_id": input.id }, + doc! {"$set": {"rating": definitely_rating, "last_updated_at": current_timestamp}}, + None, + ) + .await + { + let message = format!( + "Updating rating of review of id: `{}` failed in MongoDB.", + input.id + ); + return Err(Error::new(message)); } } Ok(()) @@ -191,10 +208,7 @@ async fn validate_product_variant_id( product_variant_id ); match collection - .find_one( - doc! {"_id": product_variant_id }, - None, - ) + .find_one(doc! {"_id": product_variant_id }, None) .await { Ok(maybe_product_variant) => match maybe_product_variant { @@ -205,7 +219,6 @@ async fn validate_product_variant_id( } } - /// Checks if user is in the system (MongoDB database populated with events). /// /// Used before adding reviews. @@ -213,13 +226,14 @@ async fn validate_user(collection: &Collection, id: Uuid) -> Result<()> { query_user(&collection, id).await.map(|_| ()) } - /// Throws an error if user has already written a review for the product variant. -async fn review_is_already_written_by_user(collection: &Collection, input: &AddReviewInput) -> Result<()> { +async fn review_is_already_written_by_user( + collection: &Collection, + input: &AddReviewInput, +) -> Result<()> { let message = format!( "User of UUID: `{}` has already written a review for product variant of UUID: `{}`.", - input.user_id, - input.product_variant_id + input.user_id, input.product_variant_id ); match collection .find_one( @@ -234,4 +248,4 @@ async fn review_is_already_written_by_user(collection: &Collection, inpu }, Err(_) => Err(Error::new(message)), } -} \ No newline at end of file +} diff --git a/src/mutation_input_structs.rs b/src/mutation_input_structs.rs index 257457a..943a704 100644 --- a/src/mutation_input_structs.rs +++ b/src/mutation_input_structs.rs @@ -1,6 +1,6 @@ +use crate::review::Rating; use async_graphql::{InputObject, SimpleObject}; use bson::Uuid; -use crate::review::Rating; #[derive(SimpleObject, InputObject)] pub struct AddReviewInput { diff --git a/src/product_variant.rs b/src/product_variant.rs index 53c3387..faaacbb 100644 --- a/src/product_variant.rs +++ b/src/product_variant.rs @@ -70,4 +70,4 @@ impl From for Bson { fn from(value: ProductVariant) -> Self { Bson::Document(doc!("_id": value._id)) } -} \ No newline at end of file +} diff --git a/src/query.rs b/src/query.rs index b0353b3..467254c 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,4 +1,11 @@ -use crate::{base_connection::{BaseConnection, FindResultWrapper}, order_datatypes::ReviewOrderInput, product_variant::ProductVariant, review_connection::ReviewConnection, user::User, Review}; +use crate::{ + base_connection::{BaseConnection, FindResultWrapper}, + order_datatypes::ReviewOrderInput, + product_variant::ProductVariant, + review_connection::ReviewConnection, + user::User, + Review, +}; use async_graphql::{Context, Error, Object, Result}; use bson::{Document, Uuid}; @@ -30,7 +37,8 @@ impl Query { #[graphql(desc = "UUID of product variant to retrieve.")] id: Uuid, ) -> Result { let db_client = ctx.data_unchecked::(); - let collection: Collection = db_client.collection::("product_variants"); + let collection: Collection = + db_client.collection::("product_variants"); query_product_variant(&collection, id).await } @@ -126,7 +134,10 @@ pub async fn query_user(collection: &Collection, id: Uuid) -> Result /// /// * `connection` - MongoDB database connection. /// * `id` - UUID of product variant. -pub async fn query_product_variant(collection: &Collection, id: Uuid) -> Result { +pub async fn query_product_variant( + collection: &Collection, + id: Uuid, +) -> Result { match collection.find_one(doc! {"_id": id }, None).await { Ok(maybe_product_variant) => match maybe_product_variant { Some(product_variant) => Ok(product_variant), diff --git a/src/review.rs b/src/review.rs index 3689543..2c12c16 100644 --- a/src/review.rs +++ b/src/review.rs @@ -3,9 +3,7 @@ use bson::{datetime::DateTime, Bson}; use bson::{doc, Uuid}; use serde::{Deserialize, Serialize}; -use crate::{ - product_variant::ProductVariant, user::User -}; +use crate::{product_variant::ProductVariant, user::User}; /// The Review of a user. #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, SimpleObject)] @@ -26,7 +24,6 @@ pub struct Review { pub last_updated_at: DateTime, /// Flag if review is visible, pub is_visible: bool, - } #[derive(Enum, Copy, Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] @@ -55,4 +52,3 @@ impl From for Bson { Bson::String(value.to_string()) } } - diff --git a/src/user.rs b/src/user.rs index c7c3933..db8ed5b 100644 --- a/src/user.rs +++ b/src/user.rs @@ -57,4 +57,4 @@ impl User { Err(_) => return Err(Error::new("Retrieving reviews failed in MongoDB.")), } } -} \ No newline at end of file +} From fd1e93e7cb90b07e0c2781ac35e6da6a1e3eb1cc Mon Sep 17 00:00:00 2001 From: st170001 Date: Tue, 6 Feb 2024 19:00:33 +0100 Subject: [PATCH 08/11] Event server from GRPC to HTTP --- Cargo.toml | 2 +- docker-compose-base.yaml | 4 +- src/app_callback_service.rs | 144 ------------------------------------ src/http_event_service.rs | 110 +++++++++++++++++++++++++++ src/main.rs | 65 ++++++---------- 5 files changed, 137 insertions(+), 188 deletions(-) delete mode 100644 src/app_callback_service.rs create mode 100644 src/http_event_service.rs diff --git a/Cargo.toml b/Cargo.toml index f233148..e911244 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ async-graphql = { version = "6.0.11", features = ["bson", "chrono", "uuid", "log async-graphql-axum = "6.0.11" tokio = { version = "1.8", features = ["macros", "rt-multi-thread"] } hyper = "1.0.1" -axum = { version = "0.6.0", features = ["headers"] } +axum = { version = "0.6.0", features = ["headers", "macros"] } slab = "0.4.2" mongodb = "2.8.0" serde = "1.0.193" diff --git a/docker-compose-base.yaml b/docker-compose-base.yaml index 9c661ed..f9ae2eb 100644 --- a/docker-compose-base.yaml +++ b/docker-compose-base.yaml @@ -32,9 +32,9 @@ services: "--app-id", "review", "--app-port", - "50051", + "8080", "--app-protocol", - "grpc", + "http", "--dapr-http-port", "3500", "-placement-host-address", diff --git a/src/app_callback_service.rs b/src/app_callback_service.rs deleted file mode 100644 index c11fb45..0000000 --- a/src/app_callback_service.rs +++ /dev/null @@ -1,144 +0,0 @@ -use json::JsonValue; -use log::info; -use mongodb::Collection; -use tonic::{Request, Response, Status}; - -use bson::Uuid; -use dapr::{appcallback::*, dapr::dapr::proto::runtime::v1::app_callback_server::AppCallback}; - -use crate::{product_variant::ProductVariant, user::User}; - -pub struct AppCallbackService { - pub product_variant_collection: Collection, - pub user_collection: Collection, -} - -impl AppCallbackService { - /// Add a newly created product variant to MongoDB. - pub async fn add_product_variant_to_mongodb(&self, id: Uuid) -> Result<(), Status> { - let product_variant = ProductVariant { _id: id }; - match self - .product_variant_collection - .insert_one(product_variant, None) - .await - { - Ok(_) => Ok(()), - Err(_) => Err(Status::internal( - "Adding product variant failed in MongoDB.", - )), - } - } - - /// Add a newly created user to MongoDB. - pub async fn add_user_to_mongodb(&self, id: Uuid) -> Result<(), Status> { - let user = User { _id: id }; - match self.user_collection.insert_one(user, None).await { - Ok(_) => Ok(()), - Err(_) => Err(Status::internal("Adding user failed in MongoDB.")), - } - } -} - -#[tonic::async_trait] -impl AppCallback for AppCallbackService { - /// Invokes service method with InvokeRequest. - async fn on_invoke( - &self, - _request: Request, - ) -> Result, Status> { - Ok(Response::new(InvokeResponse::default())) - } - - /// Lists all topics subscribed by this app. - /// - /// NOTE: Dapr runtime will call this method to get - /// the list of topics the app wants to subscribe to. - /// In this example, the app is subscribing to topic `A`. - async fn list_topic_subscriptions( - &self, - _request: Request<()>, - ) -> Result, Status> { - let product_variant_topic = "catalog/product-variant/created".to_string(); - let user_topic = "user/user/created".to_string(); - let pubsub_name = "pubsub".to_string(); - let user_topic_subscription = TopicSubscription::new(pubsub_name.clone(), user_topic, None); - - let mut list_subscriptions = - ListTopicSubscriptionsResponse::topic(pubsub_name, product_variant_topic); - list_subscriptions - .subscriptions - .push(user_topic_subscription); - - Ok(Response::new(list_subscriptions)) - } - - /// Subscribes events from Pubsub. - async fn on_topic_event( - &self, - request: Request, - ) -> Result, Status> { - let r: dapr::dapr::dapr::proto::runtime::v1::TopicEventRequest = request.into_inner(); - let data = &r.data; - - let message = String::from_utf8_lossy(data); - let error_message = format!("Expected message to be parsable JSON, got: {}", message); - let message_json = json::parse(&message).map_err(|_| Status::internal(error_message))?; - let id_json_value = &message_json["id"]; - let id = parse_id(id_json_value)?; - - info!("Event with message was received: {}", &message); - - match r.topic.as_str() { - "catalog/product-variant/created" => self.add_product_variant_to_mongodb(id).await?, - "user/user/created" => self.add_user_to_mongodb(id).await?, - _ => { - let message = format!( - "Event of topic: `{}` is not a handable by this service.", - r.topic.as_str() - ); - Err(Status::internal(message))?; - } - } - - Ok(Response::new(TopicEventResponse::default())) - } - - /// Lists all input bindings subscribed by this app. - async fn list_input_bindings( - &self, - _request: Request<()>, - ) -> Result, Status> { - Ok(Response::new(ListInputBindingsResponse::default())) - } - - /// Listens events from the input bindings. - async fn on_binding_event( - &self, - _request: Request, - ) -> Result, Status> { - Ok(Response::new(BindingEventResponse::default())) - } -} - -/// Parses Uuid from JsonValue containing a String. -fn parse_id(id_json_value: &JsonValue) -> Result { - match id_json_value { - json::JsonValue::String(id_string) => match Uuid::parse_str(id_string) { - Ok(id_uuid) => Ok(id_uuid), - Err(_) => { - let error_message = format!( - "String value in `id` field cannot be parsed as bson::Uuid, got: {}", - id_string - ); - Err(Status::internal(error_message))? - } - }, - _ => { - let error_message = format!( - "`id` field does not exist or does not contain a String value, got: {}", - id_json_value - ); - Err(Status::internal(error_message))? - } - } -} diff --git a/src/http_event_service.rs b/src/http_event_service.rs new file mode 100644 index 0000000..87ee539 --- /dev/null +++ b/src/http_event_service.rs @@ -0,0 +1,110 @@ +use axum::{debug_handler, extract::State, http::StatusCode, Json}; +use bson::Uuid; +use log::info; +use mongodb::Collection; +use serde::{Deserialize, Serialize}; + +use crate::{product_variant::ProductVariant, user::User}; + +/// Data to send to Dapr in order to describe a subscription. +#[derive(Serialize)] +pub struct Pubsub { + #[serde(rename(serialize = "pubsubName"))] + pub pubsubname: String, + pub topic: String, + pub route: String, +} + +/// Reponse data to send to Dapr when receiving an event. +#[derive(Serialize)] +pub struct TopicEventResponse { + pub status: i32, +} + +/// Default status is `0` -> Ok, according to Dapr specs. +impl Default for TopicEventResponse { + fn default() -> Self { + Self { status: 0 } + } +} + +/// Relevant part of Dapr event wrapped in a CloudEnvelope. +#[derive(Deserialize, Debug)] +pub struct Event { + pub topic: String, + pub data: EventData, +} + +/// Relevant part of Dapr event.data. +#[derive(Deserialize, Debug)] +pub struct EventData { + pub id: Uuid, +} + +/// Service state containing database connections. +#[derive(Clone)] +pub struct HttpEventServiceState { + pub product_variant_collection: Collection, + pub user_collection: Collection, +} + +/// HTTP endpoint to list topic subsciptions. +pub async fn list_topic_subscriptions() -> Result>, StatusCode> { + let pubsub_user = Pubsub { + pubsubname: "pubsub".to_string(), + topic: "user/user/created".to_string(), + route: "/on-topic-event".to_string(), + }; + let pubsub_product_variant = Pubsub { + pubsubname: "pubsub".to_string(), + topic: "catalog/product-variant/created".to_string(), + route: "/on-topic-event".to_string(), + }; + Ok(Json(vec![pubsub_user, pubsub_product_variant])) +} + +/// HTTP endpoint to receive events. +#[debug_handler(state = HttpEventServiceState)] +pub async fn on_topic_event( + State(state): State, + Json(event): Json, +) -> Result, StatusCode> { + info!("{:?}", event); + + match event.topic.as_str() { + "catalog/product-variant/created" => { + add_product_variant_to_mongodb(state.product_variant_collection, event.data.id).await? + } + "user/user/created" => add_user_to_mongodb(state.user_collection, event.data.id).await?, + _ => { + // TODO: This message can be used for further Error visibility. + let _message = format!( + "Event of topic: `{}` is not a handleable by this service.", + event.topic.as_str() + ); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + Ok(Json(TopicEventResponse::default())) +} + +/// Add a newly created product variant to MongoDB. +pub async fn add_product_variant_to_mongodb( + collection: Collection, + id: Uuid, +) -> Result<(), StatusCode> { + let product_variant = ProductVariant { _id: id }; + match collection.insert_one(product_variant, None).await { + Ok(_) => Ok(()), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +/// Add a newly created user to MongoDB. +pub async fn add_user_to_mongodb(collection: Collection, id: Uuid) -> Result<(), StatusCode> { + let user = User { _id: id }; + match collection.insert_one(user, None).await { + Ok(_) => Ok(()), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} diff --git a/src/main.rs b/src/main.rs index e1667b8..bdd25fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,18 +6,16 @@ use async_graphql::{ use async_graphql_axum::GraphQL; use axum::{ response::{self, IntoResponse}, - routing::get, + routing::{get, post}, Router, Server, }; use clap::{arg, command, Parser}; +use http_event_service::{list_topic_subscriptions, on_topic_event, HttpEventServiceState}; use simple_logger::SimpleLogger; use log::info; use mongodb::{options::ClientOptions, Client, Database}; -use dapr::dapr::dapr::proto::runtime::v1::app_callback_server::AppCallbackServer; -use tonic::transport::Server as TonicServer; - use review::Review; mod review; @@ -28,12 +26,11 @@ use query::Query; mod mutation; use mutation::Mutation; -mod app_callback_service; -use app_callback_service::AppCallbackService; - mod user; use user::User; +mod http_event_service; + use product_variant::ProductVariant; mod product_variant; @@ -64,30 +61,23 @@ async fn db_connection() -> Client { Client::with_options(client_options).unwrap() } -/// Establishes connection to Dapr. +/// Returns Router that establishes connection to Dapr. /// -/// Adds AppCallbackService which defines pub/sub interaction with Dapr. -async fn dapr_connection(db_client: Database) { - let addr = "[::]:50051".parse().unwrap(); +/// Adds endpoints to define pub/sub interaction with Dapr. +async fn build_dapr_router(db_client: Database) -> Router { let product_variant_collection: mongodb::Collection = db_client.collection::("product_variants"); let user_collection: mongodb::Collection = db_client.collection::("users"); - let callback_service = AppCallbackService { - product_variant_collection, - user_collection, - }; - - //callback_service.add_user_to_mongodb(bson::Uuid::new()).await.unwrap(); - //callback_service.add_product_variant_to_mongodb(bson::Uuid::new()).await.unwrap(); - - info!("AppCallback server listening on: {}", addr); - // Create a gRPC server with the callback_service. - TonicServer::builder() - .add_service(AppCallbackServer::new(callback_service)) - .serve(addr) - .await - .unwrap(); + // Define routes. + let app = Router::new() + .route("/dapr/subscribe", get(list_topic_subscriptions)) + .route("/on-topic-event", post(on_topic_event)) + .with_state(HttpEventServiceState { + product_variant_collection, + user_collection, + }); + app } /// Command line argument to toggle schema generation instead of service execution. @@ -129,20 +119,13 @@ async fn start_service() { .enable_federation() .finish(); - let app = Router::new().route("/", get(graphiql).post_service(GraphQL::new(schema))); - - let t1 = tokio::spawn(async { - info!("GraphiQL IDE: http://0.0.0.0:8080"); - Server::bind(&"0.0.0.0:8080".parse().unwrap()) - .serve(app.into_make_service()) - .await - .unwrap(); - }); + let graphiql = Router::new().route("/", get(graphiql).post_service(GraphQL::new(schema))); + let dapr_router = build_dapr_router(db_client).await; + let app = Router::new().merge(graphiql).merge(dapr_router); - let t2 = tokio::spawn(async { - dapr_connection(db_client).await; - }); - - t1.await.unwrap(); - t2.await.unwrap(); + info!("GraphiQL IDE: http://0.0.0.0:8080"); + Server::bind(&"0.0.0.0:8080".parse().unwrap()) + .serve(app.into_make_service()) + .await + .unwrap(); } From d374c8c3d016e860df372af5714032cc3f7766a4 Mon Sep 17 00:00:00 2001 From: st170001 Date: Tue, 6 Feb 2024 19:57:29 +0100 Subject: [PATCH 09/11] Review changes --- src/mutation.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/mutation.rs b/src/mutation.rs index a931cfc..1545aa1 100644 --- a/src/mutation.rs +++ b/src/mutation.rs @@ -20,18 +20,6 @@ pub struct Mutation; #[Object] impl Mutation { - /// Entity resolver for user of specific id. - #[graphql(entity)] - async fn user_entity_resolver<'a>( - &self, - ctx: &Context<'a>, - #[graphql(desc = "UUID of user to retrieve.")] id: Uuid, - ) -> Result { - let db_client = ctx.data_unchecked::(); - let collection: Collection = db_client.collection::("users"); - query_user(&collection, id).await - } - /// Adds a review. async fn add_review<'a>( &self, From 59947817bef2f5f5ba15e60e89369dda844f56d0 Mon Sep 17 00:00:00 2001 From: st170001 Date: Tue, 6 Feb 2024 19:58:55 +0100 Subject: [PATCH 10/11] Activate GraphQL entity resolvers --- src/query.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/query.rs b/src/query.rs index 467254c..b2ccc18 100644 --- a/src/query.rs +++ b/src/query.rs @@ -18,7 +18,7 @@ pub struct Query; #[Object] impl Query { /// Entity resolver for user of specific id. - //#[graphql(entity)] + #[graphql(entity)] async fn user_entity_resolver<'a>( &self, ctx: &Context<'a>, @@ -30,7 +30,7 @@ impl Query { } /// Entity resolver for product variant of specific id. - //#[graphql(entity)] + #[graphql(entity)] async fn product_variant_entity_resolver<'a>( &self, ctx: &Context<'a>, From ca5bd0ad63a95cabe7c45759043d430ea0cd639e Mon Sep 17 00:00:00 2001 From: st170001 Date: Tue, 6 Feb 2024 20:09:23 +0100 Subject: [PATCH 11/11] Removed unused protobuf dependency --- base-dockerfile | 2 -- dev-dockerfile | 2 -- devcontainer-dockerfile | 2 -- 3 files changed, 6 deletions(-) diff --git a/base-dockerfile b/base-dockerfile index cb7554b..3923929 100644 --- a/base-dockerfile +++ b/base-dockerfile @@ -22,8 +22,6 @@ RUN cargo build --release --bin misarch-review # We do not need the Rust toolchain to run the binary! FROM debian:bookworm-slim AS runtime -RUN apt update && apt install -y protobuf-compiler && rm -rf /var/lib/apt/lists/* - WORKDIR /misarch-review COPY --from=builder /misarch-review/target/release/misarch-review /usr/local/bin ENTRYPOINT ["/usr/local/bin/misarch-review"] \ No newline at end of file diff --git a/dev-dockerfile b/dev-dockerfile index 8358685..3a49469 100644 --- a/dev-dockerfile +++ b/dev-dockerfile @@ -1,7 +1,5 @@ FROM rust:1.75-slim-bookworm -RUN apt update && apt install -y protobuf-compiler && rm -rf /var/lib/apt/lists/* - WORKDIR /usr/src/misarch-review COPY . . diff --git a/devcontainer-dockerfile b/devcontainer-dockerfile index a386e1e..63ef32d 100644 --- a/devcontainer-dockerfile +++ b/devcontainer-dockerfile @@ -1,5 +1,3 @@ FROM rust:1.75-slim-bookworm -RUN apt update && apt install -y protobuf-compiler && rm -rf /var/lib/apt/lists/* - WORKDIR /usr/src/misarch-review \ No newline at end of file