diff --git a/.docs/README.md b/.docs/README.md
index 9974a42..58e3a84 100644
--- a/.docs/README.md
+++ b/.docs/README.md
@@ -3,30 +3,262 @@
## Content
- [Setup](#setup)
-- [Curl - simple http client (CurlExtension)](#curl)
-- [SAPI - fake request (CliRequestExtension)](#sapi)
+- [HTTP Client](#http-client)
+ - [IClient Interface](#iclient-interface)
+ - [CurlClient](#curlclient)
+ - [CurlBuilder](#curlbuilder)
+ - [FakeClient](#fakeclient)
+ - [Request & Response](#request--response)
+- [SAPI - fake request (SapiRequestExtension)](#sapi)
- [BasicAuth - simple basic authentication](#basic-authentication)
- [Useful classes](#useful-classes)
- [Url](#url)
## Setup
+**Requirements:** PHP 8.2+
+
```bash
composer require contributte/http
```
-## Curl
+## HTTP Client
+
+This package provides a simple HTTP client abstraction with cURL implementation.
+
+### IClient Interface
+
+The `IClient` interface defines a common contract for HTTP clients:
+
+```php
+use Contributte\Http\Client\IClient;
+use Contributte\Http\Client\Request;
+use Contributte\Http\Client\Response;
+
+interface IClient
+{
+ public function request(Request $request): Response;
+ public function get(string $url, array $headers = []): Response;
+ public function post(string $url, $body = null, array $headers = []): Response;
+ public function put(string $url, $body = null, array $headers = []): Response;
+ public function delete(string $url, array $headers = []): Response;
+}
+```
+
+### CurlClient
-There is a prepared simple cURL client in this package.
+The `CurlClient` is the default implementation using cURL.
-You have to register it at first.
+Register it via the DI extension:
```neon
extensions:
curl: Contributte\Http\DI\CurlExtension
```
-Extension registers [`Contributte\Http\Curl\CurlClient`](https://github.com/contributte/http/blob/master/src/Curl/CurlClient.php) as a service.
+Or use it directly:
+
+```php
+use Contributte\Http\Curl\CurlClient;
+
+$client = new CurlClient();
+
+// Simple GET request
+$response = $client->get('https://api.example.com/users');
+
+// POST with JSON body
+$response = $client->post('https://api.example.com/users', json_encode(['name' => 'John']));
+
+// Using Request object
+$request = new Request('https://api.example.com/users', Request::METHOD_POST);
+$request->setBody(json_encode(['name' => 'John']));
+$request->addHeader('Authorization', 'Bearer token');
+$response = $client->request($request);
+
+// Configure default headers
+$client->setDefaultHeaders([
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json',
+]);
+
+// Add a single default header
+$client->addDefaultHeader('X-Api-Key', 'your-api-key');
+```
+
+### CurlBuilder
+
+The `CurlBuilder` provides a fluent interface for building HTTP requests:
+
+```php
+use Contributte\Http\Curl\CurlBuilder;
+use Contributte\Http\Curl\CurlClient;
+
+$client = new CurlClient();
+
+// Simple GET request
+$request = CurlBuilder::create()
+ ->get('https://api.example.com/users')
+ ->build();
+$response = $client->request($request);
+
+// POST with JSON body
+$request = CurlBuilder::create()
+ ->post('https://api.example.com/users')
+ ->setJsonBody(['name' => 'John', 'email' => 'john@example.com'])
+ ->build();
+$response = $client->request($request);
+
+// With authentication
+$request = CurlBuilder::create()
+ ->get('https://api.example.com/protected')
+ ->setBearerToken('your-jwt-token')
+ ->build();
+
+// Or basic auth
+$request = CurlBuilder::create()
+ ->get('https://api.example.com/protected')
+ ->setBasicAuth('username', 'password')
+ ->build();
+
+// With custom headers and options
+$request = CurlBuilder::create()
+ ->post('https://api.example.com/upload')
+ ->addHeader('X-Custom', 'value')
+ ->setContentType('multipart/form-data')
+ ->setTimeout(60)
+ ->setFollowRedirects(true)
+ ->setSslVerify(true)
+ ->setUserAgent('MyApp/1.0')
+ ->build();
+
+// Form data
+$request = CurlBuilder::create()
+ ->post('https://example.com/form')
+ ->setFormBody(['username' => 'john', 'password' => 'secret'])
+ ->build();
+```
+
+Available builder methods:
+
+| Method | Description |
+|--------|-------------|
+| `get($url)` | Set GET method and URL |
+| `post($url)` | Set POST method and URL |
+| `put($url)` | Set PUT method and URL |
+| `delete($url)` | Set DELETE method and URL |
+| `patch($url)` | Set PATCH method and URL |
+| `head($url)` | Set HEAD method and URL |
+| `options($url)` | Set OPTIONS method and URL |
+| `addHeader($name, $value)` | Add a header |
+| `setHeaders($headers)` | Set all headers |
+| `setContentType($type)` | Set Content-Type header |
+| `setAccept($type)` | Set Accept header |
+| `setAuthorization($value)` | Set Authorization header |
+| `setBearerToken($token)` | Set Bearer token authentication |
+| `setBasicAuth($user, $pass)` | Set Basic authentication |
+| `setBody($body)` | Set raw body |
+| `setJsonBody($data)` | Set JSON body (auto-sets Content-Type) |
+| `setFormBody($data)` | Set form body (auto-sets Content-Type) |
+| `setTimeout($seconds)` | Set request timeout |
+| `setFollowRedirects($follow)` | Enable/disable redirect following |
+| `setSslVerify($verify)` | Enable/disable SSL verification |
+| `setUserAgent($agent)` | Set User-Agent header |
+| `setOption($key, $value)` | Set a cURL option |
+| `build()` | Build and return the Request object |
+
+### FakeClient
+
+The `FakeClient` is a test double for mocking HTTP requests in tests:
+
+```php
+use Contributte\Http\Client\FakeClient;
+use Contributte\Http\Client\Request;
+
+$client = new FakeClient();
+
+// Queue responses (FIFO)
+$client->respondWith('Hello World', 200);
+$client->respondWithJson(['status' => 'ok', 'data' => [1, 2, 3]]);
+$client->respondWithError('Connection failed', 500);
+
+// Make requests
+$response1 = $client->get('https://example.com'); // Returns "Hello World"
+$response2 = $client->get('https://example.com'); // Returns JSON response
+$response3 = $client->get('https://example.com'); // Returns error response
+
+// Record and inspect requests
+$client->get('https://api.example.com/users');
+$client->post('https://api.example.com/users', '{"name":"John"}');
+
+// Get all recorded requests
+$requests = $client->getRecordedRequests();
+
+// Get last request
+$lastRequest = $client->getLastRequest();
+echo $lastRequest->getUrl(); // https://api.example.com/users
+echo $lastRequest->getMethod(); // POST
+echo $lastRequest->getBody(); // {"name":"John"}
+
+// Assertions
+$client->assertRequestCount(2);
+$client->assertRequestMade('https://api.example.com/users');
+$client->assertRequestMade('https://api.example.com/users', Request::METHOD_POST);
+
+// Reset for next test
+$client->reset();
+```
+
+### Request & Response
+
+The `Request` class represents an HTTP request:
+
+```php
+use Contributte\Http\Client\Request;
+
+$request = new Request('https://api.example.com/users', Request::METHOD_POST);
+$request->setHeaders(['Content-Type' => 'application/json']);
+$request->addHeader('Authorization', 'Bearer token');
+$request->setBody(json_encode(['name' => 'John']));
+
+// Available methods
+$request->getUrl();
+$request->getMethod();
+$request->getHeaders();
+$request->getHeader('Content-Type');
+$request->hasHeader('Authorization');
+$request->getBody();
+$request->getOptions();
+```
+
+The `Response` class represents an HTTP response:
+
+```php
+use Contributte\Http\Client\Response;
+
+// After making a request
+$response = $client->get('https://api.example.com/users');
+
+// Body
+$body = $response->getBody();
+$jsonData = $response->getJsonBody(); // Decoded JSON
+$response->hasBody();
+
+// Status
+$statusCode = $response->getStatusCode();
+$response->isOk(); // Status is 200
+$response->isSuccess(); // Status is 2xx
+
+// Headers
+$response->getAllHeaders();
+$response->getHeader('Content-Type');
+$response->hasHeader('X-Custom');
+
+// Content type
+$response->isJson(); // Check if response is JSON
+
+// Errors
+$error = $response->getError();
+```
## SAPI
diff --git a/.github/workflows/codesniffer.yml b/.github/workflows/codesniffer.yml
new file mode 100644
index 0000000..d5ff803
--- /dev/null
+++ b/.github/workflows/codesniffer.yml
@@ -0,0 +1,18 @@
+name: "Codesniffer"
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+ push:
+ branches: ["*"]
+
+ schedule:
+ - cron: "0 8 * * 1"
+
+jobs:
+ codesniffer:
+ name: "Codesniffer"
+ uses: contributte/.github/.github/workflows/codesniffer.yml@master
+ with:
+ php: "8.3"
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
new file mode 100644
index 0000000..85f1300
--- /dev/null
+++ b/.github/workflows/coverage.yml
@@ -0,0 +1,18 @@
+name: "Coverage"
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+ push:
+ branches: ["*"]
+
+ schedule:
+ - cron: "0 8 * * 1"
+
+jobs:
+ coverage:
+ name: "Nette Tester"
+ uses: contributte/.github/.github/workflows/nette-tester-coverage-v2.yml@master
+ with:
+ php: "8.3"
diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml
deleted file mode 100644
index 7d17052..0000000
--- a/.github/workflows/main.yaml
+++ /dev/null
@@ -1,269 +0,0 @@
-name: "build"
-
-on:
- pull_request:
- paths-ignore:
- - ".docs/**"
- push:
- branches:
- - "*"
- schedule:
- - cron: "0 8 * * 1" # At 08:00 on Monday
-
-env:
- extensions: "json"
- cache-version: "1"
- composer-version: "v2"
- composer-install: "composer update --no-interaction --no-progress --prefer-dist --prefer-stable"
-
-jobs:
- qa:
- name: "Quality assurance"
- runs-on: "${{ matrix.operating-system }}"
-
- strategy:
- matrix:
- php-version: [ "7.4" ]
- operating-system: [ "ubuntu-latest" ]
- fail-fast: false
-
- steps:
- - name: "Checkout"
- uses: "actions/checkout@v2"
-
- - name: "Setup PHP cache environment"
- id: "extcache"
- uses: "shivammathur/cache-extensions@v1"
- with:
- php-version: "${{ matrix.php-version }}"
- extensions: "${{ env.extensions }}"
- key: "${{ env.cache-version }}"
-
- - name: "Cache PHP extensions"
- uses: "actions/cache@v2"
- with:
- path: "${{ steps.extcache.outputs.dir }}"
- key: "${{ steps.extcache.outputs.key }}"
- restore-keys: "${{ steps.extcache.outputs.key }}"
-
- - name: "Install PHP"
- uses: "shivammathur/setup-php@v2"
- with:
- php-version: "${{ matrix.php-version }}"
- extensions: "${{ env.extensions }}"
- tools: "composer:${{ env.composer-version }} "
-
- - name: "Setup problem matchers for PHP"
- run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"'
-
- - name: "Get Composer cache directory"
- id: "composercache"
- run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"'
-
- - name: "Cache PHP dependencies"
- uses: "actions/cache@v2"
- with:
- path: "${{ steps.composercache.outputs.dir }}"
- key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}"
- restore-keys: "${{ runner.os }}-composer-"
-
- - name: "Validate Composer"
- run: "composer validate"
-
- - name: "Install dependencies"
- run: "${{ env.composer-install }}"
-
- - name: "Coding Standard"
- run: "make cs"
-
- static-analysis:
- name: "Static analysis"
- runs-on: "${{ matrix.operating-system }}"
-
- strategy:
- matrix:
- php-version: [ "7.4" ]
- operating-system: [ "ubuntu-latest" ]
- fail-fast: false
-
- steps:
- - name: "Checkout"
- uses: "actions/checkout@v2"
-
- - name: "Setup PHP cache environment"
- id: "extcache"
- uses: "shivammathur/cache-extensions@v1"
- with:
- php-version: "${{ matrix.php-version }}"
- extensions: "${{ env.extensions }}"
- key: "${{ env.cache-version }}"
-
- - name: "Cache PHP extensions"
- uses: "actions/cache@v2"
- with:
- path: "${{ steps.extcache.outputs.dir }}"
- key: "${{ steps.extcache.outputs.key }}"
- restore-keys: "${{ steps.extcache.outputs.key }}"
-
- - name: "Install PHP"
- uses: "shivammathur/setup-php@v2"
- with:
- php-version: "${{ matrix.php-version }}"
- extensions: "${{ env.extensions }}"
- tools: "composer:${{ env.composer-version }} "
-
- - name: "Setup problem matchers for PHP"
- run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"'
-
- - name: "Get Composer cache directory"
- id: "composercache"
- run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"'
-
- - name: "Cache PHP dependencies"
- uses: "actions/cache@v2"
- with:
- path: "${{ steps.composercache.outputs.dir }}"
- key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}"
- restore-keys: "${{ runner.os }}-composer-"
-
- - name: "Install dependencies"
- run: "${{ env.composer-install }}"
-
- - name: "PHPStan"
- run: "make phpstan"
-
- tests:
- name: "Tests"
- runs-on: "${{ matrix.operating-system }}"
-
- strategy:
- matrix:
- php-version: [ "7.2", "7.3", "7.4" ]
- operating-system: [ "ubuntu-latest" ]
- composer-args: [ "" ]
- include:
- - php-version: "7.2"
- operating-system: "ubuntu-latest"
- composer-args: "--prefer-lowest"
- - php-version: "8.0"
- operating-system: "ubuntu-latest"
- composer-args: ""
- fail-fast: false
-
- continue-on-error: "${{ matrix.php-version == '8.0' }}"
-
- steps:
- - name: "Checkout"
- uses: "actions/checkout@v2"
-
- - name: "Setup PHP cache environment"
- id: "extcache"
- uses: "shivammathur/cache-extensions@v1"
- with:
- php-version: "${{ matrix.php-version }}"
- extensions: "${{ env.extensions }}"
- key: "${{ env.cache-version }}"
-
- - name: "Cache PHP extensions"
- uses: "actions/cache@v2"
- with:
- path: "${{ steps.extcache.outputs.dir }}"
- key: "${{ steps.extcache.outputs.key }}"
- restore-keys: "${{ steps.extcache.outputs.key }}"
-
- - name: "Install PHP"
- uses: "shivammathur/setup-php@v2"
- with:
- php-version: "${{ matrix.php-version }}"
- extensions: "${{ env.extensions }}"
- tools: "composer:${{ env.composer-version }} "
-
- - name: "Setup problem matchers for PHP"
- run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"'
-
- - name: "Get Composer cache directory"
- id: "composercache"
- run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"'
-
- - name: "Cache PHP dependencies"
- uses: "actions/cache@v2"
- with:
- path: "${{ steps.composercache.outputs.dir }}"
- key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}"
- restore-keys: "${{ runner.os }}-composer-"
-
- - name: "Install dependencies"
- run: "${{ env.composer-install }} ${{ matrix.composer-args }}"
-
- - name: "Setup problem matchers for PHPUnit"
- run: 'echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"'
-
- - name: "Tests"
- run: "make tests"
-
- tests-code-coverage:
- name: "Tests with code coverage"
- runs-on: "${{ matrix.operating-system }}"
-
- strategy:
- matrix:
- php-version: [ "7.4" ]
- operating-system: [ "ubuntu-latest" ]
- fail-fast: false
-
- if: "github.event_name == 'push'"
-
- steps:
- - name: "Checkout"
- uses: "actions/checkout@v2"
-
- - name: "Setup PHP cache environment"
- id: "extcache"
- uses: "shivammathur/cache-extensions@v1"
- with:
- php-version: "${{ matrix.php-version }}"
- extensions: "${{ env.extensions }}"
- key: "${{ env.cache-version }}"
-
- - name: "Cache PHP extensions"
- uses: "actions/cache@v2"
- with:
- path: "${{ steps.extcache.outputs.dir }}"
- key: "${{ steps.extcache.outputs.key }}"
- restore-keys: "${{ steps.extcache.outputs.key }}"
-
- - name: "Install PHP"
- uses: "shivammathur/setup-php@v2"
- with:
- php-version: "${{ matrix.php-version }}"
- extensions: "${{ env.extensions }}"
- tools: "composer:${{ env.composer-version }} "
-
- - name: "Setup problem matchers for PHP"
- run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"'
-
- - name: "Get Composer cache directory"
- id: "composercache"
- run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"'
-
- - name: "Cache PHP dependencies"
- uses: "actions/cache@v2"
- with:
- path: "${{ steps.composercache.outputs.dir }}"
- key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}"
- restore-keys: "${{ runner.os }}-composer-"
-
- - name: "Install dependencies"
- run: "${{ env.composer-install }}"
-
- - name: "Tests"
- run: "make coverage-clover"
-
- - name: "Coveralls.io"
- env:
- CI_NAME: github
- CI: true
- COVERALLS_REPO_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- run: |
- wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.1.0/php-coveralls.phar
- php php-coveralls.phar --verbose --config tests/.coveralls.yml
diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml
new file mode 100644
index 0000000..9827fdd
--- /dev/null
+++ b/.github/workflows/phpstan.yml
@@ -0,0 +1,18 @@
+name: "Phpstan"
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+ push:
+ branches: ["*"]
+
+ schedule:
+ - cron: "0 8 * * 1"
+
+jobs:
+ phpstan:
+ name: "Phpstan"
+ uses: contributte/.github/.github/workflows/phpstan.yml@master
+ with:
+ php: "8.3"
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..e4b0ffb
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,36 @@
+name: "Tests"
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+ push:
+ branches: ["*"]
+
+ schedule:
+ - cron: "0 8 * * 1"
+
+jobs:
+ tests-82:
+ name: "Nette Tester (PHP 8.2)"
+ uses: contributte/.github/.github/workflows/nette-tester.yml@master
+ with:
+ php: "8.2"
+
+ tests-83:
+ name: "Nette Tester (PHP 8.3)"
+ uses: contributte/.github/.github/workflows/nette-tester.yml@master
+ with:
+ php: "8.3"
+
+ tests-84:
+ name: "Nette Tester (PHP 8.4)"
+ uses: contributte/.github/.github/workflows/nette-tester.yml@master
+ with:
+ php: "8.4"
+
+ tests-85:
+ name: "Nette Tester (PHP 8.5)"
+ uses: contributte/.github/.github/workflows/nette-tester.yml@master
+ with:
+ php: "8.5"
diff --git a/Makefile b/Makefile
index 7666651..8f5528e 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: install qa cs csf phpstan tests coverage-clover coverage-html
+.PHONY: install qa cs csf phpstan tests coverage
install:
composer update
@@ -6,19 +6,24 @@ install:
qa: phpstan cs
cs:
- vendor/bin/codesniffer src tests
+ifdef GITHUB_ACTION
+ vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --extensions="php,phpt" --colors -nsp -q --report=checkstyle src tests | cs2pr
+else
+ vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --extensions="php,phpt" --colors -nsp src tests
+endif
csf:
- vendor/bin/codefixer src tests
+ vendor/bin/phpcbf --standard=ruleset.xml --encoding=utf-8 --extensions="php,phpt" --colors -nsp src tests
phpstan:
- vendor/bin/phpstan analyse -l 8 -c phpstan.neon src
+ vendor/bin/phpstan analyse -c phpstan.neon
tests:
vendor/bin/tester -s -p php --colors 1 -C tests/cases
-coverage-clover:
+coverage:
+ifdef GITHUB_ACTION
vendor/bin/tester -s -p phpdbg --colors 1 -C --coverage ./coverage.xml --coverage-src ./src tests/cases
-
-coverage-html:
+else
vendor/bin/tester -s -p phpdbg --colors 1 -C --coverage ./coverage.html --coverage-src ./src tests/cases
+endif
diff --git a/composer.json b/composer.json
index dc1bafa..c0e8333 100644
--- a/composer.json
+++ b/composer.json
@@ -19,18 +19,15 @@
}
],
"require": {
- "php": ">=7.2",
- "nette/http": "^3.0.1"
+ "php": ">=8.2",
+ "nette/http": "^3.3"
},
"require-dev": {
- "nette/di": "^3.0.0",
+ "contributte/phpstan": "^0.1",
+ "contributte/qa": "^0.4",
+ "nette/di": "^3.2",
"ninjify/nunjuck": "^0.4",
- "ninjify/qa": "^0.12",
- "phpstan/phpstan": "^1.0",
- "phpstan/phpstan-deprecation-rules": "^1.0",
- "phpstan/phpstan-nette": "^1.0",
- "phpstan/phpstan-strict-rules": "^1.0",
- "tracy/tracy": "^2.5.1"
+ "tracy/tracy": "^2.10"
},
"suggest": {
"nette/di": "to use CompilerExtensions"
@@ -50,7 +47,7 @@
},
"extra": {
"branch-alias": {
- "dev-master": "0.5.x-dev"
+ "dev-master": "0.6.x-dev"
}
}
}
diff --git a/phpstan.neon b/phpstan.neon
index abfafe5..16b3197 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,9 +1,18 @@
includes:
- - vendor/phpstan/phpstan-deprecation-rules/rules.neon
- - vendor/phpstan/phpstan-nette/extension.neon
- - vendor/phpstan/phpstan-nette/rules.neon
- - vendor/phpstan/phpstan-strict-rules/rules.neon
+ - vendor/contributte/phpstan/phpstan.neon
parameters:
+ level: 9
+ phpVersion: 80200
+
+ scanDirectories:
+ - src
+
+ fileExtensions:
+ - php
+
+ paths:
+ - src
+ - .docs
+
ignoreErrors:
- - '#Parameter \#1 \$body of method Contributte\\Http\\Curl\\ResponseFactory::setBody\(\) expects string, bool\|string given.#'
diff --git a/ruleset.xml b/ruleset.xml
index 72db73f..e7854ad 100644
--- a/ruleset.xml
+++ b/ruleset.xml
@@ -1,20 +1,18 @@
-
-
-
+
-
+
+
+
+
-
+
/tests/tmp
-
diff --git a/src/Auth/BasicAuthenticator.php b/src/Auth/BasicAuthenticator.php
index 4e56d5a..5549082 100644
--- a/src/Auth/BasicAuthenticator.php
+++ b/src/Auth/BasicAuthenticator.php
@@ -9,11 +9,10 @@
class BasicAuthenticator
{
- /** @var string */
- private $title;
+ private string $title;
- /** @var mixed[] */
- private $users = [];
+ /** @var array */
+ private array $users = [];
public function __construct(string $title)
{
@@ -26,13 +25,13 @@ public function addUser(string $user, string $password, bool $unsecured): self
'password' => $password,
'unsecured' => $unsecured,
];
+
return $this;
}
public function authenticate(IRequest $request, IResponse $response): void
{
- $user = $request->getUrl()->getUser();
- $password = $request->getUrl()->getPassword();
+ [$user, $password] = $this->parseBasicAuth($request);
if (!$this->auth($user, $password)) {
if (class_exists(Debugger::class)) {
@@ -40,7 +39,7 @@ public function authenticate(IRequest $request, IResponse $response): void
}
$response->setHeader('WWW-Authenticate', sprintf('Basic realm="%s"', $this->title));
- $response->setCode(IResponse::S401_UNAUTHORIZED);
+ $response->setCode(IResponse::S401_Unauthorized);
echo 'Authentication failed.
';
die;
@@ -49,18 +48,36 @@ public function authenticate(IRequest $request, IResponse $response): void
protected function auth(string $user, string $password): bool
{
- if (!isset($this->users[$user])) {
+ if ($user === '' || !isset($this->users[$user])) {
return false;
}
- if (
- ($this->users[$user]['unsecured'] === true && !hash_equals($password, $this->users[$user]['password'])) ||
- ($this->users[$user]['unsecured'] === false && !password_verify($password, $this->users[$user]['password']))
- ) {
+ $userData = $this->users[$user];
+ if ($userData['unsecured'] && !hash_equals($password, $userData['password'])) {
return false;
}
- return true;
+ return $userData['unsecured'] || password_verify($password, $userData['password']);
+ }
+
+ /**
+ * Parse Basic auth credentials from request
+ *
+ * @return array{string, string}
+ */
+ private function parseBasicAuth(IRequest $request): array
+ {
+ $header = $request->getHeader('Authorization');
+ if ($header !== null && str_starts_with($header, 'Basic ')) {
+ $credentials = base64_decode(substr($header, 6), true);
+ if ($credentials !== false && str_contains($credentials, ':')) {
+ $parts = explode(':', $credentials, 2);
+
+ return [$parts[0], $parts[1]];
+ }
+ }
+
+ return ['', ''];
}
}
diff --git a/src/Client/FakeClient.php b/src/Client/FakeClient.php
new file mode 100644
index 0000000..f2f1808
--- /dev/null
+++ b/src/Client/FakeClient.php
@@ -0,0 +1,255 @@
+defaultResponse = new Response('', [], ['http_code' => 200]);
+ }
+
+ /**
+ * Add a response to the queue (FIFO)
+ */
+ public function addResponse(Response $response): self
+ {
+ $this->responses[] = $response;
+
+ return $this;
+ }
+
+ /**
+ * Set the default response when queue is empty
+ */
+ public function setDefaultResponse(Response $response): self
+ {
+ $this->defaultResponse = $response;
+
+ return $this;
+ }
+
+ /**
+ * Create and add a simple response
+ *
+ * @param string[] $headers
+ * @param mixed[] $info
+ */
+ public function respondWith(string $body, int $statusCode = 200, array $headers = [], array $info = []): self
+ {
+ $info['http_code'] = $statusCode;
+ $info['status_code'] = $statusCode;
+ $this->responses[] = new Response($body, $headers, $info);
+
+ return $this;
+ }
+
+ /**
+ * Create and add a JSON response
+ *
+ * @param mixed[] $data
+ */
+ public function respondWithJson(array $data, int $statusCode = 200): self
+ {
+ $body = json_encode($data);
+ $headers = ['Content-Type' => 'application/json'];
+ $info = ['http_code' => $statusCode, 'content_type' => 'application/json'];
+ $this->responses[] = new Response($body, $headers, $info);
+
+ return $this;
+ }
+
+ /**
+ * Create and add an error response
+ */
+ public function respondWithError(string $error, int $statusCode = 500): self
+ {
+ $response = new Response('', [], ['http_code' => $statusCode]);
+ $response->setError($error);
+ $this->responses[] = $response;
+
+ return $this;
+ }
+
+ /**
+ * Enable or disable request recording
+ */
+ public function setRecordRequests(bool $record): self
+ {
+ $this->recordRequests = $record;
+
+ return $this;
+ }
+
+ /**
+ * Get all recorded requests
+ *
+ * @return Request[]
+ */
+ public function getRecordedRequests(): array
+ {
+ return $this->recordedRequests;
+ }
+
+ /**
+ * Get the last recorded request
+ */
+ public function getLastRequest(): ?Request
+ {
+ if (count($this->recordedRequests) === 0) {
+ return null;
+ }
+
+ return $this->recordedRequests[count($this->recordedRequests) - 1];
+ }
+
+ /**
+ * Get the number of recorded requests
+ */
+ public function getRequestCount(): int
+ {
+ return count($this->recordedRequests);
+ }
+
+ /**
+ * Clear all recorded requests
+ */
+ public function clearRecordedRequests(): self
+ {
+ $this->recordedRequests = [];
+
+ return $this;
+ }
+
+ /**
+ * Clear all queued responses
+ */
+ public function clearResponses(): self
+ {
+ $this->responses = [];
+
+ return $this;
+ }
+
+ /**
+ * Reset the client (clear responses and recorded requests)
+ */
+ public function reset(): self
+ {
+ $this->responses = [];
+ $this->recordedRequests = [];
+
+ return $this;
+ }
+
+ /**
+ * Assert that a specific number of requests were made
+ */
+ public function assertRequestCount(int $expected): bool
+ {
+ return count($this->recordedRequests) === $expected;
+ }
+
+ /**
+ * Assert that a request was made to a specific URL
+ */
+ public function assertRequestMade(string $url, ?string $method = null): bool
+ {
+ foreach ($this->recordedRequests as $request) {
+ if ($request->getUrl() === $url) {
+ if ($method === null || $request->getMethod() === $method) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Execute a Request object
+ */
+ public function request(Request $request): Response
+ {
+ if ($this->recordRequests) {
+ $this->recordedRequests[] = $request;
+ }
+
+ if (count($this->responses) > 0) {
+ return array_shift($this->responses);
+ }
+
+ return $this->defaultResponse;
+ }
+
+ /**
+ * Convenience method for GET requests
+ *
+ * @param string[] $headers
+ */
+ public function get(string $url, array $headers = []): Response
+ {
+ $request = new Request($url, Request::METHOD_GET);
+ $request->setHeaders($headers);
+
+ return $this->request($request);
+ }
+
+ /**
+ * Convenience method for POST requests
+ *
+ * @param string[] $headers
+ */
+ public function post(string $url, mixed $body = null, array $headers = []): Response
+ {
+ $request = new Request($url, Request::METHOD_POST);
+ $request->setHeaders($headers);
+ $request->setBody($body);
+
+ return $this->request($request);
+ }
+
+ /**
+ * Convenience method for PUT requests
+ *
+ * @param string[] $headers
+ */
+ public function put(string $url, mixed $body = null, array $headers = []): Response
+ {
+ $request = new Request($url, Request::METHOD_PUT);
+ $request->setHeaders($headers);
+ $request->setBody($body);
+
+ return $this->request($request);
+ }
+
+ /**
+ * Convenience method for DELETE requests
+ *
+ * @param string[] $headers
+ */
+ public function delete(string $url, array $headers = []): Response
+ {
+ $request = new Request($url, Request::METHOD_DELETE);
+ $request->setHeaders($headers);
+
+ return $this->request($request);
+ }
+
+}
diff --git a/src/Client/IClient.php b/src/Client/IClient.php
new file mode 100644
index 0000000..c5c49d6
--- /dev/null
+++ b/src/Client/IClient.php
@@ -0,0 +1,44 @@
+url = $url;
+ $this->method = $method;
+ }
+
+ public function getUrl(): string
+ {
+ return $this->url;
+ }
+
+ public function setUrl(string $url): self
+ {
+ $this->url = $url;
+
+ return $this;
+ }
+
+ public function getMethod(): string
+ {
+ return $this->method;
+ }
+
+ public function setMethod(string $method): self
+ {
+ $this->method = $method;
+
+ return $this;
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getHeaders(): array
+ {
+ return $this->headers;
+ }
+
+ /**
+ * @param string[] $headers
+ */
+ public function setHeaders(array $headers): self
+ {
+ $this->headers = $headers;
+
+ return $this;
+ }
+
+ public function addHeader(string $name, string $value): self
+ {
+ $this->headers[$name] = $value;
+
+ return $this;
+ }
+
+ public function hasHeader(string $name): bool
+ {
+ return isset($this->headers[$name]);
+ }
+
+ public function getHeader(string $name): ?string
+ {
+ return $this->headers[$name] ?? null;
+ }
+
+ public function getBody(): mixed
+ {
+ return $this->body;
+ }
+
+ public function setBody(mixed $body): self
+ {
+ $this->body = $body;
+
+ return $this;
+ }
+
+ /**
+ * @return mixed[]
+ */
+ public function getOptions(): array
+ {
+ return $this->options;
+ }
+
+ /**
+ * @param mixed[] $options
+ */
+ public function setOptions(array $options): self
+ {
+ $this->options = $options;
+
+ return $this;
+ }
+
+ public function setOption(string $key, mixed $value): self
+ {
+ $this->options[$key] = $value;
+
+ return $this;
+ }
+
+ /**
+ * @param int $key CURLOPT_* constant
+ */
+ public function setCurlOption(int $key, mixed $value): self
+ {
+ $this->options[$key] = $value;
+
+ return $this;
+ }
+
+}
diff --git a/src/Curl/Response.php b/src/Client/Response.php
similarity index 51%
rename from src/Curl/Response.php
rename to src/Client/Response.php
index 2220fd5..88624cd 100644
--- a/src/Curl/Response.php
+++ b/src/Client/Response.php
@@ -1,28 +1,28 @@
body = $body;
$this->headers = $headers;
@@ -42,10 +42,7 @@ public function hasInfo(string $key): bool
return isset($this->info[$key]);
}
- /**
- * @return mixed
- */
- public function getInfo(string $key)
+ public function getInfo(string $key): mixed
{
if ($this->hasInfo($key)) {
return $this->info[$key];
@@ -76,10 +73,7 @@ public function getHeader(string $key): ?string
return null;
}
- /**
- * @return mixed
- */
- public function getBody()
+ public function getBody(): mixed
{
return $this->body;
}
@@ -91,23 +85,37 @@ public function hasBody(): bool
public function isJson(): bool
{
- return $this->getInfo('content_type') === 'application/json';
+ $contentType = $this->getInfo('content_type');
+ if ($contentType === null) {
+ $contentType = $this->getHeader('Content-Type');
+ }
+
+ return is_string($contentType) && str_contains($contentType, 'application/json');
}
- /**
- * @return mixed
- */
- public function getJsonBody()
+ public function getJsonBody(): mixed
{
- $body = $this->getBody();
- if ($body === null) return null;
+ $body = $this->getBody();
+ if (!is_string($body) || $body === '') {
+ return null;
+ }
- return @json_decode((string) $this->getBody(), true);
+ return json_decode($body, true);
}
public function getStatusCode(): int
{
- return $this->getInfo('http_code') ?? 0;
+ $httpCode = $this->getInfo('http_code');
+ if (is_int($httpCode)) {
+ return $httpCode;
+ }
+
+ $statusCode = $this->getInfo('status_code');
+ if (is_int($statusCode)) {
+ return $statusCode;
+ }
+
+ return 0;
}
public function isOk(): bool
@@ -115,18 +123,19 @@ public function isOk(): bool
return $this->getStatusCode() === 200;
}
- /**
- * @return mixed
- */
- public function getError()
+ public function isSuccess(): bool
+ {
+ $code = $this->getStatusCode();
+
+ return $code >= 200 && $code < 300;
+ }
+
+ public function getError(): mixed
{
return $this->error;
}
- /**
- * @param mixed $error
- */
- public function setError($error): void
+ public function setError(mixed $error): void
{
$this->error = $error;
}
diff --git a/src/Curl/CurlBuilder.php b/src/Curl/CurlBuilder.php
new file mode 100644
index 0000000..e9d543c
--- /dev/null
+++ b/src/Curl/CurlBuilder.php
@@ -0,0 +1,292 @@
+url = $url;
+
+ return $this;
+ }
+
+ public function get(string $url): self
+ {
+ $this->method = Request::METHOD_GET;
+ $this->url = $url;
+
+ return $this;
+ }
+
+ public function post(string $url): self
+ {
+ $this->method = Request::METHOD_POST;
+ $this->url = $url;
+
+ return $this;
+ }
+
+ public function put(string $url): self
+ {
+ $this->method = Request::METHOD_PUT;
+ $this->url = $url;
+
+ return $this;
+ }
+
+ public function delete(string $url): self
+ {
+ $this->method = Request::METHOD_DELETE;
+ $this->url = $url;
+
+ return $this;
+ }
+
+ public function patch(string $url): self
+ {
+ $this->method = Request::METHOD_PATCH;
+ $this->url = $url;
+
+ return $this;
+ }
+
+ public function head(string $url): self
+ {
+ $this->method = Request::METHOD_HEAD;
+ $this->url = $url;
+
+ return $this;
+ }
+
+ public function options(string $url): self
+ {
+ $this->method = Request::METHOD_OPTIONS;
+ $this->url = $url;
+
+ return $this;
+ }
+
+ public function setMethod(string $method): self
+ {
+ $this->method = $method;
+
+ return $this;
+ }
+
+ public function addHeader(string $name, string $value): self
+ {
+ $this->headers[$name] = $value;
+
+ return $this;
+ }
+
+ /**
+ * @param string[] $headers
+ */
+ public function setHeaders(array $headers): self
+ {
+ $this->headers = $headers;
+
+ return $this;
+ }
+
+ public function setContentType(string $contentType): self
+ {
+ $this->headers['Content-Type'] = $contentType;
+
+ return $this;
+ }
+
+ public function setAccept(string $accept): self
+ {
+ $this->headers['Accept'] = $accept;
+
+ return $this;
+ }
+
+ public function setAuthorization(string $authorization): self
+ {
+ $this->headers['Authorization'] = $authorization;
+
+ return $this;
+ }
+
+ public function setBearerToken(string $token): self
+ {
+ $this->headers['Authorization'] = 'Bearer ' . $token;
+
+ return $this;
+ }
+
+ public function setBasicAuth(string $username, string $password): self
+ {
+ $this->headers['Authorization'] = 'Basic ' . base64_encode($username . ':' . $password);
+
+ return $this;
+ }
+
+ public function setBody(mixed $body): self
+ {
+ $this->body = $body;
+
+ return $this;
+ }
+
+ /**
+ * @param mixed[] $data
+ */
+ public function setJsonBody(array $data): self
+ {
+ $this->body = json_encode($data);
+ $this->headers['Content-Type'] = 'application/json';
+
+ return $this;
+ }
+
+ /**
+ * @param mixed[] $data
+ */
+ public function setFormBody(array $data): self
+ {
+ $this->body = http_build_query($data);
+ $this->headers['Content-Type'] = 'application/x-www-form-urlencoded';
+
+ return $this;
+ }
+
+ /**
+ * @param int $key CURLOPT_* constant
+ */
+ public function setOption(int $key, mixed $value): self
+ {
+ $this->options[$key] = $value;
+
+ return $this;
+ }
+
+ /**
+ * @param mixed[] $options
+ */
+ public function setOptions(array $options): self
+ {
+ $this->options = $options;
+
+ return $this;
+ }
+
+ public function setTimeout(int $seconds): self
+ {
+ $this->timeout = $seconds;
+
+ return $this;
+ }
+
+ public function setFollowRedirects(bool $follow): self
+ {
+ $this->followRedirects = $follow;
+
+ return $this;
+ }
+
+ public function setSslVerify(bool $verify): self
+ {
+ $this->sslVerify = $verify;
+
+ return $this;
+ }
+
+ public function setUserAgent(string $userAgent): self
+ {
+ $this->userAgent = $userAgent;
+
+ return $this;
+ }
+
+ public function build(): Request
+ {
+ $request = new Request($this->url, $this->method);
+ $request->setHeaders($this->headers);
+ $request->setBody($this->body);
+
+ $options = $this->options;
+
+ if ($this->timeout !== null) {
+ $options[CURLOPT_TIMEOUT] = $this->timeout;
+ }
+
+ $options[CURLOPT_FOLLOWLOCATION] = $this->followRedirects ? 1 : 0;
+ $options[CURLOPT_SSL_VERIFYPEER] = $this->sslVerify ? 1 : 0;
+
+ if ($this->userAgent !== null) {
+ $options[CURLOPT_USERAGENT] = $this->userAgent;
+ }
+
+ $request->setOptions($options);
+
+ return $request;
+ }
+
+ public function getUrl(): string
+ {
+ return $this->url;
+ }
+
+ public function getMethod(): string
+ {
+ return $this->method;
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getHeaders(): array
+ {
+ return $this->headers;
+ }
+
+ public function getBody(): mixed
+ {
+ return $this->body;
+ }
+
+ /**
+ * @return mixed[]
+ */
+ public function getOptions(): array
+ {
+ return $this->options;
+ }
+
+}
diff --git a/src/Curl/CurlClient.php b/src/Curl/CurlClient.php
index f323dd1..449be9f 100644
--- a/src/Curl/CurlClient.php
+++ b/src/Curl/CurlClient.php
@@ -2,11 +2,15 @@
namespace Contributte\Http\Curl;
-class CurlClient implements ICurlClient
+use Contributte\Http\Client\IClient;
+use Contributte\Http\Client\Request;
+use Contributte\Http\Client\Response;
+
+class CurlClient implements IClient
{
/** @var mixed[] */
- private $options = [
+ private array $options = [
CURLOPT_USERAGENT => 'Contributte',
CURLOPT_FOLLOWLOCATION => 1,
CURLOPT_SSL_VERIFYPEER => 1,
@@ -14,26 +18,156 @@ class CurlClient implements ICurlClient
];
/** @var string[] */
- private $headers = [
- 'Content-type' => 'application/json',
+ private array $headers = [
+ 'Content-Type' => 'application/json',
'Time-Zone' => 'Europe/Prague',
];
/**
+ * Set default headers for all requests
+ *
+ * @param string[] $headers
+ */
+ public function setDefaultHeaders(array $headers): self
+ {
+ $this->headers = $headers;
+
+ return $this;
+ }
+
+ /**
+ * Add a default header for all requests
+ */
+ public function addDefaultHeader(string $name, string $value): self
+ {
+ $this->headers[$name] = $value;
+
+ return $this;
+ }
+
+ /**
+ * Set default cURL options for all requests
+ *
+ * @param mixed[] $options
+ */
+ public function setDefaultOptions(array $options): self
+ {
+ $this->options = $options;
+
+ return $this;
+ }
+
+ /**
+ * Execute a Request object
+ */
+ public function request(Request $request): Response
+ {
+ $method = $request->getMethod();
+ $opts = $request->getOptions();
+
+ // Set HTTP method
+ switch ($method) {
+ case Request::METHOD_POST:
+ $opts[CURLOPT_POST] = true;
+ break;
+ case Request::METHOD_PUT:
+ $opts[CURLOPT_CUSTOMREQUEST] = 'PUT';
+ break;
+ case Request::METHOD_DELETE:
+ $opts[CURLOPT_CUSTOMREQUEST] = 'DELETE';
+ break;
+ case Request::METHOD_PATCH:
+ $opts[CURLOPT_CUSTOMREQUEST] = 'PATCH';
+ break;
+ case Request::METHOD_HEAD:
+ $opts[CURLOPT_NOBODY] = true;
+ break;
+ case Request::METHOD_OPTIONS:
+ $opts[CURLOPT_CUSTOMREQUEST] = 'OPTIONS';
+ break;
+ }
+
+ // Set body for POST/PUT/PATCH
+ $body = $request->getBody();
+ if ($body !== null) {
+ $opts[CURLOPT_POSTFIELDS] = $body;
+ }
+
+ return $this->execute($request->getUrl(), $request->getHeaders(), $opts);
+ }
+
+ /**
+ * Convenience method for GET requests
+ *
+ * @param string[] $headers
+ */
+ public function get(string $url, array $headers = []): Response
+ {
+ $request = new Request($url, Request::METHOD_GET);
+ $request->setHeaders($headers);
+
+ return $this->request($request);
+ }
+
+ /**
+ * Convenience method for POST requests
+ *
+ * @param string[] $headers
+ */
+ public function post(string $url, mixed $body = null, array $headers = []): Response
+ {
+ $request = new Request($url, Request::METHOD_POST);
+ $request->setHeaders($headers);
+ $request->setBody($body);
+
+ return $this->request($request);
+ }
+
+ /**
+ * Convenience method for PUT requests
+ *
+ * @param string[] $headers
+ */
+ public function put(string $url, mixed $body = null, array $headers = []): Response
+ {
+ $request = new Request($url, Request::METHOD_PUT);
+ $request->setHeaders($headers);
+ $request->setBody($body);
+
+ return $this->request($request);
+ }
+
+ /**
+ * Convenience method for DELETE requests
+ *
+ * @param string[] $headers
+ */
+ public function delete(string $url, array $headers = []): Response
+ {
+ $request = new Request($url, Request::METHOD_DELETE);
+ $request->setHeaders($headers);
+
+ return $this->request($request);
+ }
+
+ /**
+ * Execute the cURL request
+ *
* @param string[] $headers
* @param mixed[] $opts
*/
- public function makeRequest(string $url, array $headers = [], array $opts = []): Response
+ private function execute(string $url, array $headers = [], array $opts = []): Response
{
$ch = curl_init();
$responseFactory = new ResponseFactory();
// Set-up headers
$_headers = array_merge($this->headers, $headers);
- array_walk($_headers, function (&$value, $key): void {
- $value = sprintf('%s: %s', $key, $value);
- });
- $_headers = array_values($_headers);
+ $_headers = array_map(
+ static fn (string $key, string $value): string => sprintf('%s: %s', $key, $value),
+ array_keys($_headers),
+ array_values($_headers),
+ );
// Set-up cURL options
$_opts = [
@@ -47,6 +181,9 @@ public function makeRequest(string $url, array $headers = [], array $opts = []):
// Make request
$result = curl_exec($ch);
+ // Check for errors
+ $error = curl_error($ch);
+
// Store information about request/response
$responseFactory->setInfo(curl_getinfo($ch));
@@ -54,9 +191,17 @@ public function makeRequest(string $url, array $headers = [], array $opts = []):
curl_close($ch);
// Store response
- $responseFactory->setBody($result);
+ if (is_string($result)) {
+ $responseFactory->setBody($result);
+ }
+
+ $response = $responseFactory->create();
+
+ if ($error !== '') {
+ $response->setError($error);
+ }
- return $responseFactory->create();
+ return $response;
}
}
diff --git a/src/Curl/ICurlClient.php b/src/Curl/ICurlClient.php
deleted file mode 100644
index da17e34..0000000
--- a/src/Curl/ICurlClient.php
+++ /dev/null
@@ -1,14 +0,0 @@
-body = $body;
}
- /**
- * @param mixed $handle
- */
- public function parseHeaders($handle, string $header): int
+ public function parseHeaders(mixed $handle, string $header): int
{
preg_match('#^(.+):(.+)$#U', $header, $matches);
- if ($matches) {
+ if ($matches !== []) {
[, $key, $value] = $matches;
$this->headers[trim($key)] = trim($value);
}
diff --git a/src/DI/BasicAuthExtension.php b/src/DI/BasicAuthExtension.php
index b1d9fab..cfcef66 100644
--- a/src/DI/BasicAuthExtension.php
+++ b/src/DI/BasicAuthExtension.php
@@ -57,7 +57,7 @@ public function afterCompile(ClassType $class): void
return;
}
- $initialize = $class->methods['initialize'];
+ $initialize = $class->getMethod('initialize');
$initialize->addBody('$this->getService(?)->authenticate($this->getByType(?), $this->getByType(?));', [
$this->prefix('authenticator'),
IRequest::class,
diff --git a/src/DI/SapiRequestExtension.php b/src/DI/SapiRequestExtension.php
index 4ab9c3b..f582645 100755
--- a/src/DI/SapiRequestExtension.php
+++ b/src/DI/SapiRequestExtension.php
@@ -3,8 +3,8 @@
namespace Contributte\Http\DI;
use Nette\DI\CompilerExtension;
+use Nette\DI\Definitions\ServiceDefinition;
use Nette\DI\Definitions\Statement;
-use Nette\DI\ServiceDefinition;
use Nette\Http\Request;
use Nette\Http\UrlScript;
use Nette\Schema\Expect;
@@ -58,7 +58,7 @@ public function beforeCompile(): void
$config->files,
$config->cookies,
$config->headers,
- $config->method,
+ $config->method ?? 'GET',
$config->remoteAddress,
$config->remoteHost,
$config->rawBodyCallback,
diff --git a/tests/.coveralls.yml b/tests/.coveralls.yml
deleted file mode 100644
index 403f48b..0000000
--- a/tests/.coveralls.yml
+++ /dev/null
@@ -1,4 +0,0 @@
-# for php-coveralls
-service_name: github-actions
-coverage_clover: coverage.xml
-json_path: coverage.json
\ No newline at end of file
diff --git a/tests/cases/Client/FakeClient.phpt b/tests/cases/Client/FakeClient.phpt
new file mode 100644
index 0000000..7873a6c
--- /dev/null
+++ b/tests/cases/Client/FakeClient.phpt
@@ -0,0 +1,143 @@
+get('https://example.com');
+
+ Assert::equal(200, $response->getStatusCode());
+ Assert::equal('', $response->getBody());
+});
+
+// Test: Custom response queue
+test(static function (): void {
+ $client = new FakeClient();
+ $client->respondWith('Hello World', 200);
+ $client->respondWith('Not Found', 404);
+
+ $response1 = $client->get('https://example.com/1');
+ $response2 = $client->get('https://example.com/2');
+
+ Assert::equal('Hello World', $response1->getBody());
+ Assert::equal(200, $response1->getStatusCode());
+ Assert::equal('Not Found', $response2->getBody());
+ Assert::equal(404, $response2->getStatusCode());
+});
+
+// Test: JSON response
+test(static function (): void {
+ $client = new FakeClient();
+ $client->respondWithJson(['status' => 'ok', 'data' => [1, 2, 3]]);
+
+ $response = $client->get('https://api.example.com');
+
+ Assert::true($response->isJson());
+ Assert::equal(['status' => 'ok', 'data' => [1, 2, 3]], $response->getJsonBody());
+});
+
+// Test: Error response
+test(static function (): void {
+ $client = new FakeClient();
+ $client->respondWithError('Connection failed', 500);
+
+ $response = $client->get('https://example.com');
+
+ Assert::equal(500, $response->getStatusCode());
+ Assert::equal('Connection failed', $response->getError());
+});
+
+// Test: Request recording
+test(static function (): void {
+ $client = new FakeClient();
+ $client->respondWith('OK');
+
+ $client->get('https://example.com/get');
+ $client->post('https://example.com/post', 'body data');
+ $client->put('https://example.com/put', 'put data');
+ $client->delete('https://example.com/delete');
+
+ Assert::equal(4, $client->getRequestCount());
+
+ $requests = $client->getRecordedRequests();
+ Assert::equal('https://example.com/get', $requests[0]->getUrl());
+ Assert::equal(Request::METHOD_GET, $requests[0]->getMethod());
+
+ Assert::equal('https://example.com/post', $requests[1]->getUrl());
+ Assert::equal(Request::METHOD_POST, $requests[1]->getMethod());
+ Assert::equal('body data', $requests[1]->getBody());
+
+ Assert::equal('https://example.com/put', $requests[2]->getUrl());
+ Assert::equal(Request::METHOD_PUT, $requests[2]->getMethod());
+
+ Assert::equal('https://example.com/delete', $requests[3]->getUrl());
+ Assert::equal(Request::METHOD_DELETE, $requests[3]->getMethod());
+});
+
+// Test: Get last request
+test(static function (): void {
+ $client = new FakeClient();
+
+ $client->get('https://example.com/1');
+ $client->get('https://example.com/2');
+
+ $lastRequest = $client->getLastRequest();
+ Assert::equal('https://example.com/2', $lastRequest->getUrl());
+});
+
+// Test: Assert helpers
+test(static function (): void {
+ $client = new FakeClient();
+
+ $client->get('https://api.example.com/users');
+ $client->post('https://api.example.com/users');
+
+ Assert::true($client->assertRequestCount(2));
+ Assert::true($client->assertRequestMade('https://api.example.com/users'));
+ Assert::true($client->assertRequestMade('https://api.example.com/users', Request::METHOD_GET));
+ Assert::true($client->assertRequestMade('https://api.example.com/users', Request::METHOD_POST));
+ Assert::false($client->assertRequestMade('https://api.example.com/users', Request::METHOD_DELETE));
+});
+
+// Test: Reset
+test(static function (): void {
+ $client = new FakeClient();
+ $client->respondWith('test');
+ $client->get('https://example.com');
+
+ Assert::equal(1, $client->getRequestCount());
+
+ $client->reset();
+
+ Assert::equal(0, $client->getRequestCount());
+});
+
+// Test: Request object
+test(static function (): void {
+ $client = new FakeClient();
+ $client->respondWith('OK');
+
+ $request = new Request('https://example.com/api', Request::METHOD_POST);
+ $request->setBody('{"test": true}');
+ $request->addHeader('Authorization', 'Bearer token');
+
+ $response = $client->request($request);
+
+ Assert::equal('OK', $response->getBody());
+
+ $lastRequest = $client->getLastRequest();
+ Assert::equal('https://example.com/api', $lastRequest->getUrl());
+ Assert::equal(Request::METHOD_POST, $lastRequest->getMethod());
+ Assert::equal('{"test": true}', $lastRequest->getBody());
+ Assert::equal('Bearer token', $lastRequest->getHeader('Authorization'));
+});
diff --git a/tests/cases/Client/Request.phpt b/tests/cases/Client/Request.phpt
new file mode 100644
index 0000000..4c04817
--- /dev/null
+++ b/tests/cases/Client/Request.phpt
@@ -0,0 +1,127 @@
+getUrl());
+ Assert::equal(Request::METHOD_GET, $request->getMethod());
+ Assert::equal([], $request->getHeaders());
+ Assert::null($request->getBody());
+ Assert::equal([], $request->getOptions());
+});
+
+// Test: Constructor with method
+test(static function (): void {
+ $request = new Request('https://example.com', Request::METHOD_POST);
+
+ Assert::equal(Request::METHOD_POST, $request->getMethod());
+});
+
+// Test: Setters and getters
+test(static function (): void {
+ $request = new Request('https://example.com');
+
+ $request->setUrl('https://api.example.com');
+ Assert::equal('https://api.example.com', $request->getUrl());
+
+ $request->setMethod(Request::METHOD_PUT);
+ Assert::equal(Request::METHOD_PUT, $request->getMethod());
+
+ $request->setBody('request body');
+ Assert::equal('request body', $request->getBody());
+});
+
+// Test: Headers
+test(static function (): void {
+ $request = new Request('https://example.com');
+
+ $request->addHeader('Content-Type', 'application/json');
+ $request->addHeader('Accept', 'application/xml');
+
+ Assert::true($request->hasHeader('Content-Type'));
+ Assert::true($request->hasHeader('Accept'));
+ Assert::false($request->hasHeader('X-Custom'));
+
+ Assert::equal('application/json', $request->getHeader('Content-Type'));
+ Assert::equal('application/xml', $request->getHeader('Accept'));
+ Assert::null($request->getHeader('X-Custom'));
+
+ Assert::equal([
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/xml',
+ ], $request->getHeaders());
+});
+
+// Test: Set all headers at once
+test(static function (): void {
+ $request = new Request('https://example.com');
+ $request->addHeader('Old-Header', 'old');
+
+ $request->setHeaders([
+ 'New-Header' => 'new',
+ ]);
+
+ Assert::false($request->hasHeader('Old-Header'));
+ Assert::true($request->hasHeader('New-Header'));
+});
+
+// Test: Options
+test(static function (): void {
+ $request = new Request('https://example.com');
+
+ $request->setOption('timeout', 30);
+ $request->setCurlOption(CURLOPT_FOLLOWLOCATION, 1);
+
+ $options = $request->getOptions();
+ Assert::equal(30, $options['timeout']);
+ Assert::equal(1, $options[CURLOPT_FOLLOWLOCATION]);
+});
+
+// Test: Set all options at once
+test(static function (): void {
+ $request = new Request('https://example.com');
+ $request->setOption('old', 'value');
+
+ $request->setOptions([
+ 'new' => 'value',
+ ]);
+
+ $options = $request->getOptions();
+ Assert::false(isset($options['old']));
+ Assert::equal('value', $options['new']);
+});
+
+// Test: Fluent interface
+test(static function (): void {
+ $request = new Request('https://example.com');
+
+ Assert::type(Request::class, $request->setUrl('url'));
+ Assert::type(Request::class, $request->setMethod('POST'));
+ Assert::type(Request::class, $request->setHeaders([]));
+ Assert::type(Request::class, $request->addHeader('X', 'Y'));
+ Assert::type(Request::class, $request->setBody('body'));
+ Assert::type(Request::class, $request->setOptions([]));
+ Assert::type(Request::class, $request->setOption('k', 'v'));
+ Assert::type(Request::class, $request->setCurlOption(CURLOPT_TIMEOUT, 10));
+});
+
+// Test: Method constants
+test(static function (): void {
+ Assert::equal('GET', Request::METHOD_GET);
+ Assert::equal('POST', Request::METHOD_POST);
+ Assert::equal('PUT', Request::METHOD_PUT);
+ Assert::equal('DELETE', Request::METHOD_DELETE);
+ Assert::equal('PATCH', Request::METHOD_PATCH);
+ Assert::equal('HEAD', Request::METHOD_HEAD);
+ Assert::equal('OPTIONS', Request::METHOD_OPTIONS);
+});
diff --git a/tests/cases/Client/Response.phpt b/tests/cases/Client/Response.phpt
new file mode 100644
index 0000000..8c1d079
--- /dev/null
+++ b/tests/cases/Client/Response.phpt
@@ -0,0 +1,158 @@
+getBody());
+ Assert::false($response->hasBody());
+ Assert::equal([], $response->getAllHeaders());
+ Assert::equal([], $response->getAllInfo());
+});
+
+// Test: Constructor with values
+test(static function (): void {
+ $response = new Response(
+ 'response body',
+ ['Content-Type' => 'text/html'],
+ ['http_code' => 200]
+ );
+
+ Assert::equal('response body', $response->getBody());
+ Assert::true($response->hasBody());
+ Assert::equal(['Content-Type' => 'text/html'], $response->getAllHeaders());
+ Assert::equal(['http_code' => 200], $response->getAllInfo());
+});
+
+// Test: Headers
+test(static function (): void {
+ $response = new Response(null, [
+ 'Content-Type' => 'application/json',
+ 'X-Custom' => 'value',
+ ]);
+
+ Assert::true($response->hasHeader('Content-Type'));
+ Assert::true($response->hasHeader('X-Custom'));
+ Assert::false($response->hasHeader('Missing'));
+
+ Assert::equal('application/json', $response->getHeader('Content-Type'));
+ Assert::equal('value', $response->getHeader('X-Custom'));
+ Assert::null($response->getHeader('Missing'));
+});
+
+// Test: Info
+test(static function (): void {
+ $response = new Response(null, [], [
+ 'http_code' => 200,
+ 'content_type' => 'text/html',
+ ]);
+
+ Assert::true($response->hasInfo('http_code'));
+ Assert::true($response->hasInfo('content_type'));
+ Assert::false($response->hasInfo('missing'));
+
+ Assert::equal(200, $response->getInfo('http_code'));
+ Assert::equal('text/html', $response->getInfo('content_type'));
+ Assert::null($response->getInfo('missing'));
+});
+
+// Test: Status code from http_code
+test(static function (): void {
+ $response = new Response(null, [], ['http_code' => 404]);
+
+ Assert::equal(404, $response->getStatusCode());
+});
+
+// Test: Status code from status_code (fallback)
+test(static function (): void {
+ $response = new Response(null, [], ['status_code' => 500]);
+
+ Assert::equal(500, $response->getStatusCode());
+});
+
+// Test: Status code default
+test(static function (): void {
+ $response = new Response();
+
+ Assert::equal(0, $response->getStatusCode());
+});
+
+// Test: isOk
+test(static function (): void {
+ $response200 = new Response(null, [], ['http_code' => 200]);
+ $response404 = new Response(null, [], ['http_code' => 404]);
+
+ Assert::true($response200->isOk());
+ Assert::false($response404->isOk());
+});
+
+// Test: isSuccess
+test(static function (): void {
+ $response200 = new Response(null, [], ['http_code' => 200]);
+ $response201 = new Response(null, [], ['http_code' => 201]);
+ $response204 = new Response(null, [], ['http_code' => 204]);
+ $response400 = new Response(null, [], ['http_code' => 400]);
+
+ Assert::true($response200->isSuccess());
+ Assert::true($response201->isSuccess());
+ Assert::true($response204->isSuccess());
+ Assert::false($response400->isSuccess());
+});
+
+// Test: isJson from info
+test(static function (): void {
+ $jsonResponse = new Response(null, [], ['content_type' => 'application/json']);
+ $htmlResponse = new Response(null, [], ['content_type' => 'text/html']);
+
+ Assert::true($jsonResponse->isJson());
+ Assert::false($htmlResponse->isJson());
+});
+
+// Test: isJson from header
+test(static function (): void {
+ $jsonResponse = new Response(null, ['Content-Type' => 'application/json; charset=utf-8']);
+
+ Assert::true($jsonResponse->isJson());
+});
+
+// Test: getJsonBody
+test(static function (): void {
+ $response = new Response('{"name":"John","age":30}');
+
+ $data = $response->getJsonBody();
+ Assert::equal(['name' => 'John', 'age' => 30], $data);
+});
+
+// Test: getJsonBody with null body
+test(static function (): void {
+ $response = new Response();
+
+ Assert::null($response->getJsonBody());
+});
+
+// Test: getJsonBody with invalid JSON
+test(static function (): void {
+ $response = new Response('not json');
+
+ Assert::null($response->getJsonBody());
+});
+
+// Test: Error
+test(static function (): void {
+ $response = new Response();
+
+ Assert::null($response->getError());
+
+ $response->setError('Connection timeout');
+
+ Assert::equal('Connection timeout', $response->getError());
+});
diff --git a/tests/cases/Curl/CurlBuilder.phpt b/tests/cases/Curl/CurlBuilder.phpt
new file mode 100644
index 0000000..5f20fb9
--- /dev/null
+++ b/tests/cases/Curl/CurlBuilder.phpt
@@ -0,0 +1,138 @@
+get('https://example.com')
+ ->build();
+
+ Assert::equal('https://example.com', $request->getUrl());
+ Assert::equal(Request::METHOD_GET, $request->getMethod());
+});
+
+// Test: POST request with JSON body
+test(static function (): void {
+ $request = CurlBuilder::create()
+ ->post('https://api.example.com/users')
+ ->setJsonBody(['name' => 'John', 'email' => 'john@example.com'])
+ ->build();
+
+ Assert::equal('https://api.example.com/users', $request->getUrl());
+ Assert::equal(Request::METHOD_POST, $request->getMethod());
+ Assert::equal('{"name":"John","email":"john@example.com"}', $request->getBody());
+ Assert::equal('application/json', $request->getHeader('Content-Type'));
+});
+
+// Test: Form body
+test(static function (): void {
+ $request = CurlBuilder::create()
+ ->post('https://example.com/form')
+ ->setFormBody(['username' => 'john', 'password' => 'secret'])
+ ->build();
+
+ Assert::equal('username=john&password=secret', $request->getBody());
+ Assert::equal('application/x-www-form-urlencoded', $request->getHeader('Content-Type'));
+});
+
+// Test: Headers
+test(static function (): void {
+ $request = CurlBuilder::create()
+ ->get('https://example.com')
+ ->addHeader('X-Custom', 'value')
+ ->setContentType('text/plain')
+ ->setAccept('application/json')
+ ->build();
+
+ Assert::equal('value', $request->getHeader('X-Custom'));
+ Assert::equal('text/plain', $request->getHeader('Content-Type'));
+ Assert::equal('application/json', $request->getHeader('Accept'));
+});
+
+// Test: Authorization
+test(static function (): void {
+ $request = CurlBuilder::create()
+ ->get('https://api.example.com')
+ ->setBearerToken('my-token')
+ ->build();
+
+ Assert::equal('Bearer my-token', $request->getHeader('Authorization'));
+});
+
+// Test: Basic auth
+test(static function (): void {
+ $request = CurlBuilder::create()
+ ->get('https://example.com')
+ ->setBasicAuth('user', 'pass')
+ ->build();
+
+ Assert::equal('Basic ' . base64_encode('user:pass'), $request->getHeader('Authorization'));
+});
+
+// Test: HTTP methods
+test(static function (): void {
+ $builder = CurlBuilder::create();
+
+ $builder->put('https://example.com');
+ Assert::equal(Request::METHOD_PUT, $builder->getMethod());
+
+ $builder->delete('https://example.com');
+ Assert::equal(Request::METHOD_DELETE, $builder->getMethod());
+
+ $builder->patch('https://example.com');
+ Assert::equal(Request::METHOD_PATCH, $builder->getMethod());
+
+ $builder->head('https://example.com');
+ Assert::equal(Request::METHOD_HEAD, $builder->getMethod());
+
+ $builder->options('https://example.com');
+ Assert::equal(Request::METHOD_OPTIONS, $builder->getMethod());
+});
+
+// Test: cURL options
+test(static function (): void {
+ $request = CurlBuilder::create()
+ ->get('https://example.com')
+ ->setTimeout(30)
+ ->setFollowRedirects(false)
+ ->setSslVerify(false)
+ ->setUserAgent('MyApp/1.0')
+ ->build();
+
+ $options = $request->getOptions();
+ Assert::equal(30, $options[CURLOPT_TIMEOUT]);
+ Assert::equal(0, $options[CURLOPT_FOLLOWLOCATION]);
+ Assert::equal(0, $options[CURLOPT_SSL_VERIFYPEER]);
+ Assert::equal('MyApp/1.0', $options[CURLOPT_USERAGENT]);
+});
+
+// Test: Custom option
+test(static function (): void {
+ $request = CurlBuilder::create()
+ ->get('https://example.com')
+ ->setOption(CURLOPT_MAXREDIRS, 5)
+ ->build();
+
+ $options = $request->getOptions();
+ Assert::equal(5, $options[CURLOPT_MAXREDIRS]);
+});
+
+// Test: Fluent interface
+test(static function (): void {
+ $builder = CurlBuilder::create();
+
+ Assert::type(CurlBuilder::class, $builder->setUrl('https://example.com'));
+ Assert::type(CurlBuilder::class, $builder->setMethod('GET'));
+ Assert::type(CurlBuilder::class, $builder->addHeader('X-Test', 'value'));
+ Assert::type(CurlBuilder::class, $builder->setBody('test'));
+ Assert::type(CurlBuilder::class, $builder->setTimeout(10));
+});
diff --git a/tests/cases/DI/CurlExtension.phpt b/tests/cases/DI/CurlExtension.phpt
index 1762f87..c9b0923 100644
--- a/tests/cases/DI/CurlExtension.phpt
+++ b/tests/cases/DI/CurlExtension.phpt
@@ -4,8 +4,8 @@
* Test: DI\CurlExtension
*/
+use Contributte\Http\Client\IClient;
use Contributte\Http\Curl\CurlClient;
-use Contributte\Http\Curl\ICurlClient;
use Contributte\Http\DI\CurlExtension;
use Nette\DI\Compiler;
use Nette\DI\Container;
@@ -14,6 +14,7 @@ use Tester\Assert;
require_once __DIR__ . '/../../bootstrap.php';
+// Test: IClient interface registration
test(static function (): void {
$loader = new ContainerLoader(TEMP_DIR, true);
$class = $loader->load(static function (Compiler $compiler): void {
@@ -23,5 +24,5 @@ test(static function (): void {
/** @var Container $container */
$container = new $class();
- Assert::type(CurlClient::class, $container->getByType(ICurlClient::class));
+ Assert::type(CurlClient::class, $container->getByType(IClient::class));
});