Writing my first iPhone app – Day 4 – API integration
With the general framework of my front end’s register / login interface being done it’s time to create the API for handling user signup and login. I can’t stress it enough how great is it to work a little bit with the language I’m most familiar with after spending 3 days with Swift.
I will keep the database very simple for now:
1 2 3 4 5 6 7 8 |
CREATE TABLE `users` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(30) NOT NULL, `password` varchar(200) NOT NULL, `email` varchar(200) NOT NULL, `facebook_id` varchar(50) NOT NULL, PRIMARY KEY (`id`) ) DEFAULT CHARSET=utf8 |
For the API itself I’m going to use my No Bullshit Framework. The framework is very much in alpha stage but will speed up my development. I only need the model module from it so I’m gonna go ahead and copy the “includes” directory and the example config from the framework. I’m also going to create a user.class.php inside includes which will implement my interaction with my users table. Here is the pretty straight-forward code:
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 |
class User extends Model { public $db = null; public $schema = array( "id" => array("type" => "int", "unique" => true, "notnull" => true, "nice_name" => "Id"), "username" => array( "type" => "string", "unique" => true, "required" => true, "notnull" => true, "nice_name" => "Username"), "password" => array( "type" => "string", "required" => true, "notnull" => true, "nice_name" => "Password"), "email" => array( "type" => "string", "unique" => true, "required" => true, "notnull" => true, "nice_name" => "Email address"), "facebook_id" => array("type" => "string", "unique" => true, "nice_name" => "Facebook ID") ); public $table = "users"; public $nice_name = "User"; function __construct($db){ global $baseurl, $basepath; $this->db = $db; $this->schema['thumbnail']['path'] = $basepath."/thumbs"; $this->schema['thumbnail']['url_prefix'] = $baseurl."/thumbs"; } public function validateEmail($email){ $isValid = true; $atIndex = strrpos($email, "@"); if (is_bool($atIndex) && !$atIndex){ $isValid = false; } else { $domain = substr($email, $atIndex+1); $local = substr($email, 0, $atIndex); $localLen = strlen($local); $domainLen = strlen($domain); if ($localLen < 1 || $localLen > 64){ // local part length exceeded $isValid = false; } else if ($domainLen < 1 || $domainLen > 255){ // domain part length exceeded $isValid = false; } else if ($local[0] == '.' || $local[$localLen-1] == '.'){ // local part starts or ends with '.' $isValid = false; } else if (preg_match('/\\.\\./', $local)){ // local part has two consecutive dots $isValid = false; } else if (!preg_match('/^[A-Za-z0-9\\-\\.]+$/', $domain)){ // character not valid in domain part $isValid = false; } else if (preg_match('/\\.\\./', $domain)){ // domain part has two consecutive dots $isValid = false; } else if (!preg_match('/^(\\\\.|[A-Za-z0-9!#%&`_=\\/$\'*+?^{}|~.-])+$/',str_replace("\\\\","",$local))){ // character not valid in local part unless // local part is quoted if (!preg_match('/^"(\\\\"|[^"])+"$/',str_replace("\\\\","",$local))){ $isValid = false; } } } return $isValid; } public function validate($params, $update_id = false){ $errors = parent::validate($params, $update_id); if (!isset($errors['email']) && !$this->validateEmail($params['email'])){ $errors['email'] = "Invalid email address provided"; } if (!isset($errors['username'])){ if (strlen($params['username']) < 5 || strlen($params['username']) > 30){ $errors['username'] = "Username must be between 5 and 30 characters"; } elseif (preg_match("/[^a-zA-Z0-9_]/", $params['username'])){ $errors['username'] = "Username can only contain alphanumeric characters"; } } if (!isset($errors['password'])){ if (strlen($params['password']) < 5){ $errors['password'] = "Password must be at least 5 characters long"; } } return $errors; } public function hashPassword($password){ $iterations = 10; $salt = md5($password).";billion$"; $hash = crypt($password, $salt); for ($i = 0; $i < 10; ++$i){ $hash = crypt($hash.$password, $salt); } return $hash; } public function add($params){ $params['password'] = $this->hashPassword($params['password']); return parent::add($params); } } |
Simples, some email validation, some password hashing (not the best kind, I know), the rest is taken care of my the framework.
As for the API itself I’m going to create an “api” directory in the root folder of the project and add an index.php file:
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 |
require_once("../config.php"); require_once("../includes/model.class.php"); require_once("../includes/user.class.php"); header("Content-Type: application/json"); class API { public $db = null; public $user = null; public $params = null; function __construct($db, $params){ $this->db = $db; $this->user = new User($this->db); $this->params = $params; if (!isset($params['method'])){ $this->raiseError("No method provided"); } if ($this->params['method'] == "register"){ $this->register(); } elseif ($this->params['method'] == "login"){ $this->login(); } elseif ($this->params['method'] == "facebookLogin"){ $this->facebookLogin(); } elseif ($this->params['method'] == "stub"){ $this->stub(); } else { $this->raiseError("unknown method: {$this->params['method']}"); } } public function register(){ $errors = $this->user->validate($this->params); if (empty($errors)){ $user_id = json_encode($this->user->add($this->params)); print(json_encode(array("errors" => array(), "user_id" => $user_id))); } else { print(json_encode(array("errors" => $errors))); } } public function stub(){ $data = array( "question" => "Mi az: 2 fule van megsem szatyor?", "answers" => array( "Szatyor", "Nyul bazdmeg", "Apad", "Makosbukta" ), "correct" => 1, "current_pos" => 1000000000 ); print(json_encode($data)); } public function facebookLogin(){ $errors = array(); if (!isset($this->params['facebook_id']) || !$this->params['facebook_id']){ $errors['facebook_id'] = "Missing Facebook ID"; } if (empty($errors)){ $res = $this->user->get(array("facebook_id" => $this->params['facebook_id']), 0, 1); if (empty($res)){ print(json_encode(array("errors" => array("facebook_id" => "User does not exist")))); } else { print(json_encode(array("errors" => array(), "user_id" => $res['id']))); } } else { print(json_encode(array("errors" => $errors))); } } public function login(){ $errors = array(); if (!isset($this->params['username']) || !$this->params['username']){ $errors['username'] = "Please enter your username"; } if (!isset($this->params['password']) || !$this->params['password']){ $errors['password'] = "Please enter your password"; } if (empty($errors)){ $password_hash = $this->user->hashPassword($this->params['password']); if (substr_count($this->params['username'], "@")){ $check = array("email" => $this->params['username'], "password" => $password_hash); } else { $check = array("username" => $this->params['username'], "password" => $password_hash); } $res = $this->user->get($check, 0, 1); if (empty($res)){ print(json_encode(array("errors" => array("username" => "Invalid login details")))); } else { print(json_encode(array("errors" => array(), "user_id" => $res['id']))); } } else { print(json_encode(array("errors" => $errors))); } } public function raiseError($message){ print(json_encode(array("errors" => $message))); exit(); } } $params = array(); if (isset($_GET) && !empty($_GET)){ $params = $_GET; } elseif (isset($_POST) && !empty($_POST)){ $params = $_POST; } $db = mysqlConnect($dbhost, $dbuser, $dbpass, $dbname); $api = new API($db, $params); |
This implements everything I need from this API right now. Added a new DNS entry billion.lepunk.co.uk and pointed it to my server where the code is deployed. This way I can test the app with actual networking.
Back to the app, back to Xcode, back to OSX. In my register view I’ve added a couple of new things:
- A hidden input field to store the user’s Facebook ID once they connected
- A hidden label for explaining some stuff to Facebook users
I should not touch the original validation code in my doRegister function but need to update what happens once the form is validated:
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 |
if (!hasError){ // lets talk to the server var url : String = "http://billion.lepunk.co.uk/api/" var request : NSMutableURLRequest = NSMutableURLRequest() var bodyData = "method=register&username=\(username)&email=\(email)&password=\(passwd)&facebook_id=\(facebookId)" request.HTTPBody = bodyData.dataUsingEncoding(NSUTF8StringEncoding); request.URL = NSURL(string: url) request.HTTPMethod = "POST" NSURLConnection.sendAsynchronousRequest(request, queue: NSOperationQueue(), completionHandler:{ (response:NSURLResponse!, data: NSData!, error: NSError!) -> Void in var error: AutoreleasingUnsafeMutablePointer<NSError?> = nil var statusCode = 0 if let httpResponse = response as? NSHTTPURLResponse { statusCode = httpResponse.statusCode } if (data == nil || error != nil || statusCode >= 400 || statusCode < 200){ dispatch_async(dispatch_get_main_queue(), { self.emailErrorLabel.text = "Network error, please try again" self.emailErrorLabel.hidden = false }) } else { let jsonResult: NSDictionary! = NSJSONSerialization.JSONObjectWithData(data, options:NSJSONReadingOptions.MutableContainers, error: error) as? NSDictionary dispatch_async(dispatch_get_main_queue(), { if let userId = jsonResult["user_id"] as? String { // have a new userId save it and go to the game let defaults = NSUserDefaults.standardUserDefaults() defaults.setValue(userId, forKey: "userId") defaults.synchronize() var next = self.storyboard?.instantiateViewControllerWithIdentifier("tabController") as! UITabBarController self.presentViewController(next, animated: true, completion: nil) } else if let jsonErrors = jsonResult["errors"] as? NSDictionary { if let userError = jsonErrors["username"] as? String { self.usernameErrorLabel.text = userError self.usernameErrorLabel.hidden = false hasError = true } if let emailError = jsonErrors["email"] as? String { self.emailErrorLabel.text = emailError self.emailErrorLabel.hidden = false hasError = true } if let passwdError = jsonErrors["password"] as? String { self.passwordErrorLabel.text = passwdError self.passwordErrorLabel.hidden = false hasError = true } } }) } }) } |
Basically doing an async request to the API’s register method. If the request comes back with some errors display them otherwise grab the user id and chuck it in the App’s NSUserDefaults.standardUserDefaults storage (I will probably need to update this to be CoreData in the future) and redirect the user to the play area.
When the user decides to sign up with Facebook instead of the form there are two cases:
- The user already registered (probably on a different device), in which case we need to redirect them to the play area
- The user is not yet registered, so we need to ask them to set up a password
Here is the code that does that:
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 |
func populateUserData() { self.facebookLoginLabel.hidden = false let graphRequest : FBSDKGraphRequest = FBSDKGraphRequest(graphPath: "me", parameters: nil) graphRequest.startWithCompletionHandler({ (connection, result, error) -> Void in if ((error) != nil) { // Process error } else { let facebookId : String = result.valueForKey("id") as! String var url : String = "http://billion.lepunk.co.uk/api/" var request : NSMutableURLRequest = NSMutableURLRequest() var bodyData = "method=facebookLogin&facebook_id=\(facebookId)" request.HTTPBody = bodyData.dataUsingEncoding(NSUTF8StringEncoding); request.URL = NSURL(string: url) request.HTTPMethod = "POST" NSURLConnection.sendAsynchronousRequest(request, queue: NSOperationQueue(), completionHandler:{ (response:NSURLResponse!, data: NSData!, error: NSError!) -> Void in var error: AutoreleasingUnsafeMutablePointer<NSError?> = nil var statusCode = 0 if let httpResponse = response as? NSHTTPURLResponse { statusCode = httpResponse.statusCode } if (data == nil || error != nil || statusCode >= 400 || statusCode < 200){ dispatch_async(dispatch_get_main_queue(), { // TODO: handle error }) } else { let jsonResult: NSDictionary! = NSJSONSerialization.JSONObjectWithData(data, options:NSJSONReadingOptions.MutableContainers, error: error) as? NSDictionary dispatch_async(dispatch_get_main_queue(), { if let userId = jsonResult["user_id"] as? String { // we have a user id let defaults = NSUserDefaults.standardUserDefaults() defaults.setValue(userId, forKey: "userId") defaults.synchronize() var next = self.storyboard?.instantiateViewControllerWithIdentifier("tabController") as! UITabBarController self.presentViewController(next, animated: true, completion: nil) } else { // seems like a new user, redirect to registration screen let userFullName : String = result.valueForKey("name") as! String var stringlength = count(userFullName) var ierror: NSError? var regex:NSRegularExpression = NSRegularExpression(pattern: "([^a-zA-Z0-9_])", options: NSRegularExpressionOptions.CaseInsensitive, error: &ierror)! var generatedUserName = regex.stringByReplacingMatchesInString(userFullName, options: nil, range: NSMakeRange(0, stringlength), withTemplate: "") self.usernameInput.text = generatedUserName let userEmail : String = result.valueForKey("email") as! String self.emailInput.text = userEmail self.facebookIdInput.text = facebookId } }) } }) // check if we already have this user } }) } |
So we have a valid Facebook session and we can do a /me Graph API call to get the user’s details (including their email, since we asked for that permission). We do an async request to our API’s facebookLogin method to check if the user is already registered with us. If not populate the register form with the user’s data and add the FB id to the hidden input field. If the user already registered chuck the user id into the storage and redirect to the play view.
The code for the login view is pretty much the same so I’m not gonna share it here. The only difference really is when the user signs in with Facebook but they are not yet registered instead of redirecting them to the play area I’m redirecting them to the register view.
But of course it would be really annoying if the user needed to log in each time they used the app so some modification is needed for my Home view. I’ve added two new methods to my HomeViewController:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) self.checkLoggedUser() } func checkLoggedUser(){ let defaults = NSUserDefaults.standardUserDefaults() if let userId = defaults.valueForKey("userId") as? String { // we have a logged userId redirect to the game var next = self.storyboard?.instantiateViewControllerWithIdentifier("tabController") as! UITabBarController self.presentViewController(next, animated: true, completion: nil) } } |
So when viewDidAppear is called I’m checking the standardUserDefaults storage for the user id. If that is present I’m immediately redirecting the user to the Play view. One of the gotchas about this code is you can not use viewDidLoad for this. According to stackoverflow it is because if you do a redirect in viewDidLoad you can end up with two active views which is a big no-no. Every day I learn something.
For testing purposes I also added a button to my Me view to let the user log out. Here is the associated action:
1 2 3 4 5 6 7 8 |
@IBAction func doLogout(sender: AnyObject) { let defaults = NSUserDefaults.standardUserDefaults() defaults.removeObjectForKey("userId") defaults.synchronize() var next = self.storyboard?.instantiateViewControllerWithIdentifier("homeView") as! UIViewController self.presentViewController(next, animated: true, completion: nil) } |
If the user hits the button I’m removing the user id from the local storage and redirect them to the Home view. Important to note that calling defaults.synchronize() is a must here. Apparently iOS does the synchronisation of this storage periodically so you could end up in a weird state.
So this is the full login / register flow. Wasn’t too hard but I’m glad I’m over with it since it wasn’t the most exciting task to do. One thing I still need to figure out is how to kill the Facebook connection with the app when the user hits the log out button.
Tomorrow I’m gonna work on the API for the gameplay. Minor setback is that I need to give back the MacBook Pro I’m currently working on and start using my Mac Mini which is slow as hell.