Skip to content
206 changes: 206 additions & 0 deletions wp_api/src/wp_com/domains.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::{
decimal2::Decimal2,
impl_as_query_value_for_new_type,
url_query::{AppendUrlQueryPairs, QueryPairs, QueryPairsExtension},
wp_com::segments::SegmentId,
};
Expand Down Expand Up @@ -150,6 +151,134 @@ pub struct DomainPolicyNotice {
pub message: String,
}

impl_as_query_value_for_new_type!(CountryCode);
uniffi::custom_newtype!(CountryCode, String);
/// ISO 3166-1 alpha-2 country code (e.g. `"US"`, `"CA"`, `"GB"`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CountryCode(pub String);

impl std::fmt::Display for CountryCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}

impl From<&str> for CountryCode {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}

/// Structured response from `GET /domains/supported-countries`.
///
/// The raw API response is a flat array where a sentinel entry (empty
/// `code`/`name`, `has_postal_codes: false`) separates "featured" countries
/// from the full alphabetical list. This type deserializes that array and
/// splits it into two vectors, filtering out the sentinel.
///
/// If no sentinel is found the full list is placed in `all` and `featured`
/// is empty.
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
#[serde(from = "Vec<SupportedCountryEntry>")]
pub struct SupportedCountries {
/// Countries the API surfaces at the top of the picker, in the API's
/// priority order (not alphabetical).
pub featured: Vec<SupportedCountry>,
/// Every supported country, alphabetized by localized name.
pub all: Vec<SupportedCountry>,
}

impl From<Vec<SupportedCountryEntry>> for SupportedCountries {
fn from(mut entries: Vec<SupportedCountryEntry>) -> Self {
let into_countries = |v: Vec<SupportedCountryEntry>| {
v.into_iter()
.filter_map(|e| match e {
SupportedCountryEntry::Country(c) => Some(c),
SupportedCountryEntry::Divider { .. } => None,
})
.collect()
};

let divider_pos = entries
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If more than one separator is found, we just use the position of the first one and subsequent ones are filtered out. This seems fine.

.iter()
.position(|e| matches!(e, SupportedCountryEntry::Divider { .. }));

match divider_pos {
Some(pos) => {
let all_entries = entries.split_off(pos + 1);
Self {
featured: into_countries(entries),
all: into_countries(all_entries),
}
}
None => Self {
featured: Vec::new(),
all: into_countries(entries),
},
}
}
}

/// Internal type used to deserialize the raw API array which mixes real
/// country entries with sentinel dividers.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum SupportedCountryEntry {
Country(SupportedCountry),
Divider {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone were to ever align the response type to match Country here, this would no longer match properly. We could solve it by assuming an empty code and name are the separator, but it's pretty theoretical.

This note is mostly just in case anyone ever has to come back to it down the line.

#[allow(dead_code)]
code: String,
#[allow(dead_code)]
name: String,
#[allow(dead_code)]
has_postal_codes: bool,
},
}

/// A country supported by the WordPress.com domain registration flow.
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct SupportedCountry {
/// ISO 3166-1 alpha-2 code (e.g. `"US"`).
pub code: CountryCode,
/// Localized country name.
pub name: String,
/// Whether this country uses postal codes in addresses.
pub has_postal_codes: bool,
/// Whether VAT is collected for this country.
pub vat_supported: bool,
/// Whether a city is required in the tax address.
pub tax_needs_city: bool,
/// Whether a subdivision (state/province) is required in the tax address.
pub tax_needs_subdivision: bool,
/// Whether a street address is required for tax purposes.
#[serde(default)]
#[uniffi(default = false)]
pub tax_needs_address: bool,
/// Whether an organization name is required for tax purposes.
#[serde(default)]
#[uniffi(default = false)]
pub tax_needs_organization: bool,
/// Additional country codes whose tax rules apply alongside this one.
#[serde(default)]
#[uniffi(default = [])]
pub tax_country_codes: Vec<CountryCode>,
/// Localized tax name (e.g. `"GST"`, `"VAT"`).
pub tax_name: Option<String>,
}

/// A state, province, or other subdivision within a supported country.
///
/// Returned from `GET /domains/supported-states/<country_code>`. Countries
/// without subdivision-level address requirements return an empty array.
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct SupportedState {
/// Subdivision code (e.g. `"CA"` for California, `"ON"` for Ontario).
pub code: String,
/// Localized subdivision name.
pub name: String,
}

#[cfg(test)]
mod tests {
use std::fs::File;
Expand Down Expand Up @@ -288,6 +417,83 @@ mod tests {
}
}

#[test]
fn test_supported_countries_deserialization() {
let file = File::open("tests/wpcom/domains/supported_countries/all.json")
.expect("Failed to open file");
let response: SupportedCountries =
serde_json::from_reader(file).expect("Unable to parse JSON");

assert_eq!(response.featured.len(), 10);
assert_eq!(response.all.len(), 238);

// US is in featured and has all optional tax fields populated.
let us = response
.featured
.iter()
.find(|c| c.code.0 == "US")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we implement From<&str> for CountryCode? There's a few spots like this that seem to suggest it'd be helpful

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a9bbc11

.expect("US missing from featured");
assert_eq!(us.name, "United States");
assert!(us.has_postal_codes);
assert!(!us.vat_supported);
assert!(!us.tax_needs_city);
assert!(!us.tax_needs_subdivision);

// Brazil has no `tax_country_codes` or `tax_name`.
let br = response
.all
.iter()
.find(|c| c.code.0 == "BR")
.expect("BR missing from all");
assert!(br.tax_country_codes.is_empty());
assert_eq!(br.tax_name, None);

// Australia has `tax_country_codes` and `tax_name`.
let au = response
.all
.iter()
.find(|c| c.code.0 == "AU")
.expect("AU missing from all");
assert_eq!(au.tax_country_codes, vec![CountryCode::from("AU")]);
assert_eq!(au.tax_name.as_deref(), Some("GST"));

// The separator entry should be filtered out.
let separator = response
.featured
.iter()
.chain(response.all.iter())
.find(|c| c.code.0.is_empty());
assert!(separator.is_none(), "separator should be filtered out");
}

