From acf479278425ce19ce10b741f5b143164262122b Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Nov 2025 12:06:47 -0800 Subject: [PATCH 1/3] Ignore .claude dir --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 50761a1f..895cb5a7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.ear *.sw? *.classpath +.claude .gh-pages .idea .pmd From d69e3f434ac6fa1d42b1f49739cfeb2f0a64ae11 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Nov 2025 12:50:05 -0800 Subject: [PATCH 2/3] Add support for new anonymizer and IP risk outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Anonymizer record to InsightsResponse with VPN confidence, network last seen, and provider name fields. Adds ipRiskSnapshot field to Traits for static IP risk scores. Deprecates anonymous IP flags in Traits in favor of the new Anonymizer record for Insights responses. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 14 ++++ .../com/maxmind/geoip2/JsonSerializable.java | 2 + .../com/maxmind/geoip2/WebServiceClient.java | 4 ++ .../geoip2/model/InsightsResponse.java | 8 +++ .../com/maxmind/geoip2/record/Anonymizer.java | 71 +++++++++++++++++++ .../com/maxmind/geoip2/record/Traits.java | 31 +++++++- .../geoip2/model/InsightsResponseTest.java | 38 ++++++++++ .../com/maxmind/geoip2/model/JsonTest.java | 15 ++++ src/test/resources/test-data/insights0.json | 14 +++- src/test/resources/test-data/insights1.json | 14 +++- 10 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/maxmind/geoip2/record/Anonymizer.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f182dbbe..f4e0efe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,20 @@ CHANGELOG field. * The deprecation notices for IP Risk database support have been removed. IP Risk database support will continue to be maintained. +* A new `Anonymizer` record has been added to the `InsightsResponse` model. This + record consolidates anonymizer information including VPN confidence scores, + network last seen dates, and provider names. It includes the following fields: + `confidence`, `isAnonymous`, `isAnonymousVpn`, `isHostingProvider`, + `isPublicProxy`, `isResidentialProxy`, `isTorExitNode`, `networkLastSeen`, and + `providerName`. +* A new `ipRiskSnapshot` field has been added to the `Traits` record. This field + provides a static risk score (ranging from 0.01 to 99) associated with the IP + address. This is available from the GeoIP2 Precision Insights web service. +* The anonymous IP flags in the `Traits` record (`isAnonymous`, `isAnonymousVpn`, + `isHostingProvider`, `isPublicProxy`, `isResidentialProxy`, and `isTorExitNode`) + have been deprecated in favor of using the new `Anonymizer` record in the + `InsightsResponse`. These fields will continue to work but will be removed in + version 6.0.0. * **BREAKING:** The deprecated `WebServiceClient.Builder` methods `connectTimeout(int)`, `readTimeout(int)`, and `proxy(Proxy)` have been removed. Use `connectTimeout(Duration)`, `requestTimeout(Duration)`, and diff --git a/src/main/java/com/maxmind/geoip2/JsonSerializable.java b/src/main/java/com/maxmind/geoip2/JsonSerializable.java index 190553cd..a10a6a88 100644 --- a/src/main/java/com/maxmind/geoip2/JsonSerializable.java +++ b/src/main/java/com/maxmind/geoip2/JsonSerializable.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import java.io.IOException; @@ -20,6 +21,7 @@ public interface JsonSerializable { default String toJson() throws IOException { JsonMapper mapper = JsonMapper.builder() .disable(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .addModule(new JavaTimeModule()) .addModule(new InetAddressModule()) .serializationInclusion(JsonInclude.Include.NON_NULL) diff --git a/src/main/java/com/maxmind/geoip2/WebServiceClient.java b/src/main/java/com/maxmind/geoip2/WebServiceClient.java index c48169a9..125a3af9 100644 --- a/src/main/java/com/maxmind/geoip2/WebServiceClient.java +++ b/src/main/java/com/maxmind/geoip2/WebServiceClient.java @@ -5,7 +5,9 @@ import com.fasterxml.jackson.databind.InjectableValues; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.maxmind.geoip2.exception.AddressNotFoundException; import com.maxmind.geoip2.exception.AuthenticationException; import com.maxmind.geoip2.exception.GeoIp2Exception; @@ -133,6 +135,8 @@ private WebServiceClient(Builder builder) { mapper = JsonMapper.builder() .disable(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .addModule(new JavaTimeModule()) .build(); requestTimeout = builder.requestTimeout; diff --git a/src/main/java/com/maxmind/geoip2/model/InsightsResponse.java b/src/main/java/com/maxmind/geoip2/model/InsightsResponse.java index 2358153e..c5e67aa3 100644 --- a/src/main/java/com/maxmind/geoip2/model/InsightsResponse.java +++ b/src/main/java/com/maxmind/geoip2/model/InsightsResponse.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.maxmind.geoip2.JsonSerializable; +import com.maxmind.geoip2.record.Anonymizer; import com.maxmind.geoip2.record.City; import com.maxmind.geoip2.record.Continent; import com.maxmind.geoip2.record.Country; @@ -19,6 +20,9 @@ * This class provides a model for the data returned by the Insights web * service. * + * @param anonymizer Anonymizer record for the requested IP address. This contains information + * about whether the IP address belongs to an anonymizing network such as a VPN, + * proxy, or Tor exit node. * @param city City record for the requested IP address. * @param continent Continent record for the requested IP address. * @param country Country record for the requested IP address. This object represents the country @@ -43,6 +47,9 @@ * Services */ public record InsightsResponse( + @JsonProperty("anonymizer") + Anonymizer anonymizer, + @JsonProperty("city") City city, @@ -78,6 +85,7 @@ public record InsightsResponse( * Compact canonical constructor that sets defaults for null values. */ public InsightsResponse { + anonymizer = anonymizer != null ? anonymizer : new Anonymizer(); city = city != null ? city : new City(); continent = continent != null ? continent : new Continent(); country = country != null ? country : new Country(); diff --git a/src/main/java/com/maxmind/geoip2/record/Anonymizer.java b/src/main/java/com/maxmind/geoip2/record/Anonymizer.java new file mode 100644 index 00000000..621201c2 --- /dev/null +++ b/src/main/java/com/maxmind/geoip2/record/Anonymizer.java @@ -0,0 +1,71 @@ +package com.maxmind.geoip2.record; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.maxmind.geoip2.JsonSerializable; +import java.time.LocalDate; + +/** + *

+ * Contains data for the anonymizer record associated with an IP address. + *

+ *

+ * This record is returned by the GeoIP2 Precision Insights web service. + *

+ * + * @param confidence A score ranging from 1 to 99 that is our percent confidence that the + * network is currently part of an actively used VPN service. This is only + * available from the GeoIP2 Precision Insights web service. + * @param isAnonymous Whether the IP address belongs to any sort of anonymous network. + * @param isAnonymousVpn Whether the IP address is registered to an anonymous VPN provider. If a + * VPN provider does not register subnets under names associated with them, + * we will likely only flag their IP ranges using isHostingProvider. + * @param isHostingProvider Whether the IP address belongs to a hosting or VPN provider (see + * description of isAnonymousVpn). + * @param isPublicProxy Whether the IP address belongs to a public proxy. + * @param isResidentialProxy Whether the IP address is on a suspected anonymizing network and + * belongs to a residential ISP. + * @param isTorExitNode Whether the IP address is a Tor exit node. + * @param networkLastSeen The last day that the network was sighted in our analysis of anonymized + * networks. This is only available from the GeoIP2 Precision Insights web + * service. + * @param providerName The name of the VPN provider (e.g., NordVPN, SurfShark, etc.) associated + * with the network. This is only available from the GeoIP2 Precision Insights + * web service. + */ +public record Anonymizer( + @JsonProperty("confidence") + Integer confidence, + + @JsonProperty("is_anonymous") + boolean isAnonymous, + + @JsonProperty("is_anonymous_vpn") + boolean isAnonymousVpn, + + @JsonProperty("is_hosting_provider") + boolean isHostingProvider, + + @JsonProperty("is_public_proxy") + boolean isPublicProxy, + + @JsonProperty("is_residential_proxy") + boolean isResidentialProxy, + + @JsonProperty("is_tor_exit_node") + boolean isTorExitNode, + + @JsonProperty("network_last_seen") + LocalDate networkLastSeen, + + @JsonProperty("provider_name") + String providerName +) implements JsonSerializable { + + /** + * Constructs an {@code Anonymizer} record with {@code null} values for all the nullable + * fields and {@code false} for all boolean fields. + */ + public Anonymizer() { + this(null, false, false, false, false, false, false, null, null); + } +} diff --git a/src/main/java/com/maxmind/geoip2/record/Traits.java b/src/main/java/com/maxmind/geoip2/record/Traits.java index 2540b9d0..040895b8 100644 --- a/src/main/java/com/maxmind/geoip2/record/Traits.java +++ b/src/main/java/com/maxmind/geoip2/record/Traits.java @@ -37,14 +37,33 @@ * address for the system the code is running on. If the system is behind a * NAT, this may differ from the IP address locally assigned to it. * @param isAnonymous This is true if the IP address belongs to any sort of anonymous network. + * This field is deprecated. Please use the anonymizer object from the + * Insights response. * @param isAnonymousVpn This is true if the IP address belongs to an anonymous VPN system. + * This field is deprecated. Please use the anonymizer object from the + * Insights response. * @param isAnycast This is true if the IP address is an anycast address. * @param isHostingProvider This is true if the IP address belongs to a hosting provider. + * This field is deprecated. Please use the anonymizer object from the + * Insights response. * @param isLegitimateProxy This is true if the IP address belongs to a legitimate proxy. * @param isPublicProxy This is true if the IP address belongs to a public proxy. + * This field is deprecated. Please use the anonymizer object from the + * Insights response. * @param isResidentialProxy This is true if the IP address is on a suspected anonymizing network * and belongs to a residential ISP. + * This field is deprecated. Please use the anonymizer object from the + * Insights response. * @param isTorExitNode This is true if the IP address is a Tor exit node. + * This field is deprecated. Please use the anonymizer object from the + * Insights response. + * @param ipRiskSnapshot The risk associated with the IP address. The value ranges from 0.01 to + * 99, with a higher score indicating a higher risk. The IP risk score + * provided in GeoIP products and services is more static than the IP risk + * score provided in minFraud and is not responsive to traffic on your + * network. If you need realtime IP risk scoring based on behavioral signals + * on your own network, please use minFraud. This is only available from the + * Insights web service. * @param isp The name of the ISP associated with the IP address. This is only available from * the City Plus and Insights web services and the Enterprise database. * @param mobileCountryCode The @@ -94,10 +113,12 @@ public record Traits( @MaxMindDbIpAddress InetAddress ipAddress, + @Deprecated(since = "5.0.0", forRemoval = true) @JsonProperty("is_anonymous") @MaxMindDbParameter(name = "is_anonymous", useDefault = true) boolean isAnonymous, + @Deprecated(since = "5.0.0", forRemoval = true) @JsonProperty("is_anonymous_vpn") @MaxMindDbParameter(name = "is_anonymous_vpn", useDefault = true) boolean isAnonymousVpn, @@ -106,6 +127,7 @@ public record Traits( @MaxMindDbParameter(name = "is_anycast", useDefault = true) boolean isAnycast, + @Deprecated(since = "5.0.0", forRemoval = true) @JsonProperty("is_hosting_provider") @MaxMindDbParameter(name = "is_hosting_provider", useDefault = true) boolean isHostingProvider, @@ -114,18 +136,25 @@ public record Traits( @MaxMindDbParameter(name = "is_legitimate_proxy", useDefault = true) boolean isLegitimateProxy, + @Deprecated(since = "5.0.0", forRemoval = true) @JsonProperty("is_public_proxy") @MaxMindDbParameter(name = "is_public_proxy", useDefault = true) boolean isPublicProxy, + @Deprecated(since = "5.0.0", forRemoval = true) @JsonProperty("is_residential_proxy") @MaxMindDbParameter(name = "is_residential_proxy", useDefault = true) boolean isResidentialProxy, + @Deprecated(since = "5.0.0", forRemoval = true) @JsonProperty("is_tor_exit_node") @MaxMindDbParameter(name = "is_tor_exit_node", useDefault = true) boolean isTorExitNode, + @JsonProperty("ip_risk_snapshot") + @MaxMindDbParameter(name = "ip_risk_snapshot") + Double ipRiskSnapshot, + @JsonProperty("isp") @MaxMindDbParameter(name = "isp") String isp, @@ -168,7 +197,7 @@ public Traits() { this(null, null, (ConnectionType) null, null, null, false, false, false, false, false, false, false, false, null, - null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null); } /** diff --git a/src/test/java/com/maxmind/geoip2/model/InsightsResponseTest.java b/src/test/java/com/maxmind/geoip2/model/InsightsResponseTest.java index ae76aed2..38905e77 100644 --- a/src/test/java/com/maxmind/geoip2/model/InsightsResponseTest.java +++ b/src/test/java/com/maxmind/geoip2/model/InsightsResponseTest.java @@ -15,6 +15,7 @@ import com.maxmind.geoip2.WebServiceClient; import com.maxmind.geoip2.exception.GeoIp2Exception; import com.maxmind.geoip2.model.ConnectionTypeResponse.ConnectionType; +import com.maxmind.geoip2.record.Anonymizer; import com.maxmind.geoip2.record.Location; import com.maxmind.geoip2.record.MaxMind; import com.maxmind.geoip2.record.Postal; @@ -23,6 +24,7 @@ import java.io.IOException; import java.net.InetAddress; import java.net.URISyntaxException; +import java.time.LocalDate; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -168,6 +170,42 @@ public void testTraits() { traits.userCount(), "traits.userCount() does not return 2" ); + assertEquals( + Double.valueOf(0.01), + traits.ipRiskSnapshot(), + "traits.ipRiskSnapshot() does not return 0.01" + ); + } + + @Test + public void testAnonymizer() { + Anonymizer anonymizer = this.insights.anonymizer(); + + assertNotNull(anonymizer, "insights.anonymizer() returns null"); + assertEquals( + Integer.valueOf(99), + anonymizer.confidence(), + "anonymizer.confidence() does not return 99" + ); + assertTrue(anonymizer.isAnonymous(), "anonymizer.isAnonymous() returns true"); + assertTrue(anonymizer.isAnonymousVpn(), "anonymizer.isAnonymousVpn() returns true"); + assertTrue(anonymizer.isHostingProvider(), "anonymizer.isHostingProvider() returns true"); + assertTrue(anonymizer.isPublicProxy(), "anonymizer.isPublicProxy() returns true"); + assertTrue( + anonymizer.isResidentialProxy(), + "anonymizer.isResidentialProxy() returns true" + ); + assertTrue(anonymizer.isTorExitNode(), "anonymizer.isTorExitNode() returns true"); + assertEquals( + LocalDate.parse("2024-12-31"), + anonymizer.networkLastSeen(), + "anonymizer.networkLastSeen() does not return 2024-12-31" + ); + assertEquals( + "NordVPN", + anonymizer.providerName(), + "anonymizer.providerName() does not return NordVPN" + ); } @Test diff --git a/src/test/java/com/maxmind/geoip2/model/JsonTest.java b/src/test/java/com/maxmind/geoip2/model/JsonTest.java index 73aab8da..97e69a8b 100644 --- a/src/test/java/com/maxmind/geoip2/model/JsonTest.java +++ b/src/test/java/com/maxmind/geoip2/model/JsonTest.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.InjectableValues; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.jr.ob.JSON; import java.io.IOException; @@ -46,10 +47,22 @@ public void testInsightsSerialization() throws IOException { .put("network", "1.2.3.0/24") .put("organization", "Blorg") .put("user_type", "college") + .put("ip_risk_snapshot", 0.01) // This is here just to simplify the testing. We expect the // difference .put("is_legitimate_proxy", false) .end() + .startObjectField("anonymizer") + .put("confidence", 99) + .put("is_anonymous", true) + .put("is_anonymous_vpn", true) + .put("is_hosting_provider", true) + .put("is_public_proxy", true) + .put("is_residential_proxy", true) + .put("is_tor_exit_node", true) + .put("network_last_seen", "2024-12-31") + .put("provider_name", "NordVPN") + .end() .startObjectField("country") .startObjectField("names") .put("en", "United States of America") @@ -334,6 +347,8 @@ public void testIspSerialization() throws Exception { throws IOException { JsonMapper mapper = JsonMapper.builder() .disable(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .addModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()) .addModule(new com.maxmind.geoip2.InetAddressModule()) .build(); InjectableValues inject = new InjectableValues.Std().addValue( diff --git a/src/test/resources/test-data/insights0.json b/src/test/resources/test-data/insights0.json index 9cbe65ed..45118585 100644 --- a/src/test/resources/test-data/insights0.json +++ b/src/test/resources/test-data/insights0.json @@ -82,6 +82,18 @@ "organization": "Blorg", "static_ip_score": 1.3, "user_count": 2, - "user_type": "college" + "user_type": "college", + "ip_risk_snapshot": 0.01 + }, + "anonymizer": { + "confidence": 99, + "is_anonymous": true, + "is_anonymous_vpn": true, + "is_hosting_provider": true, + "is_public_proxy": true, + "is_residential_proxy": true, + "is_tor_exit_node": true, + "network_last_seen": "2024-12-31", + "provider_name": "NordVPN" } } \ No newline at end of file diff --git a/src/test/resources/test-data/insights1.json b/src/test/resources/test-data/insights1.json index e4b5bf4f..6cc70cba 100644 --- a/src/test/resources/test-data/insights1.json +++ b/src/test/resources/test-data/insights1.json @@ -98,6 +98,18 @@ "organization": "Blorg", "static_ip_score": 1.3, "user_count": 2, - "user_type": "college" + "user_type": "college", + "ip_risk_snapshot": 0.01 + }, + "anonymizer": { + "confidence": 99, + "is_anonymous": true, + "is_anonymous_vpn": true, + "is_hosting_provider": true, + "is_public_proxy": true, + "is_residential_proxy": true, + "is_tor_exit_node": true, + "network_last_seen": "2024-12-31", + "provider_name": "NordVPN" } } \ No newline at end of file From 865eea82095aed5d4f51a3764cc90eaba47344a7 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Nov 2025 12:58:56 -0800 Subject: [PATCH 3/3] Add CLAUDE.md documentation for AI assistants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents architectural patterns, conventions, and lessons learned for working with the GeoIP2-java codebase. Includes guidance on records, Jackson configuration, deprecation patterns, testing conventions, and common pitfalls with solutions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 406 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 406 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..11507a8e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,406 @@ +# CLAUDE.md - GeoIP2 Java API + +This document contains guidance for Claude (and other AI assistants) when working with the GeoIP2-java codebase. It captures architectural patterns, conventions, and lessons learned to help maintain consistency and quality. + +## Project Overview + +**GeoIP2-java** is MaxMind's official Java client library for: +- **GeoIP2/GeoLite2 Web Services**: Country, City, and Insights endpoints +- **GeoIP2/GeoLite2 Databases**: Local MMDB file reading for various database types (City, Country, ASN, Anonymous IP, ISP, etc.) + +The library provides both web service clients and database readers that return strongly-typed model objects containing geographic, ISP, anonymizer, and other IP-related data. + +**Key Technologies:** +- Java 17+ (using modern Java features like records) +- Jackson for JSON serialization/deserialization +- MaxMind DB reader for binary database files +- Maven for build management +- JUnit 5 for testing +- WireMock for web service testing + +## Code Architecture + +### Package Structure + +``` +com.maxmind.geoip2/ +├── model/ # Response models (CityResponse, InsightsResponse, etc.) +├── record/ # Data records (City, Location, Traits, Anonymizer, etc.) +├── exception/ # Custom exceptions for error handling +├── DatabaseReader # Local MMDB file reader +├── WebServiceClient # HTTP client for MaxMind web services +└── DatabaseProvider/WebServiceProvider interfaces +``` + +### Key Design Patterns + +#### 1. **Java Records for Immutable Data Models** +All model and record classes use Java records for immutability and conciseness: + +```java +public record Anonymizer( + @JsonProperty("confidence") + Integer confidence, + + @JsonProperty("is_anonymous") + boolean isAnonymous, + + // ... more fields +) implements JsonSerializable { + // Compact canonical constructor for defaults + public Anonymizer { + // Set defaults for null values + } +} +``` + +**Key Points:** +- Records provide automatic `equals()`, `hashCode()`, `toString()`, and accessor methods +- Use `@JsonProperty` for JSON field mapping +- Use `@MaxMindDbParameter` for database field mapping +- Implement compact canonical constructors to set defaults for null values + +#### 2. **Alphabetical Parameter Ordering** +Record parameters are **always** ordered alphabetically by field name. This maintains consistency across the codebase: + +```java +public record InsightsResponse( + Anonymizer anonymizer, // A comes first + City city, // C comes next + Continent continent, // C (alphabetically after "city") + // ... etc. +) +``` + +#### 3. **Deprecation Strategy** + +When deprecating fields: + +**For record parameters** (preferred for new deprecations): +```java +public record Traits( + @Deprecated(since = "5.0.0", forRemoval = true) + @JsonProperty("is_anonymous") + boolean isAnonymous, + // ... +) +``` + +This automatically marks the accessor method (`isAnonymous()`) as deprecated. + +**For JavaBeans-style getters** (legacy code only): +```java +@Deprecated(since = "5.0.0", forRemoval = true) +public String getUserType() { + return userType(); +} +``` + +**Do NOT add deprecated getters for new fields** - they're only needed for backward compatibility with existing fields that had JavaBeans-style getters before the record migration. + +#### 4. **Default Constructors for Record Classes** + +All record classes in `src/main/java/com/maxmind/geoip2/record/` should provide a no-arg constructor that sets sensible defaults: + +```java +public Anonymizer() { + this(null, false, false, false, false, false, false, null, null); +} +``` + +- Nullable fields → `null` +- Boolean fields → `false` + +**Note:** Model classes in `src/main/java/com/maxmind/geoip2/model/` do not require default constructors as they are typically constructed from API responses. + +#### 5. **Web Service Only vs Database Records** + +Some record classes are only used by web services and do **not** need MaxMind DB support: + +**Web Service Only Records** (no `@MaxMindDbParameter` or `@MaxMindDbConstructor`): +- Records that are exclusive to web service responses (e.g., `Anonymizer` for Insights API) +- Only need `@JsonProperty` annotations for JSON deserialization +- Simpler implementation without database parsing logic + +**Database-Supported Records** (need `@MaxMindDbParameter` and often `@MaxMindDbConstructor`): +- Records used by both web services and database files (e.g., `Traits`, `Location`, `City`) +- Need both `@JsonProperty` and `@MaxMindDbParameter` annotations +- May need `@MaxMindDbConstructor` for date parsing or other database-specific conversion + +**How to Determine:** +- Check the JavaDoc - does it say "This is only available from the X web service"? +- Look at existing similar records in the `record/` package +- If in doubt, ask - adding unnecessary database support adds complexity + +## Testing Conventions + +### Test Structure + +Tests are organized by model/class: +- `src/test/java/com/maxmind/geoip2/model/` - Response model tests +- `src/test/resources/test-data/` - JSON fixtures for tests + +### JSON Test Fixtures + +When adding new fields to responses: +1. Update the JSON fixture files in `src/test/resources/test-data/` +2. Update the corresponding test methods in `*Test.java` files +3. Update `JsonTest.java` to include the new fields in round-trip tests + +Example: Adding `anonymizer` to `InsightsResponse`: +```json +{ + "anonymizer": { + "confidence": 99, + "is_anonymous": true, + "network_last_seen": "2024-12-31", + "provider_name": "NordVPN" + }, + // ... other fields +} +``` + +### WireMock for Web Service Tests + +Web service tests use WireMock to stub HTTP responses: +```java +wireMock.stubFor(get(urlEqualTo("/geoip/v2.1/insights/1.1.1.1")) + .willReturn(aResponse() + .withStatus(200) + .withBody(readJsonFile("insights0")))); +``` + +## Working with This Codebase + +### Adding New Fields to Existing Records + +1. **Determine alphabetical position** for the new field +2. **Add the field** with proper annotations: + ```java + @JsonProperty("field_name") + @MaxMindDbParameter(name = "field_name") + Type fieldName, + ``` +3. **Update the default constructor** (if in `record/` package) to include the new parameter +4. **For minor version releases**: Add a deprecated constructor matching the old signature to avoid breaking changes (see "Avoiding Breaking Changes in Minor Versions" section) +5. **Add JavaDoc** describing the field +6. **Update test fixtures** with example data +7. **Add test assertions** to verify the field is properly deserialized + +### Adding New Records + +When creating a new record class in `src/main/java/com/maxmind/geoip2/record/`: + +1. **Determine if web service only or database-supported** (see "Web Service Only vs Database Records" section) +2. **Follow the pattern** from existing similar records (e.g., `Location`, `Traits`, or `Anonymizer`) +3. **Alphabetize parameters** by field name +4. **Add appropriate annotations**: + - All records: `@JsonProperty` + - Database-supported only: `@MaxMindDbParameter` and possibly `@MaxMindDbConstructor` +5. **Implement `JsonSerializable`** interface +6. **Add a no-arg default constructor** (see section on Default Constructors) +7. **Don't add deprecated getters** - the record accessors are sufficient +8. **Provide comprehensive JavaDoc** for all parameters + +### Deprecation Guidelines + +When deprecating fields in favor of new structures: + +1. **Use `@Deprecated` on record parameters** (not explicit methods) +2. **Include helpful deprecation messages** in JavaDoc pointing to alternatives +3. **Mark as `forRemoval = true`** with appropriate version +4. **Keep deprecated fields functional** - don't break existing code +5. **Update CHANGELOG.md** with deprecation notices + +Example deprecation message: +```java +* @param isAnonymous This is true if the IP address belongs to any sort of anonymous network. + * This field is deprecated. Please use the anonymizer object from the + * Insights response. +``` + +### CHANGELOG.md Format + +Always update `CHANGELOG.md` for user-facing changes: + +```markdown +## 5.0.0 (unreleased) + +* A new `Anonymizer` record has been added... +* A new `ipRiskSnapshot` field has been added... +* The anonymous IP flags have been deprecated... +* **BREAKING:** Description of breaking changes... +``` + +### Avoiding Breaking Changes in Minor Versions + +When adding a new field to an existing record class during a **minor version release** (e.g., 4.x.0 → 4.y.0), you must maintain backward compatibility for users who may be programmatically constructing these records. + +**The Problem:** Adding a field to a record changes the signature of the canonical constructor, which is a breaking change for existing code that constructs the record directly. + +**The Solution:** Add a deprecated constructor that matches the old signature: + +```java +public record Traits( + // ... existing fields ... + String domain, + + // NEW FIELD added in minor version (inserted in alphabetical position) + Double ipRiskSnapshot, + + String organization +) { + // Updated default constructor with new field + public Traits() { + this(null, null, null); + } + + // Deprecated constructor maintaining old signature for backward compatibility + @Deprecated(since = "4.5.0", forRemoval = true) + public Traits( + String domain, + String organization + // Note: ipRiskSnapshot is NOT in this constructor + ) { + this(domain, null, organization); // New field defaults to null (in alphabetical position) + } +} +``` + +**Key Points:** +- The deprecated constructor **matches the signature before the new field was added** +- It calls the new canonical constructor with `null` (or appropriate default) for the new field +- Mark it `@Deprecated` with `forRemoval = true` for the next major version +- Document this in CHANGELOG.md as a new feature, not a breaking change + +**For Major Versions:** You do NOT need to add the deprecated constructor - breaking changes are expected in major version bumps (e.g., 4.x.0 → 5.0.0). + +### Multi-threaded Safety + +Both `DatabaseReader` and `WebServiceClient` are **thread-safe** and should be reused across requests: +- Create once, share across threads +- Reusing clients enables connection pooling and improves performance +- Document thread-safety in JavaDoc for all client classes + +## Common Pitfalls and Solutions + +### Problem: Breaking Changes in Minor Versions +Adding a new field to a record changes the canonical constructor signature, breaking existing code. + +**Solution**: For minor version releases, add a deprecated constructor that maintains the old signature. See "Avoiding Breaking Changes in Minor Versions" section for details. + +### Problem: Record Constructor Ambiguity +When you have two constructors with similar signatures (e.g., both ending with `String`), you may get "ambiguous constructor" errors. + +**Solution**: Cast `null` parameters to their specific type: +```java +this(null, false, null); // Cast if needed: (TypeName) null +``` + +### Problem: Test Failures After Adding New Fields +After adding new fields to a response model, tests fail with deserialization errors. + +**Solution**: Update **all** related test fixtures: +1. Test JSON files (e.g., `insights0.json`, `insights1.json`) +2. In-line JSON in `JsonTest.java` +3. Test assertions in `*ResponseTest.java` files + +## Development Workflow + +### Running Tests +```bash +mvn clean test # Run all tests +mvn test -Dtest=JsonTest # Run specific test class +mvn test -Dtest=InsightsResponseTest,JsonTest # Multiple tests +``` + +### Code Style +- **Checkstyle** enforces code style (see `checkstyle.xml`) +- Run `mvn checkstyle:check` to verify compliance +- Tests must pass checkstyle to merge + +### Version Requirements +- **Java 17+** required +- Uses modern Java features (records, sealed classes potential) +- Target compatibility should match current LTS Java versions + +## Useful Patterns + +### Pattern: Compact Canonical Constructor +Use compact canonical constructors to set defaults and validate: + +```java +public record InsightsResponse( + Anonymizer anonymizer, + City city, + // ... +) { + public InsightsResponse { + anonymizer = anonymizer != null ? anonymizer : new Anonymizer(); + city = city != null ? city : new City(); + // ... + } +} +``` + +### Pattern: Empty Object Defaults +Return empty objects instead of null for better API ergonomics: + +```java +public City city() { + return city; // Never null due to compact constructor +} +``` + +Users can safely call `response.city().name()` even if city data is absent. + +### Pattern: JsonSerializable Interface +All models implement `JsonSerializable` for consistent JSON output: + +```java +public interface JsonSerializable { + default String toJson() throws IOException { + JsonMapper mapper = JsonMapper.builder() + .disable(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .addModule(new JavaTimeModule()) + .addModule(new InetAddressModule()) + .serializationInclusion(JsonInclude.Include.NON_NULL) + .build(); + return mapper.writeValueAsString(this); + } +} +``` + +## Database vs Web Service Architecture + +### Database Reader +- Reads binary MMDB files using `maxmind-db` library +- Methods return `Optional` or throw `AddressNotFoundException` +- Support for multiple database types: City, Country, ASN, Anonymous IP, etc. +- Thread-safe, should be reused + +### Web Service Client +- Uses Java 11+ `HttpClient` for HTTP requests +- Methods throw `GeoIp2Exception` or subclasses on errors +- Supports custom timeouts, locales, and proxy configuration +- Thread-safe, connection pooling via reuse + +## Key Dependencies + +- **maxmind-db**: Binary MMDB database reader +- **jackson-databind**: JSON serialization/deserialization +- **jackson-datatype-jsr310**: Java 8+ date/time support +- **wiremock**: HTTP mocking for tests +- **junit-jupiter**: JUnit 5 testing framework + +## Additional Resources + +- [API Documentation](https://maxmind.github.io/GeoIP2-java/) +- [GeoIP2 Web Services Docs](https://dev.maxmind.com/geoip/docs/web-services) +- [MaxMind DB Format](https://maxmind.github.io/MaxMind-DB/) +- GitHub Issues: https://github.com/maxmind/GeoIP2-java/issues + +--- + +*Last Updated: 2024-11-06*