Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
9cff1be
Add curl/swoole adapters
abnegate Jan 15, 2026
501264b
Fix PR review issues and failing tests
abnegate Jan 15, 2026
dd8b595
Add Docker setup for Swoole testing
abnegate Jan 15, 2026
3362723
Fix Dockerfile build failure - remove composer.lock dependency
abnegate Jan 16, 2026
0a98001
Use utopia-php/base image instead of appwrite/base
abnegate Jan 16, 2026
cbacfea
Refactor adapters to use imports and reuse client handles
abnegate Jan 16, 2026
018602c
Add constructor config options for HTTP client customization
abnegate Jan 16, 2026
fb305aa
Fix handle init failure
abnegate Jan 16, 2026
4e73fe3
Add coroutines flag to Swoole and fix Curl error handling
abnegate Jan 16, 2026
7f17abc
Replace config arrays with named constructor parameters
abnegate Jan 16, 2026
0c182a3
Use Swoole\Http\Client when coroutines=false
abnegate Jan 16, 2026
9652041
Use imports instead of FQNs for Swoole clients
abnegate Jan 16, 2026
9fe5a7a
Use ::class refs for Swoole clients
abnegate Jan 16, 2026
2e2cb20
Add swoole/ide-helper for PHPStan stubs
abnegate Jan 16, 2026
0ef30e8
Update src/Adapter/Swoole.php
abnegate Jan 16, 2026
7763987
Fix stan
abnegate Jan 16, 2026
7d1f9ea
Normalise headers
abnegate Jan 16, 2026
94543f1
Update CI workflow action versions
abnegate Jan 17, 2026
0e36e12
Fix URL query building and cross-origin header leak
abnegate Jan 17, 2026
01f0ef0
Remove deprecated install parameter from setup-buildx-action
abnegate Jan 17, 2026
00792fd
Refactor request and adapter options into dedicated classes
abnegate Jan 17, 2026
51ef689
Simplify Swoole sync client instantiation
abnegate Jan 17, 2026
7c7214d
Remove sync client support from Swoole adapter
abnegate Jan 17, 2026
1efb370
Update .github/workflows/tests.yml
abnegate Jan 19, 2026
9060573
Initial plan
Copilot Jan 19, 2026
47c46fc
Replace socket_strerror with Swoole built-in errMsg
Copilot Jan 19, 2026
ae9cc01
Merge pull request #17 from utopia-php/copilot/sub-pr-16
abnegate Jan 19, 2026
5a430b7
Initial plan
Copilot Jan 19, 2026
632486c
Address PR feedback: Import Swoole\Coroutine\run and fix GraphQL hand…
Copilot Jan 19, 2026
c6e1292
Merge pull request #18 from utopia-php/copilot/sub-pr-16
abnegate Jan 19, 2026
59088bb
Use const
abnegate Jan 19, 2026
93ca185
Merge branch 'feat-adapters' of github.com:utopia-php/fetch into feat…
abnegate Jan 19, 2026
37ca2e7
Fix headers
abnegate Jan 19, 2026
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
32 changes: 22 additions & 10 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
name: "Tests"

on: [ pull_request ]
on: [pull_request]
jobs:
lint:
tests:
name: Tests
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 2

- run: git checkout HEAD^2

- name: Install dependencies
run: composer install --profile --ignore-platform-reqs

- name: Run Tests
run: php -S localhost:8000 tests/router.php &
composer test

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build image
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: fetch-dev
load: true
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Start Server
run: |
docker compose up -d --wait --wait-timeout 30

- name: Run Tests
run: docker compose exec -T php vendor/bin/phpunit --configuration phpunit.xml
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ vendor
*.cache
composer.lock
state.json
.idea
25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM composer:2.0 AS step0

WORKDIR /src/

COPY ./composer.json /src/

RUN composer update --ignore-platform-reqs --optimize-autoloader \
--no-plugins --no-scripts --prefer-dist

FROM appwrite/utopia-base:php-8.4-0.2.1 AS final

