Tutorial

This tutorial will outline how to create a very simple notes app. The finished app is available on GitHub.

Setup

First the development environment needs to be set up. This can be done by either downloading the zip from the website or cloning it directly from GitHub:

  1. git clone git@github.com:nextcloud/server.git --branch $BRANCH
  2. cd server
  3. git submodule update --init

Note

$BRANCH is the desired Nextcloud branch (e.g. stable19 for Nextcloud 19, master for the upcoming release)

First you want to enable debug mode to get proper error messages. To do that set debug to true in the config/config.php file:

  1. <?php
  2. $CONFIG = array (
  3. 'debug' => true,
  4. ... configuration goes here ...
  5. );

Note

PHP errors are logged to data/nextcloud.log

Now open another terminal window and start the development server:

  1. cd nextcloud
  2. php -S localhost:8080

Afterwards a skeleton app can be created in the app store.

Download the compressed file that contains the generated app and extract it into your apps/ directory. Afterwards the application can be enabled on the apps page.

The first basic app is now available at http://localhost:8080/index.php/apps/yourappid/

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:

  1. // example for calling the PUT /notes/1 URL
  2. var baseUrl = OC.generateUrl('/apps/notestutorial');
  3. var note = {
  4. title: 'New note',
  5. content: 'This is the note text'
  6. };
  7. var id = 1;
  8. $.ajax({
  9. url: baseUrl + '/notes/' + id,
  10. type: 'PUT',
  11. contentType: 'application/json',
  12. data: JSON.stringify(note)
  13. }).done(function (response) {
  14. // handle success
  15. }).fail(function (response, code) {
  16. // handle failure
  17. });

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 controller and the controller will be connected to the URL with a route. The controller and route for the page are already set up in notestutorial/appinfo/routes.php:

  1. <?php
  2. return ['routes' => [
  3. ['name' => 'page#index', 'url' => '/', 'verb' => 'GET']
  4. ]];

This route calls the controller OCA\notestutorial\PageController->index() method which is defined in notestutorial/lib/Controller/PageController.php. The controller returns a template, in this case notestutorial/templates/main.php:

Note

@NoAdminRequired and @NoCSRFRequired in the comments above the method turn off security checks, see requests/controllers

  1. <?php
  2. namespace OCA\NotesTutorial\Controller;
  3. use OCP\IRequest;
  4. use OCP\AppFramework\Http\TemplateResponse;
  5. use OCP\AppFramework\Controller;
  6. class PageController extends Controller {
  7. public function __construct(string $AppName, IRequest $request){
  8. parent::__construct($AppName, $request);
  9. }
  10. /**
  11. * @NoAdminRequired
  12. * @NoCSRFRequired
  13. */
  14. public function index() {
  15. return new TemplateResponse('notestutorial', 'main');
  16. }
  17. }

Since the route which returns the initial HTML has been taken care of, the controller which handles the AJAX requests for the notes needs to be set up. Create the following file: notestutorial/lib/Controller/NoteController.php with the following content:

  1. <?php
  2. namespace OCA\NotesTutorial\Controller;
  3. use OCP\IRequest;
  4. use OCP\AppFramework\Controller;
  5. class NoteController extends Controller {
  6. public function __construct(string $AppName, IRequest $request){
  7. parent::__construct($AppName, $request);
  8. }
  9. /**
  10. * @NoAdminRequired
  11. */
  12. public function index() {
  13. // empty for now
  14. }
  15. /**
  16. * @NoAdminRequired
  17. *
  18. * @param int $id
  19. */
  20. public function show(int $id) {
  21. // empty for now
  22. }
  23. /**
  24. * @NoAdminRequired
  25. *
  26. * @param string $title
  27. * @param string $content
  28. */
  29. public function create(string $title, string $content) {
  30. // empty for now
  31. }
  32. /**
  33. * @NoAdminRequired
  34. *
  35. * @param int $id
  36. * @param string $title
  37. * @param string $content
  38. */
  39. public function update(int $id, string $title, string $content) {
  40. // empty for now
  41. }
  42. /**
  43. * @NoAdminRequired
  44. *
  45. * @param int $id
  46. */
  47. public function destroy(int $id) {
  48. // empty for now
  49. }
  50. }

Note

