webhooks.py
python
sha256:da49a05fd62cda46a7d73ec53a8d0adc5835d2070f6f0c51b12233a673c2e109
docs(mwp-1): mark Phase 5 complete, all acceptance criteria…
Sonnet 4.6
25 days ago
| 1 | """MuseHub webhook subscription route handlers. |
| 2 | |
| 3 | Endpoint summary: |
| 4 | POST /repos/{repo_id}/webhooks — register a webhook |
| 5 | GET /repos/{repo_id}/webhooks — list webhooks |
| 6 | DELETE /repos/{repo_id}/webhooks/{webhook_id} — remove a webhook |
| 7 | GET /repos/{repo_id}/webhooks/{webhook_id}/deliveries — delivery history |
| 8 | POST /repos/{repo_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver — retry a delivery |
| 9 | |
| 10 | All endpoints require a valid MSign token. |
| 11 | No business logic lives here — all persistence is delegated to |
| 12 | musehub.services.musehub_webhook_dispatcher. |
| 13 | """ |
| 14 | |
| 15 | import logging |
| 16 | |
| 17 | from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status |
| 18 | from sqlalchemy.ext.asyncio import AsyncSession |
| 19 | |
| 20 | from musehub.auth.dependencies import TokenClaims, require_valid_token |
| 21 | from musehub.api.routes.musehub.pagination import PaginationParams, build_cursor_link_header |
| 22 | from musehub.db import get_db |
| 23 | from musehub.models.musehub import ( |
| 24 | WEBHOOK_EVENT_TYPES, |
| 25 | WebhookCreate, |
| 26 | WebhookDeliveryListResponse, |
| 27 | WebhookListResponse, |
| 28 | WebhookRedeliverResponse, |
| 29 | WebhookResponse, |
| 30 | ) |
| 31 | from musehub.services import musehub_repository, musehub_webhook_dispatcher |
| 32 | |
| 33 | logger = logging.getLogger(__name__) |
| 34 | |
| 35 | router = APIRouter() |
| 36 | |
| 37 | |
| 38 | async def _guard_repo_owner(db: AsyncSession, repo_id: str, caller_handle: str) -> None: |
| 39 | """Raise 403 if *caller_handle* is not the repo owner or an accepted write/admin collaborator.""" |
| 40 | from musehub.services.musehub_repository import check_write_access |
| 41 | repo = await musehub_repository.get_repo(db, repo_id) |
| 42 | if repo is None: |
| 43 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found") |
| 44 | if not await check_write_access(db, repo_id, caller_handle, repo.owner): |
| 45 | raise HTTPException( |
| 46 | status_code=status.HTTP_403_FORBIDDEN, |
| 47 | detail="Only the repo owner or a write/admin collaborator may manage webhooks.", |
| 48 | ) |
| 49 | |
| 50 | |
| 51 | @router.post( |
| 52 | "/repos/{repo_id}/webhooks", |
| 53 | response_model=WebhookResponse, |
| 54 | status_code=status.HTTP_201_CREATED, |
| 55 | operation_id="createWebhook", |
| 56 | summary="Register a webhook subscription for a repo", |
| 57 | ) |
| 58 | async def create_webhook( |
| 59 | repo_id: str, |
| 60 | body: WebhookCreate, |
| 61 | db: AsyncSession = Depends(get_db), |
| 62 | token: TokenClaims = Depends(require_valid_token), |
| 63 | ) -> WebhookResponse: |
| 64 | """Register a new webhook that will receive HTTP POSTs for the requested event types. |
| 65 | |
| 66 | ``events`` must be a non-empty subset of: push, proposal, issue, |
| 67 | release, branch, tag, session, analysis. Unknown event types are rejected |
| 68 | with HTTP 422. Returns 403 if the caller is not the repo owner or a |
| 69 | write/admin collaborator. |
| 70 | """ |
| 71 | await _guard_repo_owner(db, repo_id, token.handle) |
| 72 | |
| 73 | unknown = [e for e in body.events if e not in WEBHOOK_EVENT_TYPES] |
| 74 | if unknown: |
| 75 | raise HTTPException( |
| 76 | status_code=422, |
| 77 | detail=f"Unknown event types: {unknown}. Valid types: {sorted(WEBHOOK_EVENT_TYPES)}", |
| 78 | ) |
| 79 | |
| 80 | webhook = await musehub_webhook_dispatcher.create_webhook( |
| 81 | db, |
| 82 | repo_id=repo_id, |
| 83 | url=body.url, |
| 84 | events=body.events, |
| 85 | secret=body.secret, |
| 86 | ) |
| 87 | await db.commit() |
| 88 | return webhook |
| 89 | |
| 90 | |
| 91 | @router.get( |
| 92 | "/repos/{repo_id}/webhooks", |
| 93 | response_model=WebhookListResponse, |
| 94 | operation_id="listWebhooks", |
| 95 | summary="List webhook subscriptions for a repo", |
| 96 | ) |
| 97 | async def list_webhooks( |
| 98 | repo_id: str, |
| 99 | request: Request, |
| 100 | response: Response, |
| 101 | pagination: PaginationParams = Depends(PaginationParams), |
| 102 | db: AsyncSession = Depends(get_db), |
| 103 | token: TokenClaims = Depends(require_valid_token), |
| 104 | ) -> WebhookListResponse: |
| 105 | """Return registered webhooks for the given repo with cursor pagination. |
| 106 | |
| 107 | Restricted to the repo owner and accepted write/admin collaborators. |
| 108 | Webhook payloads may contain sensitive URLs — listing them is a privileged operation. |
| 109 | |
| 110 | Pass ``nextCursor`` from a previous response as ``?cursor=`` to advance. |
| 111 | A null ``nextCursor`` means this is the last page. |
| 112 | """ |
| 113 | await _guard_repo_owner(db, repo_id, token.handle) |
| 114 | result = await musehub_webhook_dispatcher.list_webhooks( |
| 115 | db, repo_id, cursor=pagination.cursor, limit=pagination.limit |
| 116 | ) |
| 117 | if result.next_cursor is not None: |
| 118 | response.headers["Link"] = build_cursor_link_header( |
| 119 | request, result.next_cursor, pagination.limit |
| 120 | ) |
| 121 | return result |
| 122 | |
| 123 | |
| 124 | @router.delete( |
| 125 | "/repos/{repo_id}/webhooks/{webhook_id}", |
| 126 | status_code=status.HTTP_204_NO_CONTENT, |
| 127 | operation_id="deleteWebhook", |
| 128 | summary="Delete a webhook subscription", |
| 129 | ) |
| 130 | async def delete_webhook( |
| 131 | repo_id: str, |
| 132 | webhook_id: str, |
| 133 | db: AsyncSession = Depends(get_db), |
| 134 | token: TokenClaims = Depends(require_valid_token), |
| 135 | ) -> None: |
| 136 | """Remove a webhook subscription. All delivery history is also deleted (cascade). |
| 137 | |
| 138 | Returns 403 if the caller is not the repo owner or a write/admin collaborator. |
| 139 | """ |
| 140 | await _guard_repo_owner(db, repo_id, token.handle) |
| 141 | |
| 142 | deleted = await musehub_webhook_dispatcher.delete_webhook(db, repo_id, webhook_id) |
| 143 | if not deleted: |
| 144 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found") |
| 145 | await db.commit() |
| 146 | |
| 147 | |
| 148 | @router.get( |
| 149 | "/repos/{repo_id}/webhooks/{webhook_id}/deliveries", |
| 150 | response_model=WebhookDeliveryListResponse, |
| 151 | operation_id="listWebhookDeliveries", |
| 152 | summary="List delivery history for a webhook", |
| 153 | ) |
| 154 | async def list_deliveries( |
| 155 | repo_id: str, |
| 156 | webhook_id: str, |
| 157 | request: Request, |
| 158 | response: Response, |
| 159 | pagination: PaginationParams = Depends(PaginationParams), |
| 160 | db: AsyncSession = Depends(get_db), |
| 161 | _: TokenClaims = Depends(require_valid_token), |
| 162 | ) -> WebhookDeliveryListResponse: |
| 163 | """Return delivery attempts for a webhook with cursor pagination (newest first). |
| 164 | |
| 165 | Each attempt (including retries) is a separate record. |
| 166 | |
| 167 | Pass ``nextCursor`` from a previous response as ``?cursor=`` to advance. |
| 168 | A null ``nextCursor`` means this is the last page. |
| 169 | """ |
| 170 | repo = await musehub_repository.get_repo(db, repo_id) |
| 171 | if repo is None: |
| 172 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found") |
| 173 | |
| 174 | webhook = await musehub_webhook_dispatcher.get_webhook(db, repo_id, webhook_id) |
| 175 | if webhook is None: |
| 176 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found") |
| 177 | |
| 178 | result = await musehub_webhook_dispatcher.list_deliveries( |
| 179 | db, webhook_id, cursor=pagination.cursor, limit=pagination.limit |
| 180 | ) |
| 181 | if result.next_cursor is not None: |
| 182 | response.headers["Link"] = build_cursor_link_header( |
| 183 | request, result.next_cursor, pagination.limit |
| 184 | ) |
| 185 | return result |
| 186 | |
| 187 | |
| 188 | @router.post( |
| 189 | "/repos/{repo_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver", |
| 190 | response_model=WebhookRedeliverResponse, |
| 191 | operation_id="redeliverWebhookDelivery", |
| 192 | summary="Retry a failed webhook delivery", |
| 193 | ) |
| 194 | async def redeliver_delivery( |
| 195 | repo_id: str, |
| 196 | webhook_id: str, |
| 197 | delivery_id: str, |
| 198 | db: AsyncSession = Depends(get_db), |
| 199 | token: TokenClaims = Depends(require_valid_token), |
| 200 | ) -> WebhookRedeliverResponse: |
| 201 | """Re-send the original payload from a past delivery attempt. |
| 202 | |
| 203 | Only the repo owner or an accepted write/admin collaborator may trigger |
| 204 | redelivery — this prevents arbitrary authenticated users from hammering |
| 205 | third-party endpoints by knowing a webhook ID. |
| 206 | |
| 207 | Looks up the delivery by ``delivery_id`` and POSTs its stored payload to |
| 208 | the webhook's current URL. Each retry attempt is recorded as a new |
| 209 | delivery row — the original row is never mutated. |
| 210 | |
| 211 | Returns 404 when the repo, webhook, or delivery cannot be found. |
| 212 | Returns 422 when the delivery predates payload storage and cannot be replayed. |
| 213 | """ |
| 214 | await _guard_repo_owner(db, repo_id, token.handle) |
| 215 | webhook = await musehub_webhook_dispatcher.get_webhook(db, repo_id, webhook_id) |
| 216 | if webhook is None: |
| 217 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found") |
| 218 | |
| 219 | delivery = await musehub_webhook_dispatcher.get_delivery(db, webhook_id, delivery_id) |
| 220 | if delivery is None: |
| 221 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Delivery not found") |
| 222 | |
| 223 | try: |
| 224 | result = await musehub_webhook_dispatcher.redeliver_delivery( |
| 225 | db, repo_id=repo_id, webhook_id=webhook_id, delivery_id=delivery_id |
| 226 | ) |
| 227 | except ValueError as exc: |
| 228 | raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) |
| 229 | |
| 230 | await db.commit() |
| 231 | return result |
File History
1 commit
sha256:da49a05fd62cda46a7d73ec53a8d0adc5835d2070f6f0c51b12233a673c2e109
docs(mwp-1): mark Phase 5 complete, all acceptance criteria…
Sonnet 4.6
25 days ago