Skip to content

scriptnull/jsonseal

Repository files navigation



jsonseal

A JSON validator for Go { ❓ 🧐 ❓ }

Tests Go Reference

🚧 Work In Progress 🚧

 

Goals

  • Validation errors should be human-friendly.
  • Writing custom validators is a breeze. (just write a func() error)
  • An errgroup style API for expressing validation logic.
  • A drop-in replacement for json.Unmarshal. (if you wish)

Installation

go get github.com/scriptnull/jsonseal

Example

Consider the following JSON, that could arrive in a web request for performing payments.

{
  "account_id": "3ee7b5eb-f3fc-4f0b-9e01-8d7a0fa76f0b",
  "balance": 15,
  "currency": "USD",
  "payment": {
    "amount": 50,
    "currency": "USD",
    "payment_mode": "card"
  }
}

Validation logic for the json could written as shown below:

func (r *PaymentRequest) Validate() error {
	var payment jsonseal.CheckGroup

	payment.Check(func() error {
		if r.Payment.Currency != r.Currency {
			return errors.New("payment not allowed to different currency")
		}

		if r.Payment.Amount > r.Balance {
			return errors.New("insufficient balance")
		}

		return nil
	})

	payment.Check(func() error {
		if !slices.Contains(SupportedPaymentModes, r.Payment.Mode) {
			return fmt.Errorf("unsupported payment mode: %s", r.Payment.Mode)
		}

		return nil
	})

	return payment.Validate()
}

Now use jsonseal.Unmarshalinstead of json.Unmarshal to inflate your struct and perform validation rules.

var paymentRequest PaymentRequest

err := jsonseal.Unmarshal(paymentRequestWithInsufficientFunds, &paymentRequest)
if err != nil {
  // report error
}

API

Check Groups

Check groups are a way to group multiple checks and perform validation for them at once.

var grp1 jsonseal.CheckGroup
grp1.Check(func() error { /* check condition 1 */ })
grp1.Check(func() error { /* check condition 2 */ })
err1 := grp1.Validate()

var grp2 jsonseal.CheckGroup
grp2.Check(func() error { /* check condition 1 */ })
grp2.Check(func() error { /* check condition 2 */ })
err2 := grp2.Validate()

Errors

jsonseal comes with built-in error formatters for convenience.

err := jsonseal.Unmarshal(paymentRequestWithInsufficientFunds, &paymentRequest)
if err != nil {
	fmt.Println("Plain error")
	fmt.Print(err)
	fmt.Println()

	fmt.Println("JSON error")
	fmt.Println(jsonseal.JSONFormat(err))
	fmt.Println()

	fmt.Println("JSON error with indent")
	fmt.Println(jsonseal.JSONIndentFormat(err, "", "  "))
	fmt.Println()
	return
}

But if you wish to get a Go struct that denotes all the validation errors, you could get it like this:

err := jsonseal.Unmarshal(paymentRequestWithInsufficientFunds, &paymentRequest)
if err != nil {
		if validationErrors, ok := err.(*jsonseal.Errors); ok {
			fmt.Println(validationErrors)
		}
}

An example error message that is returned by jsonseal.JSONIndentFormat looks like

{
  "errors": [
    {
      "error": "insufficient balance"
    },
    {
      "error": "unsupported payment mode: neft"
    }
  ]
}

Fields

JSON fields could be associated with the validation errors like this:

payment.Field("payment.mode").Check(func() error {
	if !slices.Contains(SupportedPaymentModes, r.Payment.Mode) {
		return fmt.Errorf("unsupported payment mode: %s", r.Payment.Mode)
	}

	return nil
})

The above code associates the json field payment.mode with any error that arises from the Check block attached to it.

// before calling Field()
{
  "error": "unsupported payment mode: neft"
}

// after calling Field()
{
  "fields": [
    "payment.mode"
  ],
  "error": "unsupported payment mode: neft"
}

A method called Fieldf is available to help with cases like payments.Fieldf("payments[%d].amount", idx) (while trying to associate array element as a field).

An error could be asscoiated with multiple different fields by chaining Field or Fieldf.

users.Field("sender.id").Field("receiver.id").Check(AreFriends())

This will make sure to associate both fields with the error in case of the validation error.

{
  "fields": [
    "sender.id",
    "receiver.id"
  ],
  "error": "sender and receiver are not friends"
}

Drop-in Replacements

jsonseal provides drop-in replacements for a few things in encoding/json package. This is to ensure API compatibility and seamless migration experience.

  • jsonseal.Unmarshal could be used in the place of json.Unmarshal
  • jsonseal.Decoder could be used in the place of json.Decoder
    err = jsonseal.NewDecoder(data).Decode(&v)

If you wish to ensure that jsonseal.Validator interface was implemented by the input at compile time, you could use the below alternatives:

  • jsonseal.UnmarshalValidate could be used instead of jsonseal.Unmarshal.
  • jsonseal.DecodeValidate could be used instead of jsonseal.Decode.

Alternatively, you could also do the following to ensure the compile time guarantee.

var _ jsonseal.Validator = &PaymentRequest{}

Unknown Field Suggestions

It might be useful to validate if the JSON data contains only the fields that are expected by the struct to which it is decoded to.

Example: A user sends {"expires": 50} as the JSON data but our code expects it to be {"expires_in": 50}. If you are using json package, you might enable this validation by calling DisallowUnknownFields() on the json.Decoder. That will give you an error like json: unknown field "expires".

jsonseal provides WithUnknownFieldSuggestion() method which takes the error message to the next level by suggesting the right field name based on the Levenshtein Distance between the wrongly typed field name and all possible field names of the struct that we are decoding to.

type Data struct {
  ExpiresIn      int    `json:"expires_in"`
  Balance        int    `json:"balance,omitempty"`
  PrivateField   string `json:"-"`
}
var d Data
err := jsonseal.NewDecoder(data).WithUnknownFieldSuggestion().Decode(&d)
if err != nil {
  fmt.Println(jsonseal.JSONIndentFormat(err, "", "  "))
}

This gives the following error

{
  "errors": [
    {
      "fields": ["expires"],
      "error": "unknown field. Did you mean \"expires_in\""
    }
  ]
}

Thanks to this blog post for providing inspiration and motivation for this feature in jsonseal 🙏.