Friday, September 6, 2013

Backbone.js REST with PHP


Why isn't there a simple example where Backbone.js RESTful Persistence uses PHP?

Backbone.js has Models and Collections which can be, in theory, easily connected to a REST backend so that adds, deletes and changes on the client are mirrored on the server.

The following sync.html file is a simple demo (or, at least, as simple as I could make it!) that uses a single PHP page, backbone.php, as a server.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title></title>
  <meta name="description" content="">
</head>
<body onload="onload();">
  <script src="jquery-1.10.1.js"></script>
  <script src="underscore.js"></script>
  <script src="backbone.js"></script>
  <script language="JavaScript"> function onload() {

// optional since these are the defaults
Backbone.emulateHTTP = false;
Backbone.emulateJSON = false;

// create a data model
var Person = Backbone.Model.extend({
  defaults: {
    name: '',
    age: 1
  }
});

// create a data model collection
var PersonCollection = Backbone.Collection.extend({
  model: Person,
  url: 'backbone.php'
});

// store a collection first for nice demo
var people = new PersonCollection([
  new Person({"name": "Bob", "age": 20}),
  new Person({"name": "Bill", "age": 25}),
  new Person({"name": "John", "age": 30})
]);
// jQuery promise deals with async issues
var promise = $.Deferred();
people.forEach(function(person) {
  promise.then(function() {
    console.log('saving '+person.get('name'));
    person.save();
  });
});
promise.then(function() {
  // fetch the collection via HTTP GET
  people = new PersonCollection();
  people.fetch({ success: function(collection) {
    // pretty print the people who were fetched
    console.log(JSON.stringify(collection.toJSON()));
    // another promise
    var promise = $.Deferred();
    // change Bob's age and store him via HTTP PUT
    collection.filter(function(person) {
      return person.get('name') === 'Bob';
    }).forEach(function(person) {
      promise.then(function() {
        console.log('changing Bob\'s age');
        collection.get(person.id).set('age', 40).save();
      });
    });
    // change Bill's age and store him via HTTP PUT
    collection.filter(function(person) {
      return person.get('name') === 'Bill';
    }).forEach(function(person) {
      promise.then(function() {
        console.log('changing Bill\'s age');
        person.set('age', 45).save();
      });
    });
    // change Bill's properties via HTTP PATCH
    collection.filter(function(person) {
      return person.get('name') === 'Bill';
    }).forEach(function(person) {
      promise.then(function() {
        console.log('patching Bill');
        person.save({name: 'Billy', age: 55} , {patch: true});
      });
    });
    // create Tim via HTTP POST
    promise.then(function() {
      console.log('creating Tim');
      collection.create({
        name: 'Tim', age: 50
      }, {
        wait: true,
        success: function(resp) {
          // delete John via HTTP DELETE
          collection.filter(function(person) {
            return person.get('name') === 'John';
          }).forEach(function(person) {
            console.log('destroying John');
            person.destroy({ success: function(resp) {
              // pretty print the people in the collection
              console.log(JSON.stringify(collection.toJSON()));
            }});
          });
        }
      });
    });
    promise.resolve();
  }});
});
promise.resolve();

}  </script>
</body>
</html>

This page creates the initial collection, then executes a series of modifications, then shows the final collection.  The modifications are as follows: (1) Bob's age is changed from 20 to 40; (2) Bill's age is changed from 25 to 45; (3) Bill's name is patched to "Billy" and his age is patched to 55; (4) Tim is created and added; and (5) John is deleted from the collection.

saving Bob
saving Bill
saving John
[{"id":41,"name":"Bill","age":25},{"id":978,"name":"Bob","age":20},{"id":726,"name":"John","age":30}]
changing Bob's age
changing Bill's age
patching Bill
creating Tim
destroying John
[{"id":41,"name":"Billy","age":55},{"id":978,"name":"Bob","age":40},{"name":"Tim","age":50,"id":689}]  

It works by first creating a Backbone.js model called Person.

It then creates a Backbone.js collection called PersonCollection which, like a model, is a "kind" of object, not an object instance.  By default, the url property assigns backbone.php as the server URL that will handle Backbone.js RESTful Persistence.

Then, a collection instance with model instances is created and stored in the people variable.

So far, no REST calls have been made.  Backbone.js REST calls are asynchronous so, to avoid race conditions, it is helpful to use a feature like the jQuery promise APIs.  The promise APIs allow the developer to make REST calls (or other function calls) to only let the next REST call start only after the previous REST call has finished.  The three Person objects in the collection are stored as promises so they can be saved to the backend in order.

