wimski/http-requests

Opinionated starter kit for making HTTP requests (API implementations)

Maintainers

Package info

github.com/wimski/http-requests

pkg:composer/wimski/http-requests

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-04-02 08:31 UTC

This package is auto-updated.

Last update: 2026-04-02 08:33:58 UTC


README

PHPStan PHPUnit Coverage

HTTP Requests

An opinionated, extensible PHP library for building type-safe HTTP API clients using PSR standards.

Overview

This library provides a structured framework for implementing HTTP API clients with a focus on:

  • Type Safety: Full generic type support with PHPDoc annotations
  • Extensibility: Interface-driven architecture allowing custom implementations
  • PSR Compliance: Built on PSR-7, PSR-17, and PSR-18 standards
  • Separation of Concerns: Clear separation between requests, responses, and data transformation

Requirements

  • PHP 8.3+
  • PSR-7 HTTP Message implementation
  • PSR-17 HTTP Factory implementation
  • PSR-18 HTTP Client implementation

Discovery is recommended for agnostic PSR HTTP class usage.

Installation

composer require wimski/http-requests

Core Concepts

Architecture

The library follows a layered architecture:

  1. Client Layer: Client orchestrates the request/response lifecycle
  2. Request Layer: RequestInterface implementations define API endpoints
  3. Factory Layer: Factories transform between PSR and domain objects
  4. Response Layer: ResponseInterface implementations wrap API responses
  5. Data Layer: DTOs/entities representing API resources

Key Components

  • ClientInterface: Main entry point for making HTTP requests
  • RequestInterface: Defines HTTP request specifications
  • ResponseInterface: Wraps response data with type safety
  • DataFactoryInterface: Transforms arrays into typed objects
  • ResponseBodyFactoryInterface: Parses HTTP response bodies
  • StreamFactoryInterface: Serializes request bodies

Basic Usage

1. Define Your Data Object

readonly class User
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
    ) {}
}

2. Create a Request

use Wimski\HttpRequests\Requests\AbstractRequest;
use Wimski\HttpRequests\Responses\SingleResponse;

/**
 * @extends AbstractRequest<User, SingleResponse<User>>
 */
class GetUserRequest extends AbstractRequest
{
    public function __construct(
        protected readonly int $userId,
    ) {}

    public function getUri(): string
    {
        return "/users/{$this->userId}";
    }

    public function getHeaders(): array
    {
        return [
            'accept' => 'application/json',
        ];
    }

    public function getResponseDataClass(): string
    {
        return User::class;
    }

    public function getResponseClass(): string
    {
        return SingleResponse::class;
    }
}

3. Set Up the Client

use Http\Discovery\HttpClientDiscovery;
use Http\Discovery\Psr17FactoryDiscovery;
use Wimski\HttpRequests\Client;
use Wimski\HttpRequests\Factories\DataFactory;
use Wimski\HttpRequests\Factories\FormStreamFactory;
use Wimski\HttpRequests\Factories\JsonResponseBodyFactory;
use Wimski\HttpRequests\Factories\JsonStreamFactory;
use Wimski\HttpRequests\Factories\RequestFactory;
use Wimski\HttpRequests\Factories\ResponseBodyFactory;use Wimski\HttpRequests\Factories\ResponseFactory;
use Wimski\HttpRequests\Factories\SingleResponseFactory;
use Wimski\HttpRequests\Factories\UriFactory;

$streamFactory = Psr17FactoryDiscovery::findStreamFactory();

$client = new Client(
    httpClient: HttpClientDiscovery::find(),
    requestFactory: new RequestFactory(
        uriFactory: new UriFactory(
            httpUriFactory: Psr17FactoryDiscovery::findUriFactory(),
            baseUri: 'https://api.example.com',
        ),
        httpRequestFactory: Psr17FactoryDiscovery::findRequestFactory(),
        new StreamFactory(
            new FormStreamFactory($streamFactory),
            new JsonStreamFactory($streamFactory),
            // Add your custom stream factories here
        ),
    ),
    responseFactory: new ResponseFactory(
        new SingleResponseFactory(
            responseBodyFactory: new ResponseBodyFactory(
                new JsonResponseBodyFactory(),
                // Add your custom response body factories here
            ),
            dataFactory: new DataFactory(
                // Add your custom data factories here
            ),
        ),
        // Add your custom response factories here
    ),
);

