refactor: standardize API error handling (#1635)

This commit is contained in:
Elias Schneider
2026-08-02 23:36:06 +02:00
committed by GitHub
parent 281bea54d3
commit 7a4d0dd275
108 changed files with 3163 additions and 1629 deletions
+33 -42
View File
@@ -7,8 +7,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
@@ -24,19 +25,18 @@ func newHandler(service *Service, appConfig AppConfigResolver) *handler {
return &handler{service: service, appConfig: appConfig}
}
func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) {
func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) error {
setupCompleted, err := h.service.IsInitialAdminSetupCompleted(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
return err
}
if setupCompleted {
_ = c.Error(&common.SetupNotAvailableError{})
return
return apperror.SetupNotAvailable()
}
c.Status(http.StatusNoContent)
return nil
}
// signUpInitialAdmin godoc
@@ -48,37 +48,34 @@ func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) {
// @Param body body signUpDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/signup/setup [post]
func (h *handler) signUpInitialAdmin(c *gin.Context) {
func (h *handler) signUpInitialAdmin(c *gin.Context) error {
config, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
var input signUpDto
err = dto.ShouldBindWithNormalizedJSON(c, &input)
err = httpserver.BindJSON(c, &input)
if err != nil {
_ = c.Error(err)
return
return err
}
user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), config, input)
if err != nil {
_ = c.Error(err)
return
return err
}
var userDto dto.UserDto
err = dto.MapStruct(user, &userDto)
if err != nil {
_ = c.Error(err)
return
return err
}
maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
return nil
}
// createSignupTokenHandler godoc
@@ -90,11 +87,10 @@ func (h *handler) signUpInitialAdmin(c *gin.Context) {
// @Param token body signupTokenCreateDto true "Signup token information"
// @Success 201 {object} signupTokenDto
// @Router /api/signup-tokens [post]
func (h *handler) createSignupToken(c *gin.Context) {
func (h *handler) createSignupToken(c *gin.Context) error {
var input signupTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
if err := httpserver.BindJSON(c, &input); err != nil {
return err
}
ttl := input.TTL.Duration
@@ -104,18 +100,17 @@ func (h *handler) createSignupToken(c *gin.Context) {
signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
if err != nil {
_ = c.Error(err)
return
return err
}
var tokenDto signupTokenDto
err = dto.MapStruct(signupToken, &tokenDto)
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusCreated, tokenDto)
return nil
}
// listSignupTokensHandler godoc
@@ -128,26 +123,25 @@ func (h *handler) createSignupToken(c *gin.Context) {
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[signupTokenDto]
// @Router /api/signup-tokens [get]
func (h *handler) listSignupTokens(c *gin.Context) {
func (h *handler) listSignupTokens(c *gin.Context) error {
listRequestOptions := utils.ParseListRequestOptions(c)
tokens, pagination, err := h.service.ListSignupTokens(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return
return err
}
var tokensDto []signupTokenDto
err = dto.MapStructList(tokens, &tokensDto)
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusOK, dto.Paginated[signupTokenDto]{
Data: tokensDto,
Pagination: pagination,
})
return nil
}
// deleteSignupTokenHandler godoc
@@ -157,16 +151,16 @@ func (h *handler) listSignupTokens(c *gin.Context) {
// @Param id path string true "Token ID"
// @Success 204 "No Content"
// @Router /api/signup-tokens/{id} [delete]
func (h *handler) deleteSignupToken(c *gin.Context) {
func (h *handler) deleteSignupToken(c *gin.Context) error {
tokenID := c.Param("id")
err := h.service.DeleteSignupToken(c.Request.Context(), tokenID)
if err != nil {
_ = c.Error(err)
return
return err
}
c.Status(http.StatusNoContent)
return nil
}
// signupHandler godoc
@@ -178,24 +172,21 @@ func (h *handler) deleteSignupToken(c *gin.Context) {
// @Param user body signUpDto true "User information"
// @Success 201 {object} dto.UserDto
// @Router /api/signup [post]
func (h *handler) signup(c *gin.Context) {
func (h *handler) signup(c *gin.Context) error {
config, err := h.appConfig.GetConfig(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
return fmt.Errorf("error loading app configuration: %w", err)
}
var input signUpDto
err = dto.ShouldBindWithNormalizedJSON(c, &input)
err = httpserver.BindJSON(c, &input)
if err != nil {
_ = c.Error(err)
return
return err
}
user, accessToken, err := h.service.SignUp(c.Request.Context(), config, input, c.ClientIP(), c.GetHeader("User-Agent"))
if err != nil {
_ = c.Error(err)
return
return err
}
maxAge := int(config.SessionDuration.AsDurationMinutes().Seconds())
@@ -204,9 +195,9 @@ func (h *handler) signup(c *gin.Context) {
var userDto dto.UserDto
err = dto.MapStruct(user, &userDto)
if err != nil {
_ = c.Error(err)
return
return err
}
c.JSON(http.StatusCreated, userDto)
return nil
}
+1 -1
View File
@@ -18,7 +18,7 @@ import (
// It's loaded here to create the per-token actors on first startup.
// signupTokensMigratedKey is the kv key under which the pre-actor signup tokens were frozen.
const signupTokensMigratedKey = "signup_tokens_migrated" //nolint:gosec // G101 false positive: this is the name of a kv key, not a credential
const signupTokensMigratedKey = "signup_tokens_migrated" // #nosec G101 -- database key name, not a credential
// migratedSignupToken is the JSON shape of a signup token frozen into the kv table by the migration.
// All timestamps are expressed as Unix seconds.
+7 -6
View File
@@ -11,6 +11,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
@@ -77,10 +78,10 @@ func (m *Module) RunSignupTokenMigration(ctx context.Context) error {
// RegisterRoutes mounts the signup and signup-token management endpoints
// adminAuth guards the admin token-management routes; signupRateLimit throttles public self-signup
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth, signupRateLimit gin.HandlerFunc) {
apiGroup.POST("/signup-tokens", adminAuth, m.handler.createSignupToken)
apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens)
apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken)
apiGroup.POST("/signup", signupRateLimit, m.handler.signup)
apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable)
apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin)
apiGroup.POST("/signup-tokens", adminAuth, httpserver.Handle(m.handler.createSignupToken))
apiGroup.GET("/signup-tokens", adminAuth, httpserver.Handle(m.handler.listSignupTokens))
apiGroup.DELETE("/signup-tokens/:id", adminAuth, httpserver.Handle(m.handler.deleteSignupToken))
apiGroup.POST("/signup", signupRateLimit, httpserver.Handle(m.handler.signup))
apiGroup.GET("/signup/setup", httpserver.Handle(m.handler.checkInitialAdminSetupAvailable))
apiGroup.POST("/signup/setup", httpserver.Handle(m.handler.signUpInitialAdmin))
}
+4 -3
View File
@@ -13,6 +13,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -46,7 +47,7 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
tokenProvided := signupData.Token != ""
if config.AllowUserSignups.String() != "open" && !tokenProvided {
return model.User{}, "", &common.OpenSignupDisabledError{}
return model.User{}, "", apperror.OpenSignupDisabled()
}
var userGroupIDs []string
@@ -65,7 +66,7 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
}
if consumeRes.Status != signupTokenConsumeOK {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
return model.User{}, "", apperror.TokenInvalidOrExpired()
}
userGroupIDs = consumeRes.UserGroupIDs
}
@@ -160,7 +161,7 @@ func (s *Service) SignUpInitialAdmin(ctx context.Context, config *appconfig.AppC
return model.User{}, "", err
}
if setupCompleted {
return model.User{}, "", &common.SetupNotAvailableError{}
return model.User{}, "", apperror.SetupAlreadyCompleted()
}
// Build the first user with administrator privileges
+3 -5
View File
@@ -10,7 +10,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -114,8 +114,7 @@ func TestSignUpRejectsInvalidToken(t *testing.T) {
Token: "not-a-real-token",
}, "1.2.3.4", "test-agent")
var invalidErr *common.TokenInvalidOrExpiredError
require.ErrorAs(t, err, &invalidErr)
require.True(t, apperror.IsCode(err, apperror.CodeTokenInvalidOrExpired))
}
func TestSignUpInitialAdminCreatesAdmin(t *testing.T) {
@@ -154,8 +153,7 @@ func TestSignUpInitialAdminRejectsExistingInstallation(t *testing.T) {
// Reject setup when the installation already contains a user
_, _, err := svc.SignUpInitialAdmin(t.Context(), appconfig.NewTestConfig(nil), signUpDto{Username: "new-admin"})
var setupNotAvailableErr *common.SetupNotAvailableError
require.ErrorAs(t, err, &setupNotAvailableErr)
require.True(t, apperror.IsCode(err, apperror.CodeSetupAlreadyCompleted))
}
// listAllOptions returns list options that return every token on a single page.