The parameters are extracted from the request body and the URL using the controller method’s variable names. Since PHP does not support type hints for primitive types such as ints and booleans, we need to add them as annotations in the comments. In order to type cast a parameter to an int, add @param int $parameterName

Now the controller methods need to be connected to the corresponding URLs in the notestutorial/appinfo/routes.php file:

  1. <?php
  2. return [
  3. 'routes' => [
  4. ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
  5. ['name' => 'note#index', 'url' => '/notes', 'verb' => 'GET'],
  6. ['name' => 'note#show', 'url' => '/notes/{id}', 'verb' => 'GET'],
  7. ['name' => 'note#create', 'url' => '/notes', 'verb' => 'POST'],
  8. ['name' => 'note#update', 'url' => '/notes/{id}', 'verb' => 'PUT'],
  9. ['name' => 'note#destroy', 'url' => '/notes/{id}', 'verb' => 'DELETE']
  10. ]
  11. ];

Since those 5 routes are so common, they can be abbreviated by adding a resource instead:

  1. <?php
  2. return [
  3. 'resources' => [
  4. 'note' => ['url' => '/notes']
  5. ],
  6. 'routes' => [
  7. ['name' => 'page#index', 'url' => '/', 'verb' => 'GET']
  8. ]
  9. ];

Database

Now that the routes are set up and connected the notes should be saved in the database. To do that first create a database migration by creating a file notestutorial/lib/Migration/VersionXXYYZZDateYYYYMMDDHHSSAA.php, so for example notestutorial/lib/Migration/Version000000Date20181013124731.php“”

  1. <?php
  2. namespace OCA\NotesTutorial\Migration;
  3. use Closure;
  4. use OCP\DB\ISchemaWrapper;
  5. use OCP\Migration\SimpleMigrationStep;
  6. use OCP\Migration\IOutput;
  7. class Version1400Date20181013124731 extends SimpleMigrationStep {
  8. /**
  9. * @param IOutput $output
  10. * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
  11. * @param array $options
  12. * @return null|ISchemaWrapper
  13. */
  14. public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
  15. /** @var ISchemaWrapper $schema */
  16. $schema = $schemaClosure();
  17. if (!$schema->hasTable('notestutorial')) {
  18. $table = $schema->createTable('notestutorial');
  19. $table->addColumn('id', 'integer', [
  20. 'autoincrement' => true,
  21. 'notnull' => true,
  22. ]);
  23. $table->addColumn('title', 'string', [
  24. 'notnull' => true,
  25. 'length' => 200
  26. ]);
  27. $table->addColumn('user_id', 'string', [
  28. 'notnull' => true,
  29. 'length' => 200,
  30. ]);
  31. $table->addColumn('content', 'text', [
  32. 'notnull' => true,
  33. 'default' => ''
  34. ]);
  35. $table->setPrimaryKey(['id']);
  36. $table->addIndex(['user_id'], 'notestutorial_user_id_index');
  37. }
  38. return $schema;
  39. }
  40. }

To create the tables in the database, run the migration command:

  1. php ./occ migrations:execute <appId> <versionNumber>
  2. Example: sudo -u www-data php ./occ migrations:execute photos 000000Date20201002183800

Note

