Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

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

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

1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion proto
Submodule proto updated 2 files
+14 −0 reqresp.proto
+1 −0 service.proto
12 changes: 6 additions & 6 deletions src/dto/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ impl TryFrom<&str> for Code {
}
}

impl Into<String> for Code {
fn into(self) -> String {
format!("{}{}", self.0[0], self.0[1])
impl From<Code> for String {
fn from(value: Code) -> Self {
format!("{}{}", value.0[0], value.0[1])
}
}

Expand All @@ -118,9 +118,9 @@ pub enum PremiumVariant {
Year = 12,
}

impl Into<DateTime<Utc>> for PremiumVariant {
fn into(self) -> DateTime<Utc> {
self + Utc::now()
impl From<PremiumVariant> for DateTime<Utc> {
fn from(value: PremiumVariant) -> Self {
value + Utc::now()
}
}

Expand Down
46 changes: 42 additions & 4 deletions src/grpc/generated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,49 @@ use crate::repo::users::UpdateTarget;

tonic::include_proto!("user_service");

impl Into<dto::ExternalUser> for ExternalUser {
fn into(self) -> dto::ExternalUser {
/// 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 From<ExternalUser> 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,
}
}
}
Expand Down
26 changes: 25 additions & 1 deletion src/grpc/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<GetUsersRequest>) -> Result<Response<GetUsersResponse>, 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("")
Expand Down
39 changes: 34 additions & 5 deletions src/grpc/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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(())
}

Expand Down
20 changes: 20 additions & 0 deletions src/repo/test/export/mocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@ impl Users for UsersMock {
}
}

async fn get_many(&self, ids: Vec<UserId>) -> Result<HashMap<UserId, SavedUser>, RepoError<TypeConversionError>> {
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<i64, RepoError<TypeConversionError>> {
tracing::info!("UsersMock:register: {user:?} (service_id = {service_id})");
let id = self.gen_id().await;
Expand Down
Loading
Loading