Skip to content
Merged

3.0.2 #192

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
## v3.0.1
_Released on 2026-03-11_

### New Features
1. Implemented automatic Content-Type header inference using a new `metadata` field, and renamed request `body` to `payload` across the replay feature.
2. Implemented a dedicated replay send options component for execution source selection and configurable localhost host replacement.

### Docker Images
Use one of these commands to pull the Docker image:

```bash
# Pull specific version
docker pull ghcr.io/yogasw/beo-echo:3.0.1

# Pull latest version
docker pull ghcr.io/yogasw/beo-echo:latest
```

---
*This summary was automatically generated by Gemini AI*

## v3.0.0
_Released on 2026-03-10_

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.0.1
3.0.2
8 changes: 8 additions & 0 deletions backend/src/database/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,14 @@ type Replay struct {
Headers string `gorm:"type:text" json:"headers"` // Headers as JSON string (key-value pairs)
Payload string `gorm:"type:text" json:"payload"` // Request payload/body

// History & Response Details
ParentID *string `gorm:"type:string;index" json:"parent_id"` // Optional parent replay ID (for saved responses/checkpoints)
IsResponse bool `gorm:"default:false" json:"is_response"` // Whether this Replay is a response
ResponseStatus int `json:"response_status"` // HTTP status code, or equivalent (0 if NA)
ResponseMeta string `gorm:"type:text" json:"response_meta"` // JSON string for protocol specific response meta (headers, trailers, etc)
ResponseBody string `gorm:"type:text" json:"response_body"` // Raw string/JSON. NOTE: Loaded on-demand in UI
LatencyMS int `json:"latency_ms"` // Execution time

CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` // Timestamp of creation
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` // Timestamp of last update
}
Expand Down
96 changes: 75 additions & 21 deletions backend/src/database/repositories/replay_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,36 +19,69 @@ func NewReplayRepository(db *gorm.DB) *replayRepository {
return &replayRepository{db: db}
}

