diff --git a/draft2019_09_keywords.go b/draft2019_09_keywords.go index afc3485..dcec935 100644 --- a/draft2019_09_keywords.go +++ b/draft2019_09_keywords.go @@ -5,88 +5,107 @@ package jsonschema // this is also the default keyword set loaded automatically // if no other is loaded func LoadDraft2019_09() { + r, release := getGlobalKeywordRegistry() + defer release() + + r.LoadDraft2019_09() +} + +// DefaultIfEmpty populates the KeywordRegistry with the 2019_09 +// jsonschema draft specification only if the registry is empty. +func (r *KeywordRegistry) DefaultIfEmpty() { + if !r.IsRegistryLoaded() { + r.LoadDraft2019_09() + } +} + +// LoadDraft2019_09 loads the keywords for schema validation +// based on draft2019_09 +// this is also the default keyword set loaded automatically +// if no other is loaded +func (r *KeywordRegistry) LoadDraft2019_09() { // core keywords - RegisterKeyword("$schema", NewSchemaURI) - RegisterKeyword("$id", NewID) - RegisterKeyword("description", NewDescription) - RegisterKeyword("title", NewTitle) - RegisterKeyword("$comment", NewComment) - RegisterKeyword("examples", NewExamples) - RegisterKeyword("readOnly", NewReadOnly) - RegisterKeyword("writeOnly", NewWriteOnly) - RegisterKeyword("$ref", NewRef) - RegisterKeyword("$recursiveRef", NewRecursiveRef) - RegisterKeyword("$anchor", NewAnchor) - RegisterKeyword("$recursiveAnchor", NewRecursiveAnchor) - RegisterKeyword("$defs", NewDefs) - RegisterKeyword("default", NewDefault) - - SetKeywordOrder("$ref", 0) - SetKeywordOrder("$recursiveRef", 0) + r.RegisterKeyword("$schema", NewSchemaURI) + r.RegisterKeyword("$id", NewID) + r.RegisterKeyword("description", NewDescription) + r.RegisterKeyword("title", NewTitle) + r.RegisterKeyword("$comment", NewComment) + r.RegisterKeyword("examples", NewExamples) + r.RegisterKeyword("readOnly", NewReadOnly) + r.RegisterKeyword("writeOnly", NewWriteOnly) + r.RegisterKeyword("$ref", NewRef) + r.RegisterKeyword("$recursiveRef", NewRecursiveRef) + r.RegisterKeyword("$anchor", NewAnchor) + r.RegisterKeyword("$recursiveAnchor", NewRecursiveAnchor) + r.RegisterKeyword("$defs", NewDefs) + r.RegisterKeyword("default", NewDefault) + + r.SetKeywordOrder("$ref", 0) + r.SetKeywordOrder("$recursiveRef", 0) // standard keywords - RegisterKeyword("type", NewType) - RegisterKeyword("enum", NewEnum) - RegisterKeyword("const", NewConst) + r.RegisterKeyword("type", NewType) + r.RegisterKeyword("enum", NewEnum) + r.RegisterKeyword("const", NewConst) // numeric keywords - RegisterKeyword("multipleOf", NewMultipleOf) - RegisterKeyword("maximum", NewMaximum) - RegisterKeyword("exclusiveMaximum", NewExclusiveMaximum) - RegisterKeyword("minimum", NewMinimum) - RegisterKeyword("exclusiveMinimum", NewExclusiveMinimum) + r.RegisterKeyword("multipleOf", NewMultipleOf) + r.RegisterKeyword("maximum", NewMaximum) + r.RegisterKeyword("exclusiveMaximum", NewExclusiveMaximum) + r.RegisterKeyword("minimum", NewMinimum) + r.RegisterKeyword("exclusiveMinimum", NewExclusiveMinimum) // string keywords - RegisterKeyword("maxLength", NewMaxLength) - RegisterKeyword("minLength", NewMinLength) - RegisterKeyword("pattern", NewPattern) + r.RegisterKeyword("maxLength", NewMaxLength) + r.RegisterKeyword("minLength", NewMinLength) + r.RegisterKeyword("pattern", NewPattern) // boolean keywords - RegisterKeyword("allOf", NewAllOf) - RegisterKeyword("anyOf", NewAnyOf) - RegisterKeyword("oneOf", NewOneOf) - RegisterKeyword("not", NewNot) + r.RegisterKeyword("allOf", NewAllOf) + r.RegisterKeyword("anyOf", NewAnyOf) + r.RegisterKeyword("oneOf", NewOneOf) + r.RegisterKeyword("not", NewNot) // object keywords - RegisterKeyword("properties", NewProperties) - RegisterKeyword("patternProperties", NewPatternProperties) - RegisterKeyword("additionalProperties", NewAdditionalProperties) - RegisterKeyword("required", NewRequired) - RegisterKeyword("propertyNames", NewPropertyNames) - RegisterKeyword("maxProperties", NewMaxProperties) - RegisterKeyword("minProperties", NewMinProperties) - RegisterKeyword("dependentSchemas", NewDependentSchemas) - RegisterKeyword("dependentRequired", NewDependentRequired) - RegisterKeyword("unevaluatedProperties", NewUnevaluatedProperties) - - SetKeywordOrder("properties", 2) - SetKeywordOrder("additionalProperties", 3) - SetKeywordOrder("unevaluatedProperties", 4) + r.RegisterKeyword("properties", NewProperties) + r.RegisterKeyword("patternProperties", NewPatternProperties) + r.RegisterKeyword("additionalProperties", NewAdditionalProperties) + r.RegisterKeyword("required", NewRequired) + r.RegisterKeyword("propertyNames", NewPropertyNames) + r.RegisterKeyword("maxProperties", NewMaxProperties) + r.RegisterKeyword("minProperties", NewMinProperties) + r.RegisterKeyword("dependentSchemas", NewDependentSchemas) + r.RegisterKeyword("dependentRequired", NewDependentRequired) + r.RegisterKeyword("unevaluatedProperties", NewUnevaluatedProperties) + + r.SetKeywordOrder("properties", 2) + r.SetKeywordOrder("additionalProperties", 3) + r.SetKeywordOrder("unevaluatedProperties", 4) // array keywords - RegisterKeyword("items", NewItems) - RegisterKeyword("additionalItems", NewAdditionalItems) - RegisterKeyword("maxItems", NewMaxItems) - RegisterKeyword("minItems", NewMinItems) - RegisterKeyword("uniqueItems", NewUniqueItems) - RegisterKeyword("contains", NewContains) - RegisterKeyword("maxContains", NewMaxContains) - RegisterKeyword("minContains", NewMinContains) - RegisterKeyword("unevaluatedItems", NewUnevaluatedItems) - - SetKeywordOrder("maxContains", 2) - SetKeywordOrder("minContains", 2) - SetKeywordOrder("additionalItems", 3) - SetKeywordOrder("unevaluatedItems", 4) + r.RegisterKeyword("items", NewItems) + r.RegisterKeyword("additionalItems", NewAdditionalItems) + r.RegisterKeyword("maxItems", NewMaxItems) + r.RegisterKeyword("minItems", NewMinItems) + r.RegisterKeyword("uniqueItems", NewUniqueItems) + r.RegisterKeyword("contains", NewContains) + r.RegisterKeyword("maxContains", NewMaxContains) + r.RegisterKeyword("minContains", NewMinContains) + r.RegisterKeyword("unevaluatedItems", NewUnevaluatedItems) + + r.SetKeywordOrder("maxContains", 2) + r.SetKeywordOrder("minContains", 2) + r.SetKeywordOrder("additionalItems", 3) + r.SetKeywordOrder("unevaluatedItems", 4) // conditional keywords - RegisterKeyword("if", NewIf) - RegisterKeyword("then", NewThen) - RegisterKeyword("else", NewElse) + r.RegisterKeyword("if", NewIf) + r.RegisterKeyword("then", NewThen) + r.RegisterKeyword("else", NewElse) - SetKeywordOrder("then", 2) - SetKeywordOrder("else", 2) + r.SetKeywordOrder("then", 2) + r.SetKeywordOrder("else", 2) //optional formats - RegisterKeyword("format", NewFormat) + r.RegisterKeyword("format", NewFormat) } diff --git a/keyword.go b/keyword.go index c5aa3e0..eb7b18c 100644 --- a/keyword.go +++ b/keyword.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "sync" jptr "github.com/qri-io/jsonpointer" ) @@ -24,30 +25,76 @@ var notSupported = map[string]bool{ "dependencies": true, } -var ( - keywordRegistry = map[string]KeyMaker{} - keywordOrder = map[string]int{} - keywordInsertOrder = map[string]int{} -) +var kr *KeywordRegistry +var krLock sync.Mutex + +// KeywordRegistry contains a mapping of jsonschema keywords and their +// expected behavior. +type KeywordRegistry struct { + keywordRegistry map[string]KeyMaker + keywordOrder map[string]int + keywordInsertOrder map[string]int +} + +func getGlobalKeywordRegistry() (*KeywordRegistry, func()) { + krLock.Lock() + if kr == nil { + kr = &KeywordRegistry{ + keywordRegistry: make(map[string]KeyMaker, 0), + keywordOrder: make(map[string]int, 0), + keywordInsertOrder: make(map[string]int, 0), + } + } + return kr, func() { krLock.Unlock() } +} + +func copyGlobalKeywordRegistry() *KeywordRegistry { + kr, release := getGlobalKeywordRegistry() + defer release() + return kr.Copy() +} + +// Copy creates a new KeywordRegistry populated with the same data. +func (r *KeywordRegistry) Copy() *KeywordRegistry { + dest := &KeywordRegistry{ + keywordRegistry: make(map[string]KeyMaker, len(r.keywordRegistry)), + keywordOrder: make(map[string]int, len(r.keywordOrder)), + keywordInsertOrder: make(map[string]int, len(r.keywordInsertOrder)), + } + + for k, v := range r.keywordRegistry { + dest.keywordRegistry[k] = v + } + + for k, v := range r.keywordOrder { + dest.keywordOrder[k] = v + } + + for k, v := range r.keywordInsertOrder { + dest.keywordInsertOrder[k] = v + } + + return dest +} // IsRegisteredKeyword validates if a given prop string is a registered keyword -func IsRegisteredKeyword(prop string) bool { - _, ok := keywordRegistry[prop] +func (r *KeywordRegistry) IsRegisteredKeyword(prop string) bool { + _, ok := r.keywordRegistry[prop] return ok } // GetKeyword returns a new instance of the keyword -func GetKeyword(prop string) Keyword { - if !IsRegisteredKeyword(prop) { +func (r *KeywordRegistry) GetKeyword(prop string) Keyword { + if !r.IsRegisteredKeyword(prop) { return NewVoid() } - return keywordRegistry[prop]() + return r.keywordRegistry[prop]() } // GetKeywordOrder returns the order index of // the given keyword or defaults to 1 -func GetKeywordOrder(prop string) int { - if order, ok := keywordOrder[prop]; ok { +func (r *KeywordRegistry) GetKeywordOrder(prop string) int { + if order, ok := r.keywordOrder[prop]; ok { return order } return 1 @@ -55,35 +102,51 @@ func GetKeywordOrder(prop string) int { // GetKeywordInsertOrder returns the insert index of // the given keyword -func GetKeywordInsertOrder(prop string) int { - if order, ok := keywordInsertOrder[prop]; ok { +func (r *KeywordRegistry) GetKeywordInsertOrder(prop string) int { + if order, ok := r.keywordInsertOrder[prop]; ok { return order } // TODO(arqu): this is an arbitrary max return 1000 } -// SetKeywordOrder assignes a given order to a keyword +// SetKeywordOrder assigns a given order to a keyword +func (r *KeywordRegistry) SetKeywordOrder(prop string, order int) { + r.keywordOrder[prop] = order +} + +// SetKeywordOrder assigns a given order to a keyword func SetKeywordOrder(prop string, order int) { - keywordOrder[prop] = order + r, release := getGlobalKeywordRegistry() + defer release() + + r.SetKeywordOrder(prop, order) } // IsNotSupportedKeyword is a utility function to clarify when // a given keyword, while expected is not supported -func IsNotSupportedKeyword(prop string) bool { +func (r *KeywordRegistry) IsNotSupportedKeyword(prop string) bool { _, ok := notSupported[prop] return ok } // IsRegistryLoaded checks if any keywords are present -func IsRegistryLoaded() bool { - return keywordRegistry != nil && len(keywordRegistry) > 0 +func (r *KeywordRegistry) IsRegistryLoaded() bool { + return r.keywordRegistry != nil && len(r.keywordRegistry) > 0 +} + +// RegisterKeyword registers a keyword with the registry +func (r *KeywordRegistry) RegisterKeyword(prop string, maker KeyMaker) { + r.keywordRegistry[prop] = maker + r.keywordInsertOrder[prop] = len(r.keywordInsertOrder) } // RegisterKeyword registers a keyword with the registry func RegisterKeyword(prop string, maker KeyMaker) { - keywordRegistry[prop] = maker - keywordInsertOrder[prop] = len(keywordInsertOrder) + r, release := getGlobalKeywordRegistry() + defer release() + + r.RegisterKeyword(prop, maker) } // MaxKeywordErrStringLen sets how long a value can be before it's length is truncated diff --git a/keyword_test.go b/keyword_test.go index 82549b8..4f429e4 100644 --- a/keyword_test.go +++ b/keyword_test.go @@ -2,10 +2,12 @@ package jsonschema import ( "context" + "encoding/base64" "encoding/json" "fmt" "testing" + "github.com/qri-io/jsonpointer" jptr "github.com/qri-io/jsonpointer" ) @@ -63,7 +65,40 @@ func (f *IsFoo) ValidateKeyword(ctx context.Context, currentState *ValidationSta } } +// ContentEncoding represents a "custom" Schema property +type ContentEncoding string + +// newContentEncoding allocates a new ContentEncoding validator +func newContentEncoding() Keyword { + return new(ContentEncoding) +} + +func (c ContentEncoding) Validate(propPath string, data interface{}, errs *[]KeyError) {} + +func (c ContentEncoding) ValidateKeyword(ctx context.Context, currentState *ValidationState, data interface{}) { + if obj, ok := data.(string); ok { + switch c { + case "base64": + _, err := base64.StdEncoding.DecodeString(obj) + if err != nil { + currentState.AddError(data, fmt.Sprintf("invalid %s value: %s", c, obj)) + } + // Add validation support for other encodings as needed + // See https://json-schema.org/latest/json-schema-validation.html#rfc.section.8.3 + default: + currentState.AddError(data, fmt.Sprintf("unsupported or invalid contentEncoding type of %s", c)) + } + } +} + +func (c ContentEncoding) Register(uri string, registry *SchemaRegistry) {} + +func (c ContentEncoding) Resolve(pointer jsonpointer.Pointer, uri string) *Schema { + return nil +} + func ExampleCustomValidator() { + // register a custom validator by supplying a function // that creates new instances of your Validator. RegisterKeyword("foo", newIsFoo) @@ -86,6 +121,39 @@ func ExampleCustomValidator() { // Output: /: "bar" should be foo. plz make 'bar' == foo. plz } +func ExampleCustomSchemaValidator() { + + // register a custom validator by supplying a function + // that creates new instances of your Validator. + RegisterKeyword("contentEncoding", newContentEncoding) + ctx := context.Background() + + schBytes := []byte(`{ +"type": "object", +"properties" : { + "file" : { + "type": "string", + "contentEncoding": "base64" + } +}, +"required" : ["file"] +}`) + + rs := new(Schema) + if err := json.Unmarshal(schBytes, rs); err != nil { + // Real programs handle errors. + panic(err) + } + + errs, err := rs.ValidateBytes(ctx, []byte(`{ "file": "abc123" }`)) + if err != nil { + panic(err) + } + + fmt.Println(errs[0].Error()) + // Output: /file: "abc123" invalid base64 value: abc123 +} + type FooKeyword uint8 func (f *FooKeyword) Validate(propPath string, data interface{}, errs *[]KeyError) {} @@ -106,7 +174,7 @@ func TestRegisterFooKeyword(t *testing.T) { RegisterKeyword("foo", newFoo) - if !IsRegisteredKeyword("foo") { + if !kr.IsRegisteredKeyword("foo") { t.Errorf("expected %s to be added as a default validator", "foo") } } diff --git a/schema.go b/schema.go index 751a63f..babce5b 100644 --- a/schema.go +++ b/schema.go @@ -64,9 +64,9 @@ func (s *Schema) Register(uri string, registry *SchemaRegistry) { registry.RegisterLocal(s) // load default keyset if no other is present - if !IsRegistryLoaded() { - LoadDraft2019_09() - } + globalRegistry, release := getGlobalKeywordRegistry() + globalRegistry.DefaultIfEmpty() + release() address := s.id if uri != "" && address != "" { @@ -179,9 +179,8 @@ func (s *Schema) UnmarshalJSON(data []byte) error { return nil } - if !IsRegistryLoaded() { - LoadDraft2019_09() - } + keywordRegistry := copyGlobalKeywordRegistry() + keywordRegistry.DefaultIfEmpty() _s := _schema{} if err := json.Unmarshal(data, &_s); err != nil { @@ -200,9 +199,9 @@ func (s *Schema) UnmarshalJSON(data []byte) error { for prop, rawmsg := range valprops { var keyword Keyword - if IsRegisteredKeyword(prop) { - keyword = GetKeyword(prop) - } else if IsNotSupportedKeyword(prop) { + if keywordRegistry.IsRegisteredKeyword(prop) { + keyword = keywordRegistry.GetKeyword(prop) + } else if keywordRegistry.IsNotSupportedKeyword(prop) { schemaDebug(fmt.Sprintf("[Schema] WARN: '%s' is not supported and will be ignored\n", prop)) continue } else { @@ -226,13 +225,13 @@ func (s *Schema) UnmarshalJSON(data []byte) error { for k := range sch.keywords { keyOrders[i] = _keyOrder{ Key: k, - Order: GetKeywordOrder(k), + Order: keywordRegistry.GetKeywordOrder(k), } i++ } sort.SliceStable(keyOrders, func(i, j int) bool { if keyOrders[i].Order == keyOrders[j].Order { - return GetKeywordInsertOrder(keyOrders[i].Key) < GetKeywordInsertOrder(keyOrders[j].Key) + return keywordRegistry.GetKeywordInsertOrder(keyOrders[i].Key) < keywordRegistry.GetKeywordInsertOrder(keyOrders[j].Key) } return keyOrders[i].Order < keyOrders[j].Order }) diff --git a/schema_test.go b/schema_test.go index 1c30604..42077b8 100644 --- a/schema_test.go +++ b/schema_test.go @@ -546,6 +546,10 @@ func runJSONTests(t *testing.T, testFilepaths []string) { sc := ts.Schema for i, c := range ts.Tests { tests++ + + // Ensure we can register keywords in go routines + RegisterKeyword(fmt.Sprintf("content-encoding-%d", tests), newContentEncoding) + validationState := sc.Validate(ctx, c.Data) if validationState.IsValid() != c.Valid { t.Errorf("%s: %s test case %d: %s. error: %s", base, ts.Description, i, c.Description, *validationState.Errs) diff --git a/validation_state.go b/validation_state.go index 8288a6f..02fc610 100644 --- a/validation_state.go +++ b/validation_state.go @@ -17,7 +17,8 @@ type ValidationState struct { RelativeLocation *jptr.Pointer BaseRelativeLocation *jptr.Pointer - LocalRegistry *SchemaRegistry + LocalRegistry *SchemaRegistry + LocalKeywordRegistry *KeywordRegistry EvaluatedPropertyNames *map[string]bool LocalEvaluatedPropertyNames *map[string]bool