Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 42 additions & 52 deletions api/private.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ import (

// ListManager is the private API's view of the list service.
type ListManager interface {
All(ctx context.Context) ([]domain.MailingList, error)
Create(ctx context.Context, name string) (*domain.MailingList, error)
Get(ctx context.Context, id uint) (*domain.MailingList, error)
Rename(ctx context.Context, id uint, newName string) (*domain.MailingList, error)
Delete(ctx context.Context, id uint) error
CountUsers(ctx context.Context, listID uint) (domain.UserCounts, error)
Users(ctx context.Context, listID uint) ([]domain.User, error)
Get(ctx context.Context, name string) (*domain.MailingList, error)
Rename(ctx context.Context, name, newName string) (*domain.MailingList, error)
Delete(ctx context.Context, name string) error
CountUsers(ctx context.Context, listName string) (domain.UserCounts, error)
Users(ctx context.Context, listName string) ([]domain.User, error)
}

// MailDispatcher is the private API's view of the mail service.
Expand All @@ -51,11 +52,12 @@ func NewPrivateHandler(lists ListManager, mail MailDispatcher, publicKey ed25519
// Routes returns the mux for all private API endpoints.
func (h *PrivateHandler) Routes() *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("GET /lists", h.auth(h.handleAllLists))
mux.Handle("POST /lists", h.auth(h.handleCreateList))
mux.Handle("GET /lists/{id}", h.auth(h.handleGetList))
mux.Handle("PUT /lists/{id}", h.auth(h.handleRenameList))
mux.Handle("DELETE /lists/{id}", h.auth(h.handleDeleteList))
mux.Handle("GET /lists/{id}/users", h.auth(h.handleListUsers))
mux.Handle("GET /lists/{name}", h.auth(h.handleGetList))
mux.Handle("PUT /lists/{name}", h.auth(h.handleRenameList))
mux.Handle("DELETE /lists/{name}", h.auth(h.handleDeleteList))
mux.Handle("GET /lists/{name}/users", h.auth(h.handleListUsers))
mux.Handle("POST /lists/{name}/send", h.auth(h.handleSendToList))
mux.Handle("POST /mail/test", h.auth(h.handleSendTestMail))
return mux
Expand All @@ -64,12 +66,10 @@ func (h *PrivateHandler) Routes() *http.ServeMux {
// --- request/response types ---

type listResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
}

type listDetailResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Subscribers struct {
Total int `json:"total"`
Expand Down Expand Up @@ -100,6 +100,20 @@ type testMailRequest struct {

// --- handlers ---

func (h *PrivateHandler) handleAllLists(w http.ResponseWriter, r *http.Request) {
lists, err := h.lists.All(r.Context())
if err != nil {
h.logger.ErrorContext(r.Context(), "get all lists failed", slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to load lists")
return
}
resp := make([]listResponse, len(lists))
for i, l := range lists {
resp[i] = listResponse{Name: l.Name}
}
writeJSON(w, http.StatusOK, resp)
}

func (h *PrivateHandler) handleCreateList(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Expand All @@ -116,40 +130,34 @@ func (h *PrivateHandler) handleCreateList(w http.ResponseWriter, r *http.Request
return
}

writeJSON(w, http.StatusCreated, listResponse{ID: list.ID, Name: list.Name})
writeJSON(w, http.StatusCreated, listResponse{Name: list.Name})
}

func (h *PrivateHandler) handleGetList(w http.ResponseWriter, r *http.Request) {
id, ok := parseUintPath(w, r, "id")
if !ok {
return
}
name := r.PathValue("name")

list, err := h.lists.Get(r.Context(), id)
list, err := h.lists.Get(r.Context(), name)
if err != nil {
h.logger.ErrorContext(r.Context(), "get list failed", slog.Uint64("id", uint64(id)), slog.Any("error", err))
h.logger.ErrorContext(r.Context(), "get list failed", slog.String("name", name), slog.Any("error", err))
writeError(w, http.StatusNotFound, "list not found")
return
}

counts, err := h.lists.CountUsers(r.Context(), id)
counts, err := h.lists.CountUsers(r.Context(), name)
if err != nil {
h.logger.ErrorContext(r.Context(), "count users failed", slog.Uint64("id", uint64(id)), slog.Any("error", err))
h.logger.ErrorContext(r.Context(), "count users failed", slog.String("name", name), slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to load list stats")
return
}

resp := listDetailResponse{ID: list.ID, Name: list.Name}
resp := listDetailResponse{Name: list.Name}
resp.Subscribers.Total = counts.Total
resp.Subscribers.Confirmed = counts.Confirmed
writeJSON(w, http.StatusOK, resp)
}

func (h *PrivateHandler) handleRenameList(w http.ResponseWriter, r *http.Request) {
id, ok := parseUintPath(w, r, "id")
if !ok {
return
}
name := r.PathValue("name")

var body struct {
Name string `json:"name"`
Expand All @@ -159,24 +167,21 @@ func (h *PrivateHandler) handleRenameList(w http.ResponseWriter, r *http.Request
return
}

list, err := h.lists.Rename(r.Context(), id, body.Name)
list, err := h.lists.Rename(r.Context(), name, body.Name)
if err != nil {
h.logger.ErrorContext(r.Context(), "rename list failed", slog.Uint64("id", uint64(id)), slog.Any("error", err))
h.logger.ErrorContext(r.Context(), "rename list failed", slog.String("name", name), slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to rename list")
return
}

writeJSON(w, http.StatusOK, listResponse{ID: list.ID, Name: list.Name})
writeJSON(w, http.StatusOK, listResponse{Name: list.Name})
}

func (h *PrivateHandler) handleDeleteList(w http.ResponseWriter, r *http.Request) {
id, ok := parseUintPath(w, r, "id")
if !ok {
return
}
name := r.PathValue("name")

if err := h.lists.Delete(r.Context(), id); err != nil {
h.logger.ErrorContext(r.Context(), "delete list failed", slog.Uint64("id", uint64(id)), slog.Any("error", err))
if err := h.lists.Delete(r.Context(), name); err != nil {
h.logger.ErrorContext(r.Context(), "delete list failed", slog.String("name", name), slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to delete list")
return
}
Expand All @@ -185,14 +190,11 @@ func (h *PrivateHandler) handleDeleteList(w http.ResponseWriter, r *http.Request
}

func (h *PrivateHandler) handleListUsers(w http.ResponseWriter, r *http.Request) {
id, ok := parseUintPath(w, r, "id")
if !ok {
return
}
name := r.PathValue("name")

users, err := h.lists.Users(r.Context(), id)
users, err := h.lists.Users(r.Context(), name)
if err != nil {
h.logger.ErrorContext(r.Context(), "list users failed", slog.Uint64("id", uint64(id)), slog.Any("error", err))
h.logger.ErrorContext(r.Context(), "list users failed", slog.String("name", name), slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to load users")
return
}
Expand Down Expand Up @@ -301,15 +303,3 @@ func buildSignedMessage(timestamp, method, path string, body []byte) []byte {
bodyHash := hex.EncodeToString(sum[:])
return []byte(timestamp + "\n" + method + "\n" + path + "\n" + bodyHash)
}

// --- helpers ---

func parseUintPath(w http.ResponseWriter, r *http.Request, key string) (uint, bool) {
raw := r.PathValue(key)
n, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid %s", key))
return 0, false
}
return uint(n), true
}
45 changes: 31 additions & 14 deletions api/private_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,11 @@ func NewPrivateClient(baseURL string, privateKey ed25519.PrivateKey) *PrivateCli

// ListResponse is returned by list creation and rename endpoints.
type ListResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
}

// ListDetailResponse is returned by the get-list endpoint.
type ListDetailResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Subscribers struct {
Total int `json:"total"`
Expand All @@ -62,6 +60,25 @@ type RecipientInput struct {
Email string `json:"email"`
}

// GetAllLists returns all mailing lists.
func (c *PrivateClient) GetAllLists(ctx context.Context) ([]ListResponse, error) {
resp, err := c.do(ctx, http.MethodGet, "/lists", nil)
if err != nil {
return nil, fmt.Errorf("get all lists: %w", err)
}
defer resp.Body.Close()

if err := expectStatus(resp, http.StatusOK); err != nil {
return nil, fmt.Errorf("get all lists: %w", err)
}

var out []ListResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("get all lists: decode response: %w", err)
}
return out, nil
}

// CreateList creates a new mailing list.
func (c *PrivateClient) CreateList(ctx context.Context, name string) (*ListResponse, error) {
resp, err := c.do(ctx, http.MethodPost, "/lists", map[string]string{"name": name})
Expand All @@ -81,9 +98,9 @@ func (c *PrivateClient) CreateList(ctx context.Context, name string) (*ListRespo
return &out, nil
}

// GetList returns the mailing list with the given id along with subscriber stats.
func (c *PrivateClient) GetList(ctx context.Context, id uint) (*ListDetailResponse, error) {
resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/lists/%d", id), nil)
// GetList returns the mailing list with the given name along with subscriber stats.
func (c *PrivateClient) GetList(ctx context.Context, name string) (*ListDetailResponse, error) {
resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/lists/%s", name), nil)
if err != nil {
return nil, fmt.Errorf("get list: %w", err)
}
Expand All @@ -100,9 +117,9 @@ func (c *PrivateClient) GetList(ctx context.Context, id uint) (*ListDetailRespon
return &out, nil
}

// RenameList renames the mailing list with the given id.
func (c *PrivateClient) RenameList(ctx context.Context, id uint, name string) (*ListResponse, error) {
resp, err := c.do(ctx, http.MethodPut, fmt.Sprintf("/lists/%d", id), map[string]string{"name": name})
// RenameList renames the mailing list identified by name.
func (c *PrivateClient) RenameList(ctx context.Context, name, newName string) (*ListResponse, error) {
resp, err := c.do(ctx, http.MethodPut, fmt.Sprintf("/lists/%s", name), map[string]string{"name": newName})
if err != nil {
return nil, fmt.Errorf("rename list: %w", err)
}
Expand All @@ -119,9 +136,9 @@ func (c *PrivateClient) RenameList(ctx context.Context, id uint, name string) (*
return &out, nil
}

// DeleteList deletes the mailing list with the given id.
func (c *PrivateClient) DeleteList(ctx context.Context, id uint) error {
resp, err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/lists/%d", id), nil)
// DeleteList deletes the mailing list with the given name.
func (c *PrivateClient) DeleteList(ctx context.Context, name string) error {
resp, err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/lists/%s", name), nil)
if err != nil {
return fmt.Errorf("delete list: %w", err)
}
Expand All @@ -133,9 +150,9 @@ func (c *PrivateClient) DeleteList(ctx context.Context, id uint) error {
return nil
}

// GetUsers returns all subscribers (confirmed or not) for the given list id.
func (c *PrivateClient) GetUsers(ctx context.Context, listID uint) ([]UserItem, error) {
resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/lists/%d/users", listID), nil)
// GetUsers returns all subscribers (confirmed or not) for the named list.
func (c *PrivateClient) GetUsers(ctx context.Context, listName string) ([]UserItem, error) {
resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/lists/%s/users", listName), nil)
if err != nil {
return nil, fmt.Errorf("get users: %w", err)
}
Expand Down
Loading
Loading