diff --git a/src/main/java/io/ipinfo/api/IPinfoPlus.java b/src/main/java/io/ipinfo/api/IPinfoPlus.java new file mode 100644 index 0000000..6d6a2bb --- /dev/null +++ b/src/main/java/io/ipinfo/api/IPinfoPlus.java @@ -0,0 +1,91 @@ +package io.ipinfo.api; + +import io.ipinfo.api.cache.Cache; +import io.ipinfo.api.cache.SimpleCache; +import io.ipinfo.api.context.Context; +import io.ipinfo.api.errors.RateLimitedException; +import io.ipinfo.api.model.IPResponsePlus; +import io.ipinfo.api.request.IPRequestPlus; +import java.time.Duration; +import okhttp3.OkHttpClient; + +public class IPinfoPlus { + + private final OkHttpClient client; + private final Context context; + private final String token; + private final Cache cache; + + IPinfoPlus( + OkHttpClient client, + Context context, + String token, + Cache cache + ) { + this.client = client; + this.context = context; + this.token = token; + this.cache = cache; + } + + public static void main(String[] args) throws RateLimitedException { + System.out.println("Running IPinfo Plus client"); + } + + /** + * Lookup IP information using the IP. This is a blocking call. + * + * @param ip IP address to query information for. + * @return Response containing IP information. + * @throws RateLimitedException if the user has exceeded the rate limit. + */ + public IPResponsePlus lookupIP(String ip) throws RateLimitedException { + IPRequestPlus request = new IPRequestPlus(client, token, ip); + IPResponsePlus response = request.handle(); + + if (response != null) { + response.setContext(context); + if (!response.getBogon()) { + cache.set(cacheKey(ip), response); + } + } + + return response; + } + + public static String cacheKey(String k) { + return "plus_" + k; + } + + public static class Builder { + + private OkHttpClient client; + private String token; + private Cache cache; + + public Builder setClient(OkHttpClient client) { + this.client = client; + return this; + } + + public Builder setToken(String token) { + this.token = token; + return this; + } + + public Builder setCache(Cache cache) { + this.cache = cache; + return this; + } + + public IPinfoPlus build() { + if (client == null) { + client = new OkHttpClient(); + } + if (cache == null) { + cache = new SimpleCache(Duration.ofDays(1)); + } + return new IPinfoPlus(client, new Context(), token, cache); + } + } +} diff --git a/src/main/java/io/ipinfo/api/model/ASNPlus.java b/src/main/java/io/ipinfo/api/model/ASNPlus.java new file mode 100644 index 0000000..bfb696d --- /dev/null +++ b/src/main/java/io/ipinfo/api/model/ASNPlus.java @@ -0,0 +1,54 @@ +package io.ipinfo.api.model; + +public class ASNPlus { + private final String asn; + private final String name; + private final String domain; + private final String type; + private final String last_changed; + + public ASNPlus( + String asn, + String name, + String domain, + String type, + String last_changed + ) { + this.asn = asn; + this.name = name; + this.domain = domain; + this.type = type; + this.last_changed = last_changed; + } + + public String getAsn() { + return asn; + } + + public String getName() { + return name; + } + + public String getDomain() { + return domain; + } + + public String getType() { + return type; + } + + public String getLastChanged() { + return last_changed; + } + + @Override + public String toString() { + return "ASNPlus{" + + "asn='" + asn + '\'' + + ", name='" + name + '\'' + + ", domain='" + domain + '\'' + + ", type='" + type + '\'' + + ", last_changed='" + last_changed + '\'' + + '}'; + } +} diff --git a/src/main/java/io/ipinfo/api/model/Anonymous.java b/src/main/java/io/ipinfo/api/model/Anonymous.java new file mode 100644 index 0000000..769d18f --- /dev/null +++ b/src/main/java/io/ipinfo/api/model/Anonymous.java @@ -0,0 +1,46 @@ +package io.ipinfo.api.model; + +public class Anonymous { + private final Boolean is_proxy; + private final Boolean is_relay; + private final Boolean is_tor; + private final Boolean is_vpn; + + public Anonymous( + Boolean is_proxy, + Boolean is_relay, + Boolean is_tor, + Boolean is_vpn + ) { + this.is_proxy = is_proxy; + this.is_relay = is_relay; + this.is_tor = is_tor; + this.is_vpn = is_vpn; + } + + public Boolean getIsProxy() { + return is_proxy; + } + + public Boolean getIsRelay() { + return is_relay; + } + + public Boolean getIsTor() { + return is_tor; + } + + public Boolean getIsVpn() { + return is_vpn; + } + + @Override + public String toString() { + return "Anonymous{" + + "is_proxy=" + is_proxy + + ", is_relay=" + is_relay + + ", is_tor=" + is_tor + + ", is_vpn=" + is_vpn + + '}'; + } +} diff --git a/src/main/java/io/ipinfo/api/model/GeoPlus.java b/src/main/java/io/ipinfo/api/model/GeoPlus.java new file mode 100644 index 0000000..426e990 --- /dev/null +++ b/src/main/java/io/ipinfo/api/model/GeoPlus.java @@ -0,0 +1,134 @@ +package io.ipinfo.api.model; + +public class GeoPlus { + private final String city; + private final String region; + private final String region_code; + private final String country; + private final String country_code; + private final String continent; + private final String continent_code; + private final Double latitude; + private final Double longitude; + private final String timezone; + private final String postal_code; + private final String dma_code; + private final String geoname_id; + private final Integer radius; + private final String last_changed; + + public GeoPlus( + String city, + String region, + String region_code, + String country, + String country_code, + String continent, + String continent_code, + Double latitude, + Double longitude, + String timezone, + String postal_code, + String dma_code, + String geoname_id, + Integer radius, + String last_changed + ) { + this.city = city; + this.region = region; + this.region_code = region_code; + this.country = country; + this.country_code = country_code; + this.continent = continent; + this.continent_code = continent_code; + this.latitude = latitude; + this.longitude = longitude; + this.timezone = timezone; + this.postal_code = postal_code; + this.dma_code = dma_code; + this.geoname_id = geoname_id; + this.radius = radius; + this.last_changed = last_changed; + } + + public String getCity() { + return city; + } + + public String getRegion() { + return region; + } + + public String getRegionCode() { + return region_code; + } + + public String getCountry() { + return country; + } + + public String getCountryCode() { + return country_code; + } + + public String getContinent() { + return continent; + } + + public String getContinentCode() { + return continent_code; + } + + public Double getLatitude() { + return latitude; + } + + public Double getLongitude() { + return longitude; + } + + public String getTimezone() { + return timezone; + } + + public String getPostalCode() { + return postal_code; + } + + public String getDmaCode() { + return dma_code; + } + + public String getGeonameId() { + return geoname_id; + } + + public Integer getRadius() { + return radius; + } + + public String getLastChanged() { + return last_changed; + } + + @Override + public String toString() { + return "GeoPlus{" + + "city='" + city + '\'' + + ", region='" + region + '\'' + + ", region_code='" + region_code + '\'' + + ", country='" + country + '\'' + + ", country_code='" + country_code + '\'' + + ", continent='" + continent + '\'' + + ", continent_code='" + continent_code + '\'' + + ", latitude=" + latitude + + ", longitude=" + longitude + + ", timezone='" + timezone + '\'' + + ", postal_code='" + postal_code + '\'' + + ", dma_code='" + dma_code + '\'' + + ", geoname_id='" + geoname_id + '\'' + + ", radius=" + radius + + ", last_changed='" + last_changed + '\'' + + '}'; + } +} diff --git a/src/main/java/io/ipinfo/api/model/IPResponsePlus.java b/src/main/java/io/ipinfo/api/model/IPResponsePlus.java new file mode 100644 index 0000000..e55123b --- /dev/null +++ b/src/main/java/io/ipinfo/api/model/IPResponsePlus.java @@ -0,0 +1,203 @@ +package io.ipinfo.api.model; + +import com.google.gson.annotations.SerializedName; +import io.ipinfo.api.context.Context; + +public class IPResponsePlus { + private final String ip; + private final String hostname; + private final GeoPlus geo; + @SerializedName("as") + private final ASNPlus asn; + private final Mobile mobile; + private final Anonymous anonymous; + private final Abuse abuse; + private final Company company; + private final Privacy privacy; + private final Domains domains; + private final Boolean is_anonymous; + private final Boolean is_anycast; + private final Boolean is_hosting; + private final Boolean is_mobile; + private final Boolean is_satellite; + private final boolean bogon; + private transient Context context; + + public IPResponsePlus( + String ip, + String hostname, + GeoPlus geo, + ASNPlus asn, + Mobile mobile, + Anonymous anonymous, + Abuse abuse, + Company company, + Privacy privacy, + Domains domains, + Boolean is_anonymous, + Boolean is_anycast, + Boolean is_hosting, + Boolean is_mobile, + Boolean is_satellite + ) { + this.ip = ip; + this.hostname = hostname; + this.geo = geo; + this.asn = asn; + this.mobile = mobile; + this.anonymous = anonymous; + this.abuse = abuse; + this.company = company; + this.privacy = privacy; + this.domains = domains; + this.is_anonymous = is_anonymous; + this.is_anycast = is_anycast; + this.is_hosting = is_hosting; + this.is_mobile = is_mobile; + this.is_satellite = is_satellite; + this.bogon = false; + } + + public IPResponsePlus(String ip, boolean bogon) { + this.ip = ip; + this.bogon = bogon; + this.hostname = null; + this.geo = null; + this.asn = null; + this.mobile = null; + this.anonymous = null; + this.abuse = null; + this.company = null; + this.privacy = null; + this.domains = null; + this.is_anonymous = null; + this.is_anycast = null; + this.is_hosting = null; + this.is_mobile = null; + this.is_satellite = null; + } + + /** + * Set by the library for extra utility functions + * + * @param context for country information + */ + public void setContext(Context context) { + this.context = context; + } + + public String getIp() { + return ip; + } + + public String getHostname() { + return hostname; + } + + public GeoPlus getGeo() { + return geo; + } + + public ASNPlus getAsn() { + return asn; + } + + public Mobile getMobile() { + return mobile; + } + + public Anonymous getAnonymous() { + return anonymous; + } + + public Abuse getAbuse() { + return abuse; + } + + public Company getCompany() { + return company; + } + + public Privacy getPrivacy() { + return privacy; + } + + public Domains getDomains() { + return domains; + } + + public Boolean getIsAnonymous() { + return is_anonymous; + } + + public Boolean getIsAnycast() { + return is_anycast; + } + + public Boolean getIsHosting() { + return is_hosting; + } + + public Boolean getIsMobile() { + return is_mobile; + } + + public Boolean getIsSatellite() { + return is_satellite; + } + + public boolean getBogon() { + return bogon; + } + + public String getCountryName() { + return context != null && geo != null ? context.getCountryName(geo.getCountryCode()) : (geo != null ? geo.getCountry() : null); + } + + public Boolean isEU() { + return context != null && geo != null ? context.isEU(geo.getCountryCode()) : null; + } + + public CountryFlag getCountryFlag() { + return context != null && geo != null ? context.getCountryFlag(geo.getCountryCode()) : null; + } + + public String getCountryFlagURL() { + return context != null && geo != null ? context.getCountryFlagURL(geo.getCountryCode()) : null; + } + + public CountryCurrency getCountryCurrency() { + return context != null && geo != null ? context.getCountryCurrency(geo.getCountryCode()) : null; + } + + public Continent getContinentInfo() { + return context != null && geo != null ? context.getContinent(geo.getCountryCode()) : null; + } + + @Override + public String toString() { + if (bogon) { + return "IPResponsePlus{" + + "ip='" + ip + '\'' + + ", bogon=" + bogon + + '}'; + } + return "IPResponsePlus{" + + "ip='" + ip + '\'' + + ", hostname='" + hostname + '\'' + + ", geo=" + geo + + ", asn=" + asn + + ", mobile=" + mobile + + ", anonymous=" + anonymous + + ", abuse=" + abuse + + ", company=" + company + + ", privacy=" + privacy + + ", domains=" + domains + + ", is_anonymous=" + is_anonymous + + ", is_anycast=" + is_anycast + + ", is_hosting=" + is_hosting + + ", is_mobile=" + is_mobile + + ", is_satellite=" + is_satellite + + '}'; + } +} diff --git a/src/main/java/io/ipinfo/api/model/Mobile.java b/src/main/java/io/ipinfo/api/model/Mobile.java new file mode 100644 index 0000000..69ca749 --- /dev/null +++ b/src/main/java/io/ipinfo/api/model/Mobile.java @@ -0,0 +1,13 @@ +package io.ipinfo.api.model; + +public class Mobile { + // Mobile can be empty object {} so all fields are optional + + public Mobile() { + } + + @Override + public String toString() { + return "Mobile{}"; + } +} diff --git a/src/main/java/io/ipinfo/api/request/IPRequestPlus.java b/src/main/java/io/ipinfo/api/request/IPRequestPlus.java new file mode 100644 index 0000000..fbe4186 --- /dev/null +++ b/src/main/java/io/ipinfo/api/request/IPRequestPlus.java @@ -0,0 +1,44 @@ +package io.ipinfo.api.request; + +import io.ipinfo.api.errors.ErrorResponseException; +import io.ipinfo.api.errors.RateLimitedException; +import io.ipinfo.api.model.IPResponsePlus; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class IPRequestPlus extends BaseRequest { + private final static String URL_FORMAT = "https://api.ipinfo.io/lookup/%s"; + private final String ip; + + public IPRequestPlus(OkHttpClient client, String token, String ip) { + super(client, token); + this.ip = ip; + } + + @Override + public IPResponsePlus handle() throws RateLimitedException { + if (IPRequest.isBogon(ip)) { + try { + return new IPResponsePlus(ip, true); + } catch (Exception ex) { + throw new ErrorResponseException(ex); + } + } + + String url = String.format(URL_FORMAT, ip); + Request.Builder request = new Request.Builder().url(url).get(); + + try (Response response = handleRequest(request)) { + if (response == null || response.body() == null) { + return null; + } + + try { + return gson.fromJson(response.body().string(), IPResponsePlus.class); + } catch (Exception ex) { + throw new ErrorResponseException(ex); + } + } + } +} diff --git a/src/test/java/io/ipinfo/IPinfoPlusTest.java b/src/test/java/io/ipinfo/IPinfoPlusTest.java new file mode 100644 index 0000000..c598d12 --- /dev/null +++ b/src/test/java/io/ipinfo/IPinfoPlusTest.java @@ -0,0 +1,128 @@ +package io.ipinfo; + +import static org.junit.jupiter.api.Assertions.*; + +import io.ipinfo.api.IPinfoPlus; +import io.ipinfo.api.errors.RateLimitedException; +import io.ipinfo.api.model.IPResponsePlus; +import org.junit.jupiter.api.Test; + +public class IPinfoPlusTest { + + @Test + public void testAccessToken() { + String token = "test_token"; + IPinfoPlus client = new IPinfoPlus.Builder() + .setToken(token) + .build(); + assertNotNull(client); + } + + @Test + public void testGoogleDNS() { + IPinfoPlus client = new IPinfoPlus.Builder() + .setToken(System.getenv("IPINFO_TOKEN")) + .build(); + + try { + IPResponsePlus response = client.lookupIP("8.8.8.8"); + assertAll( + "8.8.8.8", + () -> assertEquals("8.8.8.8", response.getIp(), "IP mismatch"), + () -> assertEquals("dns.google", response.getHostname(), "hostname mismatch"), + () -> assertNotNull(response.getGeo(), "geo should be set"), + () -> assertEquals("Mountain View", response.getGeo().getCity(), "city mismatch"), + () -> assertEquals("California", response.getGeo().getRegion(), "region mismatch"), + () -> assertEquals("CA", response.getGeo().getRegionCode(), "region code mismatch"), + () -> assertEquals("United States", response.getGeo().getCountry(), "country mismatch"), + () -> assertEquals("US", response.getGeo().getCountryCode(), "country code mismatch"), + () -> assertEquals("North America", response.getGeo().getContinent(), "continent mismatch"), + () -> assertEquals("NA", response.getGeo().getContinentCode(), "continent code mismatch"), + () -> assertNotNull(response.getGeo().getLatitude(), "latitude should be set"), + () -> assertNotNull(response.getGeo().getLongitude(), "longitude should be set"), + () -> assertEquals("America/Los_Angeles", response.getGeo().getTimezone(), "timezone mismatch"), + () -> assertEquals("94043", response.getGeo().getPostalCode(), "postal code mismatch"), + // Enriched fields + () -> assertEquals("United States", response.getCountryName(), "country name mismatch"), + () -> assertFalse(response.isEU(), "isEU mismatch"), + () -> assertEquals("🇺🇸", response.getCountryFlag().getEmoji(), "emoji mismatch"), + () -> assertEquals("U+1F1FA U+1F1F8", response.getCountryFlag().getUnicode(), "unicode mismatch"), + () -> assertEquals("https://cdn.ipinfo.io/static/images/countries-flags/US.svg", response.getCountryFlagURL(), "flag URL mismatch"), + () -> assertEquals("USD", response.getCountryCurrency().getCode(), "currency code mismatch"), + () -> assertEquals("$", response.getCountryCurrency().getSymbol(), "currency symbol mismatch"), + () -> assertEquals("NA", response.getContinentInfo().getCode(), "continent info code mismatch"), + () -> assertEquals("North America", response.getContinentInfo().getName(), "continent info name mismatch"), + // AS fields + () -> assertNotNull(response.getAsn(), "asn should be set"), + () -> assertEquals("AS15169", response.getAsn().getAsn(), "ASN mismatch"), + () -> assertEquals("Google LLC", response.getAsn().getName(), "AS name mismatch"), + () -> assertEquals("google.com", response.getAsn().getDomain(), "AS domain mismatch"), + () -> assertEquals("hosting", response.getAsn().getType(), "AS type mismatch"), + // Network flags + () -> assertFalse(response.getIsAnonymous(), "is_anonymous mismatch"), + () -> assertTrue(response.getIsAnycast(), "is_anycast mismatch"), + () -> assertTrue(response.getIsHosting(), "is_hosting mismatch"), + () -> assertFalse(response.getIsMobile(), "is_mobile mismatch"), + () -> assertFalse(response.getIsSatellite(), "is_satellite mismatch"), + // Plus-specific fields (may not be present depending on token tier) + () -> assertFalse(response.getBogon(), "bogon mismatch") + ); + } catch (RateLimitedException e) { + fail(e); + } + } + + @Test + public void testBogon() { + IPinfoPlus client = new IPinfoPlus.Builder() + .setToken(System.getenv("IPINFO_TOKEN")) + .build(); + + try { + IPResponsePlus response = client.lookupIP("127.0.0.1"); + assertAll( + "127.0.0.1", + () -> assertEquals("127.0.0.1", response.getIp(), "IP mismatch"), + () -> assertTrue(response.getBogon(), "bogon mismatch") + ); + } catch (RateLimitedException e) { + fail(e); + } + } + + @Test + public void testCloudFlareDNS() { + IPinfoPlus client = new IPinfoPlus.Builder() + .setToken(System.getenv("IPINFO_TOKEN")) + .build(); + + try { + IPResponsePlus response = client.lookupIP("1.1.1.1"); + assertAll( + "1.1.1.1", + () -> assertEquals("1.1.1.1", response.getIp(), "IP mismatch"), + () -> assertEquals("one.one.one.one", response.getHostname(), "hostname mismatch"), + () -> assertEquals("AS13335", response.getAsn().getAsn(), "ASN mismatch"), + () -> assertEquals("Cloudflare, Inc.", response.getAsn().getName(), "AS name mismatch"), + () -> assertEquals("cloudflare.com", response.getAsn().getDomain(), "AS domain mismatch"), + () -> assertNotNull(response.getGeo().getCountryCode(), "country code should be set"), + () -> assertNotNull(response.getGeo().getCountry(), "country should be set"), + () -> assertNotNull(response.getCountryName(), "country name should be set"), + () -> assertNotNull(response.getGeo().getContinentCode(), "continent code should be set"), + () -> assertNotNull(response.getGeo().getContinent(), "continent should be set"), + () -> assertNotNull(response.isEU(), "isEU should be set"), + () -> assertNotNull(response.getCountryFlag().getEmoji(), "emoji should be set"), + () -> assertNotNull(response.getCountryFlag().getUnicode(), "unicode should be set"), + () -> assertNotNull(response.getCountryFlagURL(), "flag URL should be set"), + () -> assertNotNull(response.getCountryCurrency().getCode(), "currency code should be set"), + () -> assertNotNull(response.getCountryCurrency().getSymbol(), "currency symbol should be set"), + () -> assertNotNull(response.getContinentInfo().getCode(), "continent info code should be set"), + () -> assertNotNull(response.getContinentInfo().getName(), "continent info name should be set"), + // Plus-specific fields (may not be present depending on token tier) + () -> assertFalse(response.getBogon(), "bogon mismatch") + ); + } catch (RateLimitedException e) { + fail(e); + } + } +}