From f29a0f0ab593b25d99aa8c9836797646030da102 Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Fri, 14 Nov 2025 18:10:50 +0100 Subject: [PATCH 1/2] Add support for Plus bundle --- src/ipinfo_plus.erl | 251 +++++++++++++++++++++++++++++++++++++ test/ipinfo_plus_SUITE.erl | 82 ++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 src/ipinfo_plus.erl create mode 100644 test/ipinfo_plus_SUITE.erl diff --git a/src/ipinfo_plus.erl b/src/ipinfo_plus.erl new file mode 100644 index 0000000..6b7abfe --- /dev/null +++ b/src/ipinfo_plus.erl @@ -0,0 +1,251 @@ +-module(ipinfo_plus). +-include_lib("kernel/include/logger.hrl"). + +-export([ + '__struct__'/0, + '__struct__'/1 +]). + +-export([ + create/0, + create/1, + create/2, + details/1, + details/2 +]). + +-define(DEFAULT_COUNTRY_FILE, "countries.json"). +-define(DEFAULT_EU_COUNTRY_FILE, "eu.json"). +-define(DEFAULT_COUNTRY_FLAG_FILE, "flags.json"). +-define(DEFAULT_COUNTRY_CURRENCY_FILE, "currency.json"). +-define(DEFAULT_CONTINENT_FILE, "continent.json"). +-define(DEFAULT_COUNTRY_FLAG_BASE_URL, + <<"https://cdn.ipinfo.io/static/images/countries-flags/">>). +-define(DEFAULT_BASE_URL, <<"https://api.ipinfo.io/lookup">>). +-define(DEFAULT_TIMEOUT, timer:seconds(5)). +-define(DEFAULT_CACHE_TTL_SECONDS, (24 * 60 * 60)). + +-export_type([t/0]). + +-type t() :: #{ + '__struct__' := ?MODULE, + access_token := nil | binary(), + base_url := nil | binary(), + timeout := nil | timeout(), + cache := nil | pid(), + countries := map(), + countries_flags := map(), + country_flag_base_url := nil | binary(), + countries_currencies := map(), + continents := map(), + eu_countries := list() +}. + +-spec new() -> t(). +%% @private +new() -> + #{ + '__struct__' => ?MODULE, + access_token => nil, + base_url => nil, + timeout => nil, + cache => nil, + countries => #{}, + countries_currencies => #{}, + countries_flags => #{}, + country_flag_base_url => nil, + continents => #{}, + eu_countries => [] + }. + +-spec '__struct__'() -> t(). +%% @private +'__struct__'() -> + new(). + +-spec '__struct__'(From :: list() | map()) -> t(). +%% @private +'__struct__'(From) -> + new(From). + +-spec new(From :: list() | map()) -> t(). +%% @private +new(List) when is_list(List) -> + new(maps:from_list(List)); +new(Map) when is_map(Map) -> + maps:fold(fun maps:update/3, new(), Map). + +create() -> + create(application:get_env(ipinfo, access_token, nil)). + +create(AccessToken) when is_list(AccessToken) -> + create(list_to_binary(AccessToken)); +create(AccessToken) -> + create(AccessToken, []). + +-spec create(AccessToken, Settings) -> Result when + AccessToken :: binary() | nil, + Settings :: proplists:proplist(), + Result :: {ok, t()} | {error, term()}. +create(AccessToken, Settings) -> + CountriesFile = get_config(countries, Settings, + filename:join(code:priv_dir(ipinfo), ?DEFAULT_COUNTRY_FILE)), + EuCountriesFile = get_config(eu_countries, Settings, + filename:join(code:priv_dir(ipinfo), ?DEFAULT_EU_COUNTRY_FILE)), + CountriesFlagsFile = get_config(countries_flags, Settings, + filename:join(code:priv_dir(ipinfo), ?DEFAULT_COUNTRY_FLAG_FILE)), + CountriesCurrenciesFile = get_config(countries_currencies, Settings, + filename:join(code:priv_dir(ipinfo), ?DEFAULT_COUNTRY_CURRENCY_FILE)), + ContinentsFile = get_config(continents, Settings, + filename:join(code:priv_dir(ipinfo), ?DEFAULT_CONTINENT_FILE)), + CountryFlagBaseUrl = get_config(country_flag_base_url, Settings, + ?DEFAULT_COUNTRY_FLAG_BASE_URL), + BaseUrl = get_config(base_url, Settings, ?DEFAULT_BASE_URL), + Timeout = get_config(timeout, Settings, ?DEFAULT_TIMEOUT), + CacheTtl = get_config(cache_ttl, Settings, ?DEFAULT_CACHE_TTL_SECONDS), + create_with_files(AccessToken, BaseUrl, Timeout, CacheTtl, CountriesFile, + EuCountriesFile, CountriesFlagsFile, CountriesCurrenciesFile, + ContinentsFile, CountryFlagBaseUrl). + +create_with_files(AccessToken, BaseUrl, Timeout, CacheTtl, CountriesFile, + EuCountriesFile, CountriesFlagsFile, CountriesCurrenciesFile, + ContinentsFile, CountryFlagBaseUrl) -> + Files = [ + CountriesFile, + EuCountriesFile, + CountriesFlagsFile, + CountriesCurrenciesFile, + ContinentsFile + ], + case read_json_files(Files) of + {ok, [Countries, EuCountries, CountriesFlags, CountriesCurrencies, Continents]} -> + create_ipinfo_plus_struct( + AccessToken, + BaseUrl, + Timeout, + CacheTtl, + Countries, + EuCountries, + CountriesFlags, + CountriesCurrencies, + Continents, + CountryFlagBaseUrl + ); + {error, Reason} -> + {error, Reason} + end. + +read_json_files(Files) -> + read_json_files(Files, []). + +read_json_files([], Acc) -> + {ok, lists:reverse(Acc)}; +read_json_files([File | Rest], Acc) -> + case read_json(File) of + {ok, Data} -> + read_json_files(Rest, [Data | Acc]); + {error, Reason} -> + {error, Reason} + end. + +create_ipinfo_plus_struct(AccessToken, BaseUrl, Timeout, CacheTtl, Countries, + EuCountries, CountriesFlags, CountriesCurrencies, Continents, + CountryFlagBaseUrl) -> + {ok, Cache} = ipinfo_cache:create(CacheTtl), + {ok, new(#{ + access_token => AccessToken, + base_url => BaseUrl, + timeout => Timeout, + cache => Cache, + countries => Countries, + eu_countries => EuCountries, + countries_flags => CountriesFlags, + country_flag_base_url => CountryFlagBaseUrl, + countries_currencies => CountriesCurrencies, + continents => Continents + })}. + +details(IpInfoPlus) -> + details(IpInfoPlus, nil). + +details(#{cache := Cache, + countries := Countries, + eu_countries := EuCountries, + countries_flags := CountriesFlags, + country_flag_base_url:= CountryFlagBaseUrl, + countries_currencies := CountriesCurrencies, + continents := Continents +} = IpInfo, IpAddress) -> + ActualIpAddress = case IpAddress of + nil -> <<"me">>; + _ -> IpAddress + end, + case get_details(Cache, IpInfo, ActualIpAddress) of + {ok, Details} -> + {ok, enrich_details(Details, Countries, EuCountries, + CountriesFlags, CountryFlagBaseUrl, CountriesCurrencies, + Continents)}; + {error, Reason} -> + {error, Reason} + end. + +get_details(Cache, IpInfo, IpAddress) -> + case ipinfo_cache:get(Cache, IpAddress) of + {ok, Details} -> + {ok, Details}; + error -> + case ipinfo_http:request_details(IpInfo, IpAddress) of + {ok, Details} -> + ok = ipinfo_cache:add(Cache, IpAddress, Details), + {ok, Details}; + {error, Reason} -> + {error, Reason} + end + end. + +enrich_details(Details, Countries, EuCountries, CountriesFlags, + CountryFlagBaseUrl, CountriesCurrencies, Continents) -> + Enrichers = [ + fun enrich_geo/1, + fun(D) -> put_geo_enrichments(D, Countries, EuCountries, CountriesFlags, + CountryFlagBaseUrl, CountriesCurrencies, Continents) end + ], + lists:foldl(fun(F, Acc) -> F(Acc) end, Details, Enrichers). + +enrich_geo(#{geo := Geo} = Details) when is_map(Geo) -> + Details; +enrich_geo(Details) -> + Details. + +put_geo_enrichments(#{geo := #{country_code := CountryCode} = Geo} = Details, + Countries, EuCountries, CountriesFlags, CountryFlagBaseUrl, + CountriesCurrencies, Continents) -> + EnrichedGeo = Geo#{ + country_name => maps:get(CountryCode, Countries, CountryCode), + is_eu => lists:member(CountryCode, EuCountries), + country_flag => maps:get(CountryCode, CountriesFlags, #{}), + country_currency => maps:get(CountryCode, CountriesCurrencies, #{}), + continent => maps:get(CountryCode, Continents, #{}), + country_flag_url => <> + }, + Details#{geo => EnrichedGeo}; +put_geo_enrichments(Details, _Countries, _EuCountries, _CountriesFlags, + _CountryFlagBaseUrl, _CountriesCurrencies, _Continents) -> + Details. + +get_config(Key, Settings, Default) -> + proplists:get_value(Key, Settings, + application:get_env(ipinfo, Key, Default)). + +read_json(JsonFile) -> + case file:read_file(JsonFile) of + {ok, Binary} -> + case jsx:is_json(Binary) of + true -> + {ok, jsx:decode(Binary, [return_maps])}; + false -> + {error, invalid_json} + end; + {error, Reason} -> + {error, Reason} + end. diff --git a/test/ipinfo_plus_SUITE.erl b/test/ipinfo_plus_SUITE.erl new file mode 100644 index 0000000..18624f5 --- /dev/null +++ b/test/ipinfo_plus_SUITE.erl @@ -0,0 +1,82 @@ +-module(ipinfo_plus_SUITE). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +-export([ + all/0, + init_per_suite/1, + end_per_suite/1 +]). + +-export([ + details_test/1 +]). + +all() -> + [details_test]. + +init_per_suite(Config) -> + {ok, _} = application:ensure_all_started(ipinfo), + Token = case os:getenv("IPINFO_TOKEN") of + false -> nil; + TokenStr -> list_to_binary(TokenStr) + end, + [{token, Token} | Config]. + +end_per_suite(_Config) -> + ok = application:stop(ipinfo). + +details_test(Config) -> + Token = proplists:get_value(token, Config), + {ok, IpInfoPlus} = ipinfo_plus:create(Token), + {ok, Details} = ipinfo_plus:details(IpInfoPlus, <<"8.8.8.8">>), + + % Verify required fields + ?assertEqual(<<"8.8.8.8">>, maps:get(ip, Details)), + ?assertNotEqual(nil, maps:get(hostname, Details)), + + % Check geo object + #{geo := Geo} = Details, + ?assertNotEqual(nil, maps:get(city, Geo)), + ?assertNotEqual(nil, maps:get(region, Geo)), + ?assertNotEqual(nil, maps:get(region_code, Geo)), + ?assertNotEqual(nil, maps:get(country, Geo)), + ?assertNotEqual(nil, maps:get(country_code, Geo)), + ?assertNotEqual(nil, maps:get(continent, Geo)), + ?assertNotEqual(nil, maps:get(continent_code, Geo)), + ?assertNotEqual(nil, maps:get(latitude, Geo)), + ?assertNotEqual(nil, maps:get(longitude, Geo)), + ?assertNotEqual(nil, maps:get(timezone, Geo)), + ?assertNotEqual(nil, maps:get(postal_code, Geo)), + ?assertNotEqual(nil, maps:get(dma_code, Geo)), + ?assertNotEqual(nil, maps:get(geoname_id, Geo)), + ?assertNotEqual(nil, maps:get(radius, Geo)), + ?assertNotEqual(nil, maps:get(country_name, Geo)), + ?assertNotEqual(nil, maps:get(is_eu, Geo)), + ?assertNotEqual(nil, maps:get(country_flag, Geo)), + ?assertNotEqual(nil, maps:get(country_currency, Geo)), + ?assertNotEqual(nil, maps:get(country_flag_url, Geo)), + + % Check as object + As = maps:get(as, Details), + ?assertNotEqual(nil, maps:get(asn, As)), + ?assertNotEqual(nil, maps:get(name, As)), + ?assertNotEqual(nil, maps:get(domain, As)), + ?assertNotEqual(nil, maps:get(type, As)), + ?assertNotEqual(nil, maps:get(last_changed, As)), + + % Check mobile and anonymous objects + ?assertNotEqual(nil, maps:get(mobile, Details)), + Anonymous = maps:get(anonymous, Details), + ?assertNotEqual(nil, maps:get(is_proxy, Anonymous)), + ?assertNotEqual(nil, maps:get(is_relay, Anonymous)), + ?assertNotEqual(nil, maps:get(is_tor, Anonymous)), + ?assertNotEqual(nil, maps:get(is_vpn, Anonymous)), + + % Check network flags + ?assertNotEqual(nil, maps:get(is_anonymous, Details)), + ?assertNotEqual(nil, maps:get(is_anycast, Details)), + ?assertNotEqual(nil, maps:get(is_hosting, Details)), + ?assertNotEqual(nil, maps:get(is_mobile, Details)), + ?assertNotEqual(nil, maps:get(is_satellite, Details)). From 2166d3eae12d44b3304c7b2172943deadfd5ab45 Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Mon, 17 Nov 2025 15:04:59 +0100 Subject: [PATCH 2/2] Fix linting --- elvis.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elvis.config b/elvis.config index 0a55337..43246e2 100644 --- a/elvis.config +++ b/elvis.config @@ -7,7 +7,7 @@ ruleset => erl_files, rules => [ {elvis_style, function_naming_convention, #{ - ignore => [ipinfo, ipinfo_lite, ipinfo_core], + ignore => [ipinfo, ipinfo_lite, ipinfo_core, ipinfo_plus], regex => "^([a-z][a-z0-9]*_?)*$" }} ]