oneof

package module
v0.0.0-...-a435ecb Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 22, 2024 License: MIT Imports: 5 Imported by: 0

README

oneof

Go Reference Build Status

Package oneof enables marshaling and unmarshaling of Go interface values using the Go JSON V2 experiment (github.com/go-json-experiment/json).

By default, marshaling and unmarshaling an interface value will fail:

var s1 fmt.Stringer = crypto.SHA256
b, _ := json.Marshal(s1) // b == []byte("5")

var s2 fmt.Stringer
err := json.Unmarshal(b, &s2)
fmt.Println(err)
// Output:
// json: cannot unmarshal JSON string into Go value of type fmt.Stringer: cannot derive concrete type for non-empty interface

oneof's MarshalFunc and UnmarshalFunc encode matching Go values alongside a type discriminator, which enables round-trip marshaling and unmarshaling:

// Implementations of fmt.Stringer that our program will marshal and unmarshal,
// keyed by an option type
opts := map[string]fmt.Stringer{
  "crypto.Hash": crypto.Hash(0),
  "net.IP":      net.IP{},
  "url.URL":     &url.URL{},
}

marshalFunc := oneof.MarshalFunc(opts, nil)
var s1 fmt.Stringer = crypto.SHA256
b, _ := json.Marshal(s1, json.WithMarshalers(marshalFunc))
// b == []byte(`{"_type": "crypto.Hash", "_value": 5}`)

unmarshalFunc := oneof.UnmarshalFunc(opts, nil)
var s2 fmt.Stringer
_ = json.Unmarshal(b, &s2, json.WithUnmarshalers(unmarshalFunc))
fmt.Printf("unmarshaled type = %T\n", s2)
fmt.Printf("string output = %s\n", s2.String())
// Output:
// unmarshaled type: crypto.Hash
// string output: SHA-256

Default encoding

By default, MarshalFunc encodes known Go values into a JSON object where:

  • The value of the key "_type" is the type discriminator, and,
  • The value of the key "_value" is the default JSON-encoding of the Go value

For example, given options:

opts := map[string]fmt.Stringer{
  "crypto.Hash": crypto.Hash(0),
  "net.IP":      net.IP{},
  "url.URL":     &url.URL{},
}

... the Go value crypto.SHA256 (which encodes to the JSON number 5), would be encoded by MarshalFunc as:

{
  "_type": "crypto.Hash",
  "_value": 5
}

... and the Go value &url.URL{Scheme: "https", Host: "example.com"}, which encodes to a JSON object, would be encoded by MarshalFunc as:

{
  "_type": "url.URL",
  "_value": {
    "Scheme": "https",
    "Host": "example.com"
    // other url.URL fields omitted
  }
}

Custom encoding

WrappedValue is the interface implemented by containers which can marshal and unmarshal Go types including type information. Configure the WrappedValue used by MarshalFunc and UnmarshalFunc by setting Config.WrapFunc:

cfg := oneof.Config{
  WrapFunc: oneof.WrapNested,
}
marshalFunc := oneof.MarshalFunc(opts, cfg)

If Config.WrapFunc is unset, MarshalFunc and UnmarshalFunc default to WrapNested, which wraps encoded values under the "_value" key.

The oneof package also defines WrapInline, which inlines the fields of JSON object values, e.g.,:

{
  "_type": "url.URL",
  "Scheme": "https",
  "Host": "example.com"
  // other url.URL fields omitted
}

For finer-grained control, you can create your own WrappedValue type, or use CustomValueWrapper (whose method Wrap can be used as Config.WrapFunc)

See the WrapFunc and CustomValueWrapper examples.

Handling missing keys

If oneof encounters a Go type for which there is no matching option key while marshaling, it will return an error.

You can override this behavior by setting Config.ReplaceMissingTypeFunc:

cfg := &oneof.Config{
  ReplaceMissingTypeFunc: func(v any) string {
    return fmt.Sprintf("MISSING_%T", v)
  },
}

With the above Config, MarshalFunc will use the string produced by ReplaceMissingTypeFunc as the option type for missing values, like:

{
  "_type": "MISSING_*crypto.Hash",
  "_value": 5
},

