Centralize D&D candidate JSON codecs

This commit is contained in:
2026-07-25 13:05:02 +00:00
parent 8199d95dc1
commit d752c51aec
7 changed files with 100 additions and 95 deletions

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {