From e42e3fac1423eea3a2fc40a830a7912b858ca967 Mon Sep 17 00:00:00 2001 From: Leonid Kozarin Date: Fri, 24 Jul 2026 23:57:17 +0300 Subject: [PATCH 1/2] Implement getMany batch endpoint (issue #25) - gRPC getMany RPC returning map, with FieldMask projection (project_user honours the mask; is_premium keeps proto3 scalar default) - REST GET /search?id=&external_id=&fields=, grouped into users_by_id / users_by_external_id; empty groups and null fields omitted - repo Users::get_many(Vec) -> HashMap, keyed by the requested id so external lookups stay correlatable - bump proto submodule pointer; regenerate offline .sqlx cache - CLAUDE.md: prefer functional-style constructs Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PXNSjVb2XLKg6yqSowzrjN --- ...cd9259e9e282d166cb1cc10e2bcf3e8297640.json | 46 +++++++++ ...75ddb7fb6d4195e01cb23c47dab63eaf9efb5.json | 52 ++++++++++ CLAUDE.md | 1 + proto | 2 +- src/grpc/generated.rs | 38 +++++++ src/grpc/server.rs | 26 ++++- src/grpc/test.rs | 39 +++++++- src/repo/test/export/mocks.rs | 20 ++++ src/repo/users.rs | 70 ++++++++++++- src/rest/dto/mod.rs | 13 +++ src/rest/dto/user.rs | 3 + src/rest/service.rs | 98 ++++++++++++++++++- src/rest/test.rs | 77 ++++++++++++++- 13 files changed, 472 insertions(+), 13 deletions(-) create mode 100644 .sqlx/query-cf6c64a63e8ed7c81c4151d3d76cd9259e9e282d166cb1cc10e2bcf3e8297640.json create mode 100644 .sqlx/query-d377165dbdb0a0806755db9cf2d75ddb7fb6d4195e01cb23c47dab63eaf9efb5.json diff --git a/.sqlx/query-cf6c64a63e8ed7c81c4151d3d76cd9259e9e282d166cb1cc10e2bcf3e8297640.json b/.sqlx/query-cf6c64a63e8ed7c81c4151d3d76cd9259e9e282d166cb1cc10e2bcf3e8297640.json new file mode 100644 index 0000000..534a2f1 --- /dev/null +++ b/.sqlx/query-cf6c64a63e8ed7c81c4151d3d76cd9259e9e282d166cb1cc10e2bcf3e8297640.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name, language_code, location, premium_till FROM Users WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "language_code", + "type_info": "Bpchar" + }, + { + "ordinal": 3, + "name": "location", + "type_info": "Float8Array" + }, + { + "ordinal": 4, + "name": "premium_till", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + true, + true, + true, + true + ] + }, + "hash": "cf6c64a63e8ed7c81c4151d3d76cd9259e9e282d166cb1cc10e2bcf3e8297640" +} diff --git a/.sqlx/query-d377165dbdb0a0806755db9cf2d75ddb7fb6d4195e01cb23c47dab63eaf9efb5.json b/.sqlx/query-d377165dbdb0a0806755db9cf2d75ddb7fb6d4195e01cb23c47dab63eaf9efb5.json new file mode 100644 index 0000000..a619434 --- /dev/null +++ b/.sqlx/query-d377165dbdb0a0806755db9cf2d75ddb7fb6d4195e01cb23c47dab63eaf9efb5.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT usm.external_id, u.id, u.name, u.language_code, u.location, u.premium_till\n FROM Users u\n JOIN User_Service_Mappings usm ON u.id = usm.user_id\n WHERE usm.external_id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "external_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "language_code", + "type_info": "Bpchar" + }, + { + "ordinal": 4, + "name": "location", + "type_info": "Float8Array" + }, + { + "ordinal": 5, + "name": "premium_till", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + true, + true, + true, + true + ] + }, + "hash": "d377165dbdb0a0806755db9cf2d75ddb7fb6d4195e01cb23c47dab63eaf9efb5" +} diff --git a/CLAUDE.md b/CLAUDE.md index 9d81be3..403b36e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,7 @@ Replaces verbose `.map_err()` blocks with single-line calls. ## Code Style +- Prefer functional-style constructs (iterators, combinators, `partition`/`chain`/`fold`, `Result`/`Option` combinators) over imperative loops and mutable accumulators, as long as it doesn't harm performance significantly - Use Result/Option combinators (`.inspect()`, `.and_then()`, `.transpose()`) over verbose match expressions - Move logging to delegated functions when possible - Prefer `env::get_value_or_default()` for optional environment variables diff --git a/proto b/proto index 12a5820..88218af 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 12a5820db0b9a743052b7377840bd7ac314f1afd +Subproject commit 88218afa569e8d2eec35175923837c2bc511f88d diff --git a/src/grpc/generated.rs b/src/grpc/generated.rs index d11731a..11563c0 100644 --- a/src/grpc/generated.rs +++ b/src/grpc/generated.rs @@ -8,6 +8,44 @@ use crate::repo::users::UpdateTarget; tonic::include_proto!("user_service"); +/// Applies a `FieldMask`-style projection to a [`User`] in place, clearing any field whose path is +/// not selected. The internal `id` is always retained (correlation to the request is via the +/// `GetUsersResponse` map key, which holds the requested — possibly external — id). +/// +/// Supported paths: `id`, `name`, `is_premium`, `options`, `options.language_code`, +/// `options.location`. Selecting a nested `options.*` path keeps `options` but drops its other +/// sub-fields; selecting bare `options` keeps the whole message. Unknown paths are ignored. +/// +/// Note: `is_premium` is a bare proto3 `bool` with no field presence, so a masked-out `is_premium` +/// is reset to its default (`false`) rather than becoming truly absent — a client cannot tell +/// "pruned" from "not premium". This matches Google's `FieldMaskUtil` behaviour for scalar fields. +pub fn project_user(user: &mut User, paths: &[String]) { + let keep_name = paths.iter().any(|p| p == "name"); + let keep_premium = paths.iter().any(|p| p == "is_premium"); + let keep_options = paths.iter().any(|p| p == "options"); + let keep_language = keep_options || paths.iter().any(|p| p == "options.language_code"); + let keep_location = keep_options || paths.iter().any(|p| p == "options.location"); + + if !keep_name { + user.name = None; + } + if !keep_premium { + user.is_premium = false; + } + if keep_language || keep_location { + if let Some(options) = user.options.as_mut() { + if !keep_language { + options.language_code = None; + } + if !keep_location { + options.location = None; + } + } + } else { + user.options = None; + } +} + impl Into for ExternalUser { fn into(self) -> dto::ExternalUser { dto::ExternalUser { diff --git a/src/grpc/server.rs b/src/grpc/server.rs index 3bf96d3..691d799 100644 --- a/src/grpc/server.rs +++ b/src/grpc/server.rs @@ -4,7 +4,8 @@ use autometrics::autometrics; use derive_more::Constructor; use tonic::{Request, Response, Status}; use crate::grpc::generated::user_service_server::UserService; -use crate::grpc::generated::{ActivatePremiumRequest, ActivatePremiumResponse, GetUserRequest, PremiumVariant, RegistrationRequest, RegistrationResponse, ServiceType, UpdateUserRequest, User}; +use crate::grpc::generated::{ActivatePremiumRequest, ActivatePremiumResponse, GetUsersRequest, GetUsersResponse, GetUserRequest, PremiumVariant, RegistrationRequest, RegistrationResponse, ServiceType, UpdateUserRequest, User}; +use crate::grpc::generated::project_user; use crate::grpc::generated::update_user_request::Target; use crate::dto::RegistrationStatus; use crate::{dto, repo}; @@ -43,6 +44,29 @@ where Ok(Response::new(user)) } + #[tracing::instrument(skip(self, request), fields(ids_count = request.get_ref().ids.len(), by_external_id = %request.get_ref().by_external_id))] + #[autometrics] + async fn get_many(&self, request: Request) -> Result, Status> { + let req = request.into_inner(); + let mask = req.fields.filter(|m| !m.paths.is_empty()); + let ids = req.ids.into_iter() + .map(|id| if req.by_external_id { UserId::External(id) } else { UserId::Internal(id) }) + .collect(); + + let users = self.repos.users.get_many(ids).await + .into_status()? + .into_iter() + .map(|(key, user)| { + let mut user: User = user.into(); + if let Some(mask) = &mask { + project_user(&mut user, &mask.paths); + } + (key.value(), user) + }) + .collect(); + Ok(Response::new(GetUsersResponse { users })) + } + #[tracing::instrument(skip(self, request), fields( external_id = request.get_ref().user.as_ref().map(|u| u.external_id).unwrap_or(0), service_name = request.get_ref().service.as_ref().map(|s| s.name.as_str()).unwrap_or("") diff --git a/src/grpc/test.rs b/src/grpc/test.rs index 7096ba1..88eac90 100644 --- a/src/grpc/test.rs +++ b/src/grpc/test.rs @@ -9,7 +9,7 @@ use serde_json::json; use tokio::net::TcpListener; use tonic::Code; use tonic::transport::{Channel, Server}; -use crate::grpc::generated::{ActivatePremiumRequest, ExternalUser, GetUserRequest, Location, PremiumVariant, RegistrationRequest, RegistrationStatus, Service, ServiceType, UpdateUserRequest}; +use crate::grpc::generated::{ActivatePremiumRequest, ExternalUser, GetUsersRequest, GetUserRequest, Location, PremiumVariant, RegistrationRequest, RegistrationStatus, Service, ServiceType, UpdateUserRequest}; use crate::grpc::generated::update_user_request::Target; use crate::grpc::generated::user_service_client::UserServiceClient; use crate::grpc::generated::user_service_server::{UserService, UserServiceServer}; @@ -34,8 +34,8 @@ async fn test_all() -> anyhow::Result<()> { id: ext_id, by_external_id: true, }; - test_get_not_found(&mut client, get_req_by_internal_id.clone()).await; - test_get_not_found(&mut client, get_req_by_external_id.clone()).await; + test_get_not_found(&mut client, get_req_by_internal_id).await; + test_get_not_found(&mut client, get_req_by_external_id).await; let registration_req = RegistrationRequest { user: Some(ExternalUser { @@ -51,7 +51,7 @@ async fn test_all() -> anyhow::Result<()> { test_registration(&mut client, registration_req.clone(), RegistrationStatus::Created).await?; test_registration(&mut client, registration_req, RegistrationStatus::AlreadyPresent).await?; - let user = client.get(get_req_by_internal_id.clone()).await?.into_inner(); + let user = client.get(get_req_by_internal_id).await?.into_inner(); assert!(!user.is_premium); let opts = user.options.unwrap(); assert_eq!(opts.language_code, None); @@ -85,9 +85,38 @@ async fn test_all() -> anyhow::Result<()> { assert_eq!(user.name, Some(username)); assert!(user.is_premium); let opts = &user.options.unwrap(); - assert_eq!(opts.language_code, Some(lang)); + assert_eq!(opts.language_code, Some(lang.clone())); assert_eq!(opts.location, Some(Location { latitude, longitude })); + // batch getMany: existing id resolved (keyed by the requested internal id), missing id omitted + let resp = client.get_many(GetUsersRequest { + ids: vec![1, 999], + by_external_id: false, + fields: None, + }).await?.into_inner(); + assert_eq!(resp.users.len(), 1); + let fetched = &resp.users[&1]; + assert_eq!(fetched.id, 1); + assert!(fetched.is_premium); + assert_eq!(fetched.options.as_ref().unwrap().language_code, Some(lang.clone())); + + // batch getMany by external id with a FieldMask projection; keyed by the requested external id + let resp = client.get_many(GetUsersRequest { + ids: vec![ext_id], + by_external_id: true, + fields: Some(prost_types::FieldMask { + paths: vec!["options.language_code".to_owned()], + }), + }).await?.into_inner(); + assert_eq!(resp.users.len(), 1); + let projected = &resp.users[&ext_id]; // keyed by the external id we asked for + assert_eq!(projected.id, 1); // inner id is still our internal id + assert_eq!(projected.name, None); // pruned + assert!(!projected.is_premium); // pruned (cleared to default) + let opts = projected.options.as_ref().unwrap(); + assert_eq!(opts.language_code, Some(lang)); + assert_eq!(opts.location, None); // pruned + Ok(()) } diff --git a/src/repo/test/export/mocks.rs b/src/repo/test/export/mocks.rs index 248e54c..77bb92e 100644 --- a/src/repo/test/export/mocks.rs +++ b/src/repo/test/export/mocks.rs @@ -93,6 +93,26 @@ impl Users for UsersMock { } } + async fn get_many(&self, ids: Vec) -> Result, RepoError> { + let users = self.users.lock().await; + let mut found = HashMap::new(); + for id in ids { + match id { + UserId::Internal(internal_id) => { + if let Some(usr) = users.values().find(|usr| usr.id == internal_id) { + found.insert(id, usr.clone()); + } + } + UserId::External(external_id) => { + if let Some(usr) = users.get(&external_id) { + found.insert(id, usr.clone()); + } + } + } + } + Ok(found) + } + async fn register(&self, user: ExternalUser, service_id: i32, _: serde_json::Value) -> Result> { tracing::info!("UsersMock:register: {user:?} (service_id = {service_id})"); let id = self.gen_id().await; diff --git a/src/repo/users.rs b/src/repo/users.rs index 1e3a7db..864575a 100644 --- a/src/repo/users.rs +++ b/src/repo/users.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use chrono::{DateTime, Utc}; use derive_more::{Constructor, From}; use num_traits::Zero; @@ -38,12 +39,21 @@ impl TryFrom for SavedUser { } } -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum UserId { Internal(i64), External(i64), } +impl UserId { + /// The underlying numeric id, regardless of kind. + pub fn value(self) -> i64 { + match self { + UserId::Internal(id) | UserId::External(id) => id, + } + } +} + impl std::fmt::Display for UserId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -68,6 +78,9 @@ impl From for UpdateTarget { pub trait Users: Send + Sync { fn get(&self, id: UserId) -> impl Future, RepoError>> + Send; + /// Batch fetch. Keyed by the [`UserId`] the caller passed so results can be correlated. + /// Missing ids are simply absent from the map. + fn get_many(&self, ids: Vec) -> impl Future, RepoError>> + Send; fn register(&self, user: ExternalUser, service_id: i32, consent_info: serde_json::Value) -> impl Future>> + Send; fn get_user_id(&self, service_id: i32, external_id: i64) -> impl Future, RepoError>> + Send; fn update_value(&self, user_id: i64, target: UpdateTarget) -> impl Future>> + Send; @@ -113,6 +126,61 @@ impl Users for UsersPostgres { } } + #[tracing::instrument(skip(self), fields(ids_count = ids.len()))] + async fn get_many(&self, ids: Vec) -> Result, RepoError> { + let (internal, external): (Vec, Vec) = ids.into_iter() + .partition(|id| matches!(id, UserId::Internal(_))); + let internal_ids: Vec = internal.into_iter().map(UserId::value).collect(); + let external_ids: Vec = external.into_iter().map(UserId::value).collect(); + + let internal = if internal_ids.is_empty() { + Vec::new() + } else { + tracing::debug!(count = internal_ids.len(), "Fetching users by internal IDs"); + sqlx::query_as!(UserInternal, + "SELECT id, name, language_code, location, premium_till FROM Users WHERE id = ANY($1)", + &internal_ids) + .fetch_all(&self.pool) + .await? + .into_iter() + .map(|user| SavedUser::try_from(user).map(|u| (UserId::Internal(u.id), u))) + .collect::, _>>() + .map_err(RepoError::Other)? + }; + + let external = if external_ids.is_empty() { + Vec::new() + } else { + tracing::debug!(count = external_ids.len(), "Fetching users by external IDs"); + // Select external_id alongside so we can key results by the requested (external) id. + sqlx::query!( + "SELECT DISTINCT usm.external_id, u.id, u.name, u.language_code, u.location, u.premium_till + FROM Users u + JOIN User_Service_Mappings usm ON u.id = usm.user_id + WHERE usm.external_id = ANY($1)", &external_ids) + .fetch_all(&self.pool) + .await? + .into_iter() + .map(|r| { + let key = UserId::External(r.external_id); + let user = UserInternal { + id: r.id, + name: r.name, + language_code: r.language_code, + location: r.location, + premium_till: r.premium_till, + }; + SavedUser::try_from(user).map(|u| (key, u)) + }) + .collect::, _>>() + .map_err(RepoError::Other)? + }; + + let result: HashMap = internal.into_iter().chain(external).collect(); + tracing::debug!(found = result.len(), "Users fetched from database"); + Ok(result) + } + #[tracing::instrument(skip(self, user, consent_info), fields(external_id = %user.external_id, service_id = %service_id))] async fn register(&self, user: ExternalUser, service_id: i32, consent_info: serde_json::Value) -> Result> { tracing::debug!("Starting user registration transaction"); diff --git a/src/rest/dto/mod.rs b/src/rest/dto/mod.rs index 14da6d3..bb74df7 100644 --- a/src/rest/dto/mod.rs +++ b/src/rest/dto/mod.rs @@ -16,6 +16,19 @@ pub struct RegistrationRequest { pub consent_info: serde_json::Value, } +/// Query parameters for `GET /search`. Internal and external ids are named explicitly and may be +/// mixed in one call (serde_urlencoded has no repeated-key `Vec` support, so each is comma-separated). +#[derive(Deserialize)] +pub struct SearchParams { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub external_id: Option, + /// Comma-separated `FieldMask` paths, e.g. `options.language_code`. + #[serde(default)] + pub fields: Option, +} + #[derive(Clone, FromStr)] pub enum PremiumVariantRest { Month, diff --git a/src/rest/dto/user.rs b/src/rest/dto/user.rs index d749c17..d550f0b 100644 --- a/src/rest/dto/user.rs +++ b/src/rest/dto/user.rs @@ -5,6 +5,7 @@ use crate::dto::{Location, SavedUser}; #[derive(Serialize, Deserialize)] pub struct UserView { id: i64, + #[serde(skip_serializing_if = "Option::is_none")] name: Option, options: Options, is_premium: bool @@ -12,7 +13,9 @@ pub struct UserView { #[derive(Serialize, Deserialize)] pub struct Options { + #[serde(skip_serializing_if = "Option::is_none")] pub language_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub location: Option, } diff --git a/src/rest/service.rs b/src/rest/service.rs index c82e30e..389e251 100644 --- a/src/rest/service.rs +++ b/src/rest/service.rs @@ -10,7 +10,11 @@ use crate::rest::error::RestErrorExt; use crate::repo; use crate::repo::users::{UpdateTarget, UserId, Users}; use crate::repo::services::Services; -use crate::rest::{PremiumActivationResult, PremiumVariantRest, RegistrationRequest, RestError, Success, UserView}; +use crate::rest::{SearchParams, PremiumActivationResult, PremiumVariantRest, RegistrationRequest, RestError, Success, UserView}; + +/// JSON response group names for `GET /search` — users looked up by internal vs external id. +const BY_ID_GROUP: &str = "users_by_id"; +const BY_EXTERNAL_ID_GROUP: &str = "users_by_external_id"; pub fn router(repos: Arc>) -> axum::Router where @@ -20,6 +24,7 @@ where axum::Router::new() .route("/{id}", get(get_user::)) .route("/external/{external_id}", get(get_external_user::)) + .route("/search", get(search_users::)) .route("/external", post(register_user::)) .route("/{id}/language/{code}", patch(update_language::)) .route("/{id}/location/", patch(update_location::)) @@ -66,6 +71,97 @@ where Ok(Json(user)) } +/// A user serialized to JSON, tagged with the [`UserId`] it was requested under. +type KeyedUser = (UserId, serde_json::Value); + +/// Parses a comma-separated id list (from a query parameter) into numeric ids, ignoring blanks. +fn parse_ids(raw: &Option) -> Result, std::num::ParseIntError> { + raw.as_deref().unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::parse::) + .collect() +} + +#[tracing::instrument(skip(repos, params), fields(id = ?params.id, external_id = ?params.external_id))] +async fn search_users( + Extension(repos): Extension>>, + Query(params): Query, +) -> Result, RouteError> +where + U: Users, + S: Services, +{ + let internal_ids = parse_ids(¶ms.id) + .log_route_warn("Invalid 'id' query parameter")?; + let external_ids = parse_ids(¶ms.external_id) + .log_route_warn("Invalid 'external_id' query parameter")?; + let ids: Vec = internal_ids.into_iter().map(UserId::Internal) + .chain(external_ids.into_iter().map(UserId::External)) + .collect(); + + let paths: Option> = params.fields + .filter(|f| !f.trim().is_empty()) + .map(|f| f.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect()); + + let users = repos.users.get_many(ids).await + .log_route_error("Failed to fetch users")?; + + // Split the results into two id-keyed objects: { "internal": {..}, "external": {..} }. + let (internal, external): (Vec, Vec) = users.into_iter() + .map(|(key, user)| { + let mut value = serde_json::to_value(UserView::from(user))?; + if let Some(paths) = &paths { + project_value(&mut value, paths); + } + Ok((key, value)) + }) + .collect::, serde_json::Error>>() + .log_route_error("Failed to serialize users")? + .into_iter() + .partition(|(key, _)| matches!(key, UserId::Internal(_))); + + let to_object = |entries: Vec| serde_json::Value::Object( + entries.into_iter() + .map(|(key, value)| (key.value().to_string(), value)) + .collect() + ); + // Omit a group entirely when it has no results, rather than emitting an empty object. + let response: serde_json::Map = [(BY_ID_GROUP, internal), (BY_EXTERNAL_ID_GROUP, external)] + .into_iter() + .filter(|(_, entries)| !entries.is_empty()) + .map(|(name, entries)| (name.to_owned(), to_object(entries))) + .collect(); + Ok(Json(serde_json::Value::Object(response))) +} + +/// Prunes a JSON value in place, keeping only object keys named in `paths` (and, for keys with +/// nested selections like `options.language_code`, recursing into the selected sub-paths). Mirrors +/// the gRPC-side `project_user` semantics for `FieldMask` projections. +fn project_value(value: &mut serde_json::Value, paths: &[String]) { + let serde_json::Value::Object(map) = value else { return }; + map.retain(|key, child| { + if paths.iter().any(|p| p == key) { + return true; + } + let prefix = format!("{key}."); + let sub_paths: Vec = paths.iter() + .filter_map(|p| p.strip_prefix(&prefix).map(String::from)) + .collect(); + if sub_paths.is_empty() { + false + } else { + project_value(child, &sub_paths); + true + } + }); +} + #[tracing::instrument(skip(repos, req), fields(external_id = %req.user.external_id, service_type = ?req.service.service_type))] async fn register_user( Extension(repos): Extension>>, diff --git a/src/rest/test.rs b/src/rest/test.rs index 3867f4b..487bcc5 100644 --- a/src/rest/test.rs +++ b/src/rest/test.rs @@ -113,6 +113,17 @@ impl UserServiceClient { ).await?; Ok(response) } + + async fn search_users(&self, query: &str) -> anyhow::Result { + let app = self.router.clone(); + let response = app.oneshot( + Request::builder() + .method(http::Method::GET) + .uri(format!("/search?{query}")) + .body(Body::empty())? + ).await?; + Ok(response) + } } #[tokio::test] @@ -162,10 +173,7 @@ async fn test_get_and_create() -> anyhow::Result<()> { assert_eq!(body, json!({ "id": 1, "name": external_user.name.unwrap(), - "options": { - "language_code": null, - "location": null - }, + "options": {}, "is_premium": false })); @@ -222,6 +230,67 @@ async fn test_updates() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn test_search() -> anyhow::Result<()> { + let _ = tracing_subscriber::fmt().with_test_writer().try_init(); + + // two users with distinct languages, keyed by external id (internal ids 1 and 2) + let users = UsersMock::with_data(HashMap::from([ + (111 as ExternalId, SavedUser { + id: 1, name: Some("Alice".to_owned()), + language_code: Some("ru".try_into()?), location: None, premium_till: None, + }), + (222 as ExternalId, SavedUser { + id: 2, name: Some("Bob".to_owned()), + language_code: Some("en".try_into()?), location: None, premium_till: None, + }), + ])); + let client = UserServiceClient::new(repo::Repositories::new(users, ServicesMock::default())); + + // by internal id; results grouped under "users_by_id", a missing id simply omitted. + // The "users_by_external_id" group is absent because none were requested. + let response = client.search_users("id=1,2,999").await?; + assert_eq!(response.status(), StatusCode::OK); + let body = to_json_value(response).await?; + assert_eq!(body, json!({ + "users_by_id": { + "1": {"id": 1, "name": "Alice", "options": {"language_code": "ru"}, "is_premium": false}, + "2": {"id": 2, "name": "Bob", "options": {"language_code": "en"}, "is_premium": false} + } + })); + + // projection: only options.language_code selected; the mask is honoured literally (no forced id), + // correlation is via the map key + let response = client.search_users("id=1,2&fields=options.language_code").await?; + assert_eq!(response.status(), StatusCode::OK); + let body = to_json_value(response).await?; + assert_eq!(body, json!({ + "users_by_id": { + "1": {"options": {"language_code": "ru"}}, + "2": {"options": {"language_code": "en"}} + } + })); + + // mixed internal + external in one call; inner id stays internal + let response = client.search_users("id=1&external_id=222").await?; + assert_eq!(response.status(), StatusCode::OK); + let body = to_json_value(response).await?; + assert_eq!(body, json!({ + "users_by_id": { + "1": {"id": 1, "name": "Alice", "options": {"language_code": "ru"}, "is_premium": false} + }, + "users_by_external_id": { + "222": {"id": 2, "name": "Bob", "options": {"language_code": "en"}, "is_premium": false} + } + })); + + // invalid ids → 400 + let response = client.search_users("id=1,abc").await?; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + Ok(()) +} + async fn to_json_value(response: http::Response) -> anyhow::Result where T: HttpBody, From b390ddba174ce56a5e65bb7de383617d9c723468 Mon Sep 17 00:00:00 2001 From: Leonid Kozarin Date: Sat, 25 Jul 2026 00:57:14 +0300 Subject: [PATCH 2/2] Fix `from_over_into` warnings --- src/dto/user.rs | 12 ++++++------ src/grpc/generated.rs | 8 ++++---- src/rest/dto/mod.rs | 14 +++++++------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/dto/user.rs b/src/dto/user.rs index ae1c56b..cf3e83d 100644 --- a/src/dto/user.rs +++ b/src/dto/user.rs @@ -103,9 +103,9 @@ impl TryFrom<&str> for Code { } } -impl Into for Code { - fn into(self) -> String { - format!("{}{}", self.0[0], self.0[1]) +impl From for String { + fn from(value: Code) -> Self { + format!("{}{}", value.0[0], value.0[1]) } } @@ -118,9 +118,9 @@ pub enum PremiumVariant { Year = 12, } -impl Into> for PremiumVariant { - fn into(self) -> DateTime { - self + Utc::now() +impl From for DateTime { + fn from(value: PremiumVariant) -> Self { + value + Utc::now() } } diff --git a/src/grpc/generated.rs b/src/grpc/generated.rs index 11563c0..d3af745 100644 --- a/src/grpc/generated.rs +++ b/src/grpc/generated.rs @@ -46,11 +46,11 @@ pub fn project_user(user: &mut User, paths: &[String]) { } } -impl Into for ExternalUser { - fn into(self) -> dto::ExternalUser { +impl From for dto::ExternalUser { + fn from(value: ExternalUser) -> Self { dto::ExternalUser { - external_id: self.external_id, - name: self.name, + external_id: value.external_id, + name: value.name, } } } diff --git a/src/rest/dto/mod.rs b/src/rest/dto/mod.rs index bb74df7..3501446 100644 --- a/src/rest/dto/mod.rs +++ b/src/rest/dto/mod.rs @@ -37,13 +37,13 @@ pub enum PremiumVariantRest { Year, } -impl Into for PremiumVariantRest { - fn into(self) -> PremiumVariant { - match self { - Self::Month => PremiumVariant::Month, - Self::Quarter => PremiumVariant::Quarter, - Self::HalfYear => PremiumVariant::HalfYear, - Self::Year => PremiumVariant::Year, +impl From for PremiumVariant { + fn from(value: PremiumVariantRest) -> Self { + match value { + PremiumVariantRest::Month => PremiumVariant::Month, + PremiumVariantRest::Quarter => PremiumVariant::Quarter, + PremiumVariantRest::HalfYear => PremiumVariant::HalfYear, + PremiumVariantRest::Year => PremiumVariant::Year, } } }