Skip to content

Commit 24d3b29

Browse files
authored
MPP-4493 - feat(relay): add fetch_profile method to check premium subscription status (#7113)
Add a new fetch_profile() method to RelayClient that retrieves the authenticated user's profile from the /api/v1/profiles/ endpoint. This enables mobile clients to determine premium subscription status for feature gating and UI customization.
1 parent 3a1dc8d commit 24d3b29

File tree

3 files changed

+298
-0
lines changed

3 files changed

+298
-0
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
[Full Changelog](In progress)
44

5+
### Relay
6+
- Added `fetch_profile()` method to check premium subscription status via `has_premium` field ([#7113](https://github.com/mozilla/application-services/pull/7113))
7+
58
### Nimbus
69

710
### ⚠️ Breaking Changes ⚠️

components/relay/src/lib.rs

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,48 @@ pub struct RelayAddress {
6363
pub num_spam: i64,
6464
}
6565

66+
/// Represents a bounce status object nested within the profile.
67+
#[derive(Debug, Deserialize, uniffi::Record)]
68+
pub struct BounceStatus {
69+
pub paused: bool,
70+
#[serde(rename = "type")]
71+
pub bounce_type: String,
72+
}
73+
74+
/// Represents a Relay user profile returned by the Relay API.
75+
///
76+
/// Contains information about the user's subscription status, usage statistics,
77+
/// and account settings.
78+
///
79+
/// See: https://mozilla.github.io/fx-private-relay/api_docs.html#tag/privaterelay/operation/profiles_retrieve
80+
#[derive(Debug, Deserialize, uniffi::Record)]
81+
pub struct RelayProfile {
82+
pub id: i64,
83+
pub server_storage: bool,
84+
pub store_phone_log: bool,
85+
pub subdomain: Option<String>,
86+
pub has_premium: bool,
87+
pub has_phone: bool,
88+
pub has_vpn: bool,
89+
pub has_megabundle: bool,
90+
pub onboarding_state: i64,
91+
pub onboarding_free_state: i64,
92+
pub date_phone_registered: Option<String>,
93+
pub date_subscribed: Option<String>,
94+
pub avatar: Option<String>,
95+
pub next_email_try: String,
96+
pub bounce_status: BounceStatus,
97+
pub api_token: String,
98+
pub emails_blocked: i64,
99+
pub emails_forwarded: i64,
100+
pub emails_replied: i64,
101+
pub level_one_trackers_blocked: i64,
102+
pub remove_level_one_email_trackers: Option<bool>,
103+
pub total_masks: i64,
104+
pub at_mask_limit: bool,
105+
pub metrics_enabled: bool,
106+
}
107+
66108
#[derive(Debug, Serialize)]
67109
struct CreateAddressPayload<'a> {
68110
enabled: bool,
@@ -211,6 +253,47 @@ impl RelayClient {
211253
let address: RelayAddress = response.json()?;
212254
Ok(address)
213255
}
256+
257+
/// Retrieves the profile for the authenticated user.
258+
///
259+
/// Returns a [`RelayProfile`] object containing subscription status, usage statistics,
260+
/// and account settings. The `has_premium` field indicates whether the user has
261+
/// an active premium subscription.
262+
///
263+
/// ## Errors
264+
///
265+
/// - `RelayApi`: Returned for any non-successful (non-2xx) HTTP response.
266+
/// Provides the HTTP `status` and response `body`; downstream consumers can inspect
267+
/// these fields. If the response body is JSON with `error_code` or `detail` fields,
268+
/// these are parsed and included for more granular handling; otherwise, the raw
269+
/// response text is used as the error detail.
270+
/// - `Network`: Returned for transport-level failures, like loss of connectivity,
271+
/// with details in `reason`.
272+
/// - Other variants may be returned for unexpected deserialization, URL, or backend errors.
273+
#[handle_error(Error)]
274+
pub fn fetch_profile(&self) -> ApiResult<RelayProfile> {
275+
let url = self.build_url("/api/v1/profiles/")?;
276+
let request = self.prepare_request(Method::Get, url)?;
277+
278+
let response = request.send()?;
279+
let status = response.status;
280+
let body = response.text();
281+
log::trace!("response text: {}", body);
282+
283+
if status >= 400 {
284+
return Err(Error::RelayApi {
285+
status,
286+
body: body.to_string(),
287+
});
288+
}
289+
290+
// The API returns an array with a single profile object for the authenticated user
291+
let profiles: Vec<RelayProfile> = response.json()?;
292+
profiles.into_iter().next().ok_or_else(|| Error::RelayApi {
293+
status: 200,
294+
body: "No profile found for authenticated user".to_string(),
295+
})
296+
}
214297
}
215298

216299
#[cfg(test)]
@@ -537,4 +620,184 @@ mod tests {
537620
assert_eq!(address.generated_for, "example.com");
538621
assert!(address.enabled);
539622
}
623+
624+
fn mock_profile_json(
625+
id: i64,
626+
has_premium: bool,
627+
subdomain: Option<&str>,
628+
total_masks: i64,
629+
at_mask_limit: bool,
630+
emails_forwarded: i64,
631+
emails_blocked: i64,
632+
) -> String {
633+
let subdomain_json = subdomain
634+
.map(|s| format!(r#""{}""#, s))
635+
.unwrap_or_else(|| "null".to_string());
636+
let date_subscribed = if has_premium {
637+
r#""2023-01-10T08:00:00Z""#
638+
} else {
639+
"null"
640+
};
641+
let date_phone_registered = if has_premium {
642+
r#""2023-01-15T10:30:00Z""#
643+
} else {
644+
"null"
645+
};
646+
let avatar = if has_premium {
647+
r#""https://example.com/avatar.png""#
648+
} else {
649+
"null"
650+
};
651+
let remove_level_one_email_trackers = if has_premium { "true" } else { "null" };
652+
653+
format!(
654+
r#"
655+
[
656+
{{
657+
"id": {id},
658+
"server_storage": {has_premium},
659+
"store_phone_log": {has_premium},
660+
"subdomain": {subdomain_json},
661+
"has_premium": {has_premium},
662+
"has_phone": {has_premium},
663+
"has_vpn": false,
664+
"has_megabundle": false,
665+
"onboarding_state": 5,
666+
"onboarding_free_state": 0,
667+
"date_phone_registered": {date_phone_registered},
668+
"date_subscribed": {date_subscribed},
669+
"avatar": {avatar},
670+
"next_email_try": "2023-12-01T00:00:00Z",
671+
"bounce_status": {{
672+
"paused": false,
673+
"type": "none"
674+
}},
675+
"api_token": "550e8400-e29b-41d4-a716-446655440000",
676+
"emails_blocked": {emails_blocked},
677+
"emails_forwarded": {emails_forwarded},
678+
"emails_replied": 10,
679+
"level_one_trackers_blocked": 42,
680+
"remove_level_one_email_trackers": {remove_level_one_email_trackers},
681+
"total_masks": {total_masks},
682+
"at_mask_limit": {at_mask_limit},
683+
"metrics_enabled": true
684+
}}
685+
]
686+
"#
687+
)
688+
}
689+
690+
#[test]
691+
fn test_fetch_profile_premium_user() {
692+
viaduct_dev::init_backend_dev();
693+
694+
let profile_json = mock_profile_json(123, true, Some("testuser"), 15, false, 150, 25);
695+
696+
let _mock = mock("GET", "/api/v1/profiles/")
697+
.with_status(200)
698+
.with_header("content-type", "application/json")
699+
.with_body(profile_json)
700+
.create();
701+
702+
let client = RelayClient::new(mockito::server_url(), Some("mock_token".to_string()));
703+
704+
let profile = client
705+
.expect("success")
706+
.fetch_profile()
707+
.expect("should fetch profile");
708+
709+
assert_eq!(profile.id, 123);
710+
assert!(profile.has_premium);
711+
assert_eq!(profile.total_masks, 15);
712+
assert!(!profile.at_mask_limit);
713+
assert_eq!(profile.subdomain, Some("testuser".to_string()));
714+
assert!(profile.has_phone);
715+
assert!(!profile.has_vpn);
716+
assert_eq!(profile.emails_forwarded, 150);
717+
assert_eq!(profile.emails_blocked, 25);
718+
}
719+
720+
#[test]
721+
fn test_fetch_profile_free_user() {
722+
viaduct_dev::init_backend_dev();
723+
724+
let profile_json = mock_profile_json(456, false, None, 5, true, 20, 5);
725+
726+
let _mock = mock("GET", "/api/v1/profiles/")
727+
.with_status(200)
728+
.with_header("content-type", "application/json")
729+
.with_body(profile_json)
730+
.create();
731+
732+
let client = RelayClient::new(mockito::server_url(), Some("mock_token".to_string()));
733+
734+
let profile = client
735+
.expect("success")
736+
.fetch_profile()
737+
.expect("should fetch profile");
738+
739+
assert_eq!(profile.id, 456);
740+
assert!(!profile.has_premium);
741+
assert_eq!(profile.total_masks, 5);
742+
assert!(profile.at_mask_limit);
743+
assert_eq!(profile.subdomain, None);
744+
assert!(!profile.has_phone);
745+
assert_eq!(profile.date_subscribed, None);
746+
}
747+
748+
#[test]
749+
fn test_fetch_profile_unauthorized() {
750+
viaduct_dev::init_backend_dev();
751+
752+
let error_json = r#"{"detail": "Authentication credentials were not provided."}"#;
753+
let _mock = mock("GET", "/api/v1/profiles/")
754+
.with_status(403)
755+
.with_header("content-type", "application/json")
756+
.with_body(error_json)
757+
.create();
758+
759+
let client = RelayClient::new(mockito::server_url(), None);
760+
let result = client.expect("success").fetch_profile();
761+
762+
match result {
763+
Err(RelayApiError::Api {
764+
status,
765+
code,
766+
detail,
767+
}) => {
768+
assert_eq!(status, 403);
769+
assert_eq!(code, "unknown");
770+
assert_eq!(detail, "Authentication credentials were not provided.");
771+
}
772+
other => panic!("Expected RelayApiError::Api but got {:?}", other),
773+
}
774+
}
775+
776+
#[test]
777+
fn test_fetch_profile_invalid_token() {
778+
viaduct_dev::init_backend_dev();
779+
780+
let error_json = r#"{"error_code": "invalid_token", "detail": "Invalid FXA token."}"#;
781+
let _mock = mock("GET", "/api/v1/profiles/")
782+
.with_status(401)
783+
.with_header("content-type", "application/json")
784+
.with_body(error_json)
785+
.create();
786+
787+
let client = RelayClient::new(mockito::server_url(), Some("bad_token".to_string()));
788+
let result = client.expect("success").fetch_profile();
789+
790+
match result {
791+
Err(RelayApiError::Api {
792+
status,
793+
code,
794+
detail,
795+
}) => {
796+
assert_eq!(status, 401);
797+
assert_eq!(code, "invalid_token");
798+
assert_eq!(detail, "Invalid FXA token.");
799+
}
800+
other => panic!("Expected RelayApiError::Api but got {:?}", other),
801+
}
802+
}
540803
}

examples/relay-cli/src/main.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ struct Cli {
1919

2020
#[derive(Debug, Subcommand)]
2121
enum Commands {
22+
/// Fetch all Relay addresses
2223
Fetch,
24+
/// Fetch user profile information
25+
Profile,
2326
}
2427

2528
fn main() -> anyhow::Result<()> {
@@ -33,6 +36,7 @@ fn main() -> anyhow::Result<()> {
3336

3437
match cli.command {
3538
Commands::Fetch => fetch_addresses(client?),
39+
Commands::Profile => fetch_profile(client?),
3640
}
3741
}
3842

@@ -70,3 +74,31 @@ fn fetch_addresses(client: RelayClient) -> anyhow::Result<()> {
7074
}
7175
Ok(())
7276
}
77+
78+
fn fetch_profile(client: RelayClient) -> anyhow::Result<()> {
79+
match client.fetch_profile() {
80+
Ok(profile) => {
81+
println!("User Profile:");
82+
println!(" ID: {}", profile.id);
83+
println!(" Premium: {}", profile.has_premium);
84+
println!(" Phone: {}", profile.has_phone);
85+
println!(" VPN: {}", profile.has_vpn);
86+
println!(" Total Masks: {}", profile.total_masks);
87+
println!(" At Mask Limit: {}", profile.at_mask_limit);
88+
println!(" Emails Forwarded: {}", profile.emails_forwarded);
89+
println!(" Emails Blocked: {}", profile.emails_blocked);
90+
println!(" Emails Replied: {}", profile.emails_replied);
91+
println!(" Trackers Blocked: {}", profile.level_one_trackers_blocked);
92+
if let Some(subdomain) = profile.subdomain {
93+
println!(" Subdomain: {}", subdomain);
94+
}
95+
if let Some(date_subscribed) = profile.date_subscribed {
96+
println!(" Subscribed Since: {}", date_subscribed);
97+
}
98+
}
99+
Err(e) => {
100+
eprintln!("Failed to fetch profile: {:?}", e);
101+
}
102+
}
103+
Ok(())
104+
}

0 commit comments

Comments
 (0)