Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
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
33 changes: 23 additions & 10 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
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:
Comment on lines 10 to +22
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

GitHub Actions security best practices pinning commit SHA vs tags

💡 Result:

Short answer

  • Prefer pinning third‑party actions to a full commit SHA (immutable, safest). [1][6]
  • Only use tags (e.g., v3 or v3.1.2) if the tag/release is guaranteed immutable (and you’ve audited it); include the tag as a comment next to the SHA for clarity. [1][7]

Why (concise)

  • SHA = exact code snapshot; tags/branches are mutable and can be moved to malicious code. Pinning to SHA prevents supply‑chain surprises. [1][6]
  • GitHub now supports policies to enforce SHA pinning and block specific actions/versions, and recommends SHA pinning as best practice. [7][1]

Practical recommendations

  • Pin third‑party actions as: uses: owner/repo@ # vX.Y.Z. Use the tag in a comment so updates are easy to identify. [1]
  • Use Dependabot / Renovate (or tools like pinsha / pinact / gha-pinner) to automate finding/updating SHAs and keep pins current. [4][8][5]
  • Audit action source code or use only well‑maintained, popular actions for less risk. [1][6]
  • Enforce org/repo policies (Allowed Actions) to require SHA pins where possible. [7][1]

Tradeoffs

  • SHA = maximum security and reproducibility but needs an update workflow (automation helps). Tags = easier maintenance but weaker security unless the tag/release is immutable and verified. [1][6]

Sources
[1] GitHub Well‑Architected / Actions security (pin to commit SHA).
[4] pinsha (tool & guidance for pinning).
[5] gha‑pinner / similar tools (automation).
[6] Palo Alto Networks blog on pinning and supply‑chain risks.
[7] GitHub Changelog: Allowed actions policy & SHA pinning enforcement.


Pin GitHub Actions to immutable commit SHAs.

Tags can be retargeted, creating a supply-chain risk. Pin to commit SHAs and annotate with the tag for clarity.

🔐 Example pinning pattern
-        uses: actions/checkout@v4
+        uses: actions/checkout@<sha> # v4.x.x
...
-        uses: docker/setup-buildx-action@v3
+        uses: docker/setup-buildx-action@<sha> # v3.x.x
...
-        uses: docker/build-push-action@v6
+        uses: docker/build-push-action@<sha> # v6.x.x

Consider using Dependabot or Renovate to automate finding and updating SHAs as new versions are released.

🤖 Prompt for AI Agents
In @.github/workflows/tests.yml around lines 10 - 22, Replace floating action
tags with immutable commit SHAs: update the uses entries for
actions/checkout@v4, docker/setup-buildx-action@v3, and
docker/build-push-action@v6 to the corresponding commit SHA values (e.g.,
actions/checkout@<commit-sha>) and add a short inline comment indicating the
original tag (e.g., // was `@v4`) for clarity; ensure any other workflow action
references are similarly pinned and consider adding a note to the workflow
header about using Dependabot/Renovate for SHA updates.

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
sleep 5
- name: Run Tests
run: docker compose exec -T php vendor/bin/phpunit --configuration phpunit.xml
Comment on lines +30 to +36
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Docker Compose v2 --wait flag support ubuntu-latest GitHub Actions

💡 Result:

Short answer: Yes — docker compose's up --wait is part of Compose V2 (documented in Docker CLI) and runners for ubuntu-latest include Docker Compose V2, but the exact shipped version varies. To guarantee --wait is available in GitHub Actions, install a recent Compose V2 on the runner (for example via docker/setup-compose-action) or explicitly install a known Compose v2 release that supports --wait. [1][2][3]

Sources:

  • docker compose up docs (shows --wait and --wait-timeout). [1]
  • docker/setup-compose-action (how to install a specific/latest Compose on a runner). [2]
  • GitHub Actions runner-images releases / notes (ubuntu-latest ships Docker + Compose V2 but version varies; update schedule shown). [3]

🌐 Web query:

docker compose up --wait timeout option GitHub Actions

💡 Result:

Short answer: docker compose up supports a wait timeout via --wait-timeout (seconds) when using the v2 "docker compose" CLI — e.g. docker compose up --detach --wait --wait-timeout 60. Use the v2 CLI (not the legacy docker-compose) because GitHub-hosted runners may have the older binary without --wait/--wait-timeout. [1][2]

References:

  • Docker CLI docs (docker compose up / start — --wait and --wait-timeout). [1]
  • Reports/answers noting GH runners may have old docker-compose and to use docker compose instead. [2]

🌐 Web query:

Docker Compose 2.0 release notes --wait flag

💡 Result:

Short answer:

  • The Compose CLI supports --wait on docker compose up (it waits for services to be running/healthy and implies detached mode). [1][2]
  • Release notes also show a --wait option was added to docker compose start. [3]
  • There is no --wait for docker compose run (requested but marked not planned). [4]

Sources:
[1] StackOverflow explanation of docker compose up --wait.
[2] Docker Compose release notes (mentioning --wait on start).
[3] GitHub issue requesting --wait for docker compose run (closed/not planned).


Replace fixed sleep with service readiness wait to avoid CI flakes.

A hard sleep 5 can be too short on slower runners. Docker Compose V2 supports docker compose up --wait (with optional --wait-timeout), which waits for services to report healthy status instead of relying on a fixed delay.

✅ Example using Compose wait
       - name: Start Server
         run: |
-          docker compose up -d
-          sleep 5
+          docker compose up -d --wait --wait-timeout 30

Note: GitHub Actions ubuntu-latest includes Docker Compose V2, so --wait is available. If exact version pinning is required, use docker/setup-compose-action to install a specific release.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Start Server
run: |
docker compose up -d
sleep 5
- name: Run Tests
run: docker compose exec -T php vendor/bin/phpunit --configuration phpunit.xml
- 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
🤖 Prompt for AI Agents
In @.github/workflows/tests.yml around lines 30 - 36, Replace the fixed sleep in
the "Start Server" job with Docker Compose's service readiness wait: use `docker
compose up --wait` (optionally adding `--wait-timeout <seconds>`) instead of
`sleep 5` so the workflow waits for services to become healthy before running
the "Run Tests" step; update the "Start Server" step that currently runs `docker
compose up -d` and `sleep 5` to a single command that brings services up and
waits for readiness.

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="[email protected]"

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