- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
475 lines
13 KiB
Go
475 lines
13 KiB
Go
// Package handlers provides HTTP handlers for the rdev API.
|
|
package handlers
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/validate"
|
|
"github.com/orchard9/rdev/pkg/api"
|
|
)
|
|
|
|
// WebhookHandler handles webhook management endpoints.
|
|
type WebhookHandler struct {
|
|
webhooks port.WebhookRepository
|
|
projects port.ProjectRepository
|
|
}
|
|
|
|
// NewWebhookHandler creates a new webhook handler.
|
|
func NewWebhookHandler(webhooks port.WebhookRepository, projects port.ProjectRepository) *WebhookHandler {
|
|
return &WebhookHandler{
|
|
webhooks: webhooks,
|
|
projects: projects,
|
|
}
|
|
}
|
|
|
|
// Mount registers the webhook routes.
|
|
func (h *WebhookHandler) Mount(r api.Router) {
|
|
r.Route("/projects/{id}/webhooks", func(r chi.Router) {
|
|
r.Post("/", h.Create)
|
|
r.Get("/", h.List)
|
|
r.Get("/{webhookId}", h.Get)
|
|
r.Put("/{webhookId}", h.Update)
|
|
r.Delete("/{webhookId}", h.Delete)
|
|
r.Get("/{webhookId}/deliveries", h.GetDeliveries)
|
|
})
|
|
}
|
|
|
|
// CreateWebhookRequest is the request body for POST /projects/{id}/webhooks.
|
|
type CreateWebhookRequest struct {
|
|
URL string `json:"url"`
|
|
Events []string `json:"events"`
|
|
Secret string `json:"secret,omitempty"` // If empty, one will be generated
|
|
}
|
|
|
|
// CreateWebhookResponse is the response for POST /projects/{id}/webhooks.
|
|
type CreateWebhookResponse struct {
|
|
Webhook *WebhookDTO `json:"webhook"`
|
|
Secret string `json:"secret"` // Only returned on creation
|
|
}
|
|
|
|
// WebhookDTO is the data transfer object for webhooks.
|
|
type WebhookDTO struct {
|
|
ID string `json:"id"`
|
|
ProjectID string `json:"project_id"`
|
|
URL string `json:"url"`
|
|
Events []string `json:"events"`
|
|
Enabled bool `json:"enabled"`
|
|
HasSecret bool `json:"has_secret"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// toDTO converts a domain.Webhook to a WebhookDTO.
|
|
func toDTO(w *domain.Webhook) *WebhookDTO {
|
|
events := make([]string, len(w.Events))
|
|
for i, e := range w.Events {
|
|
events[i] = string(e)
|
|
}
|
|
return &WebhookDTO{
|
|
ID: string(w.ID),
|
|
ProjectID: w.ProjectID,
|
|
URL: w.URL,
|
|
Events: events,
|
|
Enabled: w.Enabled,
|
|
HasSecret: w.HasSecret(),
|
|
CreatedAt: w.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
UpdatedAt: w.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
}
|
|
}
|
|
|
|
// Create creates a new webhook.
|
|
// POST /projects/{id}/webhooks
|
|
func (h *WebhookHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
|
|
// Check project exists
|
|
exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID))
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to check project")
|
|
return
|
|
}
|
|
if !exists {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID))
|
|
return
|
|
}
|
|
|
|
// Parse request
|
|
var req CreateWebhookRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
api.WriteBadRequest(w, r, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// Validate URL
|
|
if req.URL == "" {
|
|
api.WriteBadRequest(w, r, "url is required")
|
|
return
|
|
}
|
|
if err := validate.HTTPURL(req.URL, "url"); err != nil {
|
|
api.WriteBadRequest(w, r, "url must be a valid HTTP or HTTPS URL")
|
|
return
|
|
}
|
|
|
|
// Validate events
|
|
if len(req.Events) == 0 {
|
|
api.WriteBadRequest(w, r, "at least one event type is required")
|
|
return
|
|
}
|
|
events := make([]domain.WebhookEventType, len(req.Events))
|
|
for i, e := range req.Events {
|
|
eventType := domain.WebhookEventType(e)
|
|
if !eventType.IsValid() {
|
|
api.WriteBadRequest(w, r, fmt.Sprintf("invalid event type: %s", e))
|
|
return
|
|
}
|
|
events[i] = eventType
|
|
}
|
|
|
|
// Generate secret if not provided
|
|
secret := req.Secret
|
|
if secret == "" {
|
|
secretBytes := make([]byte, 32)
|
|
if _, err := rand.Read(secretBytes); err != nil {
|
|
api.WriteInternalError(w, r, "failed to generate secret")
|
|
return
|
|
}
|
|
secret = hex.EncodeToString(secretBytes)
|
|
}
|
|
|
|
// Create webhook
|
|
webhook := &domain.Webhook{
|
|
ID: domain.WebhookID(uuid.New().String()),
|
|
ProjectID: projectID,
|
|
URL: req.URL,
|
|
Secret: secret,
|
|
Events: events,
|
|
Enabled: true,
|
|
}
|
|
|
|
if err := h.webhooks.Create(r.Context(), webhook); err != nil {
|
|
api.WriteInternalError(w, r, "failed to create webhook")
|
|
return
|
|
}
|
|
|
|
api.WriteCreated(w, r, CreateWebhookResponse{
|
|
Webhook: toDTO(webhook),
|
|
Secret: secret, // Only returned on creation
|
|
})
|
|
}
|
|
|
|
// List returns all webhooks for a project.
|
|
// GET /projects/{id}/webhooks
|
|
func (h *WebhookHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
|
|
// Check project exists
|
|
exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID))
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to check project")
|
|
return
|
|
}
|
|
if !exists {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID))
|
|
return
|
|
}
|
|
|
|
webhooks, err := h.webhooks.ListByProject(r.Context(), projectID)
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to list webhooks")
|
|
return
|
|
}
|
|
|
|
dtos := make([]*WebhookDTO, len(webhooks))
|
|
for i, wh := range webhooks {
|
|
dtos[i] = toDTO(wh)
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"webhooks": dtos,
|
|
"total": len(dtos),
|
|
})
|
|
}
|
|
|
|
// Get returns a specific webhook.
|
|
// GET /projects/{id}/webhooks/{webhookId}
|
|
func (h *WebhookHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
webhookID := chi.URLParam(r, "webhookId")
|
|
|
|
// Check project exists
|
|
exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID))
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to check project")
|
|
return
|
|
}
|
|
if !exists {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID))
|
|
return
|
|
}
|
|
|
|
webhook, err := h.webhooks.GetByID(r.Context(), domain.WebhookID(webhookID))
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrWebhookNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID))
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to get webhook")
|
|
return
|
|
}
|
|
|
|
// Verify webhook belongs to project
|
|
if webhook.ProjectID != projectID {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID))
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, toDTO(webhook))
|
|
}
|
|
|
|
// UpdateWebhookRequest is the request body for PUT /projects/{id}/webhooks/{webhookId}.
|
|
type UpdateWebhookRequest struct {
|
|
URL string `json:"url,omitempty"`
|
|
Events []string `json:"events,omitempty"`
|
|
Secret string `json:"secret,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
}
|
|
|
|
// Update updates a webhook.
|
|
// PUT /projects/{id}/webhooks/{webhookId}
|
|
func (h *WebhookHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
webhookID := chi.URLParam(r, "webhookId")
|
|
|
|
// Check project exists
|
|
exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID))
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to check project")
|
|
return
|
|
}
|
|
if !exists {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID))
|
|
return
|
|
}
|
|
|
|
// Get existing webhook
|
|
webhook, err := h.webhooks.GetByID(r.Context(), domain.WebhookID(webhookID))
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrWebhookNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID))
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to get webhook")
|
|
return
|
|
}
|
|
|
|
// Verify webhook belongs to project
|
|
if webhook.ProjectID != projectID {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID))
|
|
return
|
|
}
|
|
|
|
// Parse request
|
|
var req UpdateWebhookRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
api.WriteBadRequest(w, r, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// Update fields
|
|
if req.URL != "" {
|
|
if err := validate.HTTPURL(req.URL, "url"); err != nil {
|
|
api.WriteBadRequest(w, r, "url must be a valid HTTP or HTTPS URL")
|
|
return
|
|
}
|
|
webhook.URL = req.URL
|
|
}
|
|
|
|
if len(req.Events) > 0 {
|
|
events := make([]domain.WebhookEventType, len(req.Events))
|
|
for i, e := range req.Events {
|
|
eventType := domain.WebhookEventType(e)
|
|
if !eventType.IsValid() {
|
|
api.WriteBadRequest(w, r, fmt.Sprintf("invalid event type: %s", e))
|
|
return
|
|
}
|
|
events[i] = eventType
|
|
}
|
|
webhook.Events = events
|
|
}
|
|
|
|
if req.Secret != "" {
|
|
webhook.Secret = req.Secret
|
|
}
|
|
|
|
if req.Enabled != nil {
|
|
webhook.Enabled = *req.Enabled
|
|
}
|
|
|
|
if err := h.webhooks.Update(r.Context(), webhook); err != nil {
|
|
api.WriteInternalError(w, r, "failed to update webhook")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, toDTO(webhook))
|
|
}
|
|
|
|
// Delete deletes a webhook.
|
|
// DELETE /projects/{id}/webhooks/{webhookId}
|
|
func (h *WebhookHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
webhookID := chi.URLParam(r, "webhookId")
|
|
|
|
// Check project exists
|
|
exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID))
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to check project")
|
|
return
|
|
}
|
|
if !exists {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID))
|
|
return
|
|
}
|
|
|
|
// Verify webhook belongs to project
|
|
webhook, err := h.webhooks.GetByID(r.Context(), domain.WebhookID(webhookID))
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrWebhookNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID))
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to get webhook")
|
|
return
|
|
}
|
|
|
|
if webhook.ProjectID != projectID {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID))
|
|
return
|
|
}
|
|
|
|
if err := h.webhooks.Delete(r.Context(), domain.WebhookID(webhookID)); err != nil {
|
|
if errors.Is(err, domain.ErrWebhookNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID))
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to delete webhook")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"id": webhookID,
|
|
"deleted": true,
|
|
})
|
|
}
|
|
|
|
// DeliveryDTO is the data transfer object for webhook deliveries.
|
|
type DeliveryDTO struct {
|
|
ID string `json:"id"`
|
|
WebhookID string `json:"webhook_id"`
|
|
EventType string `json:"event_type"`
|
|
Payload string `json:"payload"`
|
|
ResponseStatus int `json:"response_status,omitempty"`
|
|
ResponseBody string `json:"response_body,omitempty"`
|
|
DeliveredAt string `json:"delivered_at"`
|
|
Success bool `json:"success"`
|
|
RetryCount int `json:"retry_count"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
}
|
|
|
|
// GetDeliveries returns delivery history for a webhook.
|
|
// GET /projects/{id}/webhooks/{webhookId}/deliveries
|
|
func (h *WebhookHandler) GetDeliveries(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
webhookID := chi.URLParam(r, "webhookId")
|
|
|
|
// Check project exists
|
|
exists, err := h.projects.Exists(r.Context(), domain.ProjectID(projectID))
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to check project")
|
|
return
|
|
}
|
|
if !exists {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", projectID))
|
|
return
|
|
}
|
|
|
|
// Verify webhook belongs to project
|
|
webhook, err := h.webhooks.GetByID(r.Context(), domain.WebhookID(webhookID))
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrWebhookNotFound) {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID))
|
|
return
|
|
}
|
|
api.WriteInternalError(w, r, "failed to get webhook")
|
|
return
|
|
}
|
|
|
|
if webhook.ProjectID != projectID {
|
|
api.WriteNotFound(w, r, fmt.Sprintf("webhook not found: %s", webhookID))
|
|
return
|
|
}
|
|
|
|
// Parse query params
|
|
filters := domain.DefaultWebhookDeliveryFilters()
|
|
|
|
if eventType := r.URL.Query().Get("event_type"); eventType != "" {
|
|
et := domain.WebhookEventType(eventType)
|
|
filters.EventType = &et
|
|
}
|
|
|
|
if successStr := r.URL.Query().Get("success"); successStr != "" {
|
|
success := successStr == "true"
|
|
filters.Success = &success
|
|
}
|
|
|
|
if limit := r.URL.Query().Get("limit"); limit != "" {
|
|
if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= 1000 {
|
|
filters.Limit = l
|
|
}
|
|
}
|
|
|
|
if offset := r.URL.Query().Get("offset"); offset != "" {
|
|
if o, err := strconv.Atoi(offset); err == nil && o >= 0 {
|
|
filters.Offset = o
|
|
}
|
|
}
|
|
|
|
deliveries, err := h.webhooks.GetDeliveries(r.Context(), domain.WebhookID(webhookID), filters)
|
|
if err != nil {
|
|
api.WriteInternalError(w, r, "failed to get deliveries")
|
|
return
|
|
}
|
|
|
|
dtos := make([]*DeliveryDTO, len(deliveries))
|
|
for i, d := range deliveries {
|
|
dtos[i] = &DeliveryDTO{
|
|
ID: string(d.ID),
|
|
WebhookID: string(d.WebhookID),
|
|
EventType: string(d.EventType),
|
|
Payload: d.Payload,
|
|
ResponseStatus: d.ResponseStatus,
|
|
ResponseBody: d.ResponseBody,
|
|
DeliveredAt: d.DeliveredAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
Success: d.Success,
|
|
RetryCount: d.RetryCount,
|
|
ErrorMessage: d.ErrorMessage,
|
|
}
|
|
}
|
|
|
|
api.WriteSuccess(w, r, map[string]any{
|
|
"deliveries": dtos,
|
|
"total": len(dtos),
|
|
"limit": filters.Limit,
|
|
"offset": filters.Offset,
|
|
})
|
|
}
|