Bläddra i källkod

Initial release of michel/httpclient

michelphp 1 dag sedan
incheckning
9427fefe4f
10 ändrade filer med 906 tillägg och 0 borttagningar
  1. 3 0
      .gitignore
  2. 21 0
      LICENSE
  3. 156 0
      README.md
  4. 27 0
      composer.json
  5. 46 0
      src/Http/HttpStatusCode.php
  6. 42 0
      src/Http/Response.php
  7. 294 0
      src/HttpClient.php
  8. 66 0
      src/helpers.php
  9. 174 0
      tests/HttpClientTest.php
  10. 77 0
      tests/test_server.php

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+/.idea/
+/vendor/
+composer.lock

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Michel
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 156 - 0
README.md

@@ -0,0 +1,156 @@
+# PHP Http Client
+
+This PHP HTTP Client provides a minimalistic way to perform `GET` and `POST` HTTP requests with customizable headers. It allows you to handle JSON data, form-encoded requests, and more, without using cURL.
+
+## Installation
+
+You can install this library via [Composer](https://getcomposer.org/). Ensure your project meets the minimum PHP version requirement of 7.4.
+
+```bash
+composer require michel/httpclient
+```
+## Requirements
+
+- PHP version 7.4 or higher
+
+## Features
+
+- Supports `GET` and `POST` requests.
+- Customize headers for each request.
+- Automatically handles JSON or form-encoded data.
+- Easily configurable base URL for all requests.
+- Includes error handling for invalid URLs and timeouts.
+
+## Usage
+
+### Basic GET Request
+
+```php
+use Michel\HttpClient\HttpClient;
+
+$client = new HttpClient(['base_url' => 'http://example.com']);
+
+// Perform a GET request
+$response = $client->get('/api/data');
+
+if ($response->getStatusCode() === 200) {
+    echo $response->getBody(); // Raw response body
+    print_r($response->bodyToArray()); // JSON decoded response
+}
+```
+
+### GET Request with Query Parameters
+
+```php
+$response = $client->get('/api/search', ['query' => 'test']);
+```
+
+### POST Request (Form-Encoded)
+
+```php
+$data = [
+    'username' => 'testuser',
+    'password' => 'secret'
+];
+
+$response = $client->post('/api/login', $data);
+```
+
+### POST Request (JSON)
+
+```php
+$data = [
+    'title' => 'Hello World',
+    'content' => 'This is a post content'
+];
+
+$response = $client->post('/api/posts', $data, true); // `true` specifies JSON content type
+```
+
+### Custom Headers
+
+```php
+$client = new HttpClient([
+    'base_url' => 'http://example.com',
+    'headers' => ['Authorization' => 'Bearer your_token']
+]);
+
+$response = $client->get('/api/protected');
+```
+
+---
+
+## Helper Functions
+
+To make the HTTP client easier to use, we provide a set of helper functions that allow you to quickly send `GET` and `POST` requests without needing to manually instantiate the `HttpClient` class every time.
+
+### Available Helper Functions
+
+#### 1. `http_client()`
+
+This function creates and returns a new `HttpClient` instance with the provided configuration options.
+
+```php
+$client = http_client([
+    'base_url' => 'http://example.com',
+    'headers' => ['Authorization' => 'Bearer your_token']
+]);
+```
+
+#### 2. `http_post()`
+
+Use this function to make a POST request with form-encoded data. It sends a request to the given URL with optional data and headers.
+
+```php
+$response = http_post('http://example.com/api/login', [
+    'username' => 'user123',
+    'password' => 'secret'
+]);
+```
+
+#### 3. `http_post_json()`
+
+This function sends a POST request with JSON-encoded data. Useful for APIs expecting JSON input.
+
+```php
+$response = http_post_json('http://example.com/api/create', [
+    'title' => 'New Post',
+    'body' => 'This is the content of the new post.'
+]);
+```
+
+#### 4. `http_get()`
+
+Make a GET request using this function. You can include query parameters and headers as needed.
+
+```php
+$response = http_get('http://example.com/api/users', [
+    'page' => 1,
+    'limit' => 10
+]);
+```
+
+### Example Usage of Helpers
+
+```php
+// Make a GET request
+$response = http_get('http://api.example.com/items', ['category' => 'books']);
+$data = $response->bodyToArray();
+
+// Make a POST request with form data
+$response = http_post('http://api.example.com/login', [
+    'username' => 'user123',
+    'password' => 'secret'
+]);
+
+// Make a POST request with JSON data
+$response = http_post_json('http://api.example.com/posts', [
+    'title' => 'Hello World',
+    'content' => 'This is my first post!'
+]);
+```
+
+These helper functions simplify making HTTP requests by reducing the need to manually create and configure the `HttpClient` for each request.
+
+
+

+ 27 - 0
composer.json

@@ -0,0 +1,27 @@
+{
+  "name": "michel/httpclient",
+  "description": "A lightweight PHP HTTP client library without external dependencies. No need curl extension.",
+  "type": "library",
+  "license": "MIT",
+  "authors": [
+    {
+      "name": "Michel.F"
+    }
+  ],
+  "autoload": {
+    "psr-4": {
+      "Michel\\HttpClient\\": "src",
+      "Test\\Michel\\HttpClient\\": "tests"
+    },
+    "files": [
+      "src/helpers.php"
+    ]
+  },
+  "require": {
+    "php": ">=7.4",
+    "ext-json": "*"
+  },
+  "require-dev": {
+    "michel/unitester": "^1.0.0"
+  }
+}

+ 46 - 0
src/Http/HttpStatusCode.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace Michel\HttpClient\Http;
+
+class HttpStatusCode
+{
+    const SWITCHING_PROTOCOLS = 101;
+    const OK = 200;
+    const CREATED = 201;
+    const ACCEPTED = 202;
+    const NON_AUTHORITATIVE_INFORMATION = 203;
+    const NO_CONTENT = 204;
+    const RESET_CONTENT = 205;
+    const PARTIAL_CONTENT = 206;
+    const MULTIPLE_CHOICES = 300;
+    const MOVED_PERMANENTLY = 301;
+    const MOVED_TEMPORARILY = 302;
+    const SEE_OTHER = 303;
+    const NOT_MODIFIED = 304;
+    const USE_PROXY = 305;
+    const BAD_REQUEST = 400;
+    const UNAUTHORIZED = 401;
+    const PAYMENT_REQUIRED = 402;
+    const FORBIDDEN = 403;
+    const NOT_FOUND = 404;
+    const METHOD_NOT_ALLOWED = 405;
+    const NOT_ACCEPTABLE = 406;
+    const PROXY_AUTHENTICATION_REQUIRED = 407;
+    const REQUEST_TIMEOUT = 408;
+    const CONFLICT = 408;
+    const GONE = 410;
+    const LENGTH_REQUIRED = 411;
+    const PRECONDITION_FAILED = 412;
+    const REQUEST_ENTITY_TOO_LARGE = 413;
+    const REQUEST_URI_TOO_LARGE = 414;
+    const UNSUPPORTED_MEDIA_TYPE = 415;
+    const REQUESTED_RANGE_NOT_SATISFIABLE = 416;
+    const EXPECTATION_FAILED = 417;
+    const IM_A_TEAPOT = 418;
+    const INTERNAL_SERVER_ERROR = 500;
+    const NOT_IMPLEMENTED = 501;
+    const BAD_GATEWAY = 502;
+    const SERVICE_UNAVAILABLE = 503;
+    const GATEWAY_TIMEOUT = 504;
+    const HTTP_VERSION_NOT_SUPPORTED = 505;
+}

+ 42 - 0
src/Http/Response.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Michel\HttpClient\Http;
+
+final class Response
+{
+    private string $body;
+    private int $statusCode;
+    private array $headers;
+
+    public function __construct(string $body, int $statusCode, array $headers)
+    {
+        $this->body = $body;
+        $this->statusCode = $statusCode;
+        $this->headers = $headers;
+    }
+
+    public function getBody(): string
+    {
+        return $this->body;
+    }
+
+    public function bodyToArray(): array
+    {
+        try {
+            $decodedBody = json_decode($this->body, true, 512, JSON_THROW_ON_ERROR);
+        }catch (\JsonException $e) {
+            throw new \Exception('Invalid JSON format in response body : ' . $e->getMessage());
+        }
+        return $decodedBody;
+    }
+
+    public function getStatusCode(): int
+    {
+        return $this->statusCode;
+    }
+
+    public function getHeaders(): array
+    {
+        return $this->headers;
+    }
+}

+ 294 - 0
src/HttpClient.php

@@ -0,0 +1,294 @@
+<?php
+
+namespace Michel\HttpClient;
+
+use InvalidArgumentException;
+use LogicException;
+use Michel\HttpClient\Http\HttpStatusCode;
+use Michel\HttpClient\Http\Response;
+
+final class HttpClient
+{
+    /**
+     * The options for configuring the HttpClient.
+     *
+     * @var array
+     *
+     * Possible options:
+     * - string user_agent The user agent to use for the request.
+     * - int timeout The timeout value in seconds for the request.
+     * - array headers An associative array of HTTP headers to include in the request.
+     * - string base_url The base URL to prepend to relative URLs in the request.
+     */
+    private array $options;
+
+    private $logger;
+
+    /**
+     * HttpClient constructor.
+     *
+     * @param array $options An array of options for HttpClient.
+     *                      Possible options:
+     *                      - string user_agent The user agent to use for the request.
+     *                      - int timeout The timeout value in seconds for the request.
+     *                      - array headers An associative array of HTTP headers to include in the request.
+     *                      - string base_url The base URL to prepend to relative URLs in the request.
+     */
+    public function __construct(array $options = [], ?callable $logger = null)
+    {
+        self::validateOptions($options, ['user_agent', 'timeout', 'headers', 'base_url']);
+        $this->options = array_replace([
+            'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36',
+            'timeout' => 30,
+            'headers' => [],
+            'base_url' => null,
+        ], $options);
+        $this->logger = $logger;
+    }
+
+    /**
+     * Perform a GET request.
+     *
+     * @param string $url The URL to send the GET request to.
+     * @param array $query An associative array of query parameters.
+     * @param array $headers An associative array of HTTP headers to include in the request.
+     * @return Response The response object.
+     */
+    public function get(string $url, array $query = [], array $headers = []): Response
+    {
+        $options['headers'] = $headers;
+        if (!empty($query)) {
+            $url .= '?' . http_build_query($query);
+        }
+        return $this->fetch($url, $options);
+    }
+
+    /**
+     * Perform a POST request.
+     *
+     * @param string $url The URL to send the POST request to.
+     * @param array $data An associative array of data to be sent in the request body.
+     * @param bool $json Whether to send the data as JSON.
+     * @param array $headers An associative array of HTTP headers to include in the request.
+     * @return Response The response object.
+     */
+    public function post(string $url, array $data, bool $json = false, array $headers = []): Response
+    {
+        $options['method'] = 'POST';
+        $options['body'] = $data;
+        $options['headers'] = $headers;
+        if ($json) {
+            $options['headers']['Content-Type'] = 'application/json';
+        }
+        return $this->fetch($url, $options);
+    }
+
+    /**
+     * Perform a fetch request.
+     *
+     * @param string $url The URL to fetch.
+     * @param array $options An associative array of options for the fetch request.
+     *                      Possible options:
+     *                      - string user_agent The user agent to use for the request.
+     *                      - int timeout The timeout value in seconds for the request.
+     *                      - array headers An associative array of HTTP headers to include in the request.
+     *                      - string base_url The base URL to prepend to relative URLs in the request.
+     *                      - string body The body of the request.
+     *                      - string method The HTTP method to use for the request.
+     * @return Response The response object.
+     */
+    public function fetch(string $url, array $options = []): Response
+    {
+        $options['method'] = strtoupper($options['method'] ?? 'GET');
+        $options['body'] = $options['body'] ?? '';
+        self::validateOptions($options, ['user_agent', 'timeout', 'headers', 'body', 'method']);
+
+        $options = array_merge_recursive($this->options, $options);
+        $context = $this->createContext($options);
+
+        if (!empty($options['base_url'])) {
+            $baseUrl = rtrim($options['base_url'], '/') . '/';
+            $url = ltrim($url, '/');
+            $url = $baseUrl . $url;
+        }
+
+        if (!filter_var($url, FILTER_VALIDATE_URL)) {
+            throw new InvalidArgumentException(sprintf('Invalid URL: %s', $url));
+        }
+
+        $info = [
+            'url' => $url,
+            'request' => [
+                'user_agent' => $options['user_agent'],
+                'method' => $options['method'],
+                'headers' => $options['headers'],
+                'body' => $options['body'],
+            ],
+            'response' => [
+                'body' => '',
+                'headers' => [],
+            ]
+        ];
+
+        $response = '';
+        $fp = fopen($url, 'rb', false, $context);
+        $httpResponseHeaders = $http_response_header;
+        $headers = self::parseHttpResponseHeaders($httpResponseHeaders);
+        $info['response']['headers'] = $headers;
+        if ($fp === false) {
+            $this->log($info);
+            throw new LogicException(sprintf('Error opening request to %s: %s', $url, $httpResponseHeaders[0] ?? ''));
+        }
+
+        while (!feof($fp)) {
+            $response .= fread($fp, 8192);
+        }
+
+        fclose($fp);
+
+        $info['response']['body'] = $response;
+        $this->log($info);
+
+        return new Response($response, $headers['status_code'], $headers);
+    }
+
+    /**
+     * Create a stream context based on the provided options.
+     *
+     * @param array $options An associative array of options for creating the stream context.
+     * @return resource The created stream context.
+     */
+    private function createContext(array $options)
+    {
+        $body = $options['body'];
+        if (in_array($options['method'], ['POST', 'PUT']) && is_array($body)) {
+            $body = self::prepareRequestBody($body, $options['headers']);
+        }
+
+        $opts = [
+            'http' => [
+                'method' => $options['method'],
+                'header' => self::formatHttpRequestHeaders($options['headers']),
+                'content' => $body,
+                'user_agent' => $options['user_agent'],
+                'ignore_errors' => true,
+                'timeout' => $options['timeout']
+            ]
+        ];
+
+        return stream_context_create($opts);
+    }
+
+    private function log(array $info): void
+    {
+        $logger = $this->logger;
+        if (is_callable($logger)) {
+            $logger($info);
+        }
+    }
+
+    /**
+     * Format HTTP headers from the provided associative array.
+     *
+     * @param array $headers An associative array of HTTP headers.
+     * @return string The formatted HTTP headers.
+     */
+    private static function formatHttpRequestHeaders(array $headers): string
+    {
+        $formattedHeaders = '';
+        foreach ($headers as $name => $value) {
+            $formattedHeaders .= "$name: $value\r\n";
+        }
+        return $formattedHeaders;
+    }
+
+    /**
+     * Prepare the request body based on content type.
+     *
+     * @param array $body The body of the request.
+     * @param array $headers The headers to be sent with the request.
+     * @return string The prepared request body.
+     */
+    private static function prepareRequestBody(array $body, array &$headers): string
+    {
+        if (($headers['Content-Type'] ?? '') === 'application/json') {
+            return json_encode($body);
+        }
+
+        $headers['Content-Type'] = 'application/x-www-form-urlencoded';
+        return http_build_query($body);
+    }
+
+    /**
+     * Parse the HTTP response headers into an associative array.
+     *
+     * @param array $responseHeaders The headers from the HTTP response.
+     * @return array The parsed response headers.
+     */
+    private static function parseHttpResponseHeaders(array $responseHeaders): array
+    {
+        $headers = [];
+        foreach ($responseHeaders as $header) {
+            $headerParts = explode(':', $header, 2);
+            if (count($headerParts) == 2) {
+                $key = trim($headerParts[0]);
+                $value = trim($headerParts[1]);
+                $headers[$key] = $value;
+            } else {
+                if (preg_match('{HTTP/\S*\s(\d{3})}', $header, $match)) {
+                    $httpCode = (int)$match[1];
+                    $headers['status_code'] = $httpCode;
+                }
+            }
+        }
+
+        if (!isset($headers['status_code'])) {
+            $headers['status_code'] = HttpStatusCode::HTTP_VERSION_NOT_SUPPORTED;
+        }
+        return $headers;
+    }
+
+    /**
+     * Validate the options passed for the HTTP request.
+     *
+     * @param array $options An associative array of options for the HTTP request.
+     * @param array $allowedOptions An array of allowed options.
+     * @throws LogicException If any of the options are invalid.
+     */
+    private static function validateOptions(array $options, array $allowedOptions = []): void
+    {
+        foreach ($options as $key => $value) {
+            if (!in_array($key, $allowedOptions)) {
+                throw new LogicException('Invalid option: ' . $key);
+            }
+
+            switch ($key) {
+                case 'headers':
+                    if (!is_array($value)) {
+                        throw new LogicException('Headers must be an array of key-value pairs');
+                    }
+                    break;
+                case 'user_agent':
+                    if (!is_string($value)) {
+                        throw new LogicException('User agent must be a string');
+                    }
+                    break;
+                case 'timeout':
+                    if (!is_int($value)) {
+                        throw new LogicException('Timeout must be an integer');
+                    }
+                    break;
+                case 'method':
+                    if (!is_string($value) || !in_array($value, ['GET', 'POST', 'PUT', 'DELETE', 'HEAD'])) {
+                        throw new LogicException('Method must be GET, POST, PUT, DELETE, or HEAD');
+                    }
+                    break;
+                case 'base_url':
+                    if (!empty($value) && !filter_var($value, FILTER_VALIDATE_URL)) {
+                        throw new LogicException('Base URL must be a valid URL');
+                    }
+                    break;
+            }
+        }
+    }
+}

+ 66 - 0
src/helpers.php

@@ -0,0 +1,66 @@
+<?php
+
+use Michel\HttpClient\Http\Response;
+use Michel\HttpClient\HttpClient;
+
+if (!function_exists('http_client')) {
+
+    /**
+     * Creates a new HttpClient instance with the provided options.
+     *
+     * @param array $options The options to configure the HttpClient
+     * @return HttpClient The newly created HttpClient instance
+     */
+    function http_client(array $options = [], callable $logger = null): HttpClient
+    {
+        return new HttpClient($options, $logger);
+    }
+}
+
+if (!function_exists('http_post')) {
+    /**
+     * Makes a POST request using the HttpClient
+     *
+     * @param string $url The URL to which the request is sent
+     * @param array $data The data to be sent in the request
+     * @param array $headers The headers to be sent with the request
+     * @return Response The response from the POST request
+     */
+    function http_post(string $url, array $data = [], array $headers = []): Response
+    {
+        return http_client()->post($url, $data, false, $headers);
+    }
+}
+
+if (!function_exists('http_post_json')) {
+    /**
+     * Makes a POST request with JSON data using the HttpClient
+     *
+     * @param string $url The URL to which the request is sent
+     * @param array $data The JSON data to be sent in the request
+     * @param array $headers The headers to be sent with the request
+     * @return Response The response from the POST request
+     */
+    function http_post_json(string $url, array $data = [], array $headers = []): Response
+    {
+        return http_client()->post($url, $data, true, $headers);
+    }
+}
+
+if (!function_exists('http_get')) {
+    /**
+     * Makes a GET request using the HttpClient
+     *
+     * @param string $url The URL to which the request is sent
+     * @param array $query The query parameters to be included in the request
+     * @param array $headers The headers to be sent with the request
+     * @return Response The response from the GET request
+     */
+    function http_get(string $url, array $query = [], array $headers = []): Response
+    {
+        return http_client()->get($url, $query, $headers);
+    }
+}
+
+
+

+ 174 - 0
tests/HttpClientTest.php

@@ -0,0 +1,174 @@
+<?php
+
+namespace Test\Michel\HttpClient;
+
+use Exception;
+use LogicException;
+use Michel\HttpClient\HttpClient;
+use Michel\UniTester\TestCase;
+
+class HttpClientTest extends TestCase
+{
+    const URL = 'http://localhost:4245';
+
+    protected static ?string $serverProcess = null;
+
+    protected function setUp(): void
+    {
+        $fileToRun = __DIR__ . DIRECTORY_SEPARATOR . 'test_server.php';
+        $command = sprintf('php -S %s %s > /dev/null 2>&1 & echo $!;', str_replace('http://', '', self::URL), $fileToRun);
+        self::$serverProcess = exec($command);
+        if (empty(self::$serverProcess) || !is_numeric(self::$serverProcess)) {
+            throw new Exception('Could not start test server');
+        }
+        $this->log(sprintf('Test web server started on %s', self::URL));
+        sleep(1);
+    }
+
+    protected function tearDown(): void
+    {
+        if (is_numeric(self::$serverProcess)) {
+            exec('kill ' . self::$serverProcess);
+        }
+    }
+
+    protected function execute(): void
+    {
+        $this->testGetRequest();
+        $this->testGetWithQueryRequest();
+        $this->testPostJsonRequest();
+        $this->testPostFormRequest();
+
+        $this->testPostEmptyFormRequest();
+        $this->testWrongOptions();
+        $this->testWrongOptions2();
+        $this->testWrongOptions3();
+        $this->testWrongMethod();
+        $this->testWrongUrl();
+    }
+
+    public function testGetRequest()
+    {
+        $response = http_client(
+            ['base_url' => self::URL, 'headers' => ['Authorization' => 'Bearer secret_token']],
+            function ($info) {
+                $this->assertEquals( 'GET', $info['request']['method']);
+                $this->assertEquals( 'Bearer secret_token', $info['request']['headers']['Authorization']);
+                $this->assertEquals( '{"message":"GET request received"}', $info['response']['body']);
+            }
+        )->get('/api/data');
+        $this->assertEquals(200, $response->getStatusCode());
+        $this->assertNotEmpty($response->getBody());
+    }
+
+    public function testGetWithQueryRequest()
+    {
+        $client = new HttpClient(['base_url' => self::URL, 'headers' => ['Authorization' => 'Bearer secret_token']]);
+        $response = $client->get('/api/search', [
+            'name' => 'foo',
+        ]);
+
+        $this->assertEquals(200, $response->getStatusCode());
+        $this->assertNotEmpty($response->getBody());
+
+        $data = $response->bodyToArray();
+        $this->assertEquals('foo', $data['name']);
+        $this->assertEquals(1, $data['page']);
+        $this->assertEquals(10, $data['limit']);
+
+
+        $response = $client->get('/api/search', [
+            'name' => 'foo',
+            'page' => 10,
+            'limit' => 100
+        ]);
+
+        $this->assertEquals(200, $response->getStatusCode());
+        $this->assertNotEmpty($response->getBody());
+
+        $data = $response->bodyToArray();
+        $this->assertEquals('foo', $data['name']);
+        $this->assertEquals(10, $data['page']);
+        $this->assertEquals(100, $data['limit']);
+    }
+
+    public function testPostJsonRequest()
+    {
+        $dataToPost = [
+            'title' => 'foo',
+            'body' => 'bar',
+            'userId' => 1
+        ];
+        $client = new HttpClient(['headers' => ['Authorization' => 'Bearer secret_token']]);
+        $response = $client->post(self::URL . '/api/post/data', [
+            'title' => 'foo',
+            'body' => 'bar',
+            'userId' => 1
+        ], true);
+
+        $this->assertEquals(200, $response->getStatusCode());
+        $this->assertEquals($dataToPost, $response->bodyToArray());
+    }
+
+    public function testPostFormRequest()
+    {
+        $dataToPost = [
+            'title' => 'foo',
+            'body' => 'bar',
+            'userId' => 1
+        ];
+        $client = new HttpClient(['headers' => ['Authorization' => 'Bearer secret_token']]);
+        $response = $client->post(self::URL . '/api/post/data/form', $dataToPost);
+
+        $this->assertEquals(200, $response->getStatusCode());
+        $this->assertEquals($dataToPost, $response->bodyToArray());
+    }
+
+    public function testPostEmptyFormRequest()
+    {
+        $client = new HttpClient(['headers' => ['Authorization' => 'Bearer secret_token']]);
+        $response = $client->post(self::URL . '/api/post/data/form', []);
+
+        $this->assertEquals(400, $response->getStatusCode());
+    }
+
+    public function testWrongOptions()
+    {
+        $this->expectException(LogicException::class, function () {
+            new HttpClient(['headers' => 'string']);
+        });
+    }
+
+    public function testWrongOptions2()
+    {
+        $this->expectException(LogicException::class, function () {
+            new HttpClient(['options_not_supported' => 'value']);
+        });
+    }
+
+    public function testWrongOptions3()
+    {
+        $this->expectException(LogicException::class, function () {
+            new HttpClient(['timeout' => 'string']);
+        });
+
+    }
+
+    public function testWrongMethod()
+    {
+        $client = new HttpClient(['headers' => ['Authorization' => 'Bearer secret_token']]);
+        $this->expectException(LogicException::class, function () use($client) {
+            $client->fetch(self::URL . '/api/data', ['method' => 'WRONG']);
+        });
+    }
+
+    public function testWrongUrl()
+    {
+        $client = new HttpClient(['headers' => ['Authorization' => 'Bearer secret_token']]);
+        $this->expectException(\InvalidArgumentException::class, function () use($client) {
+            $client->fetch('WRONG_URL');
+        });
+    }
+
+
+}

+ 77 - 0
tests/test_server.php

@@ -0,0 +1,77 @@
+<?php
+
+$headers = getallheaders();
+if (!isset($headers['Authorization']) || $headers['Authorization'] !== 'Bearer secret_token') {
+    header('Content-Type: application/json');
+    http_response_code(401);
+    echo json_encode(['error' => 'Unauthorized']);
+    exit(0);
+}
+
+header('Content-Type: application/json');
+$routes = [
+    'GET' => [
+        '/api/data' => function () {
+            echo json_encode(['message' => 'GET request received']);
+            exit(0);
+        },
+        '/api/search' => function () {
+            $name = $_GET['name'] ?? 'Guest';
+            $page = isset($_GET['page']) ? intval($_GET['page']) : 1;
+            $limit = isset($_GET['limit']) ? intval($_GET['limit']) : 10;
+
+            echo json_encode([
+                'message' => "GET request received",
+                'name' => $name,
+                'page' => $page,
+                'limit' => $limit
+            ]);
+            exit(0);
+        }
+    ],
+    'POST' => [
+        '/api/post/data' => function () {
+            if ('application/json' !== $_SERVER['CONTENT_TYPE']) {
+                http_response_code(400);
+                echo json_encode(['error' => 'Invalid content type']);
+                exit(0);
+            }
+
+            $input = json_decode(file_get_contents('php://input'), true);
+            if (json_last_error() !== JSON_ERROR_NONE) {
+                http_response_code(400);
+                echo json_encode(['error' => 'Invalid JSON']);
+                exit(0);
+            }
+
+            echo json_encode($input);
+            exit(0);
+        },
+        '/api/post/data/form' => function () {
+            if ('application/x-www-form-urlencoded' !== $_SERVER['CONTENT_TYPE']) {
+                http_response_code(400);
+                echo json_encode(['error' => 'Invalid content type']);
+                exit(0);
+            }
+            if (empty($_POST)) {
+                http_response_code(400);
+                echo json_encode(['error' => 'No data provided']);
+                exit(0);
+            }
+            echo json_encode($_POST);
+            exit(0);
+        }
+    ]
+];
+if (array_key_exists($_SERVER['REQUEST_METHOD'], $routes)) {
+
+    foreach ($routes[$_SERVER['REQUEST_METHOD']] as $route => $callback) {
+        if ($route == strtok($_SERVER['REQUEST_URI'], '?')) {
+            $callback();
+            break;
+        }
+    }
+}
+http_response_code(404);
+echo json_encode(['error' => 'Not found', 'route' => $_SERVER['REQUEST_URI']]);
+exit(0);