Add support for email format as well as the ability to define custom formatters
diff --git a/README.md b/README.md
index 7c99649..6c9bca1 100644
--- a/README.md
+++ b/README.md
@@ -201,6 +201,36 @@
```
%field% must be greater than or equal to %min%
```
+
+## Formatters
+JSON Schema allows for optional "format" property to validate strings against well-known formats. gojsonschema ships with an email format that you can use like this:
+````json
+ {"type": "string", "format": "email"}
+````
+For repetitive or more complex formats, you can create custom formatters and add them to gojsonschema like this:
+
+```go
+ // Define the format checker
+ type URLFormatter struct {}
+
+ // Ensure it meets the gojsonschema.FormatChecker interface
+ func (f URLFormatter) IsFormat(input string) bool {
+ if _, err := URL.Parse(input); err != nil {
+ return false;
+ }
+
+ return true
+ }
+
+ // Add the formatter
+ gojsonschema.Formatters.Add("url", URLFormatter{})
+````
+
+Now to use in your json schema:
+````json
+ {"type": "string", "format": "url"}
+````
+
## Uses
gojsonschema uses the following test suite :
diff --git a/errors.go b/errors.go
index 61c9639..6c82c26 100644
--- a/errors.go
+++ b/errors.go
@@ -106,6 +106,11 @@
ResultErrorFields
}
+ // DoesNotMatchFormatError. ErrorDetails: format
+ DoesNotMatchFormatError struct {
+ ResultErrorFields
+ }
+
// MultipleOfError. ErrorDetails: multiple
MultipleOfError struct {
ResultErrorFields
@@ -197,6 +202,9 @@
case *DoesNotMatchPatternError:
t = "pattern"
d = locale.DoesNotMatchPattern()
+ case *DoesNotMatchFormatError:
+ t = "format"
+ d = locale.DoesNotMatchFormat()
case *MultipleOfError:
t = "multiple_of"
d = locale.MultipleOf()
diff --git a/formatters.go b/formatters.go
new file mode 100644
index 0000000..b3396d0
--- /dev/null
+++ b/formatters.go
@@ -0,0 +1,78 @@
+package gojsonschema
+
+import (
+ "reflect"
+ "regexp"
+)
+
+type (
+ // FormatChecker is the interface all formatters added to FormatterChain must implement
+ FormatChecker interface {
+ IsFormat(input string) bool
+ }
+
+ // FormatterChain holds the formatters
+ FormatterChain struct {
+ formatters map[string]FormatChecker
+ }
+
+ // EmailFormatter verifies emails
+ EmailFormatter struct{}
+)
+
+var (
+ // Formatters holds the valid formatters, and is a public variable
+ // so library users can add custom formatters
+ Formatters = FormatterChain{
+ formatters: map[string]FormatChecker{
+ "email": EmailFormatter{},
+ },
+ }
+
+ // Regex credit: https://github.com/asaskevich/govalidator
+ rxEmail = regexp.MustCompile("^(((([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$")
+)
+
+// Add adds a FormatChecker to the FormatterChain
+// The name used will be the value used for the format key in your json schema
+func (c *FormatterChain) Add(name string, f FormatChecker) *FormatterChain {
+ c.formatters[name] = f
+
+ return c
+}
+
+// Remove deletes a FormatChecker from the FormatterChain (if it exists)
+func (c *FormatterChain) Remove(name string) *FormatterChain {
+ delete(c.formatters, name)
+
+ return c
+}
+
+// Has checks to see if the FormatterChain holds a FormatChecker with the given name
+func (c *FormatterChain) Has(name string) bool {
+ _, ok := c.formatters[name]
+
+ return ok
+}
+
+// IsFormat will check an input against a FormatChecker with the given name
+// to see if it is the correct format
+func (c *FormatterChain) IsFormat(name string, input interface{}) bool {
+ f, ok := c.formatters[name]
+
+ if !ok {
+ return false
+ }
+
+ if !isKind(input, reflect.String) {
+ return false
+ }
+
+ inputString := input.(string)
+
+ return f.IsFormat(inputString)
+}
+
+func (f EmailFormatter) IsFormat(input string) bool {
+ return rxEmail.MatchString(input)
+}
diff --git a/json_schema_test_suite/format/data_00.json b/json_schema_test_suite/format/data_00.json
new file mode 100644
index 0000000..60bc259
--- /dev/null
+++ b/json_schema_test_suite/format/data_00.json
@@ -0,0 +1 @@
+"test"
\ No newline at end of file
diff --git a/json_schema_test_suite/format/data_01.json b/json_schema_test_suite/format/data_01.json
new file mode 100644
index 0000000..6d9ec40
--- /dev/null
+++ b/json_schema_test_suite/format/data_01.json
@@ -0,0 +1 @@
+"test@"
\ No newline at end of file
diff --git a/json_schema_test_suite/format/data_02.json b/json_schema_test_suite/format/data_02.json
new file mode 100644
index 0000000..6b8674d
--- /dev/null
+++ b/json_schema_test_suite/format/data_02.json
@@ -0,0 +1 @@
+"test@test.com"
\ No newline at end of file
diff --git a/json_schema_test_suite/format/data_03.json b/json_schema_test_suite/format/data_03.json
new file mode 100644
index 0000000..970043c
--- /dev/null
+++ b/json_schema_test_suite/format/data_03.json
@@ -0,0 +1 @@
+"AB-10105"
\ No newline at end of file
diff --git a/json_schema_test_suite/format/data_04.json b/json_schema_test_suite/format/data_04.json
new file mode 100644
index 0000000..140843f
--- /dev/null
+++ b/json_schema_test_suite/format/data_04.json
@@ -0,0 +1 @@
+"ABC10105"
\ No newline at end of file
diff --git a/json_schema_test_suite/format/schema_0.json b/json_schema_test_suite/format/schema_0.json
new file mode 100644
index 0000000..b54a202
--- /dev/null
+++ b/json_schema_test_suite/format/schema_0.json
@@ -0,0 +1 @@
+{"type": "string", "format": "email"}
\ No newline at end of file
diff --git a/json_schema_test_suite/format/schema_1.json b/json_schema_test_suite/format/schema_1.json
new file mode 100644
index 0000000..7df6711
--- /dev/null
+++ b/json_schema_test_suite/format/schema_1.json
@@ -0,0 +1 @@
+{"type": "string", "format": "invoice"}
\ No newline at end of file
diff --git a/locales.go b/locales.go
index 2312a23..de05d60 100644
--- a/locales.go
+++ b/locales.go
@@ -48,6 +48,7 @@
StringGTE() string
StringLTE() string
DoesNotMatchPattern() string
+ DoesNotMatchFormat() string
MultipleOf() string
NumberGTE() string
NumberGT() string
@@ -63,6 +64,7 @@
CannotBeGT() string
MustBeOfType() string
MustBeValidRegex() string
+ MustBeValidFormat() string
MustBeGTEZero() string
KeyCannotBeGreaterThan() string
KeyItemsMustBeOfType() string
@@ -160,6 +162,10 @@
return `Does not match pattern '%pattern%'`
}
+func (l DefaultLocale) DoesNotMatchFormat() string {
+ return `Does not match format '%format%'`
+}
+
func (l DefaultLocale) MultipleOf() string {
return `Must be a multiple of %multiple%`
}
@@ -213,6 +219,10 @@
return `%key% must be a valid regex`
}
+func (l DefaultLocale) MustBeValidFormat() string {
+ return `%key% must be a valid format %given%`
+}
+
func (l DefaultLocale) MustBeGTEZero() string {
return `%key% must be greater than or equal to 0`
}
diff --git a/schema.go b/schema.go
index f00674e..3f8da16 100644
--- a/schema.go
+++ b/schema.go
@@ -534,6 +534,18 @@
}
}
+ if existsMapKey(m, KEY_FORMAT) {
+ formatString, ok := m[KEY_FORMAT].(string)
+ if ok && Formatters.Has(formatString) {
+ currentSchema.format = formatString
+ } else {
+ return errors.New(formatErrorDescription(
+ Locale.MustBeValidFormat(),
+ ErrorDetails{"key": KEY_FORMAT, "given": m[KEY_FORMAT]},
+ ))
+ }
+ }
+
// validation : object
if existsMapKey(m, KEY_MIN_PROPERTIES) {
diff --git a/schema_test.go b/schema_test.go
index 01ff133..f892f0b 100644
--- a/schema_test.go
+++ b/schema_test.go
@@ -29,12 +29,27 @@
"fmt"
"net/http"
"os"
+ "regexp"
"strconv"
"testing"
)
const displayErrorMessages = false
+var rxInvoice = regexp.MustCompile("^[A-Z]{2}-[0-9]{5}")
+
+// Used for remote schema in ref/schema_5.json that defines "uri" and "regex" types
+type alwaysTrueFormatter struct{}
+type invoiceFormatter struct{}
+
+func (a alwaysTrueFormatter) IsFormat(input string) bool {
+ return true
+}
+
+func (a invoiceFormatter) IsFormat(input string) bool {
+ return rxInvoice.MatchString(input)
+}
+
func TestJsonSchemaTestSuite(t *testing.T) {
JsonSchemaTestSuiteMap := []map[string]string{
@@ -286,7 +301,12 @@
map[string]string{"phase": "fragment within remote ref", "test": "remote fragment valid", "schema": "refRemote/schema_1.json", "data": "refRemote/data_10.json", "valid": "true"},
map[string]string{"phase": "fragment within remote ref", "test": "remote fragment invalid", "schema": "refRemote/schema_1.json", "data": "refRemote/data_11.json", "valid": "false"},
map[string]string{"phase": "ref within remote ref", "test": "ref within ref valid", "schema": "refRemote/schema_2.json", "data": "refRemote/data_20.json", "valid": "true"},
- map[string]string{"phase": "ref within remote ref", "test": "ref within ref invalid", "schema": "refRemote/schema_2.json", "data": "refRemote/data_21.json", "valid": "false"}}
+ map[string]string{"phase": "ref within remote ref", "test": "ref within ref invalid", "schema": "refRemote/schema_2.json", "data": "refRemote/data_21.json", "valid": "false"},
+ map[string]string{"phase": "format validation", "test": "email format is invalid", "schema": "format/schema_0.json", "data": "format/data_00.json", "valid": "false"},
+ map[string]string{"phase": "format validation", "test": "email format is invalid", "schema": "format/schema_0.json", "data": "format/data_01.json", "valid": "false"},
+ map[string]string{"phase": "format validation", "test": "email format valid", "schema": "format/schema_0.json", "data": "format/data_02.json", "valid": "true"},
+ map[string]string{"phase": "format validation", "test": "invoice format valid", "schema": "format/schema_1.json", "data": "format/data_03.json", "valid": "true"},
+ map[string]string{"phase": "format validation", "test": "invoice format is invalid", "schema": "format/schema_1.json", "data": "format/data_04.json", "valid": "false"}}
//TODO Pass failed tests : id(s) as scope for references is not implemented yet
//map[string]string{"phase": "change resolution scope", "test": "changed scope ref valid", "schema": "refRemote/schema_3.json", "data": "refRemote/data_30.json", "valid": "true"},
@@ -308,6 +328,12 @@
}
}()
+ // Used for remote schema in ref/schema_5.json that defines "uri" and "regex" types
+ Formatters.Add("uri", alwaysTrueFormatter{}).Add("regex", alwaysTrueFormatter{})
+
+ // Custom Formatter
+ Formatters.Add("invoice", invoiceFormatter{})
+
// Launch tests
for testJsonIndex, testJson := range JsonSchemaTestSuiteMap {
diff --git a/subSchema.go b/subSchema.go
index fa8124d..b249b7e 100644
--- a/subSchema.go
+++ b/subSchema.go
@@ -55,6 +55,7 @@
KEY_MIN_LENGTH = "minLength"
KEY_MAX_LENGTH = "maxLength"
KEY_PATTERN = "pattern"
+ KEY_FORMAT = "format"
KEY_MIN_PROPERTIES = "minProperties"
KEY_MAX_PROPERTIES = "maxProperties"
KEY_DEPENDENCIES = "dependencies"
@@ -107,6 +108,7 @@
minLength *int
maxLength *int
pattern *regexp.Regexp
+ format string
// validation : object
minProperties *int
diff --git a/validation.go b/validation.go
index c0d7d05..d79147a 100644
--- a/validation.go
+++ b/validation.go
@@ -698,6 +698,18 @@
}
}
+ // format
+ if currentSubSchema.format != "" {
+ if !Formatters.IsFormat(currentSubSchema.format, stringValue) {
+ result.addError(
+ new(DoesNotMatchFormatError),
+ context,
+ value,
+ ErrorDetails{"format": currentSubSchema.format},
+ )
+ }
+ }
+
result.incrementScore()
}