to trigger the table creation/alteration when user updating the app, update the version tag in notestutorial/appinfo/info.xml . migration will be executed when user reload page after app upgrade

  1. <?xml version="1.0"?>
  2. <info>
  3. <id>notestutorial</id>
  4. <name>Notes Tutorial</name>
  5. <description>My first Nextcloud app</description>
  6. <licence>AGPL</licence>
  7. <author>Your Name</author>
  8. <version>0.0.2</version>
  9. <namespace>notestutorial</namespace>
  10. <category>tool</category>
  11. <dependencies>
  12. <owncloud min-version="8" />
  13. </dependencies>
  14. </info>

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 entity in notestutorial/lib/Db/Note.php:

  1. <?php
  2. namespace OCA\NotesTutorial\Db;
  3. use JsonSerializable;
  4. use OCP\AppFramework\Db\Entity;
  5. class Note extends Entity implements JsonSerializable {
  6. protected $title;
  7. protected $content;
  8. protected $userId;
  9. public function __construct() {
  10. $this->addType('id','integer');
  11. }
  12. public function jsonSerialize() {
  13. return [
  14. 'id' => $this->id,
  15. 'title' => $this->title,
  16. 'content' => $this->content
  17. ];
  18. }
  19. }

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 Mappers. Let’s create one in notestutorial/lib/Db/NoteMapper.php and add a find and findAll method:

  1. <?php
  2. namespace OCA\NotesTutorial\Db;
  3. use OCP\IDBConnection;
  4. use OCP\AppFramework\Db\QBMapper;
  5. class NoteMapper extends QBMapper {
  6. public function __construct(IDBConnection $db) {
  7. parent::__construct($db, 'notestutorial_notes', Note::class);
  8. }
  9. public function find(int $id, string $userId) {
  10. $qb = $this->db->getQueryBuilder();
  11. $qb->select('*')
  12. ->from($this->getTableName())
  13. ->where(
  14. $qb->expr()->eq('id', $qb->createNamedParameter($id))
  15. )->andWhere(
  16. $qb->expr()->eq('user_id', $qb->createNamedParameter($userId))
  17. );
  18. return $this->findEntity($qb);
  19. }
  20. public function findAll(string $userId) {
  21. $qb = $this->db->getQueryBuilder();
  22. $qb->select('*')
  23. ->from($this->getTableName())
  24. ->where(
  25. $qb->expr()->eq('user_id', $qb->createNamedParameter($userId))
  26. );
  27. return $this->findEntities($qb);
  28. }
  29. }

Note

The first parent constructor parameter is the database layer, the second one is the 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.

You can pass in the mapper by adding it as a type hinted parameter. Nextcloud will figure out how to 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 notestutorial/lib/Controller/NoteController.php and change it to the following:

  1. <?php
  2. namespace OCA\NotesTutorial\Controller;
  3. use Exception;
  4. use OCP\IRequest;
  5. use OCP\AppFramework\Http;
  6. use OCP\AppFramework\Http\DataResponse;
  7. use OCP\AppFramework\Controller;
  8. use OCA\NotesTutorial\Db\Note;
  9. use OCA\NotesTutorial\Db\NoteMapper;
  10. class NoteController extends Controller {
  11. private $mapper;
  12. private $userId;
  13. public function __construct(string $AppName, IRequest $request, NoteMapper $mapper, $UserId){
  14. parent::__construct($AppName, $request);
  15. $this->mapper = $mapper;
  16. $this->userId = $UserId;
  17. }
  18. /**
  19. * @NoAdminRequired
  20. */
  21. public function index() {
  22. return new DataResponse($this->mapper->findAll($this->userId));
  23. }
  24. /**
  25. * @NoAdminRequired
  26. *
  27. * @param int $id
  28. */
  29. public function show(int $id) {
  30. try {
  31. return new DataResponse($this->mapper->find($id, $this->userId));
  32. } catch(Exception $e) {
  33. return new DataResponse([], Http::STATUS_NOT_FOUND);
  34. }
  35. }
  36. /**
  37. * @NoAdminRequired
  38. *
  39. * @param string $title
  40. * @param string $content
  41. */
  42. public function create(string $title, string $content) {
  43. $note = new Note();
  44. $note->setTitle($title);
  45. $note->setContent($content);
  46. $note->setUserId($this->userId);
  47. return new DataResponse($this->mapper->insert($note));
  48. }
  49. /**
  50. * @NoAdminRequired
  51. *
  52. * @param int $id
  53. * @param string $title
  54. * @param string $content
  55. */
  56. public function update(int $id, string $title, string $content) {
  57. try {
  58. $note = $this->mapper->find($id, $this->userId);
  59. } catch(Exception $e) {
  60. return new DataResponse([], Http::STATUS_NOT_FOUND);
  61. }
  62. $note->setTitle($title);
  63. $note->setContent($content);
  64. return new DataResponse($this->mapper->update($note));
  65. }
  66. /**
  67. * @NoAdminRequired
  68. *
  69. * @param int $id
  70. */
  71. public function destroy(int $id) {
  72. try {
  73. $note = $this->mapper->find($id, $this->userId);
  74. } catch(Exception $e) {
  75. return new DataResponse([], Http::STATUS_NOT_FOUND);
  76. }
  77. $this->mapper->delete($note);
  78. return new DataResponse($note);
  79. }
  80. }

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.

Making things reusable and decoupling controllers from the database

