From a6718bc175108ed97eddad1d2e9ba0621a033134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Fri, 19 Jan 2024 20:07:18 +0100 Subject: [PATCH 1/5] SubprotocolHandler prototype --- examples/echoserver.php | 1 + examples/send.php | 1 + src/Http/HttpHandler.php | 4 +- src/Middleware/Callback.php | 5 +- src/Middleware/SubprotocolHandler.php | 71 +++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 src/Middleware/SubprotocolHandler.php diff --git a/examples/echoserver.php b/examples/echoserver.php index 99db3ac..e13e585 100644 --- a/examples/echoserver.php +++ b/examples/echoserver.php @@ -31,6 +31,7 @@ $server ->addMiddleware(new \WebSocket\Middleware\CloseHandler()) ->addMiddleware(new \WebSocket\Middleware\PingResponder()) + ->addMiddleware(new \WebSocket\Middleware\SubprotocolHandler(['soap', 'towel'])) ; // If debug mode and logger is available diff --git a/examples/send.php b/examples/send.php index e670fc9..e5a5762 100644 --- a/examples/send.php +++ b/examples/send.php @@ -33,6 +33,7 @@ $client ->addMiddleware(new \WebSocket\Middleware\CloseHandler()) ->addMiddleware(new \WebSocket\Middleware\PingResponder()) + ->addMiddleware(new \WebSocket\Middleware\SubprotocolHandler(['towel', 'soap'])) ->onText(function ($client, $connection, $message) { echo "> Received '{$message->getContent()}' [opcode: {$message->getOpcode()}]\n"; echo "< Closing client\n"; diff --git a/src/Http/HttpHandler.php b/src/Http/HttpHandler.php index 5619059..06a0d99 100644 --- a/src/Http/HttpHandler.php +++ b/src/Http/HttpHandler.php @@ -57,6 +57,7 @@ public function pull(): MessageInterface $buffer = $this->stream->readLine(1024); $data .= $buffer; } while (substr_count($data, "\r\n\r\n") == 0); +echo "---------- PULL: $data \n"; list ($head, $body) = explode("\r\n\r\n", $data); $headers = array_filter(explode("\r\n", $head)); @@ -85,7 +86,7 @@ public function pull(): MessageInterface foreach ($headers as $header) { $parts = explode(':', $header, 2); if (count($parts) == 2) { - $message = $message->withHeader($parts[0], $parts[1]); + $message = $message->withAddedHeader($parts[0], $parts[1]); } } if ($message instanceof Request) { @@ -99,6 +100,7 @@ public function pull(): MessageInterface public function push(MessageInterface $message): MessageInterface { $data = implode("\r\n", $message->getAsArray()) . "\r\n\r\n"; +echo "---------- PUSH: $data \n"; $this->stream->write($data); return $message; } diff --git a/src/Middleware/Callback.php b/src/Middleware/Callback.php index d2d897f..2c65ec4 100644 --- a/src/Middleware/Callback.php +++ b/src/Middleware/Callback.php @@ -16,10 +16,7 @@ }; use Stringable; use WebSocket\Connection; -use WebSocket\Http\{ - HttpHandler, - Message as HttpMessage -}; +use WebSocket\Http\Message as HttpMessage; use WebSocket\Message\Message; use WebSocket\Trait\StringableTrait; diff --git a/src/Middleware/SubprotocolHandler.php b/src/Middleware/SubprotocolHandler.php new file mode 100644 index 0000000..11fbb3c --- /dev/null +++ b/src/Middleware/SubprotocolHandler.php @@ -0,0 +1,71 @@ +subprotocols = $subprotocols; + } + + public function processHttpOutgoing(ProcessHttpStack $stack, Connection $connection, Message $message): Message + { + if ($message instanceof \WebSocket\Http\Request) { + // Outgoing requests on Client + foreach ($this->subprotocols as $subprotocol) { + $message = $message->withAddedHeader('Sec-WebSocket-Protocol', $subprotocol); + } + } elseif ($message instanceof \WebSocket\Http\Response) { + // Outgoing Response + if ($this->selected) { + $message = $message->withHeader('Sec-WebSocket-Protocol', $this->selected); + } + } + + return $stack->handleHttpOutgoing($message); + } + + public function processHttpIncoming(ProcessHttpStack $stack, Connection $connection): Message + { + $this->selected = null; + $message = $stack->handleHttpIncoming(); + + if ($message instanceof \WebSocket\Http\ServerRequest) { + // Incoming requests on Server + foreach ($message->getHeader('Sec-WebSocket-Protocol') as $subprotocol) { + if (in_array($subprotocol, $this->subprotocols)) { + $this->selected = $subprotocol; + return $message; + } + } + } + + return $message; + } +} From 4f229a2c543a61640aa233de58e5ca31aae8dec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Fri, 8 Mar 2024 20:33:00 +0100 Subject: [PATCH 2/5] Subprotocol negotiation --- examples/echoserver.php | 6 +++- examples/random_client.php | 5 ++++ examples/random_server.php | 5 ++++ examples/send.php | 6 +++- src/Http/HttpHandler.php | 16 +++++----- ...Handler.php => SubprotocolNegotiation.php} | 29 ++++++++++--------- 6 files changed, 44 insertions(+), 23 deletions(-) rename src/Middleware/{SubprotocolHandler.php => SubprotocolNegotiation.php} (68%) diff --git a/examples/echoserver.php b/examples/echoserver.php index e13e585..922f37b 100644 --- a/examples/echoserver.php +++ b/examples/echoserver.php @@ -1,5 +1,10 @@ addMiddleware(new \WebSocket\Middleware\CloseHandler()) ->addMiddleware(new \WebSocket\Middleware\PingResponder()) - ->addMiddleware(new \WebSocket\Middleware\SubprotocolHandler(['soap', 'towel'])) ; // If debug mode and logger is available diff --git a/examples/random_client.php b/examples/random_client.php index da51fe4..bae7fbd 100644 --- a/examples/random_client.php +++ b/examples/random_client.php @@ -1,5 +1,10 @@ @@ -33,7 +38,6 @@ $client ->addMiddleware(new \WebSocket\Middleware\CloseHandler()) ->addMiddleware(new \WebSocket\Middleware\PingResponder()) - ->addMiddleware(new \WebSocket\Middleware\SubprotocolHandler(['towel', 'soap'])) ->onText(function ($client, $connection, $message) { echo "> Received '{$message->getContent()}' [opcode: {$message->getOpcode()}]\n"; echo "< Closing client\n"; diff --git a/src/Http/HttpHandler.php b/src/Http/HttpHandler.php index 06a0d99..2717922 100644 --- a/src/Http/HttpHandler.php +++ b/src/Http/HttpHandler.php @@ -1,10 +1,8 @@ stream->readLine(1024); $data .= $buffer; } while (substr_count($data, "\r\n\r\n") == 0); -echo "---------- PULL: $data \n"; list ($head, $body) = explode("\r\n\r\n", $data); $headers = array_filter(explode("\r\n", $head)); @@ -86,12 +83,16 @@ public function pull(): MessageInterface foreach ($headers as $header) { $parts = explode(':', $header, 2); if (count($parts) == 2) { - $message = $message->withAddedHeader($parts[0], $parts[1]); + if ($message->getheaderLine($parts[0]) === '') { + $message = $message->withHeader($parts[0], trim($parts[1])); + } else { + $message = $message->withAddedHeader($parts[0], trim($parts[1])); + } } } if ($message instanceof Request) { - $uri = new Uri("//{$message->getHeaderLine('host')}{$path}"); - $message = $message->withUri($uri); + $uri = new Uri("//{$message->getHeaderLine('Host')}{$path}"); + $message = $message->withUri($uri, true); } return $message; @@ -100,7 +101,6 @@ public function pull(): MessageInterface public function push(MessageInterface $message): MessageInterface { $data = implode("\r\n", $message->getAsArray()) . "\r\n\r\n"; -echo "---------- PUSH: $data \n"; $this->stream->write($data); return $message; } diff --git a/src/Middleware/SubprotocolHandler.php b/src/Middleware/SubprotocolNegotiation.php similarity index 68% rename from src/Middleware/SubprotocolHandler.php rename to src/Middleware/SubprotocolNegotiation.php index 11fbb3c..8a9aebc 100644 --- a/src/Middleware/SubprotocolHandler.php +++ b/src/Middleware/SubprotocolNegotiation.php @@ -14,20 +14,24 @@ LoggerAwareTrait }; use WebSocket\Connection; -use WebSocket\Http\Message; +use WebSocket\Http\{ + Message, + Request, + Response, + ServerRequest, +}; use WebSocket\Trait\StringableTrait; /** * WebSocket\Middleware\CloseHandler class. * Handles close procedure. */ -class SubprotocolHandler implements LoggerAwareInterface, ProcessHttpOutgoingInterface, ProcessHttpIncomingInterface +class SubprotocolNegotiation implements LoggerAwareInterface, ProcessHttpOutgoingInterface, ProcessHttpIncomingInterface { use LoggerAwareTrait; use StringableTrait; private $subprotocols; - private $selected = null; public function __construct(array $subprotocols) { @@ -36,36 +40,35 @@ public function __construct(array $subprotocols) public function processHttpOutgoing(ProcessHttpStack $stack, Connection $connection, Message $message): Message { - if ($message instanceof \WebSocket\Http\Request) { + if ($message instanceof Request) { // Outgoing requests on Client foreach ($this->subprotocols as $subprotocol) { $message = $message->withAddedHeader('Sec-WebSocket-Protocol', $subprotocol); } - } elseif ($message instanceof \WebSocket\Http\Response) { - // Outgoing Response - if ($this->selected) { - $message = $message->withHeader('Sec-WebSocket-Protocol', $this->selected); + } elseif ($message instanceof Response) { + // Outgoing Response from Server + if ($selected = $connection->getMeta('subprotocolNegotiation.selected')) { + $message = $message->withHeader('Sec-WebSocket-Protocol', $selected); } } - return $stack->handleHttpOutgoing($message); } public function processHttpIncoming(ProcessHttpStack $stack, Connection $connection): Message { - $this->selected = null; + $connection->setMeta('subprotocolNegotiation.selected', null); $message = $stack->handleHttpIncoming(); - if ($message instanceof \WebSocket\Http\ServerRequest) { + if ($message instanceof ServerRequest) { // Incoming requests on Server foreach ($message->getHeader('Sec-WebSocket-Protocol') as $subprotocol) { if (in_array($subprotocol, $this->subprotocols)) { - $this->selected = $subprotocol; + $connection->setMeta('subprotocolNegotiation.selected', $subprotocol); + $this->logger->info("Selected subprotocol: {$subprotocol}"); return $message; } } } - return $message; } } From 17b967e5c5571cfbb4469bdace846951798d68fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Fri, 8 Mar 2024 21:35:39 +0100 Subject: [PATCH 3/5] Subprotocol Negotiation middleware --- src/Client.php | 2 +- src/Middleware/SubprotocolNegotiation.php | 33 ++++++++++++++++++++--- src/Server.php | 6 ++++- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/Client.php b/src/Client.php index aa1307f..b50db2e 100644 --- a/src/Client.php +++ b/src/Client.php @@ -488,7 +488,7 @@ protected function performHandshake(Uri $uri): Response $request = $request->withHeader($name, $content); } - $this->connection->pushHttp($request); + $request = $this->connection->pushHttp($request); $response = $this->connection->pullHttp(); try { diff --git a/src/Middleware/SubprotocolNegotiation.php b/src/Middleware/SubprotocolNegotiation.php index 8a9aebc..d7a822f 100644 --- a/src/Middleware/SubprotocolNegotiation.php +++ b/src/Middleware/SubprotocolNegotiation.php @@ -1,7 +1,7 @@ subprotocols = $subprotocols; + $this->require = $require; } public function processHttpOutgoing(ProcessHttpStack $stack, Connection $connection, Message $message): Message @@ -45,10 +48,17 @@ public function processHttpOutgoing(ProcessHttpStack $stack, Connection $connect foreach ($this->subprotocols as $subprotocol) { $message = $message->withAddedHeader('Sec-WebSocket-Protocol', $subprotocol); } + if ($supported = implode(', ', $this->subprotocols)) { + $this->logger->debug("[subprotocol-negotiation] Requested subprotocols: {$supported}"); + } } elseif ($message instanceof Response) { - // Outgoing Response from Server + // Outgoing Response on Server if ($selected = $connection->getMeta('subprotocolNegotiation.selected')) { $message = $message->withHeader('Sec-WebSocket-Protocol', $selected); + $this->logger->info("[subprotocol-negotiation] Selected subprotocol: {$selected}"); + } elseif ($this->require) { + // No matching subprotocol, fail handshake + $message = $message->withStatus(426); } } return $stack->handleHttpOutgoing($message); @@ -61,13 +71,28 @@ public function processHttpIncoming(ProcessHttpStack $stack, Connection $connect if ($message instanceof ServerRequest) { // Incoming requests on Server + if ($requested = $message->getHeaderLine('Sec-WebSocket-Protocol')) { + $this->logger->debug("[subprotocol-negotiation] Requested subprotocols: {$requested}"); + } + if ($supported = implode(', ', $this->subprotocols)) { + $this->logger->debug("[subprotocol-negotiation] Supported subprotocols: {$supported}"); + } foreach ($message->getHeader('Sec-WebSocket-Protocol') as $subprotocol) { if (in_array($subprotocol, $this->subprotocols)) { $connection->setMeta('subprotocolNegotiation.selected', $subprotocol); - $this->logger->info("Selected subprotocol: {$subprotocol}"); return $message; } } + } elseif ($message instanceof Response) { + // Incoming Response on Client + if ($selected = $message->getHeaderLine('Sec-WebSocket-Protocol')) { + $connection->setMeta('subprotocolNegotiation.selected', $selected); + $this->logger->info("[subprotocol-negotiation] Selected subprotocol: {$selected}"); + } elseif ($this->require) { + // No matching subprotocol, close and fail + $connection->close(); + throw new HandshakeException("Could not resolve subprotocol.", $message); + } } return $message; } diff --git a/src/Server.php b/src/Server.php index 94f2c38..b6553cd 100644 --- a/src/Server.php +++ b/src/Server.php @@ -480,7 +480,11 @@ protected function performHandshake(Connection $connection): ServerRequest } // Respond to handshake - $connection->pushHttp($response); + $response = $connection->pushHttp($response); + if ($response->getStatusCode() != 101) { + $exception = new HandshakeException("Invalid status code {$response->getStatusCode()}", $response); + } + if ($exception) { throw $exception; } From 97e321caa97459bf869cd6d95f77c685d0241565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 9 Mar 2024 16:29:34 +0100 Subject: [PATCH 4/5] SubprotocolNegotiation docs and tests. --- docs/Middleware.md | 1 + docs/Middleware/SubprotocolNegotiation.md | 56 ++++ src/Client.php | 10 + src/Connection.php | 2 +- tests/suites/client/ClientTest.php | 1 + tests/suites/middleware/CallbackTest.php | 3 +- .../middleware/SubprotocolNegotiationTest.php | 310 ++++++++++++++++++ 7 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 docs/Middleware/SubprotocolNegotiation.md create mode 100644 tests/suites/middleware/SubprotocolNegotiationTest.php diff --git a/docs/Middleware.md b/docs/Middleware.md index fc75444..8a712f8 100644 --- a/docs/Middleware.md +++ b/docs/Middleware.md @@ -33,6 +33,7 @@ These middlewares are included in library and can be added to provide additional * [PingInterval](Middleware/PingInterval.md) - Used to automatically send Ping messages at specified interval * [Callback](Middleware/Callback.md) - Apply provided callback function on specified actions +* [SubprotocolNegotiation](Middleware/SubprotocolNegotiation.md) - Helper middleware that negotiate subprotocol ## Creating your own middleware diff --git a/docs/Middleware/SubprotocolNegotiation.md b/docs/Middleware/SubprotocolNegotiation.md new file mode 100644 index 0000000..8eb2ec9 --- /dev/null +++ b/docs/Middleware/SubprotocolNegotiation.md @@ -0,0 +1,56 @@ +[Documentation](../Index.md) / [Middleware](../Middleware.md) / SubprotocolNegotiation + +# Websocket: SubprotocolNegotiation middleware + +This middlewares is included in library and can be added to provide additional functionality. + +It can be added to both Client and Server to help negotiate subprotocol to use. +Note: This Middleware only negotiate protocols, it does NOT implement any subprotocols. + +## Client + +When used on Client, it will send a list of requested subprotocols to the Server. +The Server is then expected to respond with the first requested subprotocol it supports, if any. +The Client MUST expect Server to send messages according to selected subprotocol. + +```php +$client->addMiddleware(new WebSocket\Middleware\SubprotocolNegotiation([ + 'subproto-1', + 'subproto-2', + 'subproto-3', +])); +$client->connect(); +$selected_subprotocol = $this->client->getMeta('subprotocolNegotiation.selected'); +``` + +## Server + +When added on Server, it should be defined with a list of subprotocols that Server support. +When Client request subprotocols, it will select the first requested protocol available in the list. +The ClienServert MUST expect Client to send messages according to selected subprotocol. + +```php +$server->addMiddleware(new WebSocket\Middleware\SubprotocolNegotiation([ + 'subproto-1', + 'subproto-2', + 'subproto-3', +])); +$server->->onText(function (WebSocket\Server $server, WebSocket\Connection $connection, WebSocket\Message\Message $message) { + $selected_subprotocol = $connection->getMeta('subprotocolNegotiation.selected'); +})->start(); +``` + +## Require option + +If second parameter is set to `true` a failed negotiation will close connection. + +* When used on Client, this will cause a `HandshakeException`. +* When used on Server, server will respond with `426 Upgrade Required` HTTP error status. + +```php +$client->addMiddleware(new WebSocket\Middleware\SubprotocolNegotiation([ + 'subproto-1', + 'subproto-2', + 'subproto-3', +], true)); +``` diff --git a/src/Client.php b/src/Client.php index b50db2e..b7f8d62 100644 --- a/src/Client.php +++ b/src/Client.php @@ -448,6 +448,16 @@ public function getRemoteName(): string|null return $this->isConnected() ? $this->connection->getRemoteName() : null; } + /** + * Get meta value on connection. + * @param string $key Meta key + * @return mixed Meta value + */ + public function getMeta(string $key): mixed + { + return $this->isConnected() ? $this->connection->getMeta($key) : null; + } + /** * Get Response for handshake procedure. * @return Response|null Handshake. diff --git a/src/Connection.php b/src/Connection.php index 61525c3..15d889d 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -256,7 +256,7 @@ public function setMeta(string $key, mixed $value): void } /** - * Set meta value on connection. + * Get meta value on connection. * @param string $key Meta key * @return mixed Meta value */ diff --git a/tests/suites/client/ClientTest.php b/tests/suites/client/ClientTest.php index 0968651..89401b1 100644 --- a/tests/suites/client/ClientTest.php +++ b/tests/suites/client/ClientTest.php @@ -736,6 +736,7 @@ public function testConvenicanceMethods(): void $this->assertNull($client->getName()); $this->assertNull($client->getRemoteName()); + $this->assertNull($client->getMeta('metadata')); $this->assertEquals('WebSocket\Client(closed)', "{$client}"); $this->expectWsClientConnect(); diff --git a/tests/suites/middleware/CallbackTest.php b/tests/suites/middleware/CallbackTest.php index 75d9d56..3987304 100644 --- a/tests/suites/middleware/CallbackTest.php +++ b/tests/suites/middleware/CallbackTest.php @@ -15,6 +15,7 @@ use Psr\Log\NullLogger; use Stringable; use WebSocket\Connection; +use WebSocket\Http\Response; use WebSocket\Message\Text; use WebSocket\Middleware\Callback; @@ -148,7 +149,7 @@ public function testHttpOutgoing(): void return $message; })); $this->expectSocketStreamWrite(); - $message = $connection->pushHttp(new \WebSocket\Http\Response(200)); + $message = $connection->pushHttp(new Response(200)); $this->assertEquals(400, $message->getStatusCode()); $this->expectSocketStreamIsConnected(); diff --git a/tests/suites/middleware/SubprotocolNegotiationTest.php b/tests/suites/middleware/SubprotocolNegotiationTest.php new file mode 100644 index 0000000..89f18c4 --- /dev/null +++ b/tests/suites/middleware/SubprotocolNegotiationTest.php @@ -0,0 +1,310 @@ +setUpStack(); + } + + public function tearDown(): void + { + $this->tearDownStack(); + } + + public function testClientProtocolMatch(): void + { + $temp = tmpfile(); + + $middleware = new SubprotocolNegotiation(['sp-1', 'sp-2', 'sp-3']); + $this->assertEquals('WebSocket\Middleware\SubprotocolNegotiation', "{$middleware}"); + $this->assertInstanceOf(Stringable::class, $middleware); + + $this->expectSocketStream(); + $this->expectSocketStreamGetMetadata(); + $stream = new SocketStream($temp); + + $this->expectSocketStreamGetLocalName(); + $this->expectSocketStreamGetRemoteName(); + $connection = new Connection($stream, false, false); + $connection->addMiddleware($middleware); + + $this->expectSocketStreamWrite()->addAssert( + function (string $method, array $params): void { + $this->assertEquals( + "GET / HTTP/1.1\r\nHost: test.url\r\n" + . "Sec-WebSocket-Protocol: sp-1\r\n" + . "Sec-WebSocket-Protocol: sp-2\r\n" + . "Sec-WebSocket-Protocol: sp-3\r\n\r\n", + $params[0] + ); + } + ); + $request = new Request('GET', 'ws://test.url'); + $request = $connection->pushHttp($request); + $this->assertEquals(['sp-1', 'sp-2', 'sp-3'], $request->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamReadLine()->setReturn(function () { + return "HTTP/1.1 200 OK\r\nSec-WebSocket-Protocol: sp-2\r\n\r\n"; + }); + $response = $connection->pullHttp(); + $this->assertEquals(['sp-2'], $response->getHeader('Sec-WebSocket-Protocol')); + $this->assertEquals('sp-2', $connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamIsConnected(); + $this->expectSocketStreamClose(); + unset($connection); + } + + public function testClientProtocolNoMatch(): void + { + $temp = tmpfile(); + + $middleware = new SubprotocolNegotiation(['sp-1', 'sp-2', 'sp-3']); + $this->assertEquals('WebSocket\Middleware\SubprotocolNegotiation', "{$middleware}"); + $this->assertInstanceOf(Stringable::class, $middleware); + + $this->expectSocketStream(); + $this->expectSocketStreamGetMetadata(); + $stream = new SocketStream($temp); + + $this->expectSocketStreamGetLocalName(); + $this->expectSocketStreamGetRemoteName(); + $connection = new Connection($stream, false, false); + $connection->addMiddleware($middleware); + + $this->expectSocketStreamWrite()->addAssert( + function (string $method, array $params): void { + $this->assertEquals( + "GET / HTTP/1.1\r\nHost: test.url\r\n" + . "Sec-WebSocket-Protocol: sp-1\r\n" + . "Sec-WebSocket-Protocol: sp-2\r\n" + . "Sec-WebSocket-Protocol: sp-3\r\n\r\n", + $params[0] + ); + } + ); + $request = new Request('GET', 'ws://test.url'); + $request = $connection->pushHttp($request); + $this->assertEquals(['sp-1', 'sp-2', 'sp-3'], $request->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamReadLine()->setReturn(function () { + return "HTTP/1.1 200 OK\r\n\r\n"; + }); + $response = $connection->pullHttp(); + $this->assertEquals([], $response->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamIsConnected(); + $this->expectSocketStreamClose(); + unset($connection); + } + + public function testClientProtocolRequire(): void + { + $temp = tmpfile(); + + $middleware = new SubprotocolNegotiation(['sp-1', 'sp-2', 'sp-3'], true); + $this->assertEquals('WebSocket\Middleware\SubprotocolNegotiation', "{$middleware}"); + $this->assertInstanceOf(Stringable::class, $middleware); + + $this->expectSocketStream(); + $this->expectSocketStreamGetMetadata(); + $stream = new SocketStream($temp); + + $this->expectSocketStreamGetLocalName(); + $this->expectSocketStreamGetRemoteName(); + $connection = new Connection($stream, false, false); + $connection->addMiddleware($middleware); + + $this->expectSocketStreamWrite()->addAssert( + function (string $method, array $params): void { + $this->assertEquals( + "GET / HTTP/1.1\r\nHost: test.url\r\n" + . "Sec-WebSocket-Protocol: sp-1\r\n" + . "Sec-WebSocket-Protocol: sp-2\r\n" + . "Sec-WebSocket-Protocol: sp-3\r\n\r\n", + $params[0] + ); + } + ); + $request = new Request('GET', 'ws://test.url'); + $request = $connection->pushHttp($request); + $this->assertEquals(['sp-1', 'sp-2', 'sp-3'], $request->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamReadLine()->setReturn(function () { + return "HTTP/1.1 200 OK\r\n\r\n"; + }); + $this->expectSocketStreamWrite(); + $this->expectSocketStreamIsConnected(); + $this->expectSocketStreamClose(); + $this->expectException(HandshakeException::class); + $this->expectExceptionMessage('Could not resolve subprotocol.'); + $connection->pullHttp(); + } + + public function testServerProtocolMatch(): void + { + $temp = tmpfile(); + + $middleware = new SubprotocolNegotiation(['sp-1', 'sp-2', 'sp-3']); + $this->assertEquals('WebSocket\Middleware\SubprotocolNegotiation', "{$middleware}"); + $this->assertInstanceOf(Stringable::class, $middleware); + + $this->expectSocketStream(); + $this->expectSocketStreamGetMetadata(); + $stream = new SocketStream($temp); + + $this->expectSocketStreamGetLocalName(); + $this->expectSocketStreamGetRemoteName(); + $connection = new Connection($stream, false, false); + $connection->addMiddleware($middleware); + + $this->expectSocketStreamReadLine()->setReturn(function () { + return "GET / HTTP/1.1\r\nHost: test.url\r\n" + . "Sec-WebSocket-Protocol: sp-11\r\n" + . "Sec-WebSocket-Protocol: sp-2\r\n" + . "Sec-WebSocket-Protocol: sp-33\r\n\r\n"; + }); + $request = $connection->pullHttp(); + $this->assertEquals(['sp-11', 'sp-2', 'sp-33'], $request->getHeader('Sec-WebSocket-Protocol')); + $this->assertEquals('sp-2', $connection->getMeta('subprotocolNegotiation.selected')); + + $response = new Response(200); + $this->expectSocketStreamWrite()->addAssert( + function (string $method, array $params): void { + $this->assertEquals( + "HTTP/1.1 200 OK\r\nSec-WebSocket-Protocol: sp-2\r\n\r\n", + $params[0] + ); + } + ); + $response = $connection->pushHttp($response); + $this->assertEquals(['sp-2'], $response->getHeader('Sec-WebSocket-Protocol')); + $this->assertEquals('sp-2', $connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamIsConnected(); + $this->expectSocketStreamClose(); + unset($connection); + } + + public function testServerProtocolNoMatch(): void + { + $temp = tmpfile(); + + $middleware = new SubprotocolNegotiation(['sp-1', 'sp-2', 'sp-3']); + $this->assertEquals('WebSocket\Middleware\SubprotocolNegotiation', "{$middleware}"); + $this->assertInstanceOf(Stringable::class, $middleware); + + $this->expectSocketStream(); + $this->expectSocketStreamGetMetadata(); + $stream = new SocketStream($temp); + + $this->expectSocketStreamGetLocalName(); + $this->expectSocketStreamGetRemoteName(); + $connection = new Connection($stream, false, false); + $connection->addMiddleware($middleware); + + $this->expectSocketStreamReadLine()->setReturn(function () { + return "GET / HTTP/1.1\r\nHost: test.url\r\n" + . "Sec-WebSocket-Protocol: sp-11\r\n" + . "Sec-WebSocket-Protocol: sp-22\r\n" + . "Sec-WebSocket-Protocol: sp-33\r\n\r\n"; + }); + $request = $connection->pullHttp(); + $this->assertEquals(['sp-11', 'sp-22', 'sp-33'], $request->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $response = new Response(200); + $this->expectSocketStreamWrite()->addAssert( + function (string $method, array $params): void { + $this->assertEquals( + "HTTP/1.1 200 OK\r\n\r\n", + $params[0] + ); + } + ); + $response = $connection->pushHttp($response); + $this->assertEquals([], $response->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamIsConnected(); + $this->expectSocketStreamClose(); + unset($connection); + } + + public function testServerProtocolRequire(): void + { + $temp = tmpfile(); + + $middleware = new SubprotocolNegotiation(['sp-1', 'sp-2', 'sp-3'], true); + $this->assertEquals('WebSocket\Middleware\SubprotocolNegotiation', "{$middleware}"); + $this->assertInstanceOf(Stringable::class, $middleware); + + $this->expectSocketStream(); + $this->expectSocketStreamGetMetadata(); + $stream = new SocketStream($temp); + + $this->expectSocketStreamGetLocalName(); + $this->expectSocketStreamGetRemoteName(); + $connection = new Connection($stream, false, false); + $connection->addMiddleware($middleware); + + $this->expectSocketStreamReadLine()->setReturn(function () { + return "GET / HTTP/1.1\r\nHost: test.url\r\n" + . "Sec-WebSocket-Protocol: sp-11\r\n" + . "Sec-WebSocket-Protocol: sp-22\r\n" + . "Sec-WebSocket-Protocol: sp-33\r\n\r\n"; + }); + $request = $connection->pullHttp(); + $this->assertEquals(['sp-11', 'sp-22', 'sp-33'], $request->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $response = new Response(200); + $this->expectSocketStreamWrite()->addAssert( + function (string $method, array $params): void { + $this->assertEquals( + "HTTP/1.1 426 Upgrade Required\r\n\r\n", + $params[0] + ); + } + ); + $response = $connection->pushHttp($response); + $this->assertEquals([], $response->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamIsConnected(); + $this->expectSocketStreamClose(); + unset($connection); + } +} From ee5b1d88667dd7d607853090f2eb20570011deda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 16 Mar 2024 11:47:44 +0100 Subject: [PATCH 5/5] Docs, catch handshake push/pull --- docs/Changelog.md | 10 ++++++++-- docs/Client.md | 3 +++ docs/Contributing.md | 2 +- src/Client.php | 6 +++--- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/Changelog.md b/docs/Changelog.md index c9e09e8..1c124ca 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -8,8 +8,14 @@ ### `2.2.0` - * Documentation review (@sirn-se) - * Dependency updates (@sirn-se) + * Optional `SubprotocolNegotiation` middleware (@sirn-se) + * `getMeta()` exposed on Client (@sirn-se) + * Server throws `HandshakeException` if HTTP middleware return invalid status (@sirn-se) + * Improved URI handling (@sirn-se) + * Allow empty HTTP header handling (RFC compliance) (@sirn-se) + * Documentation changes (@sirn-se) + * Using `phrity/net v2` (@sirn-se) + * Updating workflow and dependencies (@sirn-se) ## `v2.1` diff --git a/docs/Client.md b/docs/Client.md index c351705..8db3d62 100644 --- a/docs/Client.md +++ b/docs/Client.md @@ -187,6 +187,9 @@ echo "local: {$client->getName()}\n"; // View server name echo "remote: {$client->getRemoteName()}\n"; +// Get meta data by key +echo "meta: {$client->getMeta('some-metadata')}\n"; + // Get response on handshake $response = $client->getHandshakeResponse(); ``` diff --git a/docs/Contributing.md b/docs/Contributing.md index 68c85b5..2342a6c 100644 --- a/docs/Contributing.md +++ b/docs/Contributing.md @@ -27,7 +27,7 @@ Base your patch on corresponding version branch, and target that version branch | [`2.2`](https://github.com/sirn-se/websocket-php/tree/2.2.0) | `v2.2-main` | `^8.0` | Current version | | [`2.1`](https://github.com/sirn-se/websocket-php/tree/2.1.0) | `v2.1-main` | `^8.0` | Bug fixes only | | [`2.0`](https://github.com/sirn-se/websocket-php/tree/2.0.0) | - | `^8.0` | Not supported | -| [`1.7`](https://github.com/sirn-se/websocket-php/tree/1.7.0) | - | `^7.4\|^8.0` | Not supported | +| [`1.7`](https://github.com/sirn-se/websocket-php/tree/1.7.0) | `v1.7-master` | `^7.4\|^8.0` | Bug fixes only | | [`1.6`](https://github.com/sirn-se/websocket-php/tree/1.6.0) | - | `^7.4\|^8.0` | Not supported | | [`1.5`](https://github.com/sirn-se/websocket-php/tree/1.5.0) | - | `^7.4\|^8.0` | Not supported | | [`1.4`](https://github.com/sirn-se/websocket-php/tree/1.4.0) | - | `^7.1` | Not supported | diff --git a/src/Client.php b/src/Client.php index b7f8d62..998684a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -498,10 +498,10 @@ protected function performHandshake(Uri $uri): Response $request = $request->withHeader($name, $content); } - $request = $this->connection->pushHttp($request); - $response = $this->connection->pullHttp(); - try { + $request = $this->connection->pushHttp($request); + $response = $this->connection->pullHttp(); + if ($response->getStatusCode() != 101) { throw new HandshakeException("Invalid status code {$response->getStatusCode()}.", $response); }