Merge pull request #2245 from nextcloud/enh/etag-api

This commit is contained in:
Julius Härtl
2020-11-10 21:58:30 +01:00
committed by GitHub
21 changed files with 98 additions and 18 deletions

View File

@@ -69,6 +69,31 @@ curl -u admin:admin -X GET \
-H "If-Modified-Since: Mon, 05 Nov 2018 09:28:00 GMT" -H "If-Modified-Since: Mon, 05 Nov 2018 09:28:00 GMT"
``` ```
### ETag
An ETag header is returned in order to determine if further child elements have been updated for the following endpoints:
- Fetch all user board `GET /api/v1.0/boards`
- Fetch a single board `GET /api/v1.0/boards/{boardId}`
- Fetch all stacks of a board `GET /api/v1.0/boards/{boardId}/stacks`
- Fetch a single stacks of a board `GET /api/v1.0/boards/{boardId}/stacks/{stackId}`
- Fetch a single card of a board `GET /api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}`
- Fetch attachments of a card `GET /api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments`
If a `If-None-Match` header is provided and the requested element has not changed a `304` Not Modified response will be returned.
Changes of child elements will propagate to their parents and also cause an update of the ETag which will be useful for determining if a sync is necessary on any client integration side. As an example, if a label is added to a card, the ETag of all related entities (the card, stack and board) will change.
If available the ETag will also be part of JSON response objects as shown below for a card:
```json
{
"id": 81,
"ETag": "bdb10fa2d2aeda092a2b6b469454dc90",
"title": "Test card"
}
```
# Changelog # Changelog
## 1.0.0 (unreleased) ## 1.0.0 (unreleased)

View File