Let’s say our app is now on the app store and we get a request that we should save the files in the filesystem which requires access to the filesystem.

The filesystem API is quite different from the database API and throws different exceptions, which means we need to rewrite everything in the NoteController class to use it. This is bad because a controller’s only responsibility should be to deal with incoming Http requests and return Http responses. If we need to change the controller because the data storage was changed the code is probably too tightly coupled and we need to add another layer in between. This layer is called Service.

Let’s take the logic that was inside the controller and put it into a separate class inside notestutorial/lib/Service/NoteService.php:

  1. <?php
  2. namespace OCA\NotesTutorial\Service;
  3. use Exception;
  4. use OCP\AppFramework\Db\DoesNotExistException;
  5. use OCP\AppFramework\Db\MultipleObjectsReturnedException;
  6. use OCA\NotesTutorial\Db\Note;
  7. use OCA\NotesTutorial\Db\NoteMapper;
  8. class NoteService {
  9. private $mapper;
  10. public function __construct(NoteMapper $mapper){
  11. $this->mapper = $mapper;
  12. }
  13. public function findAll(string $userId) {
  14. return $this->mapper->findAll($userId);
  15. }
  16. private function handleException ($e) {
  17. if ($e instanceof DoesNotExistException ||
  18. $e instanceof MultipleObjectsReturnedException) {
  19. throw new NotFoundException($e->getMessage());
  20. } else {
  21. throw $e;
  22. }
  23. }
  24. public function find(int $id, string $userId) {
  25. try {
  26. return $this->mapper->find($id, $userId);
  27. // in order to be able to plug in different storage backends like files
  28. // for instance it is a good idea to turn storage related exceptions
  29. // into service related exceptions so controllers and service users
  30. // have to deal with only one type of exception
  31. } catch(Exception $e) {
  32. $this->handleException($e);
  33. }
  34. }
  35. public function create(string $title, string $content, string $userId) {
  36. $note = new Note();
  37. $note->setTitle($title);
  38. $note->setContent($content);
  39. $note->setUserId($userId);
  40. return $this->mapper->insert($note);
  41. }
  42. public function update(int $id, string $title, string $content, string $userId) {
  43. try {
  44. $note = $this->mapper->find($id, $userId);
  45. $note->setTitle($title);
  46. $note->setContent($content);
  47. return $this->mapper->update($note);
  48. } catch(Exception $e) {
  49. $this->handleException($e);
  50. }
  51. }
  52. public function delete(int $id, string $userId) {
  53. try {
  54. $note = $this->mapper->find($id, $userId);
  55. $this->mapper->delete($note);
  56. return $note;
  57. } catch(Exception $e) {
  58. $this->handleException($e);
  59. }
  60. }
  61. }

Following up create the exceptions in notestutorial/lib/Service/ServiceException.php:

  1. <?php
  2. namespace OCA\NotesTutorial\Service;
  3. use Exception;
  4. class ServiceException extends Exception {}

and notestutorial/lib/Service/NotFoundException.php:

  1. <?php
  2. namespace OCA\NotesTutorial\Service;
  3. class NotFoundException extends ServiceException {}

Remember how we had all those ugly try catches that where checking for DoesNotExistException and simply returned a 404 response? Let’s also put this into a reusable class. In our case we chose a trait so we can inherit methods without having to add it to our inheritance hierarchy. This will be important later on when you’ve got controllers that inherit from the ApiController class instead.

The trait is created in notestutorial/lib/Controller/Errors.php:

  1. <?php
  2. namespace OCA\NotesTutorial\Controller;
  3. use Closure;
  4. use OCP\AppFramework\Http;
  5. use OCP\AppFramework\Http\DataResponse;
  6. use OCA\NotesTutorial\Service\NotFoundException;
  7. trait Errors {
  8. protected function handleNotFound (Closure $callback) {
  9. try {
  10. return new DataResponse($callback());
  11. } catch(NotFoundException $e) {
  12. $message = ['message' => $e->getMessage()];
  13. return new DataResponse($message, Http::STATUS_NOT_FOUND);
  14. }
  15. }
  16. }

