Skip to content

Commit

Permalink
feat: nested params ✨
Browse files Browse the repository at this point in the history
After ~30 hours of trials I've found an ugly but working solution!

Closes #24, related to #5
  • Loading branch information
vladfaust committed May 11, 2018
1 parent d4da20f commit b734566
Show file tree
Hide file tree
Showing 11 changed files with 400 additions and 128 deletions.
2 changes: 1 addition & 1 deletion spec/action/params_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ module Prism::Action::Params::Spec
end

it "halts" do
response.body.should eq "Parameter \"id\" is expected to be Int32"
response.body.should eq "Parameter \"id\" is expected to be Int32 (given foo)"
end
end

Expand Down
61 changes: 43 additions & 18 deletions spec/params_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ module Prism::Params::Specs
param :time, Time?
param :float_value, Float64?
param :"kebab-param", String?, proc: ->(p : String) { p.upcase }

param :nest1, nilable: true do
param :nest2 do
param :bar, Int32, validate: {max: 42}
end

param :foo, String?, proc: ->(p : String) { p.downcase }
end
end

@@last_params = uninitialized ParamsTuple
Expand All @@ -25,7 +33,7 @@ module Prism::Params::Specs

describe SimpleAction do
context "with valid params" do
response = handle_request(SimpleAction, Req.new(method: "GET", resource: "/?id=42&value=42&time=1506952232&kebab-param=foo"))
response = handle_request(SimpleAction, Req.new(method: "GET", resource: "/?id=42&value=42&time=1506952232&kebab-param=foo&nest1[nest2][bar]=41&nest1[foo]=BAR"))

it "doesn't halt" do
response.body.should eq "ok"
Expand All @@ -46,25 +54,13 @@ module Prism::Params::Specs
it "has kebab-param in params" do
SimpleAction.last_params["kebab-param"].should eq "FOO"
end
end

describe "testing certain content types" do
context "JSON" do
response = handle_request(SimpleAction, Req.new(
method: "POST",
resource: "/",
body: {
id: 42,
float_value: 0.000000000001,
}.to_json,
headers: HTTP::Headers{
"Content-Type" => "application/json",
}
))
it "has nest1 -> nest2 -> bar in params" do
SimpleAction.last_params[:nest1]?.try &.[:nest2]?.try &.[:bar].should eq 41
end

it "properly parses float" do
SimpleAction.last_params[:float_value].should eq 0.000000000001
end
it "has nest1 foo in params" do
SimpleAction.last_params[:nest1]?.try &.[:foo].should eq "bar"
end
end

Expand Down Expand Up @@ -109,5 +105,34 @@ module Prism::Params::Specs
end
end
end

describe "testing certain content types" do
context "JSON" do
response = handle_request(SimpleAction, Req.new(
method: "POST",
resource: "/",
body: {
id: 42,
float_value: 0.000000000001,
nest1: {
nest2: {
bar: 41,
},
},
}.to_json,
headers: HTTP::Headers{
"Content-Type" => "application/json",
}
))

it "properly parses float" do
SimpleAction.last_params[:float_value].should eq 0.000000000001
end

it "has nested params" do
SimpleAction.last_params[:nest1]?.try &.[:nest2]?.try &.[:bar].should eq 41
end
end
end
end
end
20 changes: 20 additions & 0 deletions src/prism/ext/json/any/dig.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
struct JSON::Any
def dig?(keys)
raise ArgumentError.new("Keys must not be empty!") if keys.empty?

if keys.size > 1
key = keys.shift
value = self[key]?

return unless value