[!NOTE] UnmarshalFunc will likely fail to unmarshal output produced by ReplaceMissingTypeFunc. If you need to marshal and unmarshal a Go type, include it in the option set.

Documentation

Overview

Package oneof enables marshaling and unmarshaling of Go interface values using the Go JSON V2 experiment (github.com/go-json-experiment/json).

By default, marshaling and unmarshaling an interface value will fail:

var s1 fmt.Stringer = crypto.SHA256
b, _ := json.Marshal(s1) // b == []byte("5")

var s2 fmt.Stringer
err := json.Unmarshal(b, &s2)
fmt.Println(err)
// Output:
// json: cannot unmarshal JSON string into Go value of type fmt.Stringer: cannot derive concrete type for non-empty interface

MarshalFunc and UnmarshalFunc encode matching Go values alongside a type discriminator, which enables round-trip marshaling and unmarshaling:

// Implementations of fmt.Stringer that our program will marshal and unmarshal,
// keyed by an option type
opts := map[string]fmt.Stringer{
  "crypto.Hash": crypto.Hash(0),
  "net.IP":      net.IP{},
  "url.URL":     &url.URL{},
}

marshalFunc := oneof.MarshalFunc(opts, nil)
var s1 fmt.Stringer = crypto.SHA256
b, _ := json.Marshal(s1, json.WithMarshalers(marshalFunc))
// b == []byte(`{"_type": "crypto.Hash", "_value": 5}`)

unmarshalFunc := oneof.UnmarshalFunc(opts, nil)
var s2 fmt.Stringer
_ = json.Unmarshal(b, &s2, json.WithUnmarshalers(unmarshalFunc))
fmt.Printf("unmarshaled type = %T\n", s2)
fmt.Printf("string output = %s\n", s2.String())
// Output:
// unmarshaled type: crypto.Hash
// string output: SHA-256

Default encoding

By default, MarshalFunc encodes Go values into a JSON object where:

  • The value of the key "_type" is the type discriminator, and,
  • The value of the key "_value" is the default JSON-encoding of the Go value

For example, given options:

opts := map[string]fmt.Stringer{
  "crypto.Hash": crypto.Hash(0),
  "net.IP":      net.IP{},
  "url.URL":     &url.URL{},
}

... the Go value crypto.SHA256, which encodes to the JSON number 5, would be encoded by MarshalFunc as:

{
  "_type": "crypto.Hash",
  "_value": 5
}

... and the Go value &url.URL{Scheme: "https", Host: "example.com"}, which encodes to a JSON object, would be encoded by MarshalFunc as:

{
  "_type": "url.URL",
  "_value": {
    "Scheme": "https",
    "Host": "example.com"
    // other url.URL fields omitted
  }
}

Custom encoding

WrappedValue is the interface implemented by containers which can marshal and unmarshal Go types including type information. Configure the WrappedValue used by MarshalFunc and UnmarshalFunc by providing a non-nil WrapFunc to Config:

cfg := oneof.Config{
  WrapFunc: oneof.WrapAlwaysNest,
}
marshalFunc := oneof.MarshalFunc(opts, cfg)

If WrapFunc is nil, MarshalFunc and UnmarshalFunc default to WrapNested, which wraps encoded values under the "_value" key.

oneof also defines WrapInline, which inlines the fields of JSON object values, e.g.,:

{
  "_type": "url.URL",
  "Scheme": "https",
  "Host": "example.com"
  // other url.URL fields omitted
}

For finer-grained control, you can create your own implementation of WrappedValue, or use CustomValueWrapper (CustomValueWrapper.Wrap can be used as the WrapFunc in Config)

See the Config and CustomValueWrapper examples for more details.

Handling missing keys

If oneof encounters a Go type for which there is no matching option key while marshaling, it will return an error.

You can override this behavior by setting the ReplaceMissingTypeFunc field of Config:

cfg := &oneof.Config{
  ReplaceMissingTypeFunc: func(v any) string {
    return fmt.Sprintf("MISSING_%T", v)
  },
}

With the above Config, MarshalFunc will use the string produced by ReplaceMissingTypeFunc as the option type for missing values, like:

{
  "_type": "MISSING_*crypto.Hash",
  "_value": 5
},

