From 51bfcb3615f5291e7612fdd3312e9f7278ef4d0b Mon Sep 17 00:00:00 2001 From: EgorMajj <91486022+EgorMajj@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:07:47 +0300 Subject: [PATCH 1/2] feat: connect supported client to generated OpenAPI wire types --- .gitattributes | 1 + .github/workflows/ci.yml | 4 + .github/workflows/release.yml | 4 + .php-cs-fixer.dist.php | 2 +- CHANGELOG.md | 8 +- CONTRIBUTING.md | 14 + README.md | 18 +- composer.json | 12 +- scripts/check-contract-drift.php | 223 ++++++++ scripts/check-package.php | 40 ++ scripts/generate-wire.php | 198 +++++++ src/Internal/Normalizer.php | 218 +++++--- src/Internal/Wire/Field.php | 23 + .../Wire/Generated/DetectionFlags.php | 182 +++++++ src/Internal/Wire/Generated/DomainProfile.php | 65 +++ src/Internal/Wire/Generated/HistoryPage.php | 29 ++ .../Wire/Generated/HistoryParameters.php | 57 ++ src/Internal/Wire/Generated/HistoryRow.php | 488 ++++++++++++++++++ src/Internal/Wire/Generated/IpInfo.php | 29 ++ src/Internal/Wire/Generated/LocalIpInfo.php | 29 ++ .../Wire/Generated/ProfileParameters.php | 26 + src/Internal/Wire/Generated/ScoreDetail.php | 29 ++ src/Internal/Wire/Generated/ScoredData.php | 173 +++++++ src/Internal/Wire/Generated/Signal.php | 29 ++ src/Internal/Wire/Generated/TrafficSource.php | 92 ++++ .../Wire/Generated/WebhookEnvelope.php | 47 ++ src/Internal/Wire/Read.php | 68 +++ src/Model/DomainProfile.php | 14 +- src/Model/HistoryPage.php | 7 +- src/Resource/History.php | 8 +- src/ShieldLabsManagement.php | 6 +- src/Webhook.php | 11 +- tests/Package/consumer.php | 72 +++ tests/Unit/GeneratedContractTest.php | 82 +++ 34 files changed, 2204 insertions(+), 104 deletions(-) create mode 100644 scripts/check-contract-drift.php create mode 100644 scripts/check-package.php create mode 100644 scripts/generate-wire.php create mode 100644 src/Internal/Wire/Field.php create mode 100644 src/Internal/Wire/Generated/DetectionFlags.php create mode 100644 src/Internal/Wire/Generated/DomainProfile.php create mode 100644 src/Internal/Wire/Generated/HistoryPage.php create mode 100644 src/Internal/Wire/Generated/HistoryParameters.php create mode 100644 src/Internal/Wire/Generated/HistoryRow.php create mode 100644 src/Internal/Wire/Generated/IpInfo.php create mode 100644 src/Internal/Wire/Generated/LocalIpInfo.php create mode 100644 src/Internal/Wire/Generated/ProfileParameters.php create mode 100644 src/Internal/Wire/Generated/ScoreDetail.php create mode 100644 src/Internal/Wire/Generated/ScoredData.php create mode 100644 src/Internal/Wire/Generated/Signal.php create mode 100644 src/Internal/Wire/Generated/TrafficSource.php create mode 100644 src/Internal/Wire/Generated/WebhookEnvelope.php create mode 100644 src/Internal/Wire/Read.php create mode 100644 tests/Package/consumer.php create mode 100644 tests/Unit/GeneratedContractTest.php diff --git a/.gitattributes b/.gitattributes index 30fbf30..8d56189 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,6 +15,7 @@ /phpunit.xml.dist export-ignore /CONTRIBUTING.md export-ignore generated/** linguist-generated +src/Internal/Wire/Generated/** linguist-generated resources/shieldlabs-api.yaml linguist-generated sync.sh text eol=lf generate.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 759d6f1..71a74f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,10 @@ jobs: run: composer update --no-interaction --no-progress --prefer-dist - name: PHPStan run: vendor/bin/phpstan analyse --no-progress + - name: Supported wire contract freshness and mutation checks + run: composer check:wire && composer check:contract + - name: Fresh runtime-only package consumer + run: composer check:package - name: php-cs-fixer run: vendor/bin/php-cs-fixer check --diff - name: Coverage diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c460ec5..0a54ea1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,10 @@ jobs: run: composer update --no-interaction --no-progress --prefer-dist - name: Run the test suite run: vendor/bin/phpunit + - name: Supported wire contract freshness and mutation checks + run: composer check:wire && composer check:contract + - name: Fresh runtime-only package consumer + run: composer check:package # Runs no Composer and no PHP code: only this job can write to the repository. publish: diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index b9b8038..47a40f3 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -3,7 +3,7 @@ declare(strict_types=1); $finder = (new PhpCsFixer\Finder()) - ->in([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/examples']) + ->in([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/examples', __DIR__ . '/scripts']) ->append([__FILE__]); return (new PhpCsFixer\Config()) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d01630..7031660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,13 @@ follows [Semantic Versioning](https://semver.org/). ### Added -- `sync.sh` downloads the OpenAPI description and `generate.sh` rebuilds `generated/` from it. The supported client is unchanged. +- The supported client consumes generated OpenAPI wire fields for History, profiles, + webhooks and request parameters, with compile-time contract drift checks and + unchanged tolerant normalization and raw payload access. +- `composer generate:wire`, `composer check:wire` and `composer check:contract` + reproduce the wire layer and test incompatible and additive schema changes. +- `sync.sh` downloads the OpenAPI description and `generate.sh` rebuilds the + optional standalone reference client in `generated/`. ## [1.0.0] - 2026-09-30 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d33d46..bd6c7c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,20 @@ composer install composer check # tests, static analysis and code style ``` +After updating `resources/shieldlabs-api.yaml`, run `composer generate:wire`. +The supported client consumes these typed fields, not the standalone transport in +`generated/`. `composer check:wire` checks reproducibility. `composer check:contract` +copies the actual supported source into a disposable build directory and verifies +that renamed fields and incompatible types fail PHPStan, while an optional extra +field passes. Neither check calls a live API or modifies the input schema. +The checks also reject HTTP-method changes on supported GET operations, +unsupported required parameters and parameter-location +changes, guard the shared ping/scored envelope, and execute the supported client +against a changed profile path and an optional extra History query parameter. +`composer check:package` creates an archive and installs it into a fresh runtime-only +consumer under `build/`. It downloads only public dependencies and tests History, +profiles and signed webhooks against synthetic responses. It does not call a live API. + Without a local PHP, run the same inside Docker: ```bash diff --git a/README.md b/README.md index ac3c58b..a3ffcb2 100644 --- a/README.md +++ b/README.md @@ -429,13 +429,27 @@ Enums: `LookupType`, `RiskBand` (`Trusted`, `Suspicious`, `Dangerous`, `RateLimi ## Development -Refresh the generated client when the API description changes. This does not replace the supported library in this repository. +The supported client reads History, profile and webhook data through the generated +wire fields in `src/Internal/Wire/Generated/`. Their declared types are checked by +PHPStan at every normalization boundary. History/profile paths, History query +parameters and the Management domain header also use the generated contract. Raw input remains +unvalidated here: the existing normalizers preserve unknown fields, future strings, +missing/null defaults and the original payload. ```bash ./sync.sh # download the current OpenAPI description into resources/ -./generate.sh # rebuild generated/ from that file +composer generate:wire # rebuild the fields used by the supported client +composer check:wire # byte-for-byte freshness check +composer check:contract # incompatible schema mutations fail in real client code ``` +The optional `./generate.sh` still builds the standalone reference client in +`generated/`; that transport is not used by this library. YAML parsing is a development +dependency only. The installed SDK uses the checked-in generated PHP files. +HTTP-method changes on supported GET operations, unsupported new required request +parameters, changes to a consumed parameter's location, and incompatible ping/scored +envelope fields stop generation. Optional +new request parameters are ignored until explicitly supported by the client. ```bash composer install diff --git a/composer.json b/composer.json index bb339e1..03ec764 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,8 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.64", "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^10.5 || ^11.5 || ^12.0" + "phpunit/phpunit": "^10.5 || ^11.5 || ^12.0", + "symfony/yaml": "^6.4" }, "suggest": { "ext-curl": "Used by the built-in HTTP client, the default unless your project has Guzzle 7 or Symfony HttpClient", @@ -53,6 +54,9 @@ "ShieldLabs\\Tests\\": "tests/" } }, + "archive": { + "exclude": ["/.git", "/vendor", "/build", "/tests", "/.github", "/.phpstan.cache", "/.phpunit.cache", "/.php-cs-fixer.cache", "/composer.lock"] + }, "config": { "allow-plugins": { "php-http/discovery": false @@ -60,11 +64,17 @@ "sort-packages": true }, "scripts": { + "generate:wire": "php scripts/generate-wire.php", + "check:wire": "php scripts/generate-wire.php --check", + "check:contract": "php scripts/check-contract-drift.php", + "check:package": "php scripts/check-package.php", "test": "phpunit", "analyse": "phpstan analyse --no-progress", "cs": "php-cs-fixer check --diff", "cs:fix": "php-cs-fixer fix", "check": [ + "@check:wire", + "@check:contract", "@test", "@analyse", "@cs" diff --git a/scripts/check-contract-drift.php b/scripts/check-contract-drift.php new file mode 100644 index 0000000..14cf2f3 --- /dev/null +++ b/scripts/check-contract-drift.php @@ -0,0 +1,223 @@ +isDot()) { + continue; + } + if ($entry->isDir()) { + copyContractSource($entry->getPathname(), $target . '/' . $entry->getFilename()); + } else { + copy($entry->getPathname(), $target . '/' . $entry->getFilename()); + } + } +} + +function analyseContract(string $root, string $scratch): array +{ + $process = proc_open([ + PHP_BINARY, $root . '/vendor/bin/phpstan', 'analyse', '--no-progress', + '--error-format=json', '--memory-limit=256M', '-c', $scratch . '/phpstan.neon', + ], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $root); + if (!is_resource($process)) { + throw new RuntimeException('Could not start PHPStan.'); + } + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exit = proc_close($process); + $result = json_decode($stdout, true); + if (!is_array($result) || ($result['errors'] ?? []) !== []) { + throw new RuntimeException('PHPStan did not complete: ' . $stdout . $stderr); + } + + return [$exit, $result]; +} + +try { + copyContractSource($root . '/src', $scratch . '/src'); + file_put_contents($scratch . '/phpstan.neon', "parameters:\n level: max\n paths:\n - src\n tmpDir: cache\n treatPhpDocTypesAsCertain: false\n parallel:\n maximumNumberOfProcesses: 1\n"); + $cases = ['unchanged' => [$document, false, null]]; + foreach (['searchHistory', 'getDomainProfile'] as $operationId) { + $changed = $document; + foreach ($changed['paths'] as &$path) { + if (($path['get']['operationId'] ?? null) === $operationId) { + $path['post'] = $path['get']; + unset($path['get']); + } + } + unset($path); + $cases["$operationId changed to POST"] = [$changed, true, 'generation:Consumed operation changed HTTP method: ' . $operationId]; + } + foreach ([ + ['HistoryRow', 'score', 'string'], + ['DomainProfile', 'Weight', 'string'], + ['IdentificationScoredData', 'risk_score', 'string'], + ['DetectionFlags', 'vpn', 'string'], + ['Signal', 'weight', 'string'], + ['IpInfo', 'country', 'integer'], + ['ScoreDetail', 'Value', 'string'], + ] as [$model, $field, $type]) { + $changed = $document; + $changed['components']['schemas'][$model]['properties'][$field] = ['type' => $type]; + $cases["$model.$field type"] = [$changed, true, 'argument.type']; + } + $changed = $document; + $changed['components']['schemas']['HistoryRow']['properties']['renamed_request_id'] = $changed['components']['schemas']['HistoryRow']['properties']['request_id']; + unset($changed['components']['schemas']['HistoryRow']['properties']['request_id']); + $cases['HistoryRow.request_id rename'] = [$changed, true, 'staticMethod.notFound']; + $changed = $document; + $changed['components']['parameters']['HistoryLimit']['schema'] = ['type' => 'string']; + $cases['History limit type'] = [$changed, true, 'argument.type']; + $changed = $document; + $changed['components']['schemas']['HistoryPage']['properties']['total'] = ['type' => 'string']; + $cases['History page total type'] = [$changed, true, 'argument.type']; + $changed = $document; + $changed['components']['parameters']['HistorySearchType']['schema'] = ['type' => 'integer']; + $cases['History search_type type'] = [$changed, true, 'argument.type']; + $changed = $document; + $changed['components']['parameters']['HistorySearchType']['schema']['enum'] = ['ip']; + $cases['History search_type narrowing'] = [$changed, true, 'argument.type']; + $changed = $document; + $changed['components']['parameters']['HistoryOffset']['name'] = 'renamed_offset'; + $cases['History offset rename'] = [$changed, true, 'staticMethod.notFound']; + $changed = $document; + $changed['components']['parameters']['ShieldDomain']['schema'] = ['type' => 'integer']; + $cases['Profile header type'] = [$changed, true, 'argument.type']; + foreach ([['HistoryLimit', 'header'], ['ShieldDomain', 'query']] as [$parameter, $location]) { + $changed = $document; + $changed['components']['parameters'][$parameter]['in'] = $location; + $cases["$parameter moved to $location"] = [$changed, true, 'generation:Consumed parameter changed location']; + } + foreach (['searchHistory', 'getDomainProfile'] as $operationId) { + foreach (['query', 'header', 'path'] as $location) { + $changed = $document; + foreach ($changed['paths'] as &$path) { + if (($path['get']['operationId'] ?? null) === $operationId) { + $path['get']['parameters'][] = ['name' => 'future_required', 'in' => $location, 'required' => true, 'schema' => ['type' => 'string']]; + } + } + unset($path); + $cases["$operationId required $location"] = [$changed, true, 'generation:Unsupported required parameter']; + } + } + $changed = $document; + foreach ($changed['paths'] as &$path) { + if (($path['get']['operationId'] ?? null) === 'searchHistory') { + $path['parameters'][] = ['name' => 'future_inherited', 'in' => 'header', 'required' => true, 'schema' => ['type' => 'string']]; + } + } + unset($path); + $cases['Inherited required header'] = [$changed, true, 'generation:Unsupported required parameter']; + foreach (['created_at', 'schema_version'] as $field) { + $changed = $document; + $changed['components']['schemas']['WebhookPingEvent']['properties'][$field] = ['type' => 'integer']; + $cases["Ping $field type"] = [$changed, true, 'generation:Webhook shared envelope differs']; + $changed = $document; + $changed['components']['schemas']['WebhookPingEvent']['properties']['renamed_' . $field] = $changed['components']['schemas']['WebhookPingEvent']['properties'][$field]; + unset($changed['components']['schemas']['WebhookPingEvent']['properties'][$field]); + $cases["Ping $field rename"] = [$changed, true, 'generation:Webhook shared envelope differs']; + } + $changed = $document; + foreach ($changed['paths'] as &$path) { + if (($path['get']['operationId'] ?? null) === 'searchHistory') { + $path['get']['parameters'][] = ['name' => 'future_optional', 'in' => 'query', 'required' => false, 'schema' => ['type' => 'string']]; + } + } + unset($path); + $cases['Optional query parameter'] = [$changed, false, null]; + $changed = $document; + $changed['paths']['/v2/profile'] = $changed['paths']['/v1/profile']; + unset($changed['paths']['/v1/profile']); + $cases['Profile path follows schema'] = [$changed, false, null]; + $changed = $document; + $changed['components']['schemas']['HistoryRow']['properties']['future_optional'] = ['type' => 'string']; + $cases['additive optional field'] = [$changed, false, null]; + + foreach ($cases as $name => [$schema, $mustFail, $identifier]) { + try { + $generated = generateWire($schema); + } catch (RuntimeException $exception) { + if ($mustFail && str_starts_with($identifier ?? '', 'generation:') + && str_contains($exception->getMessage(), substr($identifier, strlen('generation:')))) { + echo "PASS $name rejected by generator: " . $exception->getMessage() . "\n"; + continue; + } + throw $exception; + } + foreach (glob($scratch . '/src/Internal/Wire/Generated/*.php') as $file) { + unlink($file); + } + foreach ($generated as $file => $content) { + file_put_contents($scratch . '/src/Internal/Wire/Generated/' . $file, $content); + } + [$status, $result] = analyseContract($root, $scratch); + $messages = []; + foreach ($result['files'] ?? [] as $path => $file) { + // The supported client, not the generator, must reject this contract. + if (!str_contains($path, '/Wire/Generated/')) { + foreach ($file['messages'] as $message) { + $messages[] = $message['identifier'] ?? ''; + } + } + } + if (($mustFail && ($status !== 1 || !in_array($identifier, $messages, true))) || (!$mustFail && $status !== 0)) { + throw new RuntimeException("Unexpected result for $name:\n" . json_encode($result, JSON_PRETTY_PRINT)); + } + if ($name === 'Profile path follows schema' || $name === 'Optional query parameter') { + file_put_contents($scratch . '/profile-probe.php', <<<'PHP' +path = $request->getUri()->getPath(); + $this->query = $request->getUri()->getQuery(); + return new \Nyholm\Psr7\Response(200, [], '{}'); + } +}; +if ($argv[2] === 'history') { + $sdk = new \ShieldLabs\ShieldLabs(['api_key' => 'sec_00000000-00000000-00000000', 'http_client' => $http]); + $sdk->history->search('user_hid', 'anonymous'); + if ($http->query !== 'limit=20&offset=0') { throw new \RuntimeException('An unsupported optional parameter leaked into the request.'); } +} else { + $sdk = new \ShieldLabs\ShieldLabsManagement(['secret_key' => 'fixture', 'domain' => 'example.com', 'http_client' => $http]); + $sdk->getProfile(); + if ($http->path !== '/v2/profile') { throw new \RuntimeException('Profile ignored the generated operation path.'); } +} +PHP); + $process = proc_open([PHP_BINARY, $scratch . '/profile-probe.php', $root . '/vendor/autoload.php', $name === 'Optional query parameter' ? 'history' : 'profile'], [0 => STDIN, 1 => STDOUT, 2 => STDERR], $pipes, $scratch); + if (!is_resource($process) || proc_close($process) !== 0) { + throw new RuntimeException('The supported client failed the changed profile route.'); + } + } + echo 'PASS ' . $name . ($mustFail ? ' rejected by client type checking' : ' accepted') . "\n"; + } +} finally { + $entries = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($scratch, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($entries as $entry) { + $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($scratch); +} diff --git a/scripts/check-package.php b/scripts/check-package.php new file mode 100644 index 0000000..9c94c2f --- /dev/null +++ b/scripts/check-package.php @@ -0,0 +1,40 @@ + STDIN, 1 => STDOUT, 2 => STDERR], $pipes, $directory); + if (!is_resource($process) || proc_close($process) !== 0) { + throw new RuntimeException('Package check command failed: ' . implode(' ', $command)); + } +} + +runPackageCommand(['composer', 'archive', '--no-interaction', '--format=tar', '--dir=' . $scratch, '--file=package'], $root); +$archive = new PharData($scratch . '/package.tar'); +foreach (new RecursiveIteratorIterator($archive) as $entry) { + $relative = substr($entry->getPathname(), strlen('phar://' . $scratch . '/package.tar/')); + if (preg_match('~^(vendor|build|\.git|\.phpstan.cache)(/|$)~', $relative)) { + throw new RuntimeException('The archive contains development state.'); + } +} +$metadata = json_decode(file_get_contents($root . '/composer.json'), true, 512, JSON_THROW_ON_ERROR); +$package = array_intersect_key($metadata, array_flip(['name', 'description', 'type', 'license', 'require', 'autoload'])); +$package['version'] = '1.0.0'; +$package['dist'] = ['type' => 'tar', 'url' => 'file://' . $scratch . '/package.tar']; +file_put_contents($scratch . '/consumer/composer.json', json_encode([ + 'name' => 'shieldlabs/package-consumer', + 'repositories' => [['type' => 'package', 'package' => $package]], + 'require' => [$package['name'] => '1.0.0'], + 'config' => ['allow-plugins' => ['php-http/discovery' => false]], +], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); +runPackageCommand(['composer', 'install', '--no-dev', '--no-scripts', '--no-interaction', '--no-progress'], $scratch . '/consumer'); +if (is_dir($scratch . '/consumer/vendor/phpstan') || is_dir($scratch . '/consumer/vendor/symfony/yaml')) { + throw new RuntimeException('The runtime consumer unexpectedly installed build dependencies.'); +} +runPackageCommand([PHP_BINARY, $root . '/tests/Package/consumer.php', $scratch . '/consumer/vendor/autoload.php'], $scratch . '/consumer'); +echo 'Fresh archive consumer passed. Artifacts: ' . basename($scratch) . "\n"; diff --git a/scripts/generate-wire.php b/scripts/generate-wire.php new file mode 100644 index 0000000..2fd7c2c --- /dev/null +++ b/scripts/generate-wire.php @@ -0,0 +1,198 @@ + true])); + } + + return $schema; +} + +function wireType(array $schema, array $document): string +{ + $schema = resolveWire($schema, $document); + $types = (array) ($schema['type'] ?? throw new RuntimeException('A consumed field needs an explicit type.')); + $php = array_map(static fn(string $type): string => match ($type) { + 'string' => 'string', 'integer' => 'int', 'number' => 'float', + 'boolean' => 'bool', 'object', 'array' => 'array', 'null' => 'null', + default => throw new RuntimeException('Unsupported wire type: ' . $type), + }, $types); + + return implode('|', array_unique($php)); +} + +function wireOperation(array $document, string $id, ?string $expectedMethod = null): array +{ + foreach (['paths', 'webhooks'] as $section) { + foreach ($document[$section] ?? [] as $template => $path) { + foreach ($path as $method => $operation) { + if (is_array($operation) && ($operation['operationId'] ?? null) === $id) { + if ($expectedMethod !== null && $method !== $expectedMethod) { + throw new RuntimeException('Consumed operation changed HTTP method: ' . $id . ' (expected ' . strtoupper($expectedMethod) . ', got ' . strtoupper($method) . ').'); + } + $parameters = []; + foreach (array_merge($path['parameters'] ?? [], $operation['parameters'] ?? []) as $parameter) { + $resolved = resolveWire($parameter, $document); + $parameters[$resolved['in'] . ':' . $resolved['name']] = $resolved; + } + $operation['parameters'] = array_values($parameters); + return $operation + ['x-sdk-path' => $template]; + } + } + } + } + throw new RuntimeException('Missing operation: ' . $id); +} + +function renderWire(string $class, array $properties, array $document): string +{ + ksort($properties); + $out = " $schema) { + if (!preg_match('/^[a-zA-Z_][a-zA-Z_0-9]*$/D', $name)) { + throw new RuntimeException('Unsupported PHP field identifier: ' . $name); + } + $type = wireType($schema, $document); + $out .= " /** @return Field<$type> */\n public static function $name(): Field\n {\n /** @var Field<$type> \$field */\n \$field = new Field('$name');\n\n return \$field;\n }\n\n"; + } + + return rtrim($out) . "\n}\n"; +} + +function generateWire(array $document): array +{ + $history = wireOperation($document, 'searchHistory', 'get'); + $profile = wireOperation($document, 'getDomainProfile', 'get'); + $event = wireOperation($document, 'identificationScored'); + $ping = wireOperation($document, 'webhookPing'); + foreach ([[$history, ['search_type' => 'path', 'value' => 'path', 'limit' => 'query', 'offset' => 'query']], [$profile, ['X-Shield-Domain' => 'header']]] as [$operation, $supported]) { + foreach ($operation['parameters'] as $parameter) { + if (isset($supported[$parameter['name']]) && $supported[$parameter['name']] !== $parameter['in']) { + throw new RuntimeException('Consumed parameter changed location: ' . $parameter['name']); + } + if (($parameter['required'] ?? false) && !isset($supported[$parameter['name']])) { + throw new RuntimeException('Unsupported required parameter: ' . $parameter['name']); + } + } + } + $page = resolveWire($history['responses']['200']['content']['application/json']['schema'], $document); + $row = resolveWire($page['properties']['data'], $document); + $historyRow = resolveWire($row['items'], $document); + $scoreDetails = resolveWire($historyRow['properties']['score_details'], $document); + $scoreDetailArray = resolveWire($scoreDetails['contentSchema'], $document); + $envelope = resolveWire($event['requestBody']['content']['application/json']['schema'], $document); + $pingEnvelope = resolveWire($ping['requestBody']['content']['application/json']['schema'], $document); + // Parsing uses a shared envelope before discriminating the event. Reject a + // ping-only rename/type change rather than silently reading scored fields. + foreach (['event_type', 'schema_version', 'created_at'] as $field) { + if (!isset($pingEnvelope['properties'][$field], $envelope['properties'][$field]) + || wireType($pingEnvelope['properties'][$field], $document) !== wireType($envelope['properties'][$field], $document)) { + throw new RuntimeException('Webhook shared envelope differs: ' . $field); + } + } + $data = resolveWire($envelope['properties']['data'], $document); + $signalArray = resolveWire($data['properties']['signals'], $document); + $models = [ + 'HistoryPage' => $page, + 'HistoryRow' => $historyRow, + 'DomainProfile' => resolveWire($profile['responses']['200']['content']['application/json']['schema'], $document), + 'WebhookEnvelope' => $envelope, + 'ScoredData' => $data, + 'DetectionFlags' => resolveWire($data['properties']['detection_flags'], $document), + 'TrafficSource' => resolveWire($data['properties']['traffic_source'], $document), + 'IpInfo' => resolveWire($data['properties']['public_ip'], $document), + 'LocalIpInfo' => resolveWire($data['properties']['local_ip'], $document), + 'Signal' => resolveWire($signalArray['items'], $document), + 'ScoreDetail' => resolveWire($scoreDetailArray['items'], $document), + ]; + $out = []; + foreach ($models as $class => $schema) { + $out[$class . '.php'] = renderWire($class, $schema['properties'], $document); + } + foreach (['HistoryParameters' => $history, 'ProfileParameters' => $profile] as $class => $operation) { + $properties = []; + foreach ($operation['parameters'] as $parameter) { + $parameter = resolveWire($parameter, $document); + // PHP method identifiers cannot contain header hyphens. + $method = str_replace('-', '_', $parameter['name']); + $properties[$method] = $parameter['schema']; + } + $rendered = renderWire($class, $properties, $document); + foreach ($operation['parameters'] as $parameter) { + $parameter = resolveWire($parameter, $document); + $rendered = str_replace("new Field('" . str_replace('-', '_', $parameter['name']) . "')", "new Field('" . $parameter['name'] . "')", $rendered); + } + if ($class === 'HistoryParameters' || $class === 'ProfileParameters') { + $arguments = []; + $replacements = []; + $docs = []; + foreach ($operation['parameters'] as $parameter) { + $parameter = resolveWire($parameter, $document); + if ($parameter['in'] !== 'path') { + continue; + } + $name = $parameter['name']; + $schema = resolveWire($parameter['schema'], $document); + $type = wireType($schema, $document); + if (!in_array($type, ['string', 'int'], true)) { + throw new RuntimeException('Unsupported path parameter type: ' . $type); + } + $arguments[] = "$type \$$name"; + $replacements[] = "'{{$name}}' => (string) \$$name"; + $docType = isset($schema['enum']) ? implode('|', array_map(static fn(string $value): string => var_export($value, true), $schema['enum'])) : $type; + $docs[] = " * @param $docType \$$name"; + } + $path = var_export($operation['x-sdk-path'], true); + $comment = $docs === [] ? ' /** Operation path. */' : " /**\n * Path values must already be validated and escaped.\n" . implode("\n", $docs) . "\n */"; + $method = "\n$comment\n public static function path(" . implode(', ', $arguments) . "): string\n {\n return strtr($path, [" . implode(', ', $replacements) . "]);\n }\n"; + $rendered = substr($rendered, 0, -2) . $method . "}\n"; + } + $out[$class . '.php'] = $rendered; + } + + return $out; +} + +if (realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) { + $check = in_array('--check', $argv, true); + $document = Yaml::parseFile(dirname(__DIR__) . '/resources/shieldlabs-api.yaml'); + $directory = dirname(__DIR__) . '/src/Internal/Wire/Generated'; + $files = generateWire($document); + if (!$check && !is_dir($directory)) { + mkdir($directory, 0777, true); + } + foreach ($files as $name => $content) { + if ($check) { + if (!is_file("$directory/$name") || file_get_contents("$directory/$name") !== $content) { + fwrite(STDERR, "Generated wire types are stale: $name. Run composer generate:wire.\n"); + exit(1); + } + } else { + file_put_contents("$directory/$name", $content); + } + } + $extra = array_diff(array_map('basename', glob("$directory/*.php") ?: []), array_keys($files)); + if ($extra !== []) { + throw new RuntimeException('Unexpected generated files: ' . implode(', ', $extra)); + } + echo $check ? "Generated wire types are current.\n" : "Generated wire types written.\n"; +} diff --git a/src/Internal/Normalizer.php b/src/Internal/Normalizer.php index 1805fd2..86156a3 100644 --- a/src/Internal/Normalizer.php +++ b/src/Internal/Normalizer.php @@ -4,6 +4,17 @@ namespace ShieldLabs\Internal; +use ShieldLabs\Internal\Wire\Field; +use ShieldLabs\Internal\Wire\Generated\DetectionFlags; +use ShieldLabs\Internal\Wire\Generated\HistoryRow; +use ShieldLabs\Internal\Wire\Generated\IpInfo; +use ShieldLabs\Internal\Wire\Generated\LocalIpInfo; +use ShieldLabs\Internal\Wire\Generated\ScoredData; +use ShieldLabs\Internal\Wire\Generated\ScoreDetail; +use ShieldLabs\Internal\Wire\Generated\Signal; +use ShieldLabs\Internal\Wire\Generated\TrafficSource; +use ShieldLabs\Internal\Wire\Read; + /** * Turns a History API row or webhook `data` object into the shared Identification * shape. Every ShieldLabs server SDK applies these exact rules, and the shared @@ -29,26 +40,33 @@ final class Normalizer 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', ]; - /** Normalized flag name to History row column. */ - private const HISTORY_FLAG_MAP = [ - 'vpn' => 'is_vpn', - 'privacy_relay' => 'is_privacy_relay', - 'tor' => 'is_tor', - 'proxy' => 'is_proxy', - 'datacenter_ip' => 'is_datacenter', - 'abuser' => 'is_abuser', - 'os_mismatch' => 'is_os_mismatch', - 'os_not_detected' => 'is_os_not_detected', - 'timezone_mismatch' => 'is_timezone_mismatch', - 'anti_detect_browser' => 'is_antidetect', - 'browser_automation' => 'is_browser_automation', - 'incognito' => 'is_incognito', - 'search_bot' => 'is_search_bot', - 'suspicious_paid_click' => 'is_suspicious_paid_click', - 'javascript_disabled' => 'is_js_disabled', - 'stun_not_checked' => 'is_stun_not_checked', - 'check_incomplete' => 'check_incomplete', - ]; + /** + * Normalized flag name to History row column. + * + * @return array> + */ + private static function historyFlagMap(): array + { + return [ + 'vpn' => HistoryRow::is_vpn(), + 'privacy_relay' => HistoryRow::is_privacy_relay(), + 'tor' => HistoryRow::is_tor(), + 'proxy' => HistoryRow::is_proxy(), + 'datacenter_ip' => HistoryRow::is_datacenter(), + 'abuser' => HistoryRow::is_abuser(), + 'os_mismatch' => HistoryRow::is_os_mismatch(), + 'os_not_detected' => HistoryRow::is_os_not_detected(), + 'timezone_mismatch' => HistoryRow::is_timezone_mismatch(), + 'anti_detect_browser' => HistoryRow::is_antidetect(), + 'browser_automation' => HistoryRow::is_browser_automation(), + 'incognito' => HistoryRow::is_incognito(), + 'search_bot' => HistoryRow::is_search_bot(), + 'suspicious_paid_click' => HistoryRow::is_suspicious_paid_click(), + 'javascript_disabled' => HistoryRow::is_js_disabled(), + 'stun_not_checked' => HistoryRow::is_stun_not_checked(), + 'check_incomplete' => HistoryRow::check_incomplete(), + ]; + } private const EXACT_SLUGS = [ 'Is tor' => 'tor', @@ -193,27 +211,27 @@ public static function parseRfc3339(mixed $value): ?\DateTimeImmutable */ public static function fromHistoryRow(array $row): array { - $leakSource = self::strip(self::orEmpty($row['webrtc_leak_source'] ?? null)); + $leakSource = self::strip(self::orEmpty(Read::text(HistoryRow::webrtc_leak_source(), $row))); if ($leakSource !== '' && $leakSource !== 'none') { - $localIp = self::ip($row['webrtc_leak_ip'] ?? null); - $localCountry = self::orEmpty($row['webrtc_leak_country'] ?? null); + $localIp = self::ip(Read::text(HistoryRow::webrtc_leak_ip(), $row)); + $localCountry = self::orEmpty(Read::text(HistoryRow::webrtc_leak_country(), $row)); } else { - $localIp = self::ip($row['web_rtc_ip'] ?? null); - $localCountry = self::orEmpty($row['web_rtc_country'] ?? null); + $localIp = self::ip(Read::text(HistoryRow::web_rtc_ip(), $row)); + $localCountry = self::orEmpty(Read::text(HistoryRow::web_rtc_country(), $row)); } - $publicIp = self::ip($row['ip'] ?? null); + $publicIp = self::ip(Read::text(HistoryRow::ip(), $row)); $signals = []; $ipLeakDetail = false; - foreach (self::scoreDetails($row['score_details'] ?? null) as $detail) { + foreach (self::scoreDetails(Read::text(HistoryRow::score_details(), $row)) as $detail) { if (!self::isObject($detail)) { continue; } - $description = self::orEmpty($detail['Description'] ?? null); + $description = self::orEmpty(Read::text(ScoreDetail::Description(), $detail)); if (str_starts_with($description, self::IP_LEAK_PREFIX)) { $ipLeakDetail = true; } - $weight = $detail['Value'] ?? 0; + $weight = Read::integer(ScoreDetail::Value(), $detail) ?? 0; if (!\is_int($weight) || $weight === 0) { continue; } @@ -224,50 +242,51 @@ public static function fromHistoryRow(array $row): array ]; } - $searchBot = self::truthy($row['is_search_bot'] ?? null); + $historyFlagMap = self::historyFlagMap(); + $searchBot = self::truthy(Read::boolean(HistoryRow::is_search_bot(), $row)); $flags = []; foreach (self::FLAG_KEYS as $key) { if ($key === 'browser_vpn_proxy') { - $flags[$key] = ($row['connection_type'] ?? null) === 'browser_vpn_proxy'; + $flags[$key] = Read::text(HistoryRow::connection_type(), $row) === 'browser_vpn_proxy'; } elseif ($key === 'ip_mismatch') { $flags[$key] = !$searchBot && ($ipLeakDetail || ($publicIp !== '' && $localIp !== '' && $publicIp !== $localIp)); } else { - $flags[$key] = self::truthy($row[self::HISTORY_FLAG_MAP[$key]] ?? null); + $flags[$key] = self::truthy(Read::boolean($historyFlagMap[$key], $row)); } } - $siteDomain = $row['site_domain'] ?? null; + $siteDomain = Read::text(HistoryRow::site_domain(), $row); return [ - 'request_id' => self::str($row['request_id'] ?? null), - 'visitor_id' => self::str($row['visitor_id'] ?? null), - 'device_id' => self::str($row['device_id'] ?? null), - 'session_id' => self::str($row['session_id'] ?? null), - 'cookie_id' => self::str($row['cookie_id'] ?? null), - 'user_hid' => self::userHid($row['user_hid'] ?? null), - 'domain' => self::truthy($siteDomain) ? self::str($siteDomain) : self::str($row['domain'] ?? null), - 'public_ip' => ['ip' => $publicIp, 'country' => self::orEmpty($row['country'] ?? null)], + 'request_id' => self::str(Read::text(HistoryRow::request_id(), $row)), + 'visitor_id' => self::str(Read::text(HistoryRow::visitor_id(), $row)), + 'device_id' => self::str(Read::text(HistoryRow::device_id(), $row)), + 'session_id' => self::str(Read::text(HistoryRow::session_id(), $row)), + 'cookie_id' => self::str(Read::text(HistoryRow::cookie_id(), $row)), + 'user_hid' => self::userHid(Read::text(HistoryRow::user_hid(), $row)), + 'domain' => self::truthy($siteDomain) ? self::str($siteDomain) : self::str(Read::text(HistoryRow::domain(), $row)), + 'public_ip' => ['ip' => $publicIp, 'country' => self::orEmpty(Read::text(HistoryRow::country(), $row))], 'local_ip' => ['ip' => $localIp, 'country' => $localCountry], - 'connection_type' => self::str($row['connection_type'] ?? null), - 'os' => self::str($row['os'] ?? null), - 'browser' => self::str($row['browser'] ?? null), - 'device_type' => self::str($row['device_type'] ?? null), + 'connection_type' => self::str(Read::text(HistoryRow::connection_type(), $row)), + 'os' => self::str(Read::text(HistoryRow::os(), $row)), + 'browser' => self::str(Read::text(HistoryRow::browser(), $row)), + 'device_type' => self::str(Read::text(HistoryRow::device_type(), $row)), 'traffic_source' => [ - 'channel' => self::orEmpty($row['traffic_channel'] ?? null), - 'referrer_domain' => self::orEmpty($row['referrer_domain'] ?? null), - 'landing_url' => self::orEmpty($row['entry_url'] ?? null), - 'click_id_type' => self::orEmpty($row['click_id_type'] ?? null), - 'utm_source' => self::orEmpty($row['utm_source'] ?? null), - 'utm_medium' => self::orEmpty($row['utm_medium'] ?? null), - 'utm_campaign' => self::orEmpty($row['utm_campaign'] ?? null), - 'utm_content' => self::orEmpty($row['utm_content'] ?? null), - 'utm_term' => self::orEmpty($row['utm_term'] ?? null), + 'channel' => self::orEmpty(Read::text(HistoryRow::traffic_channel(), $row)), + 'referrer_domain' => self::orEmpty(Read::text(HistoryRow::referrer_domain(), $row)), + 'landing_url' => self::orEmpty(Read::text(HistoryRow::entry_url(), $row)), + 'click_id_type' => self::orEmpty(Read::text(HistoryRow::click_id_type(), $row)), + 'utm_source' => self::orEmpty(Read::text(HistoryRow::utm_source(), $row)), + 'utm_medium' => self::orEmpty(Read::text(HistoryRow::utm_medium(), $row)), + 'utm_campaign' => self::orEmpty(Read::text(HistoryRow::utm_campaign(), $row)), + 'utm_content' => self::orEmpty(Read::text(HistoryRow::utm_content(), $row)), + 'utm_term' => self::orEmpty(Read::text(HistoryRow::utm_term(), $row)), ], - 'risk_score' => self::int($row['score'] ?? null), + 'risk_score' => self::int(Read::integer(HistoryRow::score(), $row)), 'signals' => $signals, 'detection_flags' => $flags, - 'observed_at' => self::parseHistoryTime($row['created_at'] ?? null), + 'observed_at' => self::parseHistoryTime(Read::text(HistoryRow::created_at(), $row)), 'source' => 'history', ]; } @@ -287,54 +306,84 @@ public static function fromHistoryRow(array $row): array */ public static function fromWebhookData(array $data): array { - $rawFlags = $data['detection_flags'] ?? null; + $rawFlags = Read::collection(ScoredData::detection_flags(), $data); $rawFlags = \is_array($rawFlags) ? $rawFlags : []; $flags = []; - foreach (self::FLAG_KEYS as $key) { - $flags[$key] = self::truthy($rawFlags[$key] ?? null); + foreach ([ + 'vpn' => DetectionFlags::vpn(), + 'privacy_relay' => DetectionFlags::privacy_relay(), + 'browser_vpn_proxy' => DetectionFlags::browser_vpn_proxy(), + 'tor' => DetectionFlags::tor(), + 'proxy' => DetectionFlags::proxy(), + 'datacenter_ip' => DetectionFlags::datacenter_ip(), + 'abuser' => DetectionFlags::abuser(), + 'os_mismatch' => DetectionFlags::os_mismatch(), + 'os_not_detected' => DetectionFlags::os_not_detected(), + 'timezone_mismatch' => DetectionFlags::timezone_mismatch(), + 'anti_detect_browser' => DetectionFlags::anti_detect_browser(), + 'browser_automation' => DetectionFlags::browser_automation(), + 'ip_mismatch' => DetectionFlags::ip_mismatch(), + 'incognito' => DetectionFlags::incognito(), + 'search_bot' => DetectionFlags::search_bot(), + 'suspicious_paid_click' => DetectionFlags::suspicious_paid_click(), + 'javascript_disabled' => DetectionFlags::javascript_disabled(), + 'stun_not_checked' => DetectionFlags::stun_not_checked(), + 'check_incomplete' => DetectionFlags::check_incomplete(), + ] as $key => $field) { + $flags[$key] = self::truthy(Read::boolean($field, $rawFlags)); } - $traffic = $data['traffic_source'] ?? null; + $traffic = Read::collection(ScoredData::traffic_source(), $data); $traffic = \is_array($traffic) ? $traffic : []; $trafficSource = []; - foreach (self::TRAFFIC_KEYS as $key) { - $trafficSource[$key] = self::orEmpty($traffic[$key] ?? null); + foreach ([ + 'channel' => TrafficSource::channel(), + 'referrer_domain' => TrafficSource::referrer_domain(), + 'landing_url' => TrafficSource::landing_url(), + 'click_id_type' => TrafficSource::click_id_type(), + 'utm_source' => TrafficSource::utm_source(), + 'utm_medium' => TrafficSource::utm_medium(), + 'utm_campaign' => TrafficSource::utm_campaign(), + 'utm_content' => TrafficSource::utm_content(), + 'utm_term' => TrafficSource::utm_term(), + ] as $key => $field) { + $trafficSource[$key] = self::orEmpty(Read::text($field, $traffic)); } $signals = []; - $rawSignals = $data['signals'] ?? null; + $rawSignals = Read::collection(ScoredData::signals(), $data); if (\is_array($rawSignals) && array_is_list($rawSignals)) { foreach ($rawSignals as $signal) { if (!self::isObject($signal)) { continue; } $signals[] = [ - 'name' => self::str($signal['name'] ?? null), - 'weight' => self::int($signal['weight'] ?? null), + 'name' => self::str(Read::text(Signal::name(), $signal)), + 'weight' => self::int(Read::integer(Signal::weight(), $signal)), 'description' => null, ]; } } return [ - 'request_id' => self::str($data['request_id'] ?? null), - 'visitor_id' => self::str($data['visitor_id'] ?? null), - 'device_id' => self::str($data['device_id'] ?? null), - 'session_id' => self::str($data['session_id'] ?? null), - 'cookie_id' => self::str($data['cookie_id'] ?? null), - 'user_hid' => self::userHid($data['user_hid'] ?? null), - 'domain' => self::str($data['domain'] ?? null), - 'public_ip' => self::ipObject($data['public_ip'] ?? null), - 'local_ip' => self::ipObject($data['local_ip'] ?? null), - 'connection_type' => self::str($data['connection_type'] ?? null), - 'os' => self::str($data['os'] ?? null), - 'browser' => self::str($data['browser'] ?? null), - 'device_type' => self::str($data['device_type'] ?? null), + 'request_id' => self::str(Read::text(ScoredData::request_id(), $data)), + 'visitor_id' => self::str(Read::text(ScoredData::visitor_id(), $data)), + 'device_id' => self::str(Read::text(ScoredData::device_id(), $data)), + 'session_id' => self::str(Read::text(ScoredData::session_id(), $data)), + 'cookie_id' => self::str(Read::text(ScoredData::cookie_id(), $data)), + 'user_hid' => self::userHid(Read::text(ScoredData::user_hid(), $data)), + 'domain' => self::str(Read::text(ScoredData::domain(), $data)), + 'public_ip' => self::ipObject(Read::collection(ScoredData::public_ip(), $data), IpInfo::ip(), IpInfo::country()), + 'local_ip' => self::ipObject(Read::collection(ScoredData::local_ip(), $data), LocalIpInfo::ip(), LocalIpInfo::country()), + 'connection_type' => self::str(Read::text(ScoredData::connection_type(), $data)), + 'os' => self::str(Read::text(ScoredData::os(), $data)), + 'browser' => self::str(Read::text(ScoredData::browser(), $data)), + 'device_type' => self::str(Read::text(ScoredData::device_type(), $data)), 'traffic_source' => $trafficSource, - 'risk_score' => self::int($data['risk_score'] ?? null), + 'risk_score' => self::int(Read::integer(ScoredData::risk_score(), $data)), 'signals' => $signals, 'detection_flags' => $flags, - 'observed_at' => self::parseRfc3339($data['observed_at'] ?? null), + 'observed_at' => self::parseRfc3339(Read::text(ScoredData::observed_at(), $data)), 'source' => 'webhook', ]; } @@ -350,15 +399,18 @@ public static function strip(string $value): string } /** + * @param Field $ipField + * @param Field $countryField + * * @return array{ip: string, country: string} */ - private static function ipObject(mixed $value): array + private static function ipObject(mixed $value, Field $ipField, Field $countryField): array { $object = \is_array($value) ? $value : []; return [ - 'ip' => self::ip($object['ip'] ?? null), - 'country' => self::orEmpty($object['country'] ?? null), + 'ip' => self::ip(Read::text($ipField, $object)), + 'country' => self::orEmpty(Read::text($countryField, $object)), ]; } diff --git a/src/Internal/Wire/Field.php b/src/Internal/Wire/Field.php new file mode 100644 index 0000000..0d7c76a --- /dev/null +++ b/src/Internal/Wire/Field.php @@ -0,0 +1,23 @@ + $object */ + public function read(array $object): mixed + { + return $object[$this->name] ?? null; + } +} diff --git a/src/Internal/Wire/Generated/DetectionFlags.php b/src/Internal/Wire/Generated/DetectionFlags.php new file mode 100644 index 0000000..92a6040 --- /dev/null +++ b/src/Internal/Wire/Generated/DetectionFlags.php @@ -0,0 +1,182 @@ + */ + public static function abuser(): Field + { + /** @var Field $field */ + $field = new Field('abuser'); + + return $field; + } + + /** @return Field */ + public static function anti_detect_browser(): Field + { + /** @var Field $field */ + $field = new Field('anti_detect_browser'); + + return $field; + } + + /** @return Field */ + public static function browser_automation(): Field + { + /** @var Field $field */ + $field = new Field('browser_automation'); + + return $field; + } + + /** @return Field */ + public static function browser_vpn_proxy(): Field + { + /** @var Field $field */ + $field = new Field('browser_vpn_proxy'); + + return $field; + } + + /** @return Field */ + public static function check_incomplete(): Field + { + /** @var Field $field */ + $field = new Field('check_incomplete'); + + return $field; + } + + /** @return Field */ + public static function datacenter_ip(): Field + { + /** @var Field $field */ + $field = new Field('datacenter_ip'); + + return $field; + } + + /** @return Field */ + public static function incognito(): Field + { + /** @var Field $field */ + $field = new Field('incognito'); + + return $field; + } + + /** @return Field */ + public static function ip_mismatch(): Field + { + /** @var Field $field */ + $field = new Field('ip_mismatch'); + + return $field; + } + + /** @return Field */ + public static function javascript_disabled(): Field + { + /** @var Field $field */ + $field = new Field('javascript_disabled'); + + return $field; + } + + /** @return Field */ + public static function os_mismatch(): Field + { + /** @var Field $field */ + $field = new Field('os_mismatch'); + + return $field; + } + + /** @return Field */ + public static function os_not_detected(): Field + { + /** @var Field $field */ + $field = new Field('os_not_detected'); + + return $field; + } + + /** @return Field */ + public static function privacy_relay(): Field + { + /** @var Field $field */ + $field = new Field('privacy_relay'); + + return $field; + } + + /** @return Field */ + public static function proxy(): Field + { + /** @var Field $field */ + $field = new Field('proxy'); + + return $field; + } + + /** @return Field */ + public static function search_bot(): Field + { + /** @var Field $field */ + $field = new Field('search_bot'); + + return $field; + } + + /** @return Field */ + public static function stun_not_checked(): Field + { + /** @var Field $field */ + $field = new Field('stun_not_checked'); + + return $field; + } + + /** @return Field */ + public static function suspicious_paid_click(): Field + { + /** @var Field $field */ + $field = new Field('suspicious_paid_click'); + + return $field; + } + + /** @return Field */ + public static function timezone_mismatch(): Field + { + /** @var Field $field */ + $field = new Field('timezone_mismatch'); + + return $field; + } + + /** @return Field */ + public static function tor(): Field + { + /** @var Field $field */ + $field = new Field('tor'); + + return $field; + } + + /** @return Field */ + public static function vpn(): Field + { + /** @var Field $field */ + $field = new Field('vpn'); + + return $field; + } +} diff --git a/src/Internal/Wire/Generated/DomainProfile.php b/src/Internal/Wire/Generated/DomainProfile.php new file mode 100644 index 0000000..903227d --- /dev/null +++ b/src/Internal/Wire/Generated/DomainProfile.php @@ -0,0 +1,65 @@ + */ + public static function Callback(): Field + { + /** @var Field $field */ + $field = new Field('Callback'); + + return $field; + } + + /** @return Field */ + public static function CreatedAt(): Field + { + /** @var Field $field */ + $field = new Field('CreatedAt'); + + return $field; + } + + /** @return Field */ + public static function Domain(): Field + { + /** @var Field $field */ + $field = new Field('Domain'); + + return $field; + } + + /** @return Field */ + public static function PublicKey(): Field + { + /** @var Field $field */ + $field = new Field('PublicKey'); + + return $field; + } + + /** @return Field */ + public static function Secret(): Field + { + /** @var Field $field */ + $field = new Field('Secret'); + + return $field; + } + + /** @return Field */ + public static function Weight(): Field + { + /** @var Field $field */ + $field = new Field('Weight'); + + return $field; + } +} diff --git a/src/Internal/Wire/Generated/HistoryPage.php b/src/Internal/Wire/Generated/HistoryPage.php new file mode 100644 index 0000000..4787204 --- /dev/null +++ b/src/Internal/Wire/Generated/HistoryPage.php @@ -0,0 +1,29 @@ +> */ + public static function data(): Field + { + /** @var Field> $field */ + $field = new Field('data'); + + return $field; + } + + /** @return Field */ + public static function total(): Field + { + /** @var Field $field */ + $field = new Field('total'); + + return $field; + } +} diff --git a/src/Internal/Wire/Generated/HistoryParameters.php b/src/Internal/Wire/Generated/HistoryParameters.php new file mode 100644 index 0000000..acf5cd8 --- /dev/null +++ b/src/Internal/Wire/Generated/HistoryParameters.php @@ -0,0 +1,57 @@ + */ + public static function limit(): Field + { + /** @var Field $field */ + $field = new Field('limit'); + + return $field; + } + + /** @return Field */ + public static function offset(): Field + { + /** @var Field $field */ + $field = new Field('offset'); + + return $field; + } + + /** @return Field */ + public static function search_type(): Field + { + /** @var Field $field */ + $field = new Field('search_type'); + + return $field; + } + + /** @return Field */ + public static function value(): Field + { + /** @var Field $field */ + $field = new Field('value'); + + return $field; + } + + /** + * Path values must already be validated and escaped. + * @param 'request_id'|'device_id'|'user_hid'|'visitor_id'|'ip'|'session_id'|'cookie_id' $search_type + * @param string $value + */ + public static function path(string $search_type, string $value): string + { + return strtr('/api/v1/history/{search_type}/{value}', ['{search_type}' => (string) $search_type, '{value}' => (string) $value]); + } +} diff --git a/src/Internal/Wire/Generated/HistoryRow.php b/src/Internal/Wire/Generated/HistoryRow.php new file mode 100644 index 0000000..fe9849d --- /dev/null +++ b/src/Internal/Wire/Generated/HistoryRow.php @@ -0,0 +1,488 @@ + */ + public static function browser(): Field + { + /** @var Field $field */ + $field = new Field('browser'); + + return $field; + } + + /** @return Field */ + public static function check_incomplete(): Field + { + /** @var Field $field */ + $field = new Field('check_incomplete'); + + return $field; + } + + /** @return Field */ + public static function click_id_type(): Field + { + /** @var Field $field */ + $field = new Field('click_id_type'); + + return $field; + } + + /** @return Field */ + public static function connection_type(): Field + { + /** @var Field $field */ + $field = new Field('connection_type'); + + return $field; + } + + /** @return Field */ + public static function cookie_id(): Field + { + /** @var Field $field */ + $field = new Field('cookie_id'); + + return $field; + } + + /** @return Field */ + public static function country(): Field + { + /** @var Field $field */ + $field = new Field('country'); + + return $field; + } + + /** @return Field */ + public static function created_at(): Field + { + /** @var Field $field */ + $field = new Field('created_at'); + + return $field; + } + + /** @return Field */ + public static function device_id(): Field + { + /** @var Field $field */ + $field = new Field('device_id'); + + return $field; + } + + /** @return Field */ + public static function device_type(): Field + { + /** @var Field $field */ + $field = new Field('device_type'); + + return $field; + } + + /** @return Field */ + public static function domain(): Field + { + /** @var Field $field */ + $field = new Field('domain'); + + return $field; + } + + /** @return Field */ + public static function entry_url(): Field + { + /** @var Field $field */ + $field = new Field('entry_url'); + + return $field; + } + + /** @return Field */ + public static function ip(): Field + { + /** @var Field $field */ + $field = new Field('ip'); + + return $field; + } + + /** @return Field */ + public static function is_abuser(): Field + { + /** @var Field $field */ + $field = new Field('is_abuser'); + + return $field; + } + + /** @return Field */ + public static function is_antidetect(): Field + { + /** @var Field $field */ + $field = new Field('is_antidetect'); + + return $field; + } + + /** @return Field */ + public static function is_browser_automation(): Field + { + /** @var Field $field */ + $field = new Field('is_browser_automation'); + + return $field; + } + + /** @return Field */ + public static function is_datacenter(): Field + { + /** @var Field $field */ + $field = new Field('is_datacenter'); + + return $field; + } + + /** @return Field */ + public static function is_incognito(): Field + { + /** @var Field $field */ + $field = new Field('is_incognito'); + + return $field; + } + + /** @return Field */ + public static function is_js_disabled(): Field + { + /** @var Field $field */ + $field = new Field('is_js_disabled'); + + return $field; + } + + /** @return Field */ + public static function is_os_mismatch(): Field + { + /** @var Field $field */ + $field = new Field('is_os_mismatch'); + + return $field; + } + + /** @return Field */ + public static function is_os_not_detected(): Field + { + /** @var Field $field */ + $field = new Field('is_os_not_detected'); + + return $field; + } + + /** @return Field */ + public static function is_privacy_relay(): Field + { + /** @var Field $field */ + $field = new Field('is_privacy_relay'); + + return $field; + } + + /** @return Field */ + public static function is_proxy(): Field + { + /** @var Field $field */ + $field = new Field('is_proxy'); + + return $field; + } + + /** @return Field */ + public static function is_search_bot(): Field + { + /** @var Field $field */ + $field = new Field('is_search_bot'); + + return $field; + } + + /** @return Field */ + public static function is_stun_not_checked(): Field + { + /** @var Field $field */ + $field = new Field('is_stun_not_checked'); + + return $field; + } + + /** @return Field */ + public static function is_suspicious_paid_click(): Field + { + /** @var Field $field */ + $field = new Field('is_suspicious_paid_click'); + + return $field; + } + + /** @return Field */ + public static function is_timezone_mismatch(): Field + { + /** @var Field $field */ + $field = new Field('is_timezone_mismatch'); + + return $field; + } + + /** @return Field */ + public static function is_tor(): Field + { + /** @var Field $field */ + $field = new Field('is_tor'); + + return $field; + } + + /** @return Field */ + public static function is_vpn(): Field + { + /** @var Field $field */ + $field = new Field('is_vpn'); + + return $field; + } + + /** @return Field */ + public static function os(): Field + { + /** @var Field $field */ + $field = new Field('os'); + + return $field; + } + + /** @return Field */ + public static function referrer_domain(): Field + { + /** @var Field $field */ + $field = new Field('referrer_domain'); + + return $field; + } + + /** @return Field */ + public static function request_id(): Field + { + /** @var Field $field */ + $field = new Field('request_id'); + + return $field; + } + + /** @return Field */ + public static function score(): Field + { + /** @var Field $field */ + $field = new Field('score'); + + return $field; + } + + /** @return Field */ + public static function score_details(): Field + { + /** @var Field $field */ + $field = new Field('score_details'); + + return $field; + } + + /** @return Field */ + public static function session_id(): Field + { + /** @var Field $field */ + $field = new Field('session_id'); + + return $field; + } + + /** @return Field */ + public static function site_domain(): Field + { + /** @var Field $field */ + $field = new Field('site_domain'); + + return $field; + } + + /** @return Field */ + public static function traffic_channel(): Field + { + /** @var Field $field */ + $field = new Field('traffic_channel'); + + return $field; + } + + /** @return Field */ + public static function traffic_channel_group(): Field + { + /** @var Field $field */ + $field = new Field('traffic_channel_group'); + + return $field; + } + + /** @return Field */ + public static function traffic_reason(): Field + { + /** @var Field $field */ + $field = new Field('traffic_reason'); + + return $field; + } + + /** @return Field */ + public static function user_hid(): Field + { + /** @var Field $field */ + $field = new Field('user_hid'); + + return $field; + } + + /** @return Field */ + public static function utm_campaign(): Field + { + /** @var Field $field */ + $field = new Field('utm_campaign'); + + return $field; + } + + /** @return Field */ + public static function utm_content(): Field + { + /** @var Field $field */ + $field = new Field('utm_content'); + + return $field; + } + + /** @return Field */ + public static function utm_medium(): Field + { + /** @var Field $field */ + $field = new Field('utm_medium'); + + return $field; + } + + /** @return Field */ + public static function utm_source(): Field + { + /** @var Field $field */ + $field = new Field('utm_source'); + + return $field; + } + + /** @return Field */ + public static function utm_term(): Field + { + /** @var Field $field */ + $field = new Field('utm_term'); + + return $field; + } + + /** @return Field */ + public static function ver(): Field + { + /** @var Field $field */ + $field = new Field('ver'); + + return $field; + } + + /** @return Field */ + public static function visitor_id(): Field + { + /** @var Field $field */ + $field = new Field('visitor_id'); + + return $field; + } + + /** @return Field */ + public static function web_rtc_connection_type(): Field + { + /** @var Field $field */ + $field = new Field('web_rtc_connection_type'); + + return $field; + } + + /** @return Field */ + public static function web_rtc_country(): Field + { + /** @var Field $field */ + $field = new Field('web_rtc_country'); + + return $field; + } + + /** @return Field */ + public static function web_rtc_ip(): Field + { + /** @var Field $field */ + $field = new Field('web_rtc_ip'); + + return $field; + } + + /** @return Field */ + public static function webrtc_leak_connection_type(): Field + { + /** @var Field $field */ + $field = new Field('webrtc_leak_connection_type'); + + return $field; + } + + /** @return Field */ + public static function webrtc_leak_country(): Field + { + /** @var Field $field */ + $field = new Field('webrtc_leak_country'); + + return $field; + } + + /** @return Field */ + public static function webrtc_leak_ip(): Field + { + /** @var Field $field */ + $field = new Field('webrtc_leak_ip'); + + return $field; + } + + /** @return Field */ + public static function webrtc_leak_source(): Field + { + /** @var Field $field */ + $field = new Field('webrtc_leak_source'); + + return $field; + } +} diff --git a/src/Internal/Wire/Generated/IpInfo.php b/src/Internal/Wire/Generated/IpInfo.php new file mode 100644 index 0000000..73eb5f1 --- /dev/null +++ b/src/Internal/Wire/Generated/IpInfo.php @@ -0,0 +1,29 @@ + */ + public static function country(): Field + { + /** @var Field $field */ + $field = new Field('country'); + + return $field; + } + + /** @return Field */ + public static function ip(): Field + { + /** @var Field $field */ + $field = new Field('ip'); + + return $field; + } +} diff --git a/src/Internal/Wire/Generated/LocalIpInfo.php b/src/Internal/Wire/Generated/LocalIpInfo.php new file mode 100644 index 0000000..2a70f59 --- /dev/null +++ b/src/Internal/Wire/Generated/LocalIpInfo.php @@ -0,0 +1,29 @@ + */ + public static function country(): Field + { + /** @var Field $field */ + $field = new Field('country'); + + return $field; + } + + /** @return Field */ + public static function ip(): Field + { + /** @var Field $field */ + $field = new Field('ip'); + + return $field; + } +} diff --git a/src/Internal/Wire/Generated/ProfileParameters.php b/src/Internal/Wire/Generated/ProfileParameters.php new file mode 100644 index 0000000..7e8996b --- /dev/null +++ b/src/Internal/Wire/Generated/ProfileParameters.php @@ -0,0 +1,26 @@ + */ + public static function X_Shield_Domain(): Field + { + /** @var Field $field */ + $field = new Field('X-Shield-Domain'); + + return $field; + } + + /** Operation path. */ + public static function path(): string + { + return strtr('/v1/profile', []); + } +} diff --git a/src/Internal/Wire/Generated/ScoreDetail.php b/src/Internal/Wire/Generated/ScoreDetail.php new file mode 100644 index 0000000..1fe8c94 --- /dev/null +++ b/src/Internal/Wire/Generated/ScoreDetail.php @@ -0,0 +1,29 @@ + */ + public static function Description(): Field + { + /** @var Field $field */ + $field = new Field('Description'); + + return $field; + } + + /** @return Field */ + public static function Value(): Field + { + /** @var Field $field */ + $field = new Field('Value'); + + return $field; + } +} diff --git a/src/Internal/Wire/Generated/ScoredData.php b/src/Internal/Wire/Generated/ScoredData.php new file mode 100644 index 0000000..1b6a10b --- /dev/null +++ b/src/Internal/Wire/Generated/ScoredData.php @@ -0,0 +1,173 @@ + */ + public static function browser(): Field + { + /** @var Field $field */ + $field = new Field('browser'); + + return $field; + } + + /** @return Field */ + public static function connection_type(): Field + { + /** @var Field $field */ + $field = new Field('connection_type'); + + return $field; + } + + /** @return Field */ + public static function cookie_id(): Field + { + /** @var Field $field */ + $field = new Field('cookie_id'); + + return $field; + } + + /** @return Field> */ + public static function detection_flags(): Field + { + /** @var Field> $field */ + $field = new Field('detection_flags'); + + return $field; + } + + /** @return Field */ + public static function device_id(): Field + { + /** @var Field $field */ + $field = new Field('device_id'); + + return $field; + } + + /** @return Field */ + public static function device_type(): Field + { + /** @var Field $field */ + $field = new Field('device_type'); + + return $field; + } + + /** @return Field */ + public static function domain(): Field + { + /** @var Field $field */ + $field = new Field('domain'); + + return $field; + } + + /** @return Field> */ + public static function local_ip(): Field + { + /** @var Field> $field */ + $field = new Field('local_ip'); + + return $field; + } + + /** @return Field */ + public static function observed_at(): Field + { + /** @var Field $field */ + $field = new Field('observed_at'); + + return $field; + } + + /** @return Field */ + public static function os(): Field + { + /** @var Field $field */ + $field = new Field('os'); + + return $field; + } + + /** @return Field> */ + public static function public_ip(): Field + { + /** @var Field> $field */ + $field = new Field('public_ip'); + + return $field; + } + + /** @return Field */ + public static function request_id(): Field + { + /** @var Field $field */ + $field = new Field('request_id'); + + return $field; + } + + /** @return Field */ + public static function risk_score(): Field + { + /** @var Field $field */ + $field = new Field('risk_score'); + + return $field; + } + + /** @return Field */ + public static function session_id(): Field + { + /** @var Field $field */ + $field = new Field('session_id'); + + return $field; + } + + /** @return Field> */ + public static function signals(): Field + { + /** @var Field> $field */ + $field = new Field('signals'); + + return $field; + } + + /** @return Field> */ + public static function traffic_source(): Field + { + /** @var Field> $field */ + $field = new Field('traffic_source'); + + return $field; + } + + /** @return Field */ + public static function user_hid(): Field + { + /** @var Field $field */ + $field = new Field('user_hid'); + + return $field; + } + + /** @return Field */ + public static function visitor_id(): Field + { + /** @var Field $field */ + $field = new Field('visitor_id'); + + return $field; + } +} diff --git a/src/Internal/Wire/Generated/Signal.php b/src/Internal/Wire/Generated/Signal.php new file mode 100644 index 0000000..891aa33 --- /dev/null +++ b/src/Internal/Wire/Generated/Signal.php @@ -0,0 +1,29 @@ + */ + public static function name(): Field + { + /** @var Field $field */ + $field = new Field('name'); + + return $field; + } + + /** @return Field */ + public static function weight(): Field + { + /** @var Field $field */ + $field = new Field('weight'); + + return $field; + } +} diff --git a/src/Internal/Wire/Generated/TrafficSource.php b/src/Internal/Wire/Generated/TrafficSource.php new file mode 100644 index 0000000..4dbefc7 --- /dev/null +++ b/src/Internal/Wire/Generated/TrafficSource.php @@ -0,0 +1,92 @@ + */ + public static function channel(): Field + { + /** @var Field $field */ + $field = new Field('channel'); + + return $field; + } + + /** @return Field */ + public static function click_id_type(): Field + { + /** @var Field $field */ + $field = new Field('click_id_type'); + + return $field; + } + + /** @return Field */ + public static function landing_url(): Field + { + /** @var Field $field */ + $field = new Field('landing_url'); + + return $field; + } + + /** @return Field */ + public static function referrer_domain(): Field + { + /** @var Field $field */ + $field = new Field('referrer_domain'); + + return $field; + } + + /** @return Field */ + public static function utm_campaign(): Field + { + /** @var Field $field */ + $field = new Field('utm_campaign'); + + return $field; + } + + /** @return Field */ + public static function utm_content(): Field + { + /** @var Field $field */ + $field = new Field('utm_content'); + + return $field; + } + + /** @return Field */ + public static function utm_medium(): Field + { + /** @var Field $field */ + $field = new Field('utm_medium'); + + return $field; + } + + /** @return Field */ + public static function utm_source(): Field + { + /** @var Field $field */ + $field = new Field('utm_source'); + + return $field; + } + + /** @return Field */ + public static function utm_term(): Field + { + /** @var Field $field */ + $field = new Field('utm_term'); + + return $field; + } +} diff --git a/src/Internal/Wire/Generated/WebhookEnvelope.php b/src/Internal/Wire/Generated/WebhookEnvelope.php new file mode 100644 index 0000000..8d63411 --- /dev/null +++ b/src/Internal/Wire/Generated/WebhookEnvelope.php @@ -0,0 +1,47 @@ + */ + public static function created_at(): Field + { + /** @var Field $field */ + $field = new Field('created_at'); + + return $field; + } + + /** @return Field> */ + public static function data(): Field + { + /** @var Field> $field */ + $field = new Field('data'); + + return $field; + } + + /** @return Field */ + public static function event_type(): Field + { + /** @var Field $field */ + $field = new Field('event_type'); + + return $field; + } + + /** @return Field */ + public static function schema_version(): Field + { + /** @var Field $field */ + $field = new Field('schema_version'); + + return $field; + } +} diff --git a/src/Internal/Wire/Read.php b/src/Internal/Wire/Read.php new file mode 100644 index 0000000..c26e2c2 --- /dev/null +++ b/src/Internal/Wire/Read.php @@ -0,0 +1,68 @@ +|Field $field + * @param array $object + */ + public static function text(Field $field, array $object): mixed + { + return $field->read($object); + } + + /** + * @param Field $field + * @param array $object + */ + public static function integer(Field $field, array $object): mixed + { + return $field->read($object); + } + + /** + * @param Field $field + * @param array $object + */ + public static function boolean(Field $field, array $object): mixed + { + return $field->read($object); + } + + /** + * @param Field> $field + * @param array $object + */ + public static function collection(Field $field, array $object): mixed + { + return $field->read($object); + } + + /** + * @param Field $field + * @return array + */ + public static function integerParameter(Field $field, int $value): array + { + return [$field->name => $value]; + } + + /** + * @param Field $field + * @return array + */ + public static function textParameter(Field $field, string $value): array + { + return [$field->name => $value]; + } +} diff --git a/src/Model/DomainProfile.php b/src/Model/DomainProfile.php index 1be65c5..8168564 100644 --- a/src/Model/DomainProfile.php +++ b/src/Model/DomainProfile.php @@ -6,6 +6,8 @@ use ShieldLabs\Internal\Normalizer; use ShieldLabs\Internal\Time; +use ShieldLabs\Internal\Wire\Generated\DomainProfile as WireProfile; +use ShieldLabs\Internal\Wire\Read; /** * Domain profile from the Management API. @@ -36,18 +38,18 @@ public function __construct( */ public static function fromArray(array $body): self { - $string = static fn(string $key): string => \is_string($body[$key] ?? null) ? $body[$key] : ''; - $weight = $body['Weight'] ?? null; + $string = static fn(mixed $value): string => \is_string($value) ? $value : ''; + $weight = Read::integer(WireProfile::Weight(), $body); if (\is_float($weight) && is_finite($weight)) { $weight = (int) $weight; } return new self( - domain: $string('Domain'), + domain: $string(Read::text(WireProfile::Domain(), $body)), remaining_identifications: \is_int($weight) ? $weight : 0, - public_key_masked: $string('PublicKey'), - secret_key_masked: $string('Secret'), - created_at: Normalizer::parseRfc3339($body['CreatedAt'] ?? null), + public_key_masked: $string(Read::text(WireProfile::PublicKey(), $body)), + secret_key_masked: $string(Read::text(WireProfile::Secret(), $body)), + created_at: Normalizer::parseRfc3339(Read::text(WireProfile::CreatedAt(), $body)), raw: $body, ); } diff --git a/src/Model/HistoryPage.php b/src/Model/HistoryPage.php index e48cc66..428f129 100644 --- a/src/Model/HistoryPage.php +++ b/src/Model/HistoryPage.php @@ -4,6 +4,9 @@ namespace ShieldLabs\Model; +use ShieldLabs\Internal\Wire\Generated\HistoryPage as WirePage; +use ShieldLabs\Internal\Wire\Read; + /** * One page of History API results, newest first. * @@ -27,7 +30,7 @@ public function __construct( */ public static function fromArray(array $body): self { - $rows = $body['data'] ?? null; + $rows = Read::collection(WirePage::data(), $body); $data = []; if (\is_array($rows)) { foreach ($rows as $row) { @@ -36,7 +39,7 @@ public static function fromArray(array $body): self } } } - $total = $body['total'] ?? null; + $total = Read::integer(WirePage::total(), $body); if (\is_float($total) && is_finite($total)) { $total = (int) $total; } diff --git a/src/Resource/History.php b/src/Resource/History.php index 4c66ab1..6de3244 100644 --- a/src/Resource/History.php +++ b/src/Resource/History.php @@ -10,6 +10,8 @@ use ShieldLabs\Internal\Options; use ShieldLabs\Internal\Transport; use ShieldLabs\Internal\Validate; +use ShieldLabs\Internal\Wire\Generated\HistoryParameters; +use ShieldLabs\Internal\Wire\Read; use ShieldLabs\LookupType; use ShieldLabs\Model\HistoryPage; use ShieldLabs\Model\Identification; @@ -84,8 +86,10 @@ public function iterate(LookupType|string $type, string $value, array $options = */ public function fetch(LookupType $type, string $value, int $limit, int $offset, ?int $maxRetries = null, ?float $attemptTimeout = null): HistoryPage { - $path = '/api/v1/history/' . $type->value . '/' . Validate::pathSegment($value); - $body = $this->transport->getJson($path, ['limit' => $limit, 'offset' => $offset], $maxRetries, $attemptTimeout); + $path = HistoryParameters::path(search_type: $type->value, value: Validate::pathSegment($value)); + $query = Read::integerParameter(HistoryParameters::limit(), $limit) + + Read::integerParameter(HistoryParameters::offset(), $offset); + $body = $this->transport->getJson($path, $query, $maxRetries, $attemptTimeout); if (!\is_array($body)) { throw new ApiException('The History API returned an unexpected response body.', 200, $body); } diff --git a/src/ShieldLabsManagement.php b/src/ShieldLabsManagement.php index 4c62b8d..1b66982 100644 --- a/src/ShieldLabsManagement.php +++ b/src/ShieldLabsManagement.php @@ -15,6 +15,8 @@ use ShieldLabs\Internal\Transport; use ShieldLabs\Internal\UserAgent; use ShieldLabs\Internal\Validate; +use ShieldLabs\Internal\Wire\Generated\ProfileParameters; +use ShieldLabs\Internal\Wire\Read; use ShieldLabs\Model\DomainProfile; /** @@ -86,7 +88,7 @@ public function __construct(array $options = []) $baseUrl, [ 'Authorization' => 'Bearer ' . $secretKey, - 'X-Shield-Domain' => $this->domain, + ...Read::textParameter(ProfileParameters::X_Shield_Domain(), $this->domain), 'Accept' => 'application/json', 'User-Agent' => UserAgent::value(), ], @@ -111,7 +113,7 @@ public function getDomain(): string */ public function getProfile(): DomainProfile { - $body = $this->transport->getJson('/v1/profile'); + $body = $this->transport->getJson(ProfileParameters::path()); if (!\is_array($body)) { throw new ApiException('The Management API returned an unexpected response body.', 200, $body); } diff --git a/src/Webhook.php b/src/Webhook.php index 32d031c..0d92afe 100644 --- a/src/Webhook.php +++ b/src/Webhook.php @@ -10,6 +10,8 @@ use ShieldLabs\Exception\SignatureVerificationException; use ShieldLabs\Exception\WebhookParseException; use ShieldLabs\Internal\Normalizer; +use ShieldLabs\Internal\Wire\Generated\WebhookEnvelope; +use ShieldLabs\Internal\Wire\Read; use ShieldLabs\Model\Identification; /** @@ -109,16 +111,17 @@ private static function parse(string $payload): IdentificationScoredEvent|Webhoo if (!\is_array($body) || ($body !== [] && array_is_list($body))) { throw new WebhookParseException('The webhook body is not a JSON object.'); } - $eventType = $body['event_type'] ?? null; + $eventType = Read::text(WebhookEnvelope::event_type(), $body); if (!\is_string($eventType) || $eventType === '') { throw new WebhookParseException('The webhook body has no event_type.'); } - $schemaVersion = \is_string($body['schema_version'] ?? null) ? $body['schema_version'] : ''; - $createdAt = Normalizer::parseRfc3339($body['created_at'] ?? null); + $version = Read::text(WebhookEnvelope::schema_version(), $body); + $schemaVersion = \is_string($version) ? $version : ''; + $createdAt = Normalizer::parseRfc3339(Read::text(WebhookEnvelope::created_at(), $body)); switch ($eventType) { case IdentificationScoredEvent::TYPE: - $data = $body['data'] ?? null; + $data = Read::collection(WebhookEnvelope::data(), $body); if (!\is_array($data) || ($data !== [] && array_is_list($data))) { throw new WebhookParseException('The identification.scored event has no data object.'); } diff --git a/tests/Package/consumer.php b/tests/Package/consumer.php new file mode 100644 index 0000000..a14ff17 --- /dev/null +++ b/tests/Package/consumer.php @@ -0,0 +1,72 @@ +last = $request; + if ($request->getUri()->getPath() === '/v1/profile') { + return new Response(200, [], json_encode(['Domain' => 'example.com', 'Weight' => -2, 'PublicKey' => '***1234', 'Secret' => '***5678', 'CreatedAt' => null, 'future' => 'kept'], JSON_THROW_ON_ERROR)); + } + + return new Response(200, [], json_encode(['data' => [[ + 'request_id' => '00000000-0000-0000-0000-000000000001', + 'score' => 999, + 'connection_type' => 'future-network', + 'score_details' => '[{"Description":"future signal","Value":-30},{"Description":"future signal","Value":-30}]', + 'is_vpn' => true, + 'future' => ['kept' => true], + ]], 'total' => 1], JSON_THROW_ON_ERROR)); + } +}; +$sdk = new ShieldLabs(['api_key' => 'sec_00000000-00000000-00000000', 'http_client' => $client]); +$page = $sdk->history->search('user_hid', 'anonymous', ['limit' => 2, 'offset' => 3]); +expectSame('/api/v1/history/user_hid/anonymous', $client->last->getUri()->getPath()); +expectSame('limit=2&offset=3', $client->last->getUri()->getQuery()); +expectSame(1, $page->total); +expectSame(999, $page->data[0]->risk_score); +expectSame('future-network', $page->data[0]->connection_type); +expectSame([-30, -30], array_map(static fn($signal) => $signal->weight, $page->data[0]->signals)); +expectSame(['kept' => true], $page->data[0]->raw['future']); +expectSame(true, $page->data[0]->detection_flags->vpn); + +$management = new ShieldLabsManagement(['secret_key' => 'fixture_secret', 'domain' => 'example.com', 'http_client' => $client]); +$profile = $management->getProfile(); +expectSame('example.com', $client->last->getHeaderLine('X-Shield-Domain')); +expectSame(-2, $profile->remaining_identifications); +expectSame('kept', $profile->raw['future']); + +$payload = json_encode(['event_type' => 'identification.scored', 'schema_version' => 'future', 'data' => [ + 'risk_score' => 999, 'user_hid' => null, 'connection_type' => 'future-network', + 'signals' => [['name' => 'future', 'weight' => -30], ['name' => 'future', 'weight' => -30]], + 'detection_flags' => ['vpn' => true], 'future' => 'kept', +]], JSON_THROW_ON_ERROR); +$secret = 'whsec_fixture_package'; +$event = Webhook::constructEvent($payload, 'sha256=' . hash_hmac('sha256', $payload, $secret), $secret); +expectSame(999, $event->data->risk_score); +expectSame([-30, -30], array_map(static fn($signal) => $signal->weight, $event->data->signals)); +expectSame(null, $event->data->user_hid); +expectSame('kept', $event->data->raw['future']); +expectSame(true, $event->data->detection_flags->vpn); +echo "Packed runtime-only consumer: History, profile, signed webhook PASS\n"; diff --git a/tests/Unit/GeneratedContractTest.php b/tests/Unit/GeneratedContractTest.php new file mode 100644 index 0000000..640ba24 --- /dev/null +++ b/tests/Unit/GeneratedContractTest.php @@ -0,0 +1,82 @@ + true]] as $value) { + self::assertSame($value, Read::integer(HistoryRow::score(), ['score' => $value])); + } + self::assertNull(Read::integer(HistoryRow::score(), [])); + self::assertSame('new-network', Read::text(HistoryRow::connection_type(), ['connection_type' => 'new-network'])); + } + + public function testHistoryStillNormalizesAndKeepsUnknownFields(): void + { + $body = Fixtures::json('history-page.json'); + $row = $body['data'][0]; + $row['future_optional'] = ['nested' => true]; + $row['connection_type'] = 'new-network'; + $row['score'] = 25.9; + $row['is_vpn'] = 'yes'; + $row['created_at'] = null; + $body = ['data' => [$row, null, false], 'total' => null]; + $http = (new MockHttpClient())->always(MockHttpClient::json(200, $body)); + $page = Clients::history($http)->history->search('user_hid', 'anonymous'); + self::assertSame(1, $page->total); + self::assertCount(1, $page->data); + self::assertSame(25, $page->data[0]->risk_score); + self::assertTrue($page->data[0]->detection_flags->vpn); + self::assertSame('new-network', $page->data[0]->connection_type); + self::assertNull($page->data[0]->observed_at); + self::assertSame($row, $page->data[0]->raw); + self::assertCount(0, HistoryPage::fromArray(['data' => 'wrong', 'total' => 'wrong'])); + } + + public function testProfileKeepsExistingMalformedDefaults(): void + { + $raw = ['Domain' => null, 'Weight' => -1.9, 'Secret' => false, 'PublicKey' => 12, 'CreatedAt' => [], 'future' => 'kept']; + $profile = DomainProfile::fromArray($raw); + self::assertSame('', $profile->domain); + self::assertSame(-1, $profile->remaining_identifications); + self::assertSame('', $profile->public_key_masked); + self::assertSame('', $profile->secret_key_masked); + self::assertNull($profile->created_at); + self::assertSame($raw, $profile->raw); + } + + public function testSignedWebhookKeepsUnknownAndMalformedValues(): void + { + $raw = Fixtures::json('webhook-identification-scored.json'); + $raw['schema_version'] = 'future'; + $raw['data']['risk_score'] = 'malformed'; + $raw['data']['user_hid'] = null; + $raw['data']['connection_type'] = 'future-network'; + $raw['data']['detection_flags']['vpn'] = 'yes'; + $raw['data']['traffic_source']['channel'] = 'future-channel'; + $raw['data']['future_optional'] = ['a' => 1]; + $body = json_encode($raw, \JSON_THROW_ON_ERROR); + $secret = 'whsec_fixture_generated_contract'; + $event = Webhook::constructEvent($body, 'sha256=' . hash_hmac('sha256', $body, $secret), $secret); + self::assertSame(0, $event->data->risk_score); + self::assertSame('future-network', $event->data->connection_type); + self::assertSame('future-channel', $event->data->traffic_source->channel); + self::assertTrue($event->data->detection_flags->vpn); + self::assertNull($event->data->user_hid); + self::assertSame($raw['data'], $event->data->raw); + } +} From 5b07ff0f65bc664d65391ec3bc26dcf512951073 Mon Sep 17 00:00:00 2001 From: EgorMajj <91486022+EgorMajj@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:51:37 +0300 Subject: [PATCH 2/2] chore: prepare PHP SDK 1.0.1 --- CHANGELOG.md | 2 +- src/ShieldLabs.php | 2 +- tests/Unit/HistorySearchTest.php | 4 ++-- tests/Unit/ManagementClientTest.php | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7031660..4e6a63d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this package are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package follows [Semantic Versioning](https://semver.org/). -## [Unreleased] +## [1.0.1] - 2026-10-05 ### Added diff --git a/src/ShieldLabs.php b/src/ShieldLabs.php index 16f455e..ef07010 100644 --- a/src/ShieldLabs.php +++ b/src/ShieldLabs.php @@ -31,7 +31,7 @@ */ final class ShieldLabs { - public const VERSION = '1.0.0'; + public const VERSION = '1.0.1'; public const DEFAULT_BASE_URL = 'https://account.shieldlabs.ai'; public const DEFAULT_TIMEOUT = 10.0; public const DEFAULT_MAX_RETRIES = 2; diff --git a/tests/Unit/HistorySearchTest.php b/tests/Unit/HistorySearchTest.php index 1d126ab..5c7f357 100644 --- a/tests/Unit/HistorySearchTest.php +++ b/tests/Unit/HistorySearchTest.php @@ -32,7 +32,7 @@ public function testSendsTheDocumentedRequest(): void ); self::assertSame('Bearer ' . Clients::API_KEY, $request->getHeaderLine('Authorization')); self::assertSame('application/json', $request->getHeaderLine('Accept')); - self::assertMatchesRegularExpression('#^shieldlabs-php/1\.0\.0 \(PHP \d+\.\d+\.\d+.*; \w+\)$#', $request->getHeaderLine('User-Agent')); + self::assertMatchesRegularExpression('#^shieldlabs-php/1\.0\.1 \(PHP \d+\.\d+\.\d+.*; \w+\)$#', $request->getHeaderLine('User-Agent')); self::assertSame('', (string) $request->getBody()); } @@ -371,6 +371,6 @@ public function testVersionConstantMatchesTheChangelog(): void { $changelog = (string) file_get_contents(\dirname(__DIR__, 2) . '/CHANGELOG.md'); - self::assertStringContainsString('## [' . ShieldLabs::VERSION . '] - 2026-09-30', $changelog); + self::assertStringContainsString('## [' . ShieldLabs::VERSION . '] - 2026-10-05', $changelog); } } diff --git a/tests/Unit/ManagementClientTest.php b/tests/Unit/ManagementClientTest.php index f5ea244..e396402 100644 --- a/tests/Unit/ManagementClientTest.php +++ b/tests/Unit/ManagementClientTest.php @@ -39,7 +39,7 @@ public function testReadsTheProfile(): void self::assertSame('example.com', $request->getHeaderLine('X-Shield-Domain')); self::assertSame('Bearer ' . Clients::SECRET_KEY, $request->getHeaderLine('Authorization')); self::assertSame('application/json', $request->getHeaderLine('Accept')); - self::assertStringStartsWith('shieldlabs-php/1.0.0 ', $request->getHeaderLine('User-Agent')); + self::assertStringStartsWith('shieldlabs-php/1.0.1 ', $request->getHeaderLine('User-Agent')); self::assertSame('example.com', $client->getDomain()); }