if value.is_a?(JSON::Any)
value.dig?(keys)
else
raise ArgumentError.new("JSON is expected to have JSON::Any value at key #{key}")
end
else
self[keys.first]?
end
end
end
22 changes: 13 additions & 9 deletions src/prism/params.cr
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,15 @@ require "./params/**"
#
# params do
# param :foo, Int32?
# param :name, String, validate: ->(name : String?) { name.size >= 3 }
# param :name, String, validate: {size: {min: 3}}
# param "kebab-case-time", Time?
# param :bar, nilable: true do # Nested params are supported too
# param :baz do
# param :qux, String?
# end
#
# param :quux, Int32, proc: (quux : Int32) -> { quux * 2 }
# end
# end
#
# def self.call(context)
Expand All @@ -31,6 +38,9 @@ require "./params/**"
#
# p params["kebab-case-time"].class
# # => Time?
#
# p params[:bar]?.try &.[:baz][:qux].class
# # => String?
# end
# end
# ```
Expand Down Expand Up @@ -79,18 +89,12 @@ module Prism::Params
# ```
macro params(&block)
INTERNAL__PRISM_PARAMS = [] of NamedTuple
INTERNAL__PRISM_PARAMS_PARENTS = {current_value: [] of Symbol, nilable: {} of Array(Symbol) => Bool}

{{yield}}

define_params_tuple
define_param_type
define_parse_params
end

private macro define_params_tuple
alias ParamsTuple = NamedTuple(
{% for param in INTERNAL__PRISM_PARAMS %}
"{{param[:name].id}}": {{param[:type].id}}
{% end %}
)
end
end
30 changes: 0 additions & 30 deletions src/prism/params/casting.cr

This file was deleted.

147 changes: 116 additions & 31 deletions src/prism/params/definition.cr
Original file line number Diff line number Diff line change
@@ -1,43 +1,128 @@
module Prism::Params
# Define a single param. Must be called within the `#params` block.
# Define a single param or nested params. Must be called within the `#params` block.
#
# **Arguments:**
#
# - *name* declares an access key for the `params` tuple
# - *type* defines a type which the param must be casted to, otherwise validation will fail (i.e. "foo" won't cast to `Int32`)
# - *:nilable* option declares if this param is nilable (the same effect is achieved with nilable *type*, i.e. `Int32?`)
# - *validate* option accepts a `Proc` which must return truthy value for the param to pass validation
# - *proc* allows to call `Proc` each time the param is casted (after validation). The param becomes the returned value, so this *proc* **must** return the same type.
#
# NOTE: If a param is nilable, but is present and of invalid type, an `InvalidParamTypeError` will be raised.
# Example:
#
# ```
# params do
# param :id, Int32, validate: ->(id : Int32) { id > 0 }
# param :name, String? # => Nilable
# param :age, Int32, nilable: true # => Nilable as well
# param :uuid, String, proc: ->(uuid : String) do
# UUID.new(uuid)
# rescue ex : ArgumentError
# error!(:uuid, ex.message)
# param :user do
# param :email, String, validate: {regex: /@/}
# param :password, String, validate: {size: (0..32)}
# param :age, Int32?
# end
# end
# ```
macro param(name, type _type, **options)
#
# **Nested params** (e.g. `param :user do`) can have following options:
#
# - *nilable* (`false` by default)
#
# **Single param** has two mandatory arguments:
#
# - *name* declares an access key for the `params` tuple
# - *type* defines a type which the param must be casted to, otherwise validation will fail (i.e. "foo" won't cast to `Int32`)
#
# **Single param** can also have some options:
#
# - *nilable* declares if this param is nilable (the same effect is achieved with nilable *type*, i.e. `Int32?`)
# - *validate* defines validation options. See `Validation`
# - *proc* will be called each time the param is casted (right after validation). The param becomes the returned value, so this *proc* **must** return the same type
#
# NOTE: If a param is nilable, but is present and of invalid type, an `InvalidParamTypeError` will be raised.
macro param(name, type _type = nil, **options, &block)
{% if block %}
{%
INTERNAL__PRISM_PARAMS_PARENTS[:current_value].push(name)

if options[:nilable]
INTERNAL__PRISM_PARAMS_PARENTS[:nilable][INTERNAL__PRISM_PARAMS_PARENTS[:current_value].select { |x| x }] = options[:nilable]
end
%}

{{yield}}