4. Make Requests

$request = new GetUserRequest(userId: 123);

/** @var SingleResponse<User> $response */
$response = $client->request($request);

$user = $response->getData();

echo $user->name; // Type-safe access

Extending the Library

Custom Data Factory

Implement DataFactoryInterface to control how arrays are transformed into objects:

use Wimski\HttpRequests\Contracts\Factories\DataFactoryInterface;

readonly class SymfonySerializerDataFactory implements DataFactoryInterface
{
    public function __construct(
        protected SerializerInterface $serializer,
    ) {}

    public function supports(string $class): bool
    {
        // Support all classes in your namespace
        return str_starts_with($class, 'App\\Api\\Data\\');
    }

    public function make(string $class, array $data): object
    {
        return $this->serializer->denormalize($data, $class);
    }
}

Register it with the DataFactory:

$dataFactory = new DataFactory(
    new SymfonySerializerDataFactory($serializer),
    // Fallback factories...
);

Custom Response Body Factory

Support different content types by implementing ResponseBodyFactoryInterface:

use Psr\Http\Message\ResponseInterface as HttpResponseInterface;
use Wimski\HttpRequests\Contracts\Factories\ResponseBodyFactoryInterface;
use Wimski\HttpRequests\Contracts\Requests\RequestInterface;

readonly class XmlResponseBodyFactory implements ResponseBodyFactoryInterface
{
    public function supports(string $accept): bool
    {
        return $accept === 'application/xml';
    }

    public function make(HttpResponseInterface $httpResponse, RequestInterface $request): array
    {
        // Parse the $httpResponse->getBody()->getContents() string as XML
        // and transform it into an array
    }
}

Custom Stream Factory

Handle different request body formats by implementing StreamFactoryInterface, and probably extending AbstractStreamFactory:

use Wimski\HttpRequests\Contracts\Requests\RequestInterface;
use Wimski\HttpRequests\Factories\AbstractStreamFactory;

readonly class XmlStreamFactory extends AbstractStreamFactory
{
    public function supports(string $contentType): bool
    {
        return $contentType === 'application/xml';
    }

    public function make(RequestInterface $request): string
    {
        // Transform $request->getBody() array into an XML string
    }
}

Multi-Item Responses

For endpoints returning collections, use MultiResponse:

use App\Api\Data\User;
use Wimski\HttpRequests\Requests\AbstractRequest;
use Wimski\HttpRequests\Responses\MultiResponse;

/**
 * @extends AbstractRequest<User, MultiResponse<User>>
 */
class ListUsersRequest extends AbstractRequest
{
    public function getUri(): string
    {
        return '/users';
    }

    public function getHeaders(): array
    {
        return ['accept' => 'application/json'];
    }

    public function getResponseDataClass(): string
    {
        return User::class;
    }

    public function getResponseClass(): string
    {
        return MultiResponse::class;
    }
}

Create a custom MultiResponseFactory:

use Wimski\HttpRequests\Factories\MultiResponseFactory;

readonly class ApiMultiResponseFactory extends MultiResponseFactory
{
    protected function makeData(array $body, string $class): array
    {
        // Assuming API returns: {"data": [...]}
        $items = $body['data'] ?? [];
        
        return array_map(
            fn(array $item) => $this->dataFactory->make($class, $item),
            $items,
        );
    }

    protected function makePagination(array $body): ?ResponsePaginationInterface
    {
        // Extract pagination metadata if present
        if (!isset($body['meta'])) {
            return null;
        }

        // ApiResponsePagination implements ResponsePaginationInterface
        return new ApiResponsePagination(
            currentPage: $body['meta']['current_page'],
            totalPages: $body['meta']['total_pages'],
            perPage: $body['meta']['per_page'],
            total: $body['meta']['total'],
        );
    }
}

Pagination Support