Note that UnmarshalFunc will likely fail to unmarshal output produced by ReplaceMissingTypeFunc. If you need to marshal and unmarshal a Go type, include it in the option set.

Example
package main

import (
	"fmt"
	"io"
	"strings"

	"github.com/dhoelle/oneof"
	"github.com/go-json-experiment/json"
	"github.com/go-json-experiment/json/jsontext"
)

//
// Some examples that implement the fmt.Stringer interface
//

type LiteralStringer string

func (s LiteralStringer) String() string { return string(s) }

type JoinStringer struct {
	A         fmt.Stringer `json:"a,omitempty"`
	B         fmt.Stringer `json:"b,omitempty"`
	Separator string       `json:"separator,omitempty"`
}

func (s JoinStringer) String() string {
	return fmt.Sprintf("%s%s%s", s.A.String(), s.Separator, s.B.String())
}

type ExclamationPointsStringer int

func (s ExclamationPointsStringer) String() string {
	return strings.Repeat("!", int(s))
}

//
// An example that implements the error interface
//

// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/418
type TeapotError struct {
	ShowCode bool `json:"show_code,omitempty"`
}

func (e TeapotError) Error() string {
	if e.ShowCode {
		return "418 I'm a teapot"
	}
	return "I'm a teapot"
}

// An example struct with interface fields.
// We'll marshal and unmarshal this from JSON in the Example function, below.
type StringerAndError struct {
	Stringer fmt.Stringer `json:"stringer,omitempty"`
	Error    error        `json:"error,omitempty"`
}

func main() {
	// Sets of options which implement the fmt.Stringer
	// and error interfaces, respectively, keyed by
	// their discriminator values
	stringerOptions := map[string]fmt.Stringer{
		"literal":     LiteralStringer(""),
		"join":        JoinStringer{},
		"exclamation": ExclamationPointsStringer(0),
	}
	errorOptions := map[string]error{
		"no_progress": io.ErrNoProgress,
		"teapot":      TeapotError{},
	}

	// Use oneof.MarshalFunc to create json.Marshalers that
	// will intercept the marshaling behavior for errors
	// and fmt.Stringers.
	//
	// Note: as of https://github.com/go-json-experiment/json/commits/2e55bd4e08b08427ba10066e9617338e1f113c53/,
	// the json v2 experiment library will redirect marshaling
	// behavior to marshal funcs that are defined on interface
	// types for both:
	//
	//   a) interface values
	//      (e.g., `var err error = TeapotError{}`), and,
	//   b) types which satisfy an interface
	//      (e.g., `err := TeapotError{}`, where type
	//      TeapotError satisfies interface error`)
	//
	errorMarshalFunc := oneof.MarshalFunc(errorOptions, nil)
	stringerMarshalFunc := oneof.MarshalFunc(stringerOptions, nil)

	// Combine our marshal funcs into a single *json.Marshalers
	marshalers := json.NewMarshalers(
		errorMarshalFunc,
		stringerMarshalFunc,
	)

	// Add other JSON options as desired
	marshalOpts := []json.Options{
		json.WithMarshalers(marshalers),
		jsontext.WithIndent("  "), // make the example output easy to read
		json.Deterministic(true),  // make the example output deterministic
	}

	in := StringerAndError{
		Error: TeapotError{ShowCode: true},
		Stringer: JoinStringer{
			A: JoinStringer{
				A:         LiteralStringer("Hello"),
				Separator: " ",
				B:         LiteralStringer("world"),
			},
			Separator: "",
			B:         ExclamationPointsStringer(2),
		},
	}

	// Marshal to JSON
	b, err := json.Marshal(in, marshalOpts...)
	if err != nil {
		panic("failed to marshal: " + err.Error())
	}

	// Build unmarshal funcs, similar to the process above
	errorUnmarshalFunc := oneof.UnmarshalFunc(errorOptions, nil)
	stringerUnmarshalFunc := oneof.UnmarshalFunc(stringerOptions, nil)
	unmarshalers := json.NewUnmarshalers(
		errorUnmarshalFunc,
		stringerUnmarshalFunc,
	)
	unmarshalOpts := []json.Options{
		json.WithUnmarshalers(unmarshalers),
	}

	// Unmarshal our JSON into a new, empty StringerAndError
	out := StringerAndError{}
	if err := json.Unmarshal(b, &out, unmarshalOpts...); err != nil {
		panic("failed to unmarshal: " + err.Error())
	}

	fmt.Printf("Marshaled JSON:\n")
	fmt.Printf("%s\n", string(b))
	fmt.Printf("\n")
	fmt.Printf("Output from unmarshaled Go values:\n")
	fmt.Printf("  error: %s\n", out.Error.Error())
	fmt.Printf("  string: %s\n", out.Stringer.String())

}
Output:

