Auf der Suche nach einer Möglichkeit eine REST API für eigene Spiele zu erstellen, bin ich über das Slim Framework gestolpert.
Das Framework ist in PHP geschrieben und ermöglich einen recht schnellen Start um solche API´s oder auch andere web applications aufzubauen. Es gibt natürlich einige dieser Frameworks. Slim schien mir jedoch genau das Richtige zu sein – es ist schnell, der Einstieg ist recht leicht und außerdem ist es recht gut dokumentiert.
Zuerst stellt sich natürlich die Frage was ist eine REST API, bzw. REST. Hierbei handelt es sich um eine Architektur, die es ermöglicht API´s mit gut lesbaren URL´s zu erstellen.
Sowas sieht zum Beispiel so aus: http://us.battle.net/api/wow/achievement/485
Dahinter steht meistens eine Datenbankabfrage, die dann wie im Beispiel einen Datensatz im Json Format zurückliefert. Dieser Datensatz kann dann in der eigenen App geparst und anschließend verwendet werden.
Letztlich übernimmt das Framework „nur“ das Routing für diese URL´s, denn eigentlich wird eine index.php angesprochen, diese erscheint wie im Beispiel aber nicht in der URL.
Im Wesentlichen werden dabei vier Befehle benötigt:
- POST
- PUT
- GET
- DELETE
Es gibt zwar noch mehr dieser Befehle, diese sind aber relativ selten im Gebrauch und werden im Moment vernachlässigt. Einige öffentliche API´s lassen auch nur GET Befehle zu, da die Entwickler natürlich die Daten in der Datenbank nur durch das Spielen des Spiel geändert (POST, PUT, DELETE) werden sollen und nicht durch z.B. Anfragen im Browser. Wie im Beispiel oben dienen diese Schnittstellen dann dazu um z.B. bestimmte Daten anderen web applications zu visualisieren.
Der Aufbau
Die gesammte API besteht aus der bereits erwähnten index.php und vielen „Controllern“ für die verschiedenen Datenbankabfragen.
Das Projekt kann dann z.B. so aussehen:
Die index.php ist (noch) relativ einfach:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
<?php require_once '../include/config.php'; // die config.php einbinden um z.B. einen ApiKey abzuholen und zu vergleichen etc pp // alle "controller" und andere inkludierte Scripte laden spl_autoload_register('autoload_class'); function autoload_class($class_name) { $directories = array( '../include/', '../controller/' ); foreach ($directories as $directory) { $filename = $directory . $class_name. '.php'; if (is_file($filename)) { require($filename); break; } } } // Slim einbinden require '../libs/Slim/Slim.php'; \Slim\Slim::registerAutoloader(); $app = new \Slim\Slim(); // und damit ist Slim auch schon integriert! // der erste Test: http://www.yourdomain.com/api/v1/hello/Hans $app->get('/hello/:name', function ($name) { echo "Hello, $name"; }); // WICHTIG, WICHTIG, WICHTIG: am Ende noch die App starten: $app->run(); |
Der erste Test der API kann also so erfolgen während Slim nun anhand der url routet:
http://www.yourdomain.com/api/v1/hello/Hans
Rückgabe wäre dann: „Hello, Hans„! Natürlich ohne Anführungszeichen.
Wie sieht es aber aus, wenn ich Daten aus einer Datenbank holen oder in irgendeiner Form modifizieren will?
Gehen wir davon aus, dass wir den Character „Hans“ neu anlegen und seine Daten modifizieren, zurückgeben oder löschen wollen.
Dazu benötigt die index.php diese vier Funktionen BEVOR : $app->run(); ausgeführt wird!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// get character data $app->get('/character/:nameorid', function ($name){ $cc = new CharacterController(); $cc->GetCharacter($name); }); // put/update character by id or name $app->put('/character/:nameorid', 'authentication', function ($nameorid) use($app) { verifyRequiredParams(array('data')); $data = $app->request->put('data'); $cc = new CharacterController(); echo $cc->PutCharacter($nameorid, $data); }); // post/create new character $app->post('/character/:name', 'authentication', function ($name) use($app) { verifyRequiredParams(array('data')); $data = $app->request->put('data'); $cc = new CharacterController(); $cc->PostCharacter($name, $data); }); //delete character by name or id $app->delete('/character/:nameorid', 'authentication', function ($nameorid) use($app) { $cc = new CharacterController(); $cc->DeleteCharacter($nameorid); }); |
Bei drei dieser Funktionen taucht nun authentication auf.
Slim bietet die Möglichkeit eine Middlelayer einzubauen! Das ist großartig um z.B. durch eine Authentifizierung sicherzustellen, dass der Nutzer diese Aktionen auch ausführen darf.
Der GET-Befehl darf immer ausgeführt werden, da dieser ja nur Daten abruft, aber nicht modifiziert. Kann man machen, muss man natürlich nicht.
Also soweit so gut, damit die API nun die Daten die via PUT über den Body gesendet werden „herausfischen“ kann, benötigt es noch eine Funktion namens verifyRequiredParams.
Diese wird mit den „Feldern“ füttert, die sie filtern soll.
In diesem Fall ist es das Feld „data“. Dahinter verbirgt sich ein json String.
Beide Funktionen werden weiter unten im Text beleuchtet!
Um Nun die Daten von Hans abzurufen sieht die url nun so aus:
http://www.yourdomain.com/api/v1/character/Hans
oder auch mit der id von Hans (angenommen Hans ist bereits eingetragen und hat die id 999):
http://www.yourdomain.com/api/v1/character/999
Gibt man das in die Adresszeile des Browser ein, wird standartmäßig der GET-Befehl ausgeführt und man erhält die Daten von Hans, wenn er in der Datenbank vorhanden ist.
Nicht vergessen: dahinter liegt die index.php!
Die eigentliche URL sieht nämlich so aus:
http://www.yourdomain.com/api/v1/index.php
Direkt neben der index.php liegt eine .htaccess-Datei, die dafür sorgt, dass index.php aus der URL entfernt wird. black magic! 😉
Die .htaccess-Datei sieht so aus:
1 2 3 |
RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.*)$ %{ENV:BASE}index.php [QSA,L] |
Um nun die Daten vom Character „Hans“ zu bekommen, braucht es noch den „CharacterController“ und der sieht so aus:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
class CharacterController extends AbstractController { public function GetCharacter($character) // $character = name or id { $db = $this->connection; $rows = array(); if (!is_numeric($character)) // ist $character eine zahl oder ein string? { $qry = "SELECT * FROM character WHERE name = :name "; $stmt = $db->prepare($qry); $stmt->execute(array(':name'=>$character)); $rows = $stmt->fetch(PDO::FETCH_ASSOC); if($stmt->rowCount() > 0) // character exists! { //$json=json_encode($rows, JSON_PRETTY_PRINT); //return $json; echo $rows['stats']; // row stats contains already a json string } } else { $qry = "SELECT * FROM character WHERE id = :id "; $stmt = $db->prepare($qry); $stmt->execute(array(':id'=>$character)); $rows = $stmt->fetch(PDO::FETCH_ASSOC); if($stmt->rowCount() > 0) // character exists! { //$json=json_encode($rows, JSON_PRETTY_PRINT); //return $json; echo $rows['stats']; // row stats contains already a json string } } } public function PutCharacter($character, $jsonString) {// $character = name or id // Datenbank UPDATE hier!!! } public function PostCharacter($characterName, $jsonString) { // Datenbank INSERT hier!!! } public function DeleteCharacter($character) {// $character = name or id // Datenbank DELETE hier!!! } } |
Wie man sieht gibt es eine Klasse namens AbstractController, diese übernimmt den Aufbau der Datenbankverbindung:
1 2 3 4 5 6 7 8 9 10 |
abstract class AbstractController { public $connection; function __construct() { require '../include/dbconnect.php'; // opening db connection $db = new dbconnect(); $this->connection = $db->Connect(); } } |
und greift dabei auf das connect-Script zu:
1 2 3 4 5 6 7 8 9 10 |
class dbconnect { public function Connect() { require_once '../include/config.php'; $pdo = new PDO(DB_HOST_PDO, DB_USERNAME, DB_PASSWORD); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); return $pdo; } } |
die config.php sieht dann so aus:
1 2 3 4 5 |
define('DB_USERNAME', 'userName'); define('DB_PASSWORD', 'password'); define('DB_HOST', 'localhost'); define('DB_NAME', 'databaseName'); define('DB_HOST_PDO', 'mysql:host='.DB_HOST.';dbname='.DB_NAME); |
Die verifyRequiredParams Funktion und die sieht so aus:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
function verifyRequiredParams($required_fields) { $error = false; $error_fields = ""; $request_params = array(); $request_params = $_REQUEST; // Handling PUT request params //if ($_SERVER['REQUEST_METHOD'] == 'PUT') { if (\filter_input(INPUT_REQUEST, 'PUT') ){ $app = \Slim\Slim::getInstance(); parse_str($app->request()->getBody(), $request_params); } foreach ($required_fields as $field) { // black magic here! ;) if (!isset($request_params[$field]) || strlen(trim($request_params[$field])) <= 0) { $error = true; $error_fields .= $field . ', '; } } if ($error) { // Required field(s) are missing or empty // echo error json and stop the app $response = array(); $app = \Slim\Slim::getInstance(); $response["error"] = true; $response["message"] = 'Required field(s) ' . substr($error_fields, 0, -2) . ' is missing or empty'; //echoResponse(400, $response); } |
Da ich das Feld ‚data‘ übergebe, wird genau danach gesucht. $app->request()->getBody() holt den Body, danach wird geparst und ein paar Checks für das Errorhandling gemacht, für den Fall, dass das Feld ‚data‘ nicht vorhanden ist. Wenn irgendwas mit den Feldern nicht stimmt (nicht vorhanden oder falsch benannt), wird Slim gestoppt: app->stop();
Und was macht nun ‚authentication‘ ? Ein sehr tolles Feature von Slim ist die Möglichkeit Middlelayer einzufügen.
Die Funktion ‚authentication‘ soll überprüfen, ob denn überhaupt Daten verarbeitet werden dürfen, indem das Vorhanden sein von Usernamen und Passwort im Header überprüft wird.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function authentication(\Slim\Route $route) { // Getting request headers $headers = getallheaders(); $app = \Slim\Slim::getInstance(); // Verifying Authentication Header if (!isset($headers['Account'])) { AbstractController::ReturnStatusCode(true, AUTHENTICATION_FAILED, "Authentication failed"); $app->stop(); } if (!isset($headers['Password'])) { AbstractController::ReturnStatusCode(true, AUTHENTICATION_FAILED, "Authentication failed"); $app->stop(); } if ($headers['Apikey'] != API_KEY) { // falscher apikey AbstractController::ReturnStatusCode(true, APIKEY_CHECK_FAILED, "wrong apikey! do you try to trick the server? next time we will bann you account."); $app->stop(); } if ($headers['Version'] != VERSION) { // falsche version AbstractController::ReturnStatusCode(true, VERSION_CHECK_FAILED, "Update your game!"); $app->stop(); } } |
Man sollte sich hier sowieso mit einem „ApiKey“ und Ähnlichem absichern und sowas im Vorfeld prüfen oder besser gleich https nutzen!
Für das Beispiel verzichte ich aber erstmal darauf.
Nun hat man so ziemlich alles zusammen was man benötigt und kann neue Controller erstellen (siehe Abb. oben), diese Controller erben natürlich vom AbstractController und werden automatisch in die index.php geladen.
Von dort aus muss, wie im Beispiel, nur eine Instanz erzeugt werden. Slim übernimmt im Hintergrund die „Magie“ beim Routen, während die Datenbankabfragen vom jeweiligen Controller erledigt wird.
So lässt es sich echt schnell entwicklen! 🙂
der Unity3D, bzw. C#-Teil
Jetzt muss das Ganze noch mit Unity verbunden werden. Hierfür nehme ich nicht die Unity-eigene WWW-Klasse. Ich persönlich finde, dass sich Coroutinen für solche Angelegenheiten einfach nur bedingt anbieten und sich dazu unter C# nicht als strings zurückgeben lassen. In diesem Fall heißt der unser Verbündeter „HttpWebRequest“. Der nächste Nachteil der Coroutinen von Unity besteht aus dem Fakt, dass sie nur mit POST und GET arbeiten. Ich benötige aber auch DELETE und PUT. Diese kann man in der „HttpWebRequest.methode“ mitschleifen! Um hier den ein oder anderen Workaround zu vermeiden ist in diesem Fall die „HttpWebRequest“-Klasse der klare Sieger! Auch die WebClient-Klasse tut sich hier schwer. Einziger Nachteil ist bis dato, dass ich System.IO nutzen muss. Dies bedeutet, dass der WebPlayer (vorerst) wegfällt, da dieser aus Sicherheitsgründen in einer Sandbox läuft. Das ist auch gut so. 😉
Holen wir die Daten in das Spiel:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
using System.IO; using System.Collections; using UnityEngine; using System.Net; using GameFramework.SlimWrapper; public class TestScript : MonoBehaviour { void Start() { Slim slim = new Slim(); var character = "Hans"; Debug.Log(slim.NewHttpWebRequest(HttpMethod.GET, slim.ApiUrl(character)); var dict = new Dictionary<string, string>(); dict.Add("Apikey","123456"); var jsonString = "{\"SomeData\":\"TheData\"}"; Debug.Log(slim.NewHttpWebRequest(HttpMethod.PUT, slim.GetHelloWorld(character), dict, jsonString); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
namespace GameFramework.SlimWrapper { using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Text; using UnityEngine; public class Slim { /// <summary> /// /// </summary> public enum HttpMethod { POST, GET, PUT, DELETE } /// <summary> /// /// </summary> public virtual string ServerUrl { set { if (value == null) throw new ArgumentNullException("value"); } get { return "http://localhost/gameapi/v1/"; } } /// <summary> /// connects server url with endpoint /// </summary> /// <param name="endPoint"></param> /// <returns></returns> public virtual string APIUrl(string endPoint) { return ServerUrl + endPoint; } // endpoints private const string endHelloWorld = "hello/"; /// <summary> /// "Hello, world" /// </summary> /// <returns></returns> public string GetHelloWorld(string s) { return NewHttpWebRequest(HttpMethod.GET, endHelloWorld + s); } #region Request /// <summary> /// the ONE and ONLY http web request - returns json strings /// </summary> /// <param name="endpoint"></param> /// <param name="method"></param> /// <param name="headers"></param> /// <param name="jsonString"></param> /// <returns></returns> public virtual string NewHttpWebRequest(HttpMethod method, string endpoint, Dictionary<string, string> headers = null, string jsonString = null) { Debug.LogWarning("method: " + method + " -- endpoint: " + endpoint + " -- characterName: " + jsonString); var url = APIUrl(endpoint); Debug.LogWarning(url); var responseFromServer = String.Empty; try { // Create the web request var request = WebRequest.Create(url) as HttpWebRequest; if (request != null) { request.Credentials = CredentialCache.DefaultCredentials; //request.Accept = "application/xml"; // Determines the response type as XML or JSON etc request.ContentType = "application/x-www-form-urlencoded"; request.ProtocolVersion = HttpVersion.Version11; request.KeepAlive = true; // set request method request.Method = method.ToString().ToUpper(); if (headers != null) { foreach (var header in headers) { Debug.Log(header.Key + " ::: " + header.Value); request.Headers.Add(header.Key, header.Value); } } // data body if (method == HttpMethod.PUT || method == HttpMethod.POST) { if (string.IsNullOrEmpty(jsonString)) { Debug.LogError("ERROR - jsonString is empty"); return "ERROR - jsonString is empty"; } string dataString = "data=" + jsonString; var dataByte = Encoding.UTF8.GetBytes(dataString); request.ContentLength = dataByte.Length; using (Stream dataStream = request.GetRequestStream()) { dataStream.Write(dataByte, 0, dataByte.Length); } } var response = (HttpWebResponse) request.GetResponse(); var resStream = response.GetResponseStream(); if (response.StatusCode != HttpStatusCode.OK) { var message = String.Format("Request failed. Received HTTP {0}", response.StatusCode); throw new ApplicationException(message); } if (resStream != null) { var reader = new StreamReader(resStream); responseFromServer = reader.ReadToEnd(); reader.Close(); } if (resStream != null) resStream.Close(); response.Close(); } } catch (WebException ex) { if (ex.Status == WebExceptionStatus.ProtocolError) { var statusCode = (int) ((HttpWebResponse) ex.Response).StatusCode; var responseStream = ex.Response.GetResponseStream(); if (responseStream != null) responseFromServer = (new StreamReader(responseStream)).ReadToEnd(); ApiStatusMessageHandler.SetStatusCode(statusCode); } else { throw (ex); } } finally { Debug.Log("WEB REQUEST RESPONSE :" + responseFromServer); } return responseFromServer; } /// http://www.stickler.de/information/code-snippets/httpwebrequest-post-data.aspx /// <summary> /// /// </summary> /// <param name="method"></param> /// <param name="endpoint"></param> /// <param name="postParameters"></param> /// <param name="headers"></param> /// <returns></returns> public virtual string NewHttpWebRequest(HttpMethod method, string endpoint, Dictionary<string, string> postParameters, Dictionary<string, string> headers) { string postData = ""; foreach (string key in postParameters.Keys) { postData += key + "=" + postParameters[key] + "&"; } postData = Uri.EscapeDataString(postData); return postData; } #endregion Request } } |
Abschließend muss ich sagen, dass ich nicht einen einzigen Blick in die Slim.php werfen musste – die Doku und ein paar kleine Tests haben völlig ausgereicht um die API auf Slim aufzusetzen.
So soll es sein – funktioniert einfach out of the box! Unity3D, bzw. C# verhielt sich anfangs ein wenig sperriger. Hier war ein wenig die „trial and error“-Methode gefragt.
Cheers!
Daniel