gabriel / musehub public
webhooks.py python
231 lines 8.5 KB
Raw
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