Skip to content
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
a766032
chore: Add .idea to .gitignore
Jul 15, 2025
ca127a4
chore: rectorphp was implemented and the following adjustments
Jul 18, 2025
6c0326e
chore: remove .idea from .gitignore and clean up configuration files
Jul 18, 2025
28b7de2
chore: ractor config adjustement - skip SimplifyUselessVariableRector
Jul 18, 2025
3e9b132
Merge branch 'thenativeweb:main' into main
wundii Jul 19, 2025
1db4811
Merge branch 'thenativeweb:main' into main
wundii Jul 20, 2025
5b7d8fe
Merge branch 'thenativeweb:main' into main
wundii Jul 20, 2025
2553030
Merge branch 'thenativeweb:main' into main
wundii Jul 22, 2025
8b8739d
Merge branch 'thenativeweb:main' into main
wundii Jul 23, 2025
2e7c771
Merge branch 'thenativeweb:main' into main
wundii Jul 23, 2025
b636c85
Merge branch 'thenativeweb:main' into main
wundii Jul 23, 2025
5bfa6bd
refactor: update classes to final and readonly, enhance container sta…
Jul 23, 2025
3458d2e
Merge remote-tracking branch 'origin/main'
Jul 23, 2025
33580dd
docs: update README to include iterator_to_array usage examples for w…
Jul 25, 2025
3c4cd49
docs: update README to include iterator_to_array usage examples for w…
Jul 26, 2025
be405de
refactor: change writeEvents return type to array and update usage in…
Jul 26, 2025
cb4e163
fix: curl error handling if the response header is empty
Jul 26, 2025
0592ac8
fix: improve error handling for cURL execution in contentIterator
Jul 26, 2025
2c3a119
refactor: centralize error handling in verifyCurlHandle method
Jul 26, 2025
70c65d9
test: enhance CurlMultiHandlerTest with header
Jul 26, 2025
e8411d7
refactor: rename parameter in verifyCurlHandle method for clarity
Jul 26, 2025
799a6f3
refactor: verifyCurlHandle
Jul 26, 2025
2f015bb
Merge branch 'thenativeweb:main' into main
wundii Jul 27, 2025
d123469
Merge branch 'refs/heads/main' into HttpClient
Jul 27, 2025
d2f1375
test: improve exception message matching in CurlMultiHandlerTest
Jul 27, 2025
79f1153
refactor: rename removeLineBrake method to removeLineBrakes for clarity
Jul 27, 2025
2db57ec
Merge branch 'thenativeweb:main' into main
wundii Jul 27, 2025
e37923a
Merge branch 'refs/heads/main' into HttpClient
Jul 27, 2025
4146ffb
feat: add FileUpload support for HttpClient
Jul 27, 2025
7d61b70
feat: add FileUpload support for HttpClient
Jul 27, 2025
7331c91
feat: add FileUpload support for HttpClient
Jul 27, 2025
d782b87
refactor: improve infinity loop handling in CurlMultiHandler and add …
Jul 28, 2025
77a7bb0
test: enhance CurlFactoryTest assertions for upload options
Jul 28, 2025
7589e75
Merge branch 'thenativeweb:main' into main
wundii Jul 28, 2025
857096e
Merge branch 'refs/heads/main' into HttpClient
Jul 28, 2025
9ec8ead
test: rename method to clarify purpose of file upload handling
Jul 28, 2025
05e454e
refactor: remove FileUpload handling from CurlFactory and related tests
Jul 29, 2025
5ca6a7f
refactor: update body check in CurlFactory to allow null values
Jul 29, 2025
24ce504
Merge branch 'thenativeweb:main' into HttpClient
wundii Jul 29, 2025
0ce4195
Merge branch 'thenativeweb:main' into main
wundii Jul 29, 2025
b69af09
Merge branch 'refs/heads/main' into HttpClient
Jul 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/Stream/CurlFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ public static function create(
$contentType = null;
$options[CURLOPT_HEADERFUNCTION] = function (?CurlHandle $curlHandle, string $header) use (&$queueHeader, &$contentType): int {
$queueHeader->write($header);

if (preg_match('/^Content-Type:\s*(.+)$/i', $header, $matches)) {
$contentType = strtolower(trim($matches[1]));
}

return strlen($header);
};

Expand Down Expand Up @@ -66,17 +68,31 @@ public static function create(
$options[CURLOPT_TIMEOUT] = $timeout;
}

if ($request->getBody() !== null) {
if (is_string($request->getBody())) {
$options[CURLOPT_POSTFIELDS] = $request->getBody();
}

if ($request->getBody() instanceof FileUpload) {
$fileUpload = $request->getBody();

$options[CURLOPT_UPLOAD] = true;
$options[CURLOPT_RETURNTRANSFER] = true;
$options[CURLOPT_INFILESIZE] = $fileUpload->getSize();
$options[CURLOPT_READFUNCTION] = function () use ($fileUpload): string {
return $fileUpload->read();
};
}

if ($request->hasHeader('Accept-Encoding')) {
$options[CURLOPT_ENCODING] = implode(',', $request->getHeader('Accept-Encoding'));
}

if ($request->getMethod() === 'HEAD') {
$options[CURLOPT_NOBODY] = true;
unset(
$options[CURLOPT_UPLOAD],
$options[CURLOPT_INFILESIZE],
$options[CURLOPT_READFUNCTION],
$options[CURLOPT_WRITEFUNCTION],
);
}
Expand Down
6 changes: 6 additions & 0 deletions src/Stream/CurlMultiHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ public function execute(): void
throw new RuntimeException('Internal HttpClient: Failed to add cURL handle to multi handle: ' . curl_multi_strerror(curl_multi_errno($multiHandle)));
}

$infinityLoop = 0;

do {
$status = curl_multi_exec($multiHandle, $isRunning);
if ($isRunning) {
Expand All @@ -84,6 +86,10 @@ public function execute(): void

$this->verifyCurlHandle($multiHandle);

if (++$infinityLoop === 5) {
throw new RuntimeException('Internal HttpClient: cURL multi exec loop exceeded maximum iterations.');
}

} while ($this->header->isEmpty() && $isRunning && $status === CURLM_OK);

$this->multiHandle = $multiHandle;
Expand Down
56 changes: 56 additions & 0 deletions src/Stream/FileUpload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace Thenativeweb\Eventsourcingdb\Stream;

use InvalidArgumentException;
use SplFileObject;

class FileUpload
{
private const SUPPORTED_CONTENT_TYPES = [
'application/x-ndjson',
];

public function __construct(
private readonly SplFileObject $splFileObject,
private readonly string $contentType = 'application/x-ndjson',
) {
if (!$splFileObject->isReadable()) {
throw new InvalidArgumentException("The file {$this->splFileObject->getRealPath()} must be readable.");
}
}

public function getContentType(): string
{
if (!in_array($this->contentType, self::SUPPORTED_CONTENT_TYPES, true)) {
$supportedContentTypes = implode("', '", self::SUPPORTED_CONTENT_TYPES);
throw new InvalidArgumentException(
"Unsupported content type: '{$this->contentType}', expected '{$supportedContentTypes}'."
);
}

return $this->contentType;
}

public function isReadable(): bool
{
return $this->splFileObject->isReadable();
}

public function getRealPath(): string
{
return $this->splFileObject->getRealPath();
}

public function getSize(): int
{
return $this->splFileObject->getSize();
}

public function read(): string
{
return $this->splFileObject->fgets();
}
}
47 changes: 33 additions & 14 deletions src/Stream/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,34 +31,53 @@ public function buildUri(string $uri): string
return $buildUri;
}

public function get(string $uri, ?string $apiToken = null): Response
public function buildHeaders(?string $apiToken, null|array|object $body = null): array
{
$headers = ['Expect:'];
if ($apiToken !== null) {
$headers[] = 'Authorization: Bearer ' . $apiToken;
}
if ($body !== null && !$body instanceof FileUpload) {
$headers[] = 'Content-Type: application/json';
}
if ($body instanceof FileUpload) {
$headers[] = 'Content-Type: ' . $body->getContentType();
}

return $headers;
}

public function buildBody(null|array|object $body): string|FileUpload
{
$header = $apiToken !== null ? ['Authorization: Bearer ' . $apiToken] : [];
if ($body === null) {
return '';
}

if ($body instanceof FileUpload) {
return $body;
}

return json_encode($body);
}

public function get(string $uri, ?string $apiToken = null): Response
{
$request = new Request(
'GET',
$this->buildUri($uri),
$header,
$this->buildHeaders($apiToken),
);

return $this->sendRequest($request);
}

public function post(string $uri, ?string $apiToken = null, null|array|object $jsonValue = null): Response
public function post(string $uri, ?string $apiToken = null, null|array|object $body = null): Response
{
$header = [];
if ($apiToken !== null) {
$header[] = 'Authorization: Bearer ' . $apiToken;
}
if ($jsonValue !== null) {
$header[] = 'Content-Type: application/json';
}

$request = new Request(
'POST',
$this->buildUri($uri),
$header,
json_encode($jsonValue),
$this->buildHeaders($apiToken, $body),
$this->buildBody($body),
);

return $this->sendRequest($request);
Expand Down
2 changes: 1 addition & 1 deletion src/Stream/Queue.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function read(): string

public function write(string $data): void
{
if ($data === '') {
if (trim($data) === '') {
return;
}

Expand Down
4 changes: 2 additions & 2 deletions src/Stream/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public function __construct(
private readonly string $method,
string $uri,
array $headers = [],
private readonly ?string $body = null,
private readonly null|string|FileUpload $body = null,
private readonly string $protocolVersion = '1.1'
) {
$this->uri = new Uri($uri);
Expand All @@ -36,7 +36,7 @@ public function getProtocolVersion(): string
return $this->protocolVersion;
}

public function getBody(): ?string
public function getBody(): null|string|FileUpload
{
return $this->body;
}
Expand Down
33 changes: 33 additions & 0 deletions tests/Stream/CurlFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Thenativeweb\Eventsourcingdb\Stream\CurlFactory;
use Thenativeweb\Eventsourcingdb\Stream\FileUpload;
use Thenativeweb\Eventsourcingdb\Stream\Queue;
use Thenativeweb\Eventsourcingdb\Stream\Request;
use Thenativeweb\Eventsourcingdb\Stream\Uri;
Expand Down Expand Up @@ -121,6 +122,38 @@ public function testCreateSetsPostFieldsIfBodyExists(): void
$this->assertSame($body, $options[CURLOPT_POSTFIELDS]);
}

public function testCreateSetsReadFunctionIfBodyFileUpload(): void
{
$fileUpload = $this->createMock(FileUpload::class);
$fileUpload->method('getSize')->willReturn(123);
$fileUpload->method('read')->willReturn('chunk');

$this->uriMock->method('__toString')->willReturn('https://example.com/upload');
$this->uriMock->method('getScheme')->willReturn('https');

$this->requestMock->method('getMethod')->willReturn('POST');
$this->requestMock->method('getProtocolVersion')->willReturn('1.1');
$this->requestMock->method('getHeaders')->willReturn([]);
$this->requestMock->method('getUri')->willReturn($this->uriMock);
$this->requestMock->method('getBody')->willReturn($fileUpload);
$this->requestMock->method('hasHeader')->willReturn(false);

$options = CurlFactory::create(
$this->requestMock,
$this->headerQueueMock,
$this->writeQueueMock,
);

$this->assertArrayHasKey(CURLOPT_INFILESIZE, $options);
$this->assertArrayHasKey(CURLOPT_READFUNCTION, $options);
$this->assertArrayHasKey(CURLOPT_RETURNTRANSFER, $options);
$this->assertArrayHasKey(CURLOPT_UPLOAD, $options);
$this->assertSame(123, $options[CURLOPT_INFILESIZE]);
$this->assertIsCallable($options[CURLOPT_READFUNCTION]);
$this->assertTrue($options[CURLOPT_RETURNTRANSFER]);
$this->assertTrue($options[CURLOPT_UPLOAD]);
}

public function testCreateSetsNoBodyForHeadMethod(): void
{
$this->requestMock->method('getMethod')->willReturnCallback(static fn (): string => 'HEAD');
Expand Down
91 changes: 91 additions & 0 deletions tests/Stream/FileUploadTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

namespace Stream;

use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use SplFileObject;
use Thenativeweb\Eventsourcingdb\Stream\FileUpload;

final class FileUploadTest extends TestCase
{
private string $filePath;

protected function setUp(): void
{
$this->filePath = tempnam(sys_get_temp_dir(), 'upload_');
file_put_contents($this->filePath, "line1\nline2\n");
}

protected function tearDown(): void
{
if (file_exists($this->filePath)) {
unlink($this->filePath);
}
}

public function testItConstructsSuccessfully(): void
{
$file = new SplFileObject($this->filePath, 'r');
$fileUpload = new FileUpload($file);

$this->assertInstanceOf(FileUpload::class, $fileUpload);
$this->assertTrue($fileUpload->isReadable());
}

public function testItThrowsWhenFileIsNotReadable(): void
{
$file = new SplFileObject('php://memory', 'r');

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('must be readable.');

new FileUpload($file);
}

public function testItReturnsValidContentType(): void
{
$file = new SplFileObject($this->filePath, 'r');
$fileUpload = new FileUpload($file, 'application/x-ndjson');

$this->assertSame('application/x-ndjson', $fileUpload->getContentType());
}

public function testItThrowsOnUnsupportedContentType(): void
{
$file = new SplFileObject($this->filePath, 'r');

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Unsupported content type');

$fileUpload = new FileUpload($file, 'text/plain');
$fileUpload->getContentType();
}

public function testItReadsFirstLine(): void
{
$file = new SplFileObject($this->filePath, 'r');
$fileUpload = new FileUpload($file);

$line = $fileUpload->read();
$this->assertSame("line1\n", $line);
}

public function testItReturnsFileSize(): void
{
$file = new SplFileObject($this->filePath, 'r');
$fileUpload = new FileUpload($file);

$this->assertSame(filesize($this->filePath), $fileUpload->getSize());
}

public function testItReturnsRealPath(): void
{
$file = new SplFileObject($this->filePath, 'r');
$fileUpload = new FileUpload($file);

$this->assertSame(realpath($this->filePath), $fileUpload->getRealPath());
}
}
Loading