From be47d40ab258e3fe6ebde43d0f4cdb4bd12f6509 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 10 Jul 2026 20:53:22 +0000 Subject: [PATCH] Add residential property to Anonymizer record Add a residential sub-object to the anonymizer object with confidence, networkLastSeen, and providerName. Residential proxy data may be present even when the other anonymizer properties are unset, so the anonymizer object may now be returned with only this property set. The new GeoIp2\Record\AnonymizerFeed class is written to be reused if the server adds sibling sub-objects (vpn, mobile, datacenter) with the same shape. STF-997 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 +- src/Record/Anonymizer.php | 13 +++ src/Record/AnonymizerFeed.php | 67 +++++++++++++ tests/GeoIp2/Test/Model/InsightsTest.php | 120 +++++++++++++++++++++++ 4 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 src/Record/AnonymizerFeed.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e79bb3f..210994c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,15 @@ CHANGELOG ========= -3.3.1 (unreleased) +3.4.0 (unreleased) ------------------ +* A new `residential` property has been added to `GeoIp2\Record\Anonymizer`. + This property is an instance of the new `GeoIp2\Record\AnonymizerFeed` + class and provides residential proxy data for the network, including + `confidence`, `networkLastSeen`, and `providerName`. This may be the only + property with data even when the other anonymizer properties are unset. + This data is available from the GeoIP Insights web service. * Updated the `GeoIp2\Model\City` constructor to handle an empty `subdivisions` array. This provides compatibility with some third-party databases that publish empty subdivisions arrays. Pull request by Jarek diff --git a/src/Record/Anonymizer.php b/src/Record/Anonymizer.php index 79d0f69a..3a8ee59b 100644 --- a/src/Record/Anonymizer.php +++ b/src/Record/Anonymizer.php @@ -70,6 +70,14 @@ class Anonymizer implements \JsonSerializable */ public readonly ?string $providerName; + /** + * @var AnonymizerFeed Residential proxy data for the network. This may be the only + * property with data even when the other anonymizer properties are + * unset. This attribute is only available from the GeoIP Insights + * web service. + */ + public readonly AnonymizerFeed $residential; + /** * @ignore * @@ -86,6 +94,7 @@ public function __construct(array $record) $this->isTorExitNode = $record['is_tor_exit_node'] ?? false; $this->networkLastSeen = $record['network_last_seen'] ?? null; $this->providerName = $record['provider_name'] ?? null; + $this->residential = new AnonymizerFeed($record['residential'] ?? []); } /** @@ -122,6 +131,10 @@ public function jsonSerialize(): array if ($this->providerName !== null) { $js['provider_name'] = $this->providerName; } + $residential = $this->residential->jsonSerialize(); + if (!empty($residential)) { + $js['residential'] = $residential; + } return $js; } diff --git a/src/Record/AnonymizerFeed.php b/src/Record/AnonymizerFeed.php new file mode 100644 index 00000000..f59329fd --- /dev/null +++ b/src/Record/AnonymizerFeed.php @@ -0,0 +1,67 @@ + $record + */ + public function __construct(array $record) + { + $this->confidence = $record['confidence'] ?? null; + $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->networkLastSeen !== null) { + $js['network_last_seen'] = $this->networkLastSeen; + } + if ($this->providerName !== null) { + $js['provider_name'] = $this->providerName; + } + + return $js; + } +} diff --git a/tests/GeoIp2/Test/Model/InsightsTest.php b/tests/GeoIp2/Test/Model/InsightsTest.php index 195623b9..804889e7 100644 --- a/tests/GeoIp2/Test/Model/InsightsTest.php +++ b/tests/GeoIp2/Test/Model/InsightsTest.php @@ -27,6 +27,11 @@ public function testFull(): void 'is_tor_exit_node' => true, 'network_last_seen' => '2025-04-14', 'provider_name' => 'NordVPN', + 'residential' => [ + 'confidence' => 82, + 'network_last_seen' => '2026-05-11', + 'provider_name' => 'quickshift', + ], ], 'city' => [ 'confidence' => 76, @@ -232,6 +237,30 @@ public function testFull(): void '$model->anonymizer->providerName is NordVPN' ); + $this->assertInstanceOf( + 'GeoIp2\Record\AnonymizerFeed', + $model->anonymizer->residential, + '$model->anonymizer->residential' + ); + + $this->assertSame( + 82, + $model->anonymizer->residential->confidence, + '$model->anonymizer->residential->confidence is 82' + ); + + $this->assertSame( + '2026-05-11', + $model->anonymizer->residential->networkLastSeen, + '$model->anonymizer->residential->networkLastSeen is 2026-05-11' + ); + + $this->assertSame( + 'quickshift', + $model->anonymizer->residential->providerName, + '$model->anonymizer->residential->providerName is quickshift' + ); + $this->assertSame( 15.37, $model->traits->ipRiskSnapshot, @@ -385,6 +414,11 @@ public function testFull(): void 'is_tor_exit_node' => true, 'network_last_seen' => '2025-04-14', 'provider_name' => 'NordVPN', + 'residential' => [ + 'confidence' => 82, + 'network_last_seen' => '2026-05-11', + 'provider_name' => 'quickshift', + ], ], ], $model->jsonSerialize(), @@ -392,6 +426,65 @@ public function testFull(): void ); } + public function testAnonymizerResidentialOnly(): void + { + // Residential proxy data may be present even when the other + // anonymizer properties are unset, so the anonymizer object may + // be returned with only the residential key set. + $raw = [ + 'traits' => ['ip_address' => '6.0.42.17'], + 'anonymizer' => [ + 'residential' => [ + 'confidence' => 95, + 'network_last_seen' => '2026-05-14', + 'provider_name' => 'novada', + ], + ], + ]; + + $model = new Insights($raw, ['en']); + + $this->assertNull( + $model->anonymizer->confidence, + '$model->anonymizer->confidence is null' + ); + + $this->assertFalse( + $model->anonymizer->isAnonymous, + '$model->anonymizer->isAnonymous is false' + ); + + $this->assertInstanceOf( + 'GeoIp2\Record\AnonymizerFeed', + $model->anonymizer->residential, + '$model->anonymizer->residential' + ); + + $this->assertSame( + 95, + $model->anonymizer->residential->confidence, + '$model->anonymizer->residential->confidence is 95' + ); + + $this->assertSame( + '2026-05-14', + $model->anonymizer->residential->networkLastSeen, + '$model->anonymizer->residential->networkLastSeen is 2026-05-14' + ); + + $this->assertSame( + 'novada', + $model->anonymizer->residential->providerName, + '$model->anonymizer->residential->providerName is novada' + ); + + $this->assertSame( + $raw, + $model->jsonSerialize(), + 'jsonSerialize returns only the residential key in anonymizer' + ); + } + public function testEmptyObjects(): void { $raw = ['traits' => ['ip_address' => '5.6.7.8', 'network' => '5.6.7.0/24']]; @@ -458,6 +551,33 @@ public function testEmptyObjects(): void '$model->traits' ); + $this->assertInstanceOf( + 'GeoIp2\Record\Anonymizer', + $model->anonymizer, + '$model->anonymizer' + ); + + $this->assertInstanceOf( + 'GeoIp2\Record\AnonymizerFeed', + $model->anonymizer->residential, + '$model->anonymizer->residential' + ); + + $this->assertNull( + $model->anonymizer->residential->confidence, + '$model->anonymizer->residential->confidence is null' + ); + + $this->assertNull( + $model->anonymizer->residential->networkLastSeen, + '$model->anonymizer->residential->networkLastSeen is null' + ); + + $this->assertNull( + $model->anonymizer->residential->providerName, + '$model->anonymizer->residential->providerName is null' + ); + $this->assertSame( $raw, $model->jsonSerialize(),