Skip to content

Commit 484f5d2

Browse files
async http
1 parent 37ef998 commit 484f5d2

File tree

9 files changed

+245
-7
lines changed

9 files changed

+245
-7
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ Homestead.json
2929
.phpactor.json
3030
auth.json
3131
composer.lock
32+
.phpunit.cache/

.phpunit.cache/test-results

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"version":1,"defects":{"AsyncHttpClientTest::test_simple_get_request":8},"times":{"AsyncHttpClientTest::test_simple_get_request":0.02}}
1+
{"version":1,"defects":{"AsyncHttpClientTest::test_simple_get_request":5},"times":{"AsyncHttpClientTest::test_simple_get_request":1.039}}

composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
"type": "library",
55
"require": {
66
"php": "^8.4",
7-
"ext-sockets": "*"
7+
"ext-sockets": "*",
8+
"psr/http-client": "^1.0",
9+
"psr/http-message": "^2.0"
810
},
911
"require-dev": {
1012
"phpunit/phpunit": "^12.2",

phpunit.xml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
bootstrap="vendor/autoload.php"
55
cacheDirectory=".phpunit.cache"
66
executionOrder="depends,defects"
7-
requireCoverageMetadata="true"
8-
beStrictAboutCoverageMetadata="true"
9-
beStrictAboutOutputDuringTests="true"
7+
requireCoverageMetadata="false"
8+
beStrictAboutCoverageMetadata="false"
9+
beStrictAboutOutputDuringTests="false"
1010
displayDetailsOnPhpunitDeprecations="true"
1111
failOnPhpunitDeprecation="true"
1212
failOnRisky="true"

src/Http/AsyncHttpClient.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
namespace Async\Http;
4+
5+
class AsyncHttpClient
6+
{
7+
public function get(string $url, array $headers = []): \Generator
8+
{
9+
return $this->request('GET', $url, $headers);
10+
}
11+
12+
public function post(string $url, array $headers = [], string $body = ''): \Generator
13+
{
14+
return $this->request('POST', $url, $headers, $body);
15+
}
16+
17+
public function request(string $method, string $url, array $headers = [], string $body = ''): \Generator
18+
{
19+
$parts = parse_url($url);
20+
$scheme = $parts['scheme'] ?? 'http';
21+
$host = $parts['host'] ?? 'localhost';
22+
$port = $scheme === 'https' ? 443 : 80;
23+
$path = $parts['path'] ?? '/';
24+
$query = $parts['query'] ?? '';
25+
if ($query) {
26+
$path .= '?'.$query;
27+
}
28+
29+
$remote = ($scheme === 'https' ? 'ssl://' : '').$host.':'.$port;
30+
$errno = $errstr = null;
31+
32+
$fp = stream_socket_client($remote, $errno, $errstr, 5, STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT);
33+
if (! $fp) {
34+
throw new \Exception("Connection failed: $errstr");
35+
}
36+
37+
stream_set_blocking($fp, false);
38+
39+
// Build request headers
40+
$headerStr = "$method $path HTTP/1.1\r\n";
41+
$headerStr .= "Host: $host\r\n";
42+
$headerStr .= "Connection: close\r\n";
43+
44+
foreach ($headers as $k => $v) {
45+
$headerStr .= "$k: $v\r\n";
46+
}
47+
48+
if ($body !== '') {
49+
$headerStr .= 'Content-Length: '.strlen($body)."\r\n";
50+
}
51+
52+
$headerStr .= "\r\n".$body;
53+
54+
fwrite($fp, $headerStr);
55+
56+
// Wait for response
57+
$read = [$fp];
58+
$write = null;
59+
$except = null;
60+
if (stream_select($read, $write, $except, 5)) {
61+
$response = '';
62+
while (! feof($fp)) {
63+
$chunk = fread($fp, 8192);
64+
if ($chunk === false) {
65+
break;
66+
}
67+
$response .= $chunk;
68+
}
69+
fclose($fp);
70+
71+
// Split headers and body
72+
[$rawHeaders, $body] = explode("\r\n\r\n", $response, 2);
73+
74+
return yield new HttpResponse($rawHeaders, $body);
75+
} else {
76+
throw new \Exception('Timeout waiting for response');
77+
}
78+
}
79+
80+
public function put(string $url, array $headers = [], string $body = ''): \Generator
81+
{
82+
return $this->request('PUT', $url, $headers, $body);
83+
}
84+
85+
public function patch(string $url, array $headers = [], string $body = ''): \Generator
86+
{
87+
return $this->request('PATCH', $url, $headers, $body);
88+
}
89+
90+
public function delete(string $url, array $headers = []): \Generator
91+
{
92+
return $this->request('DELETE', $url, $headers);
93+
}
94+
}

src/Http/HttpResponse.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace Async\Http;
4+
5+
class HttpResponse
6+
{
7+
public int $statusCode;
8+
9+
public array $headers = [];
10+
11+
public string $body;
12+
13+
public function __construct(string $rawHeaders, string $body)
14+
{
15+
$this->body = $body;
16+
$lines = explode("\r\n", $rawHeaders);
17+
$statusLine = array_shift($lines);
18+
preg_match('#HTTP/\d\.\d\s+(\d+)#', $statusLine, $matches);
19+
$this->statusCode = (int) ($matches[1] ?? 0);
20+
21+
foreach ($lines as $line) {
22+
if (strpos($line, ':') !== false) {
23+
[$key, $value] = explode(':', $line, 2);
24+
$this->headers[trim($key)] = trim($value);
25+
}
26+
}
27+
}
28+
29+
public function getBody(): \Psr\Http\Message\StreamInterface
30+
{
31+
return new Stream($this->body);
32+
}
33+
34+
public function json(): mixed
35+
{
36+
return json_decode($this->body, true);
37+
}
38+
39+
public function text(): string
40+
{
41+
return $this->body;
42+
}
43+
}

src/Http/Promise.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace Async\Http;
4+
5+
class Promise
6+
{
7+
private \Fiber $fiber;
8+
9+
public function __construct(callable $callback)
10+
{
11+
$this->fiber = new \Fiber($callback);
12+
}
13+
14+
public function await()
15+
{
16+
return $this->fiber->start();
17+
}
18+
}

src/Http/Stream.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
namespace Async\Http;
4+
5+
use Psr\Http\Message\StreamInterface;
6+
7+
class Stream implements StreamInterface
8+
{
9+
private string $contents;
10+
11+
public function __construct(string $contents)
12+
{
13+
$this->contents = $contents;
14+
}
15+
16+
public function __toString(): string
17+
{
18+
return $this->contents;
19+
}
20+
21+
public function close(): void {}
22+
23+
public function detach()
24+
{
25+
return null;
26+
}
27+
28+
public function getSize(): ?int
29+
{
30+
return strlen($this->contents);
31+
}
32+
33+
public function tell(): int
34+
{
35+
return 0;
36+
}
37+
38+
public function eof(): bool
39+
{
40+
return true;
41+
}
42+
43+
public function isSeekable(): bool
44+
{
45+
return false;
46+
}
47+
48+
public function seek($offset, $whence = SEEK_SET): void {}
49+
50+
public function rewind(): void {}
51+
52+
public function isWritable(): bool
53+
{
54+
return false;
55+
}
56+
57+
public function write($string): int
58+
{
59+
return 0;
60+
}
61+
62+
public function isReadable(): bool
63+
{
64+
return true;
65+
}
66+
67+
public function read($length): string
68+
{
69+
return substr($this->contents, 0, $length);
70+
}
71+
72+
public function getContents(): string
73+
{
74+
return $this->contents;
75+
}
76+
77+
public function getMetadata($key = null): mixed
78+
{
79+
return null;
80+
}
81+
}

tests/AsyncHttpClientTest.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
<?php
22

3+
use Async\Http\AsyncHttpClient;
34
use PHPUnit\Framework\TestCase;
45

5-
// use Async\Http\AsyncHttpClient;
6-
76
class AsyncHttpClientTest extends TestCase
87
{
98
public function test_simple_get_request()

0 commit comments

Comments
 (0)