From d752c51aecddc3a6d6209b2ccd94d14e606c0f0f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 25 Jul 2026 13:05:02 +0000 Subject: [PATCH] Centralize D&D candidate JSON codecs --- .../dnd/codec/candidatejson/candidate_json.go | 38 +++++++++++++++ .../candidatejson/candidate_json_test.go | 47 +++++++++++++++++++ .../modules/dnd/codec/combatturns/codec.go | 22 ++------- .../dnd/codec/npcinteractions/codec.go | 22 ++------- internal/modules/dnd/codec/npcs/codec.go | 22 ++------- .../dnd/codec/scenedescriptions/codec.go | 22 ++------- internal/modules/dnd/codec/spells/codec.go | 22 ++------- 7 files changed, 100 insertions(+), 95 deletions(-) create mode 100644 internal/modules/dnd/codec/candidatejson/candidate_json.go create mode 100644 internal/modules/dnd/codec/candidatejson/candidate_json_test.go diff --git a/internal/modules/dnd/codec/candidatejson/candidate_json.go b/internal/modules/dnd/codec/candidatejson/candidate_json.go new file mode 100644 index 0000000..9d08e90 --- /dev/null +++ b/internal/modules/dnd/codec/candidatejson/candidate_json.go @@ -0,0 +1,38 @@ +// Package candidatejson provides strict JSON mechanics for D&D artifact +// candidates before artifact-specific validation. +package candidatejson + +import ( + "bytes" + "encoding/json" + "fmt" + "io" +) + +// EncodeCandidate encodes a typed candidate with an artifact-specific error +// label. +func EncodeCandidate[T any](label string, value T) ([]byte, error) { + content, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode %s: %w", label, err) + } + return content, nil +} + +// DecodeCandidate decodes exactly one typed JSON value while rejecting unknown +// fields and trailing values with an artifact-specific error label. +func DecodeCandidate[T any](label string, content []byte) (T, error) { + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + var value T + if err := decoder.Decode(&value); err != nil { + var zero T + return zero, fmt.Errorf("decode %s: %w", label, err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + var zero T + return zero, fmt.Errorf("decode %s: multiple JSON values", label) + } + return value, nil +} diff --git a/internal/modules/dnd/codec/candidatejson/candidate_json_test.go b/internal/modules/dnd/codec/candidatejson/candidate_json_test.go new file mode 100644 index 0000000..e9761ac --- /dev/null +++ b/internal/modules/dnd/codec/candidatejson/candidate_json_test.go @@ -0,0 +1,47 @@ +package candidatejson + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +type testCandidate struct { + Items []string `json:"items"` +} + +func TestCandidateJSON(t *testing.T) { + input := testCandidate{Items: []string{"one", "two"}} + content, err := EncodeCandidate("test candidate", input) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + if !json.Valid(content) { + t.Fatalf("EncodeCandidate() = %q, want JSON", content) + } + for _, test := range []struct { + name string + content []byte + want testCandidate + wantErr string + }{ + {name: "typed round trip", content: content, want: input}, + {name: "unknown field", content: []byte(`{"items":[],"unexpected":true}`), wantErr: "unknown field"}, + {name: "malformed JSON", content: []byte(`{"items":`), wantErr: "decode test candidate"}, + {name: "trailing value", content: []byte(`{"items":[]} {}`), wantErr: "multiple JSON values"}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := DecodeCandidate[testCandidate]("test candidate", test.content) + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("DecodeCandidate() error = %v, want %q", err, test.wantErr) + } + return + } + if err != nil || !reflect.DeepEqual(got, test.want) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", got, err, test.want) + } + }) + } +} diff --git a/internal/modules/dnd/codec/combatturns/codec.go b/internal/modules/dnd/codec/combatturns/codec.go index df2f5dd..ba79bb7 100644 --- a/internal/modules/dnd/codec/combatturns/codec.go +++ b/internal/modules/dnd/codec/combatturns/codec.go @@ -1,15 +1,13 @@ package combatturns import ( - "bytes" "embed" - "encoding/json" "fmt" - "io" "strings" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" ) const ( @@ -59,11 +57,7 @@ func (c *Codec) Encode(value dnd.CombatTurnList) ([]byte, error) { // EncodeCandidate provides the durable representation before typed validators // have approved a value. func (c *Codec) EncodeCandidate(value dnd.CombatTurnList) ([]byte, error) { - content, err := json.Marshal(value) - if err != nil { - return nil, fmt.Errorf("encode dnd combat turn list: %w", err) - } - return content, nil + return candidatejson.EncodeCandidate("dnd combat turn list", value) } func (c *Codec) Decode(content []byte) (dnd.CombatTurnList, error) { @@ -80,17 +74,7 @@ func (c *Codec) Decode(content []byte) (dnd.CombatTurnList, error) { // DecodeCandidate reads one strict durable JSON value before semantic // validators have approved it. func (c *Codec) DecodeCandidate(content []byte) (dnd.CombatTurnList, error) { - decoder := json.NewDecoder(bytes.NewReader(content)) - decoder.DisallowUnknownFields() - var value dnd.CombatTurnList - if err := decoder.Decode(&value); err != nil { - return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: %w", err) - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: multiple JSON values") - } - return value, nil + return candidatejson.DecodeCandidate[dnd.CombatTurnList]("dnd combat turn list", content) } func validate(value dnd.CombatTurnList) error { diff --git a/internal/modules/dnd/codec/npcinteractions/codec.go b/internal/modules/dnd/codec/npcinteractions/codec.go index 5052240..d72528e 100644 --- a/internal/modules/dnd/codec/npcinteractions/codec.go +++ b/internal/modules/dnd/codec/npcinteractions/codec.go @@ -2,15 +2,13 @@ package npcinteractions import ( - "bytes" "embed" - "encoding/json" "fmt" - "io" "strings" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" ) const ( @@ -60,11 +58,7 @@ func (c *Codec) Encode(value dnd.NPCInteractionList) ([]byte, error) { // EncodeCandidate provides the durable representation before semantic // validators have approved a value. func (c *Codec) EncodeCandidate(value dnd.NPCInteractionList) ([]byte, error) { - content, err := json.Marshal(value) - if err != nil { - return nil, fmt.Errorf("encode dnd npc interaction list: %w", err) - } - return content, nil + return candidatejson.EncodeCandidate("dnd npc interaction list", value) } func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) { @@ -81,17 +75,7 @@ func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) { // DecodeCandidate reads one strict durable JSON value before semantic // validators have approved it. func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCInteractionList, error) { - decoder := json.NewDecoder(bytes.NewReader(content)) - decoder.DisallowUnknownFields() - var value dnd.NPCInteractionList - if err := decoder.Decode(&value); err != nil { - return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err) - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: multiple JSON values") - } - return value, nil + return candidatejson.DecodeCandidate[dnd.NPCInteractionList]("dnd npc interaction list", content) } func validate(value dnd.NPCInteractionList) error { diff --git a/internal/modules/dnd/codec/npcs/codec.go b/internal/modules/dnd/codec/npcs/codec.go index 26646cb..0e66fbb 100644 --- a/internal/modules/dnd/codec/npcs/codec.go +++ b/internal/modules/dnd/codec/npcs/codec.go @@ -1,15 +1,13 @@ package npcs import ( - "bytes" "embed" - "encoding/json" "fmt" - "io" "strings" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" ) @@ -60,11 +58,7 @@ func (c *Codec) Encode(value dnd.NPCList) ([]byte, error) { // EncodeCandidate provides the durable representation before semantic // validators have approved a value. func (c *Codec) EncodeCandidate(value dnd.NPCList) ([]byte, error) { - content, err := json.Marshal(value) - if err != nil { - return nil, fmt.Errorf("encode dnd npc list: %w", err) - } - return content, nil + return candidatejson.EncodeCandidate("dnd npc list", value) } func (c *Codec) Decode(content []byte) (dnd.NPCList, error) { @@ -81,17 +75,7 @@ func (c *Codec) Decode(content []byte) (dnd.NPCList, error) { // DecodeCandidate reads one strict durable JSON value before semantic // validators have approved it. func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCList, error) { - decoder := json.NewDecoder(bytes.NewReader(content)) - decoder.DisallowUnknownFields() - var value dnd.NPCList - if err := decoder.Decode(&value); err != nil { - return dnd.NPCList{}, fmt.Errorf("decode dnd npc list: %w", err) - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return dnd.NPCList{}, fmt.Errorf("decode dnd npc list: multiple JSON values") - } - return value, nil + return candidatejson.DecodeCandidate[dnd.NPCList]("dnd npc list", content) } func validate(value dnd.NPCList) error { diff --git a/internal/modules/dnd/codec/scenedescriptions/codec.go b/internal/modules/dnd/codec/scenedescriptions/codec.go index de7e578..a15f16f 100644 --- a/internal/modules/dnd/codec/scenedescriptions/codec.go +++ b/internal/modules/dnd/codec/scenedescriptions/codec.go @@ -1,15 +1,13 @@ package scenedescriptions import ( - "bytes" "embed" - "encoding/json" "fmt" - "io" "strings" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" ) const ( @@ -59,11 +57,7 @@ func (c *Codec) Encode(value dnd.SceneDescriptionList) ([]byte, error) { // EncodeCandidate provides the durable representation before typed validators // have approved a value. func (c *Codec) EncodeCandidate(value dnd.SceneDescriptionList) ([]byte, error) { - content, err := json.Marshal(value) - if err != nil { - return nil, fmt.Errorf("encode dnd scene description list: %w", err) - } - return content, nil + return candidatejson.EncodeCandidate("dnd scene description list", value) } func (c *Codec) Decode(content []byte) (dnd.SceneDescriptionList, error) { @@ -80,17 +74,7 @@ func (c *Codec) Decode(content []byte) (dnd.SceneDescriptionList, error) { // DecodeCandidate reads one strict durable JSON value before typed validators // have approved it. func (c *Codec) DecodeCandidate(content []byte) (dnd.SceneDescriptionList, error) { - decoder := json.NewDecoder(bytes.NewReader(content)) - decoder.DisallowUnknownFields() - var value dnd.SceneDescriptionList - if err := decoder.Decode(&value); err != nil { - return dnd.SceneDescriptionList{}, fmt.Errorf("decode dnd scene description list: %w", err) - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return dnd.SceneDescriptionList{}, fmt.Errorf("decode dnd scene description list: multiple JSON values") - } - return value, nil + return candidatejson.DecodeCandidate[dnd.SceneDescriptionList]("dnd scene description list", content) } func validate(value dnd.SceneDescriptionList) error { diff --git a/internal/modules/dnd/codec/spells/codec.go b/internal/modules/dnd/codec/spells/codec.go index 2e118d3..f27735f 100644 --- a/internal/modules/dnd/codec/spells/codec.go +++ b/internal/modules/dnd/codec/spells/codec.go @@ -1,15 +1,13 @@ package spells import ( - "bytes" "embed" - "encoding/json" "fmt" - "io" "strings" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" ) const ( @@ -54,11 +52,7 @@ func (c *Codec) Encode(value dnd.SpellList) ([]byte, error) { // EncodeCandidate provides the same stable representation before typed // validators have approved a value. func (c *Codec) EncodeCandidate(value dnd.SpellList) ([]byte, error) { - content, err := json.Marshal(value) - if err != nil { - return nil, fmt.Errorf("encode dnd spell list: %w", err) - } - return content, nil + return candidatejson.EncodeCandidate("dnd spell list", value) } func (c *Codec) Decode(content []byte) (dnd.SpellList, error) { @@ -75,17 +69,7 @@ func (c *Codec) Decode(content []byte) (dnd.SpellList, error) { // DecodeCandidate reads the durable representation before semantic validators // have approved it. func (c *Codec) DecodeCandidate(content []byte) (dnd.SpellList, error) { - decoder := json.NewDecoder(bytes.NewReader(content)) - decoder.DisallowUnknownFields() - var value dnd.SpellList - if err := decoder.Decode(&value); err != nil { - return dnd.SpellList{}, fmt.Errorf("decode dnd spell list: %w", err) - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return dnd.SpellList{}, fmt.Errorf("decode dnd spell list: multiple JSON values") - } - return value, nil + return candidatejson.DecodeCandidate[dnd.SpellList]("dnd spell list", content) } func validate(value dnd.SpellList) error {