Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add URI::Params::Serializable #14684

Merged
Merged
121 changes: 121 additions & 0 deletions spec/std/uri/params/from_form_data_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
require "spec"
require "uri/params/serializable"

private enum Color
Red
Green
Blue
end

describe ".from_form_data" do
describe Bool do
it "a truthy value" do
Bool.from_form_data(URI::Params.parse("alive=true"), "alive").should be_true
Bool.from_form_data(URI::Params.parse("alive=on"), "alive").should be_true
Bool.from_form_data(URI::Params.parse("alive=yes"), "alive").should be_true
Bool.from_form_data(URI::Params.parse("alive=1"), "alive").should be_true
end

it "a falsey value" do
Bool.from_form_data(URI::Params.parse("alive=false"), "alive").should be_false
Bool.from_form_data(URI::Params.parse("alive=off"), "alive").should be_false
Bool.from_form_data(URI::Params.parse("alive=no"), "alive").should be_false
Bool.from_form_data(URI::Params.parse("alive=0"), "alive").should be_false
end

it "any other value" do
Bool.from_form_data(URI::Params.parse("alive=foo"), "alive").should be_nil
end

it "missing value" do
Bool.from_form_data(URI::Params.new, "value").should be_nil
end
end

describe String do
it "valid value" do
String.from_form_data(URI::Params.parse("name=John Doe"), "name").should eq "John Doe"
end

it "missing value" do
String.from_form_data(URI::Params.new, "value").should be_nil
end
end

describe Enum do
it "valid value" do
Color.from_form_data(URI::Params.parse("color=green"), "color").should eq Color::Green
end

it "missing value" do
Color.from_form_data(URI::Params.new, "value").should be_nil
end
end

describe Time do
it "valid value" do
Time.from_form_data(URI::Params.parse("time=2016-11-16T09:55:48-03:00"), "time").try &.to_utc.should eq(Time.utc(2016, 11, 16, 12, 55, 48))
Time.from_form_data(URI::Params.parse("time=2016-11-16T09:55:48-0300"), "time").try &.to_utc.should eq(Time.utc(2016, 11, 16, 12, 55, 48))
Time.from_form_data(URI::Params.parse("time=20161116T095548-03:00"), "time").try &.to_utc.should eq(Time.utc(2016, 11, 16, 12, 55, 48))
end

it "missing value" do
Time.from_form_data(URI::Params.new, "value").should be_nil
end
end

it Nil do
Nil.from_form_data(URI::Params.new, "name").should be_nil
Nil.from_form_data(URI::Params.parse("name=null"), "name").should be_nil
end

describe Number do
describe Int do
it "valid numbers" do
Int64.from_form_data(URI::Params.parse("value=123"), "value").should eq 123_i64
UInt8.from_form_data(URI::Params.parse("value=7"), "value").should eq 7_u8
Int64.from_form_data(URI::Params.parse("value=-12"), "value").should eq -12_i64
end

it "with whitespace" do
expect_raises ArgumentError do
Int32.from_form_data(URI::Params.parse("value= 123"), "value")
end
end

it "missing value" do
Int32.from_form_data(URI::Params.new, "value").should be_nil
UInt8.from_form_data(URI::Params.new, "value").should be_nil
end
end

describe Float do
it "valid numbers" do
Float32.from_form_data(URI::Params.parse("value=123.0"), "value").should eq 123_f32
Float64.from_form_data(URI::Params.parse("value=123.0"), "value").should eq 123_f64
end

it "with whitespace" do
expect_raises ArgumentError do
Float64.from_form_data(URI::Params.parse("value= 123.0"), "value")
end
end

it "missing value" do
Float64.from_form_data(URI::Params.new, "value").should be_nil
end
end
end

describe Union do
it "valid" do
String?.from_form_data(URI::Params.parse("name=John Doe"), "name").should eq "John Doe"
end

it "invalid" do
expect_raises ArgumentError do
(Int32 | Float64).from_form_data(URI::Params.parse("value=foo"), "value")
end
end
end
end
85 changes: 85 additions & 0 deletions spec/std/uri/params/serializable_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
require "spec"
require "uri/params/serializable"

private record SimpleType, page : Int32, strict : Bool, per_page : UInt8 do
include URI::Params::Serializable
end

private record SimpleTypeDefaults, page : Int32, strict : Bool, per_page : Int32 = 10 do
include URI::Params::Serializable
end

private record SimpleTypeNilable, page : Int32, strict : Bool, per_page : Int32? = nil do
include URI::Params::Serializable
end

private record SimpleTypeNilableDefault, page : Int32, strict : Bool, per_page : Int32? = 20 do
include URI::Params::Serializable
end

record Filter, status : String?, total : Float64? do
include URI::Params::Serializable
end

record Search, filter : Filter?, limit : Int32 = 25, offset : Int32 = 0 do
include URI::Params::Serializable
end

record GrandChild, name : String do
include URI::Params::Serializable
end

record Child, status : String?, grand_child : GrandChild do
include URI::Params::Serializable
end

record Parent, child : Child do
include URI::Params::Serializable
end

describe URI::Params::Serializable do
it "simple type" do
SimpleType.from_form_data(URI::Params.parse("page=10&strict=true&per_page=5")).should eq SimpleType.new(10, true, 5)
end