Marshaled JSON:
{
  "stringer": {
    "_type": "join",
    "_value": {
      "a": {
        "_type": "join",
        "_value": {
          "a": {
            "_type": "literal",
            "_value": "Hello"
          },
          "b": {
            "_type": "literal",
            "_value": "world"
          },
          "separator": " "
        }
      },
      "b": {
        "_type": "exclamation",
        "_value": 2
      }
    }
  },
  "error": {
    "_type": "teapot",
    "_value": {
      "show_code": true
    }
  }
}

Output from unmarshaled Go values:
  error: 418 I'm a teapot
  string: Hello world!!
Example (SimpleRoundTrip)
package main

import (
	"crypto"
	"fmt"
	"net"
	"net/url"

	"github.com/dhoelle/oneof"
	"github.com/go-json-experiment/json"
)

func main() {
	// Implementations of fmt.Stringer that our program will marshal and
	// unmarshal, keyed by an option type
	opts := map[string]fmt.Stringer{
		"crypto.Hash": crypto.Hash(0),
		"net.IP":      net.IP{},
		"url.URL":     &url.URL{},
	}

	marshalFunc := oneof.MarshalFunc(opts, nil)
	var s1 fmt.Stringer = crypto.SHA256
	b, _ := json.Marshal(s1, json.WithMarshalers(marshalFunc))
	// b == []byte(`{"_type": "crypto.Hash", "_value": 5}`)

	unmarshalFunc := oneof.UnmarshalFunc(opts, nil)
	var s2 fmt.Stringer
	_ = json.Unmarshal(b, &s2, json.WithUnmarshalers(unmarshalFunc))
	fmt.Printf("unmarshaled type = %T\n", s2)
	fmt.Printf("string output = %s\n", s2.String())
}
Output:

unmarshaled type = crypto.Hash
string output = SHA-256

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func JSONOptions

func JSONOptions[T any](opts map[string]T, cfg *Config) json.Options

func MarshalFunc

func MarshalFunc[T any](opts map[string]T, cfg *Config) *json.Marshalers

MarshalFunc creates a json.MarshalFuncV2 which can intercept marshaling behavior for values of type T and encode a JSON value that can be unmarshaled by UnmarshalFunc into a Go value of the original type T.

By default, MarshalFunc encodes Go values into a JSON object where:

  • The value of the key "_type" is a type discriminator uniquely identifying the initial type T, and,
  • The value of the key "_value" is the default JSON-encoding of the Go value

Encoding behavior can be customized by providing a non-nil Config.

func UnmarshalFunc

func UnmarshalFunc[T any](opts map[string]T, cfg *Config) *json.Unmarshalers

UnmarshalFunc creates a json.UnmarshalFuncV2 which will intercept unmarshaling behavior for values of type T.

UnmarshalFunc finds the destination Go type in its option set that matches the JSON type discriminator, then decodes the remaining JSON according to the default JSON encoding of T.

Types

type Config

type Config struct {
	// By default, if [MarshalFunc] attempts to encode a Go type that is not in
	// the defined set of options, it returns an error.
	//
	// If ReplaceMissingTypeFunc is defined, oneof.MarshalFunc will instead call
	// ReplaceMissingTypeFunc with the unknown Go value and use the result as the
	// value for the JSON discriminator.
	ReplaceMissingTypeFunc func(any) string

	// WrapFunc wraps a type string and [jsontext.Value] in a Go type that can be
	// encoded to and decoded from JSON.
	//
	// If unset, defaults to [WrapNestObjects].
	WrapFunc func(typ string, v jsontext.Value) WrappedValue
}
Example (ReplaceMissingTypeFunc)
// Options implementing fmt.Stringer.
// (intentionally omitting LiteralStringer)
stringerOptions := map[string]fmt.Stringer{
	// "literal":     LiteralStringer(""),
	"join":        JoinStringer{},
	"exclamation": ExclamationPointsStringer(0),
}

