Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ 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

- `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

Expand Down
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -53,18 +54,27 @@
"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
},
"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"
Expand Down
223 changes: 223 additions & 0 deletions scripts/check-contract-drift.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<?php

declare(strict_types=1);

require __DIR__ . '/generate-wire.php';

use Symfony\Component\Yaml\Yaml;

$root = dirname(__DIR__);
$document = Yaml::parseFile($root . '/resources/shieldlabs-api.yaml');
$scratch = $root . '/build/contract-' . bin2hex(random_bytes(8));
mkdir($scratch, 0777, true);

function copyContractSource(string $source, string $target): void
{
mkdir($target, 0777, true);
foreach (new DirectoryIterator($source) as $entry) {
if ($entry->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'
<?php
require $argv[1];
spl_autoload_register(static function (string $class): void {
if (str_starts_with($class, 'ShieldLabs\\')) {
require __DIR__ . '/src/' . str_replace('\\', '/', substr($class, 11)) . '.php';
}
}, true, true);
$http = new class implements \Psr\Http\Client\ClientInterface {
public string $path = '';
public string $query = '';
public function sendRequest(\Psr\Http\Message\RequestInterface $request): \Psr\Http\Message\ResponseInterface {
$this->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);
}
Loading
Loading