Packages

Loads and queries address metadata derived from the libaddressinput dataset.

Current section

Files

Jump to
address_input lib address_input.ex
Raw

lib/address_input.ex

defmodule AddressInput do
@moduledoc """
High-level API for address metadata derived from the
[libaddressinput](https://github.com/google/libaddressinput) dataset.
The module loads a JSON dataset from `priv/metadata.json` (bundled with the
application) and exposes convenience functions for looking up normalized
[Country](`AddressInput.Country`) and [Subregion](`AddressInput.Subregion`)
structs.
## Primary entry points
- `countries/0` returns all available countries sorted by name. Each country
includes parsed subregions.
- `get_country/1` returns a single country by ISO 3166-1 alpha-2 code (case
insensitive), or `nil` if it is not present. For compatibility with the raw
dataset keys it also accepts `"data/XX"` strings.
## Country fields
A [Country](`AddressInput.Country`) includes:
- `id` - ISO 3166-1 alpha-2 code.
- `name` - localized display name from the dataset.
- `default_language` - language code used for labels.
- `required_fields` - list of required address components as atoms, derived
from the dataset codes (`N`, `O`, `A`, `D`, `C`, `S`, `Z`, `X`).
- `subregion_type`, `sublocality_type`, `postal_code_type` - label hints.
- `postal_code_regex` - compiled regex for validating postal codes.
- `subregions` - list of [Subregion](`AddressInput.Subregion`) entries.
A [Subregion](`AddressInput.Subregion`) includes `id`, `iso_code`, and `name`.
## Example
iex> AddressInput.get_country("US")
%AddressInput.Country{
id: "US",
name: "UNITED STATES",
default_language: "en",
required_fields: [:address, :sublocality, :region, :postal_code],
sublocality_type: "city",
subregion_type: "state",
postal_code_type: "zip",
postal_code_regex: ~r/(\\d{5})(?:[ -](\\d{4}))?/,
subregions: [
%AddressInput.Subregion{id: "AK", iso_code: "AK", name: "Alaska"},
...
]
}
"US"
"""
alias __MODULE__.{Country, Subregion}
@external_resource "priv/metadata.json"
@metadata JSON.decode!(File.read!("#{:code.priv_dir(:address_input)}/metadata.json"))
@doc """
Returns the list of available countries as [Country](`AddressInput.Country`) structs.
"""
@spec countries() :: [Country.t()]
def countries do
@metadata
|> Enum.filter(fn
{<<"data/", _::binary-size(2)>>, %{"name" => name}} when is_binary(name) -> true
_ -> false
end)
|> Enum.map(fn {_, metadata} -> build_country!(metadata) end)
|> Enum.sort_by(& &1.name)
end
@doc """
Returns a [Country](`AddressInput.Country`) by ISO 3166-1 alpha-2 country code.
Returns `nil` when the country is not found.
"""
@spec get_country(String.t()) :: Country.t() | nil
def get_country("data/" <> country), do: get_country(country)
def get_country(country) do
# "ZZ" seems to be a blank country
case @metadata["data/#{String.upcase(country)}"] do
%{"name" => _} = metadata -> build_country!(metadata)
_ -> nil
end
end
defp build_country!(metadata) do
required_fields =
if required = metadata["require"] do
required
|> String.split("", trim: true)
|> Enum.map(&code_to_field/1)
else
[]
end
subregions =
split(metadata["sub_keys"])
|> Enum.map(&parse_subregion!("#{metadata["id"]}/#{&1}"))
postal_code_regex =
if zip = metadata["zip"] do
Regex.compile!("\\A(?:#{zip})\\z")
end
%Country{
id: metadata["key"],
name: metadata["name"],
default_language: metadata["lang"],
required_fields: required_fields,
subregion_type: metadata["state_name_type"],
sublocality_type: metadata["sublocality_name_type"] || "city",
postal_code_type: metadata["zip_name_type"],
postal_code_regex: postal_code_regex,
subregions: subregions
}
end
defp code_to_field("N"), do: :name
defp code_to_field("O"), do: :organization
defp code_to_field("A"), do: :address
defp code_to_field("D"), do: :dependent_locality
defp code_to_field("C"), do: :sublocality
defp code_to_field("S"), do: :region
defp code_to_field("Z"), do: :postal_code
defp code_to_field("X"), do: :sorting_code
defp parse_subregion!(id) do
subregion = @metadata[id]
<<"data/", _::binary-size(2), "/", id::binary>> = id
%Subregion{
id: id,
iso_code: subregion["isoid"],
name: subregion["name"] || subregion["key"]
}
end
defp split(term) when is_binary(term), do: String.split(term, "~")
defp split(_term), do: []
@doc """
Returns `{:ok, postal_code}` if the postal code matches the expected format
for the given country. Otherwise, returns `:error`. The provided country must
be either a `%Country{}` or a string that resolves to one.
"""
@spec parse_postal_code(String.t(), String.t() | Country.t()) :: {:ok, String.t()} | :error
def parse_postal_code(postal_code, country) do
case do_parse_postal_code(postal_code, country) do
[result] -> {:ok, result}
nil -> :error
end
end
defp do_parse_postal_code(postal_code, country) when is_binary(country) do
if country = get_country(country) do
do_parse_postal_code(postal_code, country)
end
end
defp do_parse_postal_code(postal_code, %Country{postal_code_regex: regex}) do
case regex do
%Regex{} -> Regex.run(regex, postal_code, capture: :first)
nil -> nil
end
end
end