// When oneof.MarshalFunc encounters a Go type that is not
// in its list of options, have it generate a discriminator.
//
// (Note: attempting to json.Marshal the output JSON back
// into a Go type will fail)
cfg := &oneof.Config{
	ReplaceMissingTypeFunc: func(v any) string {
		return fmt.Sprintf("MISSING_%T", v)
	},
}
stringerMarshalFunc := oneof.MarshalFunc(stringerOptions, cfg)

// Add other JSON options as desired
marshalOpts := []json.Options{
	json.WithMarshalers(stringerMarshalFunc),
	jsontext.WithIndent("  "), // make the example output easy to read
	json.Deterministic(true),  // make the example output deterministic
}

in := JoinStringer{
	A: JoinStringer{
		A:         LiteralStringer("Hello"),
		Separator: " ",
		B:         LiteralStringer("world"),
	},
	Separator: "",
	B:         ExclamationPointsStringer(2),
}

// Marshal to JSON
b, err := json.Marshal(in, marshalOpts...)
if err != nil {
	panic("failed to marshal: " + err.Error())
}

fmt.Printf("Marshaled JSON:\n%s\n", string(b))
Output:

Marshaled JSON:
{
  "_type": "join",
  "_value": {
    "a": {
      "_type": "join",
      "_value": {
        "a": {
          "_type": "MISSING_*oneof_test.LiteralStringer",
          "_value": "Hello"
        },
        "b": {
          "_type": "MISSING_*oneof_test.LiteralStringer",
          "_value": "world"
        },
        "separator": " "
      }
    },
    "b": {
      "_type": "exclamation",
      "_value": 2
    }
  }
}
Example (WrapFunc)
package main

import (
	"fmt"

	"github.com/dhoelle/oneof"
	"github.com/go-json-experiment/json"
	"github.com/go-json-experiment/json/jsontext"
)

// simpleWrappedValue uses "type" and "value" as the keys for type
// discriminators and nested values, respectively. It also inlines objects.
//
// Note: if option types have fields that conflict with the type and value keys
// of the type wrapper (in this example: "type" or "value"), json.Marshal will
// not encode either field. Avoid using type and value keys which conflict with
// JSON field names used by your data types.
type simpleWrappedValue struct {
	Typ         string         `json:"type"`
	NestedValue jsontext.Value `json:"value,omitempty"`
	InlineValue jsontext.Value `json:",inline"`
}

func (w simpleWrappedValue) Type() string { return w.Typ }
func (w simpleWrappedValue) Value() jsontext.Value {
	if len(w.NestedValue) > 0 {
		return w.NestedValue
	}
	return w.InlineValue
}

func wrapSimple(typ string, v jsontext.Value) oneof.WrappedValue {
	if v.Kind() == '{' {
		// inline objects
		return simpleWrappedValue{
			Typ:         typ,
			InlineValue: v,
		}
	}
	// nest non-objects
	return simpleWrappedValue{
		Typ:         typ,
		NestedValue: v,
	}
}

func main() {
	stringerOptions := map[string]fmt.Stringer{
		"literal":     LiteralStringer(""),
		"join":        JoinStringer{},
		"exclamation": ExclamationPointsStringer(0),
	}

	cfg := &oneof.Config{
		WrapFunc: wrapSimple,
	}
	stringerMarshalFunc := oneof.MarshalFunc(stringerOptions, cfg)

	marshalOpts := []json.Options{
		json.WithMarshalers(stringerMarshalFunc),
		jsontext.WithIndent("  "), // make the example output easy to read
		json.Deterministic(true),  // make the example output deterministic
	}

	in := JoinStringer{
		A: JoinStringer{
			A:         LiteralStringer("Hello"),
			Separator: " ",
			B:         LiteralStringer("world"),
		},
		Separator: "",
		B:         ExclamationPointsStringer(2),
	}

	// Marshal to JSON
	b, err := json.Marshal(in, marshalOpts...)
	if err != nil {
		panic("failed to marshal: " + err.Error())
	}

	fmt.Printf("Marshaled JSON:\n%s\n", string(b))
}
Output:

