Commit e553491e authored by Patrick Barroca's avatar Patrick Barroca 😁
Browse files

Merge branch 'dev#152400_electre_ng' into 'master'

Dev#152400 electre ng

See merge request !3
parents a5b97654 4170ddc3
Pipeline #16848 passed with stage
in 38 seconds
......@@ -5,7 +5,7 @@ test:php7_latest:
- git submodule init
- git submodule update
- php --version
- ./vendor/bin/phpunit -c tests/phpunit.xml --testdox
- ./vendor/bin/phpunit -c tests/phpunit.xml
except:
- tags
tags:
......
Pellicule is a RESTFul service that fetches records media from multiple providers
# Pellicule
I'm a RESTFul service that fetches records media from multiple providers.
I use [Slim Framework 4](https://github.com/slimphp/Slim) as a lightweight framework.
## Install
### Code
Beware: I use a mix of two dependencies management systems
* [git submodules](https://git-scm.com/docs/gitsubmodules) see [declared modules](.gitmodules)
* [composer](https://getcomposer.org)
Clone repository, then get dependencies with
Further more, as I do not want to depend on composer packagist repository availability, even composer packages are provided as [submodule repository](https://git.afi-sa.net/afi/pellicule-dependency)
To have a local copy of me, simply clone this repository, then get submodules dependencies with
```sh
git submodule init
git submodule update
```
This will download composer packages in vendor directory and storm in library/storm.
### Datas
### Run unit tests
Import all sql files from database directory in filename order into mariadb.
```sh
vendor/bin/phpunit -c tests/phpunit.xml
```
### Init datas
Import all sql files from [database](/database) directory in filename order into mariadb.
## Configuration
I load configuration files from [public index](public/index.php) at Slim application bootstraping time.
### Database access
Database connection is configured in conf/database.php, see conf/database.template.php.
My database connection must be configured in a file conf/database.php, see [conf/database.template.php](conf/database.template.php).
Supported values are described in [storm configuration](https://git.afi-sa.net/afi/storm-light/-/blob/master/src/Persistence/Configuration.php)
### HTTP client
My HTTP client options must be configured in a file conf/http_client.php, see [conf/http_client.template.php](conf/http_client.template.php)
Supported values are described in [Buzz Client Curl](https://git.afi-sa.net/afi/pellicule-dependency/-/blob/master/kriswallsmith/buzz/lib/Client/AbstractCurl.php#L190)
### Thumbnails persistence
My thumbnails persistence must be configured in a file conf/persistence.php, see [conf/persistence.template.php](conf/persistence.template.php)
The only option is `base_path` to set path where downloaded thumbnails will be saved, it is relative to public folder
## Try API
### Http client options
The root of the service will display a swagger interface to try calling the API
Http client options are configured in conf/http_client.php see conf/http_client.template.php
OpenAPI file is available at [public/openapi.yml](public/openapi.yml)
## Add new thumbnails provider
## Dependencies handling
When a client ask for a record thumbnail through its ISBN, EAN or ARK, I will try to find it firstly in my local database.
### Composer
If it's not found and the client provided an Authorization header, I will try to find a provider for this Authorization header.
For dependencies available through composer use it but vendor directory is handled as submodule to not depend on packagist servers availability.
To be valid the header must start with "Pellicule " followed by base64 encoded JSON string with at least a "provider" property.
So any composer commands resulting in vendor directory content modification should be committed in its dedicated repository.
My [RemoteProviders](src/Providers/RemoteProviders.php) class will try to match the "provider" property value with any of its known providers `$this->_providers`
### Others
Adding a new provider will require to add a new class extending src/Providers/RemoteProvider.php and declare it in `RemoteProviders::$_providers`
Other dependencies are handled as submodules in library directory.
This new class
* MUST have a `const ID` or implements `static function handles(string $provider_id) : bool` to be matched by RemoteProviders
* SHOULD implement `function providerName() : string` as it defaults to full namespaced class name which is not very user-friendly
* SHOULD override constructor to receive and store credentials
* MUST validate credentials for your provider by implementing `_isValidCredentials() : bool`, I will extract them from the Authorization header and provide them in the constructor of your remote provider
* MUST compute the thumbnail url for a given identifier by implementing `_coverUrl(string $identifier)`
You can review already existing providers for inspiration.
......@@ -51,26 +51,6 @@ class FileSystem {
}
public function download($media) {
$url = $media->getUrl();
$path = $media->getFullsize();
if (!$url || !$path)
return;
$request = (new RequestFactory())->createRequest('GET', $url);
$request = $this->_injectProviderHeaders($request, $media);
$client = $this->newFileGetContents();
$response = $client->sendRequest($request);
if (StatusCodeInterface::STATUS_OK != ($status = $response->getStatusCode()))
return;
$this->writeMediaFrom($media, $response);
}
public function writeMediaFrom($media, $response) {
if (!$path = $media->getFullsize())
return false;
......@@ -97,12 +77,4 @@ class FileSystem {
if (false === $this->fileExists($directory))
$this->mkdir($directory, 0755, true);
}
protected function _injectProviderHeaders($request, $media) {
foreach($media->getProviderHeaders() as $key => $value)
$request = $request->withHeader($key, $value);
return $request;
}
}
......@@ -21,20 +21,7 @@ class Media extends ModelAbstract {
$_default_attribute_values = ['fullsize' => '',
'created_at' => '',
'updated_at' => ''],
$_provider_headers = [];
public function getProviderHeaders() {
return $this->_provider_headers;
}
public function setProviderHeaders($headers) {
$this->_provider_headers = $headers;
return $this;
}
'updated_at' => ''];
public function getJsonAttributes() {
$attribs = array_intersect_key($this->getRawAttributes(),
......@@ -63,9 +50,6 @@ class Media extends ModelAbstract {
if (!$this->getCreatedAt())
$this->setCreatedAt($datenow);
$this->setUpdatedAt($datenow);
if (!$this->fileExists())
$this->getPelliculeFileSystem()->download($this);
}
......
<?php
namespace Pellicule\Providers;
use \Pellicule\Models\Record;
use \Fig\Http\Message\StatusCodeInterface;
class Electre extends Provider {
const
TOKEN_END_POINT = 'https://electre3staging-idp.bvdep.com',
API_END_POINT = 'https://electre3staging-api.bvdep.com',
ID = 'electre';
protected
$_client_id,
$_client_secret;
public function __construct($data = []) {
if (!array_key_exists('client_id', $data))
return;
$this->_client_id = $data['client_id'];
if (!array_key_exists('client_secret', $data))
return;
$this->_client_secret = $data['client_secret'];
}
public function providerName() {
return 'Electre';
}
public function fetchRecord() {
try {
$response = $this->_getToken($this->_client_id, $this->_client_secret);
} catch(\Exception $exception) {
return $this->_newError(StatusCodeInterface::STATUS_GATEWAY_TIMEOUT,
'no_answer_from_gateway');
}
if ($response->getStatusCode() == StatusCodeInterface::STATUS_BAD_REQUEST)
return $this->_newError(StatusCodeInterface::STATUS_UNAUTHORIZED,
'invalid_credentials');
if ($response->getStatusCode() != StatusCodeInterface::STATUS_OK)
return $this->_newError(StatusCodeInterface::STATUS_BAD_GATEWAY,
'provider_error');
$json_response = $this->_parseJsonResponse($response);
if (! $json_response)
return $this->_newError(StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
'cannot_parse_response');
if (!isset($json_response->access_token))
return $this->_newError(StatusCodeInterface::STATUS_NOT_ACCEPTABLE,
'no_expected_attribute_in_data');
$token = $json_response->access_token;
$this->setHeaders(['Authorization' => 'Bearer '. $token]);
try{
$response = $this->_getAllLinks($this->_getIsbnOrEan());
} catch(\Exception $exception) {
return $this->_newError(StatusCodeInterface::STATUS_GATEWAY_TIMEOUT,
'no_answer_from_gateway');
}
if ($response->getStatusCode() != StatusCodeInterface::STATUS_OK)
return $this->_newError(StatusCodeInterface::STATUS_NOT_FOUND,
'ean_not_found');
$json_response = $this->_parseJsonResponse($response);
if (!$json_response)
return $this->_newError(StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
'cannot_parse_response');
if (!isset($json_response->_links))
return $this->_newError(StatusCodeInterface::STATUS_NOT_ACCEPTABLE,
'no_expected_attribute_in_data');
$links = new \Storm\Model\Collection($json_response->_links);
$medialist =
$links->select(
function($element){
return in_array($element->rel, ['cover']);
})
->collect(function($link)
{
return (new \Pellicule\Models\Media())
->setProvider($this->providerName())
->setProviderHeaders($this->getHeaders())
->setUrl($link->href)
->setType($link->rel)
->setFullsizeFromIdentifier($this->_getIsbnOrEan());
});
$record = (new Record())
->updateAttributes($this->_search_args)
->setMedia($medialist);
return (new FetchRecordResult())->beSuccess($record);
}
protected function _getToken($id, $secret) {
$browser = static::newHttpClient();
$response = $browser->post(static::TOKEN_END_POINT . '/connect/token',
[],
http_build_query(['client_id' => $id,
'client_secret' => $secret,
'grant_type' => 'client_credentials',
'scope' => 'webapi']));
return $response;
}
public function _getAllLinks($ean) {
return static::newHttpClient()->get(static::API_END_POINT . '/v1.0/eans/' . $ean,
$this->getHeaders());
}
}
<?php
namespace Pellicule\Providers;
use \Fig\Http\Message\StatusCodeInterface;
use \Psr\Http\Message\ResponseInterface;
use \Pellicule\Models\Media;
use \Pellicule\Models\Record;
use \Pellicule\FileSystem;
class ElectreNg extends RemoteProvider {
const
ID = 'electre_ng',
TOKEN_URL = 'https://login.electre-ng.com/auth/realms/electre/protocol/openid-connect/token',
API_URL = 'https://api.electre-ng.com/notices/ean/{ean}',
CLIENT_ID = 'api-client',
ID_KEY = 'client_id',
SECRET_KEY = 'client_secret';
protected
$_id,
$_secret;
public static function handles(string $provider_id) : bool {
// handle previous electre provider code
return in_array($provider_id, [static::ID, 'electre_rest_2']);
}
public function __construct(array $credentials, array $args) {
parent::__construct($credentials, $args);
$this->_id = $credentials[static::ID_KEY] ?? '';
$this->_secret = $credentials[static::SECRET_KEY] ?? '';
}
public function providerName() : string {
return 'Electre';
}
protected function _isValidCredentials() : bool {
return $this->_id && $this->_secret;
}
protected function _coverUrl(string $identifier) {
$token_response = $this->_getToken();
if ($token_response instanceof FetchRecordResult)
return $token_response;
$api_url = str_replace('{ean}', $identifier, static::API_URL);
$headers = ['Authorization' => (($token_response->token_type ?? 'Bearer')
. ' ' . $token_response->access_token)];
$response = $this->_withHttpDo(fn($http) => $http->get($api_url, $headers));
if ($response instanceof FetchRecordResult)
return $response;
return (($json_response = $this->_parseJsonResponse($response))
&& ($records = $json_response->notices ?? [])
&& ($cover_url = $records[0]->imageCouverture ?? false))
? $cover_url
: $this->_providerError();
}
protected function _getToken() {
$callback = fn($http) => $http->submitForm(static::TOKEN_URL,
['grant_type' => 'password',
'client_id' => static::CLIENT_ID,
'username' => $this->_id,
'password' => $this->_secret]);
$response = $this->_withHttpDo($callback);
if ($response instanceof FetchRecordResult)
return $response;
return (($json_response = $this->_parseJsonResponse($response))
&& isset($json_response->access_token))
? $json_response
: $this->_providerError();
}
}
<?php
namespace Pellicule\Providers;
use \Fig\Http\Message\StatusCodeInterface;
use \Pellicule\Traits\TimeSource;
use \Pellicule\Models\Record;
use \Pellicule\Models\Media;
use \Pellicule\FileSystem;
class ElectreRest2 extends Provider {
use TimeSource;
const
ID = 'electre_rest_2',
API_URL = 'http://restapi.electre.com/Service/couverture/{ean}.jpg',
ID_KEY = 'client_id',
SECRET_KEY = 'client_secret';
protected $_id, $_secret;
public function __construct($credentials) {
$this->_id = isset($credentials[static::ID_KEY])
? $credentials[static::ID_KEY]
: '';
$this->_secret = isset($credentials[static::SECRET_KEY])
? $credentials[static::SECRET_KEY]
: '';
}
public function providerName() {
return 'Electre';
}
public function fetchRecord() {
if (!$this->_id || !$this->_secret)
return $this->_newError(StatusCodeInterface::STATUS_UNAUTHORIZED, 'invalid_credentials');
$identifier = $this->_getIsbnOrEan();
$base_url = str_replace('{ean}', $identifier, static::API_URL);
$params = ['oauth_consumer_key' => $this->_id,
'oauth_nonce' => $this->_nonce(),
'oauth_signature_method' => 'HMAC-SHA1',
'oauth_timestamp' => static::getTimeSource()->time(),
'oauth_version' => '1.0'];
$params['oauth_signature'] = $this->_sign($base_url, $params);
$url = $base_url . '?' . http_build_query($params);
try {
$response = static::newHttpClient()->get($url);
} catch(\Exception $e) {
return $this->_newError(StatusCodeInterface::STATUS_GATEWAY_TIMEOUT,
'no_answer_from_gateway');
}
if ($response->getStatusCode() != StatusCodeInterface::STATUS_OK
|| (!$body = $response->getBody())
|| 0 == $body->getSize())
return $this->_newError(StatusCodeInterface::STATUS_BAD_GATEWAY,
'provider_error');
$media = (new Media())
->setProvider($this->providerName())
->setUrl($base_url)
->beCover()
->setFullsizeFromIdentifier($identifier);
if (!(new FileSystem)->writeMediaFrom($media, $response))
return $this->_newError(StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR,
'error_writing_cover');
$record = (new Record())
->updateAttributes($this->_search_args)
->setMedia([$media]);
return (new FetchRecordResult())->beSuccess($record);
}
protected function _nonce() {
return bin2hex(random_bytes(8));
}
protected function _sign($url, $params) {
$base_string = 'GET&' . rawurlencode($url) . '&' . rawurlencode(http_build_query($params));
$key = rawurlencode($this->_secret) . '&';
return base64_encode(hash_hmac('sha1', $base_string, $key, true));
}
}
<?php
namespace Pellicule\Providers;
use \Fig\Http\Message\StatusCodeInterface;
class Error extends Provider {
protected $_code, $_label;
const
NO_VALID_PARAMS = 'no_valid_params_provided',
NOT_FOUND = 'record_not_found',
NO_CREDENTIALS = 'no_valid_authentication_information_provided',
INVALID_CREDENTIALS = 'invalid_credentials',
MISSING_PARAM = 'missing_param',
CANNOT_WRITE_COVER = 'error_writing_cover',
CANNOT_SAVE_RECORD = 'error_saving_record',
PROVIDER_ERROR = 'provider_error',
NO_ANSWER = 'no_answer_from_gateway';
protected
$_code,
$_label;
public static function noValidParams() {
return new static(StatusCodeInterface::STATUS_BAD_REQUEST, static::NO_VALID_PARAMS);
}
public static function notFound() {
return new static(StatusCodeInterface::STATUS_NOT_FOUND, static::NOT_FOUND);
}
public static function noCredentials() {
return new static(StatusCodeInterface::STATUS_PROXY_AUTHENTICATION_REQUIRED,
static::NO_CREDENTIALS);
}
public static function invalidCredentials() {
return new static(StatusCodeInterface::STATUS_UNAUTHORIZED, static::INVALID_CREDENTIALS);
}
public static function missingParam() {
return new static(StatusCodeInterface::STATUS_BAD_REQUEST, static::MISSING_PARAM);
}
public static function cannotWriteCover() {
return new static(StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR,
static::CANNOT_WRITE_COVER);
}
public static function cannotSaveRecord() {
return new static(StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR,
static::CANNOT_SAVE_RECORD);
}
public static function providerError() {
return new static(StatusCodeInterface::STATUS_BAD_GATEWAY, static::PROVIDER_ERROR);
}
public static function noAnswerFromGateway() {
return new static(StatusCodeInterface::STATUS_GATEWAY_TIMEOUT, static::NO_ANSWER);
}
public function __construct($code, $label) {
public function __construct(int $code, string $label) {
$this->_code = $code;
$this->_label = $label;
}
public function fetchRecord() {
return (new FetchRecordResult())->beError($this->_code, $this->_label);
public function fetchRecord() : FetchRecordResult {
return (new FetchRecordResult)->beError($this->_code, $this->_label);
}
}
......@@ -15,9 +15,8 @@ class FetchRecordResult {
}
public function beSuccess($record){
public function beSuccess($record) {
$this->_record = $record;
$this->_record->save();
$this->_status_code = 200;
return $this;
}
......@@ -30,7 +29,7 @@ class FetchRecordResult {
}
public function getStatusCode(){
public function getStatusCode() {
return $this->_status_code;
}
}
\ No newline at end of file
......@@ -10,7 +10,7 @@ class Local extends Provider {
}
public function fetchRecord() {
return (new FetchRecordResult())->beSuccess($this->_record);
public function fetchRecord() : FetchRecordResult {
return (new FetchRecordResult)->beSuccess($this->_record);
}
}
<?php
namespace Pellicule\Providers;
use \Pellicule\Models\Record;
use \Fig\Http\Message\StatusCodeInterface;
use \Pellicule\Models\Record;
use \Pellicule\Models\Media;
use \Pellicule\FileSystem;