From 6e883b5da015ca214c0b0271c461983191756ed6 Mon Sep 17 00:00:00 2001 From: Zach Gianos Date: Fri, 2 Oct 2020 07:49:02 -1000 Subject: [PATCH] Add `File` as valid param type With a type declaration of `File`, the input parameter hash of the uploaded file is coerce into a new UploadedFile object which conforms to the `Rack::Multipart::UploadedFile` interface. The input parameter hash must be of the shape: - `filename`: the original file name of the uploaded file - `head`: the header lines of the multipart request - `name`: the parameter name - `tempfile`: the `Tempfile` created from the content of the multipart request - `type`: the content media/MIME type --- README.md | 1 + lib/sinatra/param.rb | 2 ++ lib/sinatra/param/uploaded_file.rb | 37 +++++++++++++++++++++++++ spec/dummy/app.rb | 15 ++++++++++ spec/parameter_type_coercion_spec.rb | 41 ++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 lib/sinatra/param/uploaded_file.rb diff --git a/README.md b/README.md index 695d84f..4dfdb12 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ By declaring parameter types, incoming parameters will automatically be transfor * `Array` _("1,2,3,4,5")_ * `Hash` _(key1:value1,key2:value2)_ * `Date`, `Time`, & `DateTime` +* `File` ### Validations diff --git a/lib/sinatra/param.rb b/lib/sinatra/param.rb index f26a5d5..e4d56e4 100644 --- a/lib/sinatra/param.rb +++ b/lib/sinatra/param.rb @@ -1,4 +1,5 @@ require 'sinatra/base' +require 'sinatra/param/uploaded_file' require 'sinatra/param/version' require 'date' require 'time' @@ -121,6 +122,7 @@ def coerce(param, type, options = {}) return DateTime.parse(param) if type == DateTime return Array(param.split(options[:delimiter] || ",")) if type == Array return Hash[param.split(options[:delimiter] || ",").map{|c| c.split(options[:separator] || ":")}] if type == Hash + return UploadedFile.from_param(param) if type == File if [TrueClass, FalseClass, Boolean].include? type coerced = /^(false|f|no|n|0)$/i === param.to_s ? false : /^(true|t|yes|y|1)$/i === param.to_s ? true : nil raise ArgumentError if coerced.nil? diff --git a/lib/sinatra/param/uploaded_file.rb b/lib/sinatra/param/uploaded_file.rb new file mode 100644 index 0000000..53da1fe --- /dev/null +++ b/lib/sinatra/param/uploaded_file.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Sinatra + module Param + # A wrapper/delegator to an uploaded file. + # + # The attributes are the same as the original parameter hash keys with + # extra aliases conforming to the `Rack::Multipart::UploadedFile` interface. + class UploadedFile < SimpleDelegator + attr_reader :filename, :head, :name, :tempfile, :type + + def initialize(filename:, head:, name:, tempfile:, type:) + super(tempfile) + @filename = filename + @head = head + @name = name + @tempfile = tempfile + @type = type + end + + alias content_type type + alias original_filename filename + + def self.from_param(param) + new( + filename: param.fetch(:filename), + head: param.fetch(:head), + name: param.fetch(:name), + tempfile: param.fetch(:tempfile), + type: param.fetch(:type), + ) + rescue KeyError, NoMethodError => e + raise ArgumentError, e.message + end + end + end +end diff --git a/spec/dummy/app.rb b/spec/dummy/app.rb index 24dfe6c..c474ccd 100644 --- a/spec/dummy/app.rb +++ b/spec/dummy/app.rb @@ -70,6 +70,21 @@ class App < Sinatra::Base params.to_json end + put '/coerce/file' do + param :arg, File + { + arg: { + body: params[:arg].read, + content_type: params[:arg].content_type, + filename: params[:arg].filename, + head: params[:arg].head, + name: params[:arg].name, + original_filename: params[:arg].original_filename, + type: params[:arg].type, + }, + }.to_json + end + get '/coerce/boolean' do param :arg, Boolean params.to_json diff --git a/spec/parameter_type_coercion_spec.rb b/spec/parameter_type_coercion_spec.rb index d502b98..a936519 100644 --- a/spec/parameter_type_coercion_spec.rb +++ b/spec/parameter_type_coercion_spec.rb @@ -130,6 +130,47 @@ end end + describe 'File' do + let(:arg) { Rack::Test::UploadedFile.new(content, 'text/csv', original_filename: 'file.csv') } + let(:content) { StringIO.new('content') } + let(:head) do + <<~HEAD + Content-Disposition: form-data; name="arg"; filename="file.csv"\r + Content-Type: text/csv\r + Content-Length: 7\r + HEAD + end + + it 'coerces files' do + put('/coerce/file', arg: arg) do |response| + expect(response.status).to eql 200 + parsed_body = JSON.parse(response.body) + expect(parsed_body['arg']).to be_a(Hash) + expect(parsed_body['arg']).to eq({ + 'body' => 'content', + 'content_type' => 'text/csv', + 'filename' => 'file.csv', + 'head' => head, + 'name' => 'arg', + 'original_filename' => 'file.csv', + 'type' => 'text/csv', + }) + end + end + + it 'returns 400 when not a hash' do + put('/coerce/file', arg: 'arg') do |response| + expect(response.status).to eql 400 + end + end + + it 'returns 400 when not a file upload hash' do + put('/coerce/file', arg: { 'a' => 'a' }) do |response| + expect(response.status).to eql 400 + end + end + end + describe 'Boolean' do it 'coerces truthy booleans to true' do %w(1 true t yes y).each do |bool|