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