From 6f41300bb0ecc465bf45852efb10cf0bd7772499 Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Thu, 15 Jan 2026 16:14:41 +0100 Subject: [PATCH 1/2] Implement JSONPlaceholder REST APIs gRPC Methods --- Cargo.lock | 67 ++++++++- Cargo.toml | 1 + build.rs | 12 +- news.proto => proto/news.proto | 0 proto/posts.proto | 39 ++++++ proto/users.proto | 69 +++++++++ src/main.rs | 249 ++++++++++++++++++++++++++++++--- 7 files changed, 411 insertions(+), 26 deletions(-) rename news.proto => proto/news.proto (100%) create mode 100644 proto/posts.proto create mode 100644 proto/users.proto diff --git a/Cargo.lock b/Cargo.lock index 348b5a3..f3504e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1610,7 +1610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", - "heck 0.4.1", + "heck 0.5.0", "itertools 0.12.1", "log", "multimap", @@ -1659,6 +1659,70 @@ dependencies = [ "prost 0.12.6", ] +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + [[package]] name = "quote" version = "1.0.37" @@ -1863,6 +1927,7 @@ dependencies = [ "opentelemetry_sdk 0.22.1", "prost 0.12.6", "prost-types", + "protoc-bin-vendored", "shuttle-axum", "shuttle-runtime 0.49.0", "tokio", diff --git a/Cargo.toml b/Cargo.toml index dfa846a..fd4d722 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ async-trait = "0.1" [build-dependencies] gh-workflow = "0.5.1" tonic-build = "0.11.0" +protoc-bin-vendored = "3.0.0" [dev-dependencies] gh-workflow = "0.5.1" diff --git a/build.rs b/build.rs index 4f4fd27..7cc5c33 100644 --- a/build.rs +++ b/build.rs @@ -1,14 +1,14 @@ use std::path::PathBuf; fn main() { - let mut news = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - news.push("news.proto"); - tonic_build::compile_protos(news).expect("Failed to compile protos"); - + std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path().unwrap()); let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); tonic_build::configure() - .file_descriptor_set_path(out_dir.join("news_descriptor.bin")) - .compile(&["news.proto"], &["proto"]) + .file_descriptor_set_path(out_dir.join("grpc_descriptor.bin")) + .compile( + &["proto/news.proto", "proto/posts.proto", "proto/users.proto"], + &["proto"], + ) .unwrap(); } diff --git a/news.proto b/proto/news.proto similarity index 100% rename from news.proto rename to proto/news.proto diff --git a/proto/posts.proto b/proto/posts.proto new file mode 100644 index 0000000..3accd82 --- /dev/null +++ b/proto/posts.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; + +package posts; + +message Post { + int32 user_id = 1; + int32 id = 2; + string title = 3; + string body = 4; +} + +message Filter { + optional int32 user_id = 1; +} + +message PostList { + repeated Post posts = 1; +} + +message PostRequest { + int32 id = 1; +} + +message PostResponse { + Post post = 1; +} + +message DeleteResponse { + bool success = 1; + string message = 2; +} + +service PostService { + rpc ListPosts(Filter) returns (PostList); + rpc GetPost(PostRequest) returns (Post); + rpc CreatePost(Post) returns (PostResponse); + rpc UpdatePost(Post) returns (PostResponse); + rpc DeletePost(PostRequest) returns (DeleteResponse); +} diff --git a/proto/users.proto b/proto/users.proto new file mode 100644 index 0000000..017a1c7 --- /dev/null +++ b/proto/users.proto @@ -0,0 +1,69 @@ +syntax = "proto3"; + +package users; + +message Geo { + string lat = 1; + string lng = 2; +} + +message Address { + string street = 1; + string suite = 2; + string city = 3; + string zipcode = 4; + Geo geo = 5; +} + +message Company { + string name = 1; + string catch_phrase = 2; + string bs = 3; +} + +message User { + int32 id = 1; + string name = 2; + string username = 3; + string email = 4; + Address address = 5; + string phone = 6; + string website = 7; + Company company = 8; +} + +message Filter { + repeated int32 id = 1; +} + +message UserList { + repeated User users = 1; +} + +message UserRequest { + int32 id = 1; +} + +message UserResponse { + User user = 1; +} + +message PatchUserRequest { + int32 id = 1; + optional string name = 2; + optional string username = 3; + optional string email = 4; +} + +message DeleteResponse { + bool success = 1; + string message = 2; +} + +service UserService { + rpc ListUsers(Filter) returns (UserList); + rpc GetUser(UserRequest) returns (User); + rpc CreateUser(User) returns (UserResponse); + rpc PatchUser(PatchUserRequest) returns (UserResponse); + rpc DeleteUser(UserRequest) returns (DeleteResponse); +} diff --git a/src/main.rs b/src/main.rs index 7e2aed4..69433eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,29 +9,48 @@ use once_cell::sync::Lazy; use opentelemetry::{global, trace::TraceError, trace::TracerProvider, KeyValue}; use opentelemetry_otlp::WithExportConfig; use opentelemetry_sdk::{propagation::TraceContextPropagator, runtime, Resource}; +use shuttle_runtime::Service; use tonic::{metadata::MetadataMap, transport::Server as TonicServer, Response, Status}; use tonic_tracing_opentelemetry::middleware::server; use tower::make::Shared; - -use news::news_service_server::NewsService; -use news::news_service_server::NewsServiceServer; -use news::{MultipleNewsId, News, NewsId, NewsList}; -use shuttle_runtime::Service; use tracing_subscriber::layer::SubscriberExt; -pub mod news { - tonic::include_proto!("news"); // The package name specified in your .proto +pub mod grpc { + pub mod news { + tonic::include_proto!("news"); + } + pub mod posts { + tonic::include_proto!("posts"); + } + pub mod users { + tonic::include_proto!("users"); + } pub(crate) const FILE_DESCRIPTOR_SET: &[u8] = - tonic::include_file_descriptor_set!("news_descriptor"); + tonic::include_file_descriptor_set!("grpc_descriptor"); } -#[derive(Debug, Default)] -pub struct MyNewsService { +use grpc::news::news_service_server::{NewsService, NewsServiceServer}; +use grpc::news::{MultipleNewsId, News, NewsId, NewsList}; +use grpc::posts::post_service_server::{PostService, PostServiceServer}; +use grpc::posts::{ + DeleteResponse as PostDeleteResponse, Filter as PostFilter, Post, PostList, PostRequest, + PostResponse, +}; +use grpc::users::user_service_server::{UserService, UserServiceServer}; +use grpc::users::{ + DeleteResponse as UserDeleteResponse, Filter as UserFilter, PatchUserRequest, User, UserList, + UserRequest, UserResponse, +}; + +#[derive(Debug, Default, Clone)] +pub struct MyGrpcService { news: Arc>>, // Using a simple vector to store news items in memory + posts: Arc>>, + users: Arc>>, } -impl MyNewsService { - fn new() -> MyNewsService { +impl MyGrpcService { + fn new() -> MyGrpcService { let news = vec![ News { id: 1, @@ -69,14 +88,40 @@ impl MyNewsService { status: 1, }, ]; - MyNewsService { + let posts = vec![ + Post { + user_id: 1, + id: 1, + title: "Post 1".into(), + body: "Body 1".into(), + }, + Post { + user_id: 1, + id: 2, + title: "Post 2".into(), + body: "Body 2".into(), + }, + ]; + let users = vec![User { + id: 1, + name: "Leanne Graham".into(), + username: "Bret".into(), + email: "Sincere@april.biz".into(), + address: None, + phone: "1-770-736-8031 x56442".into(), + website: "hildegard.org".into(), + company: None, + }]; + MyGrpcService { news: Arc::new(Mutex::new(news)), + posts: Arc::new(Mutex::new(posts)), + users: Arc::new(Mutex::new(users)), } } } #[tonic::async_trait] -impl NewsService for MyNewsService { +impl NewsService for MyGrpcService { async fn get_all_news( &self, _request: tonic::Request<()>, @@ -164,6 +209,170 @@ impl NewsService for MyNewsService { } } +#[tonic::async_trait] +impl PostService for MyGrpcService { + async fn list_posts( + &self, + request: tonic::Request, + ) -> std::result::Result, Status> { + let filter = request.into_inner(); + let lock = self.posts.lock().unwrap(); + let posts = match filter.user_id { + Some(user_id) => lock + .iter() + .filter(|p| p.user_id == user_id) + .cloned() + .collect(), + None => lock.clone(), + }; + Ok(Response::new(PostList { posts })) + } + + async fn get_post( + &self, + request: tonic::Request, + ) -> std::result::Result, Status> { + let id = request.into_inner().id; + let lock = self.posts.lock().unwrap(); + let post = lock.iter().find(|p| p.id == id).cloned(); + match post { + Some(post) => Ok(Response::new(post)), + None => Err(Status::not_found("Post not found")), + } + } + + async fn create_post( + &self, + request: tonic::Request, + ) -> std::result::Result, Status> { + let mut post = request.into_inner(); + let mut lock = self.posts.lock().unwrap(); + let new_id = lock.iter().map(|p| p.id).max().unwrap_or(0) + 1; + post.id = new_id; + lock.push(post.clone()); + Ok(Response::new(PostResponse { post: Some(post) })) + } + + async fn update_post( + &self, + request: tonic::Request, + ) -> std::result::Result, Status> { + let post_update = request.into_inner(); + let mut lock = self.posts.lock().unwrap(); + if let Some(post) = lock.iter_mut().find(|p| p.id == post_update.id) { + *post = post_update.clone(); + return Ok(Response::new(PostResponse { + post: Some(post_update), + })); + } + Err(Status::not_found("Post not found")) + } + + async fn delete_post( + &self, + request: tonic::Request, + ) -> std::result::Result, Status> { + let id = request.into_inner().id; + let mut lock = self.posts.lock().unwrap(); + let len_before = lock.len(); + lock.retain(|p| p.id != id); + if lock.len() < len_before { + Ok(Response::new(PostDeleteResponse { + success: true, + message: "Post deleted".into(), + })) + } else { + Err(Status::not_found("Post not found")) + } + } +} + +#[tonic::async_trait] +impl UserService for MyGrpcService { + async fn list_users( + &self, + request: tonic::Request, + ) -> std::result::Result, Status> { + let filter = request.into_inner(); + let lock = self.users.lock().unwrap(); + let users = if filter.id.is_empty() { + lock.clone() + } else { + lock.iter() + .filter(|u| filter.id.contains(&u.id)) + .cloned() + .collect() + }; + Ok(Response::new(UserList { users })) + } + + async fn get_user( + &self, + request: tonic::Request, + ) -> std::result::Result, Status> { + let id = request.into_inner().id; + let lock = self.users.lock().unwrap(); + let user = lock.iter().find(|u| u.id == id).cloned(); + match user { + Some(user) => Ok(Response::new(user)), + None => Err(Status::not_found("User not found")), + } + } + + async fn create_user( + &self, + request: tonic::Request, + ) -> std::result::Result, Status> { + let mut user = request.into_inner(); + let mut lock = self.users.lock().unwrap(); + let new_id = lock.iter().map(|u| u.id).max().unwrap_or(0) + 1; + user.id = new_id; + lock.push(user.clone()); + Ok(Response::new(UserResponse { user: Some(user) })) + } + + async fn patch_user( + &self, + request: tonic::Request, + ) -> std::result::Result, Status> { + let req = request.into_inner(); + let mut lock = self.users.lock().unwrap(); + if let Some(user) = lock.iter_mut().find(|u| u.id == req.id) { + if let Some(name) = req.name { + user.name = name; + } + if let Some(username) = req.username { + user.username = username; + } + if let Some(email) = req.email { + user.email = email; + } + return Ok(Response::new(UserResponse { + user: Some(user.clone()), + })); + } + Err(Status::not_found("User not found")) + } + + async fn delete_user( + &self, + request: tonic::Request, + ) -> std::result::Result, Status> { + let id = request.into_inner().id; + let mut lock = self.users.lock().unwrap(); + let len_before = lock.len(); + lock.retain(|u| u.id != id); + if lock.len() < len_before { + Ok(Response::new(UserDeleteResponse { + success: true, + message: "User deleted".into(), + })) + } else { + Err(Status::not_found("User not found")) + } + } +} + static RESOURCE: Lazy = Lazy::new(|| { Resource::default().merge(&Resource::new(vec![ KeyValue::new( @@ -222,16 +431,16 @@ async fn shuttle_main() -> Result { init_tracer()?; } - let news_service = MyNewsService::new(); + let grpc_service = MyGrpcService::new(); - Ok(news_service) + Ok(grpc_service) } #[async_trait::async_trait] -impl Service for MyNewsService { +impl Service for MyGrpcService { async fn bind(mut self, addr: std::net::SocketAddr) -> Result<(), shuttle_runtime::Error> { let service = tonic_reflection::server::Builder::configure() - .register_encoded_file_descriptor_set(news::FILE_DESCRIPTOR_SET) + .register_encoded_file_descriptor_set(grpc::FILE_DESCRIPTOR_SET) .build() .unwrap(); @@ -239,7 +448,9 @@ impl Service for MyNewsService { let tonic_service = TonicServer::builder() .layer(server::OtelGrpcLayer::default()) - .add_service(NewsServiceServer::new(self)) + .add_service(NewsServiceServer::new(self.clone())) + .add_service(PostServiceServer::new(self.clone())) + .add_service(UserServiceServer::new(self)) .add_service(service) .into_service(); let make_svc = Shared::new(tonic_service); From bc4433c7c31ca0c9c571491159c76b5e5f2a7267 Mon Sep 17 00:00:00 2001 From: Excellencedev Date: Thu, 15 Jan 2026 16:51:24 +0100 Subject: [PATCH 2/2] add tests --- src/main.rs | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/main.rs b/src/main.rs index 69433eb..5cbdc0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -463,3 +463,60 @@ impl Service for MyGrpcService { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_list_posts() { + let service = MyGrpcService::new(); + let request = tonic::Request::new(PostFilter { user_id: None }); + let response = service.list_posts(request).await.unwrap(); + let posts = response.into_inner(); + assert_eq!(posts.posts.len(), 2); + } + + #[tokio::test] + async fn test_get_post() { + let service = MyGrpcService::new(); + let request = tonic::Request::new(PostRequest { id: 1 }); + let response = service.get_post(request).await.unwrap(); + let post = response.into_inner(); + assert_eq!(post.title, "Post 1"); + } + + #[tokio::test] + async fn test_create_post() { + let service = MyGrpcService::new(); + let new_post = Post { + user_id: 1, + id: 0, // ID should be ignored/overwritten + title: "New Post".into(), + body: "New Body".into(), + }; + let request = tonic::Request::new(new_post); + let response = service.create_post(request).await.unwrap(); + let post = response.into_inner().post.unwrap(); + assert_eq!(post.title, "New Post"); + assert_eq!(post.id, 3); // Should be 3 as there are 2 existing posts + } + + #[tokio::test] + async fn test_list_users() { + let service = MyGrpcService::new(); + let request = tonic::Request::new(UserFilter { id: vec![] }); + let response = service.list_users(request).await.unwrap(); + let users = response.into_inner(); + assert_eq!(users.users.len(), 1); + } + + #[tokio::test] + async fn test_get_user() { + let service = MyGrpcService::new(); + let request = tonic::Request::new(UserRequest { id: 1 }); + let response = service.get_user(request).await.unwrap(); + let user = response.into_inner(); + assert_eq!(user.name, "Leanne Graham"); + } +}