Now we can wire up the trait and the service inside the NoteController:

  1. <?php
  2. namespace OCA\NotesTutorial\Controller;
  3. use OCP\IRequest;
  4. use OCP\AppFramework\Http\DataResponse;
  5. use OCP\AppFramework\Controller;
  6. use OCA\NotesTutorial\Service\NoteService;
  7. class NoteController extends Controller {
  8. private $service;
  9. private $userId;
  10. use Errors;
  11. public function __construct(string $AppName, IRequest $request,
  12. NoteService $service, $UserId){
  13. parent::__construct($AppName, $request);
  14. $this->service = $service;
  15. $this->userId = $UserId;
  16. }
  17. /**
  18. * @NoAdminRequired
  19. */
  20. public function index() {
  21. return new DataResponse($this->service->findAll($this->userId));
  22. }
  23. /**
  24. * @NoAdminRequired
  25. *
  26. * @param int $id
  27. */
  28. public function show(int $id) {
  29. return $this->handleNotFound(function () use ($id) {
  30. return $this->service->find($id, $this->userId);
  31. });
  32. }
  33. /**
  34. * @NoAdminRequired
  35. *
  36. * @param string $title
  37. * @param string $content
  38. */
  39. public function create(string $title, string $content) {
  40. return $this->service->create($title, $content, $this->userId);
  41. }
  42. /**
  43. * @NoAdminRequired
  44. *
  45. * @param int $id
  46. * @param string $title
  47. * @param string $content
  48. */
  49. public function update(int $id, string $title, string $content) {
  50. return $this->handleNotFound(function () use ($id, $title, $content) {
  51. return $this->service->update($id, $title, $content, $this->userId);
  52. });
  53. }
  54. /**
  55. * @NoAdminRequired
  56. *
  57. * @param int $id
  58. */
  59. public function destroy(int $id) {
  60. return $this->handleNotFound(function () use ($id) {
  61. return $this->service->delete($id, $this->userId);
  62. });
  63. }
  64. }

Great! Now the only reason that the controller needs to be changed is when request/response related things change.

Writing a test for the controller (recommended)

Tests are essential for having happy users and a carefree life. No one wants their users to rant about your app breaking their Nextcloud 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 Nextcloud uses 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 be added by adding this to notestutorial/tests/Unit/Controller/NoteControllerTest.php:

  1. <?php
  2. namespace OCA\NotesTutorial\Tests\Unit\Controller;
  3. use PHPUnit_Framework_TestCase;
  4. use OCP\AppFramework\Http;
  5. use OCP\AppFramework\Http\DataResponse;
  6. use OCA\NotesTutorial\Service\NotFoundException;
  7. class NoteControllerTest extends PHPUnit_Framework_TestCase {
  8. protected $controller;
  9. protected $service;
  10. protected $userId = 'john';
  11. protected $request;
  12. public function setUp() {
  13. $this->request = $this->getMockBuilder('OCP\IRequest')->getMock();
  14. $this->service = $this->getMockBuilder('OCA\NotesTutorial\Service\NoteService')
  15. ->disableOriginalConstructor()
  16. ->getMock();
  17. $this->controller = new NoteController(
  18. 'notestutorial', $this->request, $this->service, $this->userId
  19. );
  20. }
  21. public function testUpdate() {
  22. $note = 'just check if this value is returned correctly';
  23. $this->service->expects($this->once())
  24. ->method('update')
  25. ->with($this->equalTo(3),
  26. $this->equalTo('title'),
  27. $this->equalTo('content'),
  28. $this->equalTo($this->userId))
  29. ->will($this->returnValue($note));
  30. $result = $this->controller->update(3, 'title', 'content');
  31. $this->assertEquals($note, $result->getData());
  32. }
  33. public function testUpdateNotFound() {
  34. // test the correct status code if no note is found
  35. $this->service->expects($this->once())
  36. ->method('update')
  37. ->will($this->throwException(new NotFoundException()));
  38. $result = $this->controller->update(3, 'title', 'content');
  39. $this->assertEquals(Http::STATUS_NOT_FOUND, $result->getStatus());
  40. }
  41. }

