Skip to content

Commit 5bf6964

Browse files
committed
fix(webpush): Add an adapter for async requests
Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Joas Schilling <coding@schilljs.com>
1 parent 66504fe commit 5bf6964

9 files changed

Lines changed: 527 additions & 5 deletions

File tree

lib/Controller/WebPushController.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use OCP\Authentication\Exceptions\InvalidTokenException;
2525
use OCP\Authentication\Token\IToken;
2626
use OCP\DB\QueryBuilder\IQueryBuilder;
27+
use OCP\Http\Client\IClientService;
2728
use OCP\IDBConnection;
2829
use OCP\IRequest;
2930
use OCP\ISession;
@@ -45,6 +46,7 @@ public function __construct(
4546
protected IProvider $tokenProvider,
4647
protected Manager $identityProof,
4748
protected IRemoteHostValidator $hostValidator,
49+
protected IClientService $clientService,
4850
protected LoggerInterface $logger,
4951
) {
5052
parent::__construct($appName, $request);
@@ -233,7 +235,7 @@ public function removeWP(): DataResponse {
233235
}
234236

235237
protected function getWPClient(): WebPushClient {
236-
return new WebPushClient($this->appConfig);
238+
return new WebPushClient($this->appConfig, $this->clientService);
237239
}
238240

239241
/**

lib/WebPush/ClientAdapter.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Notifications\WebPush;
11+
12+
use OCA\Notifications\Vendor\GuzzleHttp\Psr7\Response;
13+
use OCA\Notifications\Vendor\Http\Client\HttpAsyncClient;
14+
use OCA\Notifications\Vendor\Http\Promise\Promise;
15+
use OCA\Notifications\Vendor\Http\Promise\RejectedPromise;
16+
use OCA\Notifications\Vendor\Psr\Http\Client\ClientInterface;
17+
use OCA\Notifications\Vendor\Psr\Http\Message\RequestInterface;
18+
use OCA\Notifications\Vendor\Psr\Http\Message\ResponseInterface;
19+
use OCP\Http\Client\IClient;
20+
use OCP\Http\Client\IResponse;
21+
22+
/**
23+
* Adapts the Nextcloud HTTP client to the PSR-18 and HTTPlug client interfaces
24+
* of the vendored web-push library, so requests to the push services honour the
25+
* proxy, timeout and certificate configuration of the instance.
26+
*/
27+
class ClientAdapter implements ClientInterface, HttpAsyncClient {
28+
public function __construct(
29+
protected IClient $client,
30+
) {
31+
}
32+
33+
#[\Override]
34+
public function sendRequest(RequestInterface $request): ResponseInterface {
35+
try {
36+
$response = $this->client->request(
37+
$request->getMethod(),
38+
(string)$request->getUri(),
39+
self::buildOptions($request),
40+
);
41+
} catch (\Throwable $e) {
42+
throw new ClientException($e->getMessage(), (int)$e->getCode(), $e);
43+
}
44+
45+
return self::convertResponse($response);
46+
}
47+
48+
/**
49+
* Web push notifications are always sent as POST requests, so only those can
50+
* be handed to the asynchronous API of the Nextcloud client.
51+
*/
52+
#[\Override]
53+
public function sendAsyncRequest(RequestInterface $request): Promise {
54+
if (strtoupper($request->getMethod()) !== 'POST') {
55+
return new RejectedPromise(new ClientException('Only POST requests can be sent asynchronously'));
56+
}
57+
58+
try {
59+
$promise = $this->client->postAsync((string)$request->getUri(), self::buildOptions($request));
60+
} catch (\Throwable $e) {
61+
return new RejectedPromise($e);
62+
}
63+
64+
return new PromiseAdapter($promise);
65+
}
66+
67+
public static function convertResponse(IResponse $response): ResponseInterface {
68+
return new Response(
69+
$response->getStatusCode(),
70+
$response->getHeaders(),
71+
$response->getBody(),
72+
);
73+
}
74+
75+
/**
76+
* @return array<string, mixed>
77+
*/
78+
protected static function buildOptions(RequestInterface $request): array {
79+
return [
80+
'headers' => $request->getHeaders(),
81+
'body' => (string)$request->getBody(),
82+
// Push services report expired subscriptions and rate limits with error
83+
// status codes, they have to be inspected instead of being thrown
84+
'http_errors' => false,
85+
];
86+
}
87+
}

lib/WebPush/ClientException.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Notifications\WebPush;
11+
12+
use OCA\Notifications\Vendor\Psr\Http\Client\ClientExceptionInterface;
13+
14+
class ClientException extends \RuntimeException implements ClientExceptionInterface {
15+
}

lib/WebPush/PromiseAdapter.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Notifications\WebPush;
11+
12+
use OCA\Notifications\Vendor\Http\Promise\Promise;
13+
use OCA\Notifications\Vendor\Psr\Http\Message\ResponseInterface;
14+
use OCP\Http\Client\IPromise;
15+
use OCP\Http\Client\IResponse;
16+
17+
/**
18+
* Adapts a promise of the Nextcloud HTTP client to the HTTPlug promise the
19+
* vendored web-push library works with.
20+
*/
21+
class PromiseAdapter implements Promise {
22+
private ?ResponseInterface $response = null;
23+
private ?\Throwable $reason = null;
24+
private bool $settled = false;
25+
/** @var list<callable(ResponseInterface): void> */
26+
private array $onFulfilled = [];
27+
/** @var list<callable(\Throwable): void> */
28+
private array $onRejected = [];
29+
30+
public function __construct(
31+
protected IPromise $promise,
32+
) {
33+
$this->promise->then(function (IResponse $response): void {
34+
$this->response = ClientAdapter::convertResponse($response);
35+
});
36+
}
37+
38+
#[\Override]
39+
public function then(?callable $onFulfilled = null, ?callable $onRejected = null): Promise {
40+
if ($onFulfilled !== null) {
41+
$this->onFulfilled[] = $onFulfilled;
42+
}
43+
if ($onRejected !== null) {
44+
$this->onRejected[] = $onRejected;
45+
}
46+
47+
return $this;
48+
}
49+
50+
#[\Override]
51+
public function getState(): string {
52+
return match ($this->promise->getState()) {
53+
IPromise::STATE_FULFILLED => Promise::FULFILLED,
54+
IPromise::STATE_REJECTED => Promise::REJECTED,
55+
default => Promise::PENDING,
56+
};
57+
}
58+
59+
/**
60+
* @param bool $unwrap
61+
* @return ?ResponseInterface
62+
* @throws \Throwable When the request failed and no failure callback was registered
63+
*/
64+
#[\Override]
65+
public function wait($unwrap = true) {
66+
$this->settle();
67+
68+
if (!$unwrap) {
69+
return null;
70+
}
71+
72+
if ($this->reason !== null && $this->onRejected === []) {
73+
throw $this->reason;
74+
}
75+
76+
return $this->response;
77+
}
78+
79+
/**
80+
* The failure reason is taken from the awaited promise instead of from a
81+
* rejection callback, as the callback of the Nextcloud client only receives
82+
* request exceptions and would miss connection errors.
83+
*/
84+
private function settle(): void {
85+
if ($this->settled) {
86+
return;
87+
}
88+
$this->settled = true;
89+
90+
try {
91+
$this->promise->wait();
92+
} catch (\Throwable $e) {
93+
$this->reason = $e;
94+
}
95+
96+
if ($this->response !== null) {
97+
foreach ($this->onFulfilled as $callback) {
98+
$callback($this->response);
99+
}
100+
return;
101+
}
102+
103+
$reason = $this->reason ?? new ClientException('The request did not return a response');
104+
foreach ($this->onRejected as $callback) {
105+
$callback($reason);
106+
}
107+
}
108+
}

lib/WebPushClient.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@
1010
namespace OCA\Notifications;
1111

1212
use OCA\Notifications\Vendor\Base64Url\Base64Url;
13+
use OCA\Notifications\Vendor\GuzzleHttp\Psr7\HttpFactory;
1314
use OCA\Notifications\Vendor\Minishlink\WebPush\Subscription;
1415
use OCA\Notifications\Vendor\Minishlink\WebPush\Utils;
1516
use OCA\Notifications\Vendor\Minishlink\WebPush\VAPID;
1617
use OCA\Notifications\Vendor\Minishlink\WebPush\WebPush;
18+
use OCA\Notifications\WebPush\ClientAdapter;
1719
use OCP\AppFramework\Services\IAppConfig;
20+
use OCP\Http\Client\IClientService;
1821

1922
class WebPushClient {
2023
private WebPush $client;
@@ -23,6 +26,7 @@ class WebPushClient {
2326

2427
public function __construct(
2528
protected IAppConfig $appConfig,
29+
protected IClientService $clientService,
2630
) {
2731
$this->vapid = $this->getVapid();
2832
}
@@ -55,7 +59,15 @@ private function getClient(): WebPush {
5559
if (isset($this->client)) {
5660
return $this->client;
5761
}
58-
$this->client = new WebPush(auth: ['VAPID' => $this->vapid]);
62+
$adapter = new ClientAdapter($this->clientService->newClient());
63+
$factory = new HttpFactory();
64+
$this->client = new WebPush(
65+
auth: ['VAPID' => $this->vapid],
66+
client: $adapter,
67+
requestFactory: $factory,
68+
streamFactory: $factory,
69+
asyncClient: $adapter,
70+
);
5971
$this->client->setReuseVAPIDHeaders(true);
6072
return $this->client;
6173
}

tests/Unit/Controller/WebPushControllerTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use OCP\AppFramework\Http;
2020
use OCP\AppFramework\Http\DataResponse;
2121
use OCP\AppFramework\Services\IAppConfig;
22+
use OCP\Http\Client\IClientService;
2223
use OCP\IDBConnection;
2324
use OCP\IRequest;
2425
use OCP\ISession;
@@ -38,6 +39,7 @@ class WebPushControllerTest extends TestCase {
3839
protected IUserSession&MockObject $userSession;
3940
protected IProvider&MockObject $tokenProvider;
4041
protected IRemoteHostValidator&MockObject $hostValidator;
42+
protected IClientService&MockObject $clientService;
4143
protected Manager&MockObject $identityProof;
4244
protected LoggerInterface&MockObject $logger;
4345
protected IUser&MockObject $user;
@@ -58,6 +60,7 @@ protected function setUp(): void {
5860
$this->tokenProvider = $this->createMock(IProvider::class);
5961
$this->identityProof = $this->createMock(Manager::class);
6062
$this->hostValidator = $this->createMock(IRemoteHostValidator::class);
63+
$this->clientService = $this->createMock(IClientService::class);
6164
$this->logger = $this->createMock(LoggerInterface::class);
6265

6366
$this->appConfig->method('getAppValueBool')
@@ -77,6 +80,7 @@ protected function getController(array $methods = []): WebPushController|MockObj
7780
$this->tokenProvider,
7881
$this->identityProof,
7982
$this->hostValidator,
83+
$this->clientService,
8084
$this->logger,
8185
);
8286
}
@@ -92,6 +96,7 @@ protected function getController(array $methods = []): WebPushController|MockObj
9296
$this->tokenProvider,
9397
$this->identityProof,
9498
$this->hostValidator,
99+
$this->clientService,
95100
$this->logger,
96101
])
97102
->onlyMethods($methods)

0 commit comments

Comments
 (0)