Skip to content

Commit

Permalink
Better WWW form data support (athena-framework/athena#477)
Browse files Browse the repository at this point in the history
* Leverage `URI::Params::Serializable` to handle form data request body resolution
* Add `ATHA::MapQueryString` to map a request's query string into a DTO type
* Add `ATH::Request#content_type_format` to return the request format's name from `content-type` header
  • Loading branch information
Blacksmoke16 authored Dec 8, 2024
1 parent 1501417 commit 628fdaa
Show file tree
Hide file tree
Showing 6 changed files with 169 additions and 24 deletions.
2 changes: 1 addition & 1 deletion spec/compiler_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ describe Athena::Framework do

describe ATHR::RequestBody do
it "when the action parameter is not serializable" do
assert_error " The annotation '@[ATHA::MapRequestBody]' cannot be applied to 'CompileController#action:foo : Foo' since the 'Athena::Framework::Controller::ValueResolvers::RequestBody' resolver only supports parameters of type 'Athena::Serializer::Serializable | JSON::Serializable'.", <<-CODE
assert_error " The annotation '@[ATHA::MapRequestBody]' cannot be applied to 'CompileController#action:foo : Foo' since the 'Athena::Framework::Controller::ValueResolvers::RequestBody' resolver only supports parameters of type 'Athena::Serializer::Serializable | JSON::Serializable | URI::Params::Serializable'.", <<-CODE
record Foo, text : String
class CompileController < ATH::Controller
Expand Down
84 changes: 81 additions & 3 deletions spec/controller/value_resolvers/request_body_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ private record MockValidatableASRSerializableEntity, id : Int32, name : String d
include AVD::Validatable
end

private record MockURISerializableEntity, id : Int32, name : String do
include URI::Params::Serializable
end

private record MockJSONAndURISerializableEntity, id : Int32, name : String do
include JSON::Serializable
include URI::Params::Serializable
end

struct RequestBodyResolverTest < ASPEC::TestCase
@target : ATHR::RequestBody

Expand Down Expand Up @@ -48,6 +57,18 @@ struct RequestBodyResolverTest < ASPEC::TestCase
end
end

def test_raises_on_missing_www_form_data : Nil
expect_raises ATH::Exception::BadRequest, "Malformed www form data payload." do
@target.resolve new_request(body: "id=10", format: "form"), self.get_config(MockURISerializableEntity)
end
end

def test_raises_on_missing_query_string_data : Nil
expect_raises ATH::Exception::BadRequest, "Malformed query string." do
@target.resolve new_request(query: "id=10"), self.get_config(MockURISerializableEntity, ATHA::MapQueryString, ATHA::MapQueryStringConfiguration.new)
end
end

def test_it_raises_on_constraint_violations : Nil
serializer = DeserializableMockSerializer(MockValidatableASRSerializableEntity).new
serializer.deserialized_response = MockValidatableASRSerializableEntity.new 10, ""
Expand Down Expand Up @@ -86,6 +107,63 @@ struct RequestBodyResolverTest < ASPEC::TestCase
object.name.should eq "Fred"
end

def test_it_supports_uri_params_serializable : Nil
serializer = DeserializableMockSerializer(MockURISerializableEntity).new
serializer.deserialized_response = MockURISerializableEntity.new 10, "Fred"

request = new_request body: "id=10&name=Fred", format: "form"

object = ATHR::RequestBody.new(serializer, @validator).resolve request, self.get_config(MockURISerializableEntity)
object = object.should_not be_nil

object.id.should eq 10
object.name.should eq "Fred"
end

def test_it_supports_query_string_serializable : Nil
serializer = DeserializableMockSerializer(MockURISerializableEntity).new
serializer.deserialized_response = MockURISerializableEntity.new 10, "Fred"

request = new_request query: "id=10&name=Fred"

object = ATHR::RequestBody.new(serializer, @validator).resolve request, self.get_config(MockURISerializableEntity, ATHA::MapQueryString, ATHA::MapQueryStringConfiguration.new)
object = object.should_not be_nil

object.id.should eq 10
object.name.should eq "Fred"
end

def test_it_supports_query_string_serializable_no_query_string : Nil
serializer = DeserializableMockSerializer(MockURISerializableEntity).new
serializer.deserialized_response = MockURISerializableEntity.new 10, "Fred"

ATHR::RequestBody
.new(serializer, @validator)
.resolve(new_request, self.get_config(MockURISerializableEntity, ATHA::MapQueryString, ATHA::MapQueryStringConfiguration.new))
.should be_nil
end

def test_it_supports_multiple_serializable : Nil
serializer = DeserializableMockSerializer(MockJSONAndURISerializableEntity).new
serializer.deserialized_response = MockJSONAndURISerializableEntity.new 10, "Fred"

form_request = new_request body: "id=10&name=Fred", format: "form"
json_request = new_request body: %({"id":10,"name":"Fred"})

resolver = ATHR::RequestBody.new serializer, @validator
form_object = resolver.resolve form_request, self.get_config(MockJSONAndURISerializableEntity)
form_object = form_object.should_not be_nil

json_object = resolver.resolve json_request, self.get_config(MockJSONAndURISerializableEntity)
json_object = json_object.should_not be_nil

form_object.id.should eq 10
form_object.name.should eq "Fred"

json_object.id.should eq 10
json_object.name.should eq "Fred"
end

def test_it_supports_avd_validatable : Nil
serializer = DeserializableMockSerializer(MockValidatableASRSerializableEntity).new
serializer.deserialized_response = MockValidatableASRSerializableEntity.new 10, "Fred"
Expand All @@ -99,12 +177,12 @@ struct RequestBodyResolverTest < ASPEC::TestCase
object.name.should eq "Fred"
end

private def get_config(type : T.class) forall T
private def get_config(type : T.class, ann = ATHA::MapRequestBody, configuration = ATHA::MapRequestBodyConfiguration.new) forall T
ATH::Controller::ParameterMetadata(T).new(
"foo",
annotation_configurations: ADI::AnnotationConfigurations.new({
ATHA::MapRequestBody => [
ATHA::MapRequestBodyConfiguration.new,
ann => [
configuration,
] of ADI::AnnotationConfigurations::ConfigurationBase,
} of ADI::AnnotationConfigurations::Classes => Array(ADI::AnnotationConfigurations::ConfigurationBase))
)
Expand Down
10 changes: 10 additions & 0 deletions spec/request_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ struct ATH::RequestTest < ASPEC::TestCase
request.hostname.should eq "::1"
end

def test_content_type_format_present : Nil
ATH::Request.new("GET", "/", headers: HTTP::Headers{
"content-type" => "application/json",
}).content_type_format.should eq "json"
end

def test_content_type_format_missing : Nil
ATH::Request.new("GET", "/").content_type_format.should be_nil
end

@[DataProvider("mime_type_provider")]
def test_mime_type(format : String, mime_types : Indexable(String)) : Nil
request = ATH::Request.new "GET", "/"
Expand Down
4 changes: 4 additions & 0 deletions spec/spec_helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,16 @@ def new_request(
action : ATH::ActionBase = new_action,
body : String | IO | Nil = nil,
query : String? = nil,
format : String = "json",
) : ATH::Request
request = ATH::Request.new method, path, body: body
request.attributes.set "_controller", "TestController#test", String
request.attributes.set "_route", "test_controller_test", String
request.action = action
request.query = query
request.headers = HTTP::Headers{
"content-type" => ATH::Request::FORMATS[format].first,
}
request
end

Expand Down
88 changes: 68 additions & 20 deletions src/controller/value_resolvers/request_body.cr
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
require "uri/params/serializable"

@[ADI::Register(tags: [{name: ATHR::Interface::TAG, priority: 105}])]
# Attempts to resolve the value of any parameter with the `ATHA::MapRequestBody` annotation by
# deserializing the request body into an object of the type of the related parameter.
# Also handles running any validations defined on it, if it is `AVD::Validatable`.
# Requires the type of the related parameter to include either `ASR::Serializable` or `JSON::Serializable`.
# The `ATHA::MapQueryString` annotation works similarly, but uses the request's query string instead of its body.
#
# If the object is also [AVD::Validatable](/Validator/Validatable), any validations defined on it are executed before returning the object.
# Requires the type of the related parameter to include one or more of:
#
# * `ASR::Serializable`
# * `JSON::Serializable`
# * `URI::Params::Serializable`
#
# ```
# require "athena"
Expand Down Expand Up @@ -54,6 +62,8 @@
# }
# ```
#
# TIP: This resolver also supports `application/x-www-form-urlencoded` payloads.
#
# Would return the response:
#
# ```json
Expand Down Expand Up @@ -85,32 +95,30 @@
# }
# ```
struct Athena::Framework::Controller::ValueResolvers::RequestBody
include Athena::Framework::Controller::ValueResolvers::Interface::Typed(Athena::Serializer::Serializable, JSON::Serializable)
include Athena::Framework::Controller::ValueResolvers::Interface::Typed(Athena::Serializer::Serializable, JSON::Serializable, URI::Params::Serializable)

# Enables the `ATHR::RequestBody` resolver for the parameter this annotation is applied to.
# Enables the `ATHR::RequestBody` resolver for the parameter this annotation is applied to based on the request's body.
# See the related resolver documentation for more information.
configuration ::Athena::Framework::Annotations::MapRequestBody

# Enables the `ATHR::RequestBody` resolver for the parameter this annotation is applied to based on the request's query string.
# See the related resolver documentation for more information.
configuration ::Athena::Framework::Annotations::MapQueryString

def initialize(
@serializer : ASR::SerializerInterface,
@validator : AVD::Validator::ValidatorInterface,
); end

# :inherit:
def resolve(request : ATH::Request, parameter : ATH::Controller::ParameterMetadata)
return unless parameter.annotation_configurations.has? ATHA::MapRequestBody

if !(body = request.body) || body.peek.try &.empty?
raise ATH::Exception::BadRequest.new "Request does not have a body."
end

begin
unless object = self.map_request_body body, parameter.type
return
end
rescue ex : JSON::ParseException | ASR::Exception::DeserializationException
raise ATH::Exception::BadRequest.new "Malformed JSON payload.", cause: ex
end
object = if parameter.annotation_configurations.has?(ATHA::MapQueryString)
self.map_query_string request, parameter
elsif parameter.annotation_configurations.has?(ATHA::MapRequestBody)
self.map_request_body request, parameter
else
return
end

if object.is_a? AVD::Validatable
errors = @validator.validate object
Expand All @@ -120,14 +128,54 @@ struct Athena::Framework::Controller::ValueResolvers::RequestBody
object
end

private def map_request_body(body : IO, klass : ASR::Serializable.class)
private def map_query_string(request : ATH::Request, parameter : ATH::Controller::ParameterMetadata)
return unless query = request.query
return if query.nil? && (parameter.nilable? || parameter.has_default?)

self.deserialize_form query, parameter.type
rescue ex : URI::SerializableError
raise ATH::Exception::BadRequest.new "Malformed query string.", cause: ex
end

private def map_request_body(request : ATH::Request, parameter : ATH::Controller::ParameterMetadata)
if !(body = request.body) || body.peek.try &.empty?
raise ATH::Exception::BadRequest.new "Request does not have a body."
end

# We have to use separate deserialization methods with the case such that a type that includes multiple modules is handled as expected.
case request.content_type_format
when "form"
self.deserialize_form body, parameter.type
when "json"
self.deserialize_json body, parameter.type
else
raise ATH::Exception::UnsupportedMediaType.new "Unsupported format."
end
rescue ex : JSON::ParseException | ASR::Exception::DeserializationException
raise ATH::Exception::BadRequest.new "Malformed JSON payload.", cause: ex
rescue ex : URI::SerializableError
raise ATH::Exception::BadRequest.new "Malformed www form data payload.", cause: ex
end

private def deserialize_json(body : IO, klass : ASR::Serializable.class)
@serializer.deserialize klass, body, :json
end

private def map_request_body(body : IO, klass : JSON::Serializable.class)
private def deserialize_json(body : IO, klass : JSON::Serializable.class)
klass.from_json body
end

private def map_request_body(body : IO, klass : _) : Nil
private def deserialize_json(body : IO, klass : _) : Nil
end

private def deserialize_form(body : IO, klass : URI::Params::Serializable.class)
klass.from_www_form body.gets_to_end
end

private def deserialize_form(body : String, klass : URI::Params::Serializable.class)
klass.from_www_form body
end

private def deserialize_form(body : IO | String, klass : _)
end
end
5 changes: 5 additions & 0 deletions src/request.cr
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ class Athena::Framework::Request
self.class.mime_types(format).first?
end

# Returns the [Format][ATH::Request::FORMATS] of the request based on its `content-type` header, or `nil` if the header is missing.
def content_type_format : String?
self.format @request.headers.fetch "content-type", ""
end

# Returns the format for the provided *mime_type*.
#
# ```
Expand Down

0 comments on commit 628fdaa

Please sign in to comment.