diff --git a/README.md b/README.md index 2546d20e4dd60044eef9349f9fc79d715d432807..64afc8f3f3edf8929cb810c539a03d1bae149df0 100644 --- a/README.md +++ b/README.md @@ -6,4 +6,163 @@ [](https://gitlab.cs.ui.ac.id/bukan-macan-ternak/liongame-backend/-/commits/master) [](https://gitlab.cs.ui.ac.id/bukan-macan-ternak/liongame-backend/-/commits/master) -TBD. +## Authorization + +The API uses simple authentication by verifying `Authorization` header. The +value must match with `API_KEY` environment variable loaded by the backend. +Every request must contain `Authorization` header and its value. + +Example request: + +```bash +curl "https://example.com/api/logs" \ + -H "Authorization: 00ff11ee22dd" +``` + +## Play Session Logs + +### Append + +Submits a single play session log. + +```endpoint +POST /logs +``` + +Example request: + +```bash +curl -X POST https://liongame.adian.to/api/logs \ + -H "Authorization: {{ API_KEY }}" +``` + +Example request body: + +> Note: `//` comments are embedded for clarity. The JSON schema corresponds to +> the columns in the actual spreadsheet. +> **Do not actually include the comments in the actual JSON!** + +```json +[ + "VSWM try out", // project_name + "Try Out", // organisation_name + "Minggu 1 Jo", // group_name + "1", // participant_case_number + "Par1", // participant_name + "", // participant_symbol + "", // participant_color + "LvTLiO", // participant_token + "", // taak (?) + "5", // #levels + "4", // #items + "IND", // language + "default_theme", // theme + "16", // matrix_size + "", // id + "60", // task_id + "", // participant_id + "0", // level + "1", // item + "1", // question_number + "red", // correct_answer + "8", // correct_matrix_number + "NA", // response + "12", // response_matrix_number (index position in 1-dimensional array representation) + "131037", // response_time (in milliseconds) + "0", // score + "", // created_at (sent from the game) + "", // updated_at (sent from the game) + "", // deleted_at +] +``` + +Example response: + +> `HTTP 200 OK` + +```json +{ + "status": 200, + "data": { + "participant_name": "{{ participant_name }}", + "created_at": "{{ created_at }}", + } +} +``` + +### Batch Append + +Submits a list of play session logs. + +```endpoint +POST /logs/batch +``` + +Example request: + +```bash +curl -X POST https://liongame.adian.to/api/logs/batch \ + -H "Authorization: {{ API_KEY }}" +``` + +Example request body: + +```json +[ + [ + "VSWM try out", "Try Out", "Minggu 1 Jo", "1", "Par1", "", "", "LvTLiO", + "", "5", "4", "IND", "default_theme", "16", "", "60", "", "0", "1", "1", + "red", "8", "NA", "12", "131037", "0", "", "", "" + ], + [ + "VSWM try out", "Try Out", "Minggu 1 Jo", "1", "Par1", "", "", "LvTLiO", + "", "5", "4", "IND", "default_theme", "16", "", "60", "", "0", "1", "2", + "blue", "3", "NA", "3", "62826", "1", "", "", "" + ], + [ + "VSWM try out", "Try Out", "Minggu 1 Jo", "1", "Par1", "", "", "LvTLiO", + "", "5", "4", "IND", "default_theme", "16", "", "60", "", "0", "2", "1", + "red", "9", "NA", "13", "32213", "0", "", "", "" + ] +] +``` + +Example response: + +> HTTP 200 + +```json +{ + "participant_name": "{{ participant_name }}", + "created_at": "{{ created_at }}", // Latest created_at timestamp +} +``` + +## Errors Response + +The possible errors and their example responses are as follows: + +> `HTTP 400 Bad Request` caused by incorrect request body. + +```json +{ + "status": 400 +} +``` + +> `HTTP 403 Forbidden` caused by failed authorization. + +```json +{ + "status": 403 +} +``` + +> `HTTP 503 Service Unavailable` is possible due to connection issues to Google +> Sheets API. + +```json +{ + "status": 503 +} +``` diff --git a/src/.env.example b/src/.env.example index 79d0e6f3e2e008ce3118146bc0f3f47645c7f542..e2893575b1181d48e79110b63caa1fe230797a70 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,4 +1,4 @@ -API_KEY=sha512(bangtoyib-cepatpulang) +API_KEY=sha256(bangtoyib-cepatpulang) DEBUG=true GOOGLE_APPLICATION_CREDENTIALS=/opt/liongame/service-account.json SPREADSHEET_ID=1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms diff --git a/src/App.php b/src/App.php index 7f698ce14174635f488ab76301206148b073e148..575a7c0e38782aef5f6c16437275b9dbdaf52bef 100644 --- a/src/App.php +++ b/src/App.php @@ -1,5 +1,4 @@ -<?php -declare(strict_types=1); +<?php declare(strict_types=1); namespace addianto\LionGame\Backend; @@ -9,9 +8,12 @@ use Slim\Factory\AppFactory; use Slim\App as SlimApp; use addianto\LionGame\Backend\Config\Config; use addianto\LionGame\Backend\Service\SheetsService; +use count; +use json_encode; class App { + const VALID_LENGTH = 29; private string $apiKey; private SlimApp $app; private SheetsService $sheets; @@ -38,16 +40,110 @@ class App private function setupMiddleware(): void { + $this->app->addBodyParsingMiddleware(); $this->app->addErrorMiddleware($this->debug, true, true); } private function setupController(): void { - $this->app->post("/logs", function (Request $request, Response $response, $args) { - $response->getBody()->write(json_encode(array( - "message" => "TODO: Implement me!" - ))); - return $response; + $this->app->post("/api/logs", function (Request $request, Response $response, $args): Response { + if (!self::isAuthorized($request, $this->apiKey)) { + self::buildErrorResponse($response, 403); + return $response; + } + + $data = $request->getParsedBody(); + + if (!self::isValidData($data)) { + self::buildErrorResponse($response, 400); + return $response; + } + + if ($this->sheets->appendValues( + "'Play Session Analytics (Example)'!A:AC", + array($data) + )) { + $response + ->withStatus(200) + ->getBody()->write(json_encode(array( + "status" => 200, + "data" => array( + "participant_name" => $data[0][4], + "created_at" => $data[0][26], + ), + ))); + + return $response; + } else { + self::buildErrorResponse($response, 503); + return $response; + } }); + + $this->app->post("/api/logs/batch", function (Request $request, Response $response, $args): Response { + if (!self::isAuthorized($request, $this->apiKey)) { + self::buildErrorResponse($response, 403); + return $response; + } + + $data = $request->getParsedBody(); + + if (!self::isValidData($data)) { + self::buildErrorResponse($response, 400); + return $response; + } + + if ($this->sheets->appendValues( + "'Play Session Analytics (Example)'!A:AC", + $data + )) { + $response + ->withStatus(200) + ->getBody()->write(json_encode(array( + "status" => 200, + "data" => array( + "participant_name" => $data[count($data)-1][4], + "created_at" => $data[count($data)-1][26], + ), + ))); + + return $response; + } else { + self::buildErrorResponse($response, 503); + return $response; + } + }); + } + + private static function buildErrorResponse(Response $response, int $code): void + { + $response->withStatus($code)->getBody()->write(json_encode(array( + "status" => $code, + ))); + } + + private static function isAuthorized(Request $request, string $apiKey): bool + { + if (!$request->hasHeader("Authorization")) { + return false; + } + + $headerValue = $request->getHeader("Authorization"); + + return $headerValue[0] == $apiKey; + } + + private static function isValidData(array $data): bool + { + if (is_array($data[0])) { + foreach ($data as $entry) { + if (count($entry) != self::VALID_LENGTH) { + return false; + } + } + return true; + } + + return count($data) == self::VALID_LENGTH; } } diff --git a/src/index.php b/src/index.php index 6f99f836e806dcc7afa00439233a70ad428f0dad..884e1b9ae4ac427d2ff0f8963bebc04b2beb2c96 100644 --- a/src/index.php +++ b/src/index.php @@ -1,10 +1,11 @@ -<?php -declare(strict_types=1); +<?php declare(strict_types=1); namespace addianto\LionGame\Backend; use Dotenv\Dotenv; use addianto\LionGame\Backend\App; +use addianto\LionGame\Backend\Config\Config; +use addianto\LionGame\Backend\Service\SheetsService; require __DIR__ . '/../vendor/autoload.php'; diff --git a/src/service/SheetsService.php b/src/service/SheetsService.php index 9c6a0c35c68b74fb9e7b0138e93bcba368286974..abb256a5a5d5a7961345f5cc64b2312f88566bb3 100644 --- a/src/service/SheetsService.php +++ b/src/service/SheetsService.php @@ -25,8 +25,7 @@ class SheetsService $values = array(); try { - $response = $this->api - ->spreadsheets_values + $response = $this->api->spreadsheets_values ->get($this->spreadsheetId, $range); $values = $response->getValues(); @@ -45,7 +44,7 @@ class SheetsService try { $body = new ValueRange([ "majorDimension" => "ROWS", - "values" => array($values), + "values" => $values, ]); $optionalParameters = array( @@ -53,8 +52,7 @@ class SheetsService "valueInputOption" => "RAW", ); - $response = $this->api - ->spreadsheets_values + $this->api->spreadsheets_values ->append($this->spreadsheetId, $range, $body, $optionalParameters); $isAppended = true; diff --git a/tests/AppTest.php b/tests/AppTest.php index 3fb849c9e53d264dcac379fa0f87b5979acd279b..6b8b9363bbecb34b7ac8fca691d4184636322e54 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -23,12 +23,14 @@ class AppTest extends TestCase const SUCCESS_JSON_DECODE = 0; private SlimApp $app; + private SheetsService $sheets; protected function setUp(): void { + $this->sheets = $this->createMock(SheetsService::class); $this->app = (new App( self::API_KEY, - $this->createMock(SheetsService::class), + $this->sheets, self::DEBUG ))->get(); } @@ -38,30 +40,147 @@ class AppTest extends TestCase $this->assertInstanceOf(SlimApp::class, $this->app); } - public function testPOSTLogsShouldResolve(): void + public function testAppendLogShouldReturnJSON(): void { - $request = $this->buildRequest("POST", "/logs"); - $response = $this->app->handle($request); + $this->sheets + ->method("appendValues") + ->willReturn(true); + $request = $this->buildRequest( + "POST", + "/api/logs", + self::API_KEY, + $this->getValidRequestBody() + ); + + $json = $this->getJSON($this->app->handle($request)); - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $json->status); } - public function testPOSTLogsShouldReturnJSON(): void + public function testBatchAppendLogsShouldReturnJSON(): void { - $request = $this->buildRequest("POST", "/logs"); + $this->sheets + ->method("appendValues") + ->willReturn(true); + $request = $this->buildRequest( + "POST", + "/api/logs/batch", + self::API_KEY, + array( + $this->getValidRequestBody(), + $this->getValidRequestBody(), + $this->getValidRequestBody(), + ) + ); - $response = $this->app->handle($request); - $jsonString = $response->getBody()->__toString(); - $json = json_decode($jsonString); + $json = $this->getJSON($this->app->handle($request)); - $this->assertEquals(self::SUCCESS_JSON_DECODE, json_last_error()); + $this->assertEquals(200, $json->status); + $this->assertEquals("Par1", $json->data->participant_name); + } + + public function testFailedAuthorizationOnAppendLog(): void + { + $request = $this->buildRequest( + "POST", + "/api/logs", + "a", + $this->getValidRequestBody() + ); + + $json = $this->getJSON($this->app->handle($request)); + + $this->assertEquals(403, $json->status); + } + + public function testInvalidDataOnAppendLog(): void + { + $request = $this->buildRequest( + "POST", + "/api/logs", + self::API_KEY, + $this->getInvalidRequestBody() + ); + + $json = $this->getJSON($this->app->handle($request)); + + $this->assertEquals(400, $json->status); + } + + public function testInvalidDataOnBatchAppendLog(): void + { + $request = $this->buildRequest( + "POST", + "/api/logs", + self::API_KEY, + array( + $this->getValidRequestBody(), + $this->getInvalidRequestBody(), + $this->getValidRequestBody(), + ) + ); + + $json = $this->getJSON($this->app->handle($request)); + + $this->assertEquals(400, $json->status); } - private function buildRequest(string $method, string $path) + public function testGoogleSheetsAPIUnavailableOnAppendLog(): void { + $this->sheets + ->method("appendValues") + ->willReturn(false); + $request = $this->buildRequest( + "POST", + "/api/logs", + self::API_KEY, + $this->getValidRequestBody() + ); + + $json = $this->getJSON($this->app->handle($request)); + + $this->assertEquals(503, $json->status); + } + + private function buildRequest( + string $method, + string $path, + string $authorization, + array $body = [] + ) { return RequestFactory::create() ->createServerRequestFromGlobals() + ->withHeader("Authorization", $authorization) + ->withHeader("Content-Type", "application/json") ->withMethod(strtoupper($method)) - ->withUri(UriFactory::createUri($path)); + ->withUri(UriFactory::createUri($path)) + ->withParsedBody($body); + } + + private function getJSON(Response $response): object + { + $jsonString = $response->getBody()->__toString(); + $json = json_decode($jsonString); + + $this->assertEquals(self::SUCCESS_JSON_DECODE, json_last_error()); + + return $json; + } + + private function getValidRequestBody(): array + { + return [ + "VSWM try out", "Try Out", "Minggu 1 Jo", "1", "Par1", "", "", "LvTLiO", + "", "5", "4", "IND", "default_theme", "16", "", "60", "", "0", "1", "1", + "red", "8", "NA", "12", "131037", "0", "", "", "" + ]; + } + + private function getInvalidRequestBody(): array + { + return [ + "VSWM try out", "Try Out", "Minggu 1 Jo", "1", "Par1", "", "", "LvTLiO", + "", "5", "4", "IND", "default_theme", "16", "", "60", "", "0", "1", "1" + ]; } }