feat: built in api documentation via huma gin adapter

This commit is contained in:
Kyle Mendell
2026-07-10 16:31:45 -05:00
parent 20254eea7c
commit f8b441fb06
68 changed files with 3231 additions and 3106 deletions
+16 -8
View File
@@ -1,23 +1,31 @@
package usersignup
import (
"github.com/danielgtaylor/huma/v2"
"github.com/pocket-id/pocket-id/backend/internal/dto"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type signUpDto struct {
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
Username string `json:"username" required:"true" minLength:"1" maxLength:"50" pattern:"^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$" patternDescription:"letters, numbers, underscores, dots, hyphens, and @ symbols without leading or trailing special characters" unorm:"nfc"`
Email *string `json:"email" required:"false" format:"email" unorm:"nfc"`
FirstName string `json:"firstName" required:"false" maxLength:"50" unorm:"nfc"`
LastName string `json:"lastName" required:"false" maxLength:"50" unorm:"nfc"`
Token string `json:"token" required:"false"`
}
func (d *signupTokenCreateDto) Resolve(huma.Context) []error {
if dto.ValidateTTL(d.TTL) {
return nil
}
return []error{&huma.ErrorDetail{Location: "body.ttl", Message: "TTL must be greater than one second and no more than 31 days"}}
}
type signupTokenCreateDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
UserGroupIDs []string `json:"userGroupIds"`
TTL utils.JSONDuration `json:"ttl" required:"true"`
UsageLimit int `json:"usageLimit" required:"true" minimum:"1" maximum:"100"`
UserGroupIDs []string `json:"userGroupIds" required:"false"`
}
type signupTokenDto struct {
+59 -144
View File
@@ -1,19 +1,36 @@
package usersignup
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"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/utils"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
const defaultSignupTokenDuration = time.Hour
type userOutput struct {
SetCookie []http.Cookie `header:"Set-Cookie"`
Body dto.UserDto
}
type signupInput struct {
Body signUpDto
}
type tokenCreateInput struct {
Body signupTokenCreateDto
}
type tokenIDInput struct {
ID string `path:"id"`
}
type handler struct {
service *Service
appConfig AppConfigProvider
@@ -23,175 +40,73 @@ func newHandler(service *Service, appConfig AppConfigProvider) *handler {
return &handler{service: service, appConfig: appConfig}
}
func (h *handler) checkInitialAdminSetupAvailable(c *gin.Context) {
setupCompleted, err := h.service.IsInitialAdminSetupCompleted(c.Request.Context())
func (h *handler) checkInitialAdminSetupAvailable(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) {
setupCompleted, err := h.service.IsInitialAdminSetupCompleted(ctx)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
if setupCompleted {
_ = c.Error(&common.SetupNotAvailableError{})
return
return nil, &common.SetupNotAvailableError{}
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
// signUpInitialAdmin godoc
// @Summary Sign up initial admin user
// @Description Sign up and generate setup access token for initial admin user
// @Tags Users
// @Accept json
// @Produce json
// @Param body body signUpDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/signup/setup [post]
func (h *handler) signUpInitialAdmin(c *gin.Context) {
var input signUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
user, token, err := h.service.SignUpInitialAdmin(c.Request.Context(), input)
func (h *handler) signUpInitialAdmin(ctx context.Context, input *signupInput) (*userOutput, error) {
user, token, err := h.service.SignUpInitialAdmin(ctx, input.Body)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
return h.userOutput(user, token)
}
// createSignupTokenHandler godoc
// @Summary Create signup token
// @Description Create a new signup token that allows user registration
// @Tags Users
// @Accept json
// @Produce json
// @Param token body signupTokenCreateDto true "Signup token information"
// @Success 201 {object} signupTokenDto
// @Router /api/signup-tokens [post]
func (h *handler) createSignupToken(c *gin.Context) {
var input signupTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
ttl := input.TTL.Duration
func (h *handler) createSignupToken(ctx context.Context, input *tokenCreateInput) (*httpapi.BodyOutput[signupTokenDto], error) {
ttl := input.Body.TTL.Duration
if ttl <= 0 {
ttl = defaultSignupTokenDuration
}
signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
token, err := h.service.CreateSignupToken(ctx, ttl, input.Body.UsageLimit, input.Body.UserGroupIDs)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var tokenDto signupTokenDto
err = dto.MapStruct(signupToken, &tokenDto)
if err != nil {
_ = c.Error(err)
return
var output signupTokenDto
if err := dto.MapStruct(token, &output); err != nil {
return nil, err
}
c.JSON(http.StatusCreated, tokenDto)
return &httpapi.BodyOutput[signupTokenDto]{Body: output}, nil
}
// listSignupTokensHandler godoc
// @Summary List signup tokens
// @Description Get a paginated list of signup tokens
// @Tags Users
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @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) {
listRequestOptions := utils.ParseListRequestOptions(c)
tokens, pagination, err := h.service.ListSignupTokens(c.Request.Context(), listRequestOptions)
func (h *handler) listSignupTokens(ctx context.Context, input *httpapi.ListInput) (*httpapi.BodyOutput[dto.Paginated[signupTokenDto]], error) {
tokens, pagination, err := h.service.ListSignupTokens(ctx, input.ListRequestOptions)
if err != nil {
_ = c.Error(err)
return
return nil, err
}
var tokensDto []signupTokenDto
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
_ = c.Error(err)
return
var output []signupTokenDto
if err := dto.MapStructList(tokens, &output); err != nil {
return nil, err
}
c.JSON(http.StatusOK, dto.Paginated[signupTokenDto]{
Data: tokensDto,
Pagination: pagination,
})
return &httpapi.BodyOutput[dto.Paginated[signupTokenDto]]{Body: dto.Paginated[signupTokenDto]{Data: output, Pagination: pagination}}, nil
}
// deleteSignupTokenHandler godoc
// @Summary Delete signup token
// @Description Delete a signup token by ID
// @Tags Users
// @Param id path string true "Token ID"
// @Success 204 "No Content"
// @Router /api/signup-tokens/{id} [delete]
func (h *handler) deleteSignupToken(c *gin.Context) {
tokenID := c.Param("id")
err := h.service.DeleteSignupToken(c.Request.Context(), tokenID)
if err != nil {
_ = c.Error(err)
return
func (h *handler) deleteSignupToken(ctx context.Context, input *tokenIDInput) (*httpapi.EmptyOutput, error) {
if err := h.service.DeleteSignupToken(ctx, input.ID); err != nil {
return nil, err
}
c.Status(http.StatusNoContent)
return &httpapi.EmptyOutput{}, nil
}
// signupHandler godoc
// @Summary Sign up
// @Description Create a new user account
// @Tags Users
// @Accept json
// @Produce json
// @Param user body signUpDto true "User information"
// @Success 201 {object} dto.UserDto
// @Router /api/signup [post]
func (h *handler) signup(c *gin.Context) {
var input signUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
user, accessToken, err := h.service.SignUp(c.Request.Context(), input, ipAddress, userAgent)
func (h *handler) signup(ctx context.Context, input *signupInput) (*userOutput, error) {
user, accessToken, err := h.service.SignUp(ctx, input.Body, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx))
if err != nil {
_ = c.Error(err)
return
return nil, err
}
return h.userOutput(user, accessToken)
}
func (h *handler) userOutput(user model.User, accessToken string) (*userOutput, error) {
var output dto.UserDto
if err := dto.MapStruct(user, &output); err != nil {
return nil, err
}
maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, userDto)
return &userOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, accessToken)}, Body: output}, nil
}
+36 -8
View File
@@ -2,12 +2,14 @@ package usersignup
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
)
type TokenService interface {
@@ -50,11 +52,37 @@ func New(deps Dependencies) *Module {
// 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)
func (m *Module) RegisterRoutes(api huma.API, adminAuth func(*huma.Operation), signupRateLimit func(huma.Context, func(huma.Context))) {
createTokenOperation := signupOperation("create-signup-token", http.MethodPost, "/api/signup-tokens", "Create signup token")
createTokenOperation.DefaultStatus = http.StatusCreated
adminAuth(&createTokenOperation)
httpapi.Register(api, createTokenOperation, m.handler.createSignupToken)
listTokensOperation := signupOperation("list-signup-tokens", http.MethodGet, "/api/signup-tokens", "List signup tokens")
adminAuth(&listTokensOperation)
httpapi.Register(api, listTokensOperation, m.handler.listSignupTokens)
deleteTokenOperation := signupOperation("delete-signup-token", http.MethodDelete, "/api/signup-tokens/{id}", "Delete signup token")
deleteTokenOperation.DefaultStatus = http.StatusNoContent
adminAuth(&deleteTokenOperation)
httpapi.Register(api, deleteTokenOperation, m.handler.deleteSignupToken)
selfSignupOperation := signupOperation("signup", http.MethodPost, "/api/signup", "Sign up")
selfSignupOperation.DefaultStatus = http.StatusCreated
selfSignupOperation.Middlewares = append(selfSignupOperation.Middlewares, signupRateLimit)
httpapi.Register(api, selfSignupOperation, m.handler.signup)
setupAvailableOperation := signupOperation("check-initial-admin-setup", http.MethodGet, "/api/signup/setup", "Check initial admin setup availability")
setupAvailableOperation.DefaultStatus = http.StatusNoContent
httpapi.Register(api, setupAvailableOperation, m.handler.checkInitialAdminSetupAvailable)
httpapi.Register(api, signupOperationForInitialAdmin(), m.handler.signUpInitialAdmin)
}
func signupOperation(id, method, path, summary string) huma.Operation {
return huma.Operation{OperationID: id, Method: method, Path: path, Summary: summary, Tags: []string{"Users"}}
}
func signupOperationForInitialAdmin() huma.Operation {
return signupOperation("signup-initial-admin", http.MethodPost, "/api/signup/setup", "Sign up initial admin user")
}