Transform ratings service to Symfony MicroKernel application

The ratings service contained a PHP 4 style code. This change
introduces a framework for processing the http-requests so we
dont have to do it on our own.

It'll also showcase how Instana is usable with modern PHP.
This commit is contained in:
Cedric Ziel
2020-06-22 11:26:49 +02:00
parent 9369788ced
commit 83e1977b2f
19 changed files with 575 additions and 290 deletions

2
.env
View File

@@ -1,3 +1,3 @@
# environment file for docker-compose
REPO=robotshop
TAG=0.4.20
TAG=0.5.0

View File

@@ -59,6 +59,8 @@ services:
build:
context: ratings
image: ${REPO}/rs-ratings:${TAG}
environment:
APP_ENV: prod
networks:
- robot-shop
depends_on:

View File

@@ -24,3 +24,11 @@ COPY --from=build /app/vendor/ /var/www/html/vendor/
COPY html/ /var/www/html
# This is important. Symfony needs write permissions and we
# dont know the context in which the container will run, i.e.
# which user will be forced from the outside so better play
# safe for this simple demo.
RUN rm -Rf /var/www/var/*
RUN chown -R www-data /var/www
RUN chmod -R 777 /var/www

View File

@@ -1,5 +1,23 @@
{
"require": {
"monolog/monolog": "^1.24.0"
"php": "^7.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"psr/log": "*",
"monolog/monolog": "^1.24.0",
"symfony/config": "^5.0",
"symfony/http-kernel": "^5.0",
"symfony/http-foundation": "^5.0",
"symfony/routing": "^5.0",
"symfony/dependency-injection": "^5.0",
"symfony/framework-bundle": "^5.0",
"doctrine/annotations": "^1.10",
"symfony/monolog-bundle": "^3.5"
},
"autoload": {
"psr-4": {
"Instana\\RobotShop\\Ratings\\": "src/"
}
}
}

View File

@@ -1,6 +1,9 @@
DirectoryIndex index.php
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{ENV:REDIRECT_STATUS} =""
RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule api/(.*)$ api.php?request=$1 [QSA,NC,L]
RewriteRule ^ %{ENV:BASE}/index.php [L]
</IfModule>

View File

@@ -1,106 +0,0 @@
<?php
// load composer installed files
require_once(__DIR__.'/vendor/autoload.php');
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
abstract class API {
protected $method = '';
protected $endpoint = '';
protected $verb = '';
protected $args = array();
protected $file = Null;
protected $logger = Null;
protected $logHandler = Null;
public function __construct($request) {
// Logging
$this->logHandler = new StreamHandler('php://stdout', Logger::INFO);
// CORS
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: *');
header('Content-Type: application/json');
$this->args = explode('/', rtrim($request, '/'));
$this->endpoint = array_shift($this->args);
if(array_key_exists(0, $this->args) && !is_numeric($this->args[0])) {
$this->verb = array_shift($this->args);
}
$this->method = $_SERVER['REQUEST_METHOD'];
if($this->method == 'POST' && array_key_exists('HTTP_X_METHOD', $_SERVER)) {
if($_SERVER['HTTP_X_HTTP_METHOD'] == 'DELETE') {
$this->method = 'DELETE';
} else if($_SERVER['HTTP_X_HTTP_METHOD'] == 'PUT') {
$this->method = 'PUT';
} else {
throw new Exception('Unexpected header');
}
}
switch($this->method) {
case 'DELETE':
case 'POST':
$this->request = $this->_cleanInputs($_POST);
break;
case 'GET':
$this->request = $this->_cleanInputs($_GET);
break;
case 'PUT':
$this->request = $this->_cleanInputs($_GET);
$this->file = file_get_contents('php://input');
break;
}
}
public function processAPI() {
if(method_exists($this, $this->endpoint)) {
try {
$result = $this->{$this->endpoint}();
return $this->_response($result, 200);
} catch (Exception $e) {
return $this->_response($e->getMessage(), $e->getCode());
}
}
return $this->_response("No endpoint: $this->endpoint", 404);
}
private function _response($data, $status = 200) {
header('HTTP/1.1 ' . $status . ' ' . $this->_requestStatus($status));
return json_encode($data);
}
private function _cleanInputs($data) {
$clean_input = array();
if(is_array($data)) {
foreach($data as $k => $v) {
$clean_input[$k] = $this->_cleanInputs($v);
}
} else {
$clean_input = trim(strip_tags($data));
}
return $clean_input;
}
private function _requestStatus($code) {
$status = array(
200 => 'OK',
400 => 'Bad Request',
404 => 'Not Found',
405 => 'Method Not Allowed',
500 => 'Internal Server Error');
return (array_key_exists("$code", $status) ? $status["$code"] : $status['500']);
}
}
?>

View File

@@ -1,179 +0,0 @@
<?php
require_once 'API.class.php';
use Monolog\Logger;
class RatingsAPI extends API {
public function __construct($request, $origin) {
parent::__construct($request);
// Logging
$this->logger = new Logger('RatingsAPI');
$this->logger->pushHandler($this->logHandler);
}
protected function health() {
$this->logger->info('health OK');
return 'OK';
}
protected function dump() {
$data = array();
$data['method'] = $this->method;
$data['verb'] = $this->verb;
$data = array_merge($data, array('args' => $this->args));
return $data;
}
// ratings/fetch/sku
protected function fetch() {
if($this->method == 'GET' && isset($this->verb) && count($this->args) == 0) {
$sku = $this->verb;
if(! $this->_checkSku($sku)) {
throw new Exception("$sku not found", 404);
}
$data = $this->_getRating($sku);
return $data;
} else {
$this->logger->warn('fetch rating - bad request');
throw new Exception('Bad request', 400);
}
}
// ratings/rate/sku/score
protected function rate() {
if($this->method == 'PUT' && isset($this->verb) && count($this->args) == 1) {
$sku = $this->verb;
$score = intval($this->args[0]);
$score = min(max(1, $score), 5);
if(! $this->_checkSku($sku)) {
throw new Exception("$sku not found", 404);
}
$rating = $this->_getRating($sku);
if($rating['avg_rating'] == 0) {
// not rated yet
$this->_insertRating($sku, $score);
} else {
// iffy maths
$newAvg = (($rating['avg_rating'] * $rating['rating_count']) + $score) / ($rating['rating_count'] + 1);
$this->_updateRating($sku, $newAvg, $rating['rating_count'] + 1);
}
} else {
$this->logger->warn('set rating - bad request');
throw new Exception('Bad request', 400);
}
return 'OK';
}
private function _getRating($sku) {
$db = $this->_dbConnect();
if($db) {
$stmt = $db->prepare('select avg_rating, rating_count from ratings where sku = ?');
if($stmt->execute(array($sku))) {
$data = $stmt->fetch();
if($data) {
// for some reason avg_rating is return as a string
$data['avg_rating'] = floatval($data['avg_rating']);
return $data;
} else {
// nicer to return an empty record than throw 404
return array('avg_rating' => 0, 'rating_count' => 0);
}
} else {
$this->logger->error('failed to query data');
throw new Exception('Failed to query data', 500);
}
} else {
$this->logger->error('database connection error');
throw new Exception('Database connection error', 500);
}
}
private function _updateRating($sku, $score, $count) {
$db = $this->_dbConnect();
if($db) {
$stmt = $db->prepare('update ratings set avg_rating = ?, rating_count = ? where sku = ?');
if(! $stmt->execute(array($score, $count, $sku))) {
$this->logger->error('failed to update rating');
throw new Exception('Failed to update data', 500);
}
} else {
$this->logger->error('database connection error');
throw new Exception('Database connection error', 500);
}
}
private function _insertRating($sku, $score) {
$db = $this->_dbConnect();
if($db) {
$stmt = $db->prepare('insert into ratings(sku, avg_rating, rating_count) values(?, ?, ?)');
if(! $stmt->execute(array($sku, $score, 1))) {
$this->logger->error('failed to insert data');
throw new Exception('Failed to insert data', 500);
}
} else {
$this->logger->error('database connection error');
throw new Exception('Database connection error', 500);
}
}
private function _dbConnect() {
$dsn = getenv('PDO_URL') ? getenv('PDO_URL') : 'mysql:host=mysql;dbname=ratings;charset=utf8mb4';
$opt = array(
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false
);
$db = false;
try {
$db = new PDO($dsn, 'ratings', 'iloveit', $opt);
} catch (PDOException $e) {
$msg = $e->getMessage();
$this->logger->error("Database error $msg");
$db = false;
}
return $db;
}
// check sku exists in product catalogue
private function _checkSku($sku) {
$url = getenv('CATALOGUE_URL') ? getenv('CATALOGUE_URL') : 'http://catalogue:8080/';
$url = $url . 'product/' . $sku;
$opt = array(
CURLOPT_RETURNTRANSFER => true,
);
$curl = curl_init($url);
curl_setopt_array($curl, $opt);
$data = curl_exec($curl);
if(! $data) {
$this->logger->error('failed to connect to catalogue');
throw new Exception('Failed to connect to catalogue', 500);
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$this->logger->info("catalogue status $status");
curl_close($curl);
return $status == 200;
}
}
if(!array_key_exists('HTTP_ORIGIN', $_SERVER)) {
$_SERVER['HTTP_ORIGIN'] = $_SERVER['SERVER_NAME'];
}
try {
$API = new RatingsAPI($_REQUEST['request'], $_SERVER['HTTP_ORIGIN']);
echo $API->processAPI();
} catch(Exception $e) {
echo json_encode(Array('error' => $e->getMessage()));
}
?>

15
ratings/html/index.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
require __DIR__.'/vendor/autoload.php';
use Instana\RobotShop\Ratings\Kernel;
use Symfony\Component\HttpFoundation\Request;
$env = getenv('APP_ENV') ?: 'dev';
$kernel = new Kernel($env, true);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);

View File

@@ -1 +1,3 @@
<?php phpinfo(); ?>
<?php
phpinfo();

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Instana\RobotShop\Ratings\Controller;
use Instana\RobotShop\Ratings\Service\HealthCheckService;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route("/_health")
*/
class HealthController implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* @var HealthCheckService
*/
private $healthCheckService;
public function __construct(HealthCheckService $healthCheckService)
{
$this->healthCheckService = $healthCheckService;
}
public function __invoke(Request $request)
{
$checks = [];
try {
$this->healthCheckService->checkConnectivity();
$checks['pdo_connectivity'] = true;
} catch (\PDOException $e) {
$checks['pdo_connectivity'] = false;
}
$this->logger->info('Health-Check', $checks);
return new JsonResponse($checks, $checks['pdo_connectivity'] ? Response::HTTP_OK : Response::HTTP_BAD_REQUEST);
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Instana\RobotShop\Ratings\Controller;
use Instana\RobotShop\Ratings\Service\CatalogueService;
use Instana\RobotShop\Ratings\Service\RatingsService;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route("/api")
*/
class RatingsApiController implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* @var RatingsService
*/
private $ratingsService;
/**
* @var CatalogueService
*/
private $catalogueService;
public function __construct(CatalogueService $catalogueService, RatingsService $ratingsService)
{
$this->ratingsService = $ratingsService;
$this->catalogueService = $catalogueService;
}
/**
* @Route(path="/rate/{sku}/{score}", methods={"PUT"})
*/
public function put(Request $request, string $sku, int $score): Response
{
$score = min(max(1, $score), 5);
try {
if (false === $this->catalogueService->checkSKU($sku)) {
throw new NotFoundHttpException("$sku not found");
}
} catch (\Exception $e) {
throw new HttpException(500, $e->getMessage(), $e);
}
try {
$rating = $this->ratingsService->ratingBySku($sku);
if (0 === $rating['avg_rating']) {
// not rated yet
$this->ratingsService->addRatingForSKU($sku, $score);
} else {
// iffy maths
$newAvg = (($rating['avg_rating'] * $rating['rating_count']) + $score) / ($rating['rating_count'] + 1);
$this->ratingsService->updateRatingForSKU($sku, $newAvg, $rating['rating_count'] + 1);
}
return new JsonResponse([
'success' => true,
]);
} catch (\Exception $e) {
throw new HttpException(500, 'Unable to update rating', $e);
}
}
/**
* @Route("/fetch/{sku}", methods={"GET"})
*/
public function get(Request $request, string $sku): Response
{
try {
if (!$this->ratingsService->ratingBySku($sku)) {
throw new NotFoundHttpException("$sku not found");
}
} catch (\Exception $e) {
throw new HttpException(500, $e->getMessage(), $e);
}
return new JsonResponse($this->ratingsService->ratingBySku($sku));
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Instana\RobotShop\Ratings;
use PDO;
use PDOException;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
class Database implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* @var string
*/
private $dsn;
/**
* @var string
*/
private $user;
/**
* @var string
*/
private $password;
public function __construct(string $dsn, string $user, string $password)
{
$this->dsn = $dsn;
$this->user = $user;
$this->password = $password;
}
public function getConnection(): PDO
{
$opt = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
return new PDO($this->dsn, $this->user, $this->password, $opt);
} catch (PDOException $e) {
$msg = $e->getMessage();
$this->logger->error("Database error $msg");
return null;
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Instana\RobotShop\Ratings\Integration;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Contracts\Service\ResetInterface;
class InstanaHeadersLoggingProcessor implements EventSubscriberInterface, ResetInterface
{
private $routeData;
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['addHeaderData', 1],
KernelEvents::FINISH_REQUEST => ['removeHeaderData', 1],
];
}
public function __invoke(array $records): array
{
if ($this->routeData && !isset($records['extra']['requests'])) {
$records['extra']['instana'] = array_values($this->routeData);
}
return $records;
}
public function addHeaderData(RequestEvent $event): void
{
if ($event->isMasterRequest()) {
$this->reset();
}
$request = $event->getRequest();
if (0 === $request->headers->get('X-INSTANA-L', 0)) {
return;
}
$currentTraceHeaders = [
'l' => $request->headers->get('X-INSTANA-L', 'n/a'),
's' => $request->headers->get('X-INSTANA-S', 'n/a'),
't' => $request->headers->get('X-INSTANA-T', 'n/a'),
];
if (0 !== $request->headers->get('X-INSTANA-SYNTHETIC', 0)) {
$currentTraceHeaders['sy'] = $request->headers->get('X-INSTANA-SYNTHETIC', 0);
}
$this->routeData[spl_object_id($request)] = $currentTraceHeaders;
}
public function reset(): void
{
$this->routeData = [];
}
public function removeHeaderData(FinishRequestEvent $event): void
{
$requestId = spl_object_id($event->getRequest());
unset($this->routeData[$requestId]);
}
}

123
ratings/html/src/Kernel.php Normal file
View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Instana\RobotShop\Ratings;
use Instana\RobotShop\Ratings\Controller\HealthController;
use Instana\RobotShop\Ratings\Controller\RatingsApiController;
use Instana\RobotShop\Ratings\Integration\InstanaHeadersLoggingProcessor;
use Instana\RobotShop\Ratings\Service\CatalogueService;
use Instana\RobotShop\Ratings\Service\RatingsService;
use Monolog\Formatter\LineFormatter;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Bundle\MonologBundle\MonologBundle;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RouteCollectionBuilder;
class Kernel extends BaseKernel implements EventSubscriberInterface
{
use MicroKernelTrait;
public function registerBundles()
{
return [
new FrameworkBundle(),
new MonologBundle(),
];
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents()
{
return [
KernelEvents::RESPONSE => 'corsResponseFilter',
];
}
public function corsResponseFilter(ResponseEvent $event)
{
$response = $event->getResponse();
$response->headers->add([
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods: *',
]);
}
protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void
{
$c->loadFromExtension('framework', [
'secret' => 'S0ME_SECRET',
]);
$c->loadFromExtension('monolog', [
'handlers' => [
'stdout' => [
'type' => 'stream',
'level' => 'info',
'path' => 'php://stdout',
'channels' => ['!request'],
],
],
]);
$c->setParameter('catalogueUrl', getenv('CATALOGUE_URL') ?: 'http://catalogue:8080');
$c->setParameter('pdo_dsn', getenv('PDO_URL') ?: 'mysql:host=mysql;dbname=ratings;charset=utf8mb4');
$c->setParameter('pdo_user', 'ratings');
$c->setParameter('pdo_password', 'iloveit');
$c->setParameter('logger.name', 'RatingsAPI');
$c->register(InstanaHeadersLoggingProcessor::class)
->addTag('kernel.event_subscriber')
->addTag('monolog.processor');
$c->register('monolog.formatter.instana_headers', LineFormatter::class)
->addArgument('[%%datetime%%] [%%extra.token%%] %%channel%%.%%level_name%%: %%message%% %%context%% %%extra%%\n');
$c->register(Database::class)
->addArgument($c->getParameter('pdo_dsn'))
->addArgument($c->getParameter('pdo_user'))
->addArgument($c->getParameter('pdo_password'))
->addMethodCall('setLogger', [new Reference('logger')])
->setAutowired(true);
$c->register(CatalogueService::class)
->addArgument($c->getParameter('catalogueUrl'))
->addMethodCall('setLogger', [new Reference('logger')])
->setAutowired(true);
$c->register('database.connection', \PDO::class)
->setFactory([new Reference(Database::class), 'getConnection']);
$c->setAlias(\PDO::class, 'database.connection');
$c->register(RatingsService::class)
->addMethodCall('setLogger', [new Reference('logger')])
->setAutowired(true);
$c->register(HealthController::class)
->addMethodCall('setLogger', [new Reference('logger')])
->addTag('controller.service_arguments')
->setAutowired(true);
$c->register(RatingsApiController::class)
->addMethodCall('setLogger', [new Reference('logger')])
->addTag('controller.service_arguments')
->setAutowired(true);
}
protected function configureRoutes(RouteCollectionBuilder $routes)
{
$routes->import(__DIR__.'/Controller/', '/', 'annotation');
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Instana\RobotShop\Ratings\Service;
use Exception;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
class CatalogueService implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* @var string
*/
private $catalogueUrl;
public function __construct(string $catalogueUrl)
{
$this->catalogueUrl = $catalogueUrl;
}
public function checkSKU(string $sku): bool
{
$url = sprintf('%s/product/%s', $this->catalogueUrl, $sku);
$opt = [
CURLOPT_RETURNTRANSFER => true,
];
$curl = curl_init($url);
curl_setopt_array($curl, $opt);
$data = curl_exec($curl);
if (!$data) {
$this->logger->error('failed to connect to catalogue');
throw new Exception('Failed to connect to catalogue');
}
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$this->logger->info("catalogue status $status");
curl_close($curl);
return 200 === $status;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Instana\RobotShop\Ratings\Service;
use PDO;
class HealthCheckService
{
/**
* @var PDO
*/
private $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function checkConnectivity()
{
return $this->pdo->prepare('SELECT 1 + 1 FROM DUAL;')->execute();
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Instana\RobotShop\Ratings\Service;
use PDO;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
class RatingsService implements LoggerAwareInterface
{
private const QUERY_RATINGS_BY_SKU = 'select avg_rating, rating_count from ratings where sku = ?';
private const QUERY_UPDATE_RATINGS_BY_SKU = 'update ratings set avg_rating = ?, rating_count = ? where sku = ?';
private const QUERY_INSERT_RATING = 'insert into ratings(sku, avg_rating, rating_count) values(?, ?, ?)';
use LoggerAwareTrait;
/**
* @var PDO
*/
private $connection;
public function __construct(PDO $connection)
{
$this->connection = $connection;
}
public function ratingBySku(string $sku): array
{
$stmt = $this->connection->prepare(self::QUERY_RATINGS_BY_SKU);
if (false === $stmt->execute([$sku])) {
$this->logger->error('failed to query data');
throw new \Exception('Failed to query data', 500);
}
$data = $stmt->fetch();
if ($data) {
// for some reason avg_rating is return as a string
$data['avg_rating'] = (float) $data['avg_rating'];
return $data;
}
// nicer to return an empty record than throw 404
return ['avg_rating' => 0, 'rating_count' => 0];
}
public function updateRatingForSKU(string $sku, $score, int $count): void
{
$stmt = $this->connection->prepare(self::QUERY_UPDATE_RATINGS_BY_SKU);
if (!$stmt->execute([$score, $count, $sku])) {
$this->logger->error('failed to update rating');
throw new \Exception('Failed to update data', 500);
}
}
public function addRatingForSKU($sku, $rating): void
{
$stmt = $this->connection->prepare(self::QUERY_INSERT_RATING);
if (!$stmt->execute([$sku, $rating, 1])) {
$this->logger->error('failed to insert data');
throw new \Exception('Failed to insert data', 500);
}
}
}

0
ratings/html/var/cache/.gitkeep vendored Normal file
View File

View File