#[rstest]
#[case("tests/wpcom/domains/supported_states/us.json", 61)]
#[case("tests/wpcom/domains/supported_states/ca.json", 13)]
#[case("tests/wpcom/domains/supported_states/de.json", 0)]
fn test_supported_states_deserialization(
#[case] json_file_path: &str,
#[case] expected_len: usize,
) {
let file = File::open(json_file_path).expect("Failed to open file");
let states: Vec<SupportedState> =
serde_json::from_reader(file).expect("Unable to parse JSON");
assert_eq!(states.len(), expected_len);
states.iter().for_each(|state| {
assert!(!state.code.is_empty());
assert!(!state.name.is_empty());
});
}

#[test]
fn test_supported_states_deserialization_us_details() {
let file = File::open("tests/wpcom/domains/supported_states/us.json")
.expect("Failed to open file");
let states: Vec<SupportedState> =
serde_json::from_reader(file).expect("Unable to parse JSON");
let alabama = states.iter().find(|s| s.code == "AL").expect("AL missing");
assert_eq!(alabama.name, "Alabama");
}

#[test]
fn test_domain_suggestions_deserialization_dot_vendor() {
let file = File::open("tests/wpcom/domains/suggestions/dot-vendor.json")
Expand Down
30 changes: 29 additions & 1 deletion wp_api/src/wp_com/endpoint/domains_endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ use crate::{
request::endpoint::{AsNamespace, DerivedRequest},
wp_com::{
WpComNamespace,
domains::{DomainSuggestion, DomainSuggestionsParams},
domains::{
CountryCode, DomainSuggestion, DomainSuggestionsParams, SupportedCountries,
SupportedState,
},
},
};
use wp_derive_request_builder::WpDerivedRequest;
Expand All @@ -11,6 +14,10 @@ use wp_derive_request_builder::WpDerivedRequest;
enum DomainsRequest {
#[get(url = "/domains/suggestions", params = &DomainSuggestionsParams, output = Vec<DomainSuggestion>)]
Suggestions,
#[get(url = "/domains/supported-countries", output = SupportedCountries)]
SupportedCountries,
#[get(url = "/domains/supported-states/<country_code>", output = Vec<SupportedState>)]
SupportedStates,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of these could be pretty trivially added to the WP.com e2e test suite for ongoing validation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 99c2ea3.

}

impl DerivedRequest for DomainsRequest {
Expand All @@ -25,6 +32,7 @@ mod tests {
use crate::{
request::endpoint::ApiUrlResolver,
wp_com::{
domains::CountryCode,
endpoint::tests::{
fixture_wp_com_api_url_resolver, validate_wp_com_rest_v1_1_endpoint,
},
Expand Down Expand Up @@ -78,6 +86,26 @@ mod tests {
validate_wp_com_rest_v1_1_endpoint(endpoint.suggestions(&params), expected_path);
}

#[rstest]
fn supported_countries(endpoint: DomainsRequestEndpoint) {
validate_wp_com_rest_v1_1_endpoint(
endpoint.supported_countries(),
"/domains/supported-countries",
);
}

#[rstest]
#[case::us(CountryCode::from("US"), "/domains/supported-states/US")]
#[case::ca(CountryCode::from("CA"), "/domains/supported-states/CA")]
#[case::gb(CountryCode::from("GB"), "/domains/supported-states/GB")]
fn supported_states(
endpoint: DomainsRequestEndpoint,
#[case] country_code: CountryCode,
#[case] expected_path: &str,
) {
validate_wp_com_rest_v1_1_endpoint(endpoint.supported_states(&country_code), expected_path);
}

fn base_domain_suggestions_params() -> DomainSuggestionsParams {
DomainSuggestionsParams {
query: "coolsite".to_string(),
Expand Down
32 changes: 16 additions & 16 deletions wp_api/src/wp_com/segments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,28 +48,28 @@ mod tests {

assert_eq!(segments.len(), 5);

let blog = segments
let unicorn = segments
.iter()
.find(|s| s.slug == "blog")
.expect("blog segment missing");
assert_eq!(blog.id, SegmentId(2));
assert!(blog.mobile);
assert_eq!(blog.title, "Blog");
.find(|s| s.slug == "unicorn-ranch")
.expect("unicorn-ranch segment missing");
assert_eq!(unicorn.id, SegmentId(101));
assert!(unicorn.mobile);
assert_eq!(unicorn.title, "Unicorn Ranch");
assert_eq!(
blog.subtitle,
"Share and discuss ideas, updates, or creations."
unicorn.subtitle,
"Manage your mythical creature farm online."
);
assert_eq!(
blog.icon_url,
"https://s.wp.com/i/mobile_segmentation_icons/monochrome/ic_blogger.png"
unicorn.icon_url,
"https://example.invalid/icons/ic_unicorn.png"
);
assert_eq!(blog.icon_color, "#3d4145");
assert_eq!(unicorn.icon_color, "#ff00ff");

let online_store = segments
let bakery = segments
.iter()
.find(|s| s.slug == "online-store")
.expect("online-store segment missing");
assert_eq!(online_store.id, SegmentId(3));
assert!(!online_store.mobile);
.find(|s| s.slug == "cloud-bakery")
.expect("cloud-bakery segment missing");
assert_eq!(bakery.id, SegmentId(103));
assert!(!bakery.mobile);
}
}
Loading