it "missing required property" do
expect_raises URI::SerializableError, "Missing required property: 'page'." do
SimpleType.from_form_data(URI::Params.parse("strict=true&per_page=5"))
end
end

it "with default values" do
SimpleTypeDefaults.from_form_data(URI::Params.parse("page=10&strict=true")).should eq SimpleTypeDefaults.new(10, true, 10)
end

it "with nilable values" do
SimpleTypeNilable.from_form_data(URI::Params.parse("page=10&strict=true")).should eq SimpleTypeNilable.new(10, true, nil)
end

it "with nilable default" do
SimpleTypeNilableDefault.from_form_data(URI::Params.parse("page=10&strict=true")).should eq SimpleTypeNilableDefault.new(10, true, 20)
end

describe "nested type" do
it "happy path" do
Search.from_form_data(URI::Params.parse("offset=10&filter[status]=active&filter[total]=3.14"))
.should eq Search.new Filter.new("active", 3.14), offset: 10
end

it "missing nilable nested data" do
Search.from_form_data(URI::Params.parse("offset=10"))
.should eq Search.new Filter.new(nil, nil), offset: 10
end

it "missing required nested property" do
expect_raises URI::SerializableError, "Missing required property: 'child[grand_child][name]'." do
Parent.from_form_data(URI::Params.parse("child[status]=active"))
end
end

it "doubly nested" do
Parent.from_form_data(URI::Params.parse("child[status]=active&child[grand_child][name]=Fred"))
.should eq Parent.new Child.new("active", GrandChild.new("Fred"))
end
end
end
47 changes: 47 additions & 0 deletions src/uri/params/from_form_data.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
def Array.from_form_data(params : URI::Params, name : String)
params.fetch_all(name).map { |item| T.from_form_data(params, name).as T }
end

def Bool.from_form_data(params : URI::Params, name : String)
case params[name]?
when "true", "1", "yes", "on" then true
when "false", "0", "no", "off" then false
end
end

def Number.from_form_data(params : URI::Params, name : String)
return nil unless value = params[name]?

new value, whitespace: false
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using whitespace: false here so that it doesn't consider age= 25 to be valid.

end

def String.from_form_data(params : URI::Params, name : String)
params[name]?
end

def Enum.from_form_data(params : URI::Params, name : String)
return nil unless value = params[name]?

parse value
end

def Time.from_form_data(params : URI::Params, name : String)
return nil unless value = params[name]?

Time::Format::ISO_8601_DATE_TIME.parse value
end

def Union.from_form_data(params : URI::Params, name : String)
# Process non nilable types first as they are more likely to work.
{% for type in T.sort_by { |t| t.nilable? ? 1 : 0 } %}
begin
return {{type}}.from_form_data params, name
rescue
# Noop to allow next T to be tried.
end
{% end %}
raise ArgumentError.new "Invalid #{self}: #{params[name]}"
end

def Nil.from_form_data(params : URI::Params, name : String) : Nil
end
57 changes: 57 additions & 0 deletions src/uri/params/serializable.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
require "uri"

require "./from_form_data"

module URI::Params::Serializable
macro included
def self.from_form_data(params : ::URI::Params)
new_from_form_data(params)
end

# :nodoc:
#
# This is needed so that nested types can pass the name thru internally.
# Has to be public so the generated code can call it, but should be considered an implementation detail.
def self.from_form_data(params : ::URI::Params, name : String)
new_from_form_data(params, name)
end

protected def self.new_from_form_data(params : ::URI::Params, name : String? = nil)
instance = allocate
instance.initialize(__uri_params: params, name: name)
GC.add_finalizer(instance) if instance.responds_to?(:finalize)
instance
end

macro inherited
def self.from_form_data(params : ::URI::Params)
new_from_form_data(params)
end

# :nodoc:
def self.from_form_data(params : ::URI::Params, name : String)
new_from_form_data(params, name)
end
end
end

# :nodoc:
def initialize(*, __uri_params params : ::URI::Params, name : String?)
{% begin %}
{% for ivar, idx in @type.instance_vars %}
%name{idx} = name.nil? ? {{ivar.name.stringify}} : "#{name}[#{{{ivar.name.stringify}}}]"
Copy link
Member Author

@Blacksmoke16 Blacksmoke16 Jun 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A top level call to .from_form_data will have name be nil, otherwise the names will be appended to handle nested data via the internal overloads. E.g. filter[direction].

Would be possible to change this, but this is what I went with because I tested ['foo' => 'bar','biz' => ['age' => 10,'alive' => true]] with PHP and it was encoded as foo=bar&biz[age]=10&biz[alive]=1, which Crystal parses as URI::Params{"foo" => ["bar"], "biz[age]" => ["10"], "biz[alive]" => ["1"]}. So seemed reasonable enough to use same key format as how URI::Params represents it.

PHP does parse it out into a nested assoc array tho, but that would require making URI::Params more like JSON::Any to handle the possible recursion...


if v = {{ivar.type}}.from_form_data(params, %name{idx})
@{{ivar.name.id}} = v
else
{% unless ivar.type.resolve.nilable? || ivar.has_default_value? %}
raise URI::SerializableError.new "Missing required property: '#{%name{idx}}'."
{% end %}
end
{% end %}
{% end %}
end
end

class URI::SerializableError < URI::Error
end
Loading