diff --git a/spec/lucky/action_spec.cr b/spec/lucky/action_spec.cr index 0f309f998..efd642f57 100644 --- a/spec/lucky/action_spec.cr +++ b/spec/lucky/action_spec.cr @@ -129,6 +129,8 @@ class OptionalParams::Index < TestAction param bool_with_false_default : Bool? = false # This is to test that an explicit 'nil' can be assigned for nilable types param nilable_with_explicit_nil : Int32? = nil + param nilable_array_with_default : Array(String)? = [] of String + param with_array_default : Array(Int32) = [26, 37, 44] get "/optional_params" do plain_text "optional param: #{page} #{with_int_default} #{with_int_never_nil}" @@ -276,7 +278,7 @@ describe Lucky::Action do end it "returns optional param declarations" do - OptionalParams::Index.query_param_declarations.size.should eq 6 + OptionalParams::Index.query_param_declarations.size.should eq 8 OptionalParams::Index.query_param_declarations.should contain "bool_with_false_default : Bool | ::Nil" end end @@ -388,6 +390,21 @@ describe Lucky::Action do OptionalParams::Index.new(build_context(path: "/?with_int_never_nil=no_int"), params()).call end end + + it "allows nilable arrays with defaults" do + action = OptionalParams::Index.new(build_context(path: "/?page=3"), params) + action.nilable_array_with_default.should eq([] of String) + end + + it "sets a value to a nilable array" do + action = OptionalParams::Index.new(build_context(path: "/?nilable_array_with_default[]=1&nilable_array_with_default[]=2"), params) + action.nilable_array_with_default.should eq(["1", "2"]) + end + + it "allows required arrays with defaults" do + action = OptionalParams::Index.new(build_context(path: "/?with_array_default=2222222"), params) + action.with_array_default.should eq([26, 37, 44]) + end end end diff --git a/spec/lucky/params_spec.cr b/spec/lucky/params_spec.cr index f19780f91..6dea330d2 100644 --- a/spec/lucky/params_spec.cr +++ b/spec/lucky/params_spec.cr @@ -82,11 +82,11 @@ describe Lucky::Params do params = Lucky::Params.new(request) - params.nested?(:user) - params.nested?(:user) + params.nested?(:user).should eq({"name" => "Paul", "age" => "28"}) + params.nested?(:user).should eq({"name" => "Paul", "age" => "28"}) end - it "works when parsing multipart params twice" do + it "works when parsing json params twice" do request = build_request body: {page: 1}.to_json, content_type: "application/json", fixed_length: true @@ -443,6 +443,109 @@ describe Lucky::Params do end end + describe "nested_arrays" do + it "gets nested arrays from form encoded params" do + request = build_request body: "user:name=paul&user:langs[]=ruby&user:langs[]=elixir", + content_type: "application/x-www-form-urlencoded" + + params = Lucky::Params.new(request) + + params.nested_arrays?(:user).should eq({"langs" => ["ruby", "elixir"]}) + end + + it "gets nested arrays from JSON params" do + request = build_request body: {user: {name: "Paul", langs: ["ruby", "elixir"]}}.to_json, + content_type: "application/json" + request.query = "from=query" + + params = Lucky::Params.new(request) + + params.nested_arrays?(:user).should eq({"langs" => ["ruby", "elixir"]}) + end + + it "gets empty JSON params when nested key is missing" do + request = build_request body: "{}", + content_type: "application/json" + request.query = "from=query" + + params = Lucky::Params.new(request) + + params.nested_arrays?(:user).should eq({} of String => JSON::Any) + end + + it "handles JSON with charset directive in Content-Type header" do + request = build_request body: {user: {name: "Paul", langs: ["ruby", "elixir"]}}.to_json, + content_type: "application/json; charset=UTF-8" + + params = Lucky::Params.new(request) + + params.nested_arrays?(:user).should eq({"langs" => ["ruby", "elixir"]}) + end + + it "gets nested array JSON params mixed with query params" do + request = build_request body: {user: {name: "Bunyan", tags: ["tall"]}}.to_json, + content_type: "application/json" + request.query = "user:tags[]=tale" + + params = Lucky::Params.new(request) + + params.nested_arrays?(:user).should eq({"tags" => ["tall", "tale"]}) + end + + it "gets nested arrays from multipart params" do + request = build_multipart_request form_parts: { + "user:name" => "Paul", "user:langs" => ["ruby", "elixir"], + } + + params = Lucky::Params.new(request) + + params.nested_arrays?(:user).should eq({"langs" => ["ruby", "elixir"]}) + end + + it "gets nested arrays from query params" do + request = build_request body: "filter:toppings[]=sausage", content_type: "" + request.query = "filter:toppings[]=black_olive" + params = Lucky::Params.new(request) + params.nested_arrays?("filter").should eq({"toppings" => ["sausage", "black_olive"]}) + end + + it "returns an empty hash when no nested array is found" do + request = build_request body: "", content_type: "" + request.query = "a[]=1" + params = Lucky::Params.new(request) + params.nested_arrays?("a").empty?.should eq true + end + + it "gets nested array params after unescaping" do + request = build_request body: "post%3Atags[]=coding", + content_type: "application/x-www-form-urlencoded" + + params = Lucky::Params.new(request) + + params.nested_arrays?(:post).should eq({"tags" => ["coding"]}) + end + + it "raises if nested array params are missing" do + request = build_request body: "", + content_type: "application/x-www-form-urlencoded" + + params = Lucky::Params.new(request) + + expect_raises Lucky::MissingNestedParamError do + params.nested_arrays(:missing) + end + end + + it "returns empty hash if nested array params are missing" do + request = build_request body: "", + content_type: "application/x-www-form-urlencoded" + + params = Lucky::Params.new(request) + + params.nested_arrays?(:missing).should eq({} of String => Array(String)) + end + end + describe "get_file" do it "gets files" do request = build_multipart_request file_parts: { @@ -524,6 +627,39 @@ describe Lucky::Params do end end + describe "nested_array_files" do + it "gets multipart nested array params" do + request = build_multipart_request file_parts: { + "user:photos" => ["cat", "dog"], + } + + params = Lucky::Params.new(request) + + files = params.nested_array_files(:user)["photos"] + files.size.should eq(2) + File.read(files[0].path).should eq("cat") + File.read(files[1].path).should eq("dog") + end + + it "raises if nested array files are missing" do + request = build_multipart_request form_parts: {"this" => "that"} + + params = Lucky::Params.new(request) + + expect_raises Lucky::MissingNestedParamError do + params.nested_array_files(:missing) + end + end + + it "returns empty hash if nested array files are missing" do + request = build_multipart_request form_parts: {"this" => "that"} + + params = Lucky::Params.new(request) + + params.nested_array_files?(:missing).should eq({} of String => Array(Lucky::UploadedFile)) + end + end + describe "many_nested" do it "gets nested form encoded params" do request = build_request body: "users[0]:name=paul&users[1]:twitter_handle=@paulamason&users[0]:twitter_handle=@paulsmith&users[1]:name=paula&something:else=1", diff --git a/spec/support/multipart_helper.cr b/spec/support/multipart_helper.cr index a68f1e4df..bcb48ca70 100644 --- a/spec/support/multipart_helper.cr +++ b/spec/support/multipart_helper.cr @@ -57,4 +57,10 @@ module MultipartHelper multipart_file_part(formdata, nested_name, nested_value) end end + + private def multipart_file_part(formdata : HTTP::FormData::Builder, name : String, value : Array(String)) + value.each do |val| + multipart_file_part(formdata, name + "[]", val) + end + end end diff --git a/src/lucky/params.cr b/src/lucky/params.cr index 4ae4e9328..17fccd5a8 100644 --- a/src/lucky/params.cr +++ b/src/lucky/params.cr @@ -212,6 +212,14 @@ class Lucky::Params multipart_files[key.to_s]? end + def get_all_files(key : String | Symbol) : Array(Lucky::UploadedFile) + get_all_files?(key) || raise Lucky::MissingParamError.new(key.to_s) + end + + def get_all_files?(key : String | Symbol) : Array(Lucky::UploadedFile) + multipart_files.fetch_all(key.to_s) + end + # Retrieve a nested value from the params # # Nested params often appear in JSON requests or Form submissions. If no key @@ -255,6 +263,36 @@ class Lucky::Params end end + # Retrieve a nested array from the params + # + # Nested params often appear in JSON requests or Form submissions. If no key + # is found a `Lucky::MissingParamError` will be raised: + # + # ``` + # params.nested_array("tags") # {"tags" => ["Lucky", "Crystal"]} + # params.nested_array("missing") # Missing parameter: missing + # ``` + def nested_arrays(nested_key : String | Symbol) : Hash(String, Array(String)) + nested_params = nested_arrays?(nested_key) + if nested_params.keys.empty? + raise Lucky::MissingNestedParamError.new nested_key + else + nested_params + end + end + + def nested_arrays?(nested_key : String | Symbol) : Hash(String, Array(String)) + if json? + nested_array_json_params(nested_key.to_s).merge(nested_array_query_params(nested_key.to_s)) do |_k, v1, v2| + v1 + v2 + end + else + nested_array_form_params(nested_key.to_s).merge(nested_array_query_params(nested_key.to_s)) do |_k, v1, v2| + v1 + v2 + end + end + end + # Retrieve a nested file from the params # # Nested params often appear in JSON requests or Form submissions. If no key @@ -286,6 +324,19 @@ class Lucky::Params nested_file_params(nested_key.to_s) end + def nested_array_files(nested_key : String | Symbol) : Hash(String, Array(Lucky::UploadedFile)) + nested_file_params = nested_array_files?(nested_key) + if nested_file_params.keys.empty? + raise Lucky::MissingNestedParamError.new nested_key + else + nested_file_params + end + end + + def nested_array_files?(nested_key : String | Symbol) : Hash(String, Array(Lucky::UploadedFile))? + nested_array_file_params(nested_key.to_s) + end + # Retrieve nested values from the params # # Nested params often appear in JSON requests or Form submissions. If no key @@ -357,7 +408,7 @@ class Lucky::Params private def nested_json_params(nested_key : String) : Hash(String, String) nested_params = {} of String => String - nested_key_json = parsed_json[nested_key]? || JSON.parse("{}") + nested_key_json = parsed_json[nested_key]? || JSON::Any.new({} of String => JSON::Any) nested_key_json.as_h.each do |key, value| nested_params[key.to_s] = value.to_s @@ -366,6 +417,19 @@ class Lucky::Params nested_params end + private def nested_array_json_params(nested_key : String) : Hash(String, Array(String)) + nested_params = {} of String => Array(String) + nested_key_json = parsed_json[nested_key]? || JSON::Any.new({} of String => JSON::Any) + + nested_key_json.as_h.each do |key, value| + if array_value = value.as_a? + nested_params[key.to_s] = array_value.map(&.to_s) + end + end + + nested_params + end + private def nested_form_params(nested_key : String) : Hash(String, String) nested_key = "#{nested_key}:" source = multipart? ? multipart_params : form_params @@ -378,6 +442,22 @@ class Lucky::Params end end + private def nested_array_form_params(nested_key : String) : Hash(String, Array(String)) + nested_key = "#{nested_key}:" + nested_params = {} of String => Array(String) + + source = multipart? ? multipart_params : form_params + source.each do |key, value| + if key.starts_with?(nested_key) && key.ends_with?("[]") + new_key = key.lchop(nested_key).rchop("[]") + nested_params[new_key.to_s] ||= [] of String + nested_params[new_key.to_s] << value + end + end + + nested_params + end + private def nested_query_params(nested_key : String) : Hash(String, String) nested_key = "#{nested_key}:" query_params.to_h.reduce(empty_params) do |nested_params, (key, value)| @@ -389,6 +469,20 @@ class Lucky::Params end end + private def nested_array_query_params(nested_key : String) : Hash(String, Array(String)) + nested_key = "#{nested_key}:" + nested_params = {} of String => Array(String) + query_params.each do |key, value| + if key.starts_with?(nested_key) && key.ends_with?("[]") + new_key = key.lchop(nested_key).rchop("[]") + nested_params[new_key.to_s] ||= [] of String + nested_params[new_key.to_s] << value + end + end + + nested_params + end + private def nested_file_params(nested_key : String) : Hash(String, Lucky::UploadedFile) nested_key = "#{nested_key}:" multipart_files.to_h.reduce(empty_file_params) do |nested_params, (key, value)| @@ -400,6 +494,21 @@ class Lucky::Params end end + private def nested_array_file_params(nested_key : String) : Hash(String, Array(Lucky::UploadedFile)) + nested_key = "#{nested_key}:" + nested_params = {} of String => Array(Lucky::UploadedFile) + + multipart_files.each do |key, value| + if key.starts_with?(nested_key) && key.ends_with?("[]") + new_key = key.lchop(nested_key).rchop("[]") + nested_params[new_key.to_s] ||= [] of Lucky::UploadedFile + nested_params[new_key.to_s] << value + end + end + + nested_params + end + private def zipped_many_nested_params(nested_key : String) body_params = many_nested_body_params(nested_key) query_params = many_nested_query_params(nested_key) diff --git a/src/lucky/routable.cr b/src/lucky/routable.cr index 0befe6e00..c9bb74595 100644 --- a/src/lucky/routable.cr +++ b/src/lucky/routable.cr @@ -401,16 +401,26 @@ module Lucky::Routable # ``` # # When visiting this page, the path _must_ contain the token parameter: - # `/user_confirmations?token=abc123` + # `/user_confirmations/new?token=abc123` macro param(type_declaration) + {% unless type_declaration.is_a?(TypeDeclaration) %} + {% raise "'param' expects a type declaration like 'name : String', instead got: '#{type_declaration}'" %} + {% end %} + {% PARAM_DECLARATIONS << type_declaration %} @@query_param_declarations << "{{ type_declaration.var }} : {{ type_declaration.type }}" - def {{ type_declaration.var }} : {{ type_declaration.type }} - {% is_nilable_type = type_declaration.type.is_a?(Union) %} - {% type = is_nilable_type ? type_declaration.type.types.first : type_declaration.type %} + getter {{ type_declaration.var }} : {{ type_declaration.type }} do + {% is_nilable_type = type_declaration.type.resolve.nilable? %} + {% base_type = is_nilable_type ? type_declaration.type.types.first : type_declaration.type %} + {% is_array = base_type.is_a?(Generic) %} + {% type = is_array ? base_type.type_vars.first : base_type %} + {% if is_array %} + val = params.get_all?(:{{ type_declaration.var.id }}) + {% else %} val = params.get?(:{{ type_declaration.var.id }}) + {% end %} if val.nil? default_or_nil = {{ type_declaration.value.is_a?(Nop) ? nil : type_declaration.value }} @@ -425,9 +435,9 @@ module Lucky::Routable {% end %} end - result = {{ type }}::Lucky.parse(val) + result = {{ base_type }}.adapter.parse(val) - if result.is_a? {{ type }}::Lucky::SuccessfulCast + if result.is_a? Avram::Type::SuccessfulCast result.value else raise Lucky::InvalidParamError.new(