We can and should also create a test for the NoteService class:

  1. <?php
  2. namespace OCA\NotesTutorial\Tests\Unit\Service;
  3. use PHPUnit_Framework_TestCase;
  4. use OCP\AppFramework\Db\DoesNotExistException;
  5. use OCA\NotesTutorial\Db\Note;
  6. class NoteServiceTest extends PHPUnit_Framework_TestCase {
  7. private $service;
  8. private $mapper;
  9. private $userId = 'john';
  10. public function setUp() {
  11. $this->mapper = $this->getMockBuilder('OCA\NotesTutorial\Db\NoteMapper')
  12. ->disableOriginalConstructor()
  13. ->getMock();
  14. $this->service = new NoteService($this->mapper);
  15. }
  16. public function testUpdate() {
  17. // the existing note
  18. $note = Note::fromRow([
  19. 'id' => 3,
  20. 'title' => 'yo',
  21. 'content' => 'nope'
  22. ]);
  23. $this->mapper->expects($this->once())
  24. ->method('find')
  25. ->with($this->equalTo(3))
  26. ->will($this->returnValue($note));
  27. // the note when updated
  28. $updatedNote = Note::fromRow(['id' => 3]);
  29. $updatedNote->setTitle('title');
  30. $updatedNote->setContent('content');
  31. $this->mapper->expects($this->once())
  32. ->method('update')
  33. ->with($this->equalTo($updatedNote))
  34. ->will($this->returnValue($updatedNote));
  35. $result = $this->service->update(3, 'title', 'content', $this->userId);
  36. $this->assertEquals($updatedNote, $result);
  37. }
  38. /**
  39. * @expectedException OCA\NotesTutorial\Service\NotFoundException
  40. */
  41. public function testUpdateNotFound() {
  42. // test the correct status code if no note is found
  43. $this->mapper->expects($this->once())
  44. ->method('find')
  45. ->with($this->equalTo(3))
  46. ->will($this->throwException(new DoesNotExistException('')));
  47. $this->service->update(3, 'title', 'content', $this->userId);
  48. }
  49. }

If PHPUnit in version 8 is installed we can run the tests inside notestutorial/ with the following command:

  1. phpunit

Note

You need to adjust the notestutorial/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 whether 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 update method without mocking out the NoteMapper class so we actually write to the existing database.

To do that create a new file called notestutorial/tests/Integration/NoteIntegrationTest.php with the following content:

  1. <?php
  2. namespace OCA\NotesTutorial\Tests\Integration\Controller;
  3. use OCP\AppFramework\Http\DataResponse;
  4. use OCP\AppFramework\App;
  5. use Test\TestCase;
  6. use OCA\NotesTutorial\Db\Note;
  7. /**
  8. * @group DB
  9. */
  10. class NoteIntegrationTest extends TestCase {
  11. private $controller;
  12. private $mapper;
  13. private $userId = 'john';
  14. public function setUp() {
  15. parent::setUp();
  16. $app = new App('notestutorial');
  17. $container = $app->getContainer();
  18. // only replace the user id
  19. $container->registerService('UserId', function($c) {
  20. return $this->userId;
  21. });
  22. $this->controller = $container->query(
  23. 'OCA\NotesTutorial\Controller\NoteController'
  24. );
  25. $this->mapper = $container->query(
  26. 'OCA\NotesTutorial\Db\NoteMapper'
  27. );
  28. }
  29. public function testUpdate() {
  30. // create a new note that should be updated
  31. $note = new Note();
  32. $note->setTitle('old_title');
  33. $note->setContent('old_content');
  34. $note->setUserId($this->userId);
  35. $id = $this->mapper->insert($note)->getId();
  36. // fromRow does not set the fields as updated
  37. $updatedNote = Note::fromRow([
  38. 'id' => $id,
  39. 'user_id' => $this->userId
  40. ]);
  41. $updatedNote->setContent('content');
  42. $updatedNote->setTitle('title');
  43. $result = $this->controller->update($id, 'title', 'content');
  44. $this->assertEquals($updatedNote, $result->getData());
  45. // clean up
  46. $this->mapper->delete($result->getData());
  47. }
  48. }

To run the integration tests change into the notestutorial directory and run:

  1. phpunit -c phpunit.integration.xml

Adding a RESTful API (optional)

A 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 Nextcloud it is a good idea to add (and document!) your own RESTful API.

