API: expose the note's ETag

This commit is contained in:
korelstar 2021-03-07 21:42:35 +01:00
parent 236be66651
commit 7794375bdd
7 changed files with 85 additions and 61 deletions

View File

@ -19,12 +19,13 @@ The app and the API is mainly about notes. So, let's have a look about the attri
| Attribute | Type | Description | since API version |
|:----------|:-----|:-------------------------|:-------------------|
| `id` | integer | Every note has a unique identifier which is created by the server. It can be used to query and update a specific note. | 1.0 |
| `content` | string | Notes can contain arbitrary text. Formatting should be done using Markdown, but not every markup can be supported by every client. Therefore, markup should be used with care. | 1.0 |
| `title` | string | The note's title is also used as filename for the note's file. Therefore, some special characters are automatically removed and a sequential number is added if a note with the same title in the same category exists. When saving a title, the sanitized value is returned and should be adopted by your client. | 1.0 |
| `category` | string | Every note is assigned to a category. By default, the category is an empty string (not null), which means the note is uncategorized. Categories are mapped to folders in the file backend. Illegal characters are automatically removed and the respective folder is automatically created. Sub-categories (mapped to sub-folders) can be created by using `/` as delimiter. | 1.0 |
| `favorite` | boolean | If a note is marked as favorite, it is displayed at the top of the notes' list. Default is `false`. | 1.0 |
| `modified` | integer | Unix timestamp for the last modified date/time of the note. If not provided on note creation or content update, the current time is used. | 1.0 |
| `id` | integer (readonly) | Every note has a unique identifier which is created by the server. It can be used to query and update a specific note. | 1.0 |
| `etag` | string (readonly) | The note's entity tag (ETag) indicates if a note's attribute has changed. I.e., if the note changes, the ETag changes, too. Clients can use the ETag for detecting if the local note has to be updated from server and for [optimistic concurrency control](https://en.wikipedia.org/wiki/Optimistic_concurrency_control). | 1.2 |
| `content` | string (read/write) | Notes can contain arbitrary text. Formatting should be done using Markdown, but not every markup can be supported by every client. Therefore, markup should be used with care. | 1.0 |
| `title` | string (read/write) | The note's title is also used as filename for the note's file. Therefore, some special characters are automatically removed and a sequential number is added if a note with the same title in the same category exists. When saving a title, the sanitized value is returned and should be adopted by your client. | 1.0 |
| `category` | string (read/write) | Every note is assigned to a category. By default, the category is an empty string (not null), which means the note is uncategorized. Categories are mapped to folders in the file backend. Illegal characters are automatically removed and the respective folder is automatically created. Sub-categories (mapped to sub-folders) can be created by using `/` as delimiter. | 1.0 |
| `favorite` | boolean (read/write) | If a note is marked as favorite, it is displayed at the top of the notes' list. Default is `false`. | 1.0 |
| `modified` | integer (read/write) | Unix timestamp for the last modified date/time of the note. If not provided on note creation or content update, the current time is used. | 1.0 |
## Settings
@ -68,6 +69,7 @@ All defined routes in the specification are appended to this url. To access all
[
{
"id": 76,
"etag": "be284e00488c61c101ee28309d235e0b",
"modified": 1376753464,
"title": "New note",
"category": "sub-directory",
@ -96,6 +98,7 @@ No valid authentication credentials supplied.
```js
{
"id": 76,
"etag": "be284e00488c61c101ee28309d235e0b",
"modified": 1376753464,
"title": "New note",
"category": "sub-directory",
@ -118,7 +121,7 @@ Note not found.
<details><summary>Details</summary>
#### Request parameters
- **Body**: See section [Note attributes](#note-attributes) (except for `id`). All attributes are optional. Example:
- **Body**: some or all "read/write" attributes (see section [Note attributes](#note-attributes)), example:
```js
{
"title": "New note",
@ -149,7 +152,7 @@ Not enough free storage for saving the note's content.
| Parameter | Type | Description |
|:------|:-----|:-----|
| `id` | integer, required (path) | ID of the note to update. |
- **Body**: See section [Note attributes](#note-attributes) (except for `id`). All attributes are optional. Example see section [Create note](#create-note-post-notes).
- **Body**: some or all "read/write" attributes (see section [Note attributes](#note-attributes)), example see section [Create note](#create-note-post-notes).
#### Response
##### 200 OK

View File

@ -5,6 +5,10 @@ declare(strict_types=1);
namespace OCA\Notes\Controller;
use OCA\Notes\AppInfo\Application;
use OCA\Notes\Db\Meta;
use OCA\Notes\Service\Note;
use OCA\Notes\Service\NotesService;
use OCA\Notes\Service\MetaService;
use OCA\Notes\Service\Util;
use OCP\AppFramework\Http;
@ -15,15 +19,23 @@ use Psr\Log\LoggerInterface;
class Helper {
/** @var NotesService */
private $notesService;
/** @var MetaService */
private $metaService;
/** @var LoggerInterface */
public $logger;
/** @var IUserSession */
private $userSession;
public function __construct(
NotesService $notesService,
MetaService $metaService,
IUserSession $userSession,
LoggerInterface $logger
) {
$this->notesService = $notesService;
$this->metaService = $metaService;
$this->userSession = $userSession;
$this->logger = $logger;
}
@ -32,6 +44,43 @@ class Helper {
return $this->userSession->getUser()->getUID();
}
public function getNoteData(Note $note, array $exclude = [], Meta $meta = null) : array {
if ($meta === null) {
$meta = $this->metaService->update($this->getUID(), $note);
}
$data = $note->getData($exclude);
$data['etag'] = $meta->getEtag();
return $data;
}
public function getNotesAndCategories(
int $pruneBefore,
array $exclude,
string $category = null
) : array {
$userId = $this->getUID();
$data = $this->notesService->getAll($userId);
$notes = $data['notes'];
$metas = $this->metaService->updateAll($userId, $notes);
if ($category !== null) {
$notes = array_values(array_filter($notes, function ($note) use ($category) {
return $note->getCategory() === $category;
}));
}
$notesData = array_map(function ($note) use ($metas, $pruneBefore, $exclude) {
$meta = $metas[$note->getId()];
if ($pruneBefore && $meta->getLastUpdate() < $pruneBefore) {
return [ 'id' => $note->getId() ];
} else {
return $this->getNoteData($note, $exclude, $meta);
}
}, $notes);
return [
'notes' => $notesData,
'categories' => $data['categories'],
];
}
public function logException(\Throwable $e) : void {
$this->logger->error('Controller failed with '.get_class($e), [ 'exception' => $e ]);
}

View File

@ -49,21 +49,8 @@ class NotesApiController extends ApiController {
return $this->helper->handleErrorResponse(function () use ($category, $exclude, $pruneBefore) {
$exclude = explode(',', $exclude);
$now = new \DateTime(); // this must be before loading notes if there are concurrent changes possible
$notes = $this->service->getAll($this->helper->getUID())['notes'];
$metas = $this->metaService->updateAll($this->helper->getUID(), $notes);
if ($category !== null) {
$notes = array_values(array_filter($notes, function ($note) use ($category) {
return $note->getCategory() === $category;
}));
}
$notesData = array_map(function ($note) use ($metas, $pruneBefore, $exclude) {
$lastUpdate = $metas[$note->getId()]->getLastUpdate();
if ($pruneBefore && $lastUpdate < $pruneBefore) {
return [ 'id' => $note->getId() ];
} else {
return $note->getData($exclude);
}
}, $notes);
$data = $this->helper->getNotesAndCategories($pruneBefore, $exclude, $category);
$notesData = $data['notes'];
$etag = md5(json_encode($notesData));
return (new JSONResponse($notesData))
->setLastModified($now)
@ -81,7 +68,7 @@ class NotesApiController extends ApiController {
return $this->helper->handleErrorResponse(function () use ($id, $exclude) {
$exclude = explode(',', $exclude);
$note = $this->service->get($this->helper->getUID(), $id);
return $note->getData($exclude);
return $this->helper->getNoteData($note, $exclude);
});
}
@ -113,7 +100,7 @@ class NotesApiController extends ApiController {
$this->service->delete($this->helper->getUID(), $note->getId());
throw $e;
}
return $note->getData();
return $this->helper->getNoteData($note);
});
}
@ -171,8 +158,7 @@ class NotesApiController extends ApiController {
if ($favorite !== null) {
$note->setFavorite($favorite);
}
$this->metaService->update($this->helper->getUID(), $note);
return $note->getData();
return $this->helper->getNoteData($note);
});
}

View File

@ -49,24 +49,6 @@ class NotesController extends Controller {
$this->l10n = $l10n;
}
private function getNotesAndCategories(string $userId, int $pruneBefore) : array {
$data = $this->notesService->getAll($userId);
$metas = $this->metaService->updateAll($userId, $data['notes']);
$notes = array_map(function ($note) use ($metas, $pruneBefore) {
$lastUpdate = $metas[$note->getId()]->getLastUpdate();
if ($pruneBefore && $lastUpdate < $pruneBefore) {
return [ 'id' => $note->getId() ];
} else {
return $note->getData([ 'content' ]);
}
}, $data['notes']);
return [
'notes' => $notes,
'categories' => $data['categories'],
];
}
/**
* @NoAdminRequired
*/
@ -86,7 +68,7 @@ class NotesController extends Controller {
$categories = null;
try {
$nac = $this->getNotesAndCategories($userId, $pruneBefore);
$nac = $this->helper->getNotesAndCategories($pruneBefore, [ 'etag', 'content' ]);
[ 'notes' => $notes, 'categories' => $categories ] = $nac;
} catch (\Throwable $e) {
$this->helper->logException($e);
@ -161,10 +143,9 @@ class NotesController extends Controller {
strval($id)
);
$result = $note->getData();
$etag = md5(json_encode($result));
return (new JSONResponse($result))
->setETag($etag)
$noteData = $this->helper->getNoteData($note);
return (new JSONResponse($noteData))
->setETag($noteData['etag'])
;
});
}
@ -176,7 +157,7 @@ class NotesController extends Controller {
public function create(string $category) : JSONResponse {
return $this->helper->handleErrorResponse(function () use ($category) {
$note = $this->notesService->create($this->helper->getUID(), '', $category);
return $note->getData();
return $this->helper->getNoteData($note);
});
}
@ -203,7 +184,7 @@ class NotesController extends Controller {
try {
// check if note still exists
$note = $this->notesService->get($this->helper->getUID(), $id);
$noteData = $note->getData();
$noteData = $this->helper->getNoteData($note);
if ($noteData['error']) {
throw new \Exception();
}
@ -214,7 +195,7 @@ class NotesController extends Controller {
$note->setContent($content);
$note->setModified($modified);
$note->setFavorite($favorite);
return $note->getData();
return $this->helper->getNoteData($note);
}
});
}
@ -243,8 +224,7 @@ class NotesController extends Controller {
return $this->helper->handleErrorResponse(function () use ($id, $content) {
$note = $this->notesService->get($this->helper->getUID(), $id);
$note->setContent($content);
$this->metaService->update($this->helper->getUID(), $note);
return $note->getData();
return $this->helper->getNoteData($note);
});
}

View File

@ -92,17 +92,18 @@ class MetaService {
return $metas;
}
public function update(string $userId, Note $note) : void {
public function update(string $userId, Note $note) : Meta {
$meta = null;
try {
$meta = $this->metaMapper->findById($userId, $note->getId());
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
}
if ($meta === null) {
$this->createMeta($userId, $note);
$meta = $this->createMeta($userId, $note);
} elseif ($this->updateIfNeeded($meta, $note, true)) {
$this->metaMapper->update($meta);
}
return $meta;
}
private function getIndexedArray(array $data, string $property) : array {

View File

@ -199,6 +199,7 @@ abstract class AbstractAPITest extends TestCase {
$note->$key = $val;
}
$this->checkReferenceNote($note, $responseNote, 'Updated note');
return $responseNote;
}
protected function checkObject(

View File

@ -12,6 +12,7 @@ abstract class CommonAPITest extends AbstractAPITest {
'category' => 'string',
'modified' => 'integer',
'favorite' => 'boolean',
'etag' => 'string',
];
private $requiredSettings = [
@ -184,7 +185,7 @@ abstract class CommonAPITest extends AbstractAPITest {
$this->checkGetReferenceNotes(array_merge($refNotes, $testNotes), 'Pre-condition');
$note = $testNotes[0];
// test update note with all attributes
$this->updateNote($note, (object)[
$rn1 = $this->updateNote($note, (object)[
'title' => 'First *manual* edited title',
'content' => '# *First* edited/ note'.PHP_EOL.'This is some body content with some data.',
'favorite' => false,
@ -194,19 +195,22 @@ abstract class CommonAPITest extends AbstractAPITest {
'title' => $this->autotitle ? 'First edited note' : 'First manual edited title',
]);
// test update note with single attributes
$this->updateNote($note, (object)[
$rn2 = $this->updateNote($note, (object)[
'category' => 'Test/Third Category',
], (object)[]);
// TODO test update category with read-only folder (target category)
$this->updateNote($note, (object)[
$rn3 = $this->updateNote($note, (object)[
'favorite' => true,
], (object)[]);
$this->updateNote($note, (object)[
$rn4 = $this->updateNote($note, (object)[
'content' => '# First multi edited note'.PHP_EOL.'This is some body content with some data.',
], (object)[
'title' => $this->autotitle ? 'First multi edited note' : 'First manual edited title',
'modified' => time(),
]);
$this->assertNotEquals($rn1->etag, $rn2->etag, 'ETag changed on update (1)');
$this->assertNotEquals($rn2->etag, $rn3->etag, 'ETag changed on update (2)');
$this->assertNotEquals($rn3->etag, $rn4->etag, 'ETag changed on update (3)');
$this->checkGetReferenceNotes(array_merge($refNotes, $testNotes), 'After updating notes');
return $testNotes;
}