@@ -24,6 +24,7 @@
namespace OCA\Deck\Activity; namespace OCA\Deck\Activity;
use OCA\Deck\Db\CardMapper; use OCA\Deck\Db\CardMapper;
use OCA\Deck\Db\ChangeHelper;
use OCA\Deck\Notification\NotificationHelper; use OCA\Deck\Notification\NotificationHelper;
use OCP\Comments\CommentsEvent; use OCP\Comments\CommentsEvent;
use OCP\Comments\IComment; use OCP\Comments\IComment;
@@ -40,10 +41,14 @@ class CommentEventHandler implements ICommentsEventHandler {
/** @var CardMapper */ /** @var CardMapper */
private $cardMapper; private $cardMapper;
public function __construct(ActivityManager $activityManager, NotificationHelper $notificationHelper, CardMapper $cardMapper) { /** @var ChangeHelper */
private $changeHelper;
public function __construct(ActivityManager $activityManager, NotificationHelper $notificationHelper, CardMapper $cardMapper, ChangeHelper $changeHelper) {
$this->notificationHelper = $notificationHelper; $this->notificationHelper = $notificationHelper;
$this->activityManager = $activityManager; $this->activityManager = $activityManager;
$this->cardMapper = $cardMapper; $this->cardMapper = $cardMapper;
$this->changeHelper = $changeHelper;
} }
/** /**
@@ -54,6 +59,8 @@ class CommentEventHandler implements ICommentsEventHandler {
return; return;
} }
$this->changeHelper->cardChanged($event->getComment()->getObjectId());
$eventType = $event->getEvent(); $eventType = $event->getEvent();
if ($eventType === CommentsEvent::EVENT_ADD if ($eventType === CommentsEvent::EVENT_ADD
) { ) {

View File

@@ -24,6 +24,7 @@
namespace OCA\Deck\Controller; namespace OCA\Deck\Controller;
use OCA\Deck\Db\Board;
use OCA\Deck\StatusException; use OCA\Deck\StatusException;
use OCP\AppFramework\ApiController; use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http; use OCP\AppFramework\Http;
@@ -72,7 +73,11 @@ class BoardApiController extends ApiController {
} }
$boards = $this->boardService->findAll($date->getTimestamp(), $details); $boards = $this->boardService->findAll($date->getTimestamp(), $details);
} }
return new DataResponse($boards, HTTP::STATUS_OK); $response = new DataResponse($boards, HTTP::STATUS_OK);
$response->setETag(md5(json_encode(array_map(function (Board $board) {
return $board->getId() . '-' . $board->getETag();
}, $boards))));
return $response;
} }
/** /**
@@ -85,7 +90,9 @@ class BoardApiController extends ApiController {
*/ */
public function get() { public function get() {
$board = $this->boardService->find($this->request->getParam('boardId')); $board = $this->boardService->find($this->request->getParam('boardId'));
return new DataResponse($board, HTTP::STATUS_OK); $response = new DataResponse($board, HTTP::STATUS_OK);
$response->setETag($board->getEtag());
return $response;
} }
/** /**

View File

@@ -64,7 +64,9 @@ class CardApiController extends ApiController {
*/ */
public function get() { public function get() {
$card = $this->cardService->find($this->request->getParam('cardId')); $card = $this->cardService->find($this->request->getParam('cardId'));
return new DataResponse($card, HTTP::STATUS_OK); $response = new DataResponse($card, HTTP::STATUS_OK);
$response->setETag($card->getEtag());
return $response;
} }
/** /**

View File

@@ -83,7 +83,9 @@ class StackApiController extends ApiController {
*/ */
public function get() { public function get() {
$stack = $this->stackService->find($this->request->getParam('stackId')); $stack = $this->stackService->find($this->request->getParam('stackId'));
return new DataResponse($stack, HTTP::STATUS_OK); $response = new DataResponse($stack, HTTP::STATUS_OK);
$response->setETag($stack->getETag());
return $response;
} }
/** /**

View File

@@ -81,4 +81,8 @@ class Board extends RelationalEntity {
$this->acl[] = $a; $this->acl[] = $a;
} }
} }
public function getETag() {
return md5((string)$this->getLastModified());
}
} }

View File

@@ -155,4 +155,8 @@ class Card extends RelationalEntity {
public function getCalendarPrefix(): string { public function getCalendarPrefix(): string {
return 'card'; return 'card';
} }
public function getETag() {
return md5((string)$this->getLastModified());
}
} }

View File

@@ -36,4 +36,8 @@ class Label extends RelationalEntity {
$this->addType('cardId', 'integer'); $this->addType('cardId', 'integer');
$this->addType('lastModified', 'integer'); $this->addType('lastModified', 'integer');
} }
public function getETag() {
return md5((string)$this->getLastModified());
}
} }

View File

@@ -80,6 +80,9 @@ class RelationalEntity extends Entity implements \JsonSerializable {
$json[$property] = $value; $json[$property] = $value;
} }
} }
if ($reflection->hasMethod('getETag')) {
$json['ETag'] = $this->getETag();
}
return $json; return $json;
} }

View File

@@ -65,4 +65,8 @@ class Stack extends RelationalEntity {
public function getCalendarPrefix(): string { public function getCalendarPrefix(): string {
return 'stack'; return 'stack';
} }
public function getETag() {
return md5((string)$this->getLastModified());
}
} }

View File

@@ -148,7 +148,7 @@ class AssignmentService {
$assignment->setParticipant($userId); $assignment->setParticipant($userId);
$assignment->setType($type); $assignment->setType($type);
$assignment = $this->assignedUsersMapper->insert($assignment); $assignment = $this->assignedUsersMapper->insert($assignment);
$this->changeHelper->cardChanged($cardId, false); $this->changeHelper->cardChanged($cardId);
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_USER_ASSIGN, ['assigneduser' => $userId]); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_USER_ASSIGN, ['assigneduser' => $userId]);
$this->eventDispatcher->dispatch( $this->eventDispatcher->dispatch(
@@ -185,7 +185,7 @@ class AssignmentService {
$assignment = $this->assignedUsersMapper->delete($assignment); $assignment = $this->assignedUsersMapper->delete($assignment);
$card = $this->cardMapper->find($cardId); $card = $this->cardMapper->find($cardId);
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_USER_UNASSIGN, ['assigneduser' => $userId]); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_USER_UNASSIGN, ['assigneduser' => $userId]);
$this->changeHelper->cardChanged($cardId, false); $this->changeHelper->cardChanged($cardId);
$this->eventDispatcher->dispatch( $this->eventDispatcher->dispatch(
'\OCA\Deck\Card::onUpdate', new FTSEvent(null, ['id' => $cardId, 'card' => $card]) '\OCA\Deck\Card::onUpdate', new FTSEvent(null, ['id' => $cardId, 'card' => $card])

View File

@@ -320,6 +320,7 @@ class AttachmentService {
if ($service->allowUndo()) { if ($service->allowUndo()) {
$service->markAsDeleted($attachment); $service->markAsDeleted($attachment);
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $attachment, ActivityManager::SUBJECT_ATTACHMENT_DELETE); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $attachment, ActivityManager::SUBJECT_ATTACHMENT_DELETE);
$this->changeHelper->cardChanged($attachment->getCardId());
return $this->attachmentMapper->update($attachment); return $this->attachmentMapper->update($attachment);
} }
$service->delete($attachment); $service->delete($attachment);

View File

@@ -538,7 +538,7 @@ class CardService {
} }
$label = $this->labelMapper->find($labelId); $label = $this->labelMapper->find($labelId);
$this->cardMapper->assignLabel($cardId, $labelId); $this->cardMapper->assignLabel($cardId, $labelId);
$this->changeHelper->cardChanged($cardId, false); $this->changeHelper->cardChanged($cardId);
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_LABEL_ASSIGN, ['label' => $label]); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_LABEL_ASSIGN, ['label' => $label]);
$this->eventDispatcher->dispatch( $this->eventDispatcher->dispatch(
@@ -574,7 +574,7 @@ class CardService {
} }
$label = $this->labelMapper->find($labelId); $label = $this->labelMapper->find($labelId);
$this->cardMapper->removeLabel($cardId, $labelId); $this->cardMapper->removeLabel($cardId, $labelId);
$this->changeHelper->cardChanged($cardId, false); $this->changeHelper->cardChanged($cardId);
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_LABEL_UNASSING, ['label' => $label]); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_LABEL_UNASSING, ['label' => $label]);
$this->eventDispatcher->dispatch( $this->eventDispatcher->dispatch(

View File

@@ -25,6 +25,7 @@ namespace OCA\Deck\Activity;
use OCA\Deck\Db\Card; use OCA\Deck\Db\Card;
use OCA\Deck\Db\CardMapper; use OCA\Deck\Db\CardMapper;
use OCA\Deck\Db\ChangeHelper;
use OCA\Deck\Notification\NotificationHelper; use OCA\Deck\Notification\NotificationHelper;
use OCP\Comments\CommentsEvent; use OCP\Comments\CommentsEvent;
use OCP\Comments\IComment; use OCP\Comments\IComment;
@@ -45,10 +46,12 @@ class CommentEventHandlerTest extends TestCase {
$this->activityManager = $this->createMock(ActivityManager::class); $this->activityManager = $this->createMock(ActivityManager::class);
$this->notificationHelper = $this->createMock(NotificationHelper::class); $this->notificationHelper = $this->createMock(NotificationHelper::class);
$this->cardMapper = $this->createMock(CardMapper::class); $this->cardMapper = $this->createMock(CardMapper::class);
$this->changeHelper = $this->createMock(ChangeHelper::class);
$this->commentEventHandler = new CommentEventHandler( $this->commentEventHandler = new CommentEventHandler(
$this->activityManager, $this->activityManager,
$this->notificationHelper, $this->notificationHelper,
$this->cardMapper $this->cardMapper,
$this->changeHelper
); );
} }

View File

@@ -32,6 +32,7 @@ class BoardTest extends TestCase {
'archived' => false, 'archived' => false,
'users' => ['user1', 'user2'], 'users' => ['user1', 'user2'],
'settings' => [], 'settings' => [],
'ETag' => $board->getETag(),
], $board->jsonSerialize()); ], $board->jsonSerialize());
} }
@@ -52,6 +53,7 @@ class BoardTest extends TestCase {
'archived' => false, 'archived' => false,
'users' => [], 'users' => [],
'settings' => [], 'settings' => [],
'ETag' => $board->getETag(),
], $board->jsonSerialize()); ], $board->jsonSerialize());
} }
public function testSetAcl() { public function testSetAcl() {
@@ -80,6 +82,7 @@ class BoardTest extends TestCase {
'shared' => 1, 'shared' => 1,
'users' => [], 'users' => [],
'settings' => [], 'settings' => [],
'ETag' => $board->getETag(),
], $board->jsonSerialize()); ], $board->jsonSerialize());
} }
} }

View File

@@ -84,6 +84,7 @@ class CardTest extends TestCase {
'deletedAt' => 0, 'deletedAt' => 0,
'commentsUnread' => 0, 'commentsUnread' => 0,
'lastEditor' => null, 'lastEditor' => null,
'ETag' => $card->getETag(),
], $card->jsonSerialize()); ], $card->jsonSerialize());
} }
public function testJsonSerializeLabels() { public function testJsonSerializeLabels() {
@@ -109,6 +110,7 @@ class CardTest extends TestCase {
'deletedAt' => 0, 'deletedAt' => 0,
'commentsUnread' => 0, 'commentsUnread' => 0,
'lastEditor' => null, 'lastEditor' => null,
'ETag' => $card->getETag(),
], $card->jsonSerialize()); ], $card->jsonSerialize());
} }
@@ -144,6 +146,7 @@ class CardTest extends TestCase {
'deletedAt' => 0, 'deletedAt' => 0,
'commentsUnread' => 0, 'commentsUnread' => 0,
'lastEditor' => null, 'lastEditor' => null,
'ETag' => $card->getETag(),
], $card->jsonSerialize()); ], $card->jsonSerialize());
} }
} }

View File

@@ -42,7 +42,8 @@ class LabelTest extends TestCase {
'boardId' => 123, 'boardId' => 123,
'cardId' => null, 'cardId' => null,
'lastModified' => null, 'lastModified' => null,
'color' => '000000' 'color' => '000000',
'ETag' => $label->getETag(),
], $label->jsonSerialize()); ], $label->jsonSerialize());
} }
public function testJsonSerializeCard() { public function testJsonSerializeCard() {
@@ -54,7 +55,8 @@ class LabelTest extends TestCase {
'boardId' => null, 'boardId' => null,
'cardId' => 123, 'cardId' => 123,
'lastModified' => null, 'lastModified' => null,
'color' => '000000' 'color' => '000000',
'ETag' => $label->getETag(),
], $label->jsonSerialize()); ], $label->jsonSerialize());
} }
} }

View File

@@ -33,7 +33,7 @@ class StackTest extends \Test\TestCase {
return $board; return $board;
} }
public function testJsonSerialize() { public function testJsonSerialize() {
$board = $this->createStack(); $stack = $this->createStack();
$this->assertEquals([ $this->assertEquals([
'id' => 1, 'id' => 1,
'title' => "My Stack", 'title' => "My Stack",
@@ -41,12 +41,13 @@ class StackTest extends \Test\TestCase {
'boardId' => 1, 'boardId' => 1,
'deletedAt' => 0, 'deletedAt' => 0,
'lastModified' => 0, 'lastModified' => 0,
], $board->jsonSerialize()); 'ETag' => $stack->getETag(),
], $stack->jsonSerialize());
} }
public function testJsonSerializeWithCards() { public function testJsonSerializeWithCards() {
$cards = ["foo", "bar"]; $cards = ["foo", "bar"];
$board = $this->createStack(); $stack = $this->createStack();
$board->setCards($cards); $stack->setCards($cards);
$this->assertEquals([ $this->assertEquals([
'id' => 1, 'id' => 1,
'title' => "My Stack", 'title' => "My Stack",
@@ -55,6 +56,7 @@ class StackTest extends \Test\TestCase {
'cards' => ["foo", "bar"], 'cards' => ["foo", "bar"],
'deletedAt' => 0, 'deletedAt' => 0,
'lastModified' => 0, 'lastModified' => 0,
], $board->jsonSerialize()); 'ETag' => $stack->getETag(),
], $stack->jsonSerialize());
} }
} }

View File

@@ -72,7 +72,7 @@ class BoardApiControllerTest extends \Test\TestCase {
$expected = new DataResponse($boards, HTTP::STATUS_OK); $expected = new DataResponse($boards, HTTP::STATUS_OK);
$actual = $this->controller->index(); $actual = $this->controller->index();
$actual->setETag(null);
$this->assertEquals($expected, $actual); $this->assertEquals($expected, $actual);
} }
@@ -90,6 +90,7 @@ class BoardApiControllerTest extends \Test\TestCase {
->will($this->returnValue($boardId)); ->will($this->returnValue($boardId));
$expected = new DataResponse($board, HTTP::STATUS_OK); $expected = new DataResponse($board, HTTP::STATUS_OK);
$expected->setETag($board->getETag());
$actual = $this->controller->get(); $actual = $this->controller->get();
$this->assertEquals($expected, $actual); $this->assertEquals($expected, $actual);
} }

View File

@@ -72,6 +72,7 @@ class CardApiControllerTest extends \Test\TestCase {
->willReturn($card); ->willReturn($card);
$expected = new DataResponse($card, HTTP::STATUS_OK); $expected = new DataResponse($card, HTTP::STATUS_OK);
$expected->setETag($card->getETag());
$actual = $this->controller->get(); $actual = $this->controller->get();
$this->assertEquals($expected, $actual); $this->assertEquals($expected, $actual);
} }

View File

@@ -78,6 +78,7 @@ class StackApiControllerTest extends \Test\TestCase {
$expected = new DataResponse($stacks, HTTP::STATUS_OK); $expected = new DataResponse($stacks, HTTP::STATUS_OK);
$actual = $this->controller->index(); $actual = $this->controller->index();
$actual->setETag(null);
$this->assertEquals($expected, $actual); $this->assertEquals($expected, $actual);
} }
@@ -97,6 +98,7 @@ class StackApiControllerTest extends \Test\TestCase {
->willReturn($this->exampleStack['id']); ->willReturn($this->exampleStack['id']);
$expected = new DataResponse($stack, HTTP::STATUS_OK); $expected = new DataResponse($stack, HTTP::STATUS_OK);
$expected->setETag($stack->getETag());
$actual = $this->controller->get(); $actual = $this->controller->get();
$this->assertEquals($expected, $actual); $this->assertEquals($expected, $actual);
} }