Added new Client method to generate and validate context from API.

This commit is contained in:
abdulmohsen
2024-04-20 00:04:30 +03:00
parent 065ad8cc5c
commit ca4ec1ae76
11 changed files with 260 additions and 85 deletions

73
src/API/Backends/Add.php Normal file
View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\API\Backends;
use App\Backends\Common\ClientInterface;
use App\Libs\Attributes\Route\Post;
use App\Libs\Config;
use App\Libs\ConfigFile;
use App\Libs\Container;
use App\Libs\DataUtil;
use App\Libs\Exceptions\Backends\InvalidContextException;
use App\Libs\HTTP_STATUS;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
final class Add
{
use APITraits;
#[Post(Index::URL . '[/]', name: 'backends.add')]
public function BackendAdd(iRequest $request): iResponse
{
$data = DataUtil::fromArray($request->getParsedBody());
if (null === ($type = $data->get('type'))) {
return api_error('No type was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (null === ($name = $data->get('name'))) {
return api_error('No name was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$backend = $this->getBackends(name: $name);
if (!empty($backend)) {
return api_error(r("Backend '{backend}' already exists.", [
'backend' => $name
]), HTTP_STATUS::HTTP_CONFLICT);
}
if (null === ($class = Config::get("supported.{$type}", null))) {
throw api_error(r("Unexpected client type '{type}' was given.", [
'type' => $type
]), HTTP_STATUS::HTTP_BAD_REQUEST);
}
$instance = Container::getNew($class);
assert($instance instanceof ClientInterface, new \RuntimeException('Invalid client class.'));
try {
$context = $instance->fromRequest($request);
if (false === $instance->validateContext($context)) {
throw new InvalidContextException('Invalid context.');
}
$configFile = ConfigFile::open(Config::get('backends_file'), 'yaml', autoSave: false);
$configFile->set($name, $context);
$configFile->persist();
} catch (InvalidContextException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_BAD_REQUEST);
}
$response = [
'backends' => [],
'links' => [],
];
return api_response(HTTP_STATUS::HTTP_OK, $response);
}
}

View File

@@ -5,15 +5,16 @@ declare(strict_types=1);
namespace App\API\Backends; namespace App\API\Backends;
use App\Libs\Attributes\Route\Get; use App\Libs\Attributes\Route\Get;
use App\Libs\Config;
use App\Libs\ConfigFile;
use App\Libs\HTTP_STATUS; use App\Libs\HTTP_STATUS;
use App\Libs\Options; use App\Libs\Options;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest; use Psr\Http\Message\ServerRequestInterface as iRequest;
final class Index final class Index
{ {
use APITraits;
public const string URL = '%{api.prefix}/backends'; public const string URL = '%{api.prefix}/backends';
public const array BLACK_LIST = [ public const array BLACK_LIST = [
@@ -35,7 +36,7 @@ final class Index
], ],
]; ];
foreach (self::getBackends() as $backend) { foreach ($this->getBackends() as $backend) {
$backend = array_filter( $backend = array_filter(
$backend, $backend,
fn($key) => false === in_array($key, ['options', 'webhook'], true), fn($key) => false === in_array($key, ['options', 'webhook'], true),
@@ -51,54 +52,4 @@ final class Index
return api_response(HTTP_STATUS::HTTP_OK, $response); return api_response(HTTP_STATUS::HTTP_OK, $response);
} }
#[Get(self::URL . '/{id:backend}[/]', name: 'backends.view')]
public function backendsView(iRequest $request, array $args = []): iResponse
{
if (null === ($id = ag($args, 'id'))) {
return api_error('Invalid value for id path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$data = Index::getBackends(name: $id);
if (empty($data)) {
return api_error('Backend not found.', HTTP_STATUS::HTTP_NOT_FOUND);
}
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$data = array_pop($data);
$response = [
...$data,
'links' => [
'self' => (string)$apiUrl,
'list' => (string)$apiUrl->withPath(parseConfigValue(self::URL)),
],
];
return api_response(HTTP_STATUS::HTTP_OK, ['backend' => $response]);
}
private function getBackends(string|null $name = null): array
{
$backends = [];
foreach (ConfigFile::open(Config::get('backends_file'), 'yaml')->getAll() as $backendName => $backend) {
$backend = ['name' => $backendName, ...$backend];
if (null !== ag($backend, 'import.lastSync')) {
$backend = ag_set($backend, 'import.lastSync', makeDate(ag($backend, 'import.lastSync')));
}
if (null !== ag($backend, 'export.lastSync')) {
$backend = ag_set($backend, 'export.lastSync', makeDate(ag($backend, 'export.lastSync')));
}
$backends[] = $backend;
}
if (null !== $name) {
return array_filter($backends, fn($backend) => $backend['name'] === $name);
}
return $backends;
}
} }

43
src/API/Backends/View.php Normal file
View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\API\Backends;
use App\Libs\Attributes\Route\Get;
use App\Libs\HTTP_STATUS;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
final class View
{
use APITraits;
#[Get(Index::URL . '/{id:backend}[/]', name: 'backends.view')]
public function backendsView(iRequest $request, array $args = []): iResponse
{
if (null === ($id = ag($args, 'id'))) {
return api_error('Invalid value for id path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$data = $this->getBackends(name: $id);
if (empty($data)) {
return api_error(r("Backend '{backend}' not found.", ['backend ' => $id]), HTTP_STATUS::HTTP_NOT_FOUND);
}
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$data = array_pop($data);
$response = [
...$data,
'links' => [
'self' => (string)$apiUrl,
'list' => (string)$apiUrl->withPath(parseConfigValue(Index::URL)),
],
];
return api_response(HTTP_STATUS::HTTP_OK, ['backend' => $response]);
}
}

View File

@@ -35,7 +35,7 @@ final class GenerateAccessToken
} }
try { try {
$client = $this->getBackend($backend); $client = $this->getClient($backend);
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR); return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR);
} }

View File

@@ -200,6 +200,24 @@ interface ClientInterface
*/ */
public function listLibraries(array $opts = []): array; public function listLibraries(array $opts = []): array;
/**
* Parse backend config from request context.
*
* @param ServerRequestInterface $request request to parse.
* @return Context Returns a valid {@see Context} instance.
*/
public function fromRequest(ServerRequestInterface $request): Context;
/**
* Validate backend context.
*
* @param Context $context context to validate.
*
* @return bool Returns true if context is valid.
* @throws
*/
public function validateContext(Context $context): bool;
/** /**
* Add/Edit Backend. * Add/Edit Backend.
* *

View File

@@ -50,18 +50,18 @@ use Psr\Log\LoggerInterface as iLogger;
*/ */
class EmbyClient implements iClient class EmbyClient implements iClient
{ {
public const NAME = 'EmbyBackend'; public const string NAME = 'EmbyBackend';
public const CLIENT_NAME = 'Emby'; public const string CLIENT_NAME = 'Emby';
public const TYPE_MOVIE = JellyfinClient::TYPE_MOVIE; public const string TYPE_MOVIE = JellyfinClient::TYPE_MOVIE;
public const TYPE_SHOW = JellyfinClient::TYPE_SHOW; public const string TYPE_SHOW = JellyfinClient::TYPE_SHOW;
public const TYPE_EPISODE = JellyfinClient::TYPE_EPISODE; public const string TYPE_EPISODE = JellyfinClient::TYPE_EPISODE;
public const COLLECTION_TYPE_SHOWS = JellyfinClient::COLLECTION_TYPE_SHOWS; public const string COLLECTION_TYPE_SHOWS = JellyfinClient::COLLECTION_TYPE_SHOWS;
public const COLLECTION_TYPE_MOVIES = JellyfinClient::COLLECTION_TYPE_MOVIES; public const string COLLECTION_TYPE_MOVIES = JellyfinClient::COLLECTION_TYPE_MOVIES;
public const EXTRA_FIELDS = JellyfinClient::EXTRA_FIELDS; public const array EXTRA_FIELDS = JellyfinClient::EXTRA_FIELDS;
/** /**
* @var Context Backend context. * @var Context Backend context.
@@ -536,6 +536,22 @@ class EmbyClient implements iClient
return $response->response; return $response->response;
} }
/**
* @inheritdoc
*/
public function fromRequest(ServerRequestInterface $request): Context
{
return $this->context;
}
/**
* @inheritdoc
*/
public function validateContext(Context $context): bool
{
return true;
}
/** /**
* @inheritdoc * @inheritdoc
*/ */

View File

@@ -52,18 +52,18 @@ use Psr\Log\LoggerInterface as iLogger;
*/ */
class JellyfinClient implements iClient class JellyfinClient implements iClient
{ {
public const CLIENT_NAME = 'Jellyfin'; public const string CLIENT_NAME = 'Jellyfin';
public const TYPE_MOVIE = 'Movie'; public const string TYPE_MOVIE = 'Movie';
public const TYPE_SHOW = 'Series'; public const string TYPE_SHOW = 'Series';
public const TYPE_EPISODE = 'Episode'; public const string TYPE_EPISODE = 'Episode';
public const COLLECTION_TYPE_SHOWS = 'tvshows'; public const string COLLECTION_TYPE_SHOWS = 'tvshows';
public const COLLECTION_TYPE_MOVIES = 'movies'; public const string COLLECTION_TYPE_MOVIES = 'movies';
/** /**
* @var array<string> This constant represents a list of extra fields tobe included in the request. * @var array<string> This constant represents a list of extra fields tobe included in the request.
*/ */
public const EXTRA_FIELDS = [ public const array EXTRA_FIELDS = [
'ProviderIds', 'ProviderIds',
'DateCreated', 'DateCreated',
'OriginalTitle', 'OriginalTitle',
@@ -77,7 +77,7 @@ class JellyfinClient implements iClient
/** /**
* @var array<string> Map the Jellyfin types to our own types. * @var array<string> Map the Jellyfin types to our own types.
*/ */
public const TYPE_MAPPER = [ public const array TYPE_MAPPER = [
JellyfinClient::TYPE_SHOW => iState::TYPE_SHOW, JellyfinClient::TYPE_SHOW => iState::TYPE_SHOW,
JellyfinClient::TYPE_MOVIE => iState::TYPE_MOVIE, JellyfinClient::TYPE_MOVIE => iState::TYPE_MOVIE,
JellyfinClient::TYPE_EPISODE => iState::TYPE_EPISODE, JellyfinClient::TYPE_EPISODE => iState::TYPE_EPISODE,
@@ -569,6 +569,22 @@ class JellyfinClient implements iClient
return $response->response; return $response->response;
} }
/**
* @inheritdoc
*/
public function fromRequest(ServerRequestInterface $request): Context
{
return $this->context;
}
/**
* @inheritdoc
*/
public function validateContext(Context $context): bool
{
return true;
}
/** /**
* @inheritdoc * @inheritdoc
*/ */

View File

@@ -55,18 +55,18 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/ */
class PlexClient implements iClient class PlexClient implements iClient
{ {
public const NAME = 'PlexBackend'; public const string NAME = 'PlexBackend';
public const CLIENT_NAME = 'Plex'; public const string CLIENT_NAME = 'Plex';
public const TYPE_SHOW = 'show'; public const string TYPE_SHOW = 'show';
public const TYPE_MOVIE = 'movie'; public const string TYPE_MOVIE = 'movie';
public const TYPE_EPISODE = 'episode'; public const string TYPE_EPISODE = 'episode';
/** /**
* @var array Map plex types to iState types. * @var array Map plex types to iState types.
*/ */
public const TYPE_MAPPER = [ public const array TYPE_MAPPER = [
PlexClient::TYPE_SHOW => iState::TYPE_SHOW, PlexClient::TYPE_SHOW => iState::TYPE_SHOW,
PlexClient::TYPE_MOVIE => iState::TYPE_MOVIE, PlexClient::TYPE_MOVIE => iState::TYPE_MOVIE,
PlexClient::TYPE_EPISODE => iState::TYPE_EPISODE, PlexClient::TYPE_EPISODE => iState::TYPE_EPISODE,
@@ -75,7 +75,7 @@ class PlexClient implements iClient
/** /**
* @var array List of supported agents. * @var array List of supported agents.
*/ */
public const SUPPORTED_AGENTS = [ public const array SUPPORTED_AGENTS = [
'com.plexapp.agents.imdb', 'com.plexapp.agents.imdb',
'com.plexapp.agents.tmdb', 'com.plexapp.agents.tmdb',
'com.plexapp.agents.themoviedb', 'com.plexapp.agents.themoviedb',
@@ -550,6 +550,22 @@ class PlexClient implements iClient
return $response->response; return $response->response;
} }
/**
* @inheritdoc
*/
public function fromRequest(ServerRequestInterface $request): Context
{
return $this->context;
}
/**
* @inheritdoc
*/
public function validateContext(Context $context): bool
{
return true;
}
/** /**
* @inheritdoc * @inheritdoc
*/ */

View File

@@ -45,7 +45,7 @@ final class ConfigFile implements ArrayAccess, LoggerAwareInterface
private readonly array $opts = [], private readonly array $opts = [],
) { ) {
if (!in_array($this->type, self::CONTENT_TYPES)) { if (!in_array($this->type, self::CONTENT_TYPES)) {
throw new InvalidArgumentException(r('Invalid content type \'{type}\'. Expecting \'{types}\'.', [ throw new InvalidArgumentException(r("Invalid content type '{type}'. Expecting '{types}'.", [
'type' => $type, 'type' => $type,
'types' => implode(', ', self::CONTENT_TYPES) 'types' => implode(', ', self::CONTENT_TYPES)
])); ]));
@@ -53,10 +53,10 @@ final class ConfigFile implements ArrayAccess, LoggerAwareInterface
if (!file_exists($this->file)) { if (!file_exists($this->file)) {
if (false === $this->autoCreate) { if (false === $this->autoCreate) {
throw new InvalidArgumentException(r('File \'{file}\' does not exist.', ['file' => $file])); throw new InvalidArgumentException(r("File '{file}' does not exist.", ['file' => $file]));
} }
if (false === @touch($this->file)) { if (false === @touch($this->file)) {
throw new InvalidArgumentException(r('File \'{file}\' could not be created.', ['file' => $file])); throw new InvalidArgumentException(r("File '{file}' could not be created.", ['file' => $file]));
} }
} }
@@ -164,7 +164,7 @@ final class ConfigFile implements ArrayAccess, LoggerAwareInterface
$newHash = $this->getFileHash(); $newHash = $this->getFileHash();
if ($newHash !== $this->file_hash) { if ($newHash !== $this->file_hash) {
$this->logger?->warning( $this->logger?->warning(
'File \'{file}\' has been modified since last load. re-applying changes on top of the new data.', "File '{file}' has been modified since last load. re-applying changes on top of the new data.",
[ [
'file' => $this->file, 'file' => $this->file,
'hash' => [ 'hash' => [
@@ -195,7 +195,7 @@ final class ConfigFile implements ArrayAccess, LoggerAwareInterface
match ($this->type) { match ($this->type) {
'yaml' => Yaml::dump($this->data, inline: 8, indent: 2), 'yaml' => Yaml::dump($this->data, inline: 8, indent: 2),
'json' => json_encode($this->data, flags: $json_encode), 'json' => json_encode($this->data, flags: $json_encode),
default => throw new RuntimeException(r('Invalid content type \'{type}\'.', [ default => throw new RuntimeException(r("Invalid content type '{type}'.", [
'type' => $this->type 'type' => $this->type
])), ])),
} }
@@ -275,7 +275,7 @@ final class ConfigFile implements ArrayAccess, LoggerAwareInterface
$this->data = match ($this->type) { $this->data = match ($this->type) {
'yaml' => Yaml::parse($content), 'yaml' => Yaml::parse($content),
'json' => json_decode($content, true, flags: $jsonOpts), 'json' => json_decode($content, true, flags: $jsonOpts),
default => throw new RuntimeException(r('Invalid content type \'{type}\'.', ['type' => $this->type])), default => throw new RuntimeException(r("Invalid content type '{type}'.", ['type' => $this->type])),
}; };
} }
@@ -289,7 +289,7 @@ final class ConfigFile implements ArrayAccess, LoggerAwareInterface
break; break;
default: default:
throw new RuntimeException( throw new RuntimeException(
r('Invalid operation type \'{type}\'.', ['type' => $operation['type']]) r("Invalid operation type '{type}'.", ['type' => $operation['type']])
); );
} }
} }

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Libs\Exceptions\Backends;
/**
* Class InvalidContextException
*/
class InvalidContextException extends BackendException
{
}

View File

@@ -20,7 +20,7 @@ trait APITraits
* @return iClient The backend client instance. * @return iClient The backend client instance.
* @throws RuntimeException If no backend with the specified name is found. * @throws RuntimeException If no backend with the specified name is found.
*/ */
protected function getBackend(string $name, array $config = []): iClient protected function getClient(string $name, array $config = []): iClient
{ {
$configFile = ConfigFile::open(Config::get('backends_file'), 'yaml'); $configFile = ConfigFile::open(Config::get('backends_file'), 'yaml');
@@ -33,4 +33,34 @@ trait APITraits
return makeBackend(array_replace_recursive($default, $config), $name); return makeBackend(array_replace_recursive($default, $config), $name);
} }
/**
* Get the list of backends.
*
* @param string|null $name Filter result by backend name.
* @return array The list of backends.
*/
protected function getBackends(string|null $name = null): array
{
$backends = [];
foreach (ConfigFile::open(Config::get('backends_file'), 'yaml')->getAll() as $backendName => $backend) {
$backend = ['name' => $backendName, ...$backend];
if (null !== ag($backend, 'import.lastSync')) {
$backend = ag_set($backend, 'import.lastSync', makeDate(ag($backend, 'import.lastSync')));
}
if (null !== ag($backend, 'export.lastSync')) {
$backend = ag_set($backend, 'export.lastSync', makeDate(ag($backend, 'export.lastSync')));
}
$backends[] = $backend;
}
if (null !== $name) {
return array_filter($backends, fn($backend) => $backend['name'] === $name);
}
return $backends;
}
} }