diff --git a/.gitignore b/.gitignore index 2c6946f7..ac139d03 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 fb7c3c6a..0248ac99 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 e8e0ae4a..00000000 --- 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 84674581..c9a763e3 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 4a0e6df2..00000000 --- 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 adf1e1af..00000000 --- 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 abf1031c..00000000 --- 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 e199e635..35c57d63 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 00000000..0044d0d8 --- /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 af8ab0bc..a9ccb52a 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 00000000..4438c9a6 --- /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 00000000..ea1d15f3 --- /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 2f0596e3..6adddd4a 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 ce843981..c0b085da 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 544c30d8..f62d2a7d 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 f429c77e..b2ed2145 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 fa256895..c88b7aee 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 3521d202..9ac8a0bd 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 00000000..5532d902 --- /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 00000000..9e5422c0 --- /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 00000000..f668c5cf --- /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 755e6e25..8dc70a54 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 00000000..f9e98260 --- /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 00000000..315e13fe --- /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 00000000..425893a4 --- /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 00000000..a04c54d7 --- /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 00000000..b97d99ee --- /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 00000000..b2553b9f --- /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 00000000..7c9bbbdd --- /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 00000000..3030f7ee --- /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 00000000..597128a9 --- /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)) + } + } +}