router/pkg/config/json_schema.go (447 lines of code) (raw):
package config
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"github.com/dustin/go-humanize"
"github.com/goccy/go-yaml"
"github.com/santhosh-tekuri/jsonschema/v6"
"golang.org/x/text/message"
"io/fs"
"log"
"net"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"syscall"
"time"
)
const (
hostnameRegexStringRFC1123 = `^([a-zA-Z0-9]{1}[a-zA-Z0-9-]{0,62}){1}(\.[a-zA-Z0-9]{1}[a-zA-Z0-9-]{0,62})*?$` // accepts hostname starting with a digit https://tools.ietf.org/html/rfc1123
)
type duration struct {
min time.Duration
max time.Duration
}
func (d duration) Validate(ctx *jsonschema.ValidatorContext, v any) {
// is within bounds
val, ok := v.(string)
if !ok {
ctx.AddError(&validationErrorKind{
fmt.Sprintf("invalid duration, given %s", val),
"duration",
})
return
}
duration, err := time.ParseDuration(val)
if err != nil {
ctx.AddError(&validationErrorKind{
fmt.Sprintf("invalid duration, given %s", val),
"duration",
})
return
}
if d.min > 0 {
if duration < d.min {
ctx.AddError(&validationErrorKind{
fmt.Sprintf("duration must be greater or equal than %s", d.min),
"duration",
})
return
}
}
if d.max > 0 {
if duration > d.max {
ctx.AddError(&validationErrorKind{
fmt.Sprintf("duration must be less or equal than %s", d.max),
"duration",
})
return
}
}
}
func goDurationVocab() *jsonschema.Vocabulary {
schemaURL := "http://example.com/meta/duration"
schema, err := jsonschema.UnmarshalJSON(strings.NewReader(`{
"properties" : {
"duration": {
"type": "object",
"additionalProperties": false,
"properties": {
"minimum": {
"type": "string"
},
"maximum": {
"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: compileDuration,
}
}
func compileDuration(ctx *jsonschema.CompilerContext, m map[string]any) (jsonschema.SchemaExt, error) {
if val, ok := m["duration"]; ok {
if mapVal, ok := val.(map[string]interface{}); ok {
var minDuration, maxDuration time.Duration
var err error
minDurationString, ok := mapVal["minimum"].(string)
if ok {
minDuration, err = time.ParseDuration(minDurationString)
if err != nil {
return nil, err
}
}
maxDurationString, ok := mapVal["maximum"].(string)
if ok {
maxDuration, err = time.ParseDuration(maxDurationString)
if err != nil {
return nil, err
}
}
return duration{
min: minDuration,
max: maxDuration,
}, nil
}
return duration{}, nil
}
// nothing to compile, return nil
return nil, nil
}
type humanBytes struct {
min uint64
max uint64
}
var (
_ jsonschema.ErrorKind = (*validationErrorKind)(nil)
)
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 (d humanBytes) Validate(ctx *jsonschema.ValidatorContext, v any) {
val, ok := v.(string)
if !ok {
ctx.AddError(&validationErrorKind{
fmt.Sprintf("invalid bytes, given %s", v),
"bytes",
})
return
}
bytes, err := humanize.ParseBytes(val)
if err != nil {
ctx.AddError(&validationErrorKind{
fmt.Sprintf("invalid bytes, given %s", val),
"bytes",
})
return
}
if d.min > 0 {
if bytes < d.min {
ctx.AddError(&validationErrorKind{
fmt.Sprintf("bytes must be greater or equal than %s", humanize.Bytes(d.min)),
"bytes",
})
return
}
}
if d.max > 0 {
if bytes > d.max {
ctx.AddError(&validationErrorKind{
fmt.Sprintf("bytes must be less or equal than %s", humanize.Bytes(d.max)),
"bytes",
})
return
}
}
}
func humanBytesVocab() *jsonschema.Vocabulary {
schemaURL := "http://example.com/meta/humanBytes"
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
}
var (
//go:embed config.schema.json
JSONSchema []byte
hostnameRegexRFC1123 = regexp.MustCompile(hostnameRegexStringRFC1123)
)
func ValidateConfig(yamlData []byte, schema []byte) error {
var s any
err := json.Unmarshal(schema, &s)
if err != nil {
return err
}
var v any
if err := yaml.Unmarshal(yamlData, &v); err != nil {
log.Fatal(err)
}
c := jsonschema.NewCompiler()
c.AssertFormat()
c.AssertVocabs()
c.RegisterFormat(&jsonschema.Format{
Name: "go-duration",
Validate: isGoDuration,
})
c.RegisterFormat(&jsonschema.Format{
Name: "bytes-string",
Validate: isBytesString,
})
c.RegisterFormat(&jsonschema.Format{
Name: "url",
Validate: isURL,
})
c.RegisterFormat(&jsonschema.Format{
Name: "http-url",
Validate: isHttpURL,
})
c.RegisterFormat(&jsonschema.Format{
Name: "file-path",
Validate: isFilePath,
})
c.RegisterFormat(&jsonschema.Format{
Name: "x-uri",
Validate: isURI,
})
c.RegisterFormat(&jsonschema.Format{
Name: "hostname-port",
Validate: isHostnamePort,
})
c.RegisterVocabulary(goDurationVocab())
c.RegisterVocabulary(humanBytesVocab())
err = c.AddResource("https://raw.githubusercontent.com/wundergraph/cosmo/main/router/pkg/config/config.schema.json", s)
if err != nil {
return err
}
sch, err := c.Compile("https://raw.githubusercontent.com/wundergraph/cosmo/main/router/pkg/config/config.schema.json")
if err != nil {
return err
}
err = sch.Validate(v)
if err != nil {
return err
}
return nil
}
// isGoDuration is the validation function for validating if the current field's value is a valid Go duration.
func isGoDuration(s any) error {
val, ok := s.(string)
if !ok {
return errors.New("invalid duration")
}
_, err := time.ParseDuration(val)
return err
}
// isBytesString is the validation function for validating if the current field's value is a valid bytes string.
func isBytesString(s any) error {
val, ok := s.(string)
if !ok {
return errors.New("invalid bytes string")
}
_, err := humanize.ParseBytes(val)
return err
}
// isFileURL is the helper function for validating if the `path` valid file URL as per RFC8089
func isFileURL(path string) bool {
if !strings.HasPrefix(path, "file:/") {
return false
}
_, err := url.ParseRequestURI(path)
return err == nil
}
// isURL is the validation function for validating if the current field's value is a valid URL.
func isURL(a any) error {
val, ok := a.(string)
if !ok {
return errors.New("invalid URL")
}
s := strings.ToLower(val)
if len(s) == 0 {
return errors.New("invalid URL")
}
if isFileURL(s) {
return errors.New("invalid URL")
}
u, err := url.Parse(s)
if err != nil || u.Scheme == "" {
return errors.New("invalid URL")
}
if u.Host == "" && u.Fragment == "" && u.Opaque == "" {
return errors.New("invalid URL")
}
return nil
}
// isHttpURL is the validation function for validating if the current field's value is a valid HTTP(s) URL.
func isHttpURL(a any) error {
val, ok := a.(string)
if !ok {
return errors.New("invalid HTTP URL")
}
if err := isURL(val); err != nil {
return err
}
s := strings.ToLower(val)
u, err := url.Parse(s)
if err != nil || u.Host == "" {
return errors.New("invalid HTTP URL")
}
if u.Scheme != "http" && u.Scheme != "https" {
return errors.New("invalid HTTP scheme")
}
return nil
}
// isDir is the validation function for validating if the current field's value is a valid existing directory.
func isDir(s string) bool {
fileInfo, err := os.Stat(s)
if err != nil {
return false
}
return fileInfo.IsDir()
}
// isFile is the validation function for validating if the current field's value is a valid existing file path.
func isFile(s string) bool {
fileInfo, err := os.Stat(s)
if err != nil {
return false
}
return !fileInfo.IsDir()
}
// isFilePath is the validation function for validating if the current field's value is a valid file path.
func isFilePath(a any) error {
val, ok := a.(string)
if !ok {
return errors.New("invalid file path")
}
var exists bool
// Not valid if it is a directory.
if isDir(val) {
return errors.New("invalid file path")
}
// If it exists, it obviously is valid.
// This is done first to avoid code duplication and unnecessary additional logic.
if exists = isFile(val); exists {
return nil
}
// Every OS allows for whitespace, but none
// let you use a file with no filename (to my knowledge).
// Unless you're dealing with raw inodes, but I digress.
if strings.TrimSpace(val) == "" {
return errors.New("invalid file path")
}
// We make sure it isn't a directory.
if strings.HasSuffix(val, string(os.PathSeparator)) {
return errors.New("invalid file path")
}
if _, err := os.Stat(val); err != nil {
var t *fs.PathError
switch {
case errors.As(err, &t):
if errors.Is(t.Err, syscall.EINVAL) {
// It's definitely an invalid character in the filepath.
return errors.New("invalid file path")
}
// It could be a permission error, a does-not-exist error, etc.
// Out-of-scope for this validation, though.
return nil
default:
// Something went *seriously* wrong.
/*
Per https://pkg.go.dev/os#Stat:
"If there is an error, it will be of type *PathError."
*/
panic(err)
}
}
return nil
}
// isURI is the validation function for validating if the current field's value is a valid URI.
func isURI(a any) error {
val, ok := a.(string)
if !ok {
return errors.New("invalid URI")
}
// checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195
// emulate browser and strip the '#' suffix prior to validation. see issue-#237
if i := strings.Index(val, "#"); i > -1 {
val = val[:i]
}
if len(val) == 0 {
return errors.New("invalid URI")
}
_, err := url.ParseRequestURI(val)
return err
}
// isHostnamePort validates a <dns>:<port> combination for fields typically used for socket address.
func isHostnamePort(a any) error {
val, ok := a.(string)
if !ok {
return errors.New("invalid hostname:port")
}
host, port, err := net.SplitHostPort(val)
if err != nil {
return err
}
// Port must be an iny <= 65535.
if portNum, err := strconv.ParseInt(
port, 10, 32,
); err != nil || portNum > 65535 || portNum < 1 {
return errors.New("invalid port")
}
// If host is specified, it should match a DNS name
if host != "" {
if !hostnameRegexRFC1123.MatchString(host) {
return errors.New("invalid hostname")
}
}
return nil
}