From 697ec2a519f93ce68f2af0e145294b61995a21c9 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 16:08:44 +0200 Subject: [PATCH 01/16] Add Lists, Privacy and Webhook verification APIs - Lists API: createList, getAllLists, getList, updateList, deleteList, queryList - List Items API: createListItem, createListItems, getListItem, updateListItem, queryListItems, countListItems, archiveListItem, unarchiveListItem - Privacy API: requestUserData, deleteUserData - Webhook signature verification via Castle_Webhook::verify and the Castle_WebhookVerificationError exception - Attach the request context automatically to risk, filter and log requests - Harden API error handling against empty error response bodies --- .gitignore | 1 + CHANGELOG.md | 7 ++ README.md | 72 +++++++++++++ lib/Castle.php | 1 + lib/Castle/Castle.php | 208 +++++++++++++++++++++++++++++++++++- lib/Castle/Errors.php | 5 + lib/Castle/Request.php | 13 ++- lib/Castle/Webhook.php | 48 +++++++++ test/Castle.php | 1 + test/CastleTest.php | 164 ++++++++++++++++++++++++++++ test/RequestContextTest.php | 2 +- test/WebhookTest.php | 48 +++++++++ 12 files changed, 564 insertions(+), 6 deletions(-) create mode 100644 lib/Castle/Webhook.php create mode 100644 test/WebhookTest.php diff --git a/.gitignore b/.gitignore index c315f07..c121360 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ package-lock.json composer.lock /node_modules/ /vendor/ +.phpunit.result.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 0539f7f..17076e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ # Changelog +## 3.3.0 +* added the Lists API: `Castle::createList`, `Castle::getAllLists`, `Castle::getList`, `Castle::updateList`, `Castle::deleteList`, `Castle::queryList` +* added the List Items API: `Castle::createListItem`, `Castle::createListItems`, `Castle::getListItem`, `Castle::updateListItem`, `Castle::queryListItems`, `Castle::countListItems`, `Castle::archiveListItem`, `Castle::unarchiveListItem` +* added the Privacy API: `Castle::requestUserData`, `Castle::deleteUserData` +* added webhook signature verification: `Castle_Webhook::verify` and the `Castle_WebhookVerificationError` exception +* the request context is now attached automatically to `risk`, `filter` and `log` requests + ## 3.2.0 (2022-03-28) * updated ca-certs file diff --git a/README.md b/README.md index 4ade8b7..ddb3581 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,77 @@ Castle_RequestContext['ip'] = '1.1.1.1' $context = Castle_RequestContext::extractJson(); ``` +## Lists + +Manage [lists](https://docs.castle.io) and their items: + +```php +$list = Castle::createList([ + 'name' => 'Blocklist', + 'color' => '$red', + 'primary_field' => 'user.email', +]); + +$lists = Castle::getAllLists(); +$list = Castle::getList($list['id']); +Castle::updateList($list['id'], ['name' => 'Renamed']); +Castle::deleteList($list['id']); +Castle::queryList(['filters' => [['field' => 'name', 'op' => '$eq', 'value' => 'Blocklist']]]); +``` + +List items: + +```php +$item = Castle::createListItem($list['id'], [ + 'author' => 'user:123', + 'primary_value' => 'user@example.com', +]); + +Castle::getListItem($list['id'], $item['id']); +Castle::updateListItem($list['id'], $item['id'], ['comment' => 'Flagged for review']); +Castle::queryListItems($list['id'], ['filters' => []]); +Castle::countListItems($list['id'], ['filters' => []]); +Castle::archiveListItem($list['id'], $item['id']); +Castle::unarchiveListItem($list['id'], $item['id']); +Castle::createListItems($list['id'], ['items' => [/* ... */]]); +``` + +## Privacy + +Request or delete the data Castle stores for a user: + +```php +Castle::requestUserData([ + 'identifier' => 'user@example.com', + 'identifier_type' => '$email', +]); + +Castle::deleteUserData([ + 'identifier' => 'user@example.com', + 'identifier_type' => '$email', +]); +``` + +## Webhooks + +Verify the authenticity of incoming Castle webhooks. By default the raw body is +read from `php://input` and the signature from the `X-Castle-Signature` header: + +```php +try { + Castle_Webhook::verify(); + // handle the webhook payload +} catch (Castle_WebhookVerificationError $e) { + http_response_code(404); +} +``` + +The body and signature can also be passed explicitly: + +```php +Castle_Webhook::verify($rawBody, $signatureHeader); +``` + ## Errors Whenever something unexpected happens, an [exception](/lib/Castle/Errors.php) is thrown to indicate what went wrong. @@ -86,6 +157,7 @@ Whenever something unexpected happens, an [exception](/lib/Castle/Errors.php) is | `Castle_NotFoundError` | The resource requestd was not found. For example if a session has been revoked. | | `Castle_InvalidParametersError` | One or more of the supplied parameters are incorrect. Check the response for more information. | | `Castle_InvalidRequestTokenError` | The request token parameter is missing or invalid | +| `Castle_WebhookVerificationError` | An incoming webhook could not be verified against the `X-Castle-Signature` header | ## Running test suite Execute `vendor/bin/phpunit test` to run the full test suite diff --git a/lib/Castle.php b/lib/Castle.php index 9acaaa6..d19022a 100755 --- a/lib/Castle.php +++ b/lib/Castle.php @@ -25,3 +25,4 @@ function lcfirst( $str ) { require(dirname(__FILE__) . '/Castle/CurlTransport.php'); require(dirname(__FILE__) . '/Castle/RequestContext.php'); require(dirname(__FILE__) . '/Castle/Request.php'); +require(dirname(__FILE__) . '/Castle/Webhook.php'); diff --git a/lib/Castle/Castle.php b/lib/Castle/Castle.php index 7bbffd6..b62e181 100755 --- a/lib/Castle/Castle.php +++ b/lib/Castle/Castle.php @@ -2,7 +2,7 @@ abstract class Castle { - const VERSION = '3.2.0'; + const VERSION = '3.3.0'; const HEADER_COOKIE = 'Cookie'; const HEADER_USER_AGENT = 'User-Agent'; @@ -171,4 +171,210 @@ public static function risk(Array $attributes) } return new RestModel($response); } + + /** + * Lists API + */ + + /** + * Create a list + * @param Array $attributes 'name', 'color' and 'primary_field' are required + * @return Array + */ + public static function createList(Array $attributes) + { + return self::sendRequest('post', '/lists', $attributes); + } + + /** + * Fetch all lists + * @return Array + */ + public static function getAllLists() + { + return self::sendRequest('get', '/lists'); + } + + /** + * Fetch a single list + * @param String $listId + * @return Array + */ + public static function getList($listId) + { + return self::sendRequest('get', self::listPath($listId)); + } + + /** + * Update a list + * @param String $listId + * @param Array $attributes + * @return Array + */ + public static function updateList($listId, Array $attributes) + { + return self::sendRequest('put', self::listPath($listId), $attributes); + } + + /** + * Delete a list + * @param String $listId + * @return Array + */ + public static function deleteList($listId) + { + return self::sendRequest('delete', self::listPath($listId)); + } + + /** + * Query lists + * @param Array $attributes + * @return Array + */ + public static function queryList(Array $attributes = array()) + { + return self::sendRequest('post', '/lists/query', $attributes); + } + + /** + * List Items API + */ + + /** + * Create a list item + * @param String $listId + * @param Array $attributes 'author' and 'primary_value' are required + * @return Array + */ + public static function createListItem($listId, Array $attributes) + { + return self::sendRequest('post', self::listItemsPath($listId), $attributes); + } + + /** + * Create a batch of list items + * @param String $listId + * @param Array $attributes 'items' is required + * @return Array + */ + public static function createListItems($listId, Array $attributes) + { + return self::sendRequest('post', self::listItemsPath($listId) . '/batch', $attributes); + } + + /** + * Fetch a list item + * @param String $listId + * @param String $itemId + * @return Array + */ + public static function getListItem($listId, $itemId) + { + return self::sendRequest('get', self::listItemPath($listId, $itemId)); + } + + /** + * Update a list item + * @param String $listId + * @param String $itemId + * @param Array $attributes 'comment' is required + * @return Array + */ + public static function updateListItem($listId, $itemId, Array $attributes) + { + return self::sendRequest('put', self::listItemPath($listId, $itemId), $attributes); + } + + /** + * Query the items of a list + * @param String $listId + * @param Array $attributes + * @return Array + */ + public static function queryListItems($listId, Array $attributes = array()) + { + return self::sendRequest('post', self::listItemsPath($listId) . '/query', $attributes); + } + + /** + * Count the items of a list + * @param String $listId + * @param Array $attributes + * @return Array + */ + public static function countListItems($listId, Array $attributes = array()) + { + return self::sendRequest('post', self::listItemsPath($listId) . '/count', $attributes); + } + + /** + * Archive a list item + * @param String $listId + * @param String $itemId + * @return Array + */ + public static function archiveListItem($listId, $itemId) + { + return self::sendRequest('delete', self::listItemPath($listId, $itemId) . '/archive'); + } + + /** + * Unarchive a list item + * @param String $listId + * @param String $itemId + * @return Array + */ + public static function unarchiveListItem($listId, $itemId) + { + return self::sendRequest('put', self::listItemPath($listId, $itemId) . '/unarchive'); + } + + /** + * Privacy API + */ + + /** + * Request the data stored for a user + * @param Array $attributes 'identifier' and 'identifier_type' are required + * @return Array + */ + public static function requestUserData(Array $attributes) + { + return self::sendRequest('post', '/privacy/users', $attributes); + } + + /** + * Delete the data stored for a user + * @param Array $attributes 'identifier' and 'identifier_type' are required + * @return Array + */ + public static function deleteUserData(Array $attributes) + { + return self::sendRequest('delete', '/privacy/users', $attributes); + } + + private static function sendRequest($method, $path, $attributes = null) + { + $request = new Castle_Request(); + list($response, $request) = $request->send($method, $path, $attributes); + if ($request->rStatus == 204) { + $response = array(); + } + return $response; + } + + private static function listPath($listId) + { + return '/lists/' . rawurlencode($listId); + } + + private static function listItemsPath($listId) + { + return self::listPath($listId) . '/items'; + } + + private static function listItemPath($listId, $itemId) + { + return self::listItemsPath($listId) . '/' . rawurlencode($itemId); + } } diff --git a/lib/Castle/Errors.php b/lib/Castle/Errors.php index 2284c9a..cb15081 100755 --- a/lib/Castle/Errors.php +++ b/lib/Castle/Errors.php @@ -59,3 +59,8 @@ class Castle_InvalidRequestTokenError extends Castle_InvalidParametersError { } + +class Castle_WebhookVerificationError extends Castle_Error +{ + +} diff --git a/lib/Castle/Request.php b/lib/Castle/Request.php index 50fe291..7e29498 100755 --- a/lib/Castle/Request.php +++ b/lib/Castle/Request.php @@ -15,8 +15,9 @@ public static function apiUrl($url='') public function handleApiError($response, $status) { - $type = $response['type']; - $msg = $response['message']; + $response = is_array($response) ? $response : array(); + $type = isset($response['type']) ? $response['type'] : null; + $msg = isset($response['message']) ? $response['message'] : null; switch ($status) { case 400: throw new Castle_BadRequest($msg, $type, $status); @@ -70,7 +71,11 @@ public function preFlightCheck() } } - public function send($method, $url, $payload = 's') { + public function send($method, $url, $payload = array()) { + if (!is_array($payload)) { + $payload = array(); + } + if ( self::shouldHaveContext($url) && !array_key_exists('context', $payload)) { $payload['context'] = Castle_RequestContext::extract(); } @@ -79,7 +84,7 @@ public function send($method, $url, $payload = 's') { } private function shouldHaveContext($url) { - $WITH_CONTEXT = ['/track', '/authenticate', '/impersonate']; + $WITH_CONTEXT = ['/track', '/authenticate', '/impersonate', '/risk', '/filter', '/log']; return in_array($url, $WITH_CONTEXT); } diff --git a/lib/Castle/Webhook.php b/lib/Castle/Webhook.php new file mode 100644 index 0000000..608cb2e --- /dev/null +++ b/lib/Castle/Webhook.php @@ -0,0 +1,48 @@ + '1', 'reset' => true)); $this->assertRequest('delete', '/impersonate'); } + + public function testRiskIncludesContext() + { + Castle_RequestTransport::setResponse(200, '{}'); + Castle::risk(Array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/risk'); + $this->assertArrayHasKey('context', $request['params']); + } + + public function testFilterIncludesContext() + { + Castle_RequestTransport::setResponse(200, '{}'); + Castle::filter(Array( + 'request_token' => 'token', + 'name' => '$registration', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/filter'); + $this->assertArrayHasKey('context', $request['params']); + } + + public function testLogIncludesContext() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::log(Array( + 'request_token' => 'token', + 'name' => '$login', + 'status' => '$succeeded', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/log'); + $this->assertArrayHasKey('context', $request['params']); + } + + public function testCreateList() + { + Castle_RequestTransport::setResponse(201, '{ "id": "list-id", "name": "blocklist" }'); + $list = Castle::createList(Array( + 'name' => 'blocklist', + 'color' => '$red', + 'primary_field' => 'user.email' + )); + $this->assertRequest('post', '/lists'); + $this->assertEquals('list-id', $list['id']); + } + + public function testGetAllLists() + { + Castle_RequestTransport::setResponse(200, '[{ "id": "list-id" }]'); + $lists = Castle::getAllLists(); + $this->assertRequest('get', '/lists'); + $this->assertEquals('list-id', $lists[0]['id']); + } + + public function testGetList() + { + Castle_RequestTransport::setResponse(200, '{ "id": "list-id" }'); + Castle::getList('list-id'); + $this->assertRequest('get', '/lists/list-id'); + } + + public function testUpdateList() + { + Castle_RequestTransport::setResponse(200, '{ "id": "list-id" }'); + Castle::updateList('list-id', array('name' => 'renamed')); + $this->assertRequest('put', '/lists/list-id'); + } + + public function testDeleteList() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::deleteList('list-id'); + $this->assertRequest('delete', '/lists/list-id'); + } + + public function testQueryList() + { + Castle_RequestTransport::setResponse(200, '{ "total_count": 0, "items": [] }'); + Castle::queryList(array('filters' => array())); + $this->assertRequest('post', '/lists/query'); + } + + public function testCreateListItem() + { + Castle_RequestTransport::setResponse(201, '{ "id": "item-id" }'); + Castle::createListItem('list-id', array( + 'author' => 'user:123', + 'primary_value' => 'user@example.com' + )); + $this->assertRequest('post', '/lists/list-id/items'); + } + + public function testCreateListItems() + { + Castle_RequestTransport::setResponse(201, '{ "items": [] }'); + Castle::createListItems('list-id', array('items' => array())); + $this->assertRequest('post', '/lists/list-id/items/batch'); + } + + public function testGetListItem() + { + Castle_RequestTransport::setResponse(200, '{ "id": "item-id" }'); + Castle::getListItem('list-id', 'item-id'); + $this->assertRequest('get', '/lists/list-id/items/item-id'); + } + + public function testUpdateListItem() + { + Castle_RequestTransport::setResponse(200, '{ "id": "item-id" }'); + Castle::updateListItem('list-id', 'item-id', array('comment' => 'note')); + $this->assertRequest('put', '/lists/list-id/items/item-id'); + } + + public function testQueryListItems() + { + Castle_RequestTransport::setResponse(200, '{ "total_count": 0, "items": [] }'); + Castle::queryListItems('list-id', array('filters' => array())); + $this->assertRequest('post', '/lists/list-id/items/query'); + } + + public function testCountListItems() + { + Castle_RequestTransport::setResponse(200, '{ "count": 0 }'); + Castle::countListItems('list-id', array('filters' => array())); + $this->assertRequest('post', '/lists/list-id/items/count'); + } + + public function testArchiveListItem() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::archiveListItem('list-id', 'item-id'); + $this->assertRequest('delete', '/lists/list-id/items/item-id/archive'); + } + + public function testUnarchiveListItem() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::unarchiveListItem('list-id', 'item-id'); + $this->assertRequest('put', '/lists/list-id/items/item-id/unarchive'); + } + + public function testRequestUserData() + { + Castle_RequestTransport::setResponse(200, '{ "id": "req-id" }'); + Castle::requestUserData(array( + 'identifier' => 'user@example.com', + 'identifier_type' => '$email' + )); + $this->assertRequest('post', '/privacy/users'); + } + + public function testDeleteUserData() + { + Castle_RequestTransport::setResponse(200, '{ "id": "req-id" }'); + Castle::deleteUserData(array( + 'identifier' => 'user@example.com', + 'identifier_type' => '$email' + )); + $this->assertRequest('delete', '/privacy/users'); + } } diff --git a/test/RequestContextTest.php b/test/RequestContextTest.php index 2b20008..fc6206b 100644 --- a/test/RequestContextTest.php +++ b/test/RequestContextTest.php @@ -30,7 +30,7 @@ public function contextProvider() { } public function contextJsonProvider() { - return array(array('{"client_id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5","ip":"8.8.8.8","headers":{"User-Agent":"TestAgent","X-Castle-Client-Id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5"},"user_agent":"TestAgent","library":{"name":"castle-php","version":"3.2.0"}}')); + return array(array('{"client_id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5","ip":"8.8.8.8","headers":{"User-Agent":"TestAgent","X-Castle-Client-Id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5"},"user_agent":"TestAgent","library":{"name":"castle-php","version":"3.3.0"}}')); } /** diff --git a/test/WebhookTest.php b/test/WebhookTest.php new file mode 100644 index 0000000..385516f --- /dev/null +++ b/test/WebhookTest.php @@ -0,0 +1,48 @@ +apiSecret); + $_SERVER = array(); + } + + private function sign($body) + { + return base64_encode(hash_hmac('sha256', $body, $this->apiSecret, true)); + } + + public function testVerifyValidSignature() + { + $body = '{"type":"$incident.confirmed"}'; + $this->assertTrue(Castle_Webhook::verify($body, $this->sign($body))); + } + + public function testVerifyReadsSignatureFromHeader() + { + $body = '{"type":"$incident.confirmed"}'; + $_SERVER['HTTP_X_CASTLE_SIGNATURE'] = $this->sign($body); + $this->assertTrue(Castle_Webhook::verify($body)); + } + + public function testVerifyInvalidSignature() + { + $this->expectException(Castle_WebhookVerificationError::class); + Castle_Webhook::verify('{"type":"$incident.confirmed"}', 'invalid-signature'); + } + + public function testVerifyMissingSignature() + { + $this->expectException(Castle_WebhookVerificationError::class); + Castle_Webhook::verify('{"type":"$incident.confirmed"}'); + } + + public function testVerifyEmptyBody() + { + $this->expectException(Castle_WebhookVerificationError::class); + Castle_Webhook::verify('', $this->sign('')); + } +} From 07008c7848169c71aac711866b23ce0c6b1805e9 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 16:35:15 +0200 Subject: [PATCH 02/16] Declare Castle_ApiError properties for PHP 8 compatibility Avoids the dynamic-property deprecation (which becomes a fatal error in PHP 9). --- CHANGELOG.md | 1 + lib/Castle/Errors.php | 3 +++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17076e5..f25b7eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * added the Privacy API: `Castle::requestUserData`, `Castle::deleteUserData` * added webhook signature verification: `Castle_Webhook::verify` and the `Castle_WebhookVerificationError` exception * the request context is now attached automatically to `risk`, `filter` and `log` requests +* improved PHP 8 compatibility: declared the `Castle_ApiError` properties and made API error handling tolerant of empty response bodies ## 3.2.0 (2022-03-28) * updated ca-certs file diff --git a/lib/Castle/Errors.php b/lib/Castle/Errors.php index cb15081..886d2f3 100755 --- a/lib/Castle/Errors.php +++ b/lib/Castle/Errors.php @@ -22,6 +22,9 @@ class Castle_CurlOptionError extends Castle_Error class Castle_ApiError extends Castle_Error { + public $type; + public $httpStatus; + public function __construct($msg, $type = null, $status = null) { parent::__construct($msg); From 457129a71efeb4ec9c6e0e5ac88595cb346b3d31 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 16:47:54 +0200 Subject: [PATCH 03/16] Modernize tooling: GitHub Actions CI, Composer install, PHP 7.2-8.4 - Replace CircleCI with GitHub Actions: a Specs workflow with a PHP version matrix (7.2-8.4) and a Lint workflow (composer validate + php -l) - Pin phpunit to ^8.5 || ^9.6 and drop the unused php-coveralls dependency - Declare the curl/json extension requirements in composer.json - Simplify phpunit.xml to the modern schema - Update the README to install via Composer and document supported versions --- .circleci/config.yml | 28 ---------------------------- .coveralls.yml | 2 -- .github/workflows/lint.yml | 26 ++++++++++++++++++++++++++ .github/workflows/specs.yml | 32 ++++++++++++++++++++++++++++++++ CHANGELOG.md | 1 + README.md | 26 +++++++++++++++++--------- composer.json | 10 +++++++--- phpunit.xml | 17 ++--------------- 8 files changed, 85 insertions(+), 57 deletions(-) delete mode 100644 .circleci/config.yml delete mode 100644 .coveralls.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/specs.yml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 33edcf4..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,28 +0,0 @@ ---- -version: 2.1 - -jobs: - build: - docker: - - image: cimg/php:7.2-browsers - steps: - - checkout - - run: sudo apt-get update - - run: sudo apt-get install php7.2-xdebug -y - - restore_cache: - keys: - - v2-dependencies-{{ checksum "composer.json" }} - - v2-dependencies- - - run: composer install --no-interaction - - save_cache: - key: v2-dependencies-{{ checksum "composer.json" }} - paths: - - ./vendor - - run: mkdir -p build/logs - - run: XDEBUG_MODE=coverage ./vendor/bin/phpunit -c phpunit.xml - - run: ./vendor/bin/php-coveralls -v - -workflows: - build_and_test: - jobs: - - build diff --git a/.coveralls.yml b/.coveralls.yml deleted file mode 100644 index 6d00a3d..0000000 --- a/.coveralls.yml +++ /dev/null @@ -1,2 +0,0 @@ -coverage_clover: build/logs/clover.xml -json_path: build/logs/coveralls-upload.json \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..3b4bfaf --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: Lint + +on: + push: + branches: [master, develop] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer + + - name: Validate composer.json + run: composer validate --strict --no-check-lock + + - name: PHP syntax check + run: find lib test -name '*.php' -print0 | xargs -0 -n1 -P4 php -l diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml new file mode 100644 index 0000000..b788ea1 --- /dev/null +++ b/.github/workflows/specs.yml @@ -0,0 +1,32 @@ +name: Specs + +on: + push: + branches: [master, develop] + pull_request: + +jobs: + specs: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-version: ['7.2', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: curl, json + coverage: none + tools: composer + + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist + + - name: Run tests + run: composer test diff --git a/CHANGELOG.md b/CHANGELOG.md index f25b7eb..6d5262d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * added webhook signature verification: `Castle_Webhook::verify` and the `Castle_WebhookVerificationError` exception * the request context is now attached automatically to `risk`, `filter` and `log` requests * improved PHP 8 compatibility: declared the `Castle_ApiError` properties and made API error handling tolerant of empty response bodies +* migrated CI to GitHub Actions and now test against PHP 7.2 through 8.4 ## 3.2.0 (2022-03-28) * updated ca-certs file diff --git a/README.md b/README.md index ddb3581..cc7f30b 100644 --- a/README.md +++ b/README.md @@ -12,23 +12,25 @@ See the [documentation](https://docs.castle.io) for how to use this SDK with the Castle APIs +## Requirements + +PHP 7.2 or newer, with the `curl` and `json` extensions. The library is tested +against PHP 7.2 through 8.4. + ## Getting started -Obtain the latest version of the Castle PHP bindings with: +Install the latest version with [Composer](https://getcomposer.org): ```bash -git clone --single-branch --branch master https://github.com/castle/castle-php +composer require castle/castle-php ``` -To get started, add the following to your PHP script: +Then load Composer's autoloader and configure the library with your Castle API +secret: ```php -require_once("/path/to/castle-php/lib/Castle.php"); -``` +require_once 'vendor/autoload.php'; -Configure the library with your Castle API secret. - -```php Castle::setApiKey('YOUR_API_SECRET'); ``` @@ -160,4 +162,10 @@ Whenever something unexpected happens, an [exception](/lib/Castle/Errors.php) is | `Castle_WebhookVerificationError` | An incoming webhook could not be verified against the `X-Castle-Signature` header | ## Running test suite -Execute `vendor/bin/phpunit test` to run the full test suite + +Install the dev dependencies and run the suite with: + +```bash +composer install +composer test +``` diff --git a/composer.json b/composer.json index 0b1f73b..abba985 100644 --- a/composer.json +++ b/composer.json @@ -20,10 +20,14 @@ ] }, "require": { - "php": ">=7.2.0" + "php": ">=7.2.0", + "ext-curl": "*", + "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "*", - "php-coveralls/php-coveralls": "*" + "phpunit/phpunit": "^8.5 || ^9.6" + }, + "scripts": { + "test": "phpunit -c phpunit.xml" } } diff --git a/phpunit.xml b/phpunit.xml index 4a519d4..9d4c1d1 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,21 +1,8 @@ - + ./test - - - - - - ./lib/Castle - ./lib/RestModel - - ./lib/Castle/CookieStore.php - ./lib/Castle/CurlTransport.php - - - - \ No newline at end of file + From 86da194d79984e4c9676c1df74feff1991386f0b Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 16:49:25 +0200 Subject: [PATCH 04/16] CI: drop EOL PHP 7.2 from the test matrix --- .github/workflows/specs.yml | 2 +- CHANGELOG.md | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml index b788ea1..303d74c 100644 --- a/.github/workflows/specs.yml +++ b/.github/workflows/specs.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['7.2', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5262d..69e787c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ * added webhook signature verification: `Castle_Webhook::verify` and the `Castle_WebhookVerificationError` exception * the request context is now attached automatically to `risk`, `filter` and `log` requests * improved PHP 8 compatibility: declared the `Castle_ApiError` properties and made API error handling tolerant of empty response bodies -* migrated CI to GitHub Actions and now test against PHP 7.2 through 8.4 +* migrated CI to GitHub Actions and now test against PHP 7.4 through 8.4 ## 3.2.0 (2022-03-28) * updated ca-certs file diff --git a/README.md b/README.md index cc7f30b..dd1e3ef 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ See the [documentation](https://docs.castle.io) for how to use this SDK with the ## Requirements PHP 7.2 or newer, with the `curl` and `json` extensions. The library is tested -against PHP 7.2 through 8.4. +against PHP 7.4 through 8.4. ## Getting started From 9fbcfb7e4a19a21edadd6569789946956196f4fc Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 16:51:56 +0200 Subject: [PATCH 05/16] Auto-attach sent_at timestamp and harden Castle_ApiError on PHP 8 Add an ISO8601 millisecond-precision sent_at timestamp automatically to risk, filter and log requests, matching the behaviour of the other Castle SDKs (closes #23). Default Castle_ApiError's message to an empty string and coalesce null to avoid the PHP 8.1+ deprecation when an error body is empty. --- CHANGELOG.md | 1 + lib/Castle/Errors.php | 4 ++-- lib/Castle/Request.php | 16 ++++++++++++++++ test/CastleTest.php | 42 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e787c..3643c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * added the Privacy API: `Castle::requestUserData`, `Castle::deleteUserData` * added webhook signature verification: `Castle_Webhook::verify` and the `Castle_WebhookVerificationError` exception * the request context is now attached automatically to `risk`, `filter` and `log` requests +* a `sent_at` timestamp is now attached automatically to `risk`, `filter` and `log` requests ([#23](https://github.com/castle/castle-php/issues/23)) * improved PHP 8 compatibility: declared the `Castle_ApiError` properties and made API error handling tolerant of empty response bodies * migrated CI to GitHub Actions and now test against PHP 7.4 through 8.4 diff --git a/lib/Castle/Errors.php b/lib/Castle/Errors.php index 886d2f3..8c391b3 100755 --- a/lib/Castle/Errors.php +++ b/lib/Castle/Errors.php @@ -25,9 +25,9 @@ class Castle_ApiError extends Castle_Error public $type; public $httpStatus; - public function __construct($msg, $type = null, $status = null) + public function __construct($msg = '', $type = null, $status = null) { - parent::__construct($msg); + parent::__construct($msg === null ? '' : $msg); $this->type = $type; $this->httpStatus = $status; } diff --git a/lib/Castle/Request.php b/lib/Castle/Request.php index 7e29498..90029bc 100755 --- a/lib/Castle/Request.php +++ b/lib/Castle/Request.php @@ -80,6 +80,10 @@ public function send($method, $url, $payload = array()) { $payload['context'] = Castle_RequestContext::extract(); } + if ( self::shouldHaveSentAt($url) && !array_key_exists('sent_at', $payload)) { + $payload['sent_at'] = self::generateTimestamp(); + } + return $this->sendWithContext($url, $payload, $method); } @@ -89,6 +93,18 @@ private function shouldHaveContext($url) { return in_array($url, $WITH_CONTEXT); } + private function shouldHaveSentAt($url) { + $WITH_SENT_AT = ['/risk', '/filter', '/log']; + + return in_array($url, $WITH_SENT_AT); + } + + // ISO8601 timestamp (millisecond precision, UTC) marking when the request was sent. + public static function generateTimestamp() { + $date = new DateTime('now', new DateTimeZone('UTC')); + return $date->format('Y-m-d\TH:i:s.v\Z'); + } + public function sendWithContext($url, $payload, $method = 'post') { $this->preFlightCheck(); diff --git a/test/CastleTest.php b/test/CastleTest.php index 9123a2d..e5c06c7 100644 --- a/test/CastleTest.php +++ b/test/CastleTest.php @@ -123,6 +123,48 @@ public function testLogIncludesContext() $this->assertArrayHasKey('context', $request['params']); } + public function testRiskIncludesSentAt() + { + Castle_RequestTransport::setResponse(200, '{}'); + Castle::risk(Array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/risk'); + $this->assertArrayHasKey('sent_at', $request['params']); + $this->assertMatchesRegularExpression( + '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', + $request['params']['sent_at'] + ); + } + + public function testLogIncludesSentAt() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::log(Array( + 'request_token' => 'token', + 'name' => '$login', + 'status' => '$succeeded', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/log'); + $this->assertArrayHasKey('sent_at', $request['params']); + } + + public function testSentAtIsNotOverwritten() + { + Castle_RequestTransport::setResponse(200, '{}'); + Castle::filter(Array( + 'request_token' => 'token', + 'name' => '$registration', + 'user' => Array('id' => 'abc'), + 'sent_at' => '2020-01-01T00:00:00.000Z' + )); + $request = $this->assertRequest('post', '/filter'); + $this->assertEquals('2020-01-01T00:00:00.000Z', $request['params']['sent_at']); + } + public function testCreateList() { Castle_RequestTransport::setResponse(201, '{ "id": "list-id", "name": "blocklist" }'); From ed6f992057cce23daab61e933544372b40ac6ebe Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 17:01:59 +0200 Subject: [PATCH 06/16] Expose the library under the Castle\ namespace Register every public class under the Castle\ namespace (Castle\Castle, Castle\Webhook, Castle\ApiError, ...) via a lazy alias autoloader, keeping the existing global Castle, Castle_* and RestModel names fully functional. Wire the aliases into the Composer autoload and the bundled require chain, and declare Castle_Resource::$model to remove the remaining PHP 8.2 dynamic-property deprecation. Closes #40. --- CHANGELOG.md | 3 +- README.md | 30 +++++++++++++++++++ composer.json | 3 ++ lib/Castle.php | 1 + lib/RestModel/Resource.php | 2 ++ lib/aliases.php | 60 ++++++++++++++++++++++++++++++++++++++ test/AliasesTest.php | 48 ++++++++++++++++++++++++++++++ test/Castle.php | 1 + 8 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 lib/aliases.php create mode 100644 test/AliasesTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3643c94..25fd5c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ * added webhook signature verification: `Castle_Webhook::verify` and the `Castle_WebhookVerificationError` exception * the request context is now attached automatically to `risk`, `filter` and `log` requests * a `sent_at` timestamp is now attached automatically to `risk`, `filter` and `log` requests ([#23](https://github.com/castle/castle-php/issues/23)) -* improved PHP 8 compatibility: declared the `Castle_ApiError` properties and made API error handling tolerant of empty response bodies +* the whole library is now available under the `Castle\` namespace (`Castle\Castle`, `Castle\Webhook`, `Castle\ApiError`, ...) while the historic global class names (`Castle`, `Castle_*`, `RestModel`) continue to work unchanged ([#40](https://github.com/castle/castle-php/issues/40)) +* improved PHP 8 compatibility: declared the `Castle_ApiError` and `Castle_Resource` properties, avoided passing `null` to `Exception`, and made API error handling tolerant of empty response bodies * migrated CI to GitHub Actions and now test against PHP 7.4 through 8.4 ## 3.2.0 (2022-03-28) diff --git a/README.md b/README.md index dd1e3ef..01a192b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,36 @@ require_once 'vendor/autoload.php'; Castle::setApiKey('YOUR_API_SECRET'); ``` +## Namespaces + +The library is available under the `Castle\` namespace. The historic global +class names (`Castle`, `Castle_*`, `RestModel`) remain available and behave +identically, so existing integrations keep working without changes. + +```php +use Castle\Castle; +use Castle\Webhook; +use Castle\WebhookVerificationError; + +Castle::setApiKey('YOUR_API_SECRET'); + +$verdict = Castle::filter([ + 'request_token' => $requestToken, + 'name' => '$registration', + 'user' => ['id' => '1234'], +]); + +try { + Webhook::verify(); +} catch (WebhookVerificationError $e) { + // reject the request +} +``` + +The two styles are interchangeable — `Castle\ApiError` and `Castle_ApiError` +refer to the same class, so `instanceof` checks and `catch` blocks work with +either name. + ## Optional Configurations Set preferred connection and request timeouts: diff --git a/composer.json b/composer.json index abba985..e06f7e6 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,9 @@ "classmap": [ "lib/Castle", "lib/RestModel" + ], + "files": [ + "lib/aliases.php" ] }, "require": { diff --git a/lib/Castle.php b/lib/Castle.php index d19022a..8a3e4c5 100755 --- a/lib/Castle.php +++ b/lib/Castle.php @@ -26,3 +26,4 @@ function lcfirst( $str ) { require(dirname(__FILE__) . '/Castle/RequestContext.php'); require(dirname(__FILE__) . '/Castle/Request.php'); require(dirname(__FILE__) . '/Castle/Webhook.php'); +require(dirname(__FILE__) . '/aliases.php'); diff --git a/lib/RestModel/Resource.php b/lib/RestModel/Resource.php index 51880b0..94b23f3 100644 --- a/lib/RestModel/Resource.php +++ b/lib/RestModel/Resource.php @@ -6,6 +6,8 @@ class Castle_Resource protected $items = null; + protected $model = null; + public function __construct($model, $items=null) { $this->model = $model; diff --git a/lib/aliases.php b/lib/aliases.php new file mode 100644 index 0000000..d537113 --- /dev/null +++ b/lib/aliases.php @@ -0,0 +1,60 @@ + 'Castle', + 'Castle\\Request' => 'Castle_Request', + 'Castle\\RequestContext' => 'Castle_RequestContext', + 'Castle\\RequestTransport' => 'Castle_RequestTransport', + 'Castle\\CookieStore' => 'Castle_CookieStore', + 'Castle\\CookieStoreInterface' => 'Castle_iCookieStore', + 'Castle\\Webhook' => 'Castle_Webhook', + 'Castle\\Authenticate' => 'Castle_Authenticate', + 'Castle\\Context' => 'Castle_Context', + 'Castle\\RestModel' => 'RestModel', + 'Castle\\Resource' => 'Castle_Resource', + 'Castle\\Error' => 'Castle_Error', + 'Castle\\RequestError' => 'Castle_RequestError', + 'Castle\\ConfigurationError' => 'Castle_ConfigurationError', + 'Castle\\CurlOptionError' => 'Castle_CurlOptionError', + 'Castle\\ApiError' => 'Castle_ApiError', + 'Castle\\BadRequest' => 'Castle_BadRequest', + 'Castle\\UnauthorizedError' => 'Castle_UnauthorizedError', + 'Castle\\ForbiddenError' => 'Castle_ForbiddenError', + 'Castle\\NotFoundError' => 'Castle_NotFoundError', + 'Castle\\InvalidParametersError' => 'Castle_InvalidParametersError', + 'Castle\\InvalidRequestTokenError' => 'Castle_InvalidRequestTokenError', + 'Castle\\WebhookVerificationError' => 'Castle_WebhookVerificationError', + ); + } +} + +spl_autoload_register(function ($class) { + $map = castle_legacy_alias_map(); + + if (!isset($map[$class])) { + return; + } + + $legacy = $map[$class]; + + // class_exists()/interface_exists() trigger the legacy class to load (via + // Composer's classmap or the bundled require chain) before we alias it. + if (class_exists($legacy) || interface_exists($legacy)) { + class_alias($legacy, $class); + } +}); diff --git a/test/AliasesTest.php b/test/AliasesTest.php new file mode 100644 index 0000000..780dcc0 --- /dev/null +++ b/test/AliasesTest.php @@ -0,0 +1,48 @@ + $legacy) { + $this->assertTrue( + class_exists($namespaced) || interface_exists($namespaced), + "Expected {$namespaced} to be available" + ); + } + } + + public function testNamespacedFacadeSharesLegacyDefinition() + { + $this->assertSame(Castle::VERSION, \Castle\Castle::VERSION); + $this->assertTrue(is_a('Castle\\Castle', 'Castle', true)); + } + + public function testNamespacedExceptionsAreLegacyExceptions() + { + $error = new \Castle\WebhookVerificationError('boom'); + $this->assertInstanceOf('Castle_WebhookVerificationError', $error); + $this->assertInstanceOf('Castle_Error', $error); + $this->assertEquals('boom', $error->getMessage()); + } + + public function testNamespacedApiErrorCarriesMetadata() + { + $error = new \Castle\BadRequest('bad', 'invalid', 400); + $this->assertInstanceOf('Castle_BadRequest', $error); + $this->assertInstanceOf('Castle_ApiError', $error); + $this->assertEquals('invalid', $error->type); + $this->assertEquals(400, $error->httpStatus); + } + + public function testNamespacedWebhookVerifies() + { + $secret = 'secretkey'; + Castle::setApiKey($secret); + $body = '{"type":"$incident.confirmed"}'; + $signature = base64_encode(hash_hmac('sha256', $body, $secret, true)); + + $this->assertTrue(\Castle\Webhook::verify($body, $signature)); + } +} diff --git a/test/Castle.php b/test/Castle.php index a24ef8e..e8ad01d 100644 --- a/test/Castle.php +++ b/test/Castle.php @@ -33,3 +33,4 @@ public function assertRequest($method, $url, $headers=null) require(dirname(__FILE__) . '/../lib/Castle/RequestContext.php'); require(dirname(__FILE__) . '/../lib/Castle/Request.php'); require(dirname(__FILE__) . '/../lib/Castle/Webhook.php'); +require(dirname(__FILE__) . '/../lib/aliases.php'); From 80a5213619eaa8c1f06c0c941e3c270d009f4bfc Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 17:36:45 +0200 Subject: [PATCH 07/16] Make the Castle\ namespace canonical and bump to 4.0.0 Define every class under the Castle\ namespace (Castle\Castle, Castle\Webhook, Castle\RequestContext, Castle\ApiError, ...) as the canonical API, move the model classes from Castle\Models to the Castle\ root, and raise the minimum PHP version to 7.4. The historic global names (Castle, Castle_*, RestModel) are retained as aliases: a lazy autoloader resolves them on reference, and the exception hierarchy is aliased eagerly so catch (Castle_ApiError) keeps catching the namespaced exceptions the library throws (PHP does not autoload catch types). Also clears the remaining PHP 8 deprecations (Castle_Resource::$model, json_decode/Exception null arguments) and adds RELEASING.md. --- CHANGELOG.md | 14 +++- README.md | 28 ++++++-- RELEASING.md | 14 ++++ composer.json | 2 +- lib/Castle.php | 4 +- lib/Castle/Authenticate.php | 8 +++ lib/Castle/Castle.php | 62 ++++++++-------- lib/Castle/Context.php | 16 +++++ lib/Castle/CookieStore.php | 7 +- lib/Castle/CurlTransport.php | 6 +- lib/Castle/Errors.php | 26 +++---- lib/Castle/Models/Authenticate.php | 6 -- lib/Castle/Models/Context.php | 14 ---- lib/Castle/Request.php | 32 +++++---- lib/Castle/RequestContext.php | 4 +- lib/Castle/Webhook.php | 14 ++-- lib/RestModel/Model.php | 16 +++-- lib/RestModel/Resource.php | 4 +- lib/aliases.php | 110 ++++++++++++++++++++--------- test/AliasesTest.php | 26 ++++--- test/Castle.php | 24 +++---- test/CookieStore.php | 7 +- test/RequestContextTest.php | 2 +- test/TestTransport.php | 4 +- 24 files changed, 284 insertions(+), 166 deletions(-) create mode 100644 RELEASING.md create mode 100644 lib/Castle/Authenticate.php create mode 100644 lib/Castle/Context.php delete mode 100644 lib/Castle/Models/Authenticate.php delete mode 100644 lib/Castle/Models/Context.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 25fd5c6..fe09f6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,15 @@ # Changelog +## 4.0.0 +**BREAKING CHANGES:** + +* the library is now defined under the `Castle\` namespace (`Castle\Castle`, `Castle\Webhook`, `Castle\RequestContext`, `Castle\ApiError`, ...), which is the canonical API ([#40](https://github.com/castle/castle-php/issues/40)) +* the minimum supported PHP version is now 7.4 + +Other changes: + +* the historic global class names (`Castle`, `Castle_*`, `RestModel`) are retained as aliases of their namespaced counterparts, so existing integrations keep working without changes; `catch` and `instanceof` work with either name +* additional PHP 8 compatibility fixes: declared `Castle_Resource::$model`, and avoided passing `null` to `Exception` and `json_decode` + ## 3.3.0 * added the Lists API: `Castle::createList`, `Castle::getAllLists`, `Castle::getList`, `Castle::updateList`, `Castle::deleteList`, `Castle::queryList` * added the List Items API: `Castle::createListItem`, `Castle::createListItems`, `Castle::getListItem`, `Castle::updateListItem`, `Castle::queryListItems`, `Castle::countListItems`, `Castle::archiveListItem`, `Castle::unarchiveListItem` @@ -6,8 +17,7 @@ * added webhook signature verification: `Castle_Webhook::verify` and the `Castle_WebhookVerificationError` exception * the request context is now attached automatically to `risk`, `filter` and `log` requests * a `sent_at` timestamp is now attached automatically to `risk`, `filter` and `log` requests ([#23](https://github.com/castle/castle-php/issues/23)) -* the whole library is now available under the `Castle\` namespace (`Castle\Castle`, `Castle\Webhook`, `Castle\ApiError`, ...) while the historic global class names (`Castle`, `Castle_*`, `RestModel`) continue to work unchanged ([#40](https://github.com/castle/castle-php/issues/40)) -* improved PHP 8 compatibility: declared the `Castle_ApiError` and `Castle_Resource` properties, avoided passing `null` to `Exception`, and made API error handling tolerant of empty response bodies +* improved PHP 8 compatibility: declared the `Castle_ApiError` properties and made API error handling tolerant of empty response bodies * migrated CI to GitHub Actions and now test against PHP 7.4 through 8.4 ## 3.2.0 (2022-03-28) diff --git a/README.md b/README.md index 01a192b..f3d3c2e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ See the [documentation](https://docs.castle.io) for how to use this SDK with the ## Requirements -PHP 7.2 or newer, with the `curl` and `json` extensions. The library is tested +PHP 7.4 or newer, with the `curl` and `json` extensions. The library is tested against PHP 7.4 through 8.4. ## Getting started @@ -36,9 +36,9 @@ Castle::setApiKey('YOUR_API_SECRET'); ## Namespaces -The library is available under the `Castle\` namespace. The historic global -class names (`Castle`, `Castle_*`, `RestModel`) remain available and behave -identically, so existing integrations keep working without changes. +As of 4.0 the library lives under the `Castle\` namespace, e.g. `Castle\Castle`, +`Castle\Webhook`, `Castle\RequestContext`, `Castle\ApiError`. This is the +canonical API: ```php use Castle\Castle; @@ -60,9 +60,23 @@ try { } ``` -The two styles are interchangeable — `Castle\ApiError` and `Castle_ApiError` -refer to the same class, so `instanceof` checks and `catch` blocks work with -either name. +### Backward compatibility + +The historic global class names (`Castle`, `Castle_*`, `RestModel`) are kept as +aliases of their namespaced counterparts, so existing integrations keep working +without changes. `Castle_ApiError` and `Castle\ApiError` are the same class, so +`instanceof` checks and `catch` blocks work with either name: + +```php +try { + Castle::risk([/* ... */]); +} catch (Castle_ApiError $e) { + // still catches the namespaced Castle\ApiError thrown by the library +} +``` + +New code should prefer the namespaced names; the global aliases are retained for +compatibility. ## Optional Configurations diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..960935a --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,14 @@ +# Releasing + +1. Create branch `release/X.Y.Z` from `develop`. +2. Update the `VERSION` constant in `lib/Castle/Castle.php` to the new version. +3. Update the `CHANGELOG.md` for the impending release. +4. `git commit -am "release X.Y.Z"` (where X.Y.Z is the new version). +5. Push to Github, make a PR to the `develop` branch, and when approved, merge. +6. Pull latest `develop`, merge it into `master`, and push `master` to `origin`. +7. Make a release on Github from the `master` branch, specify tag as `vX.Y.Z` to create a tag. + +[Packagist](https://packagist.org/packages/castle/castle-php) is configured to +auto-update from the repository, so pushing the `vX.Y.Z` tag publishes the new +version. If the webhook is not configured, trigger an update manually from the +package page on Packagist. diff --git a/composer.json b/composer.json index e06f7e6..bac6972 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ ] }, "require": { - "php": ">=7.2.0", + "php": ">=7.4", "ext-curl": "*", "ext-json": "*" }, diff --git a/lib/Castle.php b/lib/Castle.php index 8a3e4c5..0a31055 100755 --- a/lib/Castle.php +++ b/lib/Castle.php @@ -20,8 +20,8 @@ function lcfirst( $str ) { require(dirname(__FILE__) . '/Castle/Errors.php'); require(dirname(__FILE__) . '/RestModel/Resource.php'); require(dirname(__FILE__) . '/RestModel/Model.php'); -require(dirname(__FILE__) . '/Castle/Models/Context.php'); -require(dirname(__FILE__) . '/Castle/Models/Authenticate.php'); +require(dirname(__FILE__) . '/Castle/Context.php'); +require(dirname(__FILE__) . '/Castle/Authenticate.php'); require(dirname(__FILE__) . '/Castle/CurlTransport.php'); require(dirname(__FILE__) . '/Castle/RequestContext.php'); require(dirname(__FILE__) . '/Castle/Request.php'); diff --git a/lib/Castle/Authenticate.php b/lib/Castle/Authenticate.php new file mode 100644 index 0000000..7d87000 --- /dev/null +++ b/lib/Castle/Authenticate.php @@ -0,0 +1,8 @@ +save(); return $auth; } public static function impersonate($attributes) { - $request = new Castle_Request(); + $request = new Request(); if(isset($attributes['reset'])) { $request->send('delete', '/impersonate', $attributes); } else { @@ -124,9 +126,9 @@ public static function impersonate($attributes) { * is required * @return None */ - public static function track(Array $attributes) + public static function track(array $attributes) { - $request = new Castle_Request(); + $request = new Request(); $request->send('post', '/track', $attributes); } @@ -134,11 +136,11 @@ public static function track(Array $attributes) /** * Filter an action * @param String $attributes 'request_token', 'event', 'context' are required, 'user' with 'id' and 'properties' are optional - * @return Castle_Log + * @return RestModel */ - public static function filter(Array $attributes) + public static function filter(array $attributes) { - $request = new Castle_Request(); + $request = new Request(); list($response, $request) = $request->send('post', '/filter', $attributes); if ($request->rStatus == 204) { $response = array(); @@ -149,22 +151,22 @@ public static function filter(Array $attributes) /** * Log events * @param String $attributes 'request_token', 'event', 'status' and 'user' object with 'id' are required - * @return Castle_Log + * @return None */ - public static function log(Array $attributes) + public static function log(array $attributes) { - $request = new Castle_Request(); + $request = new Request(); $request->send('post', '/log', $attributes); } /** * Risk * @param String $attributes 'request_token', 'event', 'context', 'user' with 'id' are required, 'status', 'properties' are optional - * @return Castle_Risk + * @return RestModel */ - public static function risk(Array $attributes) + public static function risk(array $attributes) { - $request = new Castle_Request(); + $request = new Request(); list($response, $request) = $request->send('post', '/risk', $attributes); if ($request->rStatus == 204) { $response = array(); @@ -181,7 +183,7 @@ public static function risk(Array $attributes) * @param Array $attributes 'name', 'color' and 'primary_field' are required * @return Array */ - public static function createList(Array $attributes) + public static function createList(array $attributes) { return self::sendRequest('post', '/lists', $attributes); } @@ -211,7 +213,7 @@ public static function getList($listId) * @param Array $attributes * @return Array */ - public static function updateList($listId, Array $attributes) + public static function updateList($listId, array $attributes) { return self::sendRequest('put', self::listPath($listId), $attributes); } @@ -231,7 +233,7 @@ public static function deleteList($listId) * @param Array $attributes * @return Array */ - public static function queryList(Array $attributes = array()) + public static function queryList(array $attributes = array()) { return self::sendRequest('post', '/lists/query', $attributes); } @@ -246,7 +248,7 @@ public static function queryList(Array $attributes = array()) * @param Array $attributes 'author' and 'primary_value' are required * @return Array */ - public static function createListItem($listId, Array $attributes) + public static function createListItem($listId, array $attributes) { return self::sendRequest('post', self::listItemsPath($listId), $attributes); } @@ -257,7 +259,7 @@ public static function createListItem($listId, Array $attributes) * @param Array $attributes 'items' is required * @return Array */ - public static function createListItems($listId, Array $attributes) + public static function createListItems($listId, array $attributes) { return self::sendRequest('post', self::listItemsPath($listId) . '/batch', $attributes); } @@ -280,7 +282,7 @@ public static function getListItem($listId, $itemId) * @param Array $attributes 'comment' is required * @return Array */ - public static function updateListItem($listId, $itemId, Array $attributes) + public static function updateListItem($listId, $itemId, array $attributes) { return self::sendRequest('put', self::listItemPath($listId, $itemId), $attributes); } @@ -291,7 +293,7 @@ public static function updateListItem($listId, $itemId, Array $attributes) * @param Array $attributes * @return Array */ - public static function queryListItems($listId, Array $attributes = array()) + public static function queryListItems($listId, array $attributes = array()) { return self::sendRequest('post', self::listItemsPath($listId) . '/query', $attributes); } @@ -302,7 +304,7 @@ public static function queryListItems($listId, Array $attributes = array()) * @param Array $attributes * @return Array */ - public static function countListItems($listId, Array $attributes = array()) + public static function countListItems($listId, array $attributes = array()) { return self::sendRequest('post', self::listItemsPath($listId) . '/count', $attributes); } @@ -338,7 +340,7 @@ public static function unarchiveListItem($listId, $itemId) * @param Array $attributes 'identifier' and 'identifier_type' are required * @return Array */ - public static function requestUserData(Array $attributes) + public static function requestUserData(array $attributes) { return self::sendRequest('post', '/privacy/users', $attributes); } @@ -348,14 +350,14 @@ public static function requestUserData(Array $attributes) * @param Array $attributes 'identifier' and 'identifier_type' are required * @return Array */ - public static function deleteUserData(Array $attributes) + public static function deleteUserData(array $attributes) { return self::sendRequest('delete', '/privacy/users', $attributes); } private static function sendRequest($method, $path, $attributes = null) { - $request = new Castle_Request(); + $request = new Request(); list($response, $request) = $request->send($method, $path, $attributes); if ($request->rStatus == 204) { $response = array(); diff --git a/lib/Castle/Context.php b/lib/Castle/Context.php new file mode 100644 index 0000000..ecad92d --- /dev/null +++ b/lib/Castle/Context.php @@ -0,0 +1,16 @@ +hasOne(RestModel::class, 'location'); + } + + public function userAgent() + { + return $this->hasOne(RestModel::class, 'user_agent'); + } +} diff --git a/lib/Castle/CookieStore.php b/lib/Castle/CookieStore.php index e736bd7..bae6088 100644 --- a/lib/Castle/CookieStore.php +++ b/lib/Castle/CookieStore.php @@ -1,5 +1,8 @@ hasOne('RestModel', 'location'); - } - - public function userAgent() - { - return $this->hasOne('RestModel', 'user_agent'); - } -} diff --git a/lib/Castle/Request.php b/lib/Castle/Request.php index 90029bc..a05228f 100755 --- a/lib/Castle/Request.php +++ b/lib/Castle/Request.php @@ -1,6 +1,8 @@ rError: $request->rMessage"); + throw new RequestError("$request->rError: $request->rMessage"); } public function handleResponse($request) @@ -51,9 +53,9 @@ public function handleResponse($request) $this->handleRequestError($request); } - $response = json_decode($request->rBody, true); + $response = json_decode($request->rBody === null ? '' : $request->rBody, true); if (!empty($request->rBody) && $response === null) { - throw new Castle_ApiError('Invalid response from API', 'api_error', $request->rStatus); + throw new ApiError('Invalid response from API', 'api_error', $request->rStatus); } if ($request->rStatus < 200 || $request->rStatus >= 300) { @@ -67,7 +69,7 @@ public function preFlightCheck() { $key = Castle::getApiKey(); if (empty($key)) { - throw new Castle_ConfigurationError(); + throw new ConfigurationError(); } } @@ -77,7 +79,7 @@ public function send($method, $url, $payload = array()) { } if ( self::shouldHaveContext($url) && !array_key_exists('context', $payload)) { - $payload['context'] = Castle_RequestContext::extract(); + $payload['context'] = RequestContext::extract(); } if ( self::shouldHaveSentAt($url) && !array_key_exists('sent_at', $payload)) { @@ -101,7 +103,7 @@ private function shouldHaveSentAt($url) { // ISO8601 timestamp (millisecond precision, UTC) marking when the request was sent. public static function generateTimestamp() { - $date = new DateTime('now', new DateTimeZone('UTC')); + $date = new \DateTime('now', new \DateTimeZone('UTC')); return $date->format('Y-m-d\TH:i:s.v\Z'); } @@ -110,7 +112,7 @@ public function sendWithContext($url, $payload, $method = 'post') $this->preFlightCheck(); - $request = new Castle_RequestTransport(); + $request = new RequestTransport(); $request->send($method, self::apiUrl($url), $payload); return $this->handleResponse($request); diff --git a/lib/Castle/RequestContext.php b/lib/Castle/RequestContext.php index 338d715..66c57ad 100644 --- a/lib/Castle/RequestContext.php +++ b/lib/Castle/RequestContext.php @@ -1,6 +1,8 @@ getResourcePath($path); } - $request = new Castle_Request(); + $request = new Request(); list($response, $request) = $request->send($method, $url, $params); if ($request->rStatus == 204) { $response = array(); @@ -181,7 +183,7 @@ public function hasMany($model, $attrName = null) $attrName = self::pluralize($attrName); } $items = $this->getAttribute($attrName); - $resource = new Castle_Resource($model, $items); + $resource = new Resource($model, $items); $resource->setParent($this); return $resource; } @@ -218,7 +220,7 @@ public function save() $method = array_key_exists($this->idAttribute, $this->attributes) ? 'put' : 'post'; $response = $this->request($method, null, $this->attributes); if (!is_array($response)) { - throw new Castle_Error('Invalid response'); + throw new Error('Invalid response'); } $this->setAttributes($response); return $this; @@ -269,26 +271,26 @@ public static function snakeCase($input) { public static function all($params=null) { - $instance = new Castle_Resource(get_called_class()); + $instance = new Resource(get_called_class()); return $instance->all($params); } public static function create($attributes=null) { - $instance = new Castle_Resource(get_called_class()); + $instance = new Resource(get_called_class()); return $instance->create($attributes); } public static function destroy($id) { - $instance = new Castle_Resource(get_called_class()); + $instance = new Resource(get_called_class()); return $instance->destroy($id); } public static function find($id) { - $instance = new Castle_Resource(get_called_class()); + $instance = new Resource(get_called_class()); return $instance->find($id); } } diff --git a/lib/RestModel/Resource.php b/lib/RestModel/Resource.php index 94b23f3..374dac6 100644 --- a/lib/RestModel/Resource.php +++ b/lib/RestModel/Resource.php @@ -1,6 +1,8 @@ 'Castle', - 'Castle\\Request' => 'Castle_Request', - 'Castle\\RequestContext' => 'Castle_RequestContext', - 'Castle\\RequestTransport' => 'Castle_RequestTransport', - 'Castle\\CookieStore' => 'Castle_CookieStore', - 'Castle\\CookieStoreInterface' => 'Castle_iCookieStore', - 'Castle\\Webhook' => 'Castle_Webhook', - 'Castle\\Authenticate' => 'Castle_Authenticate', - 'Castle\\Context' => 'Castle_Context', - 'Castle\\RestModel' => 'RestModel', - 'Castle\\Resource' => 'Castle_Resource', - 'Castle\\Error' => 'Castle_Error', - 'Castle\\RequestError' => 'Castle_RequestError', - 'Castle\\ConfigurationError' => 'Castle_ConfigurationError', - 'Castle\\CurlOptionError' => 'Castle_CurlOptionError', - 'Castle\\ApiError' => 'Castle_ApiError', - 'Castle\\BadRequest' => 'Castle_BadRequest', - 'Castle\\UnauthorizedError' => 'Castle_UnauthorizedError', - 'Castle\\ForbiddenError' => 'Castle_ForbiddenError', - 'Castle\\NotFoundError' => 'Castle_NotFoundError', - 'Castle\\InvalidParametersError' => 'Castle_InvalidParametersError', - 'Castle\\InvalidRequestTokenError' => 'Castle_InvalidRequestTokenError', - 'Castle\\WebhookVerificationError' => 'Castle_WebhookVerificationError', + 'Castle' => 'Castle\\Castle', + 'Castle_Request' => 'Castle\\Request', + 'Castle_RequestContext' => 'Castle\\RequestContext', + 'Castle_RequestTransport' => 'Castle\\RequestTransport', + 'Castle_CookieStore' => 'Castle\\CookieStore', + 'Castle_iCookieStore' => 'Castle\\CookieStoreInterface', + 'Castle_Webhook' => 'Castle\\Webhook', + 'Castle_Authenticate' => 'Castle\\Authenticate', + 'Castle_Context' => 'Castle\\Context', + 'RestModel' => 'Castle\\RestModel', + 'Castle_Resource' => 'Castle\\Resource', + 'Castle_Error' => 'Castle\\Error', + 'Castle_RequestError' => 'Castle\\RequestError', + 'Castle_ConfigurationError' => 'Castle\\ConfigurationError', + 'Castle_CurlOptionError' => 'Castle\\CurlOptionError', + 'Castle_ApiError' => 'Castle\\ApiError', + 'Castle_BadRequest' => 'Castle\\BadRequest', + 'Castle_UnauthorizedError' => 'Castle\\UnauthorizedError', + 'Castle_ForbiddenError' => 'Castle\\ForbiddenError', + 'Castle_NotFoundError' => 'Castle\\NotFoundError', + 'Castle_InvalidParametersError' => 'Castle\\InvalidParametersError', + 'Castle_InvalidRequestTokenError' => 'Castle\\InvalidRequestTokenError', + 'Castle_WebhookVerificationError' => 'Castle\\WebhookVerificationError', ); } } @@ -50,11 +55,50 @@ function castle_legacy_alias_map() return; } - $legacy = $map[$class]; + $target = $map[$class]; - // class_exists()/interface_exists() trigger the legacy class to load (via + // class_exists()/interface_exists() trigger the namespaced class to load (via // Composer's classmap or the bundled require chain) before we alias it. - if (class_exists($legacy) || interface_exists($legacy)) { - class_alias($legacy, $class); + if (class_exists($target) || interface_exists($target)) { + class_alias($target, $class); + } +}); + +/** + * Eagerly alias the exception hierarchy. PHP does not autoload the class named + * in a `catch` clause, so without this a consumer's `catch (Castle_ApiError $e)` + * would silently fail to catch the namespaced exception thrown by the library. + * The exception classes have no dependencies, so aliasing them here only loads + * `Errors.php` and never the (test-overridable) transport or cookie classes. + */ +call_user_func(function () { + $eager = array( + 'Castle\\Error', + 'Castle\\RequestError', + 'Castle\\ConfigurationError', + 'Castle\\CurlOptionError', + 'Castle\\ApiError', + 'Castle\\BadRequest', + 'Castle\\UnauthorizedError', + 'Castle\\ForbiddenError', + 'Castle\\NotFoundError', + 'Castle\\InvalidParametersError', + 'Castle\\InvalidRequestTokenError', + 'Castle\\WebhookVerificationError', + ); + + $legacyByTarget = array_flip(castle_legacy_alias_map()); + + foreach ($eager as $target) { + if (!isset($legacyByTarget[$target])) { + continue; + } + $legacy = $legacyByTarget[$target]; + if (class_exists($legacy, false)) { + continue; + } + if (class_exists($target)) { + class_alias($target, $legacy); + } } }); diff --git a/test/AliasesTest.php b/test/AliasesTest.php index 780dcc0..47918a8 100644 --- a/test/AliasesTest.php +++ b/test/AliasesTest.php @@ -2,28 +2,36 @@ class AliasesTest extends Castle_TestCase { - public function testNamespacedClassesResolveToLegacyClasses() + public function testLegacyAndNamespacedNamesResolveToTheSameClass() { $map = castle_legacy_alias_map(); - foreach ($map as $namespaced => $legacy) { + foreach ($map as $legacy => $namespaced) { $this->assertTrue( class_exists($namespaced) || interface_exists($namespaced), "Expected {$namespaced} to be available" ); + $this->assertTrue( + class_exists($legacy) || interface_exists($legacy), + "Expected legacy {$legacy} to be available" + ); + $this->assertTrue( + is_a($legacy, $namespaced, true), + "Expected {$legacy} to be an alias of {$namespaced}" + ); } } - public function testNamespacedFacadeSharesLegacyDefinition() + public function testLegacyFacadeSharesNamespacedDefinition() { - $this->assertSame(Castle::VERSION, \Castle\Castle::VERSION); - $this->assertTrue(is_a('Castle\\Castle', 'Castle', true)); + $this->assertSame(\Castle\Castle::VERSION, Castle::VERSION); + $this->assertTrue(is_a('Castle', 'Castle\\Castle', true)); } - public function testNamespacedExceptionsAreLegacyExceptions() + public function testLegacyExceptionsAreNamespacedExceptions() { - $error = new \Castle\WebhookVerificationError('boom'); - $this->assertInstanceOf('Castle_WebhookVerificationError', $error); - $this->assertInstanceOf('Castle_Error', $error); + $error = new Castle_WebhookVerificationError('boom'); + $this->assertInstanceOf('Castle\\WebhookVerificationError', $error); + $this->assertInstanceOf('Castle\\Error', $error); $this->assertEquals('boom', $error->getMessage()); } diff --git a/test/Castle.php b/test/Castle.php index e8ad01d..b5e35e8 100644 --- a/test/Castle.php +++ b/test/Castle.php @@ -22,15 +22,15 @@ public function assertRequest($method, $url, $headers=null) } } -require(dirname(__FILE__) . '/../lib/Castle/Castle.php'); -require(dirname(__FILE__) . '/../lib/Castle/Errors.php'); -require(dirname(__FILE__) . '/CookieStore.php'); -require(dirname(__FILE__) . '/../lib/RestModel/Resource.php'); -require(dirname(__FILE__) . '/../lib/RestModel/Model.php'); -require(dirname(__FILE__) . '/../lib/Castle/Models/Authenticate.php'); -require(dirname(__FILE__) . '/../lib/Castle/Models/Context.php'); -require(dirname(__FILE__) . '/TestTransport.php'); -require(dirname(__FILE__) . '/../lib/Castle/RequestContext.php'); -require(dirname(__FILE__) . '/../lib/Castle/Request.php'); -require(dirname(__FILE__) . '/../lib/Castle/Webhook.php'); -require(dirname(__FILE__) . '/../lib/aliases.php'); +require_once(dirname(__FILE__) . '/../lib/Castle/Castle.php'); +require_once(dirname(__FILE__) . '/../lib/Castle/Errors.php'); +require_once(dirname(__FILE__) . '/CookieStore.php'); +require_once(dirname(__FILE__) . '/../lib/RestModel/Resource.php'); +require_once(dirname(__FILE__) . '/../lib/RestModel/Model.php'); +require_once(dirname(__FILE__) . '/../lib/Castle/Authenticate.php'); +require_once(dirname(__FILE__) . '/../lib/Castle/Context.php'); +require_once(dirname(__FILE__) . '/TestTransport.php'); +require_once(dirname(__FILE__) . '/../lib/Castle/RequestContext.php'); +require_once(dirname(__FILE__) . '/../lib/Castle/Request.php'); +require_once(dirname(__FILE__) . '/../lib/Castle/Webhook.php'); +require_once(dirname(__FILE__) . '/../lib/aliases.php'); diff --git a/test/CookieStore.php b/test/CookieStore.php index b9491bd..0c84562 100644 --- a/test/CookieStore.php +++ b/test/CookieStore.php @@ -1,5 +1,8 @@ Date: Mon, 8 Jun 2026 17:44:36 +0200 Subject: [PATCH 08/16] Align README badges with the other Castle SDKs Drop the stale Code Climate and Coveralls badges and surface the Specs and Lint GitHub Actions workflows alongside the Packagist version badge, under the title. --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f3d3c2e..a86f13d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ -[![Latest Stable Version](https://poser.pugx.org/castle/castle-php/v/stable.svg)](https://packagist.org/packages/castle/castle-php) [![Total Downloads](https://poser.pugx.org/castle/castle-php/downloads.svg)](https://packagist.org/packages/castle/castle-php) [![License](https://poser.pugx.org/castle/castle-php/license.svg)](https://packagist.org/packages/castle/castle-php) - -[![Code Climate](https://codeclimate.com/github/castle/castle-php.png)](https://codeclimate.com/github/castle/castle-php) -[![Coverage Status](https://coveralls.io/repos/github/castle/castle-php/badge.svg?branch=fix%2Fcode-coverage)](https://coveralls.io/github/castle/castle-php?branch=fix%2Fcode-coverage) - # PHP SDK for Castle +[![Specs](https://github.com/castle/castle-php/actions/workflows/specs.yml/badge.svg)](https://github.com/castle/castle-php/actions/workflows/specs.yml) +[![Lint](https://github.com/castle/castle-php/actions/workflows/lint.yml/badge.svg)](https://github.com/castle/castle-php/actions/workflows/lint.yml) +[![Latest Stable Version](https://poser.pugx.org/castle/castle-php/v/stable.svg)](https://packagist.org/packages/castle/castle-php) + **[Castle](https://castle.io) analyzes user behavior in web and mobile apps to stop fraud before it happens.** From a5b84851d1938e3f68c54e9163c24dc48894a398 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 17:47:08 +0200 Subject: [PATCH 09/16] Trim README links to match the other Castle SDKs Drop the external getcomposer.org link (siblings don't link their package manager) and make the errors-file reference a relative path. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a86f13d..e56a7b2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ against PHP 7.4 through 8.4. ## Getting started -Install the latest version with [Composer](https://getcomposer.org): +Install the latest version with Composer: ```bash composer require castle/castle-php @@ -188,7 +188,7 @@ Castle_Webhook::verify($rawBody, $signatureHeader); ``` ## Errors -Whenever something unexpected happens, an [exception](/lib/Castle/Errors.php) is thrown to indicate what went wrong. +Whenever something unexpected happens, an [exception](lib/Castle/Errors.php) is thrown to indicate what went wrong. | Name | Description | |:---------------------------------|:----------------| From 49474564ece2cdab2562b72e20d6ca6e7c196e8f Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 20:19:03 +0200 Subject: [PATCH 10/16] Test PHP 7.2 through 8.5 in CI Restore the PHP 7.2 minimum and expand the specs matrix to cover every PHP 7.x and 8.x release (7.2-8.5). Seed REQUEST_TIME_FLOAT in the test bootstrap so php-timer (PHPUnit 8.5 on PHP 7.2) can run, and replace the PHPUnit 9-only assertMatchesRegularExpression with a version-agnostic preg_match assertion. --- .github/workflows/specs.yml | 2 +- CHANGELOG.md | 3 +-- README.md | 4 ++-- composer.json | 2 +- test/Castle.php | 7 +++++++ test/CastleTest.php | 6 +++--- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml index 303d74c..a2fb30b 100644 --- a/.github/workflows/specs.yml +++ b/.github/workflows/specs.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + php-version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index fe09f6d..8510ebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,6 @@ **BREAKING CHANGES:** * the library is now defined under the `Castle\` namespace (`Castle\Castle`, `Castle\Webhook`, `Castle\RequestContext`, `Castle\ApiError`, ...), which is the canonical API ([#40](https://github.com/castle/castle-php/issues/40)) -* the minimum supported PHP version is now 7.4 Other changes: @@ -18,7 +17,7 @@ Other changes: * the request context is now attached automatically to `risk`, `filter` and `log` requests * a `sent_at` timestamp is now attached automatically to `risk`, `filter` and `log` requests ([#23](https://github.com/castle/castle-php/issues/23)) * improved PHP 8 compatibility: declared the `Castle_ApiError` properties and made API error handling tolerant of empty response bodies -* migrated CI to GitHub Actions and now test against PHP 7.4 through 8.4 +* migrated CI to GitHub Actions and now test against PHP 7.2 through 8.5 ## 3.2.0 (2022-03-28) * updated ca-certs file diff --git a/README.md b/README.md index e56a7b2..aeb3c0e 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ See the [documentation](https://docs.castle.io) for how to use this SDK with the ## Requirements -PHP 7.4 or newer, with the `curl` and `json` extensions. The library is tested -against PHP 7.4 through 8.4. +PHP 7.2 or newer, with the `curl` and `json` extensions. The library is tested +against PHP 7.2 through 8.5. ## Getting started diff --git a/composer.json b/composer.json index bac6972..4717b71 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ ] }, "require": { - "php": ">=7.4", + "php": ">=7.2", "ext-curl": "*", "ext-json": "*" }, diff --git a/test/Castle.php b/test/Castle.php index b5e35e8..e0e5a0c 100644 --- a/test/Castle.php +++ b/test/Castle.php @@ -1,5 +1,12 @@ assertRequest('post', '/risk'); $this->assertArrayHasKey('sent_at', $request['params']); - $this->assertMatchesRegularExpression( - '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', - $request['params']['sent_at'] + $this->assertSame( + 1, + preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', $request['params']['sent_at']) ); } From 60c52e9eb304e70fb683107cf42b7f472d377614 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 20:22:09 +0200 Subject: [PATCH 11/16] Preserve REQUEST_TIME_FLOAT in webhook test reset WebhookTest reset $_SERVER wholesale, which stripped REQUEST_TIME_FLOAT and made php-timer (PHPUnit 8.5 on PHP 7.2) abort while printing the run summary even though all tests passed. Retain the timing key in the reset. --- test/WebhookTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/WebhookTest.php b/test/WebhookTest.php index 385516f..4dcffd5 100644 --- a/test/WebhookTest.php +++ b/test/WebhookTest.php @@ -7,7 +7,9 @@ class CastleWebhookTest extends Castle_TestCase public function setUp(): void { Castle::setApiKey($this->apiSecret); - $_SERVER = array(); + // Keep REQUEST_TIME_FLOAT so php-timer (PHPUnit 8.5 on PHP 7.2) can still + // report timing after this suite resets the server globals. + $_SERVER = array('REQUEST_TIME_FLOAT' => microtime(true)); } private function sign($body) From 9f2474f55e7e43c9b323bd3ff11eaccd96afd1dc Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 21:04:10 +0200 Subject: [PATCH 12/16] Remove legacy track, authenticate and impersonate endpoints Drop Castle::track, Castle::authenticate and Castle::impersonate along with the Castle_Authenticate model, matching castle-ruby and castle-python which expose only risk/filter/log. Removes the corresponding alias, bootstrap requires, request-context entries and tests. --- CHANGELOG.md | 1 + lib/Castle.php | 1 - lib/Castle/Authenticate.php | 8 -------- lib/Castle/Castle.php | 34 ---------------------------------- lib/Castle/Request.php | 2 +- lib/aliases.php | 1 - test/AuthenticateTest.php | 17 ----------------- test/Castle.php | 1 - test/CastleTest.php | 32 -------------------------------- 9 files changed, 2 insertions(+), 95 deletions(-) delete mode 100644 lib/Castle/Authenticate.php delete mode 100644 test/AuthenticateTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8510ebd..97d5bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ **BREAKING CHANGES:** * the library is now defined under the `Castle\` namespace (`Castle\Castle`, `Castle\Webhook`, `Castle\RequestContext`, `Castle\ApiError`, ...), which is the canonical API ([#40](https://github.com/castle/castle-php/issues/40)) +* removed the legacy `Castle::track`, `Castle::authenticate` and `Castle::impersonate` endpoints (and the `Castle_Authenticate` model); use `Castle::risk`, `Castle::filter` and `Castle::log` instead Other changes: diff --git a/lib/Castle.php b/lib/Castle.php index 0a31055..ced5c60 100755 --- a/lib/Castle.php +++ b/lib/Castle.php @@ -21,7 +21,6 @@ function lcfirst( $str ) { require(dirname(__FILE__) . '/RestModel/Resource.php'); require(dirname(__FILE__) . '/RestModel/Model.php'); require(dirname(__FILE__) . '/Castle/Context.php'); -require(dirname(__FILE__) . '/Castle/Authenticate.php'); require(dirname(__FILE__) . '/Castle/CurlTransport.php'); require(dirname(__FILE__) . '/Castle/RequestContext.php'); require(dirname(__FILE__) . '/Castle/Request.php'); diff --git a/lib/Castle/Authenticate.php b/lib/Castle/Authenticate.php deleted file mode 100644 index 7d87000..0000000 --- a/lib/Castle/Authenticate.php +++ /dev/null @@ -1,8 +0,0 @@ -save(); - return $auth; - } - - public static function impersonate($attributes) { - $request = new Request(); - if(isset($attributes['reset'])) { - $request->send('delete', '/impersonate', $attributes); - } else { - $request->send('post', '/impersonate', $attributes); - } - } - - /** - * Track a security event - * @param Array $attributes An array of attributes to track. The 'event' key - * is required - * @return None - */ - public static function track(array $attributes) - { - $request = new Request(); - $request->send('post', '/track', $attributes); - } - - /** * Filter an action * @param String $attributes 'request_token', 'event', 'context' are required, 'user' with 'id' and 'properties' are optional diff --git a/lib/Castle/Request.php b/lib/Castle/Request.php index a05228f..5051fc5 100755 --- a/lib/Castle/Request.php +++ b/lib/Castle/Request.php @@ -90,7 +90,7 @@ public function send($method, $url, $payload = array()) { } private function shouldHaveContext($url) { - $WITH_CONTEXT = ['/track', '/authenticate', '/impersonate', '/risk', '/filter', '/log']; + $WITH_CONTEXT = ['/risk', '/filter', '/log']; return in_array($url, $WITH_CONTEXT); } diff --git a/lib/aliases.php b/lib/aliases.php index 676a0bf..d4dfea3 100644 --- a/lib/aliases.php +++ b/lib/aliases.php @@ -28,7 +28,6 @@ function castle_legacy_alias_map() 'Castle_CookieStore' => 'Castle\\CookieStore', 'Castle_iCookieStore' => 'Castle\\CookieStoreInterface', 'Castle_Webhook' => 'Castle\\Webhook', - 'Castle_Authenticate' => 'Castle\\Authenticate', 'Castle_Context' => 'Castle\\Context', 'RestModel' => 'Castle\\RestModel', 'Castle_Resource' => 'Castle\\Resource', diff --git a/test/AuthenticateTest.php b/test/AuthenticateTest.php deleted file mode 100644 index 4a379b8..0000000 --- a/test/AuthenticateTest.php +++ /dev/null @@ -1,17 +0,0 @@ -save(); - $this->assertRequest('post', '/authenticate'); - } -} diff --git a/test/Castle.php b/test/Castle.php index e0e5a0c..f42d7fc 100644 --- a/test/Castle.php +++ b/test/Castle.php @@ -34,7 +34,6 @@ public function assertRequest($method, $url, $headers=null) require_once(dirname(__FILE__) . '/CookieStore.php'); require_once(dirname(__FILE__) . '/../lib/RestModel/Resource.php'); require_once(dirname(__FILE__) . '/../lib/RestModel/Model.php'); -require_once(dirname(__FILE__) . '/../lib/Castle/Authenticate.php'); require_once(dirname(__FILE__) . '/../lib/Castle/Context.php'); require_once(dirname(__FILE__) . '/TestTransport.php'); require_once(dirname(__FILE__) . '/../lib/Castle/RequestContext.php'); diff --git a/test/CastleTest.php b/test/CastleTest.php index 9a83e67..fd0046c 100644 --- a/test/CastleTest.php +++ b/test/CastleTest.php @@ -19,13 +19,6 @@ public function testSetApiKey() $this->assertEquals('secretkey', Castle::getApiKey()); } - public function testTrack() - { - Castle_RequestTransport::setResponse(204, ''); - Castle::track(array('event' => '$login.failed')); - $this->assertRequest('post', '/track'); - } - public function testFilter() { Castle_RequestTransport::setResponse(204, ''); @@ -61,31 +54,6 @@ public function testRisk() $this->assertRequest('post', '/risk'); } - public function testAuthenticate() - { - Castle_RequestTransport::setResponse(201, '{ "status": "approve" }'); - $auth = Castle::authenticate(Array( - 'user_id' => '1', - 'event' => '$login.failed' - )); - $this->assertRequest('post', '/authenticate'); - $this->assertEquals($auth->status, 'approve'); - } - - public function testImpersonate() - { - Castle_RequestTransport::setResponse(204, ''); - Castle::impersonate(array('user_id' => '1')); - $this->assertRequest('post', '/impersonate'); - } - - public function testImpersonateReset() - { - Castle_RequestTransport::setResponse(204, ''); - Castle::impersonate(array('user_id' => '1', 'reset' => true)); - $this->assertRequest('delete', '/impersonate'); - } - public function testRiskIncludesContext() { Castle_RequestTransport::setResponse(200, '{}'); From 469873026e4c8c27d145037306f1120e483e3e84 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 21:35:02 +0200 Subject: [PATCH 13/16] Add failover, do-not-track and Events API Bring castle-php in line with castle-ruby 9.0/9.1: - risk/filter/log fail over to a configurable decision (Castle\Failover::ALLOW/ DENY/CHALLENGE/THROW, default allow) on network errors, timeouts and 5xx instead of throwing; responses now carry failover and failover_reason, and 5xx maps to the new Castle\InternalServerError. 4xx still raise. - do-not-track via Castle::disableTracking/enableTracking/tracked. - Events API: Castle::eventsSchema, queryEvents, groupEvents. - configurable request timeout (Castle::setRequestTimeout, default 1000 ms, applied to connect and transfer) and a DEFAULT_ALLOWLIST reference constant. --- CHANGELOG.md | 5 + README.md | 71 +++++++++++++- lib/Castle.php | 1 + lib/Castle/Castle.php | 176 +++++++++++++++++++++++++++++++---- lib/Castle/CurlTransport.php | 13 ++- lib/Castle/Errors.php | 5 + lib/Castle/Failover.php | 36 +++++++ lib/Castle/Request.php | 3 + lib/aliases.php | 5 +- test/Castle.php | 1 + test/CastleTest.php | 122 ++++++++++++++++++++++++ test/TestTransport.php | 15 +++ 12 files changed, 430 insertions(+), 23 deletions(-) create mode 100644 lib/Castle/Failover.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 97d5bb2..79409c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,14 @@ * the library is now defined under the `Castle\` namespace (`Castle\Castle`, `Castle\Webhook`, `Castle\RequestContext`, `Castle\ApiError`, ...), which is the canonical API ([#40](https://github.com/castle/castle-php/issues/40)) * removed the legacy `Castle::track`, `Castle::authenticate` and `Castle::impersonate` endpoints (and the `Castle_Authenticate` model); use `Castle::risk`, `Castle::filter` and `Castle::log` instead +* `Castle::risk` and `Castle::filter` now fail over to a configurable decision instead of throwing on network errors, timeouts and `5xx` responses; `Castle::log` returns the same response shape. `Castle::risk`/`filter`/`log` responses now include `failover` and `failover_reason` +* the default request timeout is now 1000 ms (previously 10 s), applied to both connection and transfer; configure it with `Castle::setRequestTimeout` Other changes: +* added a configurable failover strategy (`Castle::setFailoverStrategy` with `Castle\Failover::ALLOW`/`DENY`/`CHALLENGE`/`THROW`) and the `Castle\InternalServerError` exception for `5xx` responses +* added do-not-track support: `Castle::disableTracking`, `Castle::enableTracking` and `Castle::tracked` +* added the Events API: `Castle::eventsSchema`, `Castle::queryEvents`, `Castle::groupEvents` * the historic global class names (`Castle`, `Castle_*`, `RestModel`) are retained as aliases of their namespaced counterparts, so existing integrations keep working without changes; `catch` and `instanceof` work with either name * additional PHP 8 compatibility fixes: declared `Castle_Resource::$model`, and avoided passing `null` to `Exception` and `json_decode` diff --git a/README.md b/README.md index aeb3c0e..8c42064 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,14 @@ compatibility. ## Optional Configurations -Set preferred connection and request timeouts: -valid options for setting are: +Set the per-request timeout in milliseconds (applied to both connection and +transfer). Defaults to `1000`: + +```php +Castle::setRequestTimeout(1500); +``` + +For finer-grained control, set cURL options directly. Valid options are: - `CURLOPT_CONNECTTIMEOUT` - `CURLOPT_CONNECTTIMEOUT_MS` - `CURLOPT_TIMEOUT` @@ -90,6 +96,13 @@ valid options for setting are: Castle::setCurlOpts($curlOpts) ``` +Set the failover strategy used when a `risk` or `filter` request cannot be +completed (see [Failover](#failover)): + +```php +Castle::setFailoverStrategy(Castle\Failover::ALLOW); +``` + Set a specified list of request headers to include with event context (optional, not recommended): ```php Castle::setUseAllowlist($headers) @@ -167,6 +180,59 @@ Castle::deleteUserData([ ]); ``` +## Failover + +When a `risk` or `filter` request cannot be completed because of a network +error, timeout or a `5xx` response from the Castle API, the SDK returns a +synthetic decision instead of throwing, so your authentication flow keeps +working. The decision is controlled by the failover strategy: + +```php +Castle::setFailoverStrategy(Castle\Failover::ALLOW); // default +// Castle\Failover::DENY +// Castle\Failover::CHALLENGE +// Castle\Failover::THROW -- re-raise the underlying exception instead +``` + +A failed-over response carries `failover => true` and a `failover_reason`: + +```php +$verdict = Castle::risk([ + 'request_token' => $requestToken, + 'name' => '$login', + 'user' => ['id' => '1234'], +]); + +$verdict->failover; // true when the request failed over +$verdict->action; // 'allow', 'deny' or 'challenge' +$verdict->policy['action']; +``` + +Successful responses include `failover => false`. Client errors (`4xx`, such as +`422 invalid_request_token`) are never failed over and always raise. + +## Do-not-track + +Disable outbound tracking calls, for example in staging or test environments. +While disabled, `risk`, `filter` and `log` return an `allow` response without +contacting the API: + +```php +Castle::disableTracking(); +Castle::tracked(); // false +Castle::enableTracking(); +``` + +## Events (enterprise) + +Query event data: + +```php +Castle::eventsSchema(); +Castle::queryEvents(['filters' => [['field' => 'name', 'op' => '$eq', 'value' => '$login']]]); +Castle::groupEvents(['filters' => [], 'group_by' => 'name']); +``` + ## Webhooks Verify the authenticity of incoming Castle webhooks. By default the raw body is @@ -195,6 +261,7 @@ Whenever something unexpected happens, an [exception](lib/Castle/Errors.php) is | `Castle_Error` | A generic error | | `Castle_RequestError` | A request failed. Probably due to a network error | | `Castle_ApiError` | An unexpected error for the Castle API | +| `Castle_InternalServerError` | The Castle API returned a `5xx` response (triggers failover) | | `Castle_ConfigurationError` | The Castle secret API key has not been set | | `Castle_UnauthorizedError` | Wrong Castle API secret key | | `Castle_BadRequest` | The request was invalid. For example if a challenge is created without the user having MFA enabled. | diff --git a/lib/Castle.php b/lib/Castle.php index ced5c60..0871f89 100755 --- a/lib/Castle.php +++ b/lib/Castle.php @@ -18,6 +18,7 @@ function lcfirst( $str ) { require(dirname(__FILE__) . '/Castle/Castle.php'); require(dirname(__FILE__) . '/Castle/CookieStore.php'); require(dirname(__FILE__) . '/Castle/Errors.php'); +require(dirname(__FILE__) . '/Castle/Failover.php'); require(dirname(__FILE__) . '/RestModel/Resource.php'); require(dirname(__FILE__) . '/RestModel/Model.php'); require(dirname(__FILE__) . '/Castle/Context.php'); diff --git a/lib/Castle/Castle.php b/lib/Castle/Castle.php index 3056945..13f4940 100755 --- a/lib/Castle/Castle.php +++ b/lib/Castle/Castle.php @@ -24,6 +24,26 @@ abstract class Castle private static $useAllowlist = false; public static $allowlistedHeaders = array(self::HEADER_USER_AGENT); + // Reference allowlist of headers that are safe to forward. Not applied by + // default; opt in with setUseAllowlist(true) and assign $allowlistedHeaders. + const DEFAULT_ALLOWLIST = array( + 'Accept', 'Accept-Charset', 'Accept-Datetime', 'Accept-Encoding', + 'Accept-Language', 'Cache-Control', 'Connection', 'Content-Length', + 'Content-Type', 'Dnt', 'Host', 'Origin', 'Pragma', 'Referer', + 'Sec-Fetch-Dest', 'Sec-Fetch-Mode', 'Sec-Fetch-Site', 'Sec-Fetch-User', + 'Te', 'Upgrade-Insecure-Requests', 'User-Agent', 'X-Requested-With' + ); + + // Per-request timeout in milliseconds, applied to both connect and overall + // transfer, unless overridden via setCurlOpts(). + public static $requestTimeout = 1000; + + // Decision returned when a request fails over. One of the Castle\Failover + // strategy constants: 'allow', 'deny', 'challenge' or 'throw'. + public static $failoverStrategy = 'allow'; + + private static $doNotTrack = false; + private static $curlOpts = array(); private static $validCurlOpts = array(CURLOPT_CONNECTTIMEOUT, CURLOPT_CONNECTTIMEOUT_MS, @@ -101,43 +121,132 @@ public static function setTokenStore($serializerClass) /** * Filter an action - * @param String $attributes 'request_token', 'event', 'context' are required, 'user' with 'id' and 'properties' are optional + * @param Array $attributes 'request_token', 'event' and 'context' are required, 'user' with 'id' and 'properties' are optional * @return RestModel */ public static function filter(array $attributes) { - $request = new Request(); - list($response, $request) = $request->send('post', '/filter', $attributes); - if ($request->rStatus == 204) { - $response = array(); - } - return new RestModel($response); + return self::trackingRequest('/filter', $attributes); } /** * Log events - * @param String $attributes 'request_token', 'event', 'status' and 'user' object with 'id' are required - * @return None + * @param Array $attributes 'request_token', 'event', 'status' and 'user' object with 'id' are required + * @return RestModel */ public static function log(array $attributes) { - $request = new Request(); - $request->send('post', '/log', $attributes); + return self::trackingRequest('/log', $attributes); } /** * Risk - * @param String $attributes 'request_token', 'event', 'context', 'user' with 'id' are required, 'status', 'properties' are optional + * @param Array $attributes 'request_token', 'event', 'context' and 'user' with 'id' are required, 'status' and 'properties' are optional * @return RestModel */ public static function risk(array $attributes) { - $request = new Request(); - list($response, $request) = $request->send('post', '/risk', $attributes); - if ($request->rStatus == 204) { - $response = array(); + return self::trackingRequest('/risk', $attributes); + } + + /** + * Stop sending tracking calls. While disabled, risk/filter/log return an + * 'allow' response without contacting the API. + */ + public static function disableTracking() + { + self::$doNotTrack = true; + } + + /** + * Resume sending tracking calls. + */ + public static function enableTracking() + { + self::$doNotTrack = false; + } + + public static function tracked() + { + return !self::$doNotTrack; + } + + public static function getFailoverStrategy() + { + return self::$failoverStrategy; + } + + public static function setFailoverStrategy($strategy) + { + if (!in_array($strategy, Failover::strategies(), true)) { + throw new ConfigurationError('unrecognized failover strategy'); } - return new RestModel($response); + self::$failoverStrategy = $strategy; + } + + public static function getRequestTimeout() + { + return self::$requestTimeout; + } + + public static function setRequestTimeout($milliseconds) + { + self::$requestTimeout = $milliseconds; + } + + private static function trackingRequest($path, array $attributes) + { + if (!self::tracked()) { + return self::doNotTrackResponse(self::failoverUserId($attributes)); + } + try { + $request = new Request(); + list($response, $request) = $request->send('post', $path, $attributes); + if ($request->rStatus == 204) { + $response = array(); + } + $response = is_array($response) ? $response : array(); + $response['failover'] = false; + $response['failover_reason'] = null; + return new RestModel($response); + } catch (RequestError $e) { + return self::failoverResponseOrRaise(self::failoverUserId($attributes), $e); + } catch (InternalServerError $e) { + return self::failoverResponseOrRaise(self::failoverUserId($attributes), $e); + } + } + + private static function failoverResponseOrRaise($userId, $exception) + { + if (self::$failoverStrategy === Failover::THROW) { + throw $exception; + } + return new RestModel(Failover::prepareResponse( + $userId, + self::$failoverStrategy, + get_class($exception) + )); + } + + private static function doNotTrackResponse($userId) + { + return new RestModel(Failover::prepareResponse( + $userId, + Failover::ALLOW, + 'Castle is set to do not track.' + )); + } + + private static function failoverUserId(array $attributes) + { + if (isset($attributes['user']) && is_array($attributes['user']) && + isset($attributes['user']['id'])) { + return $attributes['user']['id']; + } + if (isset($attributes['matching_user_id'])) { + return $attributes['matching_user_id']; + } + return null; } /** @@ -321,6 +430,39 @@ public static function deleteUserData(array $attributes) return self::sendRequest('delete', '/privacy/users', $attributes); } + /** + * Events API (enterprise) + */ + + /** + * Fetch the events schema + * @return Array + */ + public static function eventsSchema(array $attributes = array()) + { + return self::sendRequest('get', '/events/schema', $attributes); + } + + /** + * Query events + * @param Array $attributes + * @return Array + */ + public static function queryEvents(array $attributes) + { + return self::sendRequest('post', '/events/query', $attributes); + } + + /** + * Group events + * @param Array $attributes + * @return Array + */ + public static function groupEvents(array $attributes) + { + return self::sendRequest('post', '/events/group', $attributes); + } + private static function sendRequest($method, $path, $attributes = null) { $request = new Request(); diff --git a/lib/Castle/CurlTransport.php b/lib/Castle/CurlTransport.php index aac29d7..45af79c 100644 --- a/lib/Castle/CurlTransport.php +++ b/lib/Castle/CurlTransport.php @@ -76,16 +76,23 @@ public function send($method, $url, $payload) { $curlOptions[CURLOPT_URL] = $url; $curlOptions[CURLOPT_USERPWD] = ":" . Castle::getApiKey(); $curlOptions[CURLOPT_RETURNTRANSFER] = true; - $curlOptions[CURLOPT_CONNECTTIMEOUT] = 3; - $curlOptions[CURLOPT_TIMEOUT] = 10; $curlOptions[CURLOPT_HTTPHEADER] = array( 'Content-Type: application/json', 'Content-Length: ' . strlen($body) ); $curlOptions[CURLOPT_HEADER] = true; - // Merge user defined options. + // Apply the configured request timeout to both connect and overall transfer + // unless the caller pinned any timeout option via setCurlOpts(). $userOptions = Castle::getCurlOpts(); + $timeoutOpts = array(CURLOPT_CONNECTTIMEOUT, CURLOPT_CONNECTTIMEOUT_MS, + CURLOPT_TIMEOUT, CURLOPT_TIMEOUT_MS); + if (!array_intersect(array_keys($userOptions), $timeoutOpts)) { + $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = Castle::getRequestTimeout(); + $curlOptions[CURLOPT_TIMEOUT_MS] = Castle::getRequestTimeout(); + } + + // Merge user defined options. $curlOptions = $userOptions + $curlOptions; curl_setopt_array($curl, $curlOptions); diff --git a/lib/Castle/Errors.php b/lib/Castle/Errors.php index 40f6e3c..39b21de 100755 --- a/lib/Castle/Errors.php +++ b/lib/Castle/Errors.php @@ -35,6 +35,11 @@ public function __construct($msg = '', $type = null, $status = null) } } +class InternalServerError extends ApiError +{ + +} + class BadRequest extends ApiError { diff --git a/lib/Castle/Failover.php b/lib/Castle/Failover.php new file mode 100644 index 0000000..06f8a0d --- /dev/null +++ b/lib/Castle/Failover.php @@ -0,0 +1,36 @@ + array('action' => $strategy), + 'action' => $strategy, + 'user_id' => $userId, + 'failover' => true, + 'failover_reason' => $reason, + ); + } +} diff --git a/lib/Castle/Request.php b/lib/Castle/Request.php index 5051fc5..ca42930 100755 --- a/lib/Castle/Request.php +++ b/lib/Castle/Request.php @@ -38,6 +38,9 @@ public function handleApiError($response, $status) throw new InvalidParametersError($msg, $type, $status); } default: + if ($status >= 500 && $status <= 599) { + throw new InternalServerError($msg, $type, $status); + } throw new ApiError($msg, $type, $status); } } diff --git a/lib/aliases.php b/lib/aliases.php index d4dfea3..69ab1a1 100644 --- a/lib/aliases.php +++ b/lib/aliases.php @@ -14,7 +14,7 @@ * so a bare `instanceof Castle_Foo` only resolves once the legacy name has been * referenced elsewhere; prefer the namespaced names in new code. * - * Works on every supported runtime (PHP 7.4 through 8.x). + * Works on every supported runtime (PHP 7.2 through 8.x). */ if (!function_exists('castle_legacy_alias_map')) { @@ -29,6 +29,7 @@ function castle_legacy_alias_map() 'Castle_iCookieStore' => 'Castle\\CookieStoreInterface', 'Castle_Webhook' => 'Castle\\Webhook', 'Castle_Context' => 'Castle\\Context', + 'Castle_Failover' => 'Castle\\Failover', 'RestModel' => 'Castle\\RestModel', 'Castle_Resource' => 'Castle\\Resource', 'Castle_Error' => 'Castle\\Error', @@ -36,6 +37,7 @@ function castle_legacy_alias_map() 'Castle_ConfigurationError' => 'Castle\\ConfigurationError', 'Castle_CurlOptionError' => 'Castle\\CurlOptionError', 'Castle_ApiError' => 'Castle\\ApiError', + 'Castle_InternalServerError' => 'Castle\\InternalServerError', 'Castle_BadRequest' => 'Castle\\BadRequest', 'Castle_UnauthorizedError' => 'Castle\\UnauthorizedError', 'Castle_ForbiddenError' => 'Castle\\ForbiddenError', @@ -77,6 +79,7 @@ class_alias($target, $class); 'Castle\\ConfigurationError', 'Castle\\CurlOptionError', 'Castle\\ApiError', + 'Castle\\InternalServerError', 'Castle\\BadRequest', 'Castle\\UnauthorizedError', 'Castle\\ForbiddenError', diff --git a/test/Castle.php b/test/Castle.php index f42d7fc..1c365b7 100644 --- a/test/Castle.php +++ b/test/Castle.php @@ -31,6 +31,7 @@ public function assertRequest($method, $url, $headers=null) require_once(dirname(__FILE__) . '/../lib/Castle/Castle.php'); require_once(dirname(__FILE__) . '/../lib/Castle/Errors.php'); +require_once(dirname(__FILE__) . '/../lib/Castle/Failover.php'); require_once(dirname(__FILE__) . '/CookieStore.php'); require_once(dirname(__FILE__) . '/../lib/RestModel/Resource.php'); require_once(dirname(__FILE__) . '/../lib/RestModel/Model.php'); diff --git a/test/CastleTest.php b/test/CastleTest.php index fd0046c..7d5a938 100644 --- a/test/CastleTest.php +++ b/test/CastleTest.php @@ -12,6 +12,9 @@ public function setUp(): void { $_SESSION = array(); $_COOKIE = array(); + Castle_RequestTransport::reset(); + Castle::enableTracking(); + Castle::setFailoverStrategy('allow'); } public function testSetApiKey() @@ -259,4 +262,123 @@ public function testDeleteUserData() )); $this->assertRequest('delete', '/privacy/users'); } + + public function testEventsSchema() + { + Castle_RequestTransport::setResponse(200, '{ "events": [] }'); + Castle::eventsSchema(); + $this->assertRequest('get', '/events/schema'); + } + + public function testQueryEvents() + { + Castle_RequestTransport::setResponse(200, '{ "data": [] }'); + Castle::queryEvents(array('filters' => array())); + $this->assertRequest('post', '/events/query'); + } + + public function testGroupEvents() + { + Castle_RequestTransport::setResponse(200, '{ "data": [] }'); + Castle::groupEvents(array('filters' => array())); + $this->assertRequest('post', '/events/group'); + } + + public function testRiskSuccessIncludesFailoverFalse() + { + Castle_RequestTransport::setResponse(200, '{ "policy": { "action": "allow" } }'); + $risk = Castle::risk(array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => array('id' => 'abc') + )); + $this->assertFalse($risk->failover); + $this->assertNull($risk->failover_reason); + } + + public function testRiskFailsOverOnRequestError() + { + Castle_RequestTransport::setError(); + $risk = Castle::risk(array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => array('id' => 'abc') + )); + $this->assertTrue($risk->failover); + $this->assertEquals('allow', $risk->action); + $this->assertEquals('allow', $risk->policy['action']); + $this->assertEquals('abc', $risk->user_id); + $this->assertEquals('Castle\\RequestError', $risk->failover_reason); + } + + public function testRiskFailsOverOnServerError() + { + Castle_RequestTransport::setResponse(500, '{ "type": "server_error" }'); + $risk = Castle::risk(array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => array('id' => 'abc') + )); + $this->assertTrue($risk->failover); + $this->assertEquals('allow', $risk->action); + $this->assertEquals('Castle\\InternalServerError', $risk->failover_reason); + } + + public function testFilterFailoverUsesMatchingUserId() + { + Castle::setFailoverStrategy('deny'); + Castle_RequestTransport::setError(); + $filter = Castle::filter(array( + 'request_token' => 'token', + 'name' => '$registration', + 'matching_user_id' => 'mu-1' + )); + $this->assertTrue($filter->failover); + $this->assertEquals('deny', $filter->action); + $this->assertEquals('mu-1', $filter->user_id); + } + + public function testFailoverThrowStrategyReraises() + { + Castle::setFailoverStrategy('throw'); + Castle_RequestTransport::setError(); + $this->expectException(\Castle\RequestError::class); + Castle::risk(array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => array('id' => 'abc') + )); + } + + public function testClientErrorsAreNotFailedOver() + { + Castle_RequestTransport::setResponse(422, '{ "type": "invalid_request_token" }'); + $this->expectException(\Castle\InvalidRequestTokenError::class); + Castle::risk(array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => array('id' => 'abc') + )); + } + + public function testDoNotTrackReturnsAllowWithoutRequest() + { + Castle::disableTracking(); + $risk = Castle::risk(array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => array('id' => 'abc') + )); + $this->assertTrue($risk->failover); + $this->assertEquals('allow', $risk->action); + $this->assertEquals('abc', $risk->user_id); + $this->assertEquals('Castle is set to do not track.', $risk->failover_reason); + $this->assertNull(Castle_RequestTransport::getLastRequest()); + } + + public function testSetFailoverStrategyRejectsUnknown() + { + $this->expectException(\Castle\ConfigurationError::class); + Castle::setFailoverStrategy('bogus'); + } } diff --git a/test/TestTransport.php b/test/TestTransport.php index 763feeb..e69ef83 100644 --- a/test/TestTransport.php +++ b/test/TestTransport.php @@ -34,6 +34,14 @@ public function send($method, $url, $payload) { 'url' => $url ); $params = array_pop(self::$params); + if (isset($params['error'])) { + $this->rError = $params['error']; + $this->rMessage = $params['message']; + $this->rStatus = 0; + $this->rBody = null; + $this->rHeaders = array(); + return; + } $this->rBody = $params['body']; $this->rStatus = $params['code']; $this->rHeaders = $params['headers']; @@ -60,4 +68,11 @@ public static function setResponse($code=200, $body='', $headers=array()) { 'headers' => $headers ); } + + public static function setError($errno=28, $message='Operation timed out') { + self::$params[]= array( + 'error' => $errno, + 'message' => $message + ); + } } From dc957ed2afdf5ff96d91b8d93d0740f959552bee Mon Sep 17 00:00:00 2001 From: Bartosz Date: Tue, 9 Jun 2026 10:11:06 +0200 Subject: [PATCH 14/16] Slim default request context to ip, headers and library Drop client_id and user_agent from Castle\RequestContext::extract, and remove the now-unused extractClientId, extractUserAgent and normalize helpers. The client id is carried by the X-Castle-Client-Id header / __cid cookie and resolved server-side. --- CHANGELOG.md | 1 + lib/Castle/RequestContext.php | 34 -------------------- test/RequestContextTest.php | 58 +---------------------------------- 3 files changed, 2 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79409c2..8b22c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Other changes: +* slimmed the default request context built by `Castle\RequestContext::extract` down to `ip`, `headers` and `library`; the `client_id` and `user_agent` fields (and the `RequestContext::extractClientId`, `extractUserAgent` and `normalize` helpers) are removed, as the client id is carried by the `X-Castle-Client-Id` header / `__cid` cookie and resolved server-side * added a configurable failover strategy (`Castle::setFailoverStrategy` with `Castle\Failover::ALLOW`/`DENY`/`CHALLENGE`/`THROW`) and the `Castle\InternalServerError` exception for `5xx` responses * added do-not-track support: `Castle::disableTracking`, `Castle::enableTracking` and `Castle::tracked` * added the Events API: `Castle::eventsSchema`, `Castle::queryEvents`, `Castle::groupEvents` diff --git a/lib/Castle/RequestContext.php b/lib/Castle/RequestContext.php index 66c57ad..94762b4 100644 --- a/lib/Castle/RequestContext.php +++ b/lib/Castle/RequestContext.php @@ -7,10 +7,8 @@ class RequestContext # Extract a request context from the $Server environment. public static function extract() { return array( - 'client_id' => self::extractClientId(), 'ip' => self::extractIp(), 'headers' => self::extractHeaders(), - 'user_agent' => self::extractUserAgent(), 'library' => array( 'name' => 'castle-php', 'version' => Castle::VERSION @@ -61,36 +59,4 @@ public static function extractIp() } return null; } - - public static function extractUserAgent() - { - if (array_key_exists('HTTP_USER_AGENT', $_SERVER)) { - return $_SERVER['HTTP_USER_AGENT']; - } - return null; - } - - public static function extractClientId() - { - if (array_key_exists('HTTP_X_CASTLE_CLIENT_ID', $_SERVER)) { - return self::normalize($_SERVER['HTTP_X_CASTLE_CLIENT_ID']); - } else if (Castle::getCookieStore()->hasKey('__cid')) { - return self::normalize(Castle::getCookieStore()->read('__cid')); - } else { - // If the client_id is neither send in the header nor cookie - // we'll return the special value '?'. This doesn't have any effect on - // functionality. This is to prevent curl from removing empty headers - return '?'; - } - } - - public static function normalize($cid) - { - $cid = preg_replace("/[[:cntrl:][:space:]]/", '', $cid); - - // If we end up with an empty/invalid cid, we'll set it to the special - // value '_' to indicate there was a value but it was not valid. - // This is to prevent curl from removing empty headers - return empty($cid) ? '_' : $cid; - } } diff --git a/test/RequestContextTest.php b/test/RequestContextTest.php index 4f8ae1b..cafc740 100644 --- a/test/RequestContextTest.php +++ b/test/RequestContextTest.php @@ -15,13 +15,11 @@ public function setUp(): void { public function contextProvider() { return array(array(array( - 'client_id' => '1ccf8dee-904b-4d20-8a88-55ded468bcc5', 'ip' => '8.8.8.8', 'headers' => array( 'User-Agent' => 'TestAgent', 'X-Castle-Client-Id' => '1ccf8dee-904b-4d20-8a88-55ded468bcc5' ), - 'user_agent' => 'TestAgent', 'library' => array( 'name' => 'castle-php', 'version' => Castle::VERSION @@ -30,7 +28,7 @@ public function contextProvider() { } public function contextJsonProvider() { - return array(array('{"client_id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5","ip":"8.8.8.8","headers":{"User-Agent":"TestAgent","X-Castle-Client-Id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5"},"user_agent":"TestAgent","library":{"name":"castle-php","version":"4.0.0"}}')); + return array(array('{"ip":"8.8.8.8","headers":{"User-Agent":"TestAgent","X-Castle-Client-Id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5"},"library":{"name":"castle-php","version":"4.0.0"}}')); } /** @@ -54,60 +52,6 @@ public function testExtractJson($expected) { $this->assertEquals($expected, $actual); } - public function testExtractClientIDFromCookie() { - unset($_SERVER['HTTP_X_CASTLE_CLIENT_ID']); - $expected = $testUUID = '85B126D3-C706-4DBA-A352-883EFBCA9203'; - - $cookies = Castle::getCookieStore(); - $cookies->write('__cid', $testUUID); - - $actual = Castle_RequestContext::extractClientId(); - - $this->assertEquals($expected, $actual); - } - - public function testExtractClientIDInvalidFromCookie() { - unset($_SERVER['HTTP_X_CASTLE_CLIENT_ID']); - $expected = '_'; - $testInvalidID = " \t\n\r\0\x0B"; - - $cookies = Castle::getCookieStore(); - $cookies->write('__cid', $testInvalidID); - - $actual = Castle_RequestContext::extractClientId(); - - $this->assertEquals($expected, $actual); - } - - public function testExtractClientIDFromHeader() { - $expected = $testUUID = '85B126D3-C706-4DBA-A352-883EFBCA9203'; - - $_SERVER['HTTP_X_CASTLE_CLIENT_ID'] = $testUUID; - - $actual = Castle_RequestContext::extractClientId(); - - $this->assertEquals($expected, $actual); - } - - public function testExtractClientIDInvalidFromHeader() { - $expected = '_'; - $testInvalidID = " \t\n\r\0\x0B"; - - $_SERVER['HTTP_X_CASTLE_CLIENT_ID'] = $testInvalidID; - - $actual = Castle_RequestContext::extractClientId(); - - $this->assertEquals($expected, $actual); - } - - public function testExtractClientIDNoClientID() { - unset($_SERVER['HTTP_X_CASTLE_CLIENT_ID']); - $expected = '?'; - $actual = Castle_RequestContext::extractClientId(); - - $this->assertEquals($expected, $actual); - } - public function testExtractIp() { $expected = '8.8.8.8'; $actual = Castle_RequestContext::extractIp(); From 6323fa1defe7f6a0807d469c7cf176a6d152df32 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Tue, 9 Jun 2026 14:29:23 +0200 Subject: [PATCH 15/16] Replace apiBase/apiVersion with a single baseUrl and drop the cookie/token store Collapse the separate Castle::$apiBase / $apiVersion config into a single Castle::$baseUrl (default https://api.castle.io/v1) with getBaseUrl/setBaseUrl, and remove the now-unused configurable token/cookie store (Castle\CookieStore and the getTokenStore/getCookieStore/setTokenStore accessors) left over from the client-id cookie extraction that the slimmed request context no longer performs. --- CHANGELOG.md | 2 ++ lib/Castle.php | 1 - lib/Castle/Castle.php | 44 ++++++++++---------------------------- lib/Castle/CookieStore.php | 43 ------------------------------------- lib/Castle/Request.php | 4 +--- lib/aliases.php | 2 -- test/Castle.php | 1 - test/CookieStore.php | 38 -------------------------------- 8 files changed, 14 insertions(+), 121 deletions(-) delete mode 100644 lib/Castle/CookieStore.php delete mode 100644 test/CookieStore.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b22c7b..fe3e9c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ * removed the legacy `Castle::track`, `Castle::authenticate` and `Castle::impersonate` endpoints (and the `Castle_Authenticate` model); use `Castle::risk`, `Castle::filter` and `Castle::log` instead * `Castle::risk` and `Castle::filter` now fail over to a configurable decision instead of throwing on network errors, timeouts and `5xx` responses; `Castle::log` returns the same response shape. `Castle::risk`/`filter`/`log` responses now include `failover` and `failover_reason` * the default request timeout is now 1000 ms (previously 10 s), applied to both connection and transfer; configure it with `Castle::setRequestTimeout` +* replaced the separate `Castle::$apiBase` / `Castle::$apiVersion` (and `Castle::getApiVersion` / `setApiVersion`) with a single `Castle::$baseUrl` (default `https://api.castle.io/v1`), configurable via `Castle::getBaseUrl` / `Castle::setBaseUrl` +* removed the configurable token/cookie store (`Castle::$tokenStore`, `Castle::$cookieStore`, `Castle::getTokenStore` / `getCookieStore` / `setTokenStore`) and the `Castle\CookieStore` class (with its `Castle_CookieStore` / `Castle_iCookieStore` aliases); these read the client id from the `__cid` cookie, which the slimmed request context no longer does Other changes: diff --git a/lib/Castle.php b/lib/Castle.php index 0871f89..39b289d 100755 --- a/lib/Castle.php +++ b/lib/Castle.php @@ -16,7 +16,6 @@ function lcfirst( $str ) { } require(dirname(__FILE__) . '/Castle/Castle.php'); -require(dirname(__FILE__) . '/Castle/CookieStore.php'); require(dirname(__FILE__) . '/Castle/Errors.php'); require(dirname(__FILE__) . '/Castle/Failover.php'); require(dirname(__FILE__) . '/RestModel/Resource.php'); diff --git a/lib/Castle/Castle.php b/lib/Castle/Castle.php index 13f4940..05822f3 100755 --- a/lib/Castle/Castle.php +++ b/lib/Castle/Castle.php @@ -11,13 +11,7 @@ abstract class Castle public static $apiKey; - public static $apiBase = 'https://api.castle.io'; - - public static $apiVersion = 'v1'; - - public static $tokenStore = 'Castle_TokenStore'; - - public static $cookieStore = 'Castle\\CookieStore'; + public static $baseUrl = 'https://api.castle.io/v1'; public static $scrubHeaders = array(self::HEADER_COOKIE); @@ -60,6 +54,16 @@ public static function setApiKey($apiKey) self::$apiKey = $apiKey; } + public static function getBaseUrl() + { + return self::$baseUrl; + } + + public static function setBaseUrl($baseUrl) + { + self::$baseUrl = $baseUrl; + } + public static function setCurlOpts($curlOpts) { $invalidOpts = array_diff(array_keys($curlOpts), self::$validCurlOpts); @@ -93,32 +97,6 @@ public static function setUseAllowlist($use) self::$useAllowlist = $use; } - public static function getApiVersion() - { - return self::$apiVersion; - } - - public static function setApiVersion($apiVersion) - { - self::$apiVersion = $apiVersion; - } - - public static function getCookieStore() - { - return new self::$cookieStore; - } - - public static function getTokenStore() - { - return new self::$tokenStore(self::getCookieStore()); - } - - public static function setTokenStore($serializerClass) - { - self::$tokenStore = $serializerClass; - } - - /** * Filter an action * @param Array $attributes 'request_token', 'event' and 'context' are required, 'user' with 'id' and 'properties' are optional diff --git a/lib/Castle/CookieStore.php b/lib/Castle/CookieStore.php deleted file mode 100644 index bae6088..0000000 --- a/lib/Castle/CookieStore.php +++ /dev/null @@ -1,43 +0,0 @@ - 'Castle\\Request', 'Castle_RequestContext' => 'Castle\\RequestContext', 'Castle_RequestTransport' => 'Castle\\RequestTransport', - 'Castle_CookieStore' => 'Castle\\CookieStore', - 'Castle_iCookieStore' => 'Castle\\CookieStoreInterface', 'Castle_Webhook' => 'Castle\\Webhook', 'Castle_Context' => 'Castle\\Context', 'Castle_Failover' => 'Castle\\Failover', diff --git a/test/Castle.php b/test/Castle.php index 1c365b7..858f892 100644 --- a/test/Castle.php +++ b/test/Castle.php @@ -32,7 +32,6 @@ public function assertRequest($method, $url, $headers=null) require_once(dirname(__FILE__) . '/../lib/Castle/Castle.php'); require_once(dirname(__FILE__) . '/../lib/Castle/Errors.php'); require_once(dirname(__FILE__) . '/../lib/Castle/Failover.php'); -require_once(dirname(__FILE__) . '/CookieStore.php'); require_once(dirname(__FILE__) . '/../lib/RestModel/Resource.php'); require_once(dirname(__FILE__) . '/../lib/RestModel/Model.php'); require_once(dirname(__FILE__) . '/../lib/Castle/Context.php'); diff --git a/test/CookieStore.php b/test/CookieStore.php deleted file mode 100644 index 0c84562..0000000 --- a/test/CookieStore.php +++ /dev/null @@ -1,38 +0,0 @@ - Date: Tue, 9 Jun 2026 17:29:53 +0200 Subject: [PATCH 16/16] Remove setCurlOpts/getCurlOpts in favor of setRequestTimeout Drop Castle::setCurlOpts / Castle::getCurlOpts and the Castle\CurlOptionError exception (with its Castle_CurlOptionError alias). The connection and transfer timeout is configured solely through Castle::setRequestTimeout, which the cURL transport always applies to both CURLOPT_CONNECTTIMEOUT_MS and CURLOPT_TIMEOUT_MS. --- CHANGELOG.md | 1 + README.md | 10 ---------- lib/Castle/Castle.php | 27 +-------------------------- lib/Castle/CurlTransport.php | 15 +++------------ lib/Castle/Errors.php | 5 ----- lib/aliases.php | 2 -- test/RequestTest.php | 13 ------------- 7 files changed, 5 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe3e9c8..06b129d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * removed the legacy `Castle::track`, `Castle::authenticate` and `Castle::impersonate` endpoints (and the `Castle_Authenticate` model); use `Castle::risk`, `Castle::filter` and `Castle::log` instead * `Castle::risk` and `Castle::filter` now fail over to a configurable decision instead of throwing on network errors, timeouts and `5xx` responses; `Castle::log` returns the same response shape. `Castle::risk`/`filter`/`log` responses now include `failover` and `failover_reason` * the default request timeout is now 1000 ms (previously 10 s), applied to both connection and transfer; configure it with `Castle::setRequestTimeout` +* removed `Castle::setCurlOpts` / `Castle::getCurlOpts` (and the `Castle\CurlOptionError` exception with its `Castle_CurlOptionError` alias); use `Castle::setRequestTimeout` to configure the connection and transfer timeout * replaced the separate `Castle::$apiBase` / `Castle::$apiVersion` (and `Castle::getApiVersion` / `setApiVersion`) with a single `Castle::$baseUrl` (default `https://api.castle.io/v1`), configurable via `Castle::getBaseUrl` / `Castle::setBaseUrl` * removed the configurable token/cookie store (`Castle::$tokenStore`, `Castle::$cookieStore`, `Castle::getTokenStore` / `getCookieStore` / `setTokenStore`) and the `Castle\CookieStore` class (with its `Castle_CookieStore` / `Castle_iCookieStore` aliases); these read the client id from the `__cid` cookie, which the slimmed request context no longer does diff --git a/README.md b/README.md index 8c42064..2660eec 100644 --- a/README.md +++ b/README.md @@ -86,16 +86,6 @@ transfer). Defaults to `1000`: Castle::setRequestTimeout(1500); ``` -For finer-grained control, set cURL options directly. Valid options are: -- `CURLOPT_CONNECTTIMEOUT` -- `CURLOPT_CONNECTTIMEOUT_MS` -- `CURLOPT_TIMEOUT` -- `CURLOPT_TIMEOUT_MS` - -```php -Castle::setCurlOpts($curlOpts) -``` - Set the failover strategy used when a `risk` or `filter` request cannot be completed (see [Failover](#failover)): diff --git a/lib/Castle/Castle.php b/lib/Castle/Castle.php index 05822f3..0e40e26 100755 --- a/lib/Castle/Castle.php +++ b/lib/Castle/Castle.php @@ -29,7 +29,7 @@ abstract class Castle ); // Per-request timeout in milliseconds, applied to both connect and overall - // transfer, unless overridden via setCurlOpts(). + // transfer. public static $requestTimeout = 1000; // Decision returned when a request fails over. One of the Castle\Failover @@ -38,12 +38,6 @@ abstract class Castle private static $doNotTrack = false; - private static $curlOpts = array(); - private static $validCurlOpts = array(CURLOPT_CONNECTTIMEOUT, - CURLOPT_CONNECTTIMEOUT_MS, - CURLOPT_TIMEOUT, - CURLOPT_TIMEOUT_MS); - public static function getApiKey() { return self::$apiKey; @@ -64,25 +58,6 @@ public static function setBaseUrl($baseUrl) self::$baseUrl = $baseUrl; } - public static function setCurlOpts($curlOpts) - { - $invalidOpts = array_diff(array_keys($curlOpts), self::$validCurlOpts); - // If any options are invalid. - if (count($invalidOpts)) { - // Throw an exception listing all invalid options. - throw new CurlOptionError('These cURL options are not allowed:' . - join(',', $invalidOpts)); - } - // May seem odd, but one may want the option of stripping them out, and so - // would probably simply use error_log instead of throw. - self::$curlOpts = array_diff($curlOpts, array_flip($invalidOpts)); - } - - public static function getCurlOpts() - { - return self::$curlOpts; - } - public static function getUseAllowlist() { return self::$useAllowlist; diff --git a/lib/Castle/CurlTransport.php b/lib/Castle/CurlTransport.php index 45af79c..40d5fac 100644 --- a/lib/Castle/CurlTransport.php +++ b/lib/Castle/CurlTransport.php @@ -82,18 +82,9 @@ public function send($method, $url, $payload) { ); $curlOptions[CURLOPT_HEADER] = true; - // Apply the configured request timeout to both connect and overall transfer - // unless the caller pinned any timeout option via setCurlOpts(). - $userOptions = Castle::getCurlOpts(); - $timeoutOpts = array(CURLOPT_CONNECTTIMEOUT, CURLOPT_CONNECTTIMEOUT_MS, - CURLOPT_TIMEOUT, CURLOPT_TIMEOUT_MS); - if (!array_intersect(array_keys($userOptions), $timeoutOpts)) { - $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = Castle::getRequestTimeout(); - $curlOptions[CURLOPT_TIMEOUT_MS] = Castle::getRequestTimeout(); - } - - // Merge user defined options. - $curlOptions = $userOptions + $curlOptions; + // Apply the configured request timeout to both connect and overall transfer. + $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = Castle::getRequestTimeout(); + $curlOptions[CURLOPT_TIMEOUT_MS] = Castle::getRequestTimeout(); curl_setopt_array($curl, $curlOptions); $this->setResponse($curl); diff --git a/lib/Castle/Errors.php b/lib/Castle/Errors.php index 39b21de..9fe5610 100755 --- a/lib/Castle/Errors.php +++ b/lib/Castle/Errors.php @@ -17,11 +17,6 @@ class ConfigurationError extends Error } -class CurlOptionError extends Error -{ - -} - class ApiError extends Error { public $type; diff --git a/lib/aliases.php b/lib/aliases.php index d12307e..f15aa36 100644 --- a/lib/aliases.php +++ b/lib/aliases.php @@ -33,7 +33,6 @@ function castle_legacy_alias_map() 'Castle_Error' => 'Castle\\Error', 'Castle_RequestError' => 'Castle\\RequestError', 'Castle_ConfigurationError' => 'Castle\\ConfigurationError', - 'Castle_CurlOptionError' => 'Castle\\CurlOptionError', 'Castle_ApiError' => 'Castle\\ApiError', 'Castle_InternalServerError' => 'Castle\\InternalServerError', 'Castle_BadRequest' => 'Castle\\BadRequest', @@ -75,7 +74,6 @@ class_alias($target, $class); 'Castle\\Error', 'Castle\\RequestError', 'Castle\\ConfigurationError', - 'Castle\\CurlOptionError', 'Castle\\ApiError', 'Castle\\InternalServerError', 'Castle\\BadRequest', diff --git a/test/RequestTest.php b/test/RequestTest.php index d19f2ac..cf4a1fb 100644 --- a/test/RequestTest.php +++ b/test/RequestTest.php @@ -8,7 +8,6 @@ public static function setUpBeforeClass(): void $_SERVER['HTTP_USER_AGENT'] = 'TestAgent'; $_SERVER['REMOTE_ADDR'] = '8.8.8.8'; Castle::setApiKey('secretkey'); - Castle::setCurlOpts(array()); Castle::setUseAllowlist(false); } @@ -23,18 +22,6 @@ public function tearDown(): void Castle_RequestTransport::setResponse(); } - public function testCastleCurlOptions() - { - // Will not throw. - Castle::setCurlOpts(array(CURLOPT_CONNECTTIMEOUT => 1, - CURLOPT_CONNECTTIMEOUT_MS => 1000, - CURLOPT_TIMEOUT => 1, - CURLOPT_TIMEOUT_MS => 1000)); - // Will throw. - $this->expectException(Castle_CurlOptionError::class); - Castle::setCurlOpts(array(CURLOPT_USERAGENT => "BadBrowser/6.6.6b")); - } - public function testInvalidResponse() { Castle_RequestTransport::setResponse(200, '{invalid');