A final promise is added to execute the remaining operations.  The people variable is replaced with an empty collection and the previously saved models will be load into the collection using Backbone.js fetch() API as a test.  A new promise variable is created and promises are made to change Bob's and Bill's ages.  Finally, Tim is created, John is destroyed/deleted and the models left in both the client-side (i.e. the collection variable) and the server-side (i.e. store.txt) are the same.

The resolve() function on the promise variable kicks off the actual execution of the promises.

Although I had hoped to avoid the complication of using the jQuery promise APIs in this example, the asynchronous nature of REST APIs make it necessary, even in this simple case.

The only backend is a backbone.php page.  It usually works but it has to use some hacks and tricks which aren't kosher.  It uses a single store.txt file for a data store in the service of clarity but at the expense of reliability; a database would be better.  It also might not work on every PHP server.  That said ...

... it will give you a good idea how Backbone.js RESTful Persistence works.

<?php
$lock = fopen('log.txt', 'a+');
$floc = flock($lock, LOCK_EX);
fwrite($lock, $_SERVER['REQUEST_METHOD']." ");
fwrite($lock, $_SERVER['REQUEST_URI']." lock\r\n");
if (!$floc) {
  fwrite($lock, "flock() failed\r\n");
}
if ($_SERVER['REQUEST_METHOD'] == 'GET') {
  // see if fetching a single object
  $id = explode('/', $_SERVER['REQUEST_URI']);
  $id = array_pop($id);
  if (strpos(strrev($id), strrev('.php')) === 0) {
    // select (fetch) the collection
    $json = file_get_contents('store.txt');
    if ($json === false) {
      $json = "[\n]";
    }
    fwrite($lock, $json."\r\n");
    print $json;
  } else {
    // select (fetch) the object
    fwrite($lock, "GET $id\r\n");
    if ((string)(int)$id != $id) {
      $id = '"'.$id.'"';
    }
    // get the item from store.txt
    $json = file_get_contents('store.txt');
    if ($json === false) {
      fwrite($lock, "[\n]\r\n");
      fwrite($lock, "{}\r\n");
      print "{}";
    } else {
      fwrite($lock, $json."\r\n");
      $found = false;
      $json = explode("\n", $json);
      $json = array_slice($json, 1, -1);
      for ($p=0; $p < count($json); ++$p) {
        if (strpos($json[$p], '"id":'.$id) !== false) {
          $person = rtrim($json[$p], ', ');
          $found = true;
          break;
        }
      }
      if ($found) {
        fwrite($lock, $person."\r\n");
        print $person;
      } else {
        fwrite($lock, "{}\r\n");
        print "{}";
      }
    }
  }
} else {
  // set flags for actions and options
  $emulateHTTP = false;
  $emulateJSON = false;
  $insert = false;
  $update = ($_SERVER['REQUEST_METHOD'] == 'PUT');
  $patch = ($_SERVER['REQUEST_METHOD'] == 'PATCH');
  $delete = ($_SERVER['REQUEST_METHOD'] == 'DELETE');
  if ($update || $patch) {
    // get the new value
    $person = file_get_contents('php://input');
    // don't trust $_SERVER['CONTENT_TYPE']
    if ((substr($person, 0, 1) != '{')
      && (substr($person, 0, 1) != '[')) {
      $emulateJSON = true;
    }
    if ($emulateJSON) {
      $person = urldecode($person);
      $person = explode('=', $person);
      $person = $person[1];
    }
  } elseif ($_SERVER['REQUEST_METHOD'] == 'POST') {
    // get emulated action and value
    $emulateHTTP = true;
    if (isset($_POST['_method'])) {
      if ($_POST['_method'] === 'PUT') {
        $update = true;
        $person = $_POST['model'];
      } elseif ($_POST['_method'] === 'PATCH') {
        $patch = true;
        $person = $_POST['model'];
      } elseif ($_POST['_method'] === 'DELETE') {
        $delete = true;
      }
    } elseif (isset($_POST['model'])) {
      $person = urldecode($_POST['model']);
      $insert = true;
    } else {
      $person = file_get_contents('php://input');
      if ($person === '') {
        $delete = true;
      } else {
        $id = explode('/', $_SERVER['REQUEST_URI']);
        $id = array_pop($id);
        if (is_numeric($id)) {
          if (preg_match('/"id":(.*?)[,}]/', $person) === 1) {
            $update = true;
          }
          $patch = !$update;
        } else {
          $insert = true;
          $emulateHTTP = false;
        }
      }
    }
  }
  if ($delete) {
    // delete the object from store.txt
    $id = explode('/', $_SERVER['REQUEST_URI']);
    $id = array_pop($id);
    fwrite($lock, "DELETE $id\r\n");
    if ((string)(int)$id != $id) {
      $id = '"'.$id.'"';
    }
    // remove the item in store.txt
    $json = file_get_contents('store.txt');
    if ($json === false) {
      fwrite($lock, "[\n]\r\n");
      fwrite($lock, "[\n]\r\n");
    } else {
      fwrite($lock, $json."\r\n");
      $found = false;
      $json = explode("\n", $json);
      $json = array_slice($json, 1, -1);
      for ($p=0; $p < count($json); ++$p) {
        if (strpos($json[$p], '"id":'.$id) !== false) {
          $person = rtrim($json[$p], ', ');
          array_splice($json, $p, 1);
          array_push($json, rtrim(array_pop($json), ', '));
          $found = true;
          break;
        }
      }
      if ($found) {
        $json = implode("\n", $json);
        $json = "[\n".$json."\n]";
        file_put_contents('store.txt', $json);
        print $person;
      }
      fwrite($lock, $json."\r\n");
    }
  } else {
    // insert (create) or update the item in store.txt
    $verb = $update? 'PUT ': 'PATCH ';
    fwrite($lock, ($insert? 'POST ': $verb)."$person\r\n");
    if ($insert) {
      $id = rand(0, 999);
      $person = '{"id":'.$id.','.substr($person, 1);
    } elseif ($patch) {
      $id = explode('/', $_SERVER['REQUEST_URI']);
      $id = array_pop($id);
      $person = '{"id":'.$id.','.substr($person, 1);
      // PATCH is broken but use JSON to merge $person
      //  with the value in store.txt
    }
    $json = file_get_contents('store.txt');
    if ($json === false) {
      fwrite($lock, "[\n]\r\n");
      $json = "[\n".$person."\n]";
    } else {
      fwrite($lock, $json."\r\n");
      preg_match('/"id":(.*?)[,}]/', $person, $id);
      $id = $id[1];
      $found = false;
      $json = explode("\n", $json);
      $json = array_slice($json, 1, -1);
      // replace the item with the new value
      if ($update || $patch) {
        for ($p=0; $p < count($json); ++$p) {
          if (strpos($json[$p], '"id":'.$id) !== false) {
            $last = ($p == count($json) - 1);
            $json[$p] = $person.($last? '': ',');
            $found = true;
          }
        }
      }
      $json = implode("\n", $json);
      // insert the new value
      if (!$found) {
        $json .= ",\n".$person;
      }
      $json = "[\n".$json."\n]";
    }
    fwrite($lock, $json."\r\n");
    file_put_contents('store.txt', $json);
    print $person;
  }
}
fwrite($lock, $_SERVER['REQUEST_METHOD']." unlock\r\n");
flock($lock, LOCK_UN);
fclose($lock);
?>

When the sync.html is loaded, it uses backbone.php to synchronize the models from the client-side to the server-side via REST.  By setting the url parameter on the Backbone.js Collection in sync.html, Backbone.js RESTful Persistence is activated and directed to backbone.php on the server.

The PHP REQUEST_METHOD is used to separate the HTTP actions into GET, PUT, PATCH, POST and DELETE.  The people.fetch() call in sync.html corresponds to the GET action in backbone.php.  The person.save() call in sync.html corresponds to the PUT or PATCH actions in backbone.php.  The collection.create() call in sync.html corresponds to the POST action in backbone.php.  The person.destroy() call in sync.html corresponds to the DELETE action in backbone.php.

The implementations of GET, PUT, PATCH, POST and DELETE all manipulate a store.txt file on the server that stores JSON data for the collection.  It is complicated in places to avoid reliance on a JSON library in PHP but the end result is the same: adding, changing and deleting objects from a JSON array which is stored in store.txt.

I wish that the PHP flock() function calls were not necessary but they are a solid but not wholly successful way to handle the race conditions that can occur on PHP platforms.  Race conditions can occur when several REST calls are executed simultaneously.  Backbone.js does not wait from one REST action to finish before executing another one and, to keep sync.html simple, I've ignored this on the client and used PHP flock() to control this on the server.  The log.txt file, which is used with PHP flock(), also serves as a log.

So, finally, here's something to get you started with Backbone.js and PHP.

No comments:

Post a Comment