I'd make a custom JSON codec that inserts _links
field at the end of the JSON jenerated for the payload.
Marshaller.
type Link struct {
Href string `json:"href"`
}
type Linkset map[string]Link
type HAL[T any] struct {
Payload T
Links Linkset `json:"_links,omitempty"`
}
func (h HAL[T]) MarshalJSON() ([]byte, error) {
payloadJson, err := json.Marshal(h.Payload)
if err != nil {
return nil, err
}
if len(payloadJson) == 0 {
return nil, fmt.Errorf("Empty payload")
}
if h.Links != nil {
return appendField(payloadJson, "_links", h.Links)
}
return payloadJson, nil
}
func appendField[T any](raw []byte, fieldName string, v T) ([]byte, error) {
// The JSON data must be braced in {}
if raw[0] != '{' || raw[len(raw)-1] != '}' {
return nil, fmt.Errorf("Not an object: %s", string(raw))
}
valJson, err := json.Marshal(v)
if err != nil {
return nil, err
}
// Add the field at the end of the json text
result := bytes.NewBuffer(raw[:len(raw)-1])
// Append `"<fieldName>":value`
// Insert comma if the `raw` object is not empty
if len(raw) > 2 {
result.WriteByte(',')
}
// tag
result.WriteByte('"')
result.WriteString(fieldName)
result.WriteByte('"')
// colon
result.WriteByte(':')
// value
result.Write(valJson)
// closing brace
result.WriteByte('}')
return result.Bytes(), nil
}
The marshaller returns an error if Payload
serializes to something other than JSON object. The reason is that the codec can add _links
field to objects only.
Unmarshaller:
func (h *HAL[T]) UnmarshalJSON(raw []byte) error {
// Unmarshal fields of the payload first.
// Unmarshal the whole JSON into the payload, it is safe:
// decorer ignores unknow fields and skips "_links".
if err := json.Unmarshal(raw, &h.Payload); err != nil {
return err
}
// Get "_links": scan trough JSON until "_links" field
links := make(Linkset)
exists, err := extractField(raw, "_links", &links)
if err != nil {
return err
}
if exists {
h.Links = links
}
return nil
}
func extractField[T any](raw []byte, fieldName string, v *T) (bool, error) {
// Scan through JSON until field is found
decoder := json.NewDecoder(bytes.NewReader(raw))
t := must(decoder.Token())
// should be `{`
if t != json.Delim('{') {
return false, fmt.Errorf("Not an object: %s", string(raw))
}
t = must(decoder.Token())
if t == json.Delim('}') {
// Empty object
return false, nil
}
for decoder.More() {
name, ok := t.(string)
if !ok {
return false, fmt.Errorf("must never happen: expected string, got `%v`", t)
}
if name != fieldName {
skipValue(decoder)
} else {
if err := decoder.Decode(v); err != nil {
return false, err
}
return true, nil
}
if decoder.More() {
t = must(decoder.Token())
}
}
return false, nil
}
func skipValue(d *json.Decoder) {
braceCnt := 0
for d.More() {
t := must(d.Token())
if t == json.Delim('{') || t == json.Delim('[') {
braceCnt++
}
if t == json.Delim('}') || t == json.Delim(']') {
braceCnt--
}
if braceCnt == 0 {
return
}
}
}
The unmarshaller fails on non-object as well. It is required to read _links
field. For that the input must be an object.
The full example: https://go.dev/play/p/E3NN2T7Fbnm
func main() {
hal := HAL[TestPayload]{
Payload: TestPayload{
Name: "Graham",
Answer: 42,
},
Links: Linkset{
"self": Link{Href: "/"},
},
}
bz := must(json.Marshal(hal))
println(string(bz))
var halOut HAL[TestPayload]
err := json.Unmarshal(bz, &halOut)
if err != nil {
println("Decode failed: ", err.Error())
}
fmt.Printf("%#v\n", halOut)
}
Output:
{"name":"Graham","answer":42,"_links":{"self":{"href":"/"}}}
main.HAL[main.TestPayload]{Payload:main.TestPayload{Name:"Graham", Answer:42}, Links:main.Linkset{"self":main.Link{Href:"/"}}}