new PHP ratings service
This commit is contained in:
2
.env
2
.env
@@ -1,3 +1,3 @@
|
||||
# environment file for docker-compose
|
||||
REPO=robotshop
|
||||
TAG=0.2.9
|
||||
TAG=php
|
||||
|
@@ -65,8 +65,8 @@ services:
|
||||
condition: on-failure
|
||||
mysql:
|
||||
build:
|
||||
context: shipping/database
|
||||
image: ${REPO}/rs-shipping-db:${TAG}
|
||||
context: mysql
|
||||
image: ${REPO}/rs-mysql-db:${TAG}
|
||||
networks:
|
||||
- robot-shop
|
||||
deploy:
|
||||
@@ -75,7 +75,7 @@ services:
|
||||
condition: on-failure
|
||||
shipping:
|
||||
build:
|
||||
context: shipping/service
|
||||
context: shipping
|
||||
image: ${REPO}/rs-shipping:${TAG}
|
||||
depends_on:
|
||||
- mysql
|
||||
@@ -85,6 +85,18 @@ services:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
ratings:
|
||||
build:
|
||||
context: ratings
|
||||
image: ${REPO}/rs-ratings:${TAG}
|
||||
networks:
|
||||
- robot-shop
|
||||
depends_on:
|
||||
- mysql
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
payment:
|
||||
build:
|
||||
context: payment
|
||||
|
@@ -1,5 +1,6 @@
|
||||
from locust import HttpLocust, TaskSet, task
|
||||
from random import choice
|
||||
from random import randint
|
||||
|
||||
class UserBehavior(TaskSet):
|
||||
def on_start(self):
|
||||
@@ -33,7 +34,12 @@ class UserBehavior(TaskSet):
|
||||
if item['instock'] != 0:
|
||||
break
|
||||
|
||||
# vote for item
|
||||
if randint(1, 10) <= 3:
|
||||
self.client.put('/api/ratings/api/rate/{}/{}'.format(item['sku'], randint(1, 5)))
|
||||
|
||||
self.client.get('/api/catalogue/product/{}'.format(item['sku']))
|
||||
self.client.get('/api/ratings/api/fetch/{}'.format(item['sku']))
|
||||
self.client.get('/api/cart/add/{}/{}/1'.format(uniqueid, item['sku']))
|
||||
|
||||
cart = self.client.get('/api/cart/cart/{}'.format(uniqueid)).json()
|
||||
|
16
mysql/scripts/20-ratings.sql
Normal file
16
mysql/scripts/20-ratings.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE DATABASE ratings
|
||||
DEFAULT CHARACTER SET 'utf8';
|
||||
|
||||
USE ratings;
|
||||
|
||||
CREATE TABLE ratings (
|
||||
sku varchar(80) NOT NULL,
|
||||
avg_rating DECIMAL(3, 2) NOT NULL,
|
||||
rating_count INT NOT NULL,
|
||||
PRIMARY KEY (sku)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
|
||||
GRANT ALL ON ratings.* TO 'ratings'@'%'
|
||||
IDENTIFIED BY 'iloveit';
|
||||
|
14
ratings/Dockerfile
Normal file
14
ratings/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM php:7.2-apache
|
||||
|
||||
RUN docker-php-ext-install pdo_mysql
|
||||
|
||||
# relax permissions on status
|
||||
COPY status.conf /etc/apache2/mods-available/status.conf
|
||||
# Enable Apache mod_rewrite and status
|
||||
RUN a2enmod rewrite && a2enmod status
|
||||
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
COPY html/ /var/www/html
|
||||
|
6
ratings/html/.htaccess
Normal file
6
ratings/html/.htaccess
Normal file
@@ -0,0 +1,6 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule api/(.*)$ api.php?request=$1 [QSA,NC,L]
|
||||
</IfModule>
|
94
ratings/html/API.class.php
Normal file
94
ratings/html/API.class.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
abstract class API {
|
||||
protected $method = '';
|
||||
|
||||
protected $endpoint = '';
|
||||
|
||||
protected $verb = '';
|
||||
|
||||
protected $args = array();
|
||||
|
||||
protected $file = Null;
|
||||
|
||||
public function __construct($request) {
|
||||
// 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']);
|
||||
}
|
||||
}
|
||||
?>
|
161
ratings/html/api.php
Normal file
161
ratings/html/api.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
require_once 'API.class.php';
|
||||
|
||||
class RatingsAPI extends API {
|
||||
public function __construct($request, $origin) {
|
||||
parent::__construct($request);
|
||||
}
|
||||
|
||||
protected function health() {
|
||||
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;
|
||||
$data = $this->_getRating($sku);
|
||||
return $data;
|
||||
} else {
|
||||
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 {
|
||||
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 {
|
||||
throw new Exception('Failed to query data', 500);
|
||||
}
|
||||
} else {
|
||||
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))) {
|
||||
throw new Exception('Failed to update data', 500);
|
||||
}
|
||||
} else {
|
||||
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))) {
|
||||
throw new Exception('Failed to insert data', 500);
|
||||
}
|
||||
} else {
|
||||
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();
|
||||
error_log("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) {
|
||||
throw new Exception('Failed to connect to catalogue', 500);
|
||||
}
|
||||
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
error_log("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()));
|
||||
}
|
||||
?>
|
1
ratings/html/info.php
Normal file
1
ratings/html/info.php
Normal file
@@ -0,0 +1 @@
|
||||
<?php phpinfo(); ?>
|
29
ratings/status.conf
Normal file
29
ratings/status.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
<IfModule mod_status.c>
|
||||
# Allow server status reports generated by mod_status,
|
||||
# with the URL of http://servername/server-status
|
||||
# Uncomment and change the "192.0.2.0/24" to allow access from other hosts.
|
||||
|
||||
<Location /server-status>
|
||||
SetHandler server-status
|
||||
#Require local
|
||||
#Require ip 192.0.2.0/24
|
||||
</Location>
|
||||
|
||||
# Keep track of extended status information for each request
|
||||
ExtendedStatus On
|
||||
|
||||
# Determine if mod_status displays the first 63 characters of a request or
|
||||
# the last 63, assuming the request itself is greater than 63 chars.
|
||||
# Default: Off
|
||||
#SeeRequestTail On
|
||||
|
||||
|
||||
<IfModule mod_proxy.c>
|
||||
# Show Proxy LoadBalancer status in mod_status
|
||||
ProxyStatus On
|
||||
</IfModule>
|
||||
|
||||
|
||||
</IfModule>
|
||||
|
||||
# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
|
@@ -6,7 +6,8 @@ ENV CATALOGUE_HOST=catalogue \
|
||||
USER_HOST=user \
|
||||
CART_HOST=cart \
|
||||
SHIPPING_HOST=shipping \
|
||||
PAYMENT_HOST=payment
|
||||
PAYMENT_HOST=payment \
|
||||
RATINGS_HOST=ratings
|
||||
|
||||
COPY entrypoint.sh /root/
|
||||
ENTRYPOINT ["/root/entrypoint.sh"]
|
||||
|
@@ -63,6 +63,10 @@ server {
|
||||
proxy_pass http://${PAYMENT_HOST}:8080/;
|
||||
}
|
||||
|
||||
location /api/ratings/ {
|
||||
proxy_pass http://${RATINGS_HOST}:80/;
|
||||
}
|
||||
|
||||
location /nginx_status {
|
||||
stub_status on;
|
||||
access_log off;
|
||||
|
@@ -132,6 +132,17 @@ a.product:hover {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.rating {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.vote-star {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* login register */
|
||||
table.credentials {
|
||||
border: 2px solid cyan;
|
||||
|
@@ -187,6 +187,8 @@
|
||||
$scope.data = {};
|
||||
$scope.data.message = ' ';
|
||||
$scope.data.product = {};
|
||||
$scope.data.rating = {};
|
||||
$scope.data.rating.avg_rating = 0;
|
||||
$scope.data.quantity = 1;
|
||||
|
||||
$scope.addToCart = function() {
|
||||
@@ -207,6 +209,30 @@
|
||||
});
|
||||
};
|
||||
|
||||
$scope.rateProduct = function(score) {
|
||||
console.log('rate product', $scope.data.product.sku, score);
|
||||
var url = '/api/ratings/api/rate/' + $scope.data.product.sku + '/' + score;
|
||||
$http({
|
||||
url: url,
|
||||
method: 'PUT'
|
||||
}).then((res) => {
|
||||
$scope.data.message = 'Thankyou for your feedback';
|
||||
$timeout(clearMessage, 3000);
|
||||
loadRating($scope.data.product.sku);
|
||||
}).catch((e) => {
|
||||
console.log('ERROR', e);
|
||||
});
|
||||
};
|
||||
|
||||
$scope.glowstan = function(vote, val) {
|
||||
console.log('glowstan', vote);
|
||||
var idx = vote;
|
||||
while(idx > 0) {
|
||||
document.getElementById('vote-' + idx).style.opacity = val;
|
||||
idx--;
|
||||
}
|
||||
};
|
||||
|
||||
function loadProduct(sku) {
|
||||
$http({
|
||||
url: '/api/catalogue/product/' + sku,
|
||||
@@ -218,12 +244,24 @@
|
||||
});
|
||||
}
|
||||
|
||||
function loadRating(sku) {
|
||||
$http({
|
||||
url: '/api/ratings/api/fetch/' + sku,
|
||||
method: 'GET'
|
||||
}).then((res) => {
|
||||
$scope.data.rating = res.data;
|
||||
}).catch((e) => {
|
||||
console.log('ERROR', e);
|
||||
});
|
||||
}
|
||||
|
||||
function clearMessage() {
|
||||
console.log('clear message');
|
||||
$scope.data.message = ' ';
|
||||
}
|
||||
|
||||
loadProduct($routeParams.sku);
|
||||
loadRating($routeParams.sku);
|
||||
});
|
||||
|
||||
robotshop.controller('cartform', function($scope, $http, $location, currentUser) {
|
||||
|
@@ -8,6 +8,20 @@
|
||||
<div class="productimage">
|
||||
<img src="images/{{ data.product.sku }}.jpg"/>
|
||||
</div>
|
||||
<div class="rating">
|
||||
Rating
|
||||
<span ng-if="data.rating.avg_rating != 0">
|
||||
{{ data.rating.avg_rating.toFixed(1) }} from {{ data.rating.rating_count }} votes
|
||||
</span>
|
||||
<span ng-if="data.rating.avg_rating == 0">
|
||||
No votes yet. Vote now.
|
||||
</span>
|
||||
<div>
|
||||
<span ng-repeat="vote in [ 1, 2, 3, 4, 5 ]">
|
||||
<img id="vote-{{ vote }}" class="vote-star" alt="{{ vote }}" ng-mouseover="glowstan(vote, 1.0);" ng-mouseleave="glowstan(vote, 0.5); "src="media/instana_icon_square.png" ng-click="rateProduct(vote);"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="description">
|
||||
{{ data.product.description }}
|
||||
</div>
|
||||
@@ -22,3 +36,4 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
Reference in New Issue
Block a user