Implement pagination interfaces for paginated requests:

use Wimski\HttpRequests\Contracts\Requests\RequestPaginationInterface;

readonly class ApiRequestPagination implements RequestPaginationInterface
{
    public function __construct(
        protected int $page = 1,
        protected int $perPage = 20,
    ) {}

    public function getPagination(): array
    {
        return [
            'page'     => $this->page,
            'per_page' => $this->perPage,
        ];
    }
}

Use it in your request:

$request = new ListUsersRequest();
$request->setPagination(new ApiRequestPagination(page: 2, perPage: 50));

$response = $client->request($request);

Custom Request Methods

Override methods in AbstractRequest for specific behaviors:

use Wimski\HttpRequests\Enums\HttpRequestMethodEnum;
use Wimski\HttpRequests\Requests\AbstractRequest;

class CreateUserRequest extends AbstractRequest
{
    public function __construct(
        protected readonly string $name,
        protected readonly string $email,
    ) {}

    public function getMethod(): HttpRequestMethodEnum
    {
        return HttpRequestMethodEnum::POST;
    }

    public function getUri(): string
    {
        return '/users';
    }

    public function getHeaders(): array
    {
        return [
            'accept'       => 'application/json',
            'content-type' => 'application/json',
        ];
    }

    public function getBody(): array
    {
        return [
            'name'  => $this->name,
            'email' => $this->email,
        ];
    }
    
    public function getQuery(): array
    {
        return ['type' => 'json'];    
    }

    public function getResponseDataClass(): string
    {
        return User::class;
    }

    public function getResponseClass(): string
    {
        return SingleResponse::class;
    }
}

Query Parameters

Add query parameters via getQuery():

// /users/search?q={$query}&role={$role}
class SearchUsersRequest extends AbstractRequest
{
    public function __construct(
        protected readonly string $query,
        protected readonly ?string $role = null,
    ) {}

    public function getUri(): string
    {
        return '/users/search';
    }

    public function getQuery(): array
    {
        $query = ['q' => $this->query];
        
        if ($this->role !== null) {
            $query['role'] = $this->role;
        }
        
        return $query;
    }

    // ... other methods
}

Custom Client Behavior

Extend Client to add custom behavior:

use Psr\Http\Message\RequestInterface as HttpRequestInterface;
use Psr\Http\Message\ResponseInterface as HttpResponseInterface;
use Wimski\HttpRequests\Client;
use Wimski\HttpRequests\Contracts\Requests\RequestInterface;

readonly class AuthenticatedClient extends Client
{
    public function __construct(
        protected string $apiToken,
        // ... parent dependencies
    ) {
        parent::__construct(...);
    }
    
    protected function makeRequest(RequestInterface $request): HttpRequestInterface
    {
        return parent::makeRequest($request)
            ->withHeader('Authorization', "Bearer {$this->apiToken}");
    }

    protected function validateStatusCode(HttpResponseInterface $httpResponse, HttpRequestInterface $httpRequest): void
    {
        // Custom status code handling
        $statusCode = $httpResponse->getStatusCode();
        
        if ($statusCode === 401) {
            throw new AuthenticationException('Invalid API token');
        }

        parent::validateStatusCode($httpResponse, $httpRequest);
    }
}

Custom Response Types

Create custom response classes for specialized use cases:

use Wimski\HttpRequests\Contracts\Responses\ResponseInterface;

/**
 * @template TData of object
 * @implements ResponseInterface<TData>
 */
readonly class PagedResponse implements ResponseInterface
{
    /**
     * @param list<TData> $data
     */
    public function __construct(
        protected array $data,
        protected int $currentPage,
        protected int $totalPages,
        protected int $total,
    ) {}

    public function getData(): array
    {
        return $this->data;
    }

    public function getPagination(): ?ResponsePaginationInterface
    {
        return new PagedResponsePagination(
            $this->currentPage,
            $this->totalPages,
            $this->total,
        );
    }

    public function hasNextPage(): bool
    {
        return $this->currentPage < $this->totalPages;
    }

    public function hasPreviousPage(): bool
    {
        return $this->currentPage > 1;
    }
}

