mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-30 12:39:03 +01:00
feat: built in api documentation via huma gin adapter
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user