Skip to content

Commit

Permalink
new PHP ratings service
Browse files Browse the repository at this point in the history
  • Loading branch information
steveww committed Aug 21, 2018
1 parent 78e79ff commit b82d793
Show file tree
Hide file tree
Showing 25 changed files with 413 additions and 5 deletions.
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# environment file for docker-compose
REPO=robotshop
TAG=0.2.9
TAG=php
18 changes: 15 additions & 3 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -75,7 +75,7 @@ services:
condition: on-failure
shipping:
build:
context: shipping/service
context: shipping
image: ${REPO}/rs-shipping:${TAG}
depends_on:
- mysql
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions load-gen/robot-shop.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -33,7 +34,12 @@ def load(self):
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()
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
16 changes: 16 additions & 0 deletions mysql/scripts/20-ratings.sql
Original file line number Diff line number Diff line change
@@ -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';

File renamed without changes.
14 changes: 14 additions & 0 deletions ratings/Dockerfile
Original file line number Diff line number Diff line change
@@ -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 changes: 6 additions & 0 deletions ratings/html/.htaccess
Original file line number Diff line number Diff line change
@@ -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 changes: 94 additions & 0 deletions ratings/html/API.class.php
Original file line number Diff line number Diff line change
@@ -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 changes: 161 additions & 0 deletions ratings/html/api.php
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions ratings/html/info.php
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<?php phpinfo(); ?>
29 changes: 29 additions & 0 deletions ratings/status.conf
Original file line number Diff line number Diff line change
@@ -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
File renamed without changes.
File renamed without changes.
Loading

0 comments on commit b82d793

Please sign in to comment.