From b82d793cf18e7f71a46b131b57b583ab2a8cd262 Mon Sep 17 00:00:00 2001 From: Steve Waterworth Date: Tue, 21 Aug 2018 14:47:19 +0100 Subject: [PATCH] new PHP ratings service --- .env | 2 +- docker-compose.yaml | 18 +- load-gen/robot-shop.py | 6 + {shipping/database => mysql}/Dockerfile | 0 {shipping/database => mysql}/config.sh | 0 {shipping/database => mysql}/convert.sh | 0 .../database => mysql}/scripts/10-dump.sql.gz | Bin mysql/scripts/20-ratings.sql | 16 ++ .../database => mysql}/scripts/99-finished.sh | 0 ratings/Dockerfile | 14 ++ ratings/html/.htaccess | 6 + ratings/html/API.class.php | 94 ++++++++++ ratings/html/api.php | 161 ++++++++++++++++++ ratings/html/info.php | 1 + ratings/status.conf | 29 ++++ shipping/{service => }/Dockerfile | 0 shipping/{service => }/pom.xml | 0 .../main/java/org/steveww/spark/Location.java | 0 .../src/main/java/org/steveww/spark/Main.java | 0 .../src/main/java/org/steveww/spark/Ship.java | 0 web/Dockerfile | 3 +- web/default.conf.template | 4 + web/static/css/style.css | 11 ++ web/static/js/controller.js | 38 +++++ web/static/product.html | 15 ++ 25 files changed, 413 insertions(+), 5 deletions(-) rename {shipping/database => mysql}/Dockerfile (100%) rename {shipping/database => mysql}/config.sh (100%) rename {shipping/database => mysql}/convert.sh (100%) rename {shipping/database => mysql}/scripts/10-dump.sql.gz (100%) create mode 100644 mysql/scripts/20-ratings.sql rename {shipping/database => mysql}/scripts/99-finished.sh (100%) create mode 100644 ratings/Dockerfile create mode 100644 ratings/html/.htaccess create mode 100644 ratings/html/API.class.php create mode 100644 ratings/html/api.php create mode 100644 ratings/html/info.php create mode 100644 ratings/status.conf rename shipping/{service => }/Dockerfile (100%) rename shipping/{service => }/pom.xml (100%) rename shipping/{service => }/src/main/java/org/steveww/spark/Location.java (100%) rename shipping/{service => }/src/main/java/org/steveww/spark/Main.java (100%) rename shipping/{service => }/src/main/java/org/steveww/spark/Ship.java (100%) diff --git a/.env b/.env index 0eb94c9..ed11793 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ # environment file for docker-compose REPO=robotshop -TAG=0.2.9 +TAG=php diff --git a/docker-compose.yaml b/docker-compose.yaml index b01a716..05383da 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 diff --git a/load-gen/robot-shop.py b/load-gen/robot-shop.py index e537b25..2aa5eb9 100644 --- a/load-gen/robot-shop.py +++ b/load-gen/robot-shop.py @@ -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() diff --git a/shipping/database/Dockerfile b/mysql/Dockerfile similarity index 100% rename from shipping/database/Dockerfile rename to mysql/Dockerfile diff --git a/shipping/database/config.sh b/mysql/config.sh similarity index 100% rename from shipping/database/config.sh rename to mysql/config.sh diff --git a/shipping/database/convert.sh b/mysql/convert.sh similarity index 100% rename from shipping/database/convert.sh rename to mysql/convert.sh diff --git a/shipping/database/scripts/10-dump.sql.gz b/mysql/scripts/10-dump.sql.gz similarity index 100% rename from shipping/database/scripts/10-dump.sql.gz rename to mysql/scripts/10-dump.sql.gz diff --git a/mysql/scripts/20-ratings.sql b/mysql/scripts/20-ratings.sql new file mode 100644 index 0000000..4c2ca58 --- /dev/null +++ b/mysql/scripts/20-ratings.sql @@ -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'; + diff --git a/shipping/database/scripts/99-finished.sh b/mysql/scripts/99-finished.sh similarity index 100% rename from shipping/database/scripts/99-finished.sh rename to mysql/scripts/99-finished.sh diff --git a/ratings/Dockerfile b/ratings/Dockerfile new file mode 100644 index 0000000..b5cf8ad --- /dev/null +++ b/ratings/Dockerfile @@ -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 + diff --git a/ratings/html/.htaccess b/ratings/html/.htaccess new file mode 100644 index 0000000..b960785 --- /dev/null +++ b/ratings/html/.htaccess @@ -0,0 +1,6 @@ + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule api/(.*)$ api.php?request=$1 [QSA,NC,L] + diff --git a/ratings/html/API.class.php b/ratings/html/API.class.php new file mode 100644 index 0000000..0f73fd8 --- /dev/null +++ b/ratings/html/API.class.php @@ -0,0 +1,94 @@ +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']); + } +} +?> diff --git a/ratings/html/api.php b/ratings/html/api.php new file mode 100644 index 0000000..8bb26bc --- /dev/null +++ b/ratings/html/api.php @@ -0,0 +1,161 @@ +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())); +} +?> diff --git a/ratings/html/info.php b/ratings/html/info.php new file mode 100644 index 0000000..147cebc --- /dev/null +++ b/ratings/html/info.php @@ -0,0 +1 @@ + diff --git a/ratings/status.conf b/ratings/status.conf new file mode 100644 index 0000000..71fed0b --- /dev/null +++ b/ratings/status.conf @@ -0,0 +1,29 @@ + + # 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. + + + SetHandler server-status + #Require local + #Require ip 192.0.2.0/24 + + + # 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 + + + + # Show Proxy LoadBalancer status in mod_status + ProxyStatus On + + + + + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/shipping/service/Dockerfile b/shipping/Dockerfile similarity index 100% rename from shipping/service/Dockerfile rename to shipping/Dockerfile diff --git a/shipping/service/pom.xml b/shipping/pom.xml similarity index 100% rename from shipping/service/pom.xml rename to shipping/pom.xml diff --git a/shipping/service/src/main/java/org/steveww/spark/Location.java b/shipping/src/main/java/org/steveww/spark/Location.java similarity index 100% rename from shipping/service/src/main/java/org/steveww/spark/Location.java rename to shipping/src/main/java/org/steveww/spark/Location.java diff --git a/shipping/service/src/main/java/org/steveww/spark/Main.java b/shipping/src/main/java/org/steveww/spark/Main.java similarity index 100% rename from shipping/service/src/main/java/org/steveww/spark/Main.java rename to shipping/src/main/java/org/steveww/spark/Main.java diff --git a/shipping/service/src/main/java/org/steveww/spark/Ship.java b/shipping/src/main/java/org/steveww/spark/Ship.java similarity index 100% rename from shipping/service/src/main/java/org/steveww/spark/Ship.java rename to shipping/src/main/java/org/steveww/spark/Ship.java diff --git a/web/Dockerfile b/web/Dockerfile index 553e1a3..dd8d51b 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -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"] diff --git a/web/default.conf.template b/web/default.conf.template index 22dc53d..c9f6cc5 100644 --- a/web/default.conf.template +++ b/web/default.conf.template @@ -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; diff --git a/web/static/css/style.css b/web/static/css/style.css index bc21ab0..ad11161 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -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; diff --git a/web/static/js/controller.js b/web/static/js/controller.js index 861cb56..df3e84e 100644 --- a/web/static/js/controller.js +++ b/web/static/js/controller.js @@ -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) { diff --git a/web/static/product.html b/web/static/product.html index e45ac07..19e7f1f 100644 --- a/web/static/product.html +++ b/web/static/product.html @@ -8,6 +8,20 @@
+
+ Rating + + {{ data.rating.avg_rating.toFixed(1) }} from {{ data.rating.rating_count }} votes + + + No votes yet. Vote now. + +
+ + {{ vote }} + +
+
{{ data.product.description }}
@@ -22,3 +36,4 @@ +