11use {
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
235303fn 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.
262343fn 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