new PHP ratings service

This commit is contained in:
Steve Waterworth
2018-08-21 14:47:19 +01:00
parent 78e79fff30
commit b82d793cf1
25 changed files with 413 additions and 5 deletions

2
.env
View File

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

View File

@@ -65,8 +65,8 @@ services:
condition: on-failure condition: on-failure
mysql: mysql:
build: build:
context: shipping/database context: mysql
image: ${REPO}/rs-shipping-db:${TAG} image: ${REPO}/rs-mysql-db:${TAG}
networks: networks:
- robot-shop - robot-shop
deploy: deploy:
@@ -75,7 +75,7 @@ services:
condition: on-failure condition: on-failure
shipping: shipping:
build: build:
context: shipping/service context: shipping
image: ${REPO}/rs-shipping:${TAG} image: ${REPO}/rs-shipping:${TAG}
depends_on: depends_on:
- mysql - mysql
@@ -85,6 +85,18 @@ services:
replicas: 1 replicas: 1
restart_policy: restart_policy:
condition: on-failure 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: payment:
build: build:
context: payment context: payment

View File

@@ -1,5 +1,6 @@
from locust import HttpLocust, TaskSet, task from locust import HttpLocust, TaskSet, task
from random import choice from random import choice
from random import randint
class UserBehavior(TaskSet): class UserBehavior(TaskSet):
def on_start(self): def on_start(self):
@@ -33,7 +34,12 @@ class UserBehavior(TaskSet):
if item['instock'] != 0: if item['instock'] != 0:
break 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/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'])) self.client.get('/api/cart/add/{}/{}/1'.format(uniqueid, item['sku']))
cart = self.client.get('/api/cart/cart/{}'.format(uniqueid)).json() cart = self.client.get('/api/cart/cart/{}'.format(uniqueid)).json()

View 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
View 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
View 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>

View 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
View 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
View File

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

29
ratings/status.conf Normal file
View 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

View File

@@ -6,7 +6,8 @@ ENV CATALOGUE_HOST=catalogue \
USER_HOST=user \ USER_HOST=user \
CART_HOST=cart \ CART_HOST=cart \
SHIPPING_HOST=shipping \ SHIPPING_HOST=shipping \
PAYMENT_HOST=payment PAYMENT_HOST=payment \
RATINGS_HOST=ratings
COPY entrypoint.sh /root/ COPY entrypoint.sh /root/
ENTRYPOINT ["/root/entrypoint.sh"] ENTRYPOINT ["/root/entrypoint.sh"]

View File

@@ -63,6 +63,10 @@ server {
proxy_pass http://${PAYMENT_HOST}:8080/; proxy_pass http://${PAYMENT_HOST}:8080/;
} }
location /api/ratings/ {
proxy_pass http://${RATINGS_HOST}:80/;
}
location /nginx_status { location /nginx_status {
stub_status on; stub_status on;
access_log off; access_log off;

View File

@@ -132,6 +132,17 @@ a.product:hover {
width: 300px; width: 300px;
} }
.rating {
margin: 10px;
}
.vote-star {
width: 30px;
height: 30px;
cursor: pointer;
opacity: 0.5;
}
/* login register */ /* login register */
table.credentials { table.credentials {
border: 2px solid cyan; border: 2px solid cyan;

View File

@@ -187,6 +187,8 @@
$scope.data = {}; $scope.data = {};
$scope.data.message = ' '; $scope.data.message = ' ';
$scope.data.product = {}; $scope.data.product = {};
$scope.data.rating = {};
$scope.data.rating.avg_rating = 0;
$scope.data.quantity = 1; $scope.data.quantity = 1;
$scope.addToCart = function() { $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) { function loadProduct(sku) {
$http({ $http({
url: '/api/catalogue/product/' + sku, 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() { function clearMessage() {
console.log('clear message'); console.log('clear message');
$scope.data.message = ' '; $scope.data.message = ' ';
} }
loadProduct($routeParams.sku); loadProduct($routeParams.sku);
loadRating($routeParams.sku);
}); });
robotshop.controller('cartform', function($scope, $http, $location, currentUser) { robotshop.controller('cartform', function($scope, $http, $location, currentUser) {

View File

@@ -8,6 +8,20 @@
<div class="productimage"> <div class="productimage">
<img src="images/{{ data.product.sku }}.jpg"/> <img src="images/{{ data.product.sku }}.jpg"/>
</div> </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"> <div class="description">
{{ data.product.description }} {{ data.product.description }}
</div> </div>
@@ -22,3 +36,4 @@
</span> </span>
</div> </div>
</div> </div>