Keep each model's parent out of its JSON (closes #177)
check / check (push) Waiting to run

Every reference from a model to the record it belongs to is now
json:"-", so the webhook and target models (and the other parent and
child pairs in internal/database) can no longer marshal each other in
a loop. Preloading maps associations without reading json tags, so it
still fills these references; a new test preloads a webhook with its targets and
entrypoints and a target with its webhook, checks the references are
filled, and checks the marshalled JSON leaves them out.

Model: opus-5-5
This commit is contained in:
2026-10-02 11:08:45 +00:00
parent b14b27b78b
commit d453f76fb3
8 changed files with 89 additions and 16 deletions
+3 -2
View File
@@ -15,6 +15,7 @@ type APIKey struct {
Description string `json:"description"` Description string `json:"description"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
// Relations // Relations. No model marshals the record it belongs to:
User User `json:"user,omitzero"` // User.APIKeys leads back here, and the JSON could loop.
User User `json:"-"`
} }
+5 -3
View File
@@ -56,8 +56,10 @@ type Delivery struct {
// the index. // the index.
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"` FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
// Relations // Relations. No model marshals the record it belongs to:
Event Event `json:"event,omitzero"` // Event.Deliveries and Target.Deliveries lead back here, and the
Target Target `json:"target,omitzero"` // JSON could loop.
Event Event `json:"-"`
Target Target `json:"-"`
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"` DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
} }
+3 -2
View File
@@ -23,6 +23,7 @@ type DeliveryResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Duration int64 `json:"durationMs"` // Duration in milliseconds Duration int64 `json:"durationMs"` // Duration in milliseconds
// Relations // Relations. No model marshals the record it belongs to:
Delivery Delivery `json:"delivery,omitzero"` // Delivery.DeliveryResults leads back here, and the JSON could loop.
Delivery Delivery `json:"-"`
} }
+3 -2
View File
@@ -15,6 +15,7 @@ type Entrypoint struct {
Description string `json:"description"` Description string `json:"description"`
Active bool `gorm:"default:true" json:"active"` Active bool `gorm:"default:true" json:"active"`
// Relations // Relations. No model marshals the record it belongs to:
Webhook Webhook `json:"webhook,omitzero"` // Webhook.Entrypoints leads back here, and the JSON could loop.
Webhook Webhook `json:"-"`
} }
+4 -3
View File
@@ -44,8 +44,9 @@ type Event struct {
// kept as the record of where the copy came from either way. // kept as the record of where the copy came from either way.
ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"` ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
// Relations // Relations. No model marshals the record it belongs to, so
Webhook Webhook `json:"webhook,omitzero"` // Webhook and Entrypoint are left out of the JSON.
Entrypoint Entrypoint `json:"entrypoint,omitzero"` Webhook Webhook `json:"-"`
Entrypoint Entrypoint `json:"-"`
Deliveries []Delivery `json:"deliveries,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"`
} }
+65
View File
@@ -0,0 +1,65 @@
package database_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// TestPreloadedModelsMarshalWithoutTheirParent pins that a child's
// reference to the record it belongs to is left out of the JSON, so a
// webhook and its targets cannot marshal each other in a loop, and that
// GORM still preloads that reference, since it ignores json tags.
func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
stored := database.Webhook{
UserID: uuid.New().String(),
Name: testWebhookName,
Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}},
Targets: []database.Target{{
Name: "log",
Type: database.TargetTypeLog,
}},
}
require.NoError(t, db.Create(&stored).Error)
entrypointID := stored.Entrypoints[0].ID
targetID := stored.Targets[0].ID
var webhook database.Webhook
require.NoError(t, db.
Preload("Entrypoints.Webhook").
Preload("Targets.Webhook").
First(&webhook, "id = ?", stored.ID).Error)
require.Len(t, webhook.Entrypoints, 1)
require.Len(t, webhook.Targets, 1)
assert.Equal(t, stored.ID, webhook.Entrypoints[0].Webhook.ID)
assert.Equal(t, stored.ID, webhook.Targets[0].Webhook.ID)
encoded := marshalModel(t, webhook)
assert.Contains(t, encoded, entrypointID)
assert.Contains(t, encoded, targetID)
assert.NotContains(t, encoded, `"webhook":`)
var target database.Target
require.NoError(t, db.
Preload("Webhook").
First(&target, "id = ?", targetID).Error)
assert.Equal(t, stored.ID, target.Webhook.ID)
encoded = marshalModel(t, target)
assert.Contains(t, encoded, stored.ID)
assert.NotContains(t, encoded, `"webhook":`)
}
+3 -2
View File
@@ -34,7 +34,8 @@ type Target struct {
MaxRetries int `json:"maxRetries,omitempty"` MaxRetries int `json:"maxRetries,omitempty"`
MaxQueueSize int `json:"maxQueueSize,omitempty"` MaxQueueSize int `json:"maxQueueSize,omitempty"`
// Relations // Relations. No model marshals the record it belongs to:
Webhook Webhook `json:"webhook,omitzero"` // Webhook.Targets leads back here, and the JSON could loop.
Webhook Webhook `json:"-"`
Deliveries []Delivery `json:"deliveries,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"`
} }
+3 -2
View File
@@ -66,8 +66,9 @@ type Webhook struct {
// must equal DefaultRetentionDays. // must equal DefaultRetentionDays.
RetentionDays int `gorm:"default:30" json:"retentionDays"` RetentionDays int `gorm:"default:30" json:"retentionDays"`
// Relations // Relations. No model marshals the record it belongs to:
User User `json:"user,omitzero"` // User.Webhooks leads back here, and the JSON could loop.
User User `json:"-"`
Entrypoints []Entrypoint `json:"entrypoints,omitempty"` Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
Targets []Target `json:"targets,omitempty"` Targets []Target `json:"targets,omitempty"`
} }