Marshaled JSON:
{
  "type": "join",
  "a": {
    "type": "join",
    "a": {
      "type": "literal",
      "value": "Hello"
    },
    "b": {
      "type": "literal",
      "value": "world"
    },
    "separator": " "
  },
  "b": {
    "type": "exclamation",
    "value": 2
  }
}

type CustomValueWrapper

type CustomValueWrapper struct {
	DiscriminatorKey string
	NestedValueKey   string
	InlineObjects    bool
}

CustomValueWrapper can be used to quickly build a custom WrapFunc.

Note: CustomValueWrapper does additional work at runtime to dynamically encode and decode JSON. In many cases, this will add additional operations over standard value wrappers, including additional round trips of json.Marshal + json.Unmarshal, and additional iterations over the input value.

If performance is a concern, consider building a custom WrappedValue and [WrapFunc]. See Config for an example (WrapFunc).

Example
stringerOptions := map[string]fmt.Stringer{
	"literal":     LiteralStringer(""),
	"join":        JoinStringer{},
	"exclamation": ExclamationPointsStringer(0),
}

cw := oneof.CustomValueWrapper{
	DiscriminatorKey: "$type",
	NestedValueKey:   "$value",
	InlineObjects:    true,
}

cfg := &oneof.Config{
	WrapFunc: cw.Wrap,
}
stringerMarshalFunc := oneof.MarshalFunc(stringerOptions, cfg)

marshalOpts := []json.Options{
	json.WithMarshalers(stringerMarshalFunc),
	jsontext.WithIndent("  "), // make the example output easy to read
	json.Deterministic(true),  // make the example output deterministic
}

in := JoinStringer{
	A: JoinStringer{
		A:         LiteralStringer("Hello"),
		Separator: " ",
		B:         LiteralStringer("world"),
	},
	Separator: "",
	B:         ExclamationPointsStringer(2),
}

// Marshal to JSON
b, err := json.Marshal(in, marshalOpts...)
if err != nil {
	panic("failed to marshal: " + err.Error())
}

fmt.Printf("Marshaled JSON:\n%s\n", string(b))
Output:

Marshaled JSON:
{
  "$type": "join",
  "a": {
    "$type": "join",
    "a": {
      "$type": "literal",
      "$value": "Hello"
    },
    "b": {
      "$type": "literal",
      "$value": "world"
    },
    "separator": " "
  },
  "b": {
    "$type": "exclamation",
    "$value": 2
  }
}

func (CustomValueWrapper) Empty

func (b CustomValueWrapper) Empty() WrappedValue

func (CustomValueWrapper) Wrap

type ErrUnknownDiscriminatorValue

type ErrUnknownDiscriminatorValue struct {
	// contains filtered or unexported fields
}

ErrUnknownDiscriminatorValue is the error returned by UnmarshalFunc when it encounters a JSON discriminator value which is not in the provided set of options

func (ErrUnknownDiscriminatorValue) Error

type ErrUnknownGoType

type ErrUnknownGoType struct {
	// contains filtered or unexported fields
}

ErrUnknownGoType is the error returned by MarshalFunc when it encounters a Go type that is not in the provided set of options

func (ErrUnknownGoType) Error

func (e ErrUnknownGoType) Error() string

type WrappedValue

type WrappedValue interface {
	Type() string
	Value() jsontext.Value
}

WrappedValue is the interface implemented by types that can encode a Go type and oneof option string into JSON, and can decode that JSON back into a matching Go type.

func WrapInline

func WrapInline(typ string, v jsontext.Value) WrappedValue

WrapInline wraps a oneof value with default "inline" behavior, specifically:

  • Type is stored under the "_type" key
  • JSON object values are inlined into the same object as the "_type" key
  • All non-object JSON values are nested under the "_value" key

The marshaled JSON output looks like:

{
	"my_stringers": [
		{
			"_type": "crypto.Hash",
			"_value": 5
		},
		{
			"_type": "url.URL",
			"Scheme": "https",
			"Host": "example.com"
		},
	]
}

func WrapNested

func WrapNested(typ string, v jsontext.Value) WrappedValue

WrapNested nests the provided jsontext.Value underneath the "_value" key within the wrapper object

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL