diff --git a/developer_manual/app/index.rst b/developer_manual/app/index.rst index 74cb7a44f..231a0620b 100644 --- a/developer_manual/app/index.rst +++ b/developer_manual/app/index.rst @@ -7,6 +7,7 @@ :maxdepth: 2 :hidden: + tutorial startapp init info @@ -50,12 +51,16 @@ Before starting to write an app please read the security and coding guidelines: * :doc:`../general/security` * :doc:`../general/codingguidelines` -After this you can start to write your first app: +After this you can start with the tutorial -* :doc:`startapp` +* :doc:`tutorial` App development =============== +Create a new app: + +* :doc:`startapp` + Inner parts of an app: * :doc:`init` diff --git a/developer_manual/app/routes.rst b/developer_manual/app/routes.rst index d443038f7..52a568e2a 100644 --- a/developer_manual/app/routes.rst +++ b/developer_manual/app/routes.rst @@ -172,7 +172,7 @@ can be abbreviated by using the **resources** key: $application = new Application(); $application->registerRoutes($this, array( 'resources' => array( - array('author' => array('url' => '/authors')) + 'author' => array('url' => '/authors') ), 'routes' => array( // your other routes here diff --git a/developer_manual/app/tutorial.rst b/developer_manual/app/tutorial.rst new file mode 100644 index 000000000..0a8596e0e --- /dev/null +++ b/developer_manual/app/tutorial.rst @@ -0,0 +1,1021 @@ +======== +Tutorial +======== + +.. sectionauthor:: Bernhard Posselt + +This tutorial will outline how to create a very simple notes app. The finished app is available on `GitHub `_. + + +Setup +===== +After the `development tool `_ has been installed the :doc:`development environment needs to be set up <../general/devenv>`. This can be done by either `downloading the zip from the website `_ or cloning it directly from GitHub:: + + ocdev setup core --dir owncloud --branch stable8 + +First you want to enable debug mode to get proper error messages. To do that add **DEFINE('DEBUG', true);** at the end of the **owncloud/config/config.php** file:: + + echo "\nDEFINE('DEBUG', true);" >> owncloud/config/config.php + +.. note:: PHP errors are logged to **owncloud/data/owncloud.log** + +Now open another terminal window and start the development server:: + + cd owncloud + php -S localhost:8080 + +Afterwards the app can be created in the **apps** folder:: + + cd apps + ocdev startapp OwnNotes + +This creates a new folder called **ownnotes**. Now access and set up ownCloud through the webinterface at `http://localhost:8080 `_ and enable the OwnNotes application on the `apps page `_. + +The first basic app is now available at `http://localhost:8080/index.php/apps/ownnotes/ `_ + +Routes & Controllers +==================== +A typical web application consists of server side and client side code. The glue between those two parts are the URLs. In case of the notes app the following URLs will be used: + +* **GET /**: Returns the interface in HTML +* **GET /notes**: Returns a list of all notes in JSON +* **GET /notes/1**: Returns a note with the id 1 in JSON +* **DELETE /notes/1**: Deletes a note with the id 1 +* **POST /notes**: Creates a new note by passing in JSON +* **PUT /notes/1**: Updates a note with the id 1 by passing in JSON + +On the client side we can call these URLs with the following jQuery code: + +.. code-block:: js + + // example for calling the PUT /notes/1 URL + var baseUrl = OC.generateUrl('/apps/ownnotes'); + var note = { + title: 'New note', + content: 'This is the note text' + }; + var id = 1; + $.ajax({ + url: baseUrl + '/notes/' + id, + type: 'PUT', + contentType: 'application/json', + data: JSON.stringify(note) + }).done(function (response) { + // handle success + }).fail(function (response, code) { + // handle failure + }); + +On the server side we need to register a callback that is executed once the request comes in. The callback itself will be a method on a :doc:`controller ` and the controller will be connected to the URL with a :doc:`route `. The controller and route for the page are already set up in **ownnotes/appinfo/routes.php**: + +.. code-block:: php + + [ + ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'] + ]]; + +This route calls the controller **OCA\\OwnNotes\\PageController->index()** method which is defined in **ownnotes/controller/pagecontroller.php**. The controller returns a :doc:`template `, in this case **ownnotes/templates/main.php**: + +.. note:: @NoAdminRequired and @NoCSRFRequired in the comments above the method turn off security checks, see :doc:`controllers` + +.. code-block:: php + + [ + ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], + ['name' => 'note#index', 'url' => '/notes', 'verb' => 'GET'], + ['name' => 'note#show', 'url' => '/notes/{id}', 'verb' => 'GET'], + ['name' => 'note#create', 'url' => '/notes', 'verb' => 'POST'], + ['name' => 'note#update', 'url' => '/notes/{id}', 'verb' => 'PUT'], + ['name' => 'note#destroy', 'url' => '/notes/{id}', 'verb' => 'DELETE'] + ] + ]; + +Since those 5 routes are so common, they can be abbreviated by adding a resource instead: + +.. code-block:: php + + [ + 'note' => ['url' => '/notes'] + ], + 'routes' => [ + ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'] + ] + ]; + +Database +======== +Now that the routes are set up and connected the notes should be saved in the database. To do that first create a :doc:`database schema ` by creating **ownnotes/appinfo/database.xml**: + +.. code-block:: xml + + + *dbname* + true + false + utf8 + + *dbprefix*ownnotes_notes + + + id + integer + true + true + true + true + 8 + + + title + text + 200 + + true + + + user_id + text + 200 + + true + + + content + clob + + true + + +
+
+ +To create the tables in the database, the :doc:`version tag ` in **ownnotes/appinfo/info.xml** needs to be increased: + +.. code-block:: xml + + + + ownnotes + Own Notes + My first ownCloud app + AGPL + Your Name + 0.0.2 + OwnNotes + other + + + + + +Reload the page to trigger the database migration. + +Now that the tables are created we want to map the database result to a PHP object to be able to control data. First create an :doc:`entity ` in **ownnotes/db/note.php**: + + +.. code-block:: php + + $this->id, + 'title' => $this->title, + 'content' => $this->content + ]; + } + } + +.. note:: A field **id** is automatically set in the Entity base class + +We also define a **jsonSerializable** method and implement the interface to be able to transform the entity to JSON easily. + +Entities are returned from so called :doc:`Mappers `. Let's create one in **ownnotes/db/notemapper.php** and add a **find** and **findAll** method: + +.. code-block:: php + + findEntity($sql, [$id, $userId]); + } + + public function findAll($userId) { + $sql = 'SELECT * FROM *PREFIX*ownnotes_notes WHERE user_id = ?'; + return $this->findEntities($sql, [$userId]); + } + + } + +.. note:: The first parent constructor parameter is the database layer, the second one database table and the third is the entity on which the result should be mapped onto. Insert, delete and update methods are already implemented. + +Connect Database & Controllers +============================== +The mapper which provides the database access is finished and can be passed into the controller. + +In general it is good practice to use another class between mappers and controllers (aka services) to be able to reuse the code in other places, but for the sake of brevity we will leave them out. + +You can pass in the mapper by adding it as a type hinted parameter. ownCloud will figure out how to :doc:`assemble them by itself `. Additionally we want to know the userId of the currently logged in user. Simply add a **$UserId** parameter to the constructor (case sensitive!). To do that open **ownnotes/controller/notecontroller.php** and change it to the following: + +.. code-block:: php + + mapper = $mapper; + $this->userId = $UserId; + } + + /** + * @NoAdminRequired + */ + public function index() { + return new DataResponse($this->mapper->findAll($this->userId)); + } + + /** + * @NoAdminRequired + * + * @param int $id + */ + public function show($id) { + try { + return new DataResponse($this->mapper->find($id, $this->userId)); + } catch(Exception $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + } + + /** + * @NoAdminRequired + * + * @param string $title + * @param string $content + */ + public function create($title, $content) { + $note = new Note(); + $note->setTitle($title); + $note->setContent($content); + $note->setUserId($this->userId); + return new DataResponse($this->mapper->insert($note)); + } + + /** + * @NoAdminRequired + * + * @param int $id + * @param string $title + * @param string $content + */ + public function update($id, $title, $content) { + try { + $note = $this->mapper->find($id, $this->userId); + } catch(Exception $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + $note->setTitle($title); + $note->setContent($content); + return new DataResponse($this->mapper->update($note)); + } + + /** + * @NoAdminRequired + * + * @param int $id + */ + public function destroy($id) { + try { + $note = $this->mapper->find($id, $this->userId); + } catch(Exception $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + $this->mapper->delete($note); + return new DataResponse($note); + } + + } + +.. note:: The actual exceptions are **OCP\\AppFramework\\Db\\DoesNotExistException** and **OCP\\AppFramework\\Db\\MultipleObjectsReturnedException** but in this example we will treat them as the same. DataResponse is a more generic response than JSONResponse and also works with JSON. + +This is all that is needed on the server side. Now let's progress to the client side. + + +Writing a test for the controller (optional) +============================================ +Tests are essential for having happy users and a carefree life. No one wants their users to rant about your app breaking their ownCloud or being buggy. To do that you need to test your app. Since this amounts to a ton of repetitive tasks, we need to automate the tests. + +Unit Tests +---------- +A unit test is a test that tests a class in isolation. It is very fast and catches most of the bugs, so we want many unit tests. + +Because ownCloud uses :doc:`Dependency Injection ` to assemble your app, it is very easy to write unit tests by passing mocks into the constructor. A simple test for the update method can added by adding this to **ownnotes/tests/unit/controller/NoteControllerTest.php**: + +.. code-block:: php + + getMockBuilder('OCP\IRequest')->getMock(); + $this->mapper = $this->getMockBuilder('OCA\OwnNotes\Db\NoteMapper') + ->disableOriginalConstructor() + ->getMock(); + $this->controller = new NoteController( + 'ownnotes', $request, $this->mapper, $this->userId + ); + } + + public function testUpdate() { + // the existing note + $note = Note::fromRow([ + 'id' => 3, + 'title' => 'yo', + 'content' => 'nope' + ]); + $this->mapper->expects($this->once()) + ->method('find') + ->with($this->equalTo(3)) + ->will($this->returnValue($note)); + + // the note when updated + $updatedNote = Note::fromRow(['id' => 3]); + $updatedNote->setTitle('title'); + $updatedNote->setContent('content'); + $this->mapper->expects($this->once()) + ->method('update') + ->with($this->equalTo($updatedNote)) + ->will($this->returnValue($updatedNote)); + + $result = $this->controller->update(3, 'title', 'content'); + + $this->assertEquals($updatedNote, $result->getData()); + } + + + public function testUpdateNotFound() { + // test the correct status code if no note is found + $this->mapper->expects($this->once()) + ->method('find') + ->with($this->equalTo(3)) + ->will($this->throwException(new DoesNotExistException(''))); + + $result = $this->controller->update(3, 'title', 'content'); + + $this->assertEquals(Http::STATUS_NOT_FOUND, $result->getStatus()); + } + + } + + +If `PHPUnit is installed `_ we can run the tests inside **ownnotes/** with the following command:: + + phpunit + +.. note:: You need to adjust the **ownnotes/tests/unit/controller/PageControllerTest** file to get the tests passing: remove the **testEcho** method since that method is no longer present in your **PageController** and do not test the user id parameters since they are not passed anymore + +Integration Tests +----------------- +Integration tests are slow and need a fully working instance but make sure that our classes work well together. Instead of mocking out all classes and parameters we can decide wether to use full instances or replace certain classes. Because they are slow we don't want as many integration tests as unit tests. + +In our case we want to create an integration test for the udpate method without mocking out the **NoteMapper** class so we actually write to the existing database. + +To do that create a new file called **ownnotes/tests/integration/NoteIntegrationTest.php** with the following content: + +.. code-block:: php + + getContainer(); + + // only replace the user id + $container->registerService('UserId', function($c) { + return $this->userId; + }); + + $this->controller = $container->query( + 'OCA\OwnNotes\Controller\NoteController' + ); + + $this->mapper = $container->query( + 'OCA\OwnNotes\Db\NoteMapper' + ); + } + + public function testUpdate() { + // create a new note that should be updated + $note = new Note(); + $note->setTitle('old_title'); + $note->setContent('old_content'); + $note->setUserId($this->userId); + + $id = $this->mapper->insert($note)->getId(); + + // fromRow does not set the fields as updated + $updatedNote = Note::fromRow([ + 'id' => $id, + 'user_id' => $this->userId + ]); + $updatedNote->setContent('content'); + $updatedNote->setTitle('title'); + + $result = $this->controller->update($id, 'title', 'content'); + + $this->assertEquals($updatedNote, $result->getData()); + + // clean up + $this->mapper->delete($result->getData()); + } + + } + +To run the integration tests change into the **ownnotes** directory and run:: + + phpunit -c phpunit.integration.xml + +Adding a RESTful API (optional) +=============================== +A :doc:`RESTful API ` allows other apps such as Android or iPhone apps to access and change your notes. Since syncing is a big core component of ownCloud it is a good idea to add (and document!) your own RESTful API. + +Because **NoteController** already offers a RESTful API and returns JSON it is easy to reuse. The only pieces that need to be changed are the annotations which disable the CSRF check (not needed for a REST call usually) and add support for `CORS `_ so your API can be accessed from other webapps. + +With that in mind create a new controller in **ownnotes/controller/noteapicontroller.php**: + +.. code-block:: php + + controller = $controller; + } + + /** + * @CORS + * @NoCSRFRequired + * @NoAdminRequired + */ + public function index() { + return $this->controller->index(); + } + + /** + * @CORS + * @NoCSRFRequired + * @NoAdminRequired + * + * @param int $id + */ + public function show($id) { + return $this->controller->show($id); + } + + /** + * @CORS + * @NoCSRFRequired + * @NoAdminRequired + * + * @param string $title + * @param string $content + */ + public function create($title, $content) { + return $this->controller->create($title, $content); + } + + /** + * @CORS + * @NoCSRFRequired + * @NoAdminRequired + * + * @param int $id + * @param string $title + * @param string $content + */ + public function update($id, $title, $content) { + return $this->controller->update($id, $title, $content); + } + + /** + * @CORS + * @NoCSRFRequired + * @NoAdminRequired + * + * @param int $id + */ + public function destroy($id) { + return $this->controller->destroy($id); + } + + } + +All that is left is to connect the controller to a route and enable the built in preflighted CORS method which is defined in the **ApiController** base class: + +.. code-block:: php + + [ + 'note' => ['url' => '/notes'], + 'note_api' => ['url' => '/api/0.1/notes'] + ], + 'routes' => [ + ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], + ['name' => 'note_api#preflighted_cors', 'url' => '/api/0.1/{path}', + 'verb' => 'OPTIONS', 'requirements' => ['path' => '.+']] + ] + ]; + +.. note:: It is a good idea to version your API in your URL + +You can test the API by running a GET request with **curl**:: + + curl -u user:password http://localhost:8080/index.php/apps/ownnotes/api/0.1/notes + +Adding JavaScript and CSS +========================= +To create a modern webapp you need to write :doc:`JavaScript`. You can use any JavaScript framework but for this tutorial we want to keep it as simple as possible and therefore only include the templating library `handlebarsjs `_. `Download the file `_ into **ownnotes/js/handlebars.js** and include it at the very top of **ownnotes/templates/main.php** before the other scripts and styles: + +.. code-block:: php + + ` which we are going to reuse to style the navigation. Adjust the file to contain only the following code: + +.. code-block:: php + + + + + + +
    + +Creating the content +==================== +The template file **ownnotes/templates/part.content.php** contains the content. It will just be a textarea and a button, so replace the content with the following: + +.. code-block:: php + + +
    + +Wiring it up +============ + +When the page is loaded we want all the existing notes to load. Furthermore we want to display the current note when you click on it in the navigation, a note should be deleted when we click the deleted button and clicking on **New note** should create a new note. To do that open **ownnotes/js/script.js** and replace the example code with the following: + +.. code-block:: js + + (function (OC, window, $, undefined) { + 'use strict'; + + $(document).ready(function () { + + var translations = { + newNote: $('#new-note-string').text() + }; + + // this notes object holds all our notes + var Notes = function (baseUrl) { + this._baseUrl = baseUrl; + this._notes = []; + this._activeNote = undefined; + }; + + Notes.prototype = { + load: function (id) { + var self = this; + this._notes.forEach(function (note) { + if (note.id === id) { + note.active = true; + self._activeNote = note; + } else { + note.active = false; + } + }); + }, + getActive: function () { + return this._activeNote; + }, + removeActive: function () { + var index; + var deferred = $.Deferred(); + var id = this._activeNote.id; + this._notes.forEach(function (note, counter) { + if (note.id === id) { + index = counter; + } + }); + + if (index !== undefined) { + // delete cached active note if necessary + if (this._activeNote === this._notes[index]) { + delete this._activeNote; + } + + this._notes.splice(index, 1); + + $.ajax({ + url: this._baseUrl + '/' + id, + method: 'DELETE' + }).done(function () { + deferred.resolve(); + }).fail(function () { + deferred.reject(); + }); + } else { + deferred.reject(); + } + return deferred.promise(); + }, + create: function (note) { + var deferred = $.Deferred(); + var self = this; + $.ajax({ + url: this._baseUrl, + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(note) + }).done(function (note) { + self._notes.push(note); + self._activeNote = note; + self.load(note.id); + deferred.resolve(); + }).fail(function () { + deferred.reject(); + }); + return deferred.promise(); + }, + getAll: function () { + return this._notes; + }, + loadAll: function () { + var deferred = $.Deferred(); + var self = this; + $.get(this._baseUrl).done(function (notes) { + self._activeNote = undefined; + self._notes = notes; + deferred.resolve(); + }).fail(function () { + deferred.reject(); + }); + return deferred.promise(); + }, + updateActive: function (title, content) { + var note = this.getActive(); + note.title = title; + note.content = content; + + return $.ajax({ + url: this._baseUrl + '/' + note.id, + method: 'PUT', + contentType: 'application/json', + data: JSON.stringify(note) + }); + } + }; + + // this will be the view that is used to update the html + var View = function (notes) { + this._notes = notes; + }; + + View.prototype = { + renderContent: function () { + var source = $('#content-tpl').html(); + var template = Handlebars.compile(source); + var html = template({note: this._notes.getActive()}); + + $('#editor').html(html); + + // handle saves + var textarea = $('#app-content textarea'); + var self = this; + $('#app-content button').click(function () { + var content = textarea.val(); + var title = content.split('\n')[0]; // first line is the title + + self._notes.updateActive(title, content).done(function () { + self.render(); + }).fail(function () { + alert('Could not update note, not found'); + }); + }); + }, + renderNavigation: function () { + var source = $('#navigation-tpl').html(); + var template = Handlebars.compile(source); + var html = template({notes: this._notes.getAll()}); + + $('#app-navigation ul').html(html); + + // create a new note + var self = this; + $('#new-note').click(function () { + var note = { + title: translations.newNote, + content: '' + }; + + self._notes.create(note).done(function() { + self.render(); + $('#editor textarea').focus(); + }).fail(function () { + alert('Could not create note'); + }); + }); + + // show app menu + $('#app-navigation .app-navigation-entry-utils-menu-button').click(function () { + var entry = $(this).closest('.note'); + entry.find('.app-navigation-entry-menu').toggleClass('open'); + }); + + // delete a note + $('#app-navigation .note .delete').click(function () { + var entry = $(this).closest('.note'); + entry.find('.app-navigation-entry-menu').removeClass('open'); + + self._notes.removeActive().done(function () { + self.render(); + }).fail(function () { + alert('Could not delete note, not found'); + }); + }); + + // load a note + $('#app-navigation .note > a').click(function () { + var id = parseInt($(this).parent().data('id'), 10); + self._notes.load(id); + self.render(); + $('#editor textarea').focus(); + }); + }, + render: function () { + this.renderNavigation(); + this.renderContent(); + } + }; + + var notes = new Notes(OC.generateUrl('/apps/ownnotes/notes')); + var view = new View(notes); + notes.loadAll().done(function () { + view.render(); + }).fail(function () { + alert('Could not load notes'); + }); + + + }); + + })(OC, window, jQuery); + + +Apply finishing touches +======================= +Now the only thing left is to style the textare in a nicer fashion. To do that open **ownnotes/css/style.css** and replace the content with the following :doc:`CSS ` code: + +.. code-block:: css + + #app-content-wrapper { + height: 100%; + } + + #editor { + height: 100%; + width: 100%; + } + + #editor .input { + height: calc(100% - 51px); + width: 100%; + } + + #editor .save { + height: 50px; + width: 100%; + text-align: center; + border-top: 1px solid #ccc; + background-color: #fafafa; + } + + #editor textarea { + height: 100%; + width: 100%; + border: 0; + margin: 0; + border-radius: 0; + overflow-y: auto; + } + + #editor button { + height: 44px; + } + +Congratulations! You've written your first ownCloud app. You can now either try to further improve the tutorial notes app or start writing your own app. \ No newline at end of file