santhosh-tekuri / jsonschema

JSONSchema (draft 2020-12, draft 2019-09, draft-7, draft-6, draft-4) Validation using Go
Apache License 2.0
951 stars 98 forks source link

How to get meaningful error messages with custom Vocabulary #184

Closed StarpTech closed 3 months ago

StarpTech commented 3 months ago

As you can see the path to the failing property is missing. I'd expect that library add this information. We had this behaviour in v5. I was not able to figure out a way to add those information.

expected: %!q(**jsonschema.ValidationError=0x14000070a40)
                            in chain: "failed to validate router config: jsonschema validation failed with 'file:///Users/starptech/p/wundergraph/cosmo/router/pkg/config/config.schema.json#'\n- at '': bytes must be greater or equal than 1.0 MB"
                                "jsonschema validation failed with 'file:///Users/starptech/p/wundergraph/cosmo/router/pkg/config/config.schema.json#'\n- at '': bytes must be greater or equal than 1.0 MB"

Additionally, It was pretty hard to return a custom error from Validate function because it doesn't accept any Go error. Even worse, when passing a regular Go error it panics. In v5 we were able to do this.

Here is the code of Vocabulary

type validationErrorKind struct {
    message string
    jsonKey string
}

func (v validationErrorKind) KeywordPath() []string {
    return []string{v.jsonKey}
}

func (v validationErrorKind) LocalizedString(printer *message.Printer) string {
    return v.message
}

func newValidationError(message, key string) *jsonschema.ValidationError {
    return &jsonschema.ValidationError{
        ErrorKind: validationErrorKind{
            message: message,
            jsonKey: key,
        },
    }
}

func (d humanBytes) Validate(ctx *jsonschema.ValidatorContext, v any) {

    val, ok := v.(string)
    if !ok {
        ctx.AddErr(fmt.Errorf("invalid bytes, given %s", v))
        return
    }

    bytes, err := humanize.ParseBytes(val)
    if err != nil {
        ctx.AddErr(fmt.Errorf("invalid bytes, given %s", val))
        return
    }

    if d.min > 0 {
        if bytes < d.min {
            err := newValidationError(
                fmt.Sprintf("bytes must be greater or equal than %s", humanize.Bytes(d.min)),
                "bytes",
            )
            ctx.AddErr(err)
            return
        }
    }

    if d.max > 0 {
        if bytes > d.max {
            ctx.AddErr(fmt.Errorf("bytes must be less or equal than %s", humanize.Bytes(d.max)))
            return
        }

    }
}

func humanBytesVocab() *jsonschema.Vocabulary {
    schemaURL := "http://example.com/meta/discriminator"
    schema, err := jsonschema.UnmarshalJSON(strings.NewReader(`{
    "properties" : {
        "bytes": {
            "type": "object",
            "additionalProperties": false,
            "properties": {
                "minimum": {
                    "type": "string"
                },  
                "minimum": {
                    "type": "string"
                }
            }
        }
    }
}`))
    if err != nil {
        log.Fatal(err)
    }

    c := jsonschema.NewCompiler()
    if err := c.AddResource(schemaURL, schema); err != nil {
        log.Fatal(err)
    }
    sch, err := c.Compile(schemaURL)
    if err != nil {
        log.Fatal(err)
    }

    return &jsonschema.Vocabulary{
        URL:     schemaURL,
        Schema:  sch,
        Compile: compileHumanBytes,
    }
}

func compileHumanBytes(ctx *jsonschema.CompilerContext, m map[string]any) (jsonschema.SchemaExt, error) {
    if val, ok := m["bytes"]; ok {

        if mapVal, ok := val.(map[string]interface{}); ok {
            var minBytes, maxBytes uint64
            var err error

            minBytesString, ok := mapVal["minimum"].(string)
            if ok {
                minBytes, err = humanize.ParseBytes(minBytesString)
                if err != nil {
                    return nil, err
                }
            }
            maxBytesString, ok := mapVal["maximum"].(string)
            if ok {
                maxBytes, err = humanize.ParseBytes(maxBytesString)
                if err != nil {
                    return nil, err
                }
            }
            return humanBytes{
                min: minBytes,
                max: maxBytes,
            }, nil
        }

        return humanBytes{}, nil
    }

    // nothing to compile, return nil
    return nil, nil
}
santhosh-tekuri commented 3 months ago

it gives the location in json where error occurs:

at '': bytes must be greater or equal than 1.0 MB"

after at you see empty string. it means the root object.

looks like you are using simple value as instance json. if you have some nesting in your instance json, you will get the path there

StarpTech commented 3 months ago

HI @santhosh-tekuri, it is not the root object. In v5, we saw /properties/traffic_shaping/properties/router/properties/max_request_body_size/bytes as part of the error message.

santhosh-tekuri commented 3 months ago

let me check

StarpTech commented 3 months ago

Example

V5

failed to validate router config: jsonschema: '/traffic_shaping/router/max_request_body_size' does not validate with file:///Users/starptech/p/wundergraph/cosmo/router/pkg/config/config.schema.json#/properties/traffic_shaping/properties/router/properties/max_request_body_size/bytes: must be greater or equal than 1.0 MB

V6

failed to validate router config: jsonschema validation failed with 'file:///Users/starptech/p/wundergraph/cosmo/router/pkg/config/config.schema.json#'
- at '': bytes must be greater or equal than 1.0 MB
santhosh-tekuri commented 3 months ago