Advanced Examples

Handling File Uploads

use Wimski\HttpRequests\Contracts\Requests\RequestInterface;
use Wimski\HttpRequests\Factories\AbstractStreamFactory;

readonly class MultipartStreamFactory extends AbstractStreamFactory
{
    public function supports(string $contentType): bool
    {
        return str_starts_with($contentType, 'multipart/form-data');
    }

    public function make(RequestInterface $request): string
    {
        $boundary = uniqid('', true);
        $body = '';

        foreach ($request->getBody() as $name => $value) {
            $body .= "--{$boundary}\r\n";
            $body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n\r\n";
            $body .= "{$value}\r\n";
        }

        $body .= "--{$boundary}--\r\n";

        return $body;
    }
}

Rate Limiting

use Psr\Http\Message\RequestInterface as HttpRequestInterface;
use Psr\Http\Message\ResponseInterface as HttpResponseInterface;
use Wimski\HttpRequests\Client;
use Wimski\HttpRequests\Contracts\Requests\RequestInterface;

readonly class RateLimitedClient extends Client
{
    protected int $lastRequestTime = 0;
    protected int $minDelayMs = 100;

    protected function sendRequest(
        HttpRequestInterface $httpRequest,
        RequestInterface $request
    ): HttpResponseInterface {
        $now = (int) (microtime(true) * 1000);
        $elapsed = $now - $this->lastRequestTime;

        if ($elapsed < $this->minDelayMs) {
            usleep(($this->minDelayMs - $elapsed) * 1000);
        }

        $response = parent::sendRequest($httpRequest, $request);
        $this->lastRequestTime = (int) (microtime(true) * 1000);

        return $response;
    }
}

Retry Logic

use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Message\RequestInterface as HttpRequestInterface;
use Psr\Http\Message\ResponseInterface as HttpResponseInterface;
use Wimski\HttpRequests\Client;
use Wimski\HttpRequests\Contracts\Requests\RequestInterface;

readonly class RetryableClient extends Client
{
    public function __construct(
        protected int $maxRetries = 3,
        protected int $retryDelayMs = 1000,
        // ... parent dependencies
    ) {
        parent::__construct(...);
    }

    protected function sendRequest(
        HttpRequestInterface $httpRequest,
        RequestInterface $request
    ): HttpResponseInterface {
        $attempt = 0;
        $lastException = null;

        while ($attempt < $this->maxRetries) {
            try {
                return parent::sendRequest($httpRequest, $request);
            } catch (ClientExceptionInterface $exception) {
                $lastException = $exception;
                $attempt++;

                if ($attempt < $this->maxRetries) {
                    usleep($this->retryDelayMs * 1000 * $attempt);
                }
            }
        }

        throw $lastException;
    }
}

Exception Handling

The library provides specific exceptions for different failure scenarios:

use Wimski\HttpRequests\Exceptions\HttpStatusCodeException;
use Wimski\HttpRequests\Exceptions\RequestException;
use Wimski\HttpRequests\Exceptions\ResponseException;

try {
    $response = $client->request($request);
} catch (HttpStatusCodeException $exception) {
    // HTTP error status code (4xx, 5xx)
    $statusCode = $exception->getResponse()->getStatusCode();
    $body = $exception->getMessage();
} catch (RequestException $exception) {
    // Failed to create or send the request
    $previous = $exception->getPrevious();
} catch (ResponseException $e) {
    // Failed to parse or transform the response
    $previous = $exception->getPrevious();
}

Testing

The interface-driven design makes testing straightforward:

use Mockery;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Wimski\HttpRequests\Contracts\ClientInterface;

class UserServiceTest extends TestCase
{
    #[Test]
    public function test_can_fetch_user(): void
    {
        $client = Mockery::mock(ClientInterface::class);
        
        $client
            ->shouldReceive('request')
            ->once()
            ->andReturn(new SingleResponse(new User(1, 'John', 'john@example.com')));

        $service = new UserService($client);

        $user = $service->getUser(1);

        $this->assertEquals('John', $user->name);
    }
}