\{%
current_size = INTERNAL__PRISM_PARAMS_PARENTS[:current_value].select{ |x| x }.size
INTERNAL__PRISM_PARAMS_PARENTS[:current_value][current_size - 1] = nil
INTERNAL__PRISM_PARAMS_PARENTS[:current_value] = INTERNAL__PRISM_PARAMS_PARENTS[:current_value].select{ |x| x }
%}
{% else %}
{%
raise "Expected param type" unless _type

nilable = if options[:nilable] == nil
"#{_type}".includes?("?") || "#{_type}".includes?("Nil")
else
options[:nilable]
end

INTERNAL__PRISM_PARAMS.push({
parents: INTERNAL__PRISM_PARAMS_PARENTS[:current_value].size > 0 ? INTERNAL__PRISM_PARAMS_PARENTS[:current_value].map { |x| x } : nil,
name: name,
type: _type,
nilable: nilable,
validate: options[:validate],
proc: options[:proc],
})
%}
{% end %}
end

# TODO: Introduce macro recursion
private macro define_params_tuple
{%
nilable = if options[:nilable] == nil
"#{_type}".includes?("?") || "#{_type}".includes?("Nil")
else
options[:nilable]
end

INTERNAL__PRISM_PARAMS.push({
name: name,
type: _type,
nilable: nilable,
validations: options[:validate],
proc: options[:proc],
})
tuple_hash = INTERNAL__PRISM_PARAMS.reduce({} of Object => Object) do |hash, param|
if !param[:parents]
hash[param[:name].id.stringify] = param[:type]
else
if param[:parents].size == 0
hash[param[:name].id.stringify] = param[:type]
elsif param[:parents].size == 1
key = param[:parents][0].id.stringify
hash[key] = {} of Object => Object unless hash[key]
hash[key]["__nilable"] = true if INTERNAL__PRISM_PARAMS_PARENTS[:nilable][param[:parents]]

hash[key][param[:name].id.stringify] = param[:type]
elsif param[:parents].size == 2
key = param[:parents][0].id.stringify
hash[key] = {} of Object => Object unless hash[key]

key1 = param[:parents][1].id.stringify
hash[key][key1] = {} of Object => Object unless hash[key][key1]
hash[key][key1]["__nilable"] = true if INTERNAL__PRISM_PARAMS_PARENTS[:nilable][param[:parents]]

hash[key][key1][param[:name].id.stringify] = param[:type]
elsif param[:parents].size == 3
key = param[:parents][0].id.stringify
hash[key] = {} of Object => Object unless hash[key]

key1 = param[:parents][1].id.stringify
hash[key][key1] = {} of Object => Object unless hash[key][key1]

key2 = param[:parents][2].id.stringify
hash[key][key1][key2] = {} of Object => Object unless hash[key][key1][key2]
hash[key][key1][key2]["__nilable"] = true if INTERNAL__PRISM_PARAMS_PARENTS[:nilable][param[:parents]]

hash[key][key1][key2][param[:name].id.stringify] = param[:type]
else
raise "Too deep params nesting"
end
end

hash
end
%}

# Damn hacks
alias ParamsTuple = NamedTuple({{"#{hash}".gsub(/\"/, "\"").gsub(%r[=> {(.*), "__nilable" => true(.*)}], "=> {\\1\\2 | Nil ").gsub(/ \=>/, ":")[1..-2].id}})
end

private macro define_param_type
struct Param < AbstractParam
property value : {{INTERNAL__PRISM_PARAMS.map(&.[:type]).join(" | ").id}} | String | Hash(String, Param) | Nil

def initialize(@value)
end
end
end
end
9 changes: 5 additions & 4 deletions src/prism/params/errors.cr
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ module Prism::Params
getter name
getter expected_type

MESSAGE_TEMPLATE = "Parameter \"%{name}\" is expected to be %{type}"
MESSAGE_TEMPLATE = "Parameter \"%{name}\" is expected to be %{expected} (given %{given})"

def initialize(@name : String, @expected_type : String)
def initialize(@name : String, @expected_type : String, @given : String)
super(MESSAGE_TEMPLATE % {
name: @name,
type: @expected_type,
name: @name,
expected: @expected_type,
given: @given,
})
end
end
Expand Down
Loading

0 comments on commit b734566

Please sign in to comment.