mirror of
https://github.com/sablierapp/sablier.git
synced 2025-12-21 13:23:03 +01:00
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.)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,4 +3,5 @@ sablier.yaml
|
|||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.wasm
|
*.wasm
|
||||||
kubeconfig.yaml
|
kubeconfig.yaml
|
||||||
|
.idea
|
||||||
3
Makefile
3
Makefile
@@ -17,6 +17,9 @@ GO_LDFLAGS := -s -w -X $(VPREFIX).Branch=$(GIT_BRANCH) -X $(VPREFIX).Version=$(V
|
|||||||
$(PLATFORMS):
|
$(PLATFORMS):
|
||||||
CGO_ENABLED=0 GOOS=$(os) GOARCH=$(arch) go build -trimpath -tags=nomsgpack -v -ldflags="${GO_LDFLAGS}" -o 'sablier_$(VERSION)_$(os)-$(arch)' .
|
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:
|
build:
|
||||||
go build -v .
|
go build -v .
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,11 @@
|
|||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
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/sessions"
|
||||||
"github.com/sablierapp/sablier/app/theme"
|
"github.com/sablierapp/sablier/app/theme"
|
||||||
"github.com/sablierapp/sablier/config"
|
"github.com/sablierapp/sablier/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var osDirFS = os.DirFS
|
|
||||||
|
|
||||||
type ServeStrategy struct {
|
type ServeStrategy struct {
|
||||||
Theme *theme.Themes
|
Theme *theme.Themes
|
||||||
|
|
||||||
@@ -29,152 +13,3 @@ type ServeStrategy struct {
|
|||||||
StrategyConfig config.Strategy
|
StrategyConfig config.Strategy
|
||||||
SessionsConfig config.Sessions
|
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,24 +4,26 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/sablierapp/sablier/app/discovery"
|
"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/docker"
|
||||||
"github.com/sablierapp/sablier/app/providers/dockerswarm"
|
"github.com/sablierapp/sablier/app/providers/dockerswarm"
|
||||||
"github.com/sablierapp/sablier/app/providers/kubernetes"
|
"github.com/sablierapp/sablier/app/providers/kubernetes"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/sablierapp/sablier/app/http"
|
|
||||||
"github.com/sablierapp/sablier/app/instance"
|
"github.com/sablierapp/sablier/app/instance"
|
||||||
"github.com/sablierapp/sablier/app/providers"
|
"github.com/sablierapp/sablier/app/providers"
|
||||||
"github.com/sablierapp/sablier/app/sessions"
|
"github.com/sablierapp/sablier/app/sessions"
|
||||||
"github.com/sablierapp/sablier/app/storage"
|
"github.com/sablierapp/sablier/app/storage"
|
||||||
"github.com/sablierapp/sablier/app/theme"
|
"github.com/sablierapp/sablier/app/theme"
|
||||||
"github.com/sablierapp/sablier/config"
|
"github.com/sablierapp/sablier/config"
|
||||||
|
"github.com/sablierapp/sablier/internal/server"
|
||||||
"github.com/sablierapp/sablier/pkg/tinykv"
|
"github.com/sablierapp/sablier/pkg/tinykv"
|
||||||
"github.com/sablierapp/sablier/version"
|
"github.com/sablierapp/sablier/version"
|
||||||
log "github.com/sirupsen/logrus"
|
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)
|
logLevel, err := log.ParseLevel(conf.Logging.Level)
|
||||||
|
|
||||||
@@ -30,6 +32,8 @@ func Start(conf config.Config) error {
|
|||||||
logLevel = log.InfoLevel
|
logLevel = log.InfoLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger := slog.Default()
|
||||||
|
|
||||||
log.SetLevel(logLevel)
|
log.SetLevel(logLevel)
|
||||||
|
|
||||||
log.Info(version.Info())
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
31
app/sessions/errors.go
Normal file
31
app/sessions/errors.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,9 +19,11 @@ import (
|
|||||||
|
|
||||||
const defaultRefreshFrequency = 2 * time.Second
|
const defaultRefreshFrequency = 2 * time.Second
|
||||||
|
|
||||||
|
//go:generate mockgen -package sessionstest -source=sessions_manager.go -destination=sessionstest/mocks_sessions_manager.go *
|
||||||
|
|
||||||
type Manager interface {
|
type Manager interface {
|
||||||
RequestSession(names []string, duration time.Duration) *SessionState
|
RequestSession(names []string, duration time.Duration) (*SessionState, error)
|
||||||
RequestSessionGroup(group string, duration time.Duration) *SessionState
|
RequestSessionGroup(group string, duration time.Duration) (*SessionState, error)
|
||||||
RequestReadySession(ctx context.Context, names []string, duration time.Duration, timeout 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)
|
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 {
|
func (s *SessionState) IsReady() bool {
|
||||||
ready := true
|
ready := true
|
||||||
|
|
||||||
|
if s.Instances == nil {
|
||||||
|
s.Instances = &sync.Map{}
|
||||||
|
}
|
||||||
|
|
||||||
s.Instances.Range(func(key, value interface{}) bool {
|
s.Instances.Range(func(key, value interface{}) bool {
|
||||||
state := value.(InstanceState)
|
state := value.(InstanceState)
|
||||||
if state.Error != nil || state.Instance.Status != instance.Ready {
|
if state.Error != nil || state.Instance.Status != instance.Ready {
|
||||||
@@ -132,10 +140,9 @@ func (s *SessionState) Status() string {
|
|||||||
return "not-ready"
|
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 {
|
if len(names) == 0 {
|
||||||
return nil
|
return nil, fmt.Errorf("names cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
@@ -160,19 +167,24 @@ func (s *SessionsManager) RequestSession(names []string, duration time.Duration)
|
|||||||
|
|
||||||
wg.Wait()
|
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 {
|
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 {
|
if len(names) == 0 {
|
||||||
return nil
|
return nil, fmt.Errorf("group has no member")
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.RequestSession(names, duration)
|
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) {
|
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() {
|
if session.IsReady() {
|
||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
@@ -241,7 +256,10 @@ func (s *SessionsManager) RequestReadySession(ctx context.Context, names []strin
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
session := s.RequestSession(names, duration)
|
session, err := s.RequestSession(names, duration)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
if session.IsReady() {
|
if session.IsReady() {
|
||||||
readiness <- session
|
readiness <- session
|
||||||
}
|
}
|
||||||
@@ -272,7 +290,13 @@ func (s *SessionsManager) RequestReadySessionGroup(ctx context.Context, group st
|
|||||||
return nil, fmt.Errorf("group is mandatory")
|
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 {
|
if len(names) == 0 {
|
||||||
return nil, fmt.Errorf("group has no member")
|
return nil, fmt.Errorf("group has no member")
|
||||||
|
|||||||
144
app/sessions/sessionstest/mocks_sessions_manager.go
Normal file
144
app/sessions/sessionstest/mocks_sessions_manager.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
14
app/theme/errors.go
Normal file
14
app/theme/errors.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
tpl := t.themes.Lookup(fmt.Sprintf("%s.html", name))
|
||||||
if tpl == nil {
|
if tpl == nil {
|
||||||
return fmt.Errorf("theme %s does not exist", name)
|
return ErrThemeNotFound{
|
||||||
|
Theme: name,
|
||||||
|
AvailableThemes: t.List(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tpl.Execute(writer, options)
|
return tpl.Execute(writer, options)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ var newStartCommand = func() *cobra.Command {
|
|||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
viper.Unmarshal(&conf)
|
viper.Unmarshal(&conf)
|
||||||
|
|
||||||
err := app.Start(conf)
|
err := app.Start(cmd.Context(), conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import log "github.com/sirupsen/logrus"
|
import (
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
type Logging struct {
|
type Logging struct {
|
||||||
Level string `mapstructure:"LEVEL" yaml:"level" default:"info"`
|
Level string `mapstructure:"LEVEL" yaml:"level" default:"info"`
|
||||||
@@ -8,6 +10,6 @@ type Logging struct {
|
|||||||
|
|
||||||
func NewLoggingConfig() Logging {
|
func NewLoggingConfig() Logging {
|
||||||
return Logging{
|
return Logging{
|
||||||
Level: log.InfoLevel.String(),
|
Level: slog.LevelInfo.String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
go.mod
18
go.mod
@@ -27,7 +27,7 @@ require (
|
|||||||
github.com/Microsoft/go-winio v0.6.1 // indirect
|
github.com/Microsoft/go-winio v0.6.1 // indirect
|
||||||
github.com/ajg/form v1.5.1 // indirect
|
github.com/ajg/form v1.5.1 // indirect
|
||||||
github.com/andybalholm/brotli v1.0.4 // 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/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
@@ -42,7 +42,7 @@ require (
|
|||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.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/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-logr/stdr v1.2.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-openapi/swag v0.22.4 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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/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/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/google/gnostic-models v0.6.8 // 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/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.17.2 // 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/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
github.com/mailru/easyjson v0.7.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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.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/sanity-io/litter v1.5.5 // indirect
|
||||||
github.com/sergi/go-diff v1.0.0 // indirect
|
github.com/sergi/go-diff v1.0.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.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/spf13/cast v1.6.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // 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/gojsondiff v1.0.0 // indirect
|
||||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // 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/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/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/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/atomic v1.9.0 // indirect
|
||||||
go.uber.org/multierr v1.9.0 // indirect
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
|
|||||||
20
go.sum
20
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/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 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
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 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
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/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 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.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 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
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=
|
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/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 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.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 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
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 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
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 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
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.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 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
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/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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
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/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 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
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 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo=
|
||||||
github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
|
github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
|
||||||
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
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/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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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=
|
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/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 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg=
|
||||||
go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ=
|
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 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 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 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY=
|
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 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik=
|
||||||
go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak=
|
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 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI=
|
||||||
go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A=
|
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 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw=
|
||||||
go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4=
|
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 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94=
|
||||||
go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A=
|
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=
|
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
|
|||||||
@@ -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.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
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.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 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
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.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.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.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 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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=
|
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 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
|
||||||
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
|
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.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 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
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.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.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.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 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
|
||||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||||
|
|||||||
18
internal/api/abort.go
Normal file
18
internal/api/abort.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
18
internal/api/api_response_headers.go
Normal file
18
internal/api/api_response_headers.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/api/api_test.go
Normal file
43
internal/api/api_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package routes
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -31,3 +31,10 @@ func (h *Health) ServeHTTP(c *gin.Context) {
|
|||||||
|
|
||||||
c.String(statusCode, http.StatusText(statusCode))
|
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)
|
||||||
|
}
|
||||||
52
internal/api/problemdetail.go
Normal file
52
internal/api/problemdetail.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
59
internal/api/start_blocking.go
Normal file
59
internal/api/start_blocking.go
Normal file
@@ -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})
|
||||||
|
})
|
||||||
|
}
|
||||||
78
internal/api/start_blocking_test.go
Normal file
78
internal/api/start_blocking_test.go
Normal file
@@ -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"))
|
||||||
|
})
|
||||||
|
}
|
||||||
132
internal/api/start_dynamic.go
Normal file
132
internal/api/start_dynamic.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
78
internal/api/start_dynamic_test.go
Normal file
78
internal/api/start_dynamic_test.go
Normal file
@@ -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"))
|
||||||
|
})
|
||||||
|
}
|
||||||
18
internal/api/theme_list.go
Normal file
18
internal/api/theme_list.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
47
internal/server/logging.go
Normal file
47
internal/server/logging.go
Normal file
@@ -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{},
|
||||||
|
})
|
||||||
|
}
|
||||||
26
internal/server/routes.go
Normal file
26
internal/server/routes.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
67
internal/server/server.go
Normal file
67
internal/server/server.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user