LABEL maintainer="team@utopia.io"

WORKDIR /code

COPY --from=step0 /src/vendor /code/vendor

# Add Source Code
COPY ./src /code/src
COPY ./tests /code/tests
COPY ./phpunit.xml /code/

EXPOSE 8000

CMD [ "php", "-S", "0.0.0.0:8000", "tests/router.php"]
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"require-dev": {
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.5",
"laravel/pint": "^1.5.0"
"laravel/pint": "^1.5.0",
"swoole/ide-helper": "^6.0"
},
"scripts": {
"lint": "./vendor/bin/pint --test --config pint.json",
Expand All @@ -23,4 +24,4 @@
}
},
"authors": []
}
}
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
services:
php:
image: fetch-dev
build:
context: .
ports:
- 8000:8000
volumes:
- ./tests:/code/tests
- ./src:/code/src
8 changes: 6 additions & 2 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
parameters:
level: 8
level: max
paths:
- src
- tests
- tests
scanFiles:
- vendor/swoole/ide-helper/src/swoole_library/src/core/Coroutine/functions.php
scanDirectories:
- vendor/swoole/ide-helper/src/swoole
36 changes: 36 additions & 0 deletions src/Adapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Utopia\Fetch;

use Utopia\Fetch\Options\Request as RequestOptions;

/**
* Adapter interface
* Defines the contract for HTTP adapters
* @package Utopia\Fetch
*/
interface Adapter
{
/**
* Send an HTTP request
*
* @param string $url The URL to send the request to
* @param string $method The HTTP method (GET, POST, etc.)
* @param mixed $body The request body (string, array, or null)
* @param array<string, string> $headers The request headers (formatted as key-value pairs)
* @param RequestOptions $options Request options (timeout, connectTimeout, maxRedirects, allowRedirects, userAgent)
* @param callable|null $chunkCallback Optional callback for streaming chunks
* @return Response The HTTP response
* @throws Exception If the request fails
*/
public function send(
string $url,
string $method,
mixed $body,
array $headers,
RequestOptions $options,
?callable $chunkCallback = null
): Response;
}
194 changes: 194 additions & 0 deletions src/Adapter/Curl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
<?php

declare(strict_types=1);

namespace Utopia\Fetch\Adapter;

use CurlHandle;
use Utopia\Fetch\Adapter;
use Utopia\Fetch\Chunk;
use Utopia\Fetch\Exception;
use Utopia\Fetch\Options\Curl as CurlOptions;
use Utopia\Fetch\Options\Request as RequestOptions;
use Utopia\Fetch\Response;

