Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: app ratings endpoint #15

Merged
merged 3 commits into from
Aug 30, 2023
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
27 changes: 20 additions & 7 deletions proto/ratings_features_app.proto
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,28 @@ syntax = "proto3";
package ratings.features.app;

service App {
rpc GetVotes (GetVotesRequest) returns (GetVotesResponse) {}
rpc GetRating (GetRatingRequest) returns (GetRatingResponse) {}
}

message GetVotesRequest {
string app = 1;
message GetRatingRequest {
string snap_id = 1;
}

message GetVotesResponse {
string app = 1;
uint64 total_up_votes = 2;
uint64 total_down_votes = 3;
message GetRatingResponse {
Rating rating = 1;
}

message Rating {
string snap_id = 1;
uint64 total_votes = 2;
RatingsBand ratings_band = 3;
}

enum RatingsBand {
VERY_GOOD = 0;
GOOD = 1;
NEUTRAL = 2;
POOR = 3;
VERY_POOR = 4;
INSUFFICIENT_VOTES = 5;
}
5 changes: 2 additions & 3 deletions src/app/interfaces/authentication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,9 @@ pub fn authentication(req: Request<()>) -> Result<Request<()>, Status> {
req.extensions_mut().insert(claim);
Ok(req)
}
Err(_) => {
let error = Err(Status::unauthenticated("invalid authz token"));
Err(error) => {
error!("{error:?}");
error
Err(Status::unauthenticated("Failed to decode token."))
}
}
}
5 changes: 3 additions & 2 deletions src/app/interfaces/routes.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use tonic::transport::server::Router;
use tonic_reflection::server::ServerReflection;

use crate::features::user;
use crate::features::{app, user};

pub fn build_reflection_service(
) -> tonic_reflection::server::ServerReflectionServer<impl ServerReflection> {
Expand All @@ -15,6 +15,7 @@ pub fn build_reflection_service(

pub fn build_servers<R>(router: Router<R>) -> Router<R> {
let user_service = user::service::build_service();
let app_service = app::service::build_service();

router.add_service(user_service)
router.add_service(user_service).add_service(app_service)
}
174 changes: 174 additions & 0 deletions src/features/app/entities.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
use sqlx::FromRow;

use super::interface::protobuf;

const INSUFFICIENT_VOTES_QUANTITY: usize = 25;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RatingsBand {
VeryGood = 0,
Good = 1,
Neutral = 2,
Poor = 3,
VeryPoor = 4,
InsufficientVotes = 5,
}

impl RatingsBand {
const GOOD_UPPER: f64 = 0.8;
const NEUTRAL_UPPER: f64 = 0.55;
const POOR_UPPER: f64 = 0.45;
const VERY_POOR_UPPER: f64 = 0.2;

pub fn from_value(value: f64) -> RatingsBand {
if value > Self::GOOD_UPPER {
RatingsBand::VeryGood
} else if value <= Self::GOOD_UPPER && value > Self::NEUTRAL_UPPER {
RatingsBand::Good
} else if value <= Self::NEUTRAL_UPPER && value > Self::POOR_UPPER {
RatingsBand::Neutral
} else if value <= Self::POOR_UPPER && value > Self::VERY_POOR_UPPER {
RatingsBand::Poor
} else {
RatingsBand::VeryPoor
}
}
}

#[derive(Debug, Clone, FromRow)]
pub struct Rating {
pub snap_id: String,
pub total_votes: u64,
pub ratings_band: RatingsBand,
}

impl Rating {
pub fn new(snap_id: String, votes: Vec<Vote>) -> Self {
let total_votes = votes.len();
let ratings_band = calculate_band(votes);
Self {
snap_id,
total_votes: total_votes as u64,
ratings_band,
}
}

pub(crate) fn into_dto(self) -> protobuf::Rating {
protobuf::Rating {
snap_id: self.snap_id,
total_votes: self.total_votes,
ratings_band: self.ratings_band as i32,
}
}
}

#[derive(Debug, Clone)]
pub struct Vote {
pub vote_up: bool,
}

fn calculate_band(votes: Vec<Vote>) -> RatingsBand {
let total_ratings = votes.len();
if total_ratings < INSUFFICIENT_VOTES_QUANTITY {
return RatingsBand::InsufficientVotes;
}
let positive_ratings = votes
.into_iter()
.filter(|vote| vote.vote_up)
.collect::<Vec<Vote>>()
.len();
let adjusted_ratio = confidence_interval_lower_bound(positive_ratings, total_ratings);

RatingsBand::from_value(adjusted_ratio)
}

fn confidence_interval_lower_bound(positive_ratings: usize, total_ratings: usize) -> f64 {
if total_ratings == 0 {
return 0.0;
}

// Lower Bound of Wilson Score Confidence Interval for Ranking Snaps
//
// Purpose:
// Provides a conservative adjusted rating for a Snap by offsetting the
// actual ratio of positive votes. It penalizes Snaps with fewer ratings
// more heavily to produce an adjusted ranking that approaches the mean as
// ratings increase.
//
// Algorithm:
// Starts with the observed proportion of positive ratings, adjusts it based on
// total ratings, and incorporates a 95% confidence interval Z-score for
// uncertainty.
//
// References:
// - https://www.evanmiller.org/how-not-to-sort-by-average-rating.html
// - https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Wilson_score_interval

let z_score: f64 = 1.96; // hardcoded for a ~95% confidence
let total_ratings = total_ratings as f64;
let positive_ratings_ratio = positive_ratings as f64 / total_ratings;
((positive_ratings_ratio + (z_score * z_score) / (2.0 * total_ratings))
- z_score
* f64::sqrt(
(positive_ratings_ratio * (1.0 - positive_ratings_ratio)
+ ((z_score * z_score) / (4.0 * total_ratings)))
/ total_ratings,
))
/ (1.0 + (z_score * z_score) / total_ratings)
}

#[cfg(test)]
mod tests {
matthew-hagemann marked this conversation as resolved.
Show resolved Hide resolved
use super::*;

#[test]
fn test_zero() {
let lower_bound = confidence_interval_lower_bound(0, 0);
assert_eq!(
lower_bound, 0.0,
"Lower bound should be 0.0 when there are 0 votes"
);
}

#[test]
fn test_lb_approaches_true_ratio() {
let ratio: f64 = 0.9;
let mut last_lower_bound = 0.0;

for total_ratings in (100..1000).step_by(100) {
let positive_ratings = (total_ratings as f64 * ratio).round() as usize;
let new_lower_bound = confidence_interval_lower_bound(positive_ratings, total_ratings);
let raw_positive_ratio = positive_ratings as f64 / total_ratings as f64;

// As the total ratings increase, the new lower bound should be closer to the raw positive ratio.
assert!(
(raw_positive_ratio - new_lower_bound).abs() <= (raw_positive_ratio - last_lower_bound).abs(),
"As the number of votes goes up, the lower bound should get closer to the raw positive ratio."
);

last_lower_bound = new_lower_bound;
}
}

#[test]
fn test_insufficient_votes() {
let votes = vec![Vote { vote_up: true }];
let band = calculate_band(votes);
assert_eq!(
band,
RatingsBand::InsufficientVotes,
"Should return InsufficientVotes when not enough votes exist for a given Snap."
)
}

#[test]
fn test_sufficient_votes() {
let votes = vec![Vote { vote_up: true }; INSUFFICIENT_VOTES_QUANTITY];
let band = calculate_band(votes);
assert_eq!(
band,
RatingsBand::VeryGood,
"Should return very good for a sufficient number of all positive votes."
)
}
}
9 changes: 9 additions & 0 deletions src/features/app/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
#[error("failed to get rating for snap")]
FailedToGetRating,
#[error("unknown user error")]
Unknown,
}
47 changes: 47 additions & 0 deletions src/features/app/infrastructure.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use crate::app::AppContext;
use sqlx::Row;
use tracing::error;

use super::{entities::Vote, errors::AppError};

pub(crate) async fn get_votes_by_snap_id(
app_ctx: &AppContext,
snap_id: &str,
) -> Result<Vec<Vote>, AppError> {
let mut pool = app_ctx
.infrastructure()
.repository()
.await
.map_err(|error| {
error!("{error:?}");
AppError::FailedToGetRating
})?;
let result = sqlx::query(
r#"
SELECT
votes.id,
votes.snap_id,
votes.vote_up
FROM
votes
WHERE
votes.snap_id = $1
"#,
)
.bind(snap_id)
.fetch_all(&mut *pool)
.await
.map_err(|error| {
error!("{error:?}");
AppError::Unknown
})?;

let votes: Vec<Vote> = result
.into_iter()
.map(|row| Vote {
vote_up: row.get("vote_up"),
})
.collect();

Ok(votes)
}
42 changes: 42 additions & 0 deletions src/features/app/interface.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
use crate::app::AppContext;

use self::protobuf::{App, GetRatingRequest, GetRatingResponse};
pub use protobuf::app_server;
use tonic::{Request, Response, Status};

use super::{service::AppService, use_cases};

pub mod protobuf {
pub use self::app_server::App;

tonic::include_proto!("ratings.features.app");
}

#[tonic::async_trait]
impl App for AppService {
#[tracing::instrument]
async fn get_rating(
&self,
request: Request<GetRatingRequest>,
) -> Result<Response<GetRatingResponse>, Status> {
let app_ctx = request.extensions().get::<AppContext>().unwrap().clone();

let GetRatingRequest { snap_id } = request.into_inner();

if snap_id.is_empty() {
return Err(Status::invalid_argument("snap id"));
}

let result = use_cases::get_rating(&app_ctx, snap_id).await;

match result {
Ok(rating) => {
let payload = GetRatingResponse {
rating: Some(rating.into_dto()),
};
Ok(Response::new(payload))
}
Err(_error) => Err(Status::unknown("Internal server error")),
}
}
}
6 changes: 6 additions & 0 deletions src/features/app/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pub mod entities;
mod errors;
mod infrastructure;
pub mod interface;
pub mod service;
mod use_cases;
9 changes: 9 additions & 0 deletions src/features/app/service.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use super::interface::app_server::AppServer;

#[derive(Debug, Default)]
pub struct AppService;

pub fn build_service() -> AppServer<AppService> {
let service = AppService;
AppServer::new(service)
}
17 changes: 17 additions & 0 deletions src/features/app/use_cases.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
use crate::app::AppContext;
use tracing::error;

use super::{entities::Rating, errors::AppError, infrastructure::get_votes_by_snap_id};

pub async fn get_rating(app_ctx: &AppContext, snap_id: String) -> Result<Rating, AppError> {
let votes = get_votes_by_snap_id(app_ctx, &snap_id)
.await
.map_err(|error| {
error!("{error:?}");
AppError::Unknown
})?;

let rating = Rating::new(snap_id, votes);

Ok(rating)
}
1 change: 1 addition & 0 deletions src/features/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod app;
pub mod user;
2 changes: 1 addition & 1 deletion src/features/user/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ use super::interface::user_server::UserServer;
pub struct UserService;

pub fn build_service() -> UserServer<UserService> {
let service = UserService::default();
let service = UserService;
UserServer::new(service)
}
Loading
Loading