From 144d04eb83855dea82bc05d11b85354f3af78757 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Nov 2025 14:15:25 -0800 Subject: [PATCH 1/2] Add CLAUDE.md documentation for AI assistants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds guidance for Claude Code and other AI assistants when working with the codebase, including architecture patterns, testing conventions, and development workflows. Also update .gitignore to exclude Claude Code directories. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 4 + CLAUDE.md | 328 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index b4839906..9a3750a2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ t.php vendor/ *.sw? *.old + +# Claude Code directories +.claude/ +.claude_cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d439d70b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,328 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**GeoIP2-php** is MaxMind's official PHP 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, Anonymous Plus, 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:** +- PHP 8.1+ (uses modern PHP features like readonly properties and strict types) +- MaxMind DB Reader for binary database files +- MaxMind Web Service Common for HTTP client functionality +- PHPUnit for testing +- php-cs-fixer, phpcs, and phpstan for code quality + +## Code Architecture + +### Package Structure + +``` +GeoIp2/ +├── Model/ # Response models (City, Insights, AnonymousIp, etc.) +├── Record/ # Data records (City, Location, Traits, etc.) +├── Exception/ # Custom exceptions for error handling +├── Database/Reader # Local MMDB file reader +├── WebService/Client # HTTP client for MaxMind web services +└── ProviderInterface # Common interface for database and web service +``` + +### Key Design Patterns + +#### 1. **Readonly Properties for Immutable Data** +All model and record classes use PHP 8.1+ `readonly` properties for immutability and performance: + +```php +class AnonymousPlus extends AnonymousIp +{ + public readonly ?int $anonymizerConfidence; + public readonly ?string $networkLastSeen; + public readonly ?string $providerName; +} +``` + +**Key Points:** +- Properties are set in the constructor and cannot be modified afterward +- Use `readonly` keyword for all public properties +- Nullable properties use `?Type` syntax +- Non-nullable booleans typically default to `false` in constructor logic + +#### 2. **Inheritance Hierarchies** + +Models follow clear inheritance patterns: +- `Country` → base model with country/continent data +- `City` extends `Country` → adds city, location, postal, subdivisions +- `Insights` extends `City` → adds additional web service fields +- `Enterprise` extends `City` → adds enterprise-specific fields + +Records have similar patterns: +- `AbstractNamedRecord` → base with names/locales +- `AbstractPlaceRecord` extends `AbstractNamedRecord` → adds confidence, geonameId +- Specific records (`City`, `Country`, etc.) extend these abstracts + +#### 3. **JsonSerializable Implementation** + +All model and record classes implement `\JsonSerializable` for consistent JSON output: + +```php +public function jsonSerialize(): ?array +{ + $js = parent::jsonSerialize(); + + if ($this->anonymizerConfidence !== null) { + $js['anonymizer_confidence'] = $this->anonymizerConfidence; + } + + return $js; +} +``` + +- Only include non-null values in JSON output +- Use snake_case for JSON keys (matching API format) +- Properties use camelCase in PHP + +#### 4. **Constructor Array Parameter Pattern** + +Models and records are constructed from associative arrays (from JSON/DB): + +```php +public function __construct(array $raw) +{ + parent::__construct($raw); + $this->anonymizerConfidence = $raw['anonymizer_confidence'] ?? null; + $this->networkLastSeen = $raw['network_last_seen'] ?? null; +} +``` + +- Use `$raw['snake_case_key'] ?? null` pattern for optional fields +- Use `$raw['snake_case_key'] ?? false` for boolean fields +- Call parent constructor first if extending another class + +#### 5. **Web Service Only vs Database Models** + +Some models are only used by web services and do **not** need MaxMind DB support: + +**Web Service Only Models**: +- Models that are exclusive to web service responses +- Simpler implementation without database parsing logic +- Example: `Insights` (extends City but used only for web service) + +**Database-Supported Models**: +- Models used by both web services and database files +- Must handle MaxMind DB format data structures +- Example: `City`, `Country`, `AnonymousIp`, `AnonymousPlus` + +## Testing Conventions + +### Running Tests + +```bash +# Install dependencies +composer install + +# Run all tests +vendor/bin/phpunit + +# Run specific test class +vendor/bin/phpunit tests/GeoIp2/Test/Model/InsightsTest.php + +# Run with coverage (if xdebug installed) +vendor/bin/phpunit --coverage-html coverage/ +``` + +### Linting and Static Analysis + +```bash +# PHP-CS-Fixer (code style) +vendor/bin/php-cs-fixer fix --verbose --diff --dry-run + +# Apply fixes +vendor/bin/php-cs-fixer fix + +# PHPCS (PSR-2 compliance) +vendor/bin/phpcs --standard=PSR2 src/ + +# PHPStan (static analysis) +vendor/bin/phpstan analyze + +# Validate composer.json +composer validate +``` + +### Test Structure + +Tests are organized by model/class: +- `tests/GeoIp2/Test/Database/` - Database reader tests +- `tests/GeoIp2/Test/Model/` - Response model tests +- `tests/GeoIp2/Test/WebService/` - Web service client tests + +### Test Patterns + +When adding new fields to models: +1. Update the test method to include the new field in the `$raw` array +2. Add assertions to verify the field is properly populated +3. Test both presence and absence of the field (null handling) +4. Verify JSON serialization includes the field correctly + +Example: +```php +public function testFull(): void +{ + $raw = [ + 'anonymizer_confidence' => 99, + 'network_last_seen' => '2025-04-14', + 'provider_name' => 'FooBar VPN', + // ... other fields + ]; + + $model = new AnonymousPlus($raw); + + $this->assertSame(99, $model->anonymizerConfidence); + $this->assertSame('2025-04-14', $model->networkLastSeen); + $this->assertSame('FooBar VPN', $model->providerName); +} +``` + +## Working with This Codebase + +### Adding New Fields to Existing Models + +1. **Add the readonly property** with proper type hints and PHPDoc: + ```php + /** + * @var int|null description of the field + */ + public readonly ?int $fieldName; + ``` +2. **Update the constructor** to set the field from the raw array: + ```php + $this->fieldName = $raw['field_name'] ?? null; + ``` +3. **Update `jsonSerialize()`** to include the field: + ```php + if ($this->fieldName !== null) { + $js['field_name'] = $this->fieldName; + } + ``` +4. **Add comprehensive PHPDoc** describing the field, its source, and availability +5. **Update tests** to include the new field in test data and assertions +6. **Update CHANGELOG.md** with the change + +### Adding New Models + +When creating a new model class: + +1. **Determine if web service only or database-supported** +2. **Follow the pattern** from existing similar models +3. **Extend the appropriate base class** (e.g., `Country`, `City`, or standalone) +4. **Use `readonly` properties** for all public fields +5. **Implement `\JsonSerializable`** interface +6. **Provide comprehensive PHPDoc** for all properties +7. **Add corresponding tests** with full coverage + +### Deprecation Guidelines + +When deprecating fields: + +1. **Use `@deprecated` in PHPDoc** with version and alternative: + ```php + /** + * @var bool This field is deprecated as of version 3.2.0. + * Use the anonymizer object from the Insights response instead. + * + * @deprecated since 3.2.0 + */ + public readonly bool $isAnonymous; + ``` +2. **Keep deprecated fields functional** - don't break existing code +3. **Update CHANGELOG.md** with deprecation notices +4. **Document alternatives** in the deprecation message + +### CHANGELOG.md Format + +Always update `CHANGELOG.md` for user-facing changes. + +**Important**: Do not add a date to changelog entries until release time. + +- If there's an existing version entry without a date (e.g., `3.3.0 (unreleased)`), add your changes there +- If creating a new version entry, use `(unreleased)` instead of a date +- The release date will be added when the version is actually released + +```markdown +3.3.0 (unreleased) +------------------ + +* A new `fieldName` property has been added to `GeoIp2\Model\ModelName`. + This field provides information about... +* The `oldField` property in `GeoIp2\Model\ModelName` has been deprecated. + Please use `newField` instead. +``` + +## Common Pitfalls and Solutions + +### Problem: Incorrect Property Types +Using wrong type hints can cause type errors or allow invalid data. + +**Solution**: Follow these patterns: +- Optional values: `?Type` (e.g., `?int`, `?string`) +- Non-null booleans: `bool` (default to `false` in constructor if not present) +- Arrays: `array` with PHPDoc specifying structure (e.g., `@var array`) + +### Problem: Missing JSON Serialization +New fields not appearing in JSON output. + +**Solution**: Always update `jsonSerialize()` to include new fields: +- Check if the value is not null before adding to array +- Use snake_case for JSON keys to match API format +- Call parent's `jsonSerialize()` first if extending + +### Problem: Test Failures After Adding Fields +Tests fail because fixtures don't include new fields. + +**Solution**: Update all related tests: +1. Add field to test `$raw` array +2. Add assertions for the new field +3. Test null case if field is optional +4. Verify JSON serialization + +## Code Style Requirements + +- **PSR-2 compliance** enforced by phpcs +- **PHP-CS-Fixer** rules defined in `.php-cs-fixer.php` +- **Strict types** (`declare(strict_types=1)`) in all files +- **Yoda style disabled** - use normal comparison order (`$var === $value`) +- **Strict comparison** required (`===` and `!==` instead of `==` and `!=`) +- **No trailing whitespace** +- **Unix line endings (LF)** + +## Development Workflow + +### Setup +```bash +composer install +``` + +### Before Committing +```bash +# Run all checks +vendor/bin/php-cs-fixer fix +vendor/bin/phpcs --standard=PSR2 src/ +vendor/bin/phpstan analyze +vendor/bin/phpunit +``` + +### Version Requirements +- **PHP 8.1+** required +- Uses modern PHP features (readonly, union types, etc.) +- Target compatibility should match current supported PHP versions (8.1-8.4) + +## Additional Resources + +- [API Documentation](https://maxmind.github.io/GeoIP2-php/) +- [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-php/issues From 341119ef62b5795754b2366d012fdefb28581d84 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 6 Nov 2025 14:44:42 -0800 Subject: [PATCH 2/2] Add anonymizer and IP risk data to Insights web service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds support for the new anonymizer object and IP risk snapshot field in the GeoIP2 Insights web service response. Key changes: - Add new Anonymizer record class with VPN detection fields (confidence, provider name, network last seen, and anonymity flags) - Add anonymizer property to Insights model - Add ipRiskSnapshot field to Traits record for static IP risk scoring - Deprecate anonymous IP flags in Traits (isAnonymous, isAnonymousVpn, isHostingProvider, isPublicProxy, isResidentialProxy, isTorExitNode) in favor of the new anonymizer object - Update tests to cover new fields - Update CHANGELOG for version 3.3.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 19 ++++ src/Model/Insights.php | 41 +++++++- src/Record/Anonymizer.php | 128 +++++++++++++++++++++++ src/Record/Traits.php | 27 +++++ tests/GeoIp2/Test/Model/InsightsTest.php | 84 +++++++++++++++ 5 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 src/Record/Anonymizer.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ef655e32..9c1dad7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,25 @@ CHANGELOG ========= +3.3.0 (unreleased) +------------------ + +* A new `anonymizer` property has been added to `GeoIp2\Model\Insights`. + This property is an instance of `GeoIp2\Record\Anonymizer` and provides + information about whether the IP address belongs to an anonymous network, + VPN provider details (including `confidence`, `providerName`, and + `networkLastSeen`), and various anonymity flags. This data is available + from the GeoIP2 Insights web service. +* A new `ipRiskSnapshot` property has been added to `GeoIp2\Record\Traits`. + This property provides a risk score from 0.01 to 99.99 indicating the risk + associated with the IP address. Higher values indicate higher risk. This is + a static snapshot that is less dynamic than minFraud risk scoring. This + attribute is only available from the GeoIP2 Insights web service. +* The `isAnonymous`, `isAnonymousVpn`, `isHostingProvider`, `isPublicProxy`, + `isResidentialProxy`, and `isTorExitNode` properties in + `GeoIp2\Record\Traits` have been deprecated. Please use the corresponding + properties in the new `anonymizer` object in the Insights response instead. + 3.2.0 (2025-05-05) ------------------ diff --git a/src/Model/Insights.php b/src/Model/Insights.php index 2f5a2875..a8b42673 100644 --- a/src/Model/Insights.php +++ b/src/Model/Insights.php @@ -4,11 +4,48 @@ namespace GeoIp2\Model; +use GeoIp2\Record\Anonymizer; + /** * Model class for the data returned by GeoIP2 Insights web service. * * See https://dev.maxmind.com/geoip/docs/web-services?lang=en for * more details. */ -// phpcs:disable -class Insights extends City {} +class Insights extends City +{ + /** + * @var Anonymizer Anonymizer data for the requested IP address. This + * includes information about whether the IP belongs to an anonymous + * network, VPN provider details, and confidence scores. + */ + public readonly Anonymizer $anonymizer; + + /** + * @ignore + * + * @param array $raw + * @param list $locales + */ + public function __construct(array $raw, array $locales = ['en']) + { + parent::__construct($raw, $locales); + + $this->anonymizer = new Anonymizer($raw['anonymizer'] ?? []); + } + + /** + * @return array|null + */ + public function jsonSerialize(): ?array + { + $js = parent::jsonSerialize(); + + $anonymizer = $this->anonymizer->jsonSerialize(); + if (!empty($anonymizer)) { + $js['anonymizer'] = $anonymizer; + } + + return $js; + } +} diff --git a/src/Record/Anonymizer.php b/src/Record/Anonymizer.php new file mode 100644 index 00000000..bad86706 --- /dev/null +++ b/src/Record/Anonymizer.php @@ -0,0 +1,128 @@ + $record + */ + public function __construct(array $record) + { + $this->confidence = $record['confidence'] ?? null; + $this->isAnonymous = $record['is_anonymous'] ?? false; + $this->isAnonymousVpn = $record['is_anonymous_vpn'] ?? false; + $this->isHostingProvider = $record['is_hosting_provider'] ?? false; + $this->isPublicProxy = $record['is_public_proxy'] ?? false; + $this->isResidentialProxy = $record['is_residential_proxy'] ?? false; + $this->isTorExitNode = $record['is_tor_exit_node'] ?? false; + $this->networkLastSeen = $record['network_last_seen'] ?? null; + $this->providerName = $record['provider_name'] ?? null; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $js = []; + + if ($this->confidence !== null) { + $js['confidence'] = $this->confidence; + } + if ($this->isAnonymous !== false) { + $js['is_anonymous'] = $this->isAnonymous; + } + if ($this->isAnonymousVpn !== false) { + $js['is_anonymous_vpn'] = $this->isAnonymousVpn; + } + if ($this->isHostingProvider !== false) { + $js['is_hosting_provider'] = $this->isHostingProvider; + } + if ($this->isPublicProxy !== false) { + $js['is_public_proxy'] = $this->isPublicProxy; + } + if ($this->isResidentialProxy !== false) { + $js['is_residential_proxy'] = $this->isResidentialProxy; + } + if ($this->isTorExitNode !== false) { + $js['is_tor_exit_node'] = $this->isTorExitNode; + } + if ($this->networkLastSeen !== null) { + $js['network_last_seen'] = $this->networkLastSeen; + } + if ($this->providerName !== null) { + $js['provider_name'] = $this->providerName; + } + + return $js; + } +} diff --git a/src/Record/Traits.php b/src/Record/Traits.php index d75a8f71..c3a9fb82 100644 --- a/src/Record/Traits.php +++ b/src/Record/Traits.php @@ -63,6 +63,8 @@ class Traits implements \JsonSerializable * @var bool This is true if the IP address belongs to * any sort of anonymous network. This property is only available from GeoIP2 * Insights. + * + * @deprecated use $anonymizer->isAnonymous in the Insights response instead */ public readonly bool $isAnonymous; @@ -72,6 +74,8 @@ class Traits implements \JsonSerializable * subnets under names associated with them, we will likely only flag their IP * ranges using the isHostingProvider property. This property is only available * from GeoIP2 Insights. + * + * @deprecated use $anonymizer->isAnonymousVpn in the Insights response instead */ public readonly bool $isAnonymousVpn; @@ -86,6 +90,8 @@ class Traits implements \JsonSerializable * @var bool This is true if the IP address belongs * to a hosting or VPN provider (see description of isAnonymousVpn property). * This property is only available from GeoIP2 Insights. + * + * @deprecated use $anonymizer->isHostingProvider in the Insights response instead */ public readonly bool $isHostingProvider; @@ -100,6 +106,8 @@ class Traits implements \JsonSerializable /** * @var bool This is true if the IP address belongs to * a public proxy. This property is only available from GeoIP2 Insights. + * + * @deprecated use $anonymizer->isPublicProxy in the Insights response instead */ public readonly bool $isPublicProxy; @@ -107,12 +115,16 @@ class Traits implements \JsonSerializable * @var bool This is true if the IP address is * on a suspected anonymizing network and belongs to a residential ISP. This * property is only available from GeoIP2 Insights. + * + * @deprecated use $anonymizer->isResidentialProxy in the Insights response instead */ public readonly bool $isResidentialProxy; /** * @var bool This is true if the IP address is a Tor * exit node. This property is only available from GeoIP2 Insights. + * + * @deprecated use $anonymizer->isTorExitNode in the Insights response instead */ public readonly bool $isTorExitNode; @@ -153,6 +165,17 @@ class Traits implements \JsonSerializable */ public readonly ?string $organization; + /** + * @var float|null A risk score from 0.01 to 99 indicating the risk associated with the + * IP address. A higher score indicates a higher risk. Please note that 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 attribute is only available from + * the GeoIP2 Insights web service. + */ + public readonly ?float $ipRiskSnapshot; + /** * @var float|null An indicator of how static or * dynamic an IP address is. This property is only available from GeoIP2 @@ -220,6 +243,7 @@ public function __construct(array $record) $this->mobileCountryCode = $record['mobile_country_code'] ?? null; $this->mobileNetworkCode = $record['mobile_network_code'] ?? null; $this->organization = $record['organization'] ?? null; + $this->ipRiskSnapshot = $record['ip_risk_snapshot'] ?? null; $this->staticIpScore = $record['static_ip_score'] ?? null; $this->userCount = $record['user_count'] ?? null; $this->userType = $record['user_type'] ?? null; @@ -291,6 +315,9 @@ public function jsonSerialize(): array if ($this->organization !== null) { $js['organization'] = $this->organization; } + if ($this->ipRiskSnapshot !== null) { + $js['ip_risk_snapshot'] = $this->ipRiskSnapshot; + } if ($this->staticIpScore !== null) { $js['static_ip_score'] = $this->staticIpScore; } diff --git a/tests/GeoIp2/Test/Model/InsightsTest.php b/tests/GeoIp2/Test/Model/InsightsTest.php index 872c47ac..f2cb9f68 100644 --- a/tests/GeoIp2/Test/Model/InsightsTest.php +++ b/tests/GeoIp2/Test/Model/InsightsTest.php @@ -17,6 +17,17 @@ class InsightsTest extends TestCase public function testFull(): void { $raw = [ + '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' => '2025-04-14', + 'provider_name' => 'NordVPN', + ], 'city' => [ 'confidence' => 76, 'geoname_id' => 9876, @@ -74,6 +85,7 @@ public function testFull(): void 'connection_type' => 'Cable/DSL', 'domain' => 'example.com', 'ip_address' => '1.2.3.4', + 'ip_risk_snapshot' => 15.37, 'is_anonymous' => true, 'is_anonymous_vpn' => true, 'is_anycast' => true, @@ -166,6 +178,66 @@ public function testFull(): void '$model->traits' ); + $this->assertInstanceOf( + 'GeoIp2\Record\Anonymizer', + $model->anonymizer, + '$model->anonymizer' + ); + + $this->assertSame( + 99, + $model->anonymizer->confidence, + '$model->anonymizer->confidence is 99' + ); + + $this->assertTrue( + $model->anonymizer->isAnonymous, + '$model->anonymizer->isAnonymous is true' + ); + + $this->assertTrue( + $model->anonymizer->isAnonymousVpn, + '$model->anonymizer->isAnonymousVpn is true' + ); + + $this->assertTrue( + $model->anonymizer->isHostingProvider, + '$model->anonymizer->isHostingProvider is true' + ); + + $this->assertTrue( + $model->anonymizer->isPublicProxy, + '$model->anonymizer->isPublicProxy is true' + ); + + $this->assertTrue( + $model->anonymizer->isResidentialProxy, + '$model->anonymizer->isResidentialProxy is true' + ); + + $this->assertTrue( + $model->anonymizer->isTorExitNode, + '$model->anonymizer->isTorExitNode is true' + ); + + $this->assertSame( + '2025-04-14', + $model->anonymizer->networkLastSeen, + '$model->anonymizer->networkLastSeen is 2025-04-14' + ); + + $this->assertSame( + 'NordVPN', + $model->anonymizer->providerName, + '$model->anonymizer->providerName is NordVPN' + ); + + $this->assertSame( + 15.37, + $model->traits->ipRiskSnapshot, + '$model->traits->ipRiskSnapshot is 15.37' + ); + $this->assertTrue( $model->traits->isAnonymous, '$model->traits->isAnonymous is true' @@ -272,6 +344,7 @@ public function testFull(): void 'mobile_network_code' => '004', 'network' => '1.2.3.0/24', 'organization' => 'Blorg', + 'ip_risk_snapshot' => 15.37, 'static_ip_score' => 1.3, 'user_count' => 2, 'user_type' => 'college', @@ -302,6 +375,17 @@ public function testFull(): void 'iso_code' => 'MN', ], ], + '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' => '2025-04-14', + 'provider_name' => 'NordVPN', + ], ], $model->jsonSerialize(), 'jsonSerialize returns initial array'