[Notifications] REST API endpoints
>>> [!note] Migrated issue <!-- Drupal.org comment --> <!-- Migrated from issue #3580210. --> Reported by: [justafish](https://www.drupal.org/user/161058) Related to !829 >>> <p>Create the REST API controller and routes that expose the notification system to the Canvas UI. Two endpoints are needed: one to fetch recent notifications (with per-user read state), and one to mark notifications as read.</p> <h2>Dependencies</h2> <p>Ticket 1 (notification storage and service) must be complete.</p> <h2>Acceptance criteria</h2> <ul> <li><code>GET /canvas/api/v0/notifications</code> returns recent notifications with <code>hasRead</code> per authenticated user.</li> <li><code>POST /canvas/api/v0/notifications/read</code> accepts <code>{ "ids": ["uuid", ...] }</code> and marks those notifications as read for the current user.</li> <li>POST returns <code>204 No Content</code> on success.</li> <li>POST returns <code>400 Bad Request</code> if the body is empty or <code>ids</code> is missing.</li> <li>Both routes require <code>_canvas_authentication_required: TRUE</code> and <code>_canvas_ui_access: 'TRUE'</code>.</li> <li>Routes are added to <code>canvas.routing.yml</code>.</li> <li>Controller is registered in <code>canvas.services.yml</code>.</li> <li>OpenAPI spec (<code>openapi.yml</code>) is updated with both endpoint definitions.</li> <li>Integration tests validate response shapes, status codes, and access control.</li> </ul> <h2>Implementation plan</h2> <h3>Routes</h3> <p>Add to <code>canvas.routing.yml</code>:</p> <pre><pre>canvas.api.notifications.get:<br>&nbsp; path: '/canvas/api/v0/notifications'<br>&nbsp; defaults:<br>&nbsp;&nbsp;&nbsp; _controller: 'Drupal\canvas\Controller\ApiNotificationController::list'<br>&nbsp; requirements:<br>&nbsp;&nbsp;&nbsp; _canvas_authentication_required: TRUE<br>&nbsp;&nbsp;&nbsp; _canvas_ui_access: 'TRUE'<br>&nbsp; methods: ['GET']<br>&nbsp; options:<br>&nbsp;&nbsp;&nbsp; _format: 'json'<br><br>canvas.api.notifications.read:<br>&nbsp; path: '/canvas/api/v0/notifications/read'<br>&nbsp; defaults:<br>&nbsp;&nbsp;&nbsp; _controller: 'Drupal\canvas\Controller\ApiNotificationController::markRead'<br>&nbsp; requirements:<br>&nbsp;&nbsp;&nbsp; _canvas_authentication_required: TRUE<br>&nbsp;&nbsp;&nbsp; _canvas_ui_access: 'TRUE'<br>&nbsp; methods: ['POST']<br>&nbsp; options:<br>&nbsp;&nbsp;&nbsp; _format: 'json'</pre></pre><h3>Controller</h3> <p>Create <code>src/Controller/ApiNotificationController.php</code>:</p> <pre><pre>&amp;lt;?php<br><br>declare(strict_types=1);<br><br>namespace Drupal\canvas\Controller;<br><br>use Drupal\canvas\Notification\NotificationService;<br>use Drupal\Core\Session\AccountProxyInterface;<br>use Symfony\Component\HttpFoundation\JsonResponse;<br>use Symfony\Component\HttpFoundation\Request;<br>use Symfony\Component\HttpFoundation\Response;<br><br>final class ApiNotificationController extends ApiControllerBase {<br><br>&nbsp; public function __construct(<br>&nbsp;&nbsp;&nbsp; private readonly NotificationService $notificationService,<br>&nbsp;&nbsp;&nbsp; private readonly AccountProxyInterface $currentUser,<br>&nbsp; ) {}<br><br>&nbsp; public function list(): JsonResponse {<br>&nbsp;&nbsp;&nbsp; $uid = (int) $this-&gt;currentUser-&gt;id();<br>&nbsp;&nbsp;&nbsp; $notifications = $this-&gt;notificationService-&gt;getRecent($uid);<br><br>&nbsp;&nbsp;&nbsp; return new JsonResponse([<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'data' =&gt; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'notifications' =&gt; $notifications,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ],<br>&nbsp;&nbsp;&nbsp; ]);<br>&nbsp; }<br><br>&nbsp; public function markRead(Request $request): Response {<br>&nbsp;&nbsp;&nbsp; $data = static::decode($request);<br>&nbsp;&nbsp;&nbsp; if (!isset($data['ids']) || !is_array($data['ids'])) {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'Missing or invalid "ids" field.'<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; );<br>&nbsp;&nbsp;&nbsp; }<br>&nbsp;&nbsp;&nbsp; $uid = (int) $this-&gt;currentUser-&gt;id();<br>&nbsp;&nbsp;&nbsp; $this-&gt;notificationService-&gt;markRead($uid, $data['ids']);<br>&nbsp;&nbsp;&nbsp; return new Response('', 204);<br>&nbsp; }<br>}</pre></pre><h3>Service registration</h3> <p>Add to <code>canvas.services.yml</code>:</p> <pre>Drupal\canvas\Controller\ApiNotificationController: {}</pre><h3>OpenAPI spec</h3> <p>Add to <code>openapi.yml</code> under <code>paths</code>:</p> <pre><pre>/canvas/api/v0/notifications:<br>&nbsp; get:<br>&nbsp;&nbsp;&nbsp; summary: Fetch recent notifications for the authenticated user.<br>&nbsp;&nbsp;&nbsp; responses:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; '200':<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; description: List of recent notifications.<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; content:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; application/json:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; schema:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: object<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; required: [data]<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; properties:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; data:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: object<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; required: [notifications]<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; properties:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; notifications:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: array<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; items:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $ref: '#/components/schemas/Notification'<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; '403':<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $ref: '#/components/responses/ClientErrorResponse'<br><br>/canvas/api/v0/notifications/read:<br>&nbsp; post:<br>&nbsp;&nbsp;&nbsp; summary: Mark notifications as read for the authenticated user.<br>&nbsp;&nbsp;&nbsp; requestBody:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; required: true<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; content:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; application/json:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; schema:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: object<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; required: [ids]<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; properties:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ids:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: array<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; items:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: string<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; format: uuid<br>&nbsp;&nbsp;&nbsp; responses:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; '204':<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; description: Notifications marked as read.<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; '400':<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $ref: '#/components/responses/ClientErrorResponse'<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; '403':<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $ref: '#/components/responses/ClientErrorResponse'</pre></pre><p>Add to <code>components/schemas</code>:</p> <pre><pre>Notification:<br>&nbsp; type: object<br>&nbsp; required: [id, type, title, message, timestamp, hasRead]<br>&nbsp; properties:<br>&nbsp;&nbsp;&nbsp; id:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: string<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; format: uuid<br>&nbsp;&nbsp;&nbsp; type:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: string<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; enum: [processing, success, info, warning, error]<br>&nbsp;&nbsp;&nbsp; key:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: string<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; nullable: true<br>&nbsp;&nbsp;&nbsp; title:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: string<br>&nbsp;&nbsp;&nbsp; message:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: string<br>&nbsp;&nbsp;&nbsp; timestamp:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: integer<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; description: Unix timestamp in milliseconds.<br>&nbsp;&nbsp;&nbsp; hasRead:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: boolean<br>&nbsp;&nbsp;&nbsp; actions:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: array<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; nullable: true<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; items:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: object<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; required: [label, href]<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; properties:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; label:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: string<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; href:<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; type: string</pre></pre><h3>GET response shape</h3> <pre><pre>{<br>&nbsp; "data": {<br>&nbsp;&nbsp;&nbsp; "notifications": [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "id": "550e8400-e29b-41d4-a716-446655440000",<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "type": "processing",<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "key": "github_sync",<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "title": "Sync in progress",<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "message": "Syncing with GitHub repository...",<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "timestamp": 1234567890000,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "hasRead": false,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "actions": null<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; },<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; {<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "id": "550e8400-e29b-41d4-a716-446655440001",<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "type": "error",<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "key": "github_sync",<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "title": "Publish failed",<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "message": "Unable to connect to server",<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "timestamp": 1234567880000,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "hasRead": true,<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; "actions": [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; { "label": "View logs", "href": "/logs" },<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; { "label": "Revert", "href": "/github-revert" }<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ]<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; }<br>&nbsp;&nbsp;&nbsp; ]<br>&nbsp; }<br>}</pre></pre><h3>POST /notifications/read request shape</h3> <pre><pre>{<br>&nbsp; "ids": [<br>&nbsp;&nbsp;&nbsp; "550e8400-e29b-41d4-a716-446655440001",<br>&nbsp;&nbsp;&nbsp; "550e8400-e29b-41d4-a716-446655440002"<br>&nbsp; ]<br>}</pre></pre><h2>Tests required</h2> <p>Kernel test: <code>tests/src/Kernel/Controller/ApiNotificationControllerTest.php</code></p> <p>Follow existing Canvas kernel test patterns. Use <code>RequestTrait</code> for simulating HTTP requests.</p> <table> <thead> <tr> <th>Test method</th> <th>Validates</th> </tr> </thead> <tbody> <tr> <td><code>testGetNotificationsReturnsJsonResponse</code></td> <td>200 with correct response shape</td> </tr> <tr> <td><code>testGetNotificationsIncludesHasReadPerUser</code></td> <td><code>hasRead</code> differs per user</td> </tr> <tr> <td><code>testGetNotificationsRequiresAuthentication</code></td> <td>Anonymous user gets 401</td> </tr> <tr> <td><code>testMarkReadReturns204</code></td> <td>Successful mark-read returns 204</td> </tr> <tr> <td><code>testMarkReadUpdatesHasRead</code></td> <td>Subsequent GET shows <code>hasRead: true</code></td> </tr> <tr> <td><code>testMarkReadRejectsMissingIds</code></td> <td>Missing <code>ids</code> field returns 400</td> </tr> <tr> <td><code>testMarkReadRejectsEmptyBody</code></td> <td>Empty body returns 400</td> </tr> </tbody> </table> <p>Use the existing <code>OpenApiSpecTrait</code> to validate that responses match the spec.</p> <h2>Manual testing steps</h2> <ol> <li>Ensure <code>canvas_dev_mode</code> is enabled and the <code>canvas_notifications</code> / <code>canvas_notification_reads</code> tables exist:<br> <pre>ddev drush sqlq "SHOW TABLES LIKE 'canvas_notif%';"</pre></li> <li>Create test notifications via Drush (see ticket 1 manual steps).</li> <li>Open the Canvas UI and use the browser network tab to call the GET endpoint directly. Verify the response includes a <code>notifications</code> array.</li> <li> <p>Copy a notification ID from the response and mark it as read:</p> <pre><pre>POST /canvas/api/v0/notifications/read<br>Content-Type: application/json<br><br>{ "ids": ["&amp;lt;notification-id&amp;gt;"] }</pre></pre><p>Verify the response is <code>204 No Content</code>.</p> </li> <li>Call GET again and verify <code>hasRead</code> is now <code>true</code> for that notification.</li> <li>Open a second browser / incognito window with a different user. Verify that the same notification shows <code>hasRead: false</code> for the other user.</li> <li>Test access control: log out and call GET &mdash; expect 401.</li> </ol>
issue