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/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/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 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'