Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"phpstan/phpstan": "^1.10",
"phpstan/phpstan-symfony": "^1.3",
"phpunit/phpunit": "^10.5",
"rector/rector": "^1.2"
"rector/rector": "^1.2",
"mikey179/vfsstream": "^1.6"
},
"autoload": {
"psr-4": {
Expand Down
88 changes: 88 additions & 0 deletions src/Client/JsonRpc/JsonRpcClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

namespace Sal\Seven\Client\JsonRpc;

use GuzzleHttp\Exception\GuzzleException;
use Sal\Seven\Adapter\Http\HttpAdapterInterface;
use Sal\Seven\Factory\HttpHeaderFactory;
use Sal\Seven\Model\ContentType;
use Sal\Seven\Model\JsonRpc\JsonRpcResponse;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\SerializerInterface;

class JsonRpcClient implements JsonRpcClientInterface
{
private ?string $endpoint = null;

private mixed $auth = null;

public function __construct(
private readonly SerializerInterface $serializer,
private readonly HttpAdapterInterface $http,
) {
$this->http->addHeader(HttpHeaderFactory::contentType(ContentType::JSON));
}

public function setEndpoint(string $endpoint): void
{
$this->endpoint = $endpoint;
}

public function setAuth(mixed $auth): void
{
$this->auth = $auth;
}

/**
* @param mixed[] $params
*
* @throws GuzzleException
* @throws \RuntimeException
* @throws ExceptionInterface
*/
public function call(string $method, array $params = []): JsonRpcResponse
{
if (null === $this->endpoint) {
throw new \RuntimeException('The JSON-RPC endpoint not set.');
}

$payload = [
'jsonrpc' => '2.0',
'method' => $method,
'params' => $params,
'id' => (string) microtime(),
'auth' => $this->auth,
];

$json = json_encode($payload);
if (false === $json) {
throw new \RuntimeException('Invalid JSON-RPC payload.');
}

$response = $this->http->post(
$this->endpoint,
json: $json
);

if (!$response->isSuccessful()) {
throw new \RuntimeException("JSON-RPC responded with {$response->getStatusCode()}: '{$response->getBody()?->getContents()}'");
}

$body = $response->getBody()?->getContents();
if (null === $body) {
throw new \RuntimeException('No JSON-RPC response received.');
}

$response = $this->serializer->deserialize(
$body,
JsonRpcResponse::class,
'json'
);

if (!$response instanceof JsonRpcResponse) {
throw new \RuntimeException('Invalid JSON-RPC response.');
}

return $response;
}
}
23 changes: 23 additions & 0 deletions src/Client/JsonRpc/JsonRpcClientInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Sal\Seven\Client\JsonRpc;

use GuzzleHttp\Exception\GuzzleException;
use Sal\Seven\Model\JsonRpc\JsonRpcResponse;
use Symfony\Component\Console\Exception\ExceptionInterface;

interface JsonRpcClientInterface
{
/**
* @param mixed[] $params A JSON encodable array of parameters
*
* @throws GuzzleException
* @throws \RuntimeException
* @throws ExceptionInterface
*/
public function call(string $method, array $params = []): JsonRpcResponse;

public function setEndpoint(string $endpoint): void;

public function setAuth(mixed $auth): void;
}
28 changes: 28 additions & 0 deletions src/Model/JsonRpc/JsonRpcError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace Sal\Seven\Model\JsonRpc;

readonly class JsonRpcError
{
public function __construct(
private int $code,
private string $message,
private mixed $data = null,
) {
}

public function getCode(): int
{
return $this->code;
}

public function getMessage(): string
{
return $this->message;
}

public function getData(): mixed
{
return $this->data;
}
}
33 changes: 33 additions & 0 deletions src/Model/JsonRpc/JsonRpcResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Sal\Seven\Model\JsonRpc;

class JsonRpcResponse
{
public function __construct(
private readonly ?string $id,
private readonly mixed $result,
private readonly ?JsonRpcError $error = null,
) {
}

public function getId(): ?string
{
return $this->id;
}

public function getResult(): mixed
{
return $this->result;
}

public function getError(): ?JsonRpcError
{
return $this->error;
}

public function isSuccessful(): bool
{
return null === $this->error;
}
}
16 changes: 8 additions & 8 deletions tests/Adapter/Ssh/SshAdapterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

class SshAdapterTest extends TestCase
{
public function testSetLogger()
public function testSetLogger(): void
{
$adapter = new SshAdapter();
/** @var LoggerInterface|MockObject */
Expand All @@ -24,7 +24,7 @@ public function testSetLogger()
$this->assertInstanceOf(NullLogger::class, $adapter->getLogger());
}

public function testSetTimeout()
public function testSetTimeout(): void
{
$adapter = new SshAdapter();
$adapter->setTimeout(120);
Expand All @@ -36,7 +36,7 @@ public function testSetTimeout()
$this->assertContains('ConnectTimeout=0', $adapter->getOptions());
}

public function testSetters()
public function testSetters(): void
{
$adapter = new SshAdapter();
$adapter->setHost('127.0.0.1');
Expand All @@ -49,39 +49,39 @@ public function testSetters()
$this->assertContains('-o', $adapter->getOptions());
}

public function testAddIdentityFile()
public function testAddIdentityFile(): void
{
$adapter = new SshAdapter();
$adapter->addIdentityFile('/path/to/id_rsa');
$this->assertContains('-i', $adapter->getOptions());
$this->assertContains('/path/to/id_rsa', $adapter->getOptions());
}

public function testAddJump()
public function testAddJump(): void
{
$adapter = new SshAdapter();
$adapter->addJump('jump.example.com');
$this->assertContains('-J', $adapter->getOptions());
$this->assertContains('jump.example.com', $adapter->getOptions());
}

public function testAddConfigFile()
public function testAddConfigFile(): void
{
$adapter = new SshAdapter();
$adapter->addConfigFile('/path/to/ssh_config');
$this->assertContains('-F', $adapter->getOptions());
$this->assertContains('/path/to/ssh_config', $adapter->getOptions());
}

public function testAddOption()
public function testAddOption(): void
{
$adapter = new SshAdapter();
$adapter->addOption('test');
$this->assertContains('-o', $adapter->getOptions());
$this->assertContains('test', $adapter->getOptions());
}

public function testPermitDsaHostKey()
public function testPermitDsaHostKey(): void
{
$adapter = new SshAdapter();
$options = $adapter->getOptions();
Expand Down
139 changes: 139 additions & 0 deletions tests/Client/JsonRpc/JsonRpcClientTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

namespace Sal\Seven\Tests\Client\JsonRpc;

use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface;
use Sal\Seven\Adapter\Http\HttpAdapterInterface;
use Sal\Seven\Client\JsonRpc\JsonRpcClient;
use Sal\Seven\Factory\HttpHeaderFactory;
use Sal\Seven\Model\ContentType;
use Sal\Seven\Model\Http\HttpResponse;
use Sal\Seven\Model\JsonRpc\JsonRpcResponse;
use Symfony\Component\Serializer\SerializerInterface;

class JsonRpcClientTest extends TestCase
{
/** @var SerializerInterface|MockObject */
private SerializerInterface $serializer;

/** @var HttpAdapterInterface|MockObject */
private HttpAdapterInterface $http;

private JsonRpcClient $client;

protected function setUp(): void
{
$this->serializer = $this->createMock(SerializerInterface::class);
$this->http = $this->createMock(HttpAdapterInterface::class);

$this->http
->expects($this->once())
->method('addHeader')
->with(HttpHeaderFactory::contentType(ContentType::JSON));

$this->client = new JsonRpcClient($this->serializer, $this->http);
}

public function testThrowsIfEndpointMissing(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('The JSON-RPC endpoint not set.');
$this->client->call('method');
}

public function testThrowsOnInvalidJsonPayload(): void
{
$this->client->setEndpoint('http://test');

$this->client->setAuth("\xB1\x31"); // force json_decode to fail

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Invalid JSON-RPC payload.');
$this->client->call('method');
}

public function testThrowsIfHttpNotSuccessful(): void
{
$this->client->setEndpoint('http://test');
$this->client->setAuth('auth');

$stream = $this->createMock(StreamInterface::class);
$stream->method('getContents')->willReturn('ERR');

$httpResponse = $this->createMock(HttpResponse::class);
$httpResponse->method('isSuccessful')->willReturn(false);
$httpResponse->method('getStatusCode')->willReturn(500);
$httpResponse->method('getBody')->willReturn($stream);

$this->http->method('post')->willReturn($httpResponse);

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage("500: 'ERR'");

$this->client->call('test');
}

public function testThrowsIfNoBody(): void
{
$this->client->setEndpoint('http://test');

$httpResponse = $this->createMock(HttpResponse::class);
$httpResponse->method('isSuccessful')->willReturn(true);
$httpResponse->method('getBody')->willReturn(null);

$this->http->method('post')->willReturn($httpResponse);

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('No JSON-RPC response received.');

$this->client->call('test');
}

public function testThrowsIfInvalidResponseObject(): void
{
$this->client->setEndpoint('http://test');

$stream = $this->createMock(StreamInterface::class);
$stream->method('getContents')->willReturn('{"ok":1}');

$httpResponse = $this->createMock(HttpResponse::class);
$httpResponse->method('isSuccessful')->willReturn(true);
$httpResponse->method('getBody')->willReturn($stream);

$this->http->method('post')->willReturn($httpResponse);
$this->serializer->method('deserialize')->willReturn(new \stdClass());

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Invalid JSON-RPC response.');

$this->client->call('test');
}

public function testSuccessfulCall(): void
{
$this->client->setEndpoint('http://test');
$this->client->setAuth('auth');

$stream = $this->createMock(StreamInterface::class);
$stream->method('getContents')->willReturn('{"result":123}');

$httpResponse = $this->createMock(HttpResponse::class);
$httpResponse->method('isSuccessful')->willReturn(true);
$httpResponse->method('getBody')->willReturn($stream);

$this->http->method('post')->willReturn($httpResponse);

$expected = $this->createMock(JsonRpcResponse::class);

$this->serializer
->method('deserialize')
->with('{"result":123}', JsonRpcResponse::class, 'json')
->willReturn($expected);

$out = $this->client->call('method', ['a' => 1]);

$this->assertSame($expected, $out);
}
}
Loading