-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #8 from eliasjpr/refactor
[v0.2.0] Refactor and API Changes
- Loading branch information
Showing
34 changed files
with
715 additions
and
740 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 protected]&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,108 +150,72 @@ 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 | ||
property alive : Bool | ||
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,23 +274,15 @@ regex - Regular Expression | |
eq - Equal | ||
``` | ||
|
||
> **CONTRIBUTE** - Add more predicates to this shards by contributing a Pull Request. | ||
Additional params | ||
|
||
```crystal | ||
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 (<https://github.com/your-github-user/schemas/fork>) | ||
|
Oops, something went wrong.