Skip to content

Commit c3126cb

Browse files
committed
feat: add project features
1 parent c007202 commit c3126cb

3 files changed

Lines changed: 241 additions & 2 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ async-trait = "0.1"
1616

1717
# Serialization
1818
serde = { version = "1", features = ["derive", "rc"] }
19+
serde_json = "1"
1920

2021
## Misc
2122
bitflags = "2.4"

src/project/types/project_data.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,28 @@ pub struct PlanLimits {
6464
pub is_above_mau_limit: bool,
6565
}
6666

67+
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone)]
68+
#[serde(rename_all = "camelCase")]
69+
pub struct Feature {
70+
pub id: String,
71+
pub is_enabled: bool,
72+
pub config: Option<serde_json::Value>,
73+
}
74+
75+
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone)]
76+
#[serde(rename_all = "camelCase")]
77+
pub struct FeaturesResponse {
78+
pub features: Vec<Feature>,
79+
}
80+
81+
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone)]
82+
#[serde(rename_all = "camelCase")]
83+
pub struct ProjectDataWithLimitsAndFeatures {
84+
pub data: ProjectData,
85+
pub limits: PlanLimits,
86+
pub features: Vec<Feature>,
87+
}
88+
6789
impl ProjectData {
6890
pub fn validate_access(
6991
&self,

src/registry/client.rs

Lines changed: 218 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
use {
22
crate::{
3-
project::{PlanLimits, ProjectData, ProjectDataWithLimits, ProjectDataWithQuota},
3+
project::{
4+
FeaturesResponse, PlanLimits, ProjectData, ProjectDataWithLimits,
5+
ProjectDataWithLimitsAndFeatures, ProjectDataWithQuota,
6+
},
47
registry::error::RegistryError,
58
},
69
async_trait::async_trait,
@@ -38,6 +41,11 @@ pub trait RegistryClient: 'static + Send + Sync + Debug {
3841
&self,
3942
id: &str,
4043
) -> RegistryResult<Option<ProjectDataWithLimits>>;
44+
async fn project_features(&self, id: &str) -> RegistryResult<Option<FeaturesResponse>>;
45+
async fn project_data_with_limits_and_features(
46+
&self,
47+
id: &str,
48+
) -> RegistryResult<Option<ProjectDataWithLimitsAndFeatures>>;
4149
}
4250

4351
/// HTTP client configuration.
@@ -92,6 +100,7 @@ impl RegistryHttpClient {
92100
) -> RegistryResult<Self> {
93101
Self::with_config(
94102
base_explorer_url,
103+
None::<&str>,
95104
auth_token,
96105
origin,
97106
st,
@@ -102,6 +111,7 @@ impl RegistryHttpClient {
102111

103112
pub fn with_config(
104113
base_explorer_url: impl IntoUrl,
114+
base_internal_api_url: Option<impl IntoUrl>,
105115
auth_token: &str,
106116
origin: &str,
107117
st: &str,
@@ -132,11 +142,16 @@ impl RegistryHttpClient {
132142
http_client = http_client.connect_timeout(timeout).timeout(timeout);
133143
}
134144

145+
let internal_api_url = match base_internal_api_url {
146+
Some(url) => url.into_url().map_err(RegistryError::BaseUrlIntoUrl)?,
147+
None => INTERNAL_API_BASE_URI.clone(),
148+
};
149+
135150
Ok(Self {
136151
base_explorer_url: base_explorer_url
137152
.into_url()
138153
.map_err(RegistryError::BaseUrlIntoUrl)?,
139-
base_internal_api_url: INTERNAL_API_BASE_URI.clone(),
154+
base_internal_api_url: internal_api_url,
140155
http_client: http_client.build().map_err(RegistryError::BuildClient)?,
141156
st: st.to_string(),
142157
sv: sv.to_string(),
@@ -205,6 +220,48 @@ impl RegistryHttpClient {
205220

206221
Ok(Some(ProjectDataWithLimits { data, limits }))
207222
}
223+
224+
async fn project_features_impl<T: DeserializeOwned>(
225+
&self,
226+
project_id: &str,
227+
) -> RegistryResult<Option<T>> {
228+
if !is_valid_project_id(project_id) {
229+
return Ok(None);
230+
}
231+
232+
let url = build_features_url(&self.base_internal_api_url, project_id, &self.st, &self.sv)
233+
.map_err(RegistryError::UrlBuild)?;
234+
235+
let resp = self
236+
.http_client
237+
.get(url)
238+
.send()
239+
.await
240+
.map_err(RegistryError::Transport)?;
241+
242+
parse_http_response(resp).await
243+
}
244+
245+
async fn project_data_with_limits_and_features_impl(
246+
&self,
247+
project_id: &str,
248+
) -> RegistryResult<Option<ProjectDataWithLimitsAndFeatures>> {
249+
let data_with_limits = match self.project_data_with_limits_impl(project_id).await? {
250+
Some(data_with_limits) => data_with_limits,
251+
None => return Ok(None),
252+
};
253+
254+
let features_response: FeaturesResponse = match self.project_features(project_id).await? {
255+
Some(response) => response,
256+
None => return Ok(None),
257+
};
258+
259+
Ok(Some(ProjectDataWithLimitsAndFeatures {
260+
data: data_with_limits.data,
261+
limits: data_with_limits.limits,
262+
features: features_response.features,
263+
}))
264+
}
208265
}
209266

210267
#[async_trait]
@@ -230,6 +287,17 @@ impl RegistryClient for RegistryHttpClient {
230287
) -> RegistryResult<Option<ProjectDataWithLimits>> {
231288
self.project_data_with_limits_impl(project_id).await
232289
}
290+
291+
async fn project_features(&self, project_id: &str) -> RegistryResult<Option<FeaturesResponse>> {
292+
self.project_features_impl(project_id).await
293+
}
294+
295+
async fn project_data_with_limits_and_features(
296+
&self,
297+
project_id: &str,
298+
) -> RegistryResult<Option<ProjectDataWithLimitsAndFeatures>> {
299+
self.project_data_with_limits_and_features_impl(project_id).await
300+
}
233301
}
234302

235303
fn build_explorer_url(
@@ -257,6 +325,19 @@ fn build_internal_api_url(
257325
Ok(url)
258326
}
259327

328+
fn build_features_url(
329+
base_url: &Url,
330+
project_id: &str,
331+
st: &str,
332+
sv: &str,
333+
) -> Result<Url, url::ParseError> {
334+
let mut url = base_url.join("/appkit/v1/config")?;
335+
url.query_pairs_mut().append_pair("projectId", project_id);
336+
url.query_pairs_mut().append_pair("st", st);
337+
url.query_pairs_mut().append_pair("sv", sv);
338+
Ok(url)
339+
}
340+
260341
/// Checks if the project ID is formatted properly. It must be 32 hex
261342
/// characters.
262343
fn is_valid_project_id(project_id: &str) -> bool {
@@ -478,4 +559,139 @@ mod test {
478559
"http://example.com/internal/project/key/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?quotas=true"
479560
);
480561
}
562+
563+
#[test]
564+
fn test_build_features_url() {
565+
let base_url = Url::parse("http://example.com").unwrap();
566+
let project_id = "a".repeat(32);
567+
568+
let url = build_features_url(&base_url, &project_id, "blockchain-api", "1.0.0").unwrap();
569+
assert_eq!(
570+
url.as_str(),
571+
"http://example.com/appkit/v1/config?projectId=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&st=blockchain-api&sv=1.0.0"
572+
);
573+
}
574+
575+
fn mock_features_response() -> FeaturesResponse {
576+
FeaturesResponse {
577+
features: vec![
578+
crate::project::Feature {
579+
id: "multi_wallet".to_string(),
580+
is_enabled: false,
581+
config: Some(serde_json::json!([])),
582+
},
583+
crate::project::Feature {
584+
id: "social_login".to_string(),
585+
is_enabled: true,
586+
config: None,
587+
},
588+
],
589+
}
590+
}
591+
592+
#[tokio::test]
593+
async fn project_features_exist() {
594+
let project_id = "a".repeat(32);
595+
let mock_server = MockServer::start().await;
596+
597+
Mock::given(method(Method::Get))
598+
.and(path("/appkit/v1/config"))
599+
.and(query_param("projectId", project_id.clone()))
600+
.and(query_param("st", "st"))
601+
.and(query_param("sv", "sv"))
602+
.respond_with(
603+
ResponseTemplate::new(StatusCode::OK).set_body_json(mock_features_response()),
604+
)
605+
.mount(&mock_server)
606+
.await;
607+
608+
let response = RegistryHttpClient::with_config(
609+
mock_server.uri(),
610+
Some(mock_server.uri()),
611+
"auth",
612+
TEST_ORIGIN,
613+
"st",
614+
"sv",
615+
Default::default(),
616+
)
617+
.unwrap()
618+
.project_features(&project_id)
619+
.await
620+
.unwrap();
621+
assert!(response.is_some());
622+
let features = response.unwrap();
623+
assert_eq!(features.features.len(), 2);
624+
assert_eq!(features.features[0].id, "multi_wallet");
625+
assert!(!features.features[0].is_enabled);
626+
assert_eq!(features.features[1].id, "social_login");
627+
assert!(features.features[1].is_enabled);
628+
}
629+
630+
#[tokio::test]
631+
async fn project_data_with_limits_and_features_exists() {
632+
let project_id = "a".repeat(32);
633+
let mock_server = MockServer::start().await;
634+
635+
// Mock project data endpoint
636+
Mock::given(method(Method::Get))
637+
.and(path(format!("/internal/project/key/{project_id}")))
638+
.respond_with(ResponseTemplate::new(StatusCode::OK).set_body_json(mock_project_data()))
639+
.mount(&mock_server)
640+
.await;
641+
642+
// Mock project limits endpoint
643+
Mock::given(method(Method::Get))
644+
.and(path("/internal/v1/project-limits"))
645+
.and(query_param("projectId", project_id.clone()))
646+
.and(query_param("st", "st"))
647+
.and(query_param("sv", "sv"))
648+
.respond_with(
649+
ResponseTemplate::new(StatusCode::OK).set_body_json(LimitsResponse {
650+
plan_limits: crate::project::PlanLimits {
651+
tier: "free".to_string(),
652+
is_above_rpc_limit: false,
653+
is_above_mau_limit: false,
654+
},
655+
}),
656+
)
657+
.mount(&mock_server)
658+
.await;
659+
660+
// Mock features endpoint
661+
Mock::given(method(Method::Get))
662+
.and(path("/appkit/v1/config"))
663+
.and(query_param("projectId", project_id.clone()))
664+
.and(query_param("st", "st"))
665+
.and(query_param("sv", "sv"))
666+
.respond_with(
667+
ResponseTemplate::new(StatusCode::OK).set_body_json(mock_features_response()),
668+
)
669+
.mount(&mock_server)
670+
.await;
671+
672+
let response = RegistryHttpClient::with_config(
673+
mock_server.uri(),
674+
Some(mock_server.uri()),
675+
"auth",
676+
TEST_ORIGIN,
677+
"st",
678+
"sv",
679+
Default::default(),
680+
)
681+
.unwrap()
682+
.project_data_with_limits_and_features(&project_id)
683+
.await
684+
.unwrap();
685+
686+
assert!(response.is_some());
687+
let data = response.unwrap();
688+
assert_eq!(data.limits.tier, "free");
689+
assert!(!data.limits.is_above_rpc_limit);
690+
assert!(!data.limits.is_above_mau_limit);
691+
assert_eq!(data.features.len(), 2);
692+
assert_eq!(data.features[0].id, "multi_wallet");
693+
assert_eq!(data.features[1].id, "social_login");
694+
assert!(!data.features[0].is_enabled);
695+
assert!(data.features[1].is_enabled);
696+
}
481697
}

0 commit comments

Comments
 (0)