/**
* Curl Adapter
* HTTP adapter using PHP's cURL extension
* @package Utopia\Fetch\Adapter
*/
class Curl implements Adapter
{
private ?CurlHandle $handle = null;

/**
* @var array<int, mixed>
*/
private array $config = [];

/**
* Create a new Curl adapter
*
* @param CurlOptions|null $options Curl adapter options
*/
public function __construct(?CurlOptions $options = null)
{
$options ??= new CurlOptions();

$this->config[CURLOPT_SSL_VERIFYPEER] = $options->getSslVerifyPeer();
$this->config[CURLOPT_SSL_VERIFYHOST] = $options->getSslVerifyHost() ? 2 : 0;

if ($options->getSslCertificate() !== null) {
$this->config[CURLOPT_SSLCERT] = $options->getSslCertificate();
}

if ($options->getSslKey() !== null) {
$this->config[CURLOPT_SSLKEY] = $options->getSslKey();
}

if ($options->getCaInfo() !== null) {
$this->config[CURLOPT_CAINFO] = $options->getCaInfo();
}

if ($options->getCaPath() !== null) {
$this->config[CURLOPT_CAPATH] = $options->getCaPath();
}

if ($options->getProxy() !== null) {
$this->config[CURLOPT_PROXY] = $options->getProxy();
$this->config[CURLOPT_PROXYTYPE] = $options->getProxyType();

if ($options->getProxyUserPwd() !== null) {
$this->config[CURLOPT_PROXYUSERPWD] = $options->getProxyUserPwd();
}
}

$this->config[CURLOPT_HTTP_VERSION] = $options->getHttpVersion();
$this->config[CURLOPT_TCP_KEEPALIVE] = $options->getTcpKeepAlive() ? 1 : 0;
$this->config[CURLOPT_TCP_KEEPIDLE] = $options->getTcpKeepIdle();
$this->config[CURLOPT_TCP_KEEPINTVL] = $options->getTcpKeepInterval();
$this->config[CURLOPT_BUFFERSIZE] = $options->getBufferSize();
$this->config[CURLOPT_VERBOSE] = $options->getVerbose();
}

/**
* Get or create the cURL handle
*
* @return CurlHandle
* @throws Exception If cURL initialization fails
*/
private function getHandle(): CurlHandle
{
if ($this->handle === null) {
$handle = curl_init();
if ($handle === false) {
throw new Exception('Failed to initialize cURL handle');
}
$this->handle = $handle;
} else {
curl_reset($this->handle);
}

return $this->handle;
}

/**
* Send an HTTP request using cURL
*
* @param string $url The URL to send the request to
* @param string $method The HTTP method (GET, POST, etc.)
* @param mixed $body The request body (string, array, or null)
* @param array<string, string> $headers The request headers (formatted as key-value pairs)
* @param RequestOptions $options Request options (timeout, connectTimeout, maxRedirects, allowRedirects, userAgent)
* @param callable|null $chunkCallback Optional callback for streaming chunks
* @return Response The HTTP response
* @throws Exception If the request fails
*/
public function send(
string $url,
string $method,
mixed $body,
array $headers,
RequestOptions $options,
?callable $chunkCallback = null
): Response {
$formattedHeaders = array_map(function ($key, $value) {
return $key . ':' . $value;
}, array_keys($headers), $headers);

$responseHeaders = [];
$responseBody = '';
$chunkIndex = 0;

$ch = $this->getHandle();
$curlOptions = [
CURLOPT_URL => $url,
CURLOPT_HTTPHEADER => $formattedHeaders,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HEADERFUNCTION => function ($curl, $header) use (&$responseHeaders) {
$len = strlen($header);
$header = explode(':', $header, 2);
if (count($header) < 2) {
return $len;
}
$responseHeaders[strtolower(trim($header[0]))] = trim($header[1]);
return $len;
},
CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($chunkCallback, &$responseBody, &$chunkIndex) {
if ($chunkCallback !== null) {
$chunk = new Chunk(
data: $data,
size: strlen($data),
timestamp: microtime(true),
index: $chunkIndex++
);
$chunkCallback($chunk);
} else {
$responseBody .= $data;
}
return strlen($data);
},
CURLOPT_CONNECTTIMEOUT_MS => $options->getConnectTimeout(),
CURLOPT_TIMEOUT_MS => $options->getTimeout(),
CURLOPT_MAXREDIRS => $options->getMaxRedirects(),
CURLOPT_FOLLOWLOCATION => $options->getAllowRedirects(),
CURLOPT_USERAGENT => $options->getUserAgent()
];

if ($body !== null && $body !== [] && $body !== '') {
$curlOptions[CURLOPT_POSTFIELDS] = $body;
}

// Merge adapter config (adapter config takes precedence)
$curlOptions = $this->config + $curlOptions;

foreach ($curlOptions as $option => $value) {
curl_setopt($ch, $option, $value);
}

$success = curl_exec($ch);
if ($success === false) {
$errorMsg = curl_error($ch);
throw new Exception($errorMsg);
}

$responseStatusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

return new Response(
statusCode: $responseStatusCode,
headers: $responseHeaders,
body: $responseBody
);
}

/**
* Close the cURL handle when the adapter is destroyed
*/
public function __destruct()
{
if ($this->handle !== null) {
curl_close($this->handle);
$this->handle = null;
}
}
}
Loading