From 00cc153d7adfb22f5c06cde4f04ed8e754c1547c Mon Sep 17 00:00:00 2001 From: Alexis Couvreur Date: Sat, 1 Feb 2025 21:00:49 -0800 Subject: [PATCH] fix: add http error responses (#494) This features adds rfc7807 Problem detail responses when an error happens processing a request. This will greatly improve the common issues with "blank pages" and "404 pages" issues which should now properly tell the user what input was wrong (group that does not exist, container name that does not exist, etc.) --- .gitignore | 3 +- Makefile | 3 + app/http/middleware/logging.go | 78 ----- app/http/routes/strategies.go | 165 ---------- app/http/routes/strategies_test.go | 285 ------------------ app/http/routes/theme_list.go | 13 - app/http/server.go | 83 ----- app/sablier.go | 17 +- app/sessions/errors.go | 31 ++ app/sessions/sessions_manager.go | 52 +++- .../sessionstest/mocks_sessions_manager.go | 144 +++++++++ app/theme/errors.go | 14 + app/theme/render.go | 5 +- cmd/start.go | 2 +- config/logging.go | 6 +- go.mod | 18 +- go.sum | 20 ++ go.work.sum | 4 + internal/api/abort.go | 18 ++ internal/api/api_response_headers.go | 18 ++ internal/api/api_test.go | 43 +++ {app/http/routes => internal/api}/health.go | 9 +- internal/api/problemdetail.go | 52 ++++ internal/api/start_blocking.go | 59 ++++ internal/api/start_blocking_test.go | 78 +++++ internal/api/start_dynamic.go | 132 ++++++++ internal/api/start_dynamic_test.go | 78 +++++ internal/api/theme_list.go | 18 ++ internal/server/logging.go | 47 +++ internal/server/routes.go | 26 ++ internal/server/server.go | 67 ++++ 31 files changed, 933 insertions(+), 655 deletions(-) delete mode 100644 app/http/middleware/logging.go delete mode 100644 app/http/routes/strategies_test.go delete mode 100644 app/http/routes/theme_list.go delete mode 100644 app/http/server.go create mode 100644 app/sessions/errors.go create mode 100644 app/sessions/sessionstest/mocks_sessions_manager.go create mode 100644 app/theme/errors.go create mode 100644 internal/api/abort.go create mode 100644 internal/api/api_response_headers.go create mode 100644 internal/api/api_test.go rename {app/http/routes => internal/api}/health.go (78%) create mode 100644 internal/api/problemdetail.go create mode 100644 internal/api/start_blocking.go create mode 100644 internal/api/start_blocking_test.go create mode 100644 internal/api/start_dynamic.go create mode 100644 internal/api/start_dynamic_test.go create mode 100644 internal/api/theme_list.go create mode 100644 internal/server/logging.go create mode 100644 internal/server/routes.go create mode 100644 internal/server/server.go diff --git a/.gitignore b/.gitignore index 2c6946f..ac139d0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ sablier.yaml node_modules .DS_Store *.wasm -kubeconfig.yaml \ No newline at end of file +kubeconfig.yaml +.idea \ No newline at end of file diff --git a/Makefile b/Makefile index fb7c3c6..0248ac9 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,9 @@ GO_LDFLAGS := -s -w -X $(VPREFIX).Branch=$(GIT_BRANCH) -X $(VPREFIX).Version=$(V $(PLATFORMS): CGO_ENABLED=0 GOOS=$(os) GOARCH=$(arch) go build -trimpath -tags=nomsgpack -v -ldflags="${GO_LDFLAGS}" -o 'sablier_$(VERSION)_$(os)-$(arch)' . +run: + go run main.go start + build: go build -v . diff --git a/app/http/middleware/logging.go b/app/http/middleware/logging.go deleted file mode 100644 index e8e0ae4..0000000 --- a/app/http/middleware/logging.go +++ /dev/null @@ -1,78 +0,0 @@ -package middleware - -import ( - "fmt" - "math" - "net/http" - "os" - "time" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" -) - -var timeFormat = "02/Jan/2006:15:04:05 -0700" - -// Logger is the logrus logger handler -func Logger(logger logrus.FieldLogger, notLogged ...string) gin.HandlerFunc { - hostname, err := os.Hostname() - if err != nil { - hostname = "unknow" - } - - var skip map[string]struct{} - - if length := len(notLogged); length > 0 { - skip = make(map[string]struct{}, length) - - for _, p := range notLogged { - skip[p] = struct{}{} - } - } - - return func(c *gin.Context) { - // other handler can change c.Path so: - path := c.Request.URL.Path - start := time.Now() - c.Next() - stop := time.Since(start) - latency := int(math.Ceil(float64(stop.Nanoseconds()) / 1000000.0)) - statusCode := c.Writer.Status() - clientIP := c.ClientIP() - clientUserAgent := c.Request.UserAgent() - referer := c.Request.Referer() - dataLength := c.Writer.Size() - if dataLength < 0 { - dataLength = 0 - } - - if _, ok := skip[path]; ok { - return - } - - entry := logger.WithFields(logrus.Fields{ - "hostname": hostname, - "statusCode": statusCode, - "latency": latency, // time to process - "clientIP": clientIP, - "method": c.Request.Method, - "path": path, - "referer": referer, - "dataLength": dataLength, - "userAgent": clientUserAgent, - }) - - if len(c.Errors) > 0 { - entry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String()) - } else { - msg := fmt.Sprintf("%s - %s [%s] \"%s %s\" %d %d \"%s\" \"%s\" (%dms)", clientIP, hostname, time.Now().Format(timeFormat), c.Request.Method, path, statusCode, dataLength, referer, clientUserAgent, latency) - if statusCode >= http.StatusInternalServerError { - entry.Error(msg) - } else if statusCode >= http.StatusBadRequest { - entry.Warn(msg) - } else { - entry.Info(msg) - } - } - } -} diff --git a/app/http/routes/strategies.go b/app/http/routes/strategies.go index 8467458..c9a763e 100644 --- a/app/http/routes/strategies.go +++ b/app/http/routes/strategies.go @@ -1,27 +1,11 @@ package routes import ( - "bufio" - "bytes" - "fmt" - "net/http" - "os" - "sort" - "strconv" - "strings" - - log "github.com/sirupsen/logrus" - - "github.com/gin-gonic/gin" - "github.com/sablierapp/sablier/app/http/routes/models" - "github.com/sablierapp/sablier/app/instance" "github.com/sablierapp/sablier/app/sessions" "github.com/sablierapp/sablier/app/theme" "github.com/sablierapp/sablier/config" ) -var osDirFS = os.DirFS - type ServeStrategy struct { Theme *theme.Themes @@ -29,152 +13,3 @@ type ServeStrategy struct { StrategyConfig config.Strategy SessionsConfig config.Sessions } - -func NewServeStrategy(sessionsManager sessions.Manager, strategyConf config.Strategy, sessionsConf config.Sessions, themes *theme.Themes) *ServeStrategy { - - serveStrategy := &ServeStrategy{ - Theme: themes, - SessionsManager: sessionsManager, - StrategyConfig: strategyConf, - SessionsConfig: sessionsConf, - } - - return serveStrategy -} - -func (s *ServeStrategy) ServeDynamic(c *gin.Context) { - request := models.DynamicRequest{ - Theme: s.StrategyConfig.Dynamic.DefaultTheme, - ShowDetails: s.StrategyConfig.Dynamic.ShowDetailsByDefault, - RefreshFrequency: s.StrategyConfig.Dynamic.DefaultRefreshFrequency, - SessionDuration: s.SessionsConfig.DefaultDuration, - } - - if err := c.ShouldBind(&request); err != nil { - c.AbortWithError(http.StatusBadRequest, err) - return - } - - var sessionState *sessions.SessionState - if len(request.Names) > 0 { - sessionState = s.SessionsManager.RequestSession(request.Names, request.SessionDuration) - } else { - sessionState = s.SessionsManager.RequestSessionGroup(request.Group, request.SessionDuration) - } - - if sessionState == nil { - c.AbortWithStatus(http.StatusNotFound) - return - } - - if sessionState.IsReady() { - c.Header("X-Sablier-Session-Status", "ready") - } else { - c.Header("X-Sablier-Session-Status", "not-ready") - } - - renderOptions := theme.Options{ - DisplayName: request.DisplayName, - ShowDetails: request.ShowDetails, - SessionDuration: request.SessionDuration, - RefreshFrequency: request.RefreshFrequency, - InstanceStates: sessionStateToRenderOptionsInstanceState(sessionState), - } - - buf := new(bytes.Buffer) - writer := bufio.NewWriter(buf) - if err := s.Theme.Render(request.Theme, renderOptions, writer); err != nil { - log.Error(err) - c.AbortWithError(http.StatusInternalServerError, err) - return - } - writer.Flush() - - c.Header("Cache-Control", "no-cache") - c.Header("Content-Type", "text/html") - c.Header("Content-Length", strconv.Itoa(buf.Len())) - c.Writer.Write(buf.Bytes()) -} - -func (s *ServeStrategy) ServeBlocking(c *gin.Context) { - request := models.BlockingRequest{ - Timeout: s.StrategyConfig.Blocking.DefaultTimeout, - } - - if err := c.ShouldBind(&request); err != nil { - c.AbortWithError(http.StatusBadRequest, err) - return - } - - var sessionState *sessions.SessionState - var err error - if len(request.Names) > 0 { - sessionState, err = s.SessionsManager.RequestReadySession(c.Request.Context(), request.Names, request.SessionDuration, request.Timeout) - } else { - sessionState, err = s.SessionsManager.RequestReadySessionGroup(c.Request.Context(), request.Group, request.SessionDuration, request.Timeout) - } - - if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - - if sessionState == nil { - c.AbortWithStatus(http.StatusNotFound) - return - } - - if err != nil { - c.Header("X-Sablier-Session-Status", "not-ready") - c.JSON(http.StatusGatewayTimeout, map[string]interface{}{"error": err.Error()}) - return - } - - if sessionState.IsReady() { - c.Header("X-Sablier-Session-Status", "ready") - } else { - c.Header("X-Sablier-Session-Status", "not-ready") - } - - c.JSON(http.StatusOK, map[string]interface{}{"session": sessionState}) -} - -func sessionStateToRenderOptionsInstanceState(sessionState *sessions.SessionState) (instances []theme.Instance) { - if sessionState == nil { - log.Warnf("sessionStateToRenderOptionsInstanceState: sessionState is nil") - return - } - sessionState.Instances.Range(func(key, value any) bool { - if value != nil { - instances = append(instances, instanceStateToRenderOptionsRequestState(value.(sessions.InstanceState).Instance)) - } else { - log.Warnf("sessionStateToRenderOptionsInstanceState: sessionState instance is nil, key: %v", key) - } - - return true - }) - - sort.SliceStable(instances, func(i, j int) bool { - return strings.Compare(instances[i].Name, instances[j].Name) == -1 - }) - - return -} - -func instanceStateToRenderOptionsRequestState(instanceState *instance.State) theme.Instance { - - var err error - if instanceState.Message == "" { - err = nil - } else { - err = fmt.Errorf(instanceState.Message) - } - - return theme.Instance{ - Name: instanceState.Name, - Status: instanceState.Status, - CurrentReplicas: instanceState.CurrentReplicas, - DesiredReplicas: instanceState.DesiredReplicas, - Error: err, - } -} diff --git a/app/http/routes/strategies_test.go b/app/http/routes/strategies_test.go deleted file mode 100644 index 4a0e6df..0000000 --- a/app/http/routes/strategies_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package routes - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "sync" - "testing" - "testing/fstest" - "time" - - "github.com/gin-gonic/gin" - "github.com/sablierapp/sablier/app/http/routes/models" - "github.com/sablierapp/sablier/app/instance" - "github.com/sablierapp/sablier/app/sessions" - "github.com/sablierapp/sablier/app/theme" - "github.com/sablierapp/sablier/config" - "gotest.tools/v3/assert" -) - -type SessionsManagerMock struct { - SessionState sessions.SessionState - sessions.Manager -} - -func (s *SessionsManagerMock) RequestSession(names []string, duration time.Duration) *sessions.SessionState { - return &s.SessionState -} - -func (s *SessionsManagerMock) RequestReadySession(ctx context.Context, names []string, duration time.Duration, timeout time.Duration) (*sessions.SessionState, error) { - return &s.SessionState, nil -} - -func (s *SessionsManagerMock) LoadSessions(io.ReadCloser) error { - return nil -} -func (s *SessionsManagerMock) SaveSessions(io.WriteCloser) error { - return nil -} - -func (s *SessionsManagerMock) Stop() {} - -func TestServeStrategy_ServeDynamic(t *testing.T) { - type arg struct { - body models.DynamicRequest - session sessions.SessionState - } - tests := []struct { - name string - arg arg - expectedHeaderKey string - expectedHeaderValue string - }{ - { - name: "header has not ready value when not ready", - arg: arg{ - body: models.DynamicRequest{ - Names: []string{"nginx"}, - DisplayName: "Test", - Theme: "hacker-terminal", - SessionDuration: 1 * time.Minute, - }, - session: sessions.SessionState{ - Instances: createMap([]*instance.State{ - {Name: "nginx", Status: instance.NotReady}, - }), - }, - }, - expectedHeaderKey: "X-Sablier-Session-Status", - expectedHeaderValue: "not-ready", - }, - { - name: "header requests no caching", - arg: arg{ - body: models.DynamicRequest{ - Names: []string{"nginx"}, - DisplayName: "Test", - Theme: "hacker-terminal", - SessionDuration: 1 * time.Minute, - }, - session: sessions.SessionState{ - Instances: createMap([]*instance.State{ - {Name: "nginx", Status: instance.NotReady}, - }), - }, - }, - expectedHeaderKey: "Cache-Control", - expectedHeaderValue: "no-cache", - }, - { - name: "header has ready value when session is ready", - arg: arg{ - body: models.DynamicRequest{ - Names: []string{"nginx"}, - DisplayName: "Test", - Theme: "hacker-terminal", - SessionDuration: 1 * time.Minute, - }, - session: sessions.SessionState{ - Instances: createMap([]*instance.State{ - {Name: "nginx", Status: instance.Ready}, - }), - }, - }, - expectedHeaderKey: "X-Sablier-Session-Status", - expectedHeaderValue: "ready", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - theme, err := theme.NewWithCustomThemes(fstest.MapFS{}) - if err != nil { - panic(err) - } - s := &ServeStrategy{ - SessionsManager: &SessionsManagerMock{ - SessionState: tt.arg.session, - }, - StrategyConfig: config.NewStrategyConfig(), - Theme: theme, - } - recorder := httptest.NewRecorder() - c := GetTestGinContext(recorder) - MockJsonPost(c, tt.arg.body) - - s.ServeDynamic(c) - - res := recorder.Result() - defer res.Body.Close() - - assert.Equal(t, c.Writer.Header().Get(tt.expectedHeaderKey), tt.expectedHeaderValue) - }) - } -} - -func TestServeStrategy_ServeBlocking(t *testing.T) { - type arg struct { - body models.BlockingRequest - session sessions.SessionState - } - tests := []struct { - name string - arg arg - expectedBody string - expectedHeaderKey string - expectedHeaderValue string - }{ - { - name: "not ready returns session status not ready", - arg: arg{ - body: models.BlockingRequest{ - Names: []string{"nginx"}, - Timeout: 10 * time.Second, - SessionDuration: 1 * time.Minute, - }, - session: sessions.SessionState{ - Instances: createMap([]*instance.State{ - {Name: "nginx", Status: instance.NotReady, CurrentReplicas: 0, DesiredReplicas: 1}, - }), - }, - }, - expectedBody: `{"session":{"instances":[{"instance":{"name":"nginx","currentReplicas":0,"desiredReplicas":1,"status":"not-ready"},"error":null}],"status":"not-ready"}}`, - expectedHeaderKey: "X-Sablier-Session-Status", - expectedHeaderValue: "not-ready", - }, - { - name: "ready returns session status ready", - arg: arg{ - body: models.BlockingRequest{ - Names: []string{"nginx"}, - SessionDuration: 1 * time.Minute, - }, - session: sessions.SessionState{ - Instances: createMap([]*instance.State{ - {Name: "nginx", Status: instance.Ready, CurrentReplicas: 1, DesiredReplicas: 1}, - }), - }, - }, - expectedBody: `{"session":{"instances":[{"instance":{"name":"nginx","currentReplicas":1,"desiredReplicas":1,"status":"ready"},"error":null}],"status":"ready"}}`, - expectedHeaderKey: "X-Sablier-Session-Status", - expectedHeaderValue: "ready", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - s := &ServeStrategy{ - SessionsManager: &SessionsManagerMock{ - SessionState: tt.arg.session, - }, - StrategyConfig: config.NewStrategyConfig(), - } - recorder := httptest.NewRecorder() - c := GetTestGinContext(recorder) - MockJsonPost(c, tt.arg.body) - - s.ServeBlocking(c) - - res := recorder.Result() - defer res.Body.Close() - - bytes, err := io.ReadAll(res.Body) - - if err != nil { - panic(err) - } - - assert.Equal(t, c.Writer.Header().Get(tt.expectedHeaderKey), tt.expectedHeaderValue) - assert.Equal(t, string(bytes), tt.expectedBody) - }) - } -} - -// mock gin context -func GetTestGinContext(w *httptest.ResponseRecorder) *gin.Context { - gin.SetMode(gin.TestMode) - - ctx, _ := gin.CreateTestContext(w) - ctx.Request = &http.Request{ - Header: make(http.Header), - URL: &url.URL{}, - } - - return ctx -} - -// mock getrequest -func MockJsonGet(c *gin.Context, params gin.Params, u url.Values) { - c.Request.Method = "GET" - c.Request.Header.Set("Content-Type", "application/json") - c.Params = params - c.Request.URL.RawQuery = u.Encode() -} - -func MockJsonPost(c *gin.Context, content interface{}) { - c.Request.Method = "POST" - c.Request.Header.Set("Content-Type", "application/json") - - jsonbytes, err := json.Marshal(content) - if err != nil { - panic(err) - } - - // the request body must be an io.ReadCloser - // the bytes buffer though doesn't implement io.Closer, - // so you wrap it in a no-op closer - c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonbytes)) -} - -func MockJsonPut(c *gin.Context, content interface{}, params gin.Params) { - c.Request.Method = "PUT" - c.Request.Header.Set("Content-Type", "application/json") - c.Params = params - - jsonbytes, err := json.Marshal(content) - if err != nil { - panic(err) - } - - c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonbytes)) -} - -func MockJsonDelete(c *gin.Context, params gin.Params) { - c.Request.Method = "DELETE" - c.Request.Header.Set("Content-Type", "application/json") - c.Params = params -} - -func createMap(instances []*instance.State) (store *sync.Map) { - store = &sync.Map{} - - for _, v := range instances { - store.Store(v.Name, sessions.InstanceState{ - Instance: v, - Error: nil, - }) - } - - return -} diff --git a/app/http/routes/theme_list.go b/app/http/routes/theme_list.go deleted file mode 100644 index adf1e1a..0000000 --- a/app/http/routes/theme_list.go +++ /dev/null @@ -1,13 +0,0 @@ -package routes - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -func (s *ServeStrategy) ServeDynamicThemes(c *gin.Context) { - c.JSON(http.StatusOK, map[string]interface{}{ - "themes": s.Theme.List(), - }) -} diff --git a/app/http/server.go b/app/http/server.go deleted file mode 100644 index abf1031..0000000 --- a/app/http/server.go +++ /dev/null @@ -1,83 +0,0 @@ -package http - -import ( - "context" - "fmt" - "net/http" - "os/signal" - "syscall" - "time" - - log "github.com/sirupsen/logrus" - - "github.com/gin-gonic/gin" - "github.com/sablierapp/sablier/app/http/middleware" - "github.com/sablierapp/sablier/app/http/routes" - "github.com/sablierapp/sablier/app/sessions" - "github.com/sablierapp/sablier/app/theme" - "github.com/sablierapp/sablier/config" -) - -func Start(serverConf config.Server, strategyConf config.Strategy, sessionsConf config.Sessions, sessionManager sessions.Manager, t *theme.Themes) { - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - r := gin.New() - - r.Use(middleware.Logger(log.New()), gin.Recovery()) - - base := r.Group(serverConf.BasePath) - { - api := base.Group("/api") - { - strategy := routes.NewServeStrategy(sessionManager, strategyConf, sessionsConf, t) - api.GET("/strategies/dynamic", strategy.ServeDynamic) - api.GET("/strategies/dynamic/themes", strategy.ServeDynamicThemes) - api.GET("/strategies/blocking", strategy.ServeBlocking) - } - health := routes.Health{} - health.SetDefaults() - health.WithContext(ctx) - base.GET("/health", health.ServeHTTP) - } - - srv := &http.Server{ - Addr: fmt.Sprintf(":%d", serverConf.Port), - Handler: r, - } - - // Initializing the server in a goroutine so that - // it won't block the graceful shutdown handling below - go func() { - log.Info("server listening ", srv.Addr) - logRoutes(r.Routes()) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("listen: %s\n", err) - } - }() - - // Listen for the interrupt signal. - <-ctx.Done() - - // Restore default behavior on the interrupt signal and notify user of shutdown. - stop() - log.Info("shutting down gracefully, press Ctrl+C again to force") - - // The context is used to inform the server it has 10 seconds to finish - // the request it is currently handling - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := srv.Shutdown(ctx); err != nil { - log.Fatal("server forced to shutdown: ", err) - } - - log.Info("server exiting") - -} - -func logRoutes(routes gin.RoutesInfo) { - for _, route := range routes { - log.Debug(fmt.Sprintf("%s %s %s", route.Method, route.Path, route.Handler)) - } -} diff --git a/app/sablier.go b/app/sablier.go index e199e63..35c57d6 100644 --- a/app/sablier.go +++ b/app/sablier.go @@ -4,24 +4,26 @@ import ( "context" "fmt" "github.com/sablierapp/sablier/app/discovery" + "github.com/sablierapp/sablier/app/http/routes" "github.com/sablierapp/sablier/app/providers/docker" "github.com/sablierapp/sablier/app/providers/dockerswarm" "github.com/sablierapp/sablier/app/providers/kubernetes" + "log/slog" "os" - "github.com/sablierapp/sablier/app/http" "github.com/sablierapp/sablier/app/instance" "github.com/sablierapp/sablier/app/providers" "github.com/sablierapp/sablier/app/sessions" "github.com/sablierapp/sablier/app/storage" "github.com/sablierapp/sablier/app/theme" "github.com/sablierapp/sablier/config" + "github.com/sablierapp/sablier/internal/server" "github.com/sablierapp/sablier/pkg/tinykv" "github.com/sablierapp/sablier/version" log "github.com/sirupsen/logrus" ) -func Start(conf config.Config) error { +func Start(ctx context.Context, conf config.Config) error { logLevel, err := log.ParseLevel(conf.Logging.Level) @@ -30,6 +32,8 @@ func Start(conf config.Config) error { logLevel = log.InfoLevel } + logger := slog.Default() + log.SetLevel(logLevel) log.Info(version.Info()) @@ -80,7 +84,14 @@ func Start(conf config.Config) error { } } - http.Start(conf.Server, conf.Strategy, conf.Sessions, sessionsManager, t) + strategy := &routes.ServeStrategy{ + Theme: t, + SessionsManager: sessionsManager, + StrategyConfig: conf.Strategy, + SessionsConfig: conf.Sessions, + } + + server.Start(ctx, logger, conf.Server, strategy) return nil } diff --git a/app/sessions/errors.go b/app/sessions/errors.go new file mode 100644 index 0000000..0044d0d --- /dev/null +++ b/app/sessions/errors.go @@ -0,0 +1,31 @@ +package sessions + +import ( + "fmt" + "time" +) + +type ErrGroupNotFound struct { + Group string + AvailableGroups []string +} + +func (g ErrGroupNotFound) Error() string { + return fmt.Sprintf("group %s not found", g.Group) +} + +type ErrRequestBinding struct { + Err error +} + +func (e ErrRequestBinding) Error() string { + return e.Err.Error() +} + +type ErrTimeout struct { + Duration time.Duration +} + +func (e ErrTimeout) Error() string { + return fmt.Sprintf("timeout after %s", e.Duration) +} diff --git a/app/sessions/sessions_manager.go b/app/sessions/sessions_manager.go index af8ab0b..a9ccb52 100644 --- a/app/sessions/sessions_manager.go +++ b/app/sessions/sessions_manager.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "io" + "maps" + "slices" "sync" "time" @@ -17,9 +19,11 @@ import ( const defaultRefreshFrequency = 2 * time.Second +//go:generate mockgen -package sessionstest -source=sessions_manager.go -destination=sessionstest/mocks_sessions_manager.go * + type Manager interface { - RequestSession(names []string, duration time.Duration) *SessionState - RequestSessionGroup(group string, duration time.Duration) *SessionState + RequestSession(names []string, duration time.Duration) (*SessionState, error) + RequestSessionGroup(group string, duration time.Duration) (*SessionState, error) RequestReadySession(ctx context.Context, names []string, duration time.Duration, timeout time.Duration) (*SessionState, error) RequestReadySessionGroup(ctx context.Context, group string, duration time.Duration, timeout time.Duration) (*SessionState, error) @@ -112,6 +116,10 @@ type SessionState struct { func (s *SessionState) IsReady() bool { ready := true + if s.Instances == nil { + s.Instances = &sync.Map{} + } + s.Instances.Range(func(key, value interface{}) bool { state := value.(InstanceState) if state.Error != nil || state.Instance.Status != instance.Ready { @@ -132,10 +140,9 @@ func (s *SessionState) Status() string { return "not-ready" } -func (s *SessionsManager) RequestSession(names []string, duration time.Duration) (sessionState *SessionState) { - +func (s *SessionsManager) RequestSession(names []string, duration time.Duration) (sessionState *SessionState, err error) { if len(names) == 0 { - return nil + return nil, fmt.Errorf("names cannot be empty") } var wg sync.WaitGroup @@ -160,19 +167,24 @@ func (s *SessionsManager) RequestSession(names []string, duration time.Duration) wg.Wait() - return sessionState + return sessionState, nil } -func (s *SessionsManager) RequestSessionGroup(group string, duration time.Duration) (sessionState *SessionState) { - +func (s *SessionsManager) RequestSessionGroup(group string, duration time.Duration) (sessionState *SessionState, err error) { if len(group) == 0 { - return nil + return nil, fmt.Errorf("group is mandatory") } - names := s.groups[group] + names, ok := s.groups[group] + if !ok { + return nil, ErrGroupNotFound{ + Group: group, + AvailableGroups: slices.Collect(maps.Keys(s.groups)), + } + } if len(names) == 0 { - return nil + return nil, fmt.Errorf("group has no member") } return s.RequestSession(names, duration) @@ -227,8 +239,11 @@ func (s *SessionsManager) requestSessionInstance(name string, duration time.Dura } func (s *SessionsManager) RequestReadySession(ctx context.Context, names []string, duration time.Duration, timeout time.Duration) (*SessionState, error) { + session, err := s.RequestSession(names, duration) + if err != nil { + return nil, err + } - session := s.RequestSession(names, duration) if session.IsReady() { return session, nil } @@ -241,7 +256,10 @@ func (s *SessionsManager) RequestReadySession(ctx context.Context, names []strin for { select { case <-ticker.C: - session := s.RequestSession(names, duration) + session, err := s.RequestSession(names, duration) + if err != nil { + return + } if session.IsReady() { readiness <- session } @@ -272,7 +290,13 @@ func (s *SessionsManager) RequestReadySessionGroup(ctx context.Context, group st return nil, fmt.Errorf("group is mandatory") } - names := s.groups[group] + names, ok := s.groups[group] + if !ok { + return nil, ErrGroupNotFound{ + Group: group, + AvailableGroups: slices.Collect(maps.Keys(s.groups)), + } + } if len(names) == 0 { return nil, fmt.Errorf("group has no member") diff --git a/app/sessions/sessionstest/mocks_sessions_manager.go b/app/sessions/sessionstest/mocks_sessions_manager.go new file mode 100644 index 0000000..4438c9a --- /dev/null +++ b/app/sessions/sessionstest/mocks_sessions_manager.go @@ -0,0 +1,144 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: sessions_manager.go +// +// Generated by this command: +// +// mockgen -package sessionstest -source=sessions_manager.go -destination=sessionstest/mocks_sessions_manager.go * +// + +// Package sessionstest is a generated GoMock package. +package sessionstest + +import ( + context "context" + io "io" + reflect "reflect" + time "time" + + sessions "github.com/sablierapp/sablier/app/sessions" + gomock "go.uber.org/mock/gomock" +) + +// MockManager is a mock of Manager interface. +type MockManager struct { + ctrl *gomock.Controller + recorder *MockManagerMockRecorder + isgomock struct{} +} + +// MockManagerMockRecorder is the mock recorder for MockManager. +type MockManagerMockRecorder struct { + mock *MockManager +} + +// NewMockManager creates a new mock instance. +func NewMockManager(ctrl *gomock.Controller) *MockManager { + mock := &MockManager{ctrl: ctrl} + mock.recorder = &MockManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManager) EXPECT() *MockManagerMockRecorder { + return m.recorder +} + +// LoadSessions mocks base method. +func (m *MockManager) LoadSessions(arg0 io.ReadCloser) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadSessions", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// LoadSessions indicates an expected call of LoadSessions. +func (mr *MockManagerMockRecorder) LoadSessions(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadSessions", reflect.TypeOf((*MockManager)(nil).LoadSessions), arg0) +} + +// RequestReadySession mocks base method. +func (m *MockManager) RequestReadySession(ctx context.Context, names []string, duration, timeout time.Duration) (*sessions.SessionState, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestReadySession", ctx, names, duration, timeout) + ret0, _ := ret[0].(*sessions.SessionState) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestReadySession indicates an expected call of RequestReadySession. +func (mr *MockManagerMockRecorder) RequestReadySession(ctx, names, duration, timeout any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestReadySession", reflect.TypeOf((*MockManager)(nil).RequestReadySession), ctx, names, duration, timeout) +} + +// RequestReadySessionGroup mocks base method. +func (m *MockManager) RequestReadySessionGroup(ctx context.Context, group string, duration, timeout time.Duration) (*sessions.SessionState, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestReadySessionGroup", ctx, group, duration, timeout) + ret0, _ := ret[0].(*sessions.SessionState) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestReadySessionGroup indicates an expected call of RequestReadySessionGroup. +func (mr *MockManagerMockRecorder) RequestReadySessionGroup(ctx, group, duration, timeout any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestReadySessionGroup", reflect.TypeOf((*MockManager)(nil).RequestReadySessionGroup), ctx, group, duration, timeout) +} + +// RequestSession mocks base method. +func (m *MockManager) RequestSession(names []string, duration time.Duration) (*sessions.SessionState, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestSession", names, duration) + ret0, _ := ret[0].(*sessions.SessionState) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestSession indicates an expected call of RequestSession. +func (mr *MockManagerMockRecorder) RequestSession(names, duration any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestSession", reflect.TypeOf((*MockManager)(nil).RequestSession), names, duration) +} + +// RequestSessionGroup mocks base method. +func (m *MockManager) RequestSessionGroup(group string, duration time.Duration) (*sessions.SessionState, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestSessionGroup", group, duration) + ret0, _ := ret[0].(*sessions.SessionState) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestSessionGroup indicates an expected call of RequestSessionGroup. +func (mr *MockManagerMockRecorder) RequestSessionGroup(group, duration any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestSessionGroup", reflect.TypeOf((*MockManager)(nil).RequestSessionGroup), group, duration) +} + +// SaveSessions mocks base method. +func (m *MockManager) SaveSessions(arg0 io.WriteCloser) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveSessions", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveSessions indicates an expected call of SaveSessions. +func (mr *MockManagerMockRecorder) SaveSessions(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSessions", reflect.TypeOf((*MockManager)(nil).SaveSessions), arg0) +} + +// Stop mocks base method. +func (m *MockManager) Stop() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Stop") +} + +// Stop indicates an expected call of Stop. +func (mr *MockManagerMockRecorder) Stop() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockManager)(nil).Stop)) +} diff --git a/app/theme/errors.go b/app/theme/errors.go new file mode 100644 index 0000000..ea1d15f --- /dev/null +++ b/app/theme/errors.go @@ -0,0 +1,14 @@ +package theme + +import ( + "fmt" +) + +type ErrThemeNotFound struct { + Theme string + AvailableThemes []string +} + +func (t ErrThemeNotFound) Error() string { + return fmt.Sprintf("theme %s not found", t.Theme) +} diff --git a/app/theme/render.go b/app/theme/render.go index 2f0596e..6adddd4 100644 --- a/app/theme/render.go +++ b/app/theme/render.go @@ -27,7 +27,10 @@ func (t *Themes) Render(name string, opts Options, writer io.Writer) error { tpl := t.themes.Lookup(fmt.Sprintf("%s.html", name)) if tpl == nil { - return fmt.Errorf("theme %s does not exist", name) + return ErrThemeNotFound{ + Theme: name, + AvailableThemes: t.List(), + } } return tpl.Execute(writer, options) diff --git a/cmd/start.go b/cmd/start.go index ce84398..c0b085d 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -13,7 +13,7 @@ var newStartCommand = func() *cobra.Command { Run: func(cmd *cobra.Command, args []string) { viper.Unmarshal(&conf) - err := app.Start(conf) + err := app.Start(cmd.Context(), conf) if err != nil { panic(err) } diff --git a/config/logging.go b/config/logging.go index 544c30d..f62d2a7 100644 --- a/config/logging.go +++ b/config/logging.go @@ -1,6 +1,8 @@ package config -import log "github.com/sirupsen/logrus" +import ( + "log/slog" +) type Logging struct { Level string `mapstructure:"LEVEL" yaml:"level" default:"info"` @@ -8,6 +10,6 @@ type Logging struct { func NewLoggingConfig() Logging { return Logging{ - Level: log.InfoLevel.String(), + Level: slog.LevelInfo.String(), } } diff --git a/go.mod b/go.mod index f429c77..b2ed214 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ajg/form v1.5.1 // indirect github.com/andybalholm/brotli v1.0.4 // indirect - github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic v1.11.9 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect @@ -42,7 +42,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gabriel-vasile/mimetype v1.4.4 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -51,9 +51,9 @@ require ( github.com/go-openapi/swag v0.22.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-playground/validator/v10 v10.22.0 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect @@ -69,7 +69,7 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.2 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -89,6 +89,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/samber/slog-gin v1.14.1 // indirect github.com/sanity-io/litter v1.5.5 // indirect github.com/sergi/go-diff v1.0.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -96,6 +97,7 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tniswong/go.rfcx v0.0.0-20181019234604-07783c52761f // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect @@ -108,11 +110,11 @@ require ( github.com/yudai/gojsondiff v1.0.0 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.27.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect - go.opentelemetry.io/otel/metric v1.27.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect go.opentelemetry.io/otel/sdk v1.27.0 // indirect - go.opentelemetry.io/otel/trace v1.27.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.8.0 // indirect diff --git a/go.sum b/go.sum index fa25689..c88b7ae 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg= +github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -51,6 +53,8 @@ github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= +github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= @@ -75,12 +79,16 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= +github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -124,6 +132,8 @@ github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQs github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= +github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -186,6 +196,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/samber/slog-gin v1.14.1 h1:6DMAcy2gBFyyztrpYIvAcXZH1sA/j75iSSXuqhirLtg= +github.com/samber/slog-gin v1.14.1/go.mod h1:yS2C+cX5tRnPX0MqDby7a3tRFsJuMk7hNwAunyfDxQk= github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo= github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= @@ -223,6 +235,8 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= +github.com/tniswong/go.rfcx v0.0.0-20181019234604-07783c52761f h1:C43EMGXFtvYf/zunHR6ivZV7Z6ytg73t0GXwYyicXMQ= +github.com/tniswong/go.rfcx v0.0.0-20181019234604-07783c52761f/go.mod h1:N+sR0vLSCTtI6o06PMWsjMB4TVqqDttKNq4iC9wvxVY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= @@ -255,16 +269,22 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY= go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI= go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= diff --git a/go.work.sum b/go.work.sum index 3521d20..9ac8a0b 100644 --- a/go.work.sum +++ b/go.work.sum @@ -364,6 +364,7 @@ golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= @@ -375,6 +376,7 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= @@ -392,6 +394,7 @@ golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= @@ -399,6 +402,7 @@ golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= diff --git a/internal/api/abort.go b/internal/api/abort.go new file mode 100644 index 0000000..5532d90 --- /dev/null +++ b/internal/api/abort.go @@ -0,0 +1,18 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/tniswong/go.rfcx/rfc7807" + "net/url" +) + +func AbortWithProblemDetail(c *gin.Context, p rfc7807.Problem) { + _ = c.Error(p) + instance, err := url.Parse(c.Request.RequestURI) + if err != nil { + instance = &url.URL{} + } + p.Instance = *instance + c.Header("Content-Type", rfc7807.JSONMediaType) + c.IndentedJSON(p.Status, p) +} diff --git a/internal/api/api_response_headers.go b/internal/api/api_response_headers.go new file mode 100644 index 0000000..9e5422c --- /dev/null +++ b/internal/api/api_response_headers.go @@ -0,0 +1,18 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/sablierapp/sablier/app/sessions" +) + +const SablierStatusHeader = "X-Sablier-Session-Status" +const SablierStatusReady = "ready" +const SablierStatusNotReady = "not-ready" + +func AddSablierHeader(c *gin.Context, session *sessions.SessionState) { + if session.IsReady() { + c.Header(SablierStatusHeader, SablierStatusReady) + } else { + c.Header(SablierStatusHeader, SablierStatusNotReady) + } +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..f668c5c --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,43 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/sablierapp/sablier/app/http/routes" + "github.com/sablierapp/sablier/app/sessions/sessionstest" + "github.com/sablierapp/sablier/app/theme" + "github.com/sablierapp/sablier/config" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" + "net/http" + "net/http/httptest" + "testing" +) + +func NewApiTest(t *testing.T) (app *gin.Engine, router *gin.RouterGroup, strategy *routes.ServeStrategy, mock *sessionstest.MockManager) { + t.Helper() + gin.SetMode(gin.TestMode) + ctrl := gomock.NewController(t) + th, err := theme.New() + assert.NilError(t, err) + + app = gin.New() + router = app.Group("/api") + mock = sessionstest.NewMockManager(ctrl) + strategy = &routes.ServeStrategy{ + Theme: th, + SessionsManager: mock, + StrategyConfig: config.NewStrategyConfig(), + SessionsConfig: config.NewSessionsConfig(), + } + + return app, router, strategy, mock +} + +// PerformRequest runs an API request with an empty request body. +func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecorder { + req, _ := http.NewRequest(method, path, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + return w +} diff --git a/app/http/routes/health.go b/internal/api/health.go similarity index 78% rename from app/http/routes/health.go rename to internal/api/health.go index 755e6e2..8dc70a5 100644 --- a/app/http/routes/health.go +++ b/internal/api/health.go @@ -1,4 +1,4 @@ -package routes +package api import ( "context" @@ -31,3 +31,10 @@ func (h *Health) ServeHTTP(c *gin.Context) { c.String(statusCode, http.StatusText(statusCode)) } + +func Healthcheck(router *gin.RouterGroup, ctx context.Context) { + health := Health{} + health.SetDefaults() + health.WithContext(ctx) + router.GET("/health", health.ServeHTTP) +} diff --git a/internal/api/problemdetail.go b/internal/api/problemdetail.go new file mode 100644 index 0000000..f9e9826 --- /dev/null +++ b/internal/api/problemdetail.go @@ -0,0 +1,52 @@ +package api + +import ( + "github.com/sablierapp/sablier/app/sessions" + "github.com/sablierapp/sablier/app/theme" + "github.com/tniswong/go.rfcx/rfc7807" + "net/http" +) + +func ProblemError(e error) rfc7807.Problem { + return rfc7807.Problem{ + Type: "https://sablierapp.dev/#/errors?id=internal-error", + Title: http.StatusText(http.StatusInternalServerError), + Status: http.StatusInternalServerError, + Detail: e.Error(), + } +} + +func ProblemValidation(e error) rfc7807.Problem { + return rfc7807.Problem{ + Type: "https://sablierapp.dev/#/errors?id=validation-error", + Title: "Validation Failed", + Status: http.StatusBadRequest, + Detail: e.Error(), + } +} + +func ProblemGroupNotFound(e sessions.ErrGroupNotFound) rfc7807.Problem { + pb := rfc7807.Problem{ + Type: "https://sablierapp.dev/#/errors?id=group-not-found", + Title: "Group not found", + Status: http.StatusNotFound, + Detail: "The group you requested does not exist. It is possible that the group has not been scanned yet.", + } + _ = pb.Extend("availableGroups", e.AvailableGroups) + _ = pb.Extend("requestGroup", e.Group) + _ = pb.Extend("error", e.Error()) + return pb +} + +func ProblemThemeNotFound(e theme.ErrThemeNotFound) rfc7807.Problem { + pb := rfc7807.Problem{ + Type: "https://sablierapp.dev/#/errors?id=theme-not-found", + Title: "Theme not found", + Status: http.StatusNotFound, + Detail: "The theme you requested does not exist among the default themes and the custom themes (if any).", + } + _ = pb.Extend("availableTheme", e.AvailableThemes) + _ = pb.Extend("requestTheme", e.Theme) + _ = pb.Extend("error", e.Error()) + return pb +} diff --git a/internal/api/start_blocking.go b/internal/api/start_blocking.go new file mode 100644 index 0000000..315e13f --- /dev/null +++ b/internal/api/start_blocking.go @@ -0,0 +1,59 @@ +package api + +import ( + "errors" + "github.com/gin-gonic/gin" + "github.com/sablierapp/sablier/app/http/routes" + "github.com/sablierapp/sablier/app/http/routes/models" + "github.com/sablierapp/sablier/app/sessions" + "net/http" +) + +func StartBlocking(router *gin.RouterGroup, s *routes.ServeStrategy) { + router.GET("/strategies/blocking", func(c *gin.Context) { + request := models.BlockingRequest{ + Timeout: s.StrategyConfig.Blocking.DefaultTimeout, + } + + if err := c.ShouldBind(&request); err != nil { + AbortWithProblemDetail(c, ProblemValidation(err)) + return + } + + if len(request.Names) == 0 && request.Group == "" { + AbortWithProblemDetail(c, ProblemValidation(errors.New("'names' or 'group' query parameter must be set"))) + return + } + + if len(request.Names) > 0 && request.Group != "" { + AbortWithProblemDetail(c, ProblemValidation(errors.New("'names' and 'group' query parameters are both set, only one must be set"))) + return + } + + var sessionState *sessions.SessionState + var err error + if len(request.Names) > 0 { + sessionState, err = s.SessionsManager.RequestReadySession(c.Request.Context(), request.Names, request.SessionDuration, request.Timeout) + } else { + sessionState, err = s.SessionsManager.RequestReadySessionGroup(c.Request.Context(), request.Group, request.SessionDuration, request.Timeout) + var groupNotFoundError sessions.ErrGroupNotFound + if errors.As(err, &groupNotFoundError) { + AbortWithProblemDetail(c, ProblemGroupNotFound(groupNotFoundError)) + return + } + } + if err != nil { + AbortWithProblemDetail(c, ProblemError(err)) + return + } + + if sessionState == nil { + AbortWithProblemDetail(c, ProblemError(errors.New("session could not be created, please check logs for more details"))) + return + } + + AddSablierHeader(c, sessionState) + + c.JSON(http.StatusOK, map[string]interface{}{"session": sessionState}) + }) +} diff --git a/internal/api/start_blocking_test.go b/internal/api/start_blocking_test.go new file mode 100644 index 0000000..425893a --- /dev/null +++ b/internal/api/start_blocking_test.go @@ -0,0 +1,78 @@ +package api + +import ( + "errors" + "github.com/sablierapp/sablier/app/sessions" + "github.com/tniswong/go.rfcx/rfc7807" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" + "net/http" + "testing" +) + +func TestStartBlocking(t *testing.T) { + t.Run("StartBlockingInvalidBind", func(t *testing.T) { + app, router, strategy, _ := NewApiTest(t) + StartBlocking(router, strategy) + r := PerformRequest(app, "GET", "/api/strategies/blocking?timeout=invalid") + assert.Equal(t, http.StatusBadRequest, r.Code) + assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type")) + }) + t.Run("StartBlockingWithoutNamesOrGroup", func(t *testing.T) { + app, router, strategy, _ := NewApiTest(t) + StartBlocking(router, strategy) + r := PerformRequest(app, "GET", "/api/strategies/blocking") + assert.Equal(t, http.StatusBadRequest, r.Code) + assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type")) + }) + t.Run("StartBlockingWithNamesAndGroup", func(t *testing.T) { + app, router, strategy, _ := NewApiTest(t) + StartBlocking(router, strategy) + r := PerformRequest(app, "GET", "/api/strategies/blocking?names=test&group=test") + assert.Equal(t, http.StatusBadRequest, r.Code) + assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type")) + }) + t.Run("StartBlockingByNames", func(t *testing.T) { + app, router, strategy, m := NewApiTest(t) + StartBlocking(router, strategy) + m.EXPECT().RequestReadySession(gomock.Any(), []string{"test"}, gomock.Any(), gomock.Any()).Return(&sessions.SessionState{}, nil) + r := PerformRequest(app, "GET", "/api/strategies/blocking?names=test") + assert.Equal(t, http.StatusOK, r.Code) + assert.Equal(t, SablierStatusReady, r.Header().Get(SablierStatusHeader)) + }) + t.Run("StartBlockingByGroup", func(t *testing.T) { + app, router, strategy, m := NewApiTest(t) + StartBlocking(router, strategy) + m.EXPECT().RequestReadySessionGroup(gomock.Any(), "test", gomock.Any(), gomock.Any()).Return(&sessions.SessionState{}, nil) + r := PerformRequest(app, "GET", "/api/strategies/blocking?group=test") + assert.Equal(t, http.StatusOK, r.Code) + assert.Equal(t, SablierStatusReady, r.Header().Get(SablierStatusHeader)) + }) + t.Run("StartBlockingErrGroupNotFound", func(t *testing.T) { + app, router, strategy, m := NewApiTest(t) + StartBlocking(router, strategy) + m.EXPECT().RequestReadySessionGroup(gomock.Any(), "test", gomock.Any(), gomock.Any()).Return(nil, sessions.ErrGroupNotFound{ + Group: "test", + AvailableGroups: []string{"test1", "test2"}, + }) + r := PerformRequest(app, "GET", "/api/strategies/blocking?group=test") + assert.Equal(t, http.StatusNotFound, r.Code) + assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type")) + }) + t.Run("StartBlockingError", func(t *testing.T) { + app, router, strategy, m := NewApiTest(t) + StartBlocking(router, strategy) + m.EXPECT().RequestReadySessionGroup(gomock.Any(), "test", gomock.Any(), gomock.Any()).Return(nil, errors.New("unknown error")) + r := PerformRequest(app, "GET", "/api/strategies/blocking?group=test") + assert.Equal(t, http.StatusInternalServerError, r.Code) + assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type")) + }) + t.Run("StartBlockingSessionNil", func(t *testing.T) { + app, router, strategy, m := NewApiTest(t) + StartBlocking(router, strategy) + m.EXPECT().RequestReadySessionGroup(gomock.Any(), "test", gomock.Any(), gomock.Any()).Return(nil, nil) + r := PerformRequest(app, "GET", "/api/strategies/blocking?group=test") + assert.Equal(t, http.StatusInternalServerError, r.Code) + assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type")) + }) +} diff --git a/internal/api/start_dynamic.go b/internal/api/start_dynamic.go new file mode 100644 index 0000000..a04c54d --- /dev/null +++ b/internal/api/start_dynamic.go @@ -0,0 +1,132 @@ +package api + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "github.com/sablierapp/sablier/app/http/routes" + "github.com/sablierapp/sablier/app/http/routes/models" + "github.com/sablierapp/sablier/app/instance" + "github.com/sablierapp/sablier/app/sessions" + "github.com/sablierapp/sablier/app/theme" + log "github.com/sirupsen/logrus" + "sort" + "strconv" + "strings" +) + +func StartDynamic(router *gin.RouterGroup, s *routes.ServeStrategy) { + router.GET("/strategies/dynamic", func(c *gin.Context) { + request := models.DynamicRequest{ + Theme: s.StrategyConfig.Dynamic.DefaultTheme, + ShowDetails: s.StrategyConfig.Dynamic.ShowDetailsByDefault, + RefreshFrequency: s.StrategyConfig.Dynamic.DefaultRefreshFrequency, + SessionDuration: s.SessionsConfig.DefaultDuration, + } + + if err := c.ShouldBind(&request); err != nil { + AbortWithProblemDetail(c, ProblemValidation(err)) + return + } + + if len(request.Names) == 0 && request.Group == "" { + AbortWithProblemDetail(c, ProblemValidation(errors.New("'names' or 'group' query parameter must be set"))) + return + } + + if len(request.Names) > 0 && request.Group != "" { + AbortWithProblemDetail(c, ProblemValidation(errors.New("'names' and 'group' query parameters are both set, only one must be set"))) + return + } + + var sessionState *sessions.SessionState + var err error + if len(request.Names) > 0 { + sessionState, err = s.SessionsManager.RequestSession(request.Names, request.SessionDuration) + } else { + sessionState, err = s.SessionsManager.RequestSessionGroup(request.Group, request.SessionDuration) + var groupNotFoundError sessions.ErrGroupNotFound + if errors.As(err, &groupNotFoundError) { + AbortWithProblemDetail(c, ProblemGroupNotFound(groupNotFoundError)) + return + } + } + + if err != nil { + AbortWithProblemDetail(c, ProblemError(err)) + return + } + + if sessionState == nil { + AbortWithProblemDetail(c, ProblemError(errors.New("session could not be created, please check logs for more details"))) + return + } + + AddSablierHeader(c, sessionState) + + renderOptions := theme.Options{ + DisplayName: request.DisplayName, + ShowDetails: request.ShowDetails, + SessionDuration: request.SessionDuration, + RefreshFrequency: request.RefreshFrequency, + InstanceStates: sessionStateToRenderOptionsInstanceState(sessionState), + } + + buf := new(bytes.Buffer) + writer := bufio.NewWriter(buf) + err = s.Theme.Render(request.Theme, renderOptions, writer) + var themeNotFound theme.ErrThemeNotFound + if errors.As(err, &themeNotFound) { + AbortWithProblemDetail(c, ProblemThemeNotFound(themeNotFound)) + return + } + writer.Flush() + + c.Header("Cache-Control", "no-cache") + c.Header("Content-Type", "text/html") + c.Header("Content-Length", strconv.Itoa(buf.Len())) + c.Writer.Write(buf.Bytes()) + }) +} + +func sessionStateToRenderOptionsInstanceState(sessionState *sessions.SessionState) (instances []theme.Instance) { + if sessionState == nil { + log.Warnf("sessionStateToRenderOptionsInstanceState: sessionState is nil") + return + } + sessionState.Instances.Range(func(key, value any) bool { + if value != nil { + instances = append(instances, instanceStateToRenderOptionsRequestState(value.(sessions.InstanceState).Instance)) + } else { + log.Warnf("sessionStateToRenderOptionsInstanceState: sessionState instance is nil, key: %v", key) + } + + return true + }) + + sort.SliceStable(instances, func(i, j int) bool { + return strings.Compare(instances[i].Name, instances[j].Name) == -1 + }) + + return +} + +func instanceStateToRenderOptionsRequestState(instanceState *instance.State) theme.Instance { + + var err error + if instanceState.Message == "" { + err = nil + } else { + err = fmt.Errorf(instanceState.Message) + } + + return theme.Instance{ + Name: instanceState.Name, + Status: instanceState.Status, + CurrentReplicas: instanceState.CurrentReplicas, + DesiredReplicas: instanceState.DesiredReplicas, + Error: err, + } +} diff --git a/internal/api/start_dynamic_test.go b/internal/api/start_dynamic_test.go new file mode 100644 index 0000000..b97d99e --- /dev/null +++ b/internal/api/start_dynamic_test.go @@ -0,0 +1,78 @@ +package api + +import ( + "errors" + "github.com/sablierapp/sablier/app/sessions" + "github.com/tniswong/go.rfcx/rfc7807" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" + "net/http" + "testing" +) + +func TestStartDynamic(t *testing.T) { + t.Run("StartDynamicInvalidBind", func(t *testing.T) { + app, router, strategy, _ := NewApiTest(t) + StartDynamic(router, strategy) + r := PerformRequest(app, "GET", "/api/strategies/dynamic?timeout=invalid") + assert.Equal(t, http.StatusBadRequest, r.Code) + assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type")) + }) + t.Run("StartDynamicWithoutNamesOrGroup", func(t *testing.T) { + app, router, strategy, _ := NewApiTest(t) + StartDynamic(router, strategy) + r := PerformRequest(app, "GET", "/api/strategies/dynamic") + assert.Equal(t, http.StatusBadRequest, r.Code) + assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type")) + }) + t.Run("StartDynamicWithNamesAndGroup", func(t *testing.T) { + app, router, strategy, _ := NewApiTest(t) + StartDynamic(router, strategy) + r := PerformRequest(app, "GET", "/api/strategies/dynamic?names=test&group=test") + assert.Equal(t, http.StatusBadRequest, r.Code) + assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type")) + }) + t.Run("StartDynamicByNames", func(t *testing.T) { + app, router, strategy, m := NewApiTest(t) + StartDynamic(router, strategy) + m.EXPECT().RequestSession([]string{"test"}, gomock.Any()).Return(&sessions.SessionState{}, nil) + r := PerformRequest(app, "GET", "/api/strategies/dynamic?names=test") + assert.Equal(t, http.StatusOK, r.Code) + assert.Equal(t, SablierStatusReady, r.Header().Get(SablierStatusHeader)) + }) + t.Run("StartDynamicByGroup", func(t *testing.T) { + app, router, strategy, m := NewApiTest(t) + StartDynamic(router, strategy) + m.EXPECT().RequestSessionGroup("test", gomock.Any()).Return(&sessions.SessionState{}, nil) + r := PerformRequest(app, "GET", "/api/strategies/dynamic?group=test") + assert.Equal(t, http.StatusOK, r.Code) + assert.Equal(t, SablierStatusReady, r.Header().Get(SablierStatusHeader)) + }) + t.Run("StartDynamicErrGroupNotFound", func(t *testing.T) { + app, router, strategy, m := NewApiTest(t) + StartDynamic(router, strategy) + m.EXPECT().RequestSessionGroup("test", gomock.Any()).Return(nil, sessions.ErrGroupNotFound{ + Group: "test", + AvailableGroups: []string{"test1", "test2"}, + }) + r := PerformRequest(app, "GET", "/api/strategies/dynamic?group=test") + assert.Equal(t, http.StatusNotFound, r.Code) + assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type")) + }) + t.Run("StartDynamicError", func(t *testing.T) { + app, router, strategy, m := NewApiTest(t) + StartDynamic(router, strategy) + m.EXPECT().RequestSessionGroup("test", gomock.Any()).Return(nil, errors.New("unknown error")) + r := PerformRequest(app, "GET", "/api/strategies/dynamic?group=test") + assert.Equal(t, http.StatusInternalServerError, r.Code) + assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type")) + }) + t.Run("StartDynamicSessionNil", func(t *testing.T) { + app, router, strategy, m := NewApiTest(t) + StartDynamic(router, strategy) + m.EXPECT().RequestSessionGroup("test", gomock.Any()).Return(nil, nil) + r := PerformRequest(app, "GET", "/api/strategies/dynamic?group=test") + assert.Equal(t, http.StatusInternalServerError, r.Code) + assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type")) + }) +} diff --git a/internal/api/theme_list.go b/internal/api/theme_list.go new file mode 100644 index 0000000..b2553b9 --- /dev/null +++ b/internal/api/theme_list.go @@ -0,0 +1,18 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/sablierapp/sablier/app/http/routes" + "net/http" +) + +func ListThemes(router *gin.RouterGroup, s *routes.ServeStrategy) { + handler := func(c *gin.Context) { + c.JSON(http.StatusOK, map[string]interface{}{ + "themes": s.Theme.List(), + }) + } + + router.GET("/themes", handler) + router.GET("/dynamic/themes", handler) // Legacy path +} diff --git a/internal/server/logging.go b/internal/server/logging.go new file mode 100644 index 0000000..7c9bbbd --- /dev/null +++ b/internal/server/logging.go @@ -0,0 +1,47 @@ +package server + +import ( + "github.com/gin-gonic/gin" + sloggin "github.com/samber/slog-gin" + "log/slog" +) + +// StructuredLogger logs a gin HTTP request in JSON format. Allows to set the +// logger for testing purposes. +func StructuredLogger(logger *slog.Logger) gin.HandlerFunc { + if logger.Enabled(nil, slog.LevelDebug) { + return sloggin.NewWithConfig(logger, sloggin.Config{ + DefaultLevel: slog.LevelInfo, + ClientErrorLevel: slog.LevelWarn, + ServerErrorLevel: slog.LevelError, + + WithUserAgent: false, + WithRequestID: true, + WithRequestBody: false, + WithRequestHeader: false, + WithResponseBody: false, + WithResponseHeader: false, + WithSpanID: false, + WithTraceID: false, + + Filters: []sloggin.Filter{}, + }) + } + + return sloggin.NewWithConfig(logger, sloggin.Config{ + DefaultLevel: slog.LevelInfo, + ClientErrorLevel: slog.LevelWarn, + ServerErrorLevel: slog.LevelError, + + WithUserAgent: false, + WithRequestID: true, + WithRequestBody: false, + WithRequestHeader: false, + WithResponseBody: false, + WithResponseHeader: false, + WithSpanID: false, + WithTraceID: false, + + Filters: []sloggin.Filter{}, + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..3030f7e --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,26 @@ +package server + +import ( + "context" + "github.com/gin-gonic/gin" + "github.com/sablierapp/sablier/app/http/routes" + "github.com/sablierapp/sablier/config" + "github.com/sablierapp/sablier/internal/api" +) + +func registerRoutes(ctx context.Context, router *gin.Engine, serverConf config.Server, s *routes.ServeStrategy) { + // Enables automatic redirection if the current route cannot be matched but a + // handler for the path with (without) the trailing slash exists. + router.RedirectTrailingSlash = true + + base := router.Group(serverConf.BasePath) + + api.Healthcheck(base, ctx) + + // Create REST API router group. + APIv1 := base.Group("/api") + + api.StartDynamic(APIv1, s) + api.StartBlocking(APIv1, s) + api.ListThemes(APIv1, s) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..597128a --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,67 @@ +package server + +import ( + "context" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "github.com/sablierapp/sablier/app/http/routes" + "github.com/sablierapp/sablier/config" + "log/slog" + "net/http" + "time" +) + +func setupRouter(ctx context.Context, logger *slog.Logger, serverConf config.Server, s *routes.ServeStrategy) *gin.Engine { + r := gin.New() + + r.Use(StructuredLogger(logger)) + r.Use(gin.Recovery()) + + registerRoutes(ctx, r, serverConf, s) + + return r +} + +func Start(ctx context.Context, logger *slog.Logger, serverConf config.Server, s *routes.ServeStrategy) { + start := time.Now() + + if logger.Enabled(ctx, slog.LevelDebug) { + gin.SetMode(gin.DebugMode) + } else { + gin.SetMode(gin.ReleaseMode) + } + + r := setupRouter(ctx, logger, serverConf, s) + + var server *http.Server + server = &http.Server{ + Addr: fmt.Sprintf(":%d", serverConf.Port), + Handler: r, + } + + logger.Info("starting ", + slog.String("listen", server.Addr), + slog.Duration("startup", time.Since(start))) + + go StartHttp(server, logger) + + // Graceful web server shutdown. + <-ctx.Done() + logger.Info("server: shutting down") + err := server.Close() + if err != nil { + logger.Error("server: shutdown failed", slog.Any("error", err)) + } +} + +// StartHttp starts the Web server in http mode. +func StartHttp(s *http.Server, logger *slog.Logger) { + if err := s.ListenAndServe(); err != nil { + if errors.Is(err, http.ErrServerClosed) { + logger.Info("server: shutdown complete") + } else { + logger.Error("server failed to start", slog.Any("error", err)) + } + } +}