Because we put our logic into the NoteService class it is very easy to reuse it. 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 notestutorial/lib/Controller/NoteApiController.php:

  1. <?php
  2. namespace OCA\NotesTutorial\Controller;
  3. use OCP\IRequest;
  4. use OCP\AppFramework\Http\DataResponse;
  5. use OCP\AppFramework\ApiController;
  6. use OCA\NotesTutorial\Service\NoteService;
  7. class NoteApiController extends ApiController {
  8. private $service;
  9. private $userId;
  10. use Errors;
  11. public function __construct($AppName, IRequest $request,
  12. NoteService $service, $UserId){
  13. parent::__construct($AppName, $request);
  14. $this->service = $service;
  15. $this->userId = $UserId;
  16. }
  17. /**
  18. * @CORS
  19. * @NoCSRFRequired
  20. * @NoAdminRequired
  21. */
  22. public function index() {
  23. return new DataResponse($this->service->findAll($this->userId));
  24. }
  25. /**
  26. * @CORS
  27. * @NoCSRFRequired
  28. * @NoAdminRequired
  29. *
  30. * @param int $id
  31. */
  32. public function show($id) {
  33. return $this->handleNotFound(function () use ($id) {
  34. return $this->service->find($id, $this->userId);
  35. });
  36. }
  37. /**
  38. * @CORS
  39. * @NoCSRFRequired
  40. * @NoAdminRequired
  41. *
  42. * @param string $title
  43. * @param string $content
  44. */
  45. public function create($title, $content) {
  46. return $this->service->create($title, $content, $this->userId);
  47. }
  48. /**
  49. * @CORS
  50. * @NoCSRFRequired
  51. * @NoAdminRequired
  52. *
  53. * @param int $id
  54. * @param string $title
  55. * @param string $content
  56. */
  57. public function update($id, $title, $content) {
  58. return $this->handleNotFound(function () use ($id, $title, $content) {
  59. return $this->service->update($id, $title, $content, $this->userId);
  60. });
  61. }
  62. /**
  63. * @CORS
  64. * @NoCSRFRequired
  65. * @NoAdminRequired
  66. *
  67. * @param int $id
  68. */
  69. public function destroy($id) {
  70. return $this->handleNotFound(function () use ($id) {
  71. return $this->service->delete($id, $this->userId);
  72. });
  73. }
  74. }

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:

  1. <?php
  2. return [
  3. 'resources' => [
  4. 'note' => ['url' => '/notes'],
  5. 'note_api' => ['url' => '/api/0.1/notes']
  6. ],
  7. 'routes' => [
  8. ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
  9. ['name' => 'note_api#preflighted_cors', 'url' => '/api/0.1/{path}',
  10. 'verb' => 'OPTIONS', 'requirements' => ['path' => '.+']]
  11. ]
  12. ];

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:

  1. curl -u user:password http://localhost:8080/index.php/apps/notestutorial/api/0.1/notes

Since the NoteApiController is basically identical to the NoteController, the unit test for it simply inherits its tests from the NoteControllerTest. Create the file notestutorial/tests/Unit/Controller/NoteApiControllerTest.php:

  1. <?php
  2. namespace OCA\NotesTutorial\Tests\Unit\Controller;
  3. require_once __DIR__ . '/NoteControllerTest.php';
  4. class NoteApiControllerTest extends NoteControllerTest {
  5. public function setUp() {
  6. parent::setUp();
  7. $this->controller = new NoteApiController(
  8. 'notestutorial', $this->request, $this->service, $this->userId
  9. );
  10. }
  11. }

Building the frontend

To create a modern webapp you need to write JavaScript. You can use any JavaScript framework, but this tutorial focusses on a simple frontend using Vue.js. For a more detailed introduction to Vue.js please head over to the official documentation.

The source files of our frontend will be stored in the src/ directory. We use webpack for bundling the files and output of that will be stored in js/notestutorial.js.

The template of our view will be very simple due to the fact that Vue.js is taking care of all frontend rendering. We only need to load the main script bundle and add a div that will be replaced by our Vue app at runtime:

  1. <?php
  2. script('notestutorial', 'notestutorial');
  3. <div id="content"></div>

The frontend source code will consist of two files:

  • main.js which is the main entry point of our javascript code that gets loaded when the page is opened
  • App.vue which is our one single file component that takes care of all logic inside of the Vue app. Our example app contains some additional comments to explain how the frontend is built.

Congratulations! You’ve written your first Nextcloud app. You can now either try to further improve the tutorial notes app or start writing your own app.