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
@@ -6,7 +6,7 @@ import (
"log/slog"
"os"
"github.com/gin-gonic/gin"
"github.com/danielgtaylor/huma/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/controller"
@@ -15,8 +15,8 @@ import (
// When building for E2E tests, add the e2etest controller
func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
registerTestControllers = []func(api huma.API, db *gorm.DB, svc *services){
func(api huma.API, db *gorm.DB, svc *services) {
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage)
if err != nil {
slog.Error("Failed to initialize test service", slog.Any("error", err))
@@ -24,7 +24,7 @@ func init() {
return
}
controller.NewTestController(apiGroup, testService)
controller.NewTestController(api, testService)
},
}
}
+33 -31
View File
@@ -14,6 +14,7 @@ import (
"sync/atomic"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/fsnotify/fsnotify"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin"
@@ -27,11 +28,12 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/controller"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/tracing"
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
"github.com/pocket-id/pocket-id/backend/internal/utils/systemd"
)
// This is used to register additional controllers for tests
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
var registerTestControllers []func(api huma.API, db *gorm.DB, svc *services)
func initRouter(db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) (servicerunner.Service, error) {
r, err := initEngine()
@@ -137,45 +139,45 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyModule, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitServices)
apiRateLimitMiddleware := rateLimitMiddleware.Add(middleware.RateLimitAPI)
baseGroup := r.Group("/", rateLimitMiddleware.Add(middleware.RateLimitAPI))
apiGroup := baseGroup.Group("/api")
api := httpapi.New(r, baseGroup)
apiGroup := r.Group("/api", apiRateLimitMiddleware)
baseGroup := r.Group("/", apiRateLimitMiddleware)
svc.apiKeyModule.RegisterRoutes(apiGroup,
authMiddleware.WithAdminNotRequired().Add(),
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(),
svc.apiKeyModule.RegisterRoutes(api,
authMiddleware.WithAdminNotRequired().Huma(api),
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Huma(api),
)
svc.webauthnModule.RegisterRoutes(apiGroup,
authMiddleware.WithAdminNotRequired().Add(),
rateLimitMiddleware.Add(middleware.RateLimitWebauthnLogin),
rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate),
svc.webauthnModule.RegisterRoutes(api,
authMiddleware.WithAdminNotRequired().Huma(api),
rateLimitMiddleware.Huma(api, middleware.RateLimitWebauthnLogin),
rateLimitMiddleware.Huma(api, middleware.RateLimitWebauthnReauthenticate),
)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add())
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
svc.userSignUpModule.RegisterRoutes(apiGroup,
authMiddleware.Add(),
rateLimitMiddleware.Add(middleware.RateLimitSignup),
controller.NewOidcController(api, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
controller.NewUserController(api, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService)
controller.NewAppConfigController(api, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(api, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(api, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(api, authMiddleware, svc.userGroupService)
svc.apiModule.RegisterRoutes(api, authMiddleware.Huma(api))
controller.NewCustomClaimController(api, authMiddleware, svc.customClaimService)
controller.NewVersionController(api, authMiddleware, svc.versionService)
controller.NewScimController(api, authMiddleware, svc.scimService)
svc.userSignUpModule.RegisterRoutes(api,
authMiddleware.Huma(api),
rateLimitMiddleware.Huma(api, middleware.RateLimitSignup),
)
optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add()
browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add()
svc.oidcModule.RegisterRoutes(baseGroup, apiGroup, optionalBrowserAuth, browserAuth)
svc.oidcModule.RegisterRawRoutes(baseGroup, apiGroup, optionalBrowserAuth, api)
svc.oidcModule.RegisterTypedRoutes(api, authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Huma(api))
registerTestRoutes(apiGroup, db, svc)
registerTestRoutes(api, db, svc)
controller.NewWellKnownController(baseGroup, svc.jwtService)
controller.NewWellKnownController(api, svc.jwtService)
// These are not rate-limited.
controller.NewHealthzController(r)
httpapi.AddRawOperation(api, "healthz", http.MethodGet, "/healthz", "Health check", []string{"Health"}, nil, http.StatusNoContent)
// Receives OTLP trace payloads from the browser SPA (POST /internal/telemetry/traces) and forwards them to the collector, when trace export is enabled.
// Outside /api, so it's unauthenticated and not traced, but it is rate-limited.
@@ -184,13 +186,13 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
return nil
}
func registerTestRoutes(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
func registerTestRoutes(api huma.API, db *gorm.DB, svc *services) {
if common.EnvConfig.AppEnv.IsProduction() {
return
}
for _, f := range registerTestControllers {
f(apiGroup, db, svc)
f(api, db, svc)
}
}
@@ -0,0 +1,109 @@
package bootstrap
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestHumaRouterOpenAPI(t *testing.T) {
originalConfig := common.EnvConfig
t.Cleanup(func() { common.EnvConfig = originalConfig })
common.EnvConfig.AppEnv = common.AppEnvTest
common.EnvConfig.AppURL = "https://test.example.com"
common.EnvConfig.InternalAppURL = "https://test.example.com"
common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
common.EnvConfig.DisableRateLimiting = true
db := testutils.NewDatabaseForTest(t)
fileStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
scheduler, err := job.NewScheduler()
require.NoError(t, err)
services, err := initServices(t.Context(), db, http.DefaultClient, map[string]string{}, fileStorage, scheduler)
require.NoError(t, err)
router, err := initEngine()
require.NoError(t, err)
require.NoError(t, registerRoutes(router, db, services, nil))
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/openapi.json", nil))
require.Equal(t, http.StatusOK, response.Code)
var document struct {
Paths map[string]map[string]struct {
OperationID string `json:"operationId"`
Responses map[string]any `json:"responses"`
} `json:"paths"`
Components struct {
SecuritySchemes map[string]any `json:"securitySchemes"`
} `json:"components"`
}
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &document))
for _, path := range []string{
"/authorize",
"/.well-known/openid-configuration",
"/.well-known/jwks.json",
"/api/users",
"/api/user-groups",
"/api/oidc/token",
"/api/oidc/interactions/{id}",
"/api/webauthn/reauthenticate",
"/api/signup",
"/api/api-keys",
"/api/apis",
"/healthz",
} {
require.Contains(t, document.Paths, path)
}
operationIDs := map[string]struct{}{}
for _, methods := range document.Paths {
for _, operation := range methods {
require.NotEmpty(t, operation.OperationID)
require.NotContains(t, operation.Responses, "422")
_, duplicate := operationIDs[operation.OperationID]
require.False(t, duplicate, "duplicate operation ID %q", operation.OperationID)
operationIDs[operation.OperationID] = struct{}{}
}
}
for _, scheme := range []string{"BearerAuth", "SessionCookie", "ApiKeyAuth", "OIDCAccessToken", "OIDCClientBasic"} {
require.Contains(t, document.Components.SecuritySchemes, scheme)
}
require.NotContains(t, response.Body.String(), `"$schema"`)
require.NotContains(t, response.Header().Get("Link"), "schema")
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/users", nil))
require.Equal(t, http.StatusUnauthorized, response.Code)
require.Equal(t, "application/json; charset=utf-8", response.Header().Get("Content-Type"))
require.JSONEq(t, `{"error":"You are not signed in"}`, response.Body.String())
response = httptest.NewRecorder()
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/signup/setup", nil)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(response, request)
require.Equal(t, http.StatusBadRequest, response.Code)
require.JSONEq(t, `{"error":"Request body is required"}`, response.Body.String())
response = httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/docs", nil))
require.Equal(t, http.StatusOK, response.Code)
require.Contains(t, response.Header().Get("Content-Security-Policy"), "https://cdn.jsdelivr.net")
require.NotContains(t, response.Header().Get("Content-Security-Policy"), "script-src 'unsafe-inline'")
require.Contains(t, response.Body.String(), "@scalar/api-reference@1.62.5")
response = httptest.NewRecorder()
newHTTPServer(router, nil).Handler.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodHead, "/healthz", nil))
require.Equal(t, http.StatusNoContent, response.Code)
require.Empty(t, response.Body.String())
}