Skip to content

Commit

Permalink
Merge pull request #35 from sirn-se/subprotocol-middleware
Browse files Browse the repository at this point in the history
SubprotocolHandler middleware
  • Loading branch information
sirn-se authored Mar 16, 2024
2 parents 129db7c + ee5b1d8 commit c1a28ab
Show file tree
Hide file tree
Showing 12 changed files with 500 additions and 9 deletions.
10 changes: 8 additions & 2 deletions docs/Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
3 changes: 3 additions & 0 deletions docs/Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
```
2 changes: 1 addition & 1 deletion docs/Contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions docs/Middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 56 additions & 0 deletions docs/Middleware/SubprotocolNegotiation.md
Original file line number Diff line number Diff line change
@@ -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));
```
16 changes: 13 additions & 3 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -488,10 +498,10 @@ protected function performHandshake(Uri $uri): Response
$request = $request->withHeader($name, $content);
}

$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);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
99 changes: 99 additions & 0 deletions src/Middleware/SubprotocolNegotiation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

/**
* Copyright (C) 2014-2024 Textalk and contributors.
*
* This file is part of Websocket PHP and is free software under the ISC License.
* License text: https://raw.githubusercontent.com/sirn-se/websocket-php/master/COPYING.md
*/

namespace WebSocket\Middleware;

use Psr\Log\{
LoggerAwareInterface,
LoggerAwareTrait
};
use WebSocket\Connection;
use WebSocket\Exception\HandshakeException;
use WebSocket\Http\{
Message,
Request,
Response,
ServerRequest,
};
use WebSocket\Trait\StringableTrait;

/**
* WebSocket\Middleware\CloseHandler class.
* Handles close procedure.
*/
class SubprotocolNegotiation implements LoggerAwareInterface, ProcessHttpOutgoingInterface, ProcessHttpIncomingInterface
{
use LoggerAwareTrait;
use StringableTrait;

private $subprotocols;
private $require;

public function __construct(array $subprotocols, bool $require = false)
{
$this->subprotocols = $subprotocols;
$this->require = $require;
}

public function processHttpOutgoing(ProcessHttpStack $stack, Connection $connection, Message $message): Message
{
if ($message instanceof Request) {
// Outgoing requests on Client
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 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);
}

public function processHttpIncoming(ProcessHttpStack $stack, Connection $connection): Message
{
$connection->setMeta('subprotocolNegotiation.selected', null);
$message = $stack->handleHttpIncoming();

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);
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;
}
}
6 changes: 5 additions & 1 deletion src/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions tests/suites/client/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion tests/suites/middleware/CallbackTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
Loading

0 comments on commit c1a28ab

Please sign in to comment.