- 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)
go get github.com/scriptnull/jsonseal
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.Unmarshal
instead of json.Unmarshal
to inflate your struct and perform validation rules.
var paymentRequest PaymentRequest
err := jsonseal.Unmarshal(paymentRequestWithInsufficientFunds, &paymentRequest)
if err != nil {
// report error
}
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()
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"
}
]
}
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"
}
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 ofjson.Unmarshal
jsonseal.Decoder
could be used in the place ofjson.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 ofjsonseal.Unmarshal
.jsonseal.DecodeValidate
could be used instead ofjsonseal.Decode
.
Alternatively, you could also do the following to ensure the compile time guarantee.
var _ jsonseal.Validator = &PaymentRequest{}
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 🙏.