Repository: saleor/saleor

Issue:
Add address validation rules endpoint
### What I'm trying to achieve
I would like to get validation rules for my shipping and billing forms.
In addition, if no country code provided endpoint should returns rules based on IP address.

### Describe a proposed solution

```graphql
query GetAddressValidator($countryCode:String, $countryArea: String, $cityArea: String) {
  addressValidatior(countrCode: $countryCode, countryArea: $countryArea, cityArea: $cityArea) {
    countryCode
    countryName
    addressFormat
    addressLatinFormat
    allowedFields
    requiredFields
    upperFields
    countryAreaType
    countryAreaChoices
    cityType
    cityChoices
    cityAreaType
    cityAreaChoices
    postalCodeType
    postalCodeMatchers
    postalCodeExamples
    postalCodePrefix
  }
}
```

Return validation rules from the [google-i18n-address](https://github.com/mirumee/google-i18n-address).

```python
from i18naddress import get_validation_rules

def get_validation_rules(country_code=None, country_area=None, city_area=None, ip=None):
        if country_code is None and ip is not None:
            country_code = get_country_code_from_ip(ip)
        if not country:
            return None

        validation_rules = {
            'country_code': country_code,
            'country_area': country_area,
            'city_area': city_area,
        }
        # this part can throw ValueError
        return get_validation_rules(validation_rules)
```

Take country code from geoIP:
```python
geo_data = georeader.get(ip_address)
if geo_data and 'country' in geo_data and 'iso_code' in geo_data['country']:
    country_iso_code = geo_data['country']['iso_code']
```



Discussion:
I'd like to make the country code required. It's easy enough for the client to handle this and client may prefer to use other means to obtain the address (like location APIs on mobile devices).
@mociepka Why do you need whole address validation data exposed in the API? Wouldn't it be easier for you when client side could only send an address to the backend for validation?
Another endpoint could deliver address format for the particular country. 

From my perspective exposing the whole validator could lead to logic duplication between the client and server side.
I would like to autofill fields, add zip codes validation in the frontend, etc. That will be duplication but improve UX.
We also need to know the address format to put the form fields in the correct order.

Resolving patch:
diff --git a/saleor/graphql/account/resolvers.py b/saleor/graphql/account/resolvers.py
--- a/saleor/graphql/account/resolvers.py
+++ b/saleor/graphql/account/resolvers.py
@@ -1,5 +1,9 @@
+from i18naddress import get_validation_rules
+
 from ...account import models
+from ...core.utils import get_client_ip, get_country_by_ip
 from ..utils import filter_by_query_param
+from .types import AddressValidationData, ChoiceValue
 
 USER_SEARCH_FIELDS = (
     'email', 'default_shipping_address__first_name',
@@ -11,3 +15,41 @@ def resolve_users(info, query):
     qs = models.User.objects.all().prefetch_related('addresses')
     return filter_by_query_param(
         queryset=qs, query=query, search_fields=USER_SEARCH_FIELDS)
+
+
+def resolve_address_validator(info, input):
+    country_code = input['country_code']
+    if not country_code:
+        client_ip = get_client_ip(info.context)
+        country = get_country_by_ip(client_ip)
+        if country:
+            country_code = country.code
+        else:
+            return None
+    params = {
+        'country_code': country_code,
+        'country_area': input['country_area'],
+        'city_area': input['city_area']}
+    rules = get_validation_rules(params)
+
+    return AddressValidationData(
+        country_code=rules.country_code,
+        country_name=rules.country_name,
+        address_format=rules.address_format,
+        address_latin_format=rules.address_latin_format,
+        allowed_fields=rules.allowed_fields,
+        required_fields=rules.required_fields,
+        upper_fields=rules.upper_fields,
+        country_area_type=rules.country_area_type,
+        country_area_choices=[
+            ChoiceValue(area[0], area[1])
+            for area in rules.country_area_choices],
+        city_type=rules.city_type,
+        city_area_choices=[
+            ChoiceValue(area[0], area[1]) for area in rules.city_area_choices],
+        postal_code_type=rules.postal_code_type,
+        postal_code_matchers=[
+            compiled.pattern for compiled in rules.postal_code_matchers],
+        postal_code_examples=rules.postal_code_examples,
+        postal_code_prefix=rules.postal_code_prefix
+    )
diff --git a/saleor/graphql/account/types.py b/saleor/graphql/account/types.py
--- a/saleor/graphql/account/types.py
+++ b/saleor/graphql/account/types.py
@@ -66,3 +66,32 @@ def resolve_permissions(self, info, **kwargs):
                 auth_models.Permission.objects.filter(group__user=self))
         permissions = permissions.select_related('content_type')
         return format_permissions_for_display(permissions)
+
+
+class AddressValidationInput(graphene.InputObjectType):
+    country_code = graphene.String()
+    country_area = graphene.String()
+    city_area = graphene.String()
+
+
+class ChoiceValue(graphene.ObjectType):
+    raw = graphene.String()
+    verbose = graphene.String()
+
+
+class AddressValidationData(graphene.ObjectType):
+    country_code = graphene.String()
+    country_name = graphene.String()
+    address_format = graphene.String()
+    address_latin_format = graphene.String()
+    allowed_fields = graphene.List(graphene.String)
+    required_fields = graphene.List(graphene.String)
+    upper_fields = graphene.List(graphene.String)
+    country_area_type = graphene.String()
+    country_area_choices = graphene.List(ChoiceValue)
+    city_type = graphene.String()
+    city_area_choices = graphene.List(ChoiceValue)
+    postal_code_type = graphene.String()
+    postal_code_matchers = graphene.List(graphene.String)
+    postal_code_examples = graphene.List(graphene.String)
+    postal_code_prefix = graphene.String()
diff --git a/saleor/graphql/api.py b/saleor/graphql/api.py
--- a/saleor/graphql/api.py
+++ b/saleor/graphql/api.py
@@ -6,8 +6,8 @@
 from .account.mutations import (
     CustomerCreate, CustomerUpdate, PasswordReset, SetPassword, StaffCreate,
     StaffUpdate, AddressCreate, AddressUpdate, AddressDelete)
-from .account.resolvers import resolve_users
-from .account.types import User
+from .account.resolvers import resolve_users, resolve_address_validator
+from .account.types import User, AddressValidationData, AddressValidationInput
 from .menu.resolvers import resolve_menu, resolve_menus, resolve_menu_items
 from .menu.types import Menu, MenuItem
 # FIXME: sorting import by putting below line at the beginning breaks app
@@ -66,6 +66,9 @@
 
 
 class Query(graphene.ObjectType):
+    address_validator = graphene.Field(
+        AddressValidationData,
+        input=graphene.Argument(AddressValidationInput, required=True))
     attributes = DjangoFilterConnectionField(
         ProductAttribute,
         query=graphene.String(description=DESCRIPTIONS['attributes']),
@@ -250,6 +253,9 @@ def resolve_shipping_zone(self, info, id):
     def resolve_shipping_zones(self, info, **kwargs):
         return resolve_shipping_zones(info)
 
+    def resolve_address_validator(self, info, input):
+        return resolve_address_validator(info, input)
+
 
 class Mutations(graphene.ObjectType):
     assign_navigation = AssignNavigation.Field()


Test patch:
diff --git a/tests/api/test_account.py b/tests/api/test_account.py
--- a/tests/api/test_account.py
+++ b/tests/api/test_account.py
@@ -1,5 +1,6 @@
 import json
-from unittest.mock import patch
+import re
+from unittest.mock import patch, Mock
 
 import graphene
 import pytest
@@ -626,3 +627,66 @@ def test_address_delete_mutation(admin_api_client, customer_user):
     assert data['address']['city'] == address_obj.city
     with pytest.raises(address_obj._meta.model.DoesNotExist):
         address_obj.refresh_from_db()
+
+
+def test_address_validator(user_api_client):
+    query = """
+    query getValidator($input: AddressValidationInput!) {
+        addressValidator(input: $input) {
+            countryCode
+            countryName
+            addressFormat
+            addressLatinFormat
+            postalCodeMatchers
+        }
+    }
+    """
+    variables = json.dumps({'input': {
+        'countryCode': 'PL',
+        'countryArea': None,
+        'cityArea': None
+    }})
+    response = user_api_client.post(
+        reverse('api'), {'query': query, 'variables': variables})
+    content = get_graphql_content(response)
+    assert 'errors' not in content
+    data = content['data']['addressValidator']
+    assert data['countryCode'] == 'PL'
+    assert data['countryName'] == 'POLAND'
+    assert data['addressFormat'] is not None
+    assert data['addressLatinFormat'] is not None
+    matcher = data['postalCodeMatchers'][0]
+    matcher = re.compile(matcher)
+    assert matcher.match('00-123')
+
+
+def test_address_validator_uses_geip_when_country_code_missing(
+        user_api_client, monkeypatch):
+    query = """
+    query getValidator($input: AddressValidationInput!) {
+        addressValidator(input: $input) {
+            countryCode,
+            countryName
+        }
+    }
+    """
+    variables = json.dumps({'input': {
+        'countryCode': None,
+        'countryArea': None,
+        'cityArea': None
+    }})
+    mock_country_by_ip = Mock(return_value=Mock(code='US'))
+    monkeypatch.setattr(
+        'saleor.graphql.account.resolvers.get_client_ip',
+        lambda request: Mock(return_value='127.0.0.1'))
+    monkeypatch.setattr(
+        'saleor.graphql.account.resolvers.get_country_by_ip',
+        mock_country_by_ip)
+    response = user_api_client.post(
+        reverse('api'), {'query': query, 'variables': variables})
+    content = get_graphql_content(response)
+    assert mock_country_by_ip.called
+    assert 'errors' not in content
+    data = content['data']['addressValidator']
+    assert data['countryCode'] == 'US'
+    assert data['countryName'] == 'UNITED STATES'