[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> path: '/canvas/api/v0/notifications'<br> defaults:<br> _controller: 'Drupal\canvas\Controller\ApiNotificationController::list'<br> requirements:<br> _canvas_authentication_required: TRUE<br> _canvas_ui_access: 'TRUE'<br> methods: ['GET']<br> options:<br> _format: 'json'<br><br>canvas.api.notifications.read:<br> path: '/canvas/api/v0/notifications/read'<br> defaults:<br> _controller: 'Drupal\canvas\Controller\ApiNotificationController::markRead'<br> requirements:<br> _canvas_authentication_required: TRUE<br> _canvas_ui_access: 'TRUE'<br> methods: ['POST']<br> options:<br> _format: 'json'</pre></pre><h3>Controller</h3>
<p>Create <code>src/Controller/ApiNotificationController.php</code>:</p>
<pre><pre>&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> public function __construct(<br> private readonly NotificationService $notificationService,<br> private readonly AccountProxyInterface $currentUser,<br> ) {}<br><br> public function list(): JsonResponse {<br> $uid = (int) $this->currentUser->id();<br> $notifications = $this->notificationService->getRecent($uid);<br><br> return new JsonResponse([<br> 'data' => [<br> 'notifications' => $notifications,<br> ],<br> ]);<br> }<br><br> public function markRead(Request $request): Response {<br> $data = static::decode($request);<br> if (!isset($data['ids']) || !is_array($data['ids'])) {<br> throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(<br> 'Missing or invalid "ids" field.'<br> );<br> }<br> $uid = (int) $this->currentUser->id();<br> $this->notificationService->markRead($uid, $data['ids']);<br> return new Response('', 204);<br> }<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> get:<br> summary: Fetch recent notifications for the authenticated user.<br> responses:<br> '200':<br> description: List of recent notifications.<br> content:<br> application/json:<br> schema:<br> type: object<br> required: [data]<br> properties:<br> data:<br> type: object<br> required: [notifications]<br> properties:<br> notifications:<br> type: array<br> items:<br> $ref: '#/components/schemas/Notification'<br> '403':<br> $ref: '#/components/responses/ClientErrorResponse'<br><br>/canvas/api/v0/notifications/read:<br> post:<br> summary: Mark notifications as read for the authenticated user.<br> requestBody:<br> required: true<br> content:<br> application/json:<br> schema:<br> type: object<br> required: [ids]<br> properties:<br> ids:<br> type: array<br> items:<br> type: string<br> format: uuid<br> responses:<br> '204':<br> description: Notifications marked as read.<br> '400':<br> $ref: '#/components/responses/ClientErrorResponse'<br> '403':<br> $ref: '#/components/responses/ClientErrorResponse'</pre></pre><p>Add to <code>components/schemas</code>:</p>
<pre><pre>Notification:<br> type: object<br> required: [id, type, title, message, timestamp, hasRead]<br> properties:<br> id:<br> type: string<br> format: uuid<br> type:<br> type: string<br> enum: [processing, success, info, warning, error]<br> key:<br> type: string<br> nullable: true<br> title:<br> type: string<br> message:<br> type: string<br> timestamp:<br> type: integer<br> description: Unix timestamp in milliseconds.<br> hasRead:<br> type: boolean<br> actions:<br> type: array<br> nullable: true<br> items:<br> type: object<br> required: [label, href]<br> properties:<br> label:<br> type: string<br> href:<br> type: string</pre></pre><h3>GET response shape</h3>
<pre><pre>{<br> "data": {<br> "notifications": [<br> {<br> "id": "550e8400-e29b-41d4-a716-446655440000",<br> "type": "processing",<br> "key": "github_sync",<br> "title": "Sync in progress",<br> "message": "Syncing with GitHub repository...",<br> "timestamp": 1234567890000,<br> "hasRead": false,<br> "actions": null<br> },<br> {<br> "id": "550e8400-e29b-41d4-a716-446655440001",<br> "type": "error",<br> "key": "github_sync",<br> "title": "Publish failed",<br> "message": "Unable to connect to server",<br> "timestamp": 1234567880000,<br> "hasRead": true,<br> "actions": [<br> { "label": "View logs", "href": "/logs" },<br> { "label": "Revert", "href": "/github-revert" }<br> ]<br> }<br> ]<br> }<br>}</pre></pre><h3>POST /notifications/read request shape</h3>
<pre><pre>{<br> "ids": [<br> "550e8400-e29b-41d4-a716-446655440001",<br> "550e8400-e29b-41d4-a716-446655440002"<br> ]<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": ["&lt;notification-id&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 — expect 401.</li>
</ol>
issue