the vocab code you provided is incomplete it is missing humantype definition

I just tried with uniqueKeys example vocab, and it works. below is the code I tried:

package jsonschema_test

import (
        "fmt"
        "log"
        "strings"

        "github.com/santhosh-tekuri/jsonschema/v6"
        "golang.org/x/text/message"
)

// SchemaExt --

type uniqueKeys struct {
        pname string
}

func (s *uniqueKeys) Validate(ctx *jsonschema.ValidatorContext, v any) {
        arr, ok := v.([]any)
        if !ok {
                return
        }
        var keys []any
        for _, item := range arr {
                obj, ok := item.(map[string]any)
                if !ok {
                        continue
                }
                key, ok := obj[s.pname]
                if ok {
                        keys = append(keys, key)
                }
        }

        i, j, err := ctx.Duplicates(keys)
        if err != nil {
                ctx.AddErr(err)
                return
        }
        if i != -1 {
                ctx.AddError(&UniqueKeys{Key: s.pname, Duplicates: []int{i, j}})
        }
}

// Vocab --

func uniqueKeysVocab() *jsonschema.Vocabulary {
        url := "http://example.com/meta/unique-keys"
        schema, err := jsonschema.UnmarshalJSON(strings.NewReader(`{
                "properties": {
                        "uniqueKeys": { "type": "string" }
                }
        }`))
        if err != nil {
                log.Fatal(err)
        }

        c := jsonschema.NewCompiler()
        if err := c.AddResource(url, schema); err != nil {
                log.Fatal(err)
        }
        sch, err := c.Compile(url)
        if err != nil {
                log.Fatal(err)
        }

        return &jsonschema.Vocabulary{
                URL:     url,
                Schema:  sch,
                Compile: compileUniqueKeys,
        }
}

func compileUniqueKeys(ctx *jsonschema.CompilerContext, obj map[string]any) (jsonschema.SchemaExt, error) {
        v, ok := obj["uniqueKeys"]
        if !ok {
                return nil, nil
        }
        s, ok := v.(string)
        if !ok {
                return nil, nil
        }
        return &uniqueKeys{pname: s}, nil
}

// ErrorKind --

type UniqueKeys struct {
        Key        string
        Duplicates []int
}

func (*UniqueKeys) KeywordPath() []string {
        return []string{"uniqueKeys"}
}

func (k *UniqueKeys) LocalizedString(p *message.Printer) string {
        return p.Sprintf("items at %d and %d have same %s", k.Duplicates[0], k.Duplicates[1], k.Key)
}

// Example --

func Example_vocab_uniquekeys() {
        schema, err := jsonschema.UnmarshalJSON(strings.NewReader(`{
                "properties": {
                        "list": {
                "uniqueKeys": "id"
                        }
                }
        }`))
        if err != nil {
                log.Fatal(err)
        }
        inst, err := jsonschema.UnmarshalJSON(strings.NewReader(`{"list":[
                { "id": 1, "name": "alice" },
                { "id": 2, "name": "bob" },
                { "id": 1, "name": "scott" }
        ]}`))
        if err != nil {
                log.Fatal(err)
        }

        c := jsonschema.NewCompiler()
        c.AssertVocabs()
        c.RegisterVocabulary(uniqueKeysVocab())
        if err := c.AddResource("schema.json", schema); err != nil {
                log.Fatal(err)
        }
        sch, err := c.Compile("schema.json")
        if err != nil {
                log.Fatal(err)
        }

        err = sch.Validate(inst)
        fmt.Println("valid:", err)
        // Output:
        // valid: false
}

it prints:

valid: jsonschema validation failed with 'file:///Volumes/Backup/github.com/santhosh-tekuri/jsonschema/schema.json#'
- at '/list': items at 0 and 2 have same id

you can see that I got /list here (not empty string)

may be if you could provide isolated runnable code, that will help to find the issue

santhosh-tekuri commented 3 months ago

found the issue:

you should not use ctx.addErr and should never create ValidationError struct error yourself.

you should use ctx.addError method.

see this example to see to how to add error

santhosh-tekuri commented 3 months ago

in short you have to do:

ctx.AddErr(&validationErrorKind{message:"xxx", jsonkey:"yyy"})
StarpTech commented 3 months ago

Doesn't work because validationErrorKind does not implement Error(). After implementing it, it panics because it can't be casted to *ValidationError

santhosh-tekuri commented 3 months ago

There is typo in my previous comment. You have to use ctx.AddError method instead of ctx.AddErr

in short you have to do:

ctx.AddError(&validationErrorKind{message:"xxx", jsonkey:"yyy"})

It takes ErrorKind as argument and internally creates ValidationError

StarpTech commented 3 months ago

Thank you! This has solved it. I recommend to not expose an interface that should not be used by the users. Is there any use case to use ctx.AddErr directly?

santhosh-tekuri commented 3 months ago

ctx.AddErr is for another use case where your keyword contains subschema. the documentation clearly states that:

This is typically used to report the error created by subschema validation.

for example you use like this:

err := ctx.Validate(subSchema, v, vpath)
if err!=nil{
    ctx.addErr(err)
}
StarpTech commented 3 months ago

@santhosh-tekuri thank you! Really appreciate your feedback on this and quick responses. I'm going to close the issue.