diff --git a/README.md b/README.md index 9fe6192..1fac89d 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ ![Crystal CI](https://github.com/eliasjpr/schema/workflows/Crystal%20CI/badge.svg) -Schemas come to solve a simple problem. Sometimes we would like to have type-safe guarantee params when parsing HTTP parameters or Hash(String, String) for a request moreover; Schemas is to resolve precisely this problem with the added benefit of performing business rules validation to have the params adhere to a `"business schema."` +Schemas come to solve a simple problem. Sometimes we would like to have type-safe guarantee parameters when parsing HTTP requests or Hash(String, String) for a request. Schema shard resolve precisely this problem with the added benefit of enabling self validating schemas that can be applied to any object, requiring little to no boilerplate code making you more productive from the moment you use this shard. -Schemas are beneficial, in my opinion, ideal, for when defining API Requests, Web Forms, JSON, YAML. Schema-Validation Takes a different approach and focuses a lot on explicitness, clarity, and precision of validation logic. It is designed to work with any data input, whether it’s a simple hash, an array or a complex object with deeply nested data. +Self validating Schemas are beneficial, and in my opinion, ideal, for when defining API Requests, Web Forms, JSON. Schema-Validation Takes a different approach and focuses a lot on explicitness, clarity, and precision of validation logic. It is designed to work with any data input, whether it’s a simple hash, an array or a complex object with deeply nested data. Each validation is encapsulated by a simple, stateless predicate that receives some input and returns either true or false. Those predicates are encapsulated by rules which can be composed together using predicate logic, meaning you can use the familiar logic operators to build up a validation schema. @@ -26,85 +26,116 @@ dependencies: require "schema" ``` -## Defining Self Validated Schemas +### Defining Self Validated Schemas Schemas are defined as value objects, meaning structs, which are NOT mutable, making them ideal to pass schema objects as arguments to constructors. ```crystal -class ExampleController - getter params : Hash(String, String) +class Example + include Schema::Definition + include Schema::Validation - def initialize(@params) - end - - schema User do - param email : String, match: /\w+@\w+\.\w{2,3}/, message: "Email must be valid!" - param name : String, size: (1..20) - param age : Int32, gte: 24, lte: 25, message: "Must be 24 and 30 years old" - param alive : Bool, eq: true - param childrens : Array(String) - param childrens_ages : Array(Int32) - - schema Address do - param street : String, size: (5..15) - param zip : String, match: /\d{5}/ - param city : String, size: 2, in: %w[NY NJ CA UT] - - schema Location do - param longitude : Float32 - param latitute : Float32 - end + property email : String + property name : String + property age : Int32 + property alive : Bool + property childrens : Array(String) + property childrens_ages : Array(Int32) + property last_name : String + + use EmailValidator, UniqueRecordValidator + validate :email, match: /\w+@\w+\.\w{2,3}/, message: "Email must be valid!" + validate :name, size: (1..20) + validate :age, gte: 18, lte: 25, message: "Age must be 18 and 25 years old" + validate :alive, eq: true + validate :last_name, presence: true, message: "Last name is invalid" + + predicates do + def some?(value : String, some) : Bool + (!value.nil? && value != "") && !some.nil? end - def some_method(arg) - ...do something + def if?(value : Array(Int32), bool : Bool) : Bool + !bool end end + + def initialize(@email, @name, @age, @alive, @childrens, @childrens_ages, @last_name) + end end ``` ### Schema class methods ```crystal -ExampleController::User.from_json(pyaload: String) -ExampleController::User.from_yaml(pyaload: String) -ExampleController::User.new(params: Hash(String, String)) +Example.from_json +Example.from_urlencoded("&foo=bar") +# Any object that responds to `.each`, `#[]?`, `#[]`, `#fetch_all?` +Example.new(params) ``` ### Schema instance methods ```crystal -getters - For each of the params valid? - Bool -validate! - True or Raise Error +validate! - True or Raise ValidationError errors - Errors(T, S) -rules - Rules(T, S) -params - Original params payload -to_json - Outputs JSON -to_yaml - Outputs YAML ``` -## Example parsing HTTP Params (With nested params) +### Example parsing HTTP Params (With nested params) + +Below find a list of the supported params parsing structure and it's corresponding representation in Query String or `application/x-www-form-urlencoded` form data. + +```crystal +http_params = HTTP::Params.build do |p| + p.add("string", "string_value") + p.add("optional_string", "optional_string_value") + p.add("string_with_default", "string_with_default_value") + p.add("int", "1") + p.add("optional_int", "2") + p.add("int_with_default", "3") + p.add("enum", "Foo") + p.add("optional_enum", "Bar") + p.add("enum_with_default", "Baz") + p.add("array[]", "foo") + p.add("array[]", "bar") + p.add("array[]", "baz") + p.add("optional_array[]", "foo") + p.add("optional_array[]", "bar") + p.add("array_with_default[]", "foo") + p.add("hash[foo]", "1") + p.add("hash[bar]", "2") + p.add("optional_hash[foo][]", "3") + p.add("optional_hash[foo][]", "4") + p.add("optional_hash[bar][]", "5") + p.add("hash_with_default[foo]", "5") + p.add("tuple[]", "foo") + p.add("tuple[]", "2") + p.add("tuple[]", "3.5") + p.add("boolean", "1") + p.add("optional_boolean", "false") + p.add("boolean_with_default", "true") + p.add("nested[foo]", "1") + p.add("nested[bar]", "3") + p.add("nested[baz][]", "foo") + p.add("nested[baz][]", "bar") +end +``` ```crystal -params = HTTP::Params.parse( - "email=test@example.com&name=john&age=24&alive=true&" + - "childrens=Child1,Child2&childrens_ages=1,2&" + - # Nested params - "address.city=NY&address.street=Sleepy Hollow&address.zip=12345&" + - "address.location.longitude=41.085651&address.location.latitute=-73.858467" - ) - -subject = ExampleController.new(params.to_h) +params = HTTP::Params.parse("email=test%40example.com&name=john&age=24&alive=true&childrens%5B%5D=Child1%2CChild2&childrens_ages%5B%5D=12&childrens_ages%5B%5D=18&address%5Bcity%5D=NY&address%5Bstreet%5D=Sleepy+Hollow&address%5Bzip%5D=12345&address%5Blocation%5D%5Blongitude%5D=41.085651&address%5Blocation%5D%5Blatitude%5D=-73.858467&address%5Blocation%5D%5Buseful%5D=true") + +# HTTP::Params responds to `#[]`, `#[]?`, `#fetch_all?` and `.each` +subject = ExampleController.new(params) ``` Accessing the generated schemas: ```crystal -user = subject.user - ExampleController -address = user.address - ExampleController::Address -location = address.location - ExampleController::Address::Location +user = subject.user - Example +address = user.address - Example::Address +location = address.location - Example::Address::Location ``` ## Example parsing from JSON @@ -119,60 +150,16 @@ json = %({ "user": { "childrens_ages": [9, 12] }}) -user = ExampleController.from_json(json, "user") -``` - -## Registring Schema Custom Converters - -Custom converters allows you to define how to parse your custom data types. To Define a custom converter simply define a `convert` method for your custom type. - -For example lets say we want to convert a `string` time representation to `Time` type. - -```crystal -module Schema - module Cast(T) - def convert(asType : Time.class) - asType.parse(@value, "%m-%d-%Y", Time::Location::UTC) - end - end -end -``` - -or - -```crystal -class CustomType - include Schema::Cast(CustomType) - - def initialize(@value : String) - end - - def value - convert(self.class) - end - - def convert(asType : self.class) - @value.split(",").map { |i| i.to_i32 } - end -end -``` - -The implicit `@value` contains the actual string to parse as `Time`. - -The definition of the `convert(asType : Time.class)` method registers the custome converter. - -To use your converter simply define `param` with your custom type and the schema framework will do the rest. - -```crystal -param ended_at : Time +user = Example.from_json(json, "user") ``` - ## Validations You can also perform validations for existing objects without the use of Schemas. ```crystal class User < Model + include Schema::Validation + property email : String property name : String property age : Int32 @@ -180,47 +167,55 @@ class User < Model property childrens : Array(String) property childrens_ages : Array(Int32) - validation do - # To use a custom validator, this will enable the predicate `unique_record` - # which is derived from the class name minus `validator` - use UniqueRecordValidator - - # Use the `custom` class name predicate as follow - validate email, match: /\w+@\w+\.\w{2,3}/, message: "Email must be valid!", unique_record: true - validate name, size: (1..20) - validate age, gte: 18, lte: 25, message: "Must be 24 and 30 years old" - validate alive, eq: true - validate childrens - validate childrens_ages - end + # To use a custom validator. UniqueRecordValidator will be initialized with an `User` instance + use UniqueRecordValidator + + # Use the `custom` class name predicate as follow + validate email, match: /\w+@\w+\.\w{2,3}/, message: "Email must be valid!", unique_record: true + validate name, size: (1..20) + validate age, gte: 18, lte: 25, message: "Must be 24 and 30 years old" + validate alive, eq: true def initialize(@email, @name, @age, @alive, @childrens, @childrens_ages) end end ``` -## Custom Validations +### Custom Validations Simply create a class `{Name}Validator` with the following signature: ```crystal -class UniqueRecordValidator - getter :record, :message +class EmailValidator < Schema::Validator + getter :record, :field, :message - def initialize(@record : UserModel, @message : String) + def initialize(@record : UserModel) + @field = :email + @message = "Email must be valid!" end - def valid? - false + def valid? : Array(Schema::Error) + [] of Schema::Error end end -``` -Notice that `unique_record:` corresponds to `UniqueRecord`Validator. +class UniqueRecordValidator < Schema::Validator + getter :record, :field, :message -### Defining Your Own Predicates + def initialize(@record : UserModel) + @field = :email + @message = "Record must be unique!" + end + + def valid? : Array(Schema::Error) + [] of Schema::Error + end +end +``` + +### Defining Predicates -You can define your custom predicates by simply creating a custom validator or creating methods in the `Schema::Validators` module ending with `?` and it should return a `boolean`. For example: +You can define your custom predicates by simply creating a custom validator or creating methods in the `Schema::Predicates` module ending with `?` and it should return a `boolean`. For example: ```crystal class User < Model @@ -231,14 +226,16 @@ class User < Model property childrens : Array(String) property childrens_ages : Array(Int32) - validation do - ... - params password : String, presence: true + ... - predicates do - def presence?(password : String, _other : String) : Bool - !value.nil? - end + # Uses a `presense` predicate + validate password : String, presence: true + + # Use the `predicates` macro to define predicate methods + predicates do + # Presence Predicate Definition + def presence?(password : String, _other : String) : Bool + !value.nil? end end @@ -247,15 +244,25 @@ class User < Model end ``` +### Differences: Custom Validator vs Predicates + The differences between a custom validator and a method predicate are: -- Custom validators receive an instance of the object as a `record` instance var. -- Custom validators allow for more control over validations. -- Predicates are assertions against the class properties (instance var). -- Predicates matches property value with predicate value. +**Custom Validators** +- Must be inherited from `Schema::Validator` abstract +- Receives an instance of the object as a `record` instance var. +- Must have a `:field` and `:message` defined. +- Must implement a `def valid? : Array(Schema::Error)` method. + +**Predicates** +- Assertions of the property value against an expected value. +- Predicates are light weight boolean methods. +- Predicates methods must be defined as `def {predicate}?(property_value, expected_value) : Bool` . ### Built in Predicates +These are the current available predicates. + ```crystal gte - Greater Than or Equal To lte - Less Than or Equal To @@ -267,6 +274,8 @@ regex - Regular Expression eq - Equal ``` +> **CONTRIBUTE** - Add more predicates to this shards by contributing a Pull Request. + Additional params ```crystal @@ -274,16 +283,6 @@ message - Error message to display nilable - Allow nil, true or false ``` -## Development (Help Wanted!) - -API subject to change until marked as released version - -Things left to do: - -- [x] Validate nested - When calling `valid?` validates inner schemas. -- [x] Build nested yaml/json- Currently json and yaml do not support the sub schemas. -- [x] Document Custom Converter for custom types. - ## Contributing 1. Fork it () diff --git a/shard.yml b/shard.yml index 69d487c..7812436 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: schema -version: 0.1.0 +version: 0.2.0 authors: - Elias J. Perez diff --git a/spec/cast_spec.cr b/spec/cast_spec.cr deleted file mode 100644 index d2b6de9..0000000 --- a/spec/cast_spec.cr +++ /dev/null @@ -1,41 +0,0 @@ -require "spec" -require "../src/schema" - -class CustomType - include Schema::Cast(CustomType) - - def initialize(@value : String) - end - - def value - convert(self.class) - end - - def convert(asType : self.class) - @value.split(",").map { |i| i.to_i32 } - end -end - -describe CustomType do - it "converts from String to Custom Type" do - converter = CustomType.new("1,2,3,4,5") - converter.value.should eq [1, 2, 3, 4, 5] - end -end - -describe Schema::ConvertTo do - it "converts from String to Boolean" do - converter = Schema::ConvertTo(Bool).new("false") - converter.value.should eq false - end - - it "converts from String to Int32" do - converter = Schema::ConvertTo(Int32).new("123") - converter.value.should eq 123 - end - - it "converts from String to Float" do - converter = Schema::ConvertTo(Float32).new("123.321") - converter.value.should eq 123.321_f32 - end -end diff --git a/spec/definition_spec.cr b/spec/definition_spec.cr new file mode 100644 index 0000000..4f1dfb7 --- /dev/null +++ b/spec/definition_spec.cr @@ -0,0 +1,83 @@ +require "./spec_helper" + +describe Schema::Definition do + params = HTTP::Params.build do |p| + p.add("email", "test@example.com") + p.add("name", "john") + p.add("age", "24") + p.add("alive", "true") + p.add("childrens[]", "Child1,Child2") + p.add("childrens_ages[]", "12") + p.add("childrens_ages[]", "18") + p.add("address[city]", "NY") + p.add("address[street]", "Sleepy Hollow") + p.add("address[zip]", "12345") + p.add("address[location][longitude]", "41.085651") + p.add("address[location][latitude]", "-73.858467") + p.add("address[location][useful]", "true") + end + + it "defines schema from Hash(String, String)" do + user = Example.from_urlencoded(params) + user.should be_a Example + end + + it "defines a schema from JSON" do + json = %({ "user": { + "email": "fake@example.com", + "name": "Fake name", + "age": 25, + "alive": true, + "childrens": ["Child 1", "Child 2"], + "childrens_ages": [9, 12], + "address": { + "city": "NY", + "street": "sleepy", + "zip": "12345", + "location": { + "longitude": 123.123, + "latitude": 342454.4321, + "useful": true + } + } + }}) + + subject = Example.from_json(json, "user") + + subject.email.should eq "fake@example.com" + subject.name.should eq "Fake name" + subject.age.should eq 25 + subject.alive.should eq true + subject.childrens.should eq ["Child 1", "Child 2"] + subject.childrens_ages.should eq [9, 12] + subject.address.city.should eq "NY" + subject.address.street.should eq "sleepy" + subject.address.zip.should eq "12345" + subject.address.location.longitude.should eq 123.123 + end + + it "validates schema and sub schemas" do + json = %({ "user": { + "email": "fake@example.com", + "name": "Fake name", + "age": 25, + "alive": true, + "childrens": ["Child 1", "Child 2"], + "childrens_ages": [9, 12], + "address": { + "city": "NY", + "street": "slepy", + "zip": "12345", + "location": { + "longitude": 123.122, + "latitude": 342454.4321, + "useful": false + } + } + }}) + + subject = Example.from_json(json, "user") + + subject.should be_a Example + end +end diff --git a/spec/defintion_spec.cr b/spec/defintion_spec.cr deleted file mode 100644 index 2d62f6a..0000000 --- a/spec/defintion_spec.cr +++ /dev/null @@ -1,83 +0,0 @@ -require "./spec_helper" -require "json" - -struct User - include JSON::Serializable - include YAML::Serializable - include Schema::Definition - include Schema::Validation - - param email : String - param name : String - param age : Int32 - param alive : Bool - param childrens : Array(String) - param childrens_ages : Array(Int32) - - schema Address do - param city : String - - schema Location do - param latitude : Float32 - end - end - - schema Phone do - param number : String - end -end - -describe "Schema::Definition" do - params = { - "email" => "fake@example.com", - "name" => "Fake name", - "age" => "25", - "alive" => "true", - "childrens" => "Child 1,Child 2", - "childrens_ages" => "9,12", - "phone.number" => "123456789", - "address.city" => "NY", - "address.location.latitude" => "1234.12", - } - - it "defines a schema object from Hash(String, Stirng)" do - subject = User.new(params) - - subject.should be_a User - subject.email.should eq "fake@example.com" - subject.name.should eq "Fake name" - subject.age.should eq 25 - subject.alive.should eq true - subject.childrens.should eq ["Child 1", "Child 2"] - subject.childrens_ages.should eq [9, 12] - end - - it "defines a schema from JSON" do - json = %({"user": { - "email": "fake@example.com", - "name": "Fake name", - "age": 25, - "alive": true, - "childrens": ["Child 1", "Child 2"], - "childrens_ages": [9, 12], - "phone": { - "number": "123456789" - }, - "address": { - "city": "NY", - "location": { - "latitude": 12345.12 - } - } - }}) - - subject = User.from_json(json, "user") - - subject.email.should eq "fake@example.com" - subject.name.should eq "Fake name" - subject.age.should eq 25 - subject.alive.should eq true - subject.childrens.should eq ["Child 1", "Child 2"] - subject.childrens_ages.should eq [9, 12] - end -end diff --git a/spec/rules_spec.cr b/spec/rules_spec.cr deleted file mode 100644 index 6eb0208..0000000 --- a/spec/rules_spec.cr +++ /dev/null @@ -1,58 +0,0 @@ -require "./spec_helper" - -describe Schema::Rule do - describe "#valid?" do - it "applies rule" do - rule = Schema::Rule.new :field, "Invalid!" do |_rule| - _rule.gte?(2, 1) && _rule.lt?(1, 2) - end - - rule.valid?.should be_true - end - end -end - -describe Schema::Rules do - subject = Schema::Rules(Schema::Rule, Symbol).new - - describe "#<<" do - it "adds a rule" do - rule = Schema::Rule.new :field, "Invalid!" do |_rule| - _rule.gte?(2, 1) && _rule.lt?(1, 2) - end - - subject << rule - - subject.size.should eq 1 - end - end - - describe "#apply" do - it "returns true all rules are valid" do - subject = Schema::Rules(Schema::Rule, Symbol).new - rule = Schema::Rule.new :field, "Invalid!" do |_rule| - _rule.gte?(2, 1) && _rule.lt?(1, 2) - end - - subject << rule - subject.errors.should be_empty - end - - it "returns false any rule is invalid" do - subject = Schema::Rules(Schema::Rule, Symbol).new - rule1 = Schema::Rule.new :field, "Invalid!" do |_rule| - _rule.gte?(2, 1) - end - rule2 = Schema::Rule.new :field, "Invalid!" do |_rule| - _rule.lt?(2, 1) - end - - subject << rule1 - subject << rule2 - - subject.size.should eq 2 - subject.errors.size.should eq 1 - subject.errors.should contain Schema::Error(Schema::Rule, Symbol).new(:field, "Invalid!") - end - end -end diff --git a/spec/schema_spec.cr b/spec/schema_spec.cr index fa2d432..2ec354a 100644 --- a/spec/schema_spec.cr +++ b/spec/schema_spec.cr @@ -1,107 +1,24 @@ require "./spec_helper" -require "http" -struct ExampleController - include JSON::Serializable - include YAML::Serializable - include Schema::Definition - include Schema::Validation - - schema do - param email : String, match: /\w+@\w+\.\w{2,3}/, message: "Email must be valid!" - param name : String, size: (1..20) - param age : Int32, gte: 24, lte: 25, message: "Must be 24 and 30 years old" - param alive : Bool, eq: true - param childrens : Array(String) - param childrens_ages : Array(Int32) - - schema Address do - param street : String, size: (5..15) - param zip : String, match: /\d{5}/ - param city : String, size: 2, in: %w[NY NJ CA UT] - - schema Location do - param longitude : Float64 - param latitude : Float64 - param useful : Bool, eq: true - end - end - end -end - -describe Schema do - it "defines schema from Hash(String, String)" do - params = HTTP::Params.parse( - "email=test@example.com&name=john&age=24&alive=true&" + - "childrens=Child1,Child2&childrens_ages=1,2&" + - "address.city=NY&address.street=Sleepy Hollow&address.zip=12345&" + - "address.location.longitude=41.085651&address.location.latitude=-73.858467&address.location.useful=true" + - "" - ) - - user = ExampleController.new(params.to_h) - - user.valid?.should be_true - user.address.valid?.should be_true - user.address.location.valid?.should be_true +describe "Integration test of Definitions and Validations" do + params = HTTP::Params.build do |p| + p.add("email", "test@example.com") + p.add("name", "john") + p.add("age", "24") + p.add("alive", "true") + p.add("childrens[]", "Child1,Child2") + p.add("childrens_ages[]", "12") + p.add("childrens_ages[]", "18") + p.add("address[city]", "NY") + p.add("address[street]", "Sleepy Hollow") + p.add("address[zip]", "12345") + p.add("address[location][longitude]", "41.085651") + p.add("address[location][latitude]", "-73.858467") + p.add("address[location][useful]", "true") end - it "defines a schema from JSON" do - json = %({ "user": { - "email": "fake@example.com", - "name": "Fake name", - "age": 25, - "alive": true, - "childrens": ["Child 1", "Child 2"], - "childrens_ages": [9, 12], - "address": { - "city": "NY", - "street": "sleepy", - "zip": "12345", - "location": { - "longitude": 123.123, - "latitude": 342454.4321, - "useful": true - } - } - }}) - - subject = ExampleController.from_json(json, "user") - - subject.email.should eq "fake@example.com" - subject.name.should eq "Fake name" - subject.age.should eq 25 - subject.alive.should eq true - subject.childrens.should eq ["Child 1", "Child 2"] - subject.childrens_ages.should eq [9, 12] - subject.address.city.should eq "NY" - subject.address.street.should eq "sleepy" - subject.address.zip.should eq "12345" - subject.address.location.longitude.should eq 123.123 - end - - it "validates schema and sub schemas" do - json = %({ "user": { - "email": "fake@example.com", - "name": "Fake name", - "age": 25, - "alive": true, - "childrens": ["Child 1", "Child 2"], - "childrens_ages": [9, 12], - "address": { - "city": "NY", - "street": "slepy", - "zip": "12345", - "location": { - "longitude": 123.122, - "latitude": 342454.4321, - "useful": false - } - } - }}) - - subject = ExampleController.from_json(json, "user") - - subject.valid?.should be_falsey + it "builds from HTTP::Params and validates" do + example = Example.from_urlencoded(params) + example.valid?.should be_true end end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index eba4486..e18c8c3 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,2 +1,33 @@ require "spec" require "../src/schema" + +struct Example + include Schema::Definition + include Schema::Validation + + getter email : String + getter name : String + getter age : Int32 + getter alive : Bool + getter childrens : Array(String) + getter childrens_ages : Array(Int32) + getter address : Address + + validate :email, match: /\w+@\w+\.\w{2,3}/, message: "Email must be valid!" + + class Address + include Schema::Definition + + getter street : String + getter zip : String + getter city : String + getter location : Location + end + + class Location + include Schema::Definition + getter longitude : Float64 + getter latitude : Float64 + getter useful : Bool + end +end diff --git a/spec/validation_spec.cr b/spec/validation_spec.cr index 57759c3..81ac3fa 100644 --- a/spec/validation_spec.cr +++ b/spec/validation_spec.cr @@ -1,28 +1,9 @@ require "./spec_helper" - -class EmailValidator - getter :record, :message - - def initialize(@record : UserModel, @message : String) - end - - def valid? - true - end -end - -class UniqueRecordValidator - getter :record, :message - - def initialize(@record : UserModel, @message : String) - end - - def valid? - false - end -end +require "../src/schema/validation" class UserModel + include Schema::Validation + property email : String property name : String property age : Int32 @@ -31,83 +12,70 @@ class UserModel property childrens_ages : Array(Int32) property last_name : String - validation do - use UniqueRecordValidator, EmailValidator - validate email, match: /\w+@\w+\.\w{2,3}/, message: "Email must be valid!", unique_record: true, email: true - validate name, size: (1..20) - validate age, gte: 18, lte: 25, message: "Must be 24 and 30 years old", some: "hello" - validate alive, eq: true - validate childrens - validate childrens_ages, if: something? - validate last_name, presence: true, message: "Last name is required" + use EmailValidator, UniqueRecordValidator + validate :email, match: /\w+@\w+\.\w{2,3}/, message: "Email must be valid!" + validate :name, size: (1..20) + validate :age, gte: 18, lte: 25, message: "Age must be 18 and 25 years old" + validate :alive, eq: true + validate :last_name, presence: true, message: "Last name is invalid" - predicates do - def some?(value, compare) : Bool - false - end + predicates do + def some?(value : String, some) : Bool + (!value.nil? && value != "") && !some.nil? + end - def if?(value : Array(Int32), bool : Bool) : Bool - !bool - end + def if?(value : Array(Int32), bool : Bool) : Bool + !bool end end def initialize(@email, @name, @age, @alive, @childrens, @childrens_ages, @last_name) end - def something? - false + def add_custom_rule + errors << Schema::Error.new(:fake, "fake error message") end +end - def add_custom_rule - errors << {:fake, "fake error message"} +class EmailValidator < Schema::Validator + getter :record, :field, :message + + def initialize(@record : UserModel) + @field = :email + @message = "Email must be valid!" + end + + def valid? : Array(Schema::Error) + [] of Schema::Error end end -describe Schema::Validation do - subject = UserModel.new( - "fake@example.com", - "Fake name", - 25, - true, - ["Child 1", "Child 2"], - [9, 12], - "" - ) +class UniqueRecordValidator < Schema::Validator + getter :record, :field, :message - context "with custom validator" do - it "it validates the user" do - subject.errors.clear - subject.valid?.should be_falsey - subject.errors.map(&.message).should eq ["Email must be valid!", "Must be 24 and 30 years old", "Last name is required"] - end + def initialize(@record : UserModel) + @field = :email + @message = "Record must be unique!" end - context "with custom predicate" do - it "validates the user" do - subject.errors.clear - subject.valid?.should be_falsey - subject.errors.map(&.message).should eq ["Email must be valid!", "Must be 24 and 30 years old", "Last name is required"] - end + def valid? : Array(Schema::Error) + [] of Schema::Error end +end - context "when adding your own errors" do +describe Schema::Validation do + context "with custom validator" do subject = UserModel.new( - "fake@example.com", - "Fake name", - 25, - true, - ["Child 1", "Child 2"], - [9, 12], - "" + "bad", "Fake name", 38, true, ["Child 1", "Child 2"], [9, 12], "" ) - it "adds custom rules" do - subject.errors.clear - subject.add_custom_rule - - subject.errors.size.should eq 4 - subject.errors.map(&.message).should contain "fake error message" + it "it validates the user" do + subject.valid?.should be_falsey + subject.errors.map(&.message).should eq [ + "Email must be valid!", + "Age must be 18 and 25 years old", + "Last name is invalid", + ] end end end diff --git a/spec/validators_spec.cr b/spec/validators_spec.cr index fec1d10..781d07f 100644 --- a/spec/validators_spec.cr +++ b/spec/validators_spec.cr @@ -1,7 +1,7 @@ require "./spec_helper" -include Schema::Validators +include Schema::Predicates -describe Schema::Validators do +describe Schema::Predicates do describe "#eq?" do it { eq?(1, 1).should be_true } it { eq?("one", "one").should be_true } diff --git a/src/schema.cr b/src/schema.cr index f666bc3..9596f3b 100644 --- a/src/schema.cr +++ b/src/schema.cr @@ -1,11 +1,5 @@ -require "./schema/validation" -require "./schema/error" -require "./schema/errors" -require "./schema/rule" -require "./schema/rules" -require "./schema/cast" require "./schema/definition" -require "./schema/schema_macro" +require "./schema/validation" # A schema is an abstraction to handle validation of # arbitrary data or object state. It is a fully self-contained @@ -14,5 +8,5 @@ require "./schema/schema_macro" # The Schema macros helps you define schemas and assists # with instantiating and validating data with those schemas at runtime. module Schema - VERSION = "0.1.0" + VERSION = "0.2.0" end diff --git a/src/schema/cast.cr b/src/schema/cast.cr deleted file mode 100644 index 717dfd1..0000000 --- a/src/schema/cast.cr +++ /dev/null @@ -1,38 +0,0 @@ -module Schema - module Cast(T) - def value : T - convert(T) - end - - def convert(asType : String.class) - @value - end - - def convert(asType : Bool.class) - [1, "true", "yes"].includes?(@value) - end - - def convert(asType : Int32.class) - @value.to_i32 - end - - def convert(asType : Int64.class) - @value.to_i64 - end - - def convert(asType : Float32.class) - @value.to_f32 - end - - def convert(asType : Float64.class) - @value.to_f64 - end - end - - class ConvertTo(T) - include Cast(T) - - def initialize(@value : String) - end - end -end diff --git a/src/schema/definition.cr b/src/schema/definition.cr index ffea5c5..c7d712e 100644 --- a/src/schema/definition.cr +++ b/src/schema/definition.cr @@ -1,53 +1,299 @@ require "json" -require "yaml" +require "http" module Schema + annotation Settings + end + module Definition + annotation Field + end + macro included - CONTENT_ATTRIBUTES = {} of Nil => Nil - FIELD_OPTIONS = {} of Nil => Nil + include JSON::Serializable + + {% settings = @type.annotation(::Schema::Settings) || {strict: false, unmapped: false} %} + {% raise "strict and unmapped are mutually exclusive" if settings[:strict] && settings[:unmapped] %} + {% if settings[:strict] %} + include JSON::Serializable::Strict + {% elsif settings[:unmapped] %} + include JSON::Serializable::Unmapped + + @[JSON::Field(ignore: true)] + getter query_unmapped = Hash(String, Array(String)).new + {% end %} + + def self.from_urlencoded(string) + new(HTTP::Params.parse(string)) + end - macro finished - __process_params + def self.new(http_params, path : Array(String) = [] of String) + new_from_http_params(http_params, path) + end + + private def self.new_from_http_params(http_params, path : Array(String) = [] of String) + instance = allocate + instance.initialize(__http_params_from_schema: http_params, __path_from_schema: path) + GC.add_finalizer(instance) if instance.responds_to?(:finalize) + instance end end - macro param(attribute, **options) - {% FIELD_OPTIONS[attribute.var] = options %} - {% CONTENT_ATTRIBUTES[attribute.var] = options || {} of Nil => Nil %} - {% CONTENT_ATTRIBUTES[attribute.var][:type] = attribute.type %} + macro string_value_from_params(params, name, nilable, has_default) + %values = string_values_from_params({{params}}, {{name}}, {{nilable}}, {{has_default}}) + {% if nilable || has_default %} + %values.empty? ? nil : %values.last + {% else %} + %values.last + {% end %} end - private macro __process_params - {% for name, options in FIELD_OPTIONS %} - {% type = options[:type] %} - {% nilable = options[:nilable] != nil ? true : false %} - {% key = options[:key] != nil ? options[:key] : name.downcase.stringify %} - @[JSON::Field(emit_null: {{nilable}}, key: {{key}})] - @[YAML::Field(emit_null: {{nilable}}, key: {{key}})] - getter {{name}} : {{type}} + macro string_values_from_params(params, name, nilable, has_default) + {% if nilable || has_default %} + {{params}}.fetch_all({{name}}) + {% else %} + {{params}}.fetch_all({{name}}).tap do |values| + raise KeyError.new(%|Missing hash key: "#{{{name}}}"|) if values.empty? + end {% end %} + end - def initialize(params : Hash(String, String) | HTTP::Params | _, prefix = "") - {% for name, options in FIELD_OPTIONS %} - {% field_type = CONTENT_ATTRIBUTES[name][:type] %} - {% key = name.id %} - key = "#{prefix}{{key.id}}" + def initialize(*, __http_params_from_schema http_params, __path_from_schema path = [] of String) + {% begin %} + {% settings = @type.annotation(::Schema::Settings) || {strict: false, unmapped: false} %} + {% if settings[:strict] || settings[:unmapped] %} + handled_param_names = [] of String + {% end %} - {% if options[:inner] %} - @{{name.id}} = {{field_type}}.new(params, "#{key}.") - {% else %} - {% if field_type.is_a?(Generic) %} - {% sub_type = field_type.type_vars %} - @{{name.id}} = params[key].split(",").map do |item| - Schema::ConvertTo({{sub_type.join('|').id}}).new(item).value + {% for ivar in @type.instance_vars %} + {% non_nil_type = ivar.type.union? ? ivar.type.union_types.reject { |type| type == ::Nil }.first : ivar.type %} + {% nilable = ivar.type.nilable? %} + {% has_default = ivar.has_default_value? %} + {% default = has_default ? ivar.default_value : nil %} + {% ann = ivar.annotation(::Schema::Definition::Field) %} + {% converter = ann && ann[:converter] %} + {% key = (ann && ann[:key] || ivar.name.stringify) %} + + %param_name = (path + [{{key}}]).reduce { |result, fragment| "#{result}[#{fragment}]" } + {% if converter %} + %values = string_values_from_params(http_params, %param_name, {{nilable}}, {{has_default}}) + {% if nilable || has_default %} + if %values.empty? + @{{ivar.name}} = {{default}} + else + {% end %} + @{{ivar.name}} = {{converter}}.from_params(%values) + {% if nilable || has_default %} + end + {% end %} + + {% elsif non_nil_type <= Array %} + %values = string_values_from_params(http_params, "#{%param_name}[]", {{nilable}}, {{has_default}}) + {% if nilable || has_default %} + if %values.empty? + @{{ivar.name}} = {{default}} + else + {% end %} + + @{{ivar.name}} = %values.map do |item| + {% item_type = non_nil_type.type_vars.first %} + {% if item_type <= String %} + item + {% elsif item_type == Bool %} + !\%w[0 false no].includes?(item) + {% elsif item_type <= Enum %} + {{item_type}}.parse(item) + {% else %} + {{item_type}}.new(item) + {% end %} + end + + {% if nilable || has_default %} + end + {% end %} + + {% if settings[:strict] || settings[:unmapped] %} + handled_param_names << "#{%param_name}[]" + {% end %} + + {% elsif non_nil_type <= Tuple %} + %values = string_values_from_params(http_params, "#{%param_name}[]", {{nilable}}, {{has_default}}) + {% if nilable || has_default %} + if %values.empty? + @{{ivar.name}} = {{default}} + else + {% end %} + @{{ivar.name}} = { + {% for item_type, index in non_nil_type.type_vars %} + {% if item_type <= String %} + %values[{{index}}], + {% elsif item_type == Bool %} + !\%w[0 false no].includes?(%values[{{index}}]), + {% elsif item_type <= Enum %} + {{item_type}}.parse(%values[{{index}}]), + {% else %} + {{item_type}}.new(%values[{{index}}]), + {% end %} + {% end %} + } + {% if nilable || has_default %} + end + {% end %} + + {% if settings[:strict] || settings[:unmapped] %} + handled_param_names << "#{%param_name}[]" + {% end %} + + {% elsif non_nil_type <= ::Schema::Definition %} + %nested_params = HTTP::Params.new({} of String => Array(String)) + http_params.each do |key, value| + if key.starts_with?("#{%param_name}[") + %nested_params.add(key, value) + {% if settings[:strict] || settings[:unmapped] %} + handled_param_names << key + {% end %} + end + end + + if %nested_params.any? + @{{ivar.name}} = {{non_nil_type}}.new( + %nested_params, + path + [{{ivar.name.stringify}}] + ) + else + {% if nilable || has_default %} + @{{ivar.name}} = {{default}} + {% else %} + raise KeyError.new(%|Missing nested hash keys: "#{%param_name}"|) + {% end %} + end + + {% elsif non_nil_type == String %} + %value = string_value_from_params(http_params, %param_name, {{nilable}}, {{has_default}}) + {% if nilable || has_default %} + @{{ivar.name}} = %value || {{default}} + {% else %} + @{{ivar.name}} = %value + {% end %} + + {% if settings[:strict] || settings[:unmapped] %} + handled_param_names << %param_name + {% end %} + + {% elsif non_nil_type == Bool %} + %value = string_value_from_params(http_params, %param_name, {{nilable}}, {{has_default}}) + {% if nilable || has_default %} + if %value.nil? + @{{ivar.name}} = {{default}} + else + {% end %} + @{{ivar.name}} = !\%w[0 false no].includes?(%value.downcase) + {% if nilable || has_default %} end + {% end %} + {% if settings[:strict] || settings[:unmapped] %} + handled_param_names << %param_name + {% end %} + + {% elsif non_nil_type <= ::Enum %} + %value = string_value_from_params(http_params, %param_name, {{nilable}}, {{has_default}}) + {% if nilable || has_default %} + @{{ivar.name}} = %value.try { |value| {{non_nil_type}}.parse(value) } || {{default}} {% else %} - @{{name.id}} = Schema::ConvertTo({{field_type}}).new(params[key]).value + @{{ivar.name}} = {{non_nil_type}}.parse(%value) + {% end %} + + {% if settings[:strict] || settings[:unmapped] %} + handled_param_names << %param_name + {% end %} + + {% elsif non_nil_type <= Hash %} + %value = {{non_nil_type}}.new + escaped_param_name = Regex.escape(%param_name) + + {% key_type = non_nil_type.type_vars[0] %} + {% value_type = non_nil_type.type_vars[1] %} + + {% if value_type <= Array %} + matcher = /^#{escaped_param_name}\[(?[^\]]+)\]\[\]$/ + {% else %} + matcher = /^#{escaped_param_name}\[(?[^\]]+)\]$/ + {% end %} + + http_params.each do |key, value| + match = key.match(matcher) + next if match.nil? + + {% if key_type <= String %} + key = match["key"] + {% else %} + key = {{key_type}}.new(match["key"]) + {% end %} + + {% element_type = value_type <= Array ? value_type.type_vars.first : value_type %} + + {% if element_type <= String %} + {% elsif element_type == Bool %} + value = !\%w[0 false no].includes?(value) + {% elsif element_type <= Enum %} + value = {{element_type}}.parse(value) + {% else %} + value = {{element_type}}.new(value) + {% end %} + + {% if value_type <= Array %} + if %value.has_key?(key) + %value[key] << value + else + %value[key] = {{value_type}}.new(1) { value } + end + {% else %} + %value[key] = value + {% end %} + + {% if settings[:strict] || settings[:unmapped] %} + handled_param_names << key + {% end %} + end + + if %value.empty? + {% if nilable || has_default %} + @{{ivar.name}} = {{default}} + {% else %} + raise KeyError.new(%|Missing nested keys for: "#{%param_name}"|) + {% end %} + else + @{{ivar.name}} = %value + end + + {% else %} + %value = string_value_from_params(http_params, %param_name, {{nilable}}, {{has_default}}) + {% if nilable || has_default %} + if %value.nil? + @{{ivar.name}} = {{default}} + else + {% end %} + @{{ivar.name}} = {{non_nil_type}}.new(%value) + {% if nilable || has_default %} + end + {% end %} + {% if settings[:strict] || settings[:unmapped] %} + handled_param_names << %param_name {% end %} {% end %} {% end %} - end + + {% if settings[:strict] || settings[:unmapped] %} + http_params.each do |key, _| + next if handled_param_names.includes?(key) + {% if settings[:strict] %} + raise %|Unknown param: "#{key}"| + {% else %} + @query_unmapped[key] = http_params.fetch_all(key) + {% end %} + end + {% end %} + {% end %} end end end diff --git a/src/schema/error.cr b/src/schema/error.cr deleted file mode 100644 index 97cb576..0000000 --- a/src/schema/error.cr +++ /dev/null @@ -1,22 +0,0 @@ -module Schema - struct Error(T, S) - def self.from(rule : T) - new(rule.record, rule.message) - end - - def self.from_tuple(tuple : Tuple(Symbol, String)) - new(tuple.first, tuple.last) - end - - def initialize(@record : S, @message : String) - end - - def field - @record - end - - def message - @message - end - end -end diff --git a/src/schema/errors.cr b/src/schema/errors.cr deleted file mode 100644 index 562efb1..0000000 --- a/src/schema/errors.cr +++ /dev/null @@ -1,19 +0,0 @@ -module Schema - class Errors(T, S) < Array(Error(T, S)) - def <<(rule : T) - self << Error(T, S).from(rule) - end - - def <<(tuple : Tuple(Symbol, String)) - self << Error(T, S).from_tuple(tuple) - end - - def <<(error : Error(T, S)) - push error unless includes?(error) - end - - def messages - map { |e| e.message } - end - end -end diff --git a/src/schema/rule.cr b/src/schema/rule.cr deleted file mode 100644 index 333f43e..0000000 --- a/src/schema/rule.cr +++ /dev/null @@ -1,15 +0,0 @@ -module Schema - class Rule - include Schema::Validators - - getter :record, :message - - def initialize(@record : Symbol, @message : String, &block : Rule -> Bool) - @block = block - end - - def valid? - @block.call(self) - end - end -end diff --git a/src/schema/rules.cr b/src/schema/rules.cr deleted file mode 100644 index b208d2f..0000000 --- a/src/schema/rules.cr +++ /dev/null @@ -1,14 +0,0 @@ -require "./validation" - -module Schema - class Rules(T, S) < Array(T) - @errors = Errors(T, S).new - - def errors - reduce(@errors) do |errors, rule| - errors << rule unless rule.valid? - errors - end - end - end -end diff --git a/src/schema/schema_macro.cr b/src/schema/schema_macro.cr deleted file mode 100644 index 9cc7fd1..0000000 --- a/src/schema/schema_macro.cr +++ /dev/null @@ -1,25 +0,0 @@ -macro schema(name = nil, nilable = false) - {% if name != nil %} - param {{name.id.underscore}} : {{name.id}}, inner: true, nilable: required - {% end %} - - {% if name != nil %} - struct {{name.id}} - include JSON::Serializable - include YAML::Serializable - include Schema::Definition - include Schema::Validation - {% end %} - - {{yield}} - - {% if name != nil %} - end - {% end %} -end - -macro validation - include Schema::Validation - - {{yield}} -end diff --git a/src/schema/validation.cr b/src/schema/validation.cr index 2f2d498..225f2af 100644 --- a/src/schema/validation.cr +++ b/src/schema/validation.cr @@ -1,95 +1,86 @@ -require "./validations/*" +require "./validation/predicates" +require "./validation/error" +require "./validation/validator" +require "./validation/constraint" module Schema - module Validators - include Equal - include Exclusion - include GreaterThan - include GreaterThanOrEqual - include Inclusion - include LessThan - include LessThanOrEqual - include RegularExpression - include Size - include Presence - end - module Validation - CONTENT_ATTRIBUTES = {} of Nil => Nil - FIELD_OPTIONS = {} of Nil => Nil - CUSTOM_VALIDATORS = {} of Nil => Nil + class ValidationError < Exception + def initialize(@errors : Array(Error)) + end - macro validate(attribute, **options) - {% FIELD_OPTIONS[attribute] = options %} - {% CONTENT_ATTRIBUTES[attribute] = options || {} of Nil => Nil %} + def message + @errors.map(&.message).join(",") + end end macro use(*validators) {% for validator in validators %} - {% CUSTOM_VALIDATORS[validator.stringify] = @type %} + {% SCHEMA_VALIDATORS << validator %} {% end %} end + macro validate(attribute, **options) + {% SCHEMA_VALIDATIONS[attribute] = options %} + end + macro predicates - module ::Schema - module Validators - {{yield}} - end + module Schema::Predicates + {{yield}} end end - macro included - macro finished - __process_validation + macro create_validator(type_validator) + {% type_validator = type_validator.resolve %} + + class Validator + def self.validate(instance : {{type_validator}}) + errors = Array(Schema::Error).new + rules = Array(Schema::Constraint | Schema::Validator).new + validations(rules, instance) + rules.reduce([] of Schema::Error) do |errors, rule| + errors + rule.valid? + end + end + + private def self.validations(rules, instance) + {% for validtor in type_validator.constant(:SCHEMA_VALIDATORS) %} + rules << {{validtor}}.new(instance) + {% end %} + + rules << Schema::Constraint.new do |rule, errors| + {% for name, options in type_validator.constant(:SCHEMA_VALIDATIONS) %} + {% for predicate, expected_value in options %} + {% if !["message"].includes?(predicate.stringify) %} + unless rule.{{predicate.id}}?(instance.{{name.id}}, {{expected_value}}) + errors << Schema::Error.new(:{{name.id}}, {{options["message"] || "Invalid field: " + name.stringify}}) + end + {% end %} + {% end %} + {% end %} + end + end end end - macro __process_validation - {% CUSTOM_VALIDATORS["Schema::Rule"] = "Symbol" %} - {% custom_validators = CUSTOM_VALIDATORS.keys.map { |v| v.id }.join("|") %} - {% custom_types = CUSTOM_VALIDATORS.values.map { |v| v.id }.join("|") %} - - @[JSON::Field(ignore: true)] - @[YAML::Field(ignore: true)] - getter rules : Schema::Rules({{custom_validators.id}}, {{custom_types.id}}) = - Schema::Rules({{custom_validators.id}},{{custom_types.id}}).new + macro included + SCHEMA_VALIDATORS = [] of Nil + SCHEMA_VALIDATIONS = {} of Nil => Nil def valid? - load_validations_rules - rules.errors.empty? + errors.empty? end def validate! - valid? || raise errors.messages.join "," + valid? || raise ValidationError.new(errors) end def errors - rules.errors + Validator.validate(self) end - private def load_validations_rules - {% for name, options in FIELD_OPTIONS %} - {% for predicate, expected_value in options %} - {% custom_validator = predicate.id.stringify.split('_').map(&.capitalize).join("") + "Validator" %} - {% if !["message", "type"].includes?(predicate.stringify) && CUSTOM_VALIDATORS[custom_validator] != nil %} - rules << {{custom_validator.id}}.new(self, {{options[:message]}} || "") - {% end %} - {% end %} - - rules << Schema::Rule.new(:{{name.id}}, {{options[:message]}} || "") do |rule| - {% for predicate, expected_value in options %} - {% custom_validator = predicate.id.stringify.split('_').map(&.capitalize).join("") + "Validator" %} - {% if !["message", "param_type", "type", "inner", "nilable"].includes?(predicate.stringify) && CUSTOM_VALIDATORS[custom_validator] == nil %} - rule.{{predicate.id}}?(@{{name.id}}, {{expected_value}}) & - {% end %} - {% end %} - {% if options[:inner] %} - @{{name.id}}.valid? - {% else %} - true - {% end %} - end - {% end %} + macro finished + create_validator(\{{ @type }}) end end end diff --git a/src/schema/validation/constraint.cr b/src/schema/validation/constraint.cr new file mode 100644 index 0000000..8fbbbd6 --- /dev/null +++ b/src/schema/validation/constraint.cr @@ -0,0 +1,16 @@ +module Schema + class Constraint + include Schema::Predicates + + @errors = Array(Schema::Error).new + + def initialize(&block : Constraint, Array(Schema::Error) -> Nil) + @block = block + end + + def valid? : Array(Schema::Error) + @block.call(self, @errors) + @errors + end + end +end diff --git a/src/schema/validation/error.cr b/src/schema/validation/error.cr new file mode 100644 index 0000000..8b632fa --- /dev/null +++ b/src/schema/validation/error.cr @@ -0,0 +1,8 @@ +module Schema + struct Error + getter :field, :message + + def initialize(@field : Symbol, @message : String) + end + end +end diff --git a/src/schema/validation/errors.cr b/src/schema/validation/errors.cr new file mode 100644 index 0000000..5df0acf --- /dev/null +++ b/src/schema/validation/errors.cr @@ -0,0 +1,11 @@ +module Schema + class Errors + @errors = Array(Error).new + + forward_missing_to @errors + + def messages + @errors.map &.message + end + end +end diff --git a/src/schema/validation/predicates.cr b/src/schema/validation/predicates.cr new file mode 100644 index 0000000..178db31 --- /dev/null +++ b/src/schema/validation/predicates.cr @@ -0,0 +1,16 @@ +require "./predicates/*" + +module Schema + module Predicates + include Equal + include Exclusion + include GreaterThan + include GreaterThanOrEqual + include Inclusion + include LessThan + include LessThanOrEqual + include RegularExpression + include Size + include Presence + end +end diff --git a/src/schema/validations/equal.cr b/src/schema/validation/predicates/equal.cr similarity index 100% rename from src/schema/validations/equal.cr rename to src/schema/validation/predicates/equal.cr diff --git a/src/schema/validations/exclusion.cr b/src/schema/validation/predicates/exclusion.cr similarity index 100% rename from src/schema/validations/exclusion.cr rename to src/schema/validation/predicates/exclusion.cr diff --git a/src/schema/validations/greater_than.cr b/src/schema/validation/predicates/greater_than.cr similarity index 100% rename from src/schema/validations/greater_than.cr rename to src/schema/validation/predicates/greater_than.cr diff --git a/src/schema/validations/greater_than_or_equal.cr b/src/schema/validation/predicates/greater_than_or_equal.cr similarity index 100% rename from src/schema/validations/greater_than_or_equal.cr rename to src/schema/validation/predicates/greater_than_or_equal.cr diff --git a/src/schema/validations/inclusion.cr b/src/schema/validation/predicates/inclusion.cr similarity index 100% rename from src/schema/validations/inclusion.cr rename to src/schema/validation/predicates/inclusion.cr diff --git a/src/schema/validations/less_than.cr b/src/schema/validation/predicates/less_than.cr similarity index 100% rename from src/schema/validations/less_than.cr rename to src/schema/validation/predicates/less_than.cr diff --git a/src/schema/validations/less_than_or_equal.cr b/src/schema/validation/predicates/less_than_or_equal.cr similarity index 100% rename from src/schema/validations/less_than_or_equal.cr rename to src/schema/validation/predicates/less_than_or_equal.cr diff --git a/src/schema/validations/presence.cr b/src/schema/validation/predicates/presence.cr similarity index 100% rename from src/schema/validations/presence.cr rename to src/schema/validation/predicates/presence.cr diff --git a/src/schema/validations/regular_expression.cr b/src/schema/validation/predicates/regular_expression.cr similarity index 100% rename from src/schema/validations/regular_expression.cr rename to src/schema/validation/predicates/regular_expression.cr diff --git a/src/schema/validations/size.cr b/src/schema/validation/predicates/size.cr similarity index 100% rename from src/schema/validations/size.cr rename to src/schema/validation/predicates/size.cr diff --git a/src/schema/validation/validator.cr b/src/schema/validation/validator.cr new file mode 100644 index 0000000..8f5ae67 --- /dev/null +++ b/src/schema/validation/validator.cr @@ -0,0 +1,10 @@ +module Schema + abstract class Validator + include Schema::Predicates + + def initialize(@record) + end + + abstract def valid? : Array(Error) + end +end