// FindByProjectID finds all replays for a specific project
func (r *replayRepository) FindByProjectID(ctx context.Context, projectID string) ([]database.Replay, error) {
var replays []database.Replay
// ReplayListRow is a minimal projection used by FindByProjectID.
// GORM maps field names to column names automatically — add fields here when you
// need more data in the list view without touching the query string.
type ReplayListRow struct {
ID string `json:"id"`
Name string `json:"name"`
ProjectID string `json:"project_id"`
FolderID *string `json:"folder_id"`
ParentID *string `json:"parent_id"`
IsResponse bool `json:"is_response"`
Method string `json:"method"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}

// FindByProjectID finds all replays for a specific project.
// Returns only the fields defined in ReplayListRow — no payload, headers, body, etc.
// Full data is loaded on demand via FindByID.
func (r *replayRepository) FindByProjectID(ctx context.Context, projectID string) ([]ReplayListRow, error) {
var rows []ReplayListRow

err := r.db.WithContext(ctx).
Model(&database.Replay{}).
Where("project_id = ?", projectID).
Order("created_at DESC").
Find(&replays).Error
Scan(&rows).Error

if err != nil {
return nil, err
}

return replays, nil
return rows, nil
}

// ReplayFolderListRow is a minimal projection used by FindFoldersByProjectID.
// Add fields here when you need more folder data in the list view — GORM auto-selects
// matching columns without changing the query string.
type ReplayFolderListRow struct {
ID string `json:"id"`
Name string `json:"name"`
ProjectID string `json:"project_id"`
ParentID *string `json:"parent_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}

// FindFoldersByProjectID finds all replay folders for a specific project
func (r *replayRepository) FindFoldersByProjectID(ctx context.Context, projectID string) ([]database.ReplayFolder, error) {
var folders []database.ReplayFolder
// FindFoldersByProjectID finds all replay folders for a specific project.
// Returns only the fields defined in ReplayFolderListRow — doc and nested relations are excluded.
// Full folder data (including doc) is loaded on demand via a dedicated get endpoint.
func (r *replayRepository) FindFoldersByProjectID(ctx context.Context, projectID string) ([]ReplayFolderListRow, error) {
var rows []ReplayFolderListRow

err := r.db.WithContext(ctx).
Model(&database.ReplayFolder{}).
Where("project_id = ?", projectID).
Order("name ASC").
Find(&folders).Error
Scan(&rows).Error

if err != nil {
return nil, err
}

return folders, nil
return rows, nil
}

// FindByID finds a replay by its ID
Expand All @@ -69,6 +102,22 @@ func (r *replayRepository) FindByID(ctx context.Context, id string) (*database.R
return &replay, nil
}

// FindFolderByID finds a single replay folder by ID scoped to a project.
// Returns the full database.ReplayFolder model (for updates, not list views).
func (r *replayRepository) FindFolderByID(ctx context.Context, projectID string, folderID string) (*database.ReplayFolder, error) {
var folder database.ReplayFolder

err := r.db.WithContext(ctx).
Where("id = ? AND project_id = ?", folderID, projectID).
First(&folder).Error

if err != nil {
return nil, err
}

return &folder, nil
}

// Create creates a new replay
func (r *replayRepository) Create(ctx context.Context, replay *database.Replay) error {
return r.db.WithContext(ctx).Create(replay).Error
Expand Down Expand Up @@ -135,21 +184,26 @@ func (r *replayRepository) Update(ctx context.Context, replay *database.Replay)
return r.db.WithContext(ctx).Save(replay).Error
}

// Delete deletes a replay by ID
// Delete deletes a replay by ID, and any children (histories)
func (r *replayRepository) Delete(ctx context.Context, id string) error {
result := r.db.WithContext(ctx).
Where("id = ?", id).
Delete(&database.Replay{})
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Delete any children checking this replay as a parent first
if err := tx.Where("parent_id = ?", id).Delete(&database.Replay{}).Error; err != nil {
return err
}

if result.Error != nil {
return result.Error
}
result := tx.Where("id = ?", id).Delete(&database.Replay{})

if result.RowsAffected == 0 {
return errors.New("replay not found")
}
if result.Error != nil {
return result.Error
}

return nil
if result.RowsAffected == 0 {
return errors.New("replay not found")
}

return nil
})
}

// CreateRequestLog creates a new request log entry
Expand Down
13 changes: 0 additions & 13 deletions backend/src/replay/handlers/create_replay.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,6 @@ func (s *replayHandler) CreateReplayHandler(c *gin.Context) {
return
}

log.Info().
Str("project_id", projectID).
Str("name", req.Name).
Str("protocol", req.Protocol).
Str("method", req.Method).
Msg("handling create replay request")

replay, err := s.service.CreateReplay(c.Request.Context(), projectID, req)
if err != nil {
log.Error().
Expand All @@ -48,12 +41,6 @@ func (s *replayHandler) CreateReplayHandler(c *gin.Context) {
return
}

log.Info().
Str("replay_id", replay.ID).
Str("project_id", projectID).
Str("name", req.Name).
Msg("successfully created replay")

c.JSON(http.StatusCreated, gin.H{
"replay": replay,
"message": "Replay created successfully",
Expand Down
10 changes: 0 additions & 10 deletions backend/src/replay/handlers/delete_replay.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,6 @@ func (s *replayHandler) DeleteReplayHandler(c *gin.Context) {
return
}

log.Info().
Str("project_id", projectID).
Str("replay_id", replayID).
Msg("handling delete replay request")

// First verify the replay belongs to the project
replay, err := s.service.GetReplay(c.Request.Context(), replayID)
if err != nil {
Expand Down Expand Up @@ -60,11 +55,6 @@ func (s *replayHandler) DeleteReplayHandler(c *gin.Context) {
return
}

log.Info().
Str("project_id", projectID).
Str("replay_id", replayID).
Msg("successfully deleted replay")

c.JSON(http.StatusOK, gin.H{
"message": "Replay deleted successfully",
})
Expand Down
29 changes: 29 additions & 0 deletions backend/src/replay/handlers/get_folder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package handlers

import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)

// GetFolderHandler handles GET /projects/{projectId}/replays/folder/{folderId}
func (s *replayHandler) GetFolderHandler(c *gin.Context) {
log := zerolog.Ctx(c.Request.Context())
projectID := c.Param("projectId")
folderID := c.Param("folderId")

if projectID == "" || folderID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project ID and folder ID are required"})
return
}

folder, err := s.service.GetFolder(c.Request.Context(), projectID, folderID)
if err != nil {
log.Error().Err(err).Msg("failed to get folder")
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, gin.H{"folder": folder})
}
11 changes: 0 additions & 11 deletions backend/src/replay/handlers/get_replay.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,6 @@ func (s *replayHandler) GetReplayHandler(c *gin.Context) {
return
}

log.Info().
Str("project_id", projectID).
Str("replay_id", replayID).
Msg("handling get replay request")

replay, err := s.service.GetReplay(c.Request.Context(), replayID)
if err != nil {
log.Error().
Expand All @@ -49,12 +44,6 @@ func (s *replayHandler) GetReplayHandler(c *gin.Context) {
return
}

log.Info().
Str("project_id", projectID).
Str("replay_id", replayID).
Str("name", replay.Name).
Msg("successfully retrieved replay")

c.JSON(http.StatusOK, gin.H{
"replay": replay,
})
Expand Down
15 changes: 0 additions & 15 deletions backend/src/replay/handlers/get_replay_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,6 @@ func (s *replayHandler) GetReplayLogsHandler(c *gin.Context) {
replayID = &replayIDParam
}

log.Info().
Str("project_id", projectID).
Str("replay_id", func() string {
if replayID != nil {
return *replayID
}
return "all"
}()).
Msg("handling get replay logs request")

logs, err := s.service.GetReplayLogs(c.Request.Context(), projectID, replayID)
if err != nil {
log.Error().
Expand All @@ -45,11 +35,6 @@ func (s *replayHandler) GetReplayLogsHandler(c *gin.Context) {
return
}

log.Info().
Str("project_id", projectID).
Int("count", len(logs)).
Msg("successfully retrieved replay logs")

c.JSON(http.StatusOK, gin.H{
"logs": logs,
"count": len(logs),
Expand Down
4 changes: 0 additions & 4 deletions backend/src/replay/handlers/list_replays.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@ func (s *replayHandler) ListReplaysHandler(c *gin.Context) {
return
}

log.Info().
Str("project_id", projectID).
Msg("handling list replays request")

result, err := s.service.ListReplays(c.Request.Context(), projectID)
if err != nil {
log.Error().
Expand Down
11 changes: 0 additions & 11 deletions backend/src/replay/handlers/update_replay.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,6 @@ func (s *replayHandler) UpdateReplayHandler(c *gin.Context) {
return
}

log.Info().
Str("project_id", projectID).
Str("replay_id", replayID).
Msg("handling update replay request")

// First verify the replay belongs to the project
existingReplay, err := s.service.GetReplay(c.Request.Context(), replayID)
if err != nil {
Expand Down Expand Up @@ -73,12 +68,6 @@ func (s *replayHandler) UpdateReplayHandler(c *gin.Context) {
return
}

log.Info().
Str("project_id", projectID).
Str("replay_id", replayID).
Str("name", replay.Name).
Msg("successfully updated replay")

c.JSON(http.StatusOK, gin.H{
"replay": replay,
"message": "Replay updated successfully",
Expand Down
15 changes: 15 additions & 0 deletions backend/src/replay/services/create_replay.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ func (s *ReplayService) CreateReplay(ctx context.Context, projectID string, req
Name: name,
ProjectID: projectID,
FolderID: req.FolderID,
ParentID: req.ParentID,
IsResponse: req.IsResponse,
Protocol: database.ReplayProtocol(strings.ToLower(req.Protocol)),
Method: strings.ToUpper(req.Method),
Url: req.Url,
Expand All @@ -78,6 +80,19 @@ func (s *ReplayService) CreateReplay(ctx context.Context, projectID string, req
Config: string(configJSON),
}

if req.ResponseStatus != nil {
replay.ResponseStatus = *req.ResponseStatus
}
if req.ResponseMeta != nil {
replay.ResponseMeta = *req.ResponseMeta
}
if req.ResponseBody != nil {
replay.ResponseBody = *req.ResponseBody
}
if req.LatencyMS != nil {
replay.LatencyMS = *req.LatencyMS
}

err = s.repo.Create(ctx, replay)
if err != nil {
log.Error().
Expand Down
Loading
Loading