├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop ├── layout.yml ├── metrics.yml └── style.yml ├── .yardopts ├── CHANGES.md ├── Gemfile ├── Guardfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── http-form_data.gemspec ├── lib └── http │ ├── form_data.rb │ └── form_data │ ├── composite_io.rb │ ├── file.rb │ ├── multipart.rb │ ├── multipart │ └── param.rb │ ├── part.rb │ ├── readable.rb │ ├── urlencoded.rb │ └── version.rb └── spec ├── fixtures ├── expected-multipart-body.tpl └── the-http-gem.info ├── lib └── http │ ├── form_data │ ├── composite_io_spec.rb │ ├── file_spec.rb │ ├── multipart_spec.rb │ ├── part_spec.rb │ └── urlencoded_spec.rb │ └── form_data_spec.rb ├── spec_helper.rb └── support ├── fixtures_helper.rb ├── fuubar.rb └── simplecov.rb /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | BUNDLE_WITHOUT: "development" 11 | JRUBY_OPTS: "--dev --debug" 12 | 13 | jobs: 14 | test: 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | matrix: 19 | ruby: [ ruby-2.5, ruby-2.6, ruby-2.7, ruby-3.0, ruby-3.1, ruby-3.2, ruby-3.3, jruby-9.2 ] 20 | os: [ ubuntu-latest, windows-latest ] 21 | exclude: 22 | # TODO(ixti): fails because it can't find spec files o_O 23 | - { ruby: jruby-9.2, os: windows-latest } 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{ matrix.ruby }} 31 | bundler-cache: true 32 | 33 | - name: bundle exec rspec 34 | run: bundle exec rspec --format progress --force-colour 35 | 36 | - name: Prepare Coveralls test coverage report 37 | uses: coverallsapp/github-action@v2 38 | with: 39 | github-token: ${{ secrets.GITHUB_TOKEN }} 40 | flag-name: "${{ matrix.ruby }} @${{ matrix.os }}" 41 | parallel: true 42 | 43 | coveralls: 44 | needs: test 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Finalize Coveralls test coverage report 48 | uses: coverallsapp/github-action@v2 49 | with: 50 | github-token: ${{ secrets.GITHUB_TOKEN }} 51 | parallel-finished: true 52 | 53 | lint: 54 | runs-on: ubuntu-latest 55 | 56 | steps: 57 | - uses: actions/checkout@v4 58 | 59 | - uses: ruby/setup-ruby@v1 60 | with: 61 | ruby-version: 2.5 62 | bundler-cache: true 63 | 64 | - name: bundle exec rubocop 65 | run: bundle exec rubocop --format progress --color 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /.ruby-version 4 | /Gemfile.lock 5 | /_yardoc/ 6 | /coverage/ 7 | /doc/ 8 | /pkg/ 9 | /spec/reports/ 10 | /tmp/ 11 | *.bundle 12 | *.so 13 | *.o 14 | *.a 15 | mkmf.log 16 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - .rubocop/layout.yml 3 | - .rubocop/metrics.yml 4 | - .rubocop/style.yml 5 | 6 | AllCops: 7 | DisplayCopNames: true 8 | NewCops: enable 9 | TargetRubyVersion: 2.5 10 | DefaultFormatter: fuubar 11 | -------------------------------------------------------------------------------- /.rubocop/layout.yml: -------------------------------------------------------------------------------- 1 | Layout/ArgumentAlignment: 2 | Enabled: true 3 | EnforcedStyle: with_fixed_indentation 4 | -------------------------------------------------------------------------------- /.rubocop/metrics.yml: -------------------------------------------------------------------------------- 1 | Metrics/BlockLength: 2 | Enabled: true 3 | Exclude: 4 | - 'spec/**/*_spec.rb' 5 | -------------------------------------------------------------------------------- /.rubocop/style.yml: -------------------------------------------------------------------------------- 1 | Style/HashSyntax: 2 | Enabled: true 3 | EnforcedStyle: hash_rockets 4 | 5 | Style/StringLiterals: 6 | Enabled: true 7 | EnforcedStyle: double_quotes 8 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup-provider=redcarpet 2 | --markup=markdown 3 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 2.3.0 (2020-03-08) 2 | 3 | * [#29](https://github.com/httprb/form_data/pull/29) 4 | Enhance HTTP::FormData::Urlencoded with per-instance encoder. 5 | [@summera][] 6 | 7 | 8 | ## 2.2.0 (2020-01-09) 9 | 10 | * [#28](https://github.com/httprb/form_data/pull/28) 11 | Ruby 2.7 compatibility. 12 | [@janko][] 13 | 14 | 15 | ## 2.1.1 (2018-06-01) 16 | 17 | * [#23](https://github.com/httprb/form_data/pull/23) 18 | Allow override urlencoded form data encoder. 19 | [@FabienChaynes][] 20 | 21 | 22 | ## 2.1.0 (2018-03-05) 23 | 24 | * [#21](https://github.com/httprb/form_data/pull/21) 25 | Rewind content at the end of `Readable#to_s`. 26 | [@janko-m][] 27 | 28 | * [#19](https://github.com/httprb/form_data/pull/19) 29 | Fix buffer encoding. 30 | [@HoneyryderChuck][] 31 | 32 | 33 | ## 2.0.0 (2017-10-01) 34 | 35 | * [#17](https://github.com/httprb/form_data/pull/17) 36 | Add CRLF character to end of multipart body. 37 | [@mhickman][] 38 | 39 | 40 | ## 2.0.0.pre2 (2017-05-11) 41 | 42 | * [#14](https://github.com/httprb/form_data/pull/14) 43 | Enable streaming for urlencoded form data. 44 | [@janko-m][] 45 | 46 | 47 | ## 2.0.0.pre1 (2017-05-10) 48 | 49 | * [#12](https://github.com/httprb/form_data.rb/pull/12) 50 | Enable form data streaming. 51 | [@janko-m][] 52 | 53 | 54 | ## 1.0.2 (2017-05-08) 55 | 56 | * [#5](https://github.com/httprb/form_data.rb/issues/5) 57 | Allow setting Content-Type non-file parts 58 | [@abotalov][] 59 | 60 | * [#6](https://github.com/httprb/form_data.rb/issues/6) 61 | Creation of file parts without filename 62 | [@abotalov][] 63 | 64 | * [#11](https://github.com/httprb/form_data.rb/pull/11) 65 | Deprecate `HTTP::FormData::File#mime_type`. Use `#content_type` instead. 66 | [@ixti][] 67 | 68 | 69 | ## 1.0.1 (2015-03-31) 70 | 71 | * Fix usage of URI module. 72 | 73 | 74 | ## 1.0.0 (2015-01-04) 75 | 76 | * Gem renamed to `http-form_data` as `FormData` is not top-level citizen 77 | anymore: `FormData -> HTTP::FormData`. 78 | 79 | 80 | ## 0.1.0 (2015-01-02) 81 | 82 | * Move repo under `httprb` organization on GitHub. 83 | * Add `nil` support to `FormData#ensure_hash`. 84 | 85 | 86 | ## 0.0.1 (2014-12-15) 87 | 88 | * First release ever! 89 | 90 | [@ixti]: https://github.com/ixti 91 | [@abotalov]: https://github.com/abotalov 92 | [@janko-m]: https://github.com/janko-m 93 | [@mhickman]: https://github.com/mhickman 94 | [@HoneyryderChuck]: https://github.com/HoneyryderChuck 95 | [@FabienChaynes]: https://github.com/FabienChaynes 96 | [@summera]: https://github.com/summera 97 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | 7 | group :development do 8 | gem "guard" 9 | gem "guard-rspec", :require => false 10 | gem "pry" 11 | 12 | # RSpec formatter 13 | gem "fuubar", :require => false 14 | 15 | platform :mri do 16 | gem "pry-byebug" 17 | end 18 | end 19 | 20 | group :test do 21 | gem "rspec", "~> 3.10" 22 | 23 | gem "rubocop" 24 | gem "rubocop-performance" 25 | gem "rubocop-rake" 26 | gem "rubocop-rspec" 27 | 28 | gem "simplecov", :require => false 29 | gem "simplecov-lcov", :require => false 30 | end 31 | 32 | group :doc do 33 | gem "redcarpet", :platform => :mri 34 | gem "yard" 35 | end 36 | 37 | # Specify your gem's dependencies in form_data.gemspec 38 | gemspec 39 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | guard :rspec, :cmd => "bundle exec rspec" do 4 | require "guard/rspec/dsl" 5 | dsl = Guard::RSpec::Dsl.new(self) 6 | 7 | # RSpec files 8 | rspec = dsl.rspec 9 | watch(rspec.spec_helper) { rspec.spec_dir } 10 | watch(rspec.spec_support) { rspec.spec_dir } 11 | watch(rspec.spec_files) 12 | 13 | # Ruby files 14 | ruby = dsl.ruby 15 | dsl.watch_spec_files_for(ruby.lib_files) 16 | end 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2021 Alexey V Zapparov 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTTP::FormData 2 | 3 | [![Gem Version](https://badge.fury.io/rb/http-form_data.svg)](http://rubygems.org/gems/http-form_data) 4 | [![Build Status](https://github.com/httprb/form_data/workflows/CI/badge.svg)](https://github.com/httprb/form_data/actions?query=workflow%3ACI+branch%3Amaster) 5 | [![Code Climate](https://codeclimate.com/github/httprb/form_data.svg)](https://codeclimate.com/github/httprb/form_data) 6 | [![Coverage Status](https://coveralls.io/repos/github/httprb/form_data/badge.svg?branch=master)](https://coveralls.io/github/httprb/form_data?branch=master) 7 | 8 | Utility-belt to build form data request bodies. 9 | 10 | 11 | ## Installation 12 | 13 | Add this line to your application's Gemfile: 14 | 15 | ```ruby 16 | gem 'http-form_data' 17 | ``` 18 | 19 | And then execute: 20 | 21 | $ bundle 22 | 23 | Or install it yourself as: 24 | 25 | $ gem install http-form_data 26 | 27 | 28 | ## Usage 29 | 30 | ``` ruby 31 | require "http/form_data" 32 | 33 | form = HTTP::FormData.create({ 34 | :username => "ixti", 35 | :avatar_file => HTTP::FormData::File.new("/home/ixti/avatar.png") 36 | }) 37 | 38 | # Assuming socket is an open socket to some HTTP server 39 | socket << "POST /some-url HTTP/1.1\r\n" 40 | socket << "Host: example.com\r\n" 41 | socket << "Content-Type: #{form.content_type}\r\n" 42 | socket << "Content-Length: #{form.content_length}\r\n" 43 | socket << "\r\n" 44 | socket << form.to_s 45 | ``` 46 | 47 | It's also possible to create a non-file part with Content-Type: 48 | 49 | ``` ruby 50 | form = HTTP::FormData.create({ 51 | :username => HTTP::FormData::Part.new('{"a": 1}', content_type: 'application/json'), 52 | :avatar_file => HTTP::FormData::File.new("/home/ixti/avatar.png") 53 | }) 54 | ``` 55 | 56 | ## Supported Ruby Versions 57 | 58 | This library aims to support and is [tested against][ci] the following Ruby 59 | versions: 60 | 61 | * Ruby 2.5 62 | * Ruby 2.6 63 | * Ruby 2.7 64 | * Ruby 3.0 65 | * Ruby 3.1 66 | * Ruby 3.2 67 | * Ruby 3.3 68 | * JRuby 9.2 69 | 70 | If something doesn't work on one of these versions, it's a bug. 71 | 72 | This library may inadvertently work (or seem to work) on other Ruby versions, 73 | however support will only be provided for the versions listed above. 74 | 75 | If you would like this library to support another Ruby version or 76 | implementation, you may volunteer to be a maintainer. Being a maintainer 77 | entails making sure all tests run and pass on that implementation. When 78 | something breaks on your implementation, you will be responsible for providing 79 | patches in a timely fashion. If critical issues for a particular implementation 80 | exist at the time of a major release, support for that Ruby version may be 81 | dropped. 82 | 83 | 84 | ## Contributing 85 | 86 | 1. Fork it ( https://github.com/httprb/form_data.rb/fork ) 87 | 2. Create your feature branch (`git checkout -b my-new-feature`) 88 | 3. Commit your changes (`git commit -am 'Add some feature'`) 89 | 4. Push to the branch (`git push origin my-new-feature`) 90 | 5. Create a new Pull Request 91 | 92 | 93 | ## Copyright 94 | 95 | Copyright (c) 2015-2021 Alexey V Zapparov. 96 | See [LICENSE.txt][license] for further details. 97 | 98 | 99 | [ci]: http://travis-ci.org/httprb/form_data.rb 100 | [license]: https://github.com/httprb/form_data.rb/blob/master/LICENSE.txt 101 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | 5 | require "rspec/core/rake_task" 6 | RSpec::Core::RakeTask.new 7 | 8 | begin 9 | require "rubocop/rake_task" 10 | RuboCop::RakeTask.new 11 | rescue LoadError 12 | task :rubocop do 13 | warn "RuboCop is disabled" 14 | end 15 | end 16 | 17 | task :default => %i[spec rubocop] 18 | -------------------------------------------------------------------------------- /http-form_data.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "http/form_data/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "http-form_data" 9 | spec.version = HTTP::FormData::VERSION 10 | spec.homepage = "https://github.com/httprb/form_data.rb" 11 | spec.authors = ["Aleksey V Zapparov"] 12 | spec.email = ["ixti@member.fsf.org"] 13 | spec.license = "MIT" 14 | spec.summary = "http-form_data-#{HTTP::FormData::VERSION}" 15 | spec.description = <<-DESC.gsub(/^\s+> /m, "").tr("\n", " ").strip 16 | > Utility-belt to build form data request bodies. 17 | > Provides support for `application/x-www-form-urlencoded` and 18 | > `multipart/form-data` types. 19 | DESC 20 | 21 | spec.metadata["changelog_uri"] = "https://github.com/httprb/form_data/blob/master/CHANGES.md" 22 | 23 | spec.files = `git ls-files -z`.split("\x0") 24 | spec.executables = spec.files.grep(%r{^bin/}).map { |f| File.basename(f) } 25 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 26 | spec.require_paths = ["lib"] 27 | 28 | spec.required_ruby_version = ">= 2.5" 29 | end 30 | -------------------------------------------------------------------------------- /lib/http/form_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "http/form_data/part" 4 | require "http/form_data/file" 5 | require "http/form_data/multipart" 6 | require "http/form_data/urlencoded" 7 | require "http/form_data/version" 8 | 9 | # http gem namespace. 10 | # @see https://github.com/httprb/http 11 | module HTTP 12 | # Utility-belt to build form data request bodies. 13 | # Provides support for `application/x-www-form-urlencoded` and 14 | # `multipart/form-data` types. 15 | # 16 | # @example Usage 17 | # 18 | # form = FormData.create({ 19 | # :username => "ixti", 20 | # :avatar_file => FormData::File.new("/home/ixti/avatar.png") 21 | # }) 22 | # 23 | # # Assuming socket is an open socket to some HTTP server 24 | # socket << "POST /some-url HTTP/1.1\r\n" 25 | # socket << "Host: example.com\r\n" 26 | # socket << "Content-Type: #{form.content_type}\r\n" 27 | # socket << "Content-Length: #{form.content_length}\r\n" 28 | # socket << "\r\n" 29 | # socket << form.to_s 30 | module FormData 31 | # CRLF 32 | CRLF = "\r\n" 33 | 34 | # Generic FormData error. 35 | class Error < StandardError; end 36 | 37 | class << self 38 | # FormData factory. Automatically selects best type depending on given 39 | # `data` Hash. 40 | # 41 | # @param [#to_h, Hash] data 42 | # @return [Multipart] if any of values is a {FormData::File} 43 | # @return [Urlencoded] otherwise 44 | def create(data, encoder: nil) 45 | data = ensure_hash data 46 | 47 | if multipart?(data) 48 | Multipart.new(data) 49 | else 50 | Urlencoded.new(data, :encoder => encoder) 51 | end 52 | end 53 | 54 | # Coerce `obj` to Hash. 55 | # 56 | # @note Internal usage helper, to workaround lack of `#to_h` on Ruby < 2.1 57 | # @raise [Error] `obj` can't be coerced. 58 | # @return [Hash] 59 | def ensure_hash(obj) 60 | if obj.nil? then {} 61 | elsif obj.is_a?(Hash) then obj 62 | elsif obj.respond_to?(:to_h) then obj.to_h 63 | else raise Error, "#{obj.inspect} is neither Hash nor responds to :to_h" 64 | end 65 | end 66 | 67 | private 68 | 69 | # Tells whenever data contains multipart data or not. 70 | # 71 | # @param [Hash] data 72 | # @return [Boolean] 73 | def multipart?(data) 74 | data.any? do |_, v| 75 | next true if v.is_a? FormData::Part 76 | 77 | v.respond_to?(:to_ary) && v.to_ary.any? { |e| e.is_a? FormData::Part } 78 | end 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/http/form_data/composite_io.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "stringio" 4 | 5 | module HTTP 6 | module FormData 7 | # Provides IO interface across multiple IO objects. 8 | class CompositeIO 9 | # @param [Array] ios Array of IO objects 10 | def initialize(ios) # rubocop:disable Metrics/MethodLength 11 | @index = 0 12 | @buffer = "".b 13 | @ios = ios.map do |io| 14 | if io.is_a?(String) 15 | StringIO.new(io) 16 | elsif io.respond_to?(:read) 17 | io 18 | else 19 | raise ArgumentError, 20 | "#{io.inspect} is neither a String nor an IO object" 21 | end 22 | end 23 | end 24 | 25 | # Reads and returns partial content acrosss multiple IO objects. 26 | # 27 | # @param [Integer] length Number of bytes to retrieve 28 | # @param [String] outbuf String to be replaced with retrieved data 29 | # 30 | # @return [String, nil] 31 | def read(length = nil, outbuf = nil) 32 | data = outbuf.clear.force_encoding(Encoding::BINARY) if outbuf 33 | data ||= "".b 34 | 35 | read_chunks(length) { |chunk| data << chunk } 36 | 37 | data unless length && data.empty? 38 | end 39 | 40 | # Returns sum of all IO sizes. 41 | def size 42 | @size ||= @ios.map(&:size).inject(0, :+) 43 | end 44 | 45 | # Rewinds all IO objects and set cursor to the first IO object. 46 | def rewind 47 | @ios.each(&:rewind) 48 | @index = 0 49 | end 50 | 51 | private 52 | 53 | # Yields chunks with total length up to `length`. 54 | def read_chunks(length = nil) 55 | while (chunk = readpartial(length)) 56 | yield chunk.force_encoding(Encoding::BINARY) 57 | 58 | next if length.nil? 59 | 60 | length -= chunk.bytesize 61 | 62 | break if length.zero? 63 | end 64 | end 65 | 66 | # Reads chunk from current IO with length up to `max_length`. 67 | def readpartial(max_length = nil) 68 | while current_io 69 | chunk = current_io.read(max_length, @buffer) 70 | 71 | return chunk if chunk && !chunk.empty? 72 | 73 | advance_io 74 | end 75 | end 76 | 77 | # Returns IO object under the cursor. 78 | def current_io 79 | @ios[@index] 80 | end 81 | 82 | # Advances cursor to the next IO object. 83 | def advance_io 84 | @index += 1 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/http/form_data/file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP 4 | module FormData 5 | # Represents file form param. 6 | # 7 | # @example Usage with StringIO 8 | # 9 | # io = StringIO.new "foo bar baz" 10 | # FormData::File.new io, :filename => "foobar.txt" 11 | # 12 | # @example Usage with IO 13 | # 14 | # File.open "/home/ixti/avatar.png" do |io| 15 | # FormData::File.new io 16 | # end 17 | # 18 | # @example Usage with pathname 19 | # 20 | # FormData::File.new "/home/ixti/avatar.png" 21 | class File < Part 22 | # Default MIME type 23 | DEFAULT_MIME = "application/octet-stream" 24 | 25 | # @deprecated Use #content_type instead 26 | alias mime_type content_type 27 | 28 | # @see DEFAULT_MIME 29 | # @param [String, Pathname, IO] path_or_io Filename or IO instance. 30 | # @param [#to_h] opts 31 | # @option opts [#to_s] :content_type (DEFAULT_MIME) 32 | # Value of Content-Type header 33 | # @option opts [#to_s] :filename 34 | # When `path_or_io` is a String, Pathname or File, defaults to basename. 35 | # When `path_or_io` is a IO, defaults to `"stream-{object_id}"`. 36 | def initialize(path_or_io, opts = {}) # rubocop:disable Lint/MissingSuper 37 | opts = FormData.ensure_hash(opts) 38 | 39 | if opts.key? :mime_type 40 | warn "[DEPRECATED] :mime_type option deprecated, use :content_type" 41 | opts[:content_type] = opts[:mime_type] 42 | end 43 | 44 | @io = make_io(path_or_io) 45 | @content_type = opts.fetch(:content_type, DEFAULT_MIME).to_s 46 | @filename = opts.fetch(:filename, filename_for(@io)) 47 | end 48 | 49 | private 50 | 51 | def make_io(path_or_io) 52 | if path_or_io.is_a?(String) 53 | ::File.open(path_or_io, :binmode => true) 54 | elsif defined?(Pathname) && path_or_io.is_a?(Pathname) 55 | path_or_io.open(:binmode => true) 56 | else 57 | path_or_io 58 | end 59 | end 60 | 61 | def filename_for(io) 62 | if io.respond_to?(:path) 63 | ::File.basename io.path 64 | else 65 | "stream-#{io.object_id}" 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/http/form_data/multipart.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | 5 | require "http/form_data/multipart/param" 6 | require "http/form_data/readable" 7 | require "http/form_data/composite_io" 8 | 9 | module HTTP 10 | module FormData 11 | # `multipart/form-data` form data. 12 | class Multipart 13 | include Readable 14 | 15 | attr_reader :boundary 16 | 17 | # @param [#to_h, Hash] data form data key-value Hash 18 | def initialize(data, boundary: self.class.generate_boundary) 19 | parts = Param.coerce FormData.ensure_hash data 20 | 21 | @boundary = boundary.to_s.freeze 22 | @io = CompositeIO.new [*parts.flat_map { |part| [glue, part] }, tail] 23 | end 24 | 25 | # Generates a string suitable for using as a boundary in multipart form 26 | # data. 27 | # 28 | # @return [String] 29 | def self.generate_boundary 30 | ("-" * 21) << SecureRandom.hex(21) 31 | end 32 | 33 | # Returns MIME type to be used for HTTP request `Content-Type` header. 34 | # 35 | # @return [String] 36 | def content_type 37 | "multipart/form-data; boundary=#{@boundary}" 38 | end 39 | 40 | # Returns form data content size to be used for HTTP request 41 | # `Content-Length` header. 42 | # 43 | # @return [Integer] 44 | alias content_length size 45 | 46 | private 47 | 48 | # @return [String] 49 | def glue 50 | @glue ||= "--#{@boundary}#{CRLF}" 51 | end 52 | 53 | # @return [String] 54 | def tail 55 | @tail ||= "--#{@boundary}--#{CRLF}" 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/http/form_data/multipart/param.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "http/form_data/readable" 4 | require "http/form_data/composite_io" 5 | 6 | module HTTP 7 | module FormData 8 | class Multipart 9 | # Utility class to represent multi-part chunks 10 | class Param 11 | include Readable 12 | 13 | # Initializes body part with headers and data. 14 | # 15 | # @example With {FormData::File} value 16 | # 17 | # Content-Disposition: form-data; name="avatar"; filename="avatar.png" 18 | # Content-Type: application/octet-stream 19 | # 20 | # ...data of avatar.png... 21 | # 22 | # @example With non-{FormData::File} value 23 | # 24 | # Content-Disposition: form-data; name="username" 25 | # 26 | # ixti 27 | # 28 | # @return [String] 29 | # @param [#to_s] name 30 | # @param [FormData::File, FormData::Part, #to_s] value 31 | def initialize(name, value) 32 | @name = name.to_s 33 | 34 | @part = 35 | if value.is_a?(FormData::Part) 36 | value 37 | else 38 | FormData::Part.new(value) 39 | end 40 | 41 | @io = CompositeIO.new [header, @part, footer] 42 | end 43 | 44 | # Flattens given `data` Hash into an array of `Param`'s. 45 | # Nested array are unwinded. 46 | # Behavior is similar to `URL.encode_www_form`. 47 | # 48 | # @param [Hash] data 49 | # @return [Array] 50 | def self.coerce(data) 51 | params = [] 52 | 53 | data.each do |name, values| 54 | Array(values).each do |value| 55 | params << new(name, value) 56 | end 57 | end 58 | 59 | params 60 | end 61 | 62 | private 63 | 64 | def header 65 | header = "".b 66 | header << "Content-Disposition: form-data; #{parameters}#{CRLF}" 67 | header << "Content-Type: #{content_type}#{CRLF}" if content_type 68 | header << CRLF 69 | header 70 | end 71 | 72 | def parameters 73 | parameters = { :name => @name } 74 | parameters[:filename] = filename if filename 75 | parameters.map { |k, v| "#{k}=#{v.inspect}" }.join("; ") 76 | end 77 | 78 | def content_type 79 | @part.content_type 80 | end 81 | 82 | def filename 83 | @part.filename 84 | end 85 | 86 | def footer 87 | CRLF.dup 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/http/form_data/part.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "stringio" 4 | 5 | require "http/form_data/readable" 6 | 7 | module HTTP 8 | module FormData 9 | # Represents a body part of multipart/form-data request. 10 | # 11 | # @example Usage with String 12 | # 13 | # body = "Message" 14 | # FormData::Part.new body, :content_type => 'foobar.txt; charset="UTF-8"' 15 | class Part 16 | include Readable 17 | 18 | attr_reader :content_type, :filename 19 | 20 | # @param [#to_s] body 21 | # @param [String] content_type Value of Content-Type header 22 | # @param [String] filename Value of filename parameter 23 | def initialize(body, content_type: nil, filename: nil) 24 | @io = StringIO.new(body.to_s) 25 | @content_type = content_type 26 | @filename = filename 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/http/form_data/readable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP 4 | module FormData 5 | # Common behaviour for objects defined by an IO object. 6 | module Readable 7 | # Returns IO content. 8 | # 9 | # @return [String] 10 | def to_s 11 | rewind 12 | content = read 13 | rewind 14 | content 15 | end 16 | 17 | # Reads and returns part of IO content. 18 | # 19 | # @param [Integer] length Number of bytes to retrieve 20 | # @param [String] outbuf String to be replaced with retrieved data 21 | # 22 | # @return [String, nil] 23 | def read(length = nil, outbuf = nil) 24 | @io.read(length, outbuf) 25 | end 26 | 27 | # Returns IO size. 28 | # 29 | # @return [Integer] 30 | def size 31 | @io.size 32 | end 33 | 34 | # Rewinds the IO. 35 | def rewind 36 | @io.rewind 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/http/form_data/urlencoded.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "http/form_data/readable" 4 | 5 | require "uri" 6 | require "stringio" 7 | 8 | module HTTP 9 | module FormData 10 | # `application/x-www-form-urlencoded` form data. 11 | class Urlencoded 12 | include Readable 13 | 14 | class << self 15 | # Set custom form data encoder implementation. 16 | # 17 | # @example 18 | # 19 | # module CustomFormDataEncoder 20 | # UNESCAPED_CHARS = /[^a-z0-9\-\.\_\~]/i 21 | # 22 | # def self.escape(s) 23 | # ::URI::DEFAULT_PARSER.escape(s.to_s, UNESCAPED_CHARS) 24 | # end 25 | # 26 | # def self.call(data) 27 | # parts = [] 28 | # 29 | # data.each do |k, v| 30 | # k = escape(k) 31 | # 32 | # if v.nil? 33 | # parts << k 34 | # elsif v.respond_to?(:to_ary) 35 | # v.to_ary.each { |vv| parts << "#{k}=#{escape vv}" } 36 | # else 37 | # parts << "#{k}=#{escape v}" 38 | # end 39 | # end 40 | # 41 | # parts.join("&") 42 | # end 43 | # end 44 | # 45 | # HTTP::FormData::Urlencoded.encoder = CustomFormDataEncoder 46 | # 47 | # @raise [ArgumentError] if implementation deos not responds to `#call`. 48 | # @param implementation [#call] 49 | # @return [void] 50 | def encoder=(implementation) 51 | raise ArgumentError unless implementation.respond_to? :call 52 | 53 | @encoder = implementation 54 | end 55 | 56 | # Returns form data encoder implementation. 57 | # Default: `URI.encode_www_form`. 58 | # 59 | # @see .encoder= 60 | # @return [#call] 61 | def encoder 62 | @encoder ||= ::URI.method(:encode_www_form) 63 | end 64 | end 65 | 66 | # @param [#to_h, Hash] data form data key-value Hash 67 | def initialize(data, encoder: nil) 68 | encoder ||= self.class.encoder 69 | @io = StringIO.new(encoder.call(FormData.ensure_hash(data))) 70 | end 71 | 72 | # Returns MIME type to be used for HTTP request `Content-Type` header. 73 | # 74 | # @return [String] 75 | def content_type 76 | "application/x-www-form-urlencoded" 77 | end 78 | 79 | # Returns form data content size to be used for HTTP request 80 | # `Content-Length` header. 81 | # 82 | # @return [Integer] 83 | alias content_length size 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/http/form_data/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP 4 | module FormData 5 | # Gem version. 6 | VERSION = "2.3.0" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/fixtures/expected-multipart-body.tpl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/httprb/form_data/59ad8751f362e5399a1d4374c66f92f18668b853/spec/fixtures/expected-multipart-body.tpl -------------------------------------------------------------------------------- /spec/fixtures/the-http-gem.info: -------------------------------------------------------------------------------- 1 | The HTTP Gem is an easy-to-use client library for making requests from Ruby. 2 | -------------------------------------------------------------------------------- /spec/lib/http/form_data/composite_io_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe HTTP::FormData::CompositeIO do 4 | subject(:composite_io) { HTTP::FormData::CompositeIO.new(ios) } 5 | 6 | let(:ios) { ["Hello", " ", "", "world", "!"].map { |s| StringIO.new(s) } } 7 | 8 | describe "#initialize" do 9 | it "accepts IOs and strings" do 10 | io = HTTP::FormData::CompositeIO.new(["Hello ", StringIO.new("world!")]) 11 | expect(io.read).to eq "Hello world!" 12 | end 13 | 14 | it "fails if an IO is neither a String nor an IO" do 15 | expect { HTTP::FormData::CompositeIO.new %i[hello world] } 16 | .to raise_error(ArgumentError) 17 | end 18 | end 19 | 20 | describe "#read" do 21 | it "reads all data" do 22 | expect(composite_io.read).to eq "Hello world!" 23 | end 24 | 25 | it "reads partial data" do 26 | expect(composite_io.read(3)).to eq "Hel" 27 | expect(composite_io.read(2)).to eq "lo" 28 | expect(composite_io.read(1)).to eq " " 29 | expect(composite_io.read(6)).to eq "world!" 30 | end 31 | 32 | it "returns empty string when no data was retrieved" do 33 | composite_io.read 34 | expect(composite_io.read).to eq "" 35 | end 36 | 37 | it "returns nil when no partial data was retrieved" do 38 | composite_io.read 39 | expect(composite_io.read(3)).to eq nil 40 | end 41 | 42 | it "reads partial data with a buffer" do 43 | outbuf = String.new 44 | expect(composite_io.read(3, outbuf)).to eq "Hel" 45 | expect(composite_io.read(2, outbuf)).to eq "lo" 46 | expect(composite_io.read(1, outbuf)).to eq " " 47 | expect(composite_io.read(6, outbuf)).to eq "world!" 48 | end 49 | 50 | it "fills the buffer with retrieved content" do 51 | outbuf = String.new 52 | composite_io.read(3, outbuf) 53 | expect(outbuf).to eq "Hel" 54 | composite_io.read(2, outbuf) 55 | expect(outbuf).to eq "lo" 56 | composite_io.read(1, outbuf) 57 | expect(outbuf).to eq " " 58 | composite_io.read(6, outbuf) 59 | expect(outbuf).to eq "world!" 60 | end 61 | 62 | it "returns nil when no partial data was retrieved with a buffer" do 63 | outbuf = String.new("content") 64 | composite_io.read 65 | expect(composite_io.read(3, outbuf)).to eq nil 66 | expect(outbuf).to eq "" 67 | end 68 | 69 | it "returns data in binary encoding" do 70 | io = HTTP::FormData::CompositeIO.new(%w[Janko Marohnić]) 71 | 72 | expect(io.read(5).encoding).to eq Encoding::BINARY 73 | expect(io.read(9).encoding).to eq Encoding::BINARY 74 | 75 | io.rewind 76 | expect(io.read.encoding).to eq Encoding::BINARY 77 | expect(io.read.encoding).to eq Encoding::BINARY 78 | end 79 | 80 | it "reads data in bytes" do 81 | emoji = "😃" 82 | io = HTTP::FormData::CompositeIO.new([emoji]) 83 | 84 | expect(io.read(1)).to eq emoji.b[0] 85 | expect(io.read(1)).to eq emoji.b[1] 86 | expect(io.read(1)).to eq emoji.b[2] 87 | expect(io.read(1)).to eq emoji.b[3] 88 | end 89 | end 90 | 91 | describe "#rewind" do 92 | it "rewinds all IOs" do 93 | composite_io.read 94 | composite_io.rewind 95 | expect(composite_io.read).to eq "Hello world!" 96 | end 97 | end 98 | 99 | describe "#size" do 100 | it "returns sum of all IO sizes" do 101 | expect(composite_io.size).to eq 12 102 | end 103 | 104 | it "returns 0 when there are no IOs" do 105 | empty_composite_io = HTTP::FormData::CompositeIO.new [] 106 | expect(empty_composite_io.size).to eq 0 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /spec/lib/http/form_data/file_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe HTTP::FormData::File do 4 | let(:opts) { nil } 5 | let(:form_file) { described_class.new(file, opts) } 6 | 7 | describe "#size" do 8 | subject { form_file.size } 9 | 10 | context "when file given as a String" do 11 | let(:file) { fixture("the-http-gem.info").to_s } 12 | it { is_expected.to eq fixture("the-http-gem.info").size } 13 | end 14 | 15 | context "when file given as a Pathname" do 16 | let(:file) { fixture("the-http-gem.info") } 17 | it { is_expected.to eq fixture("the-http-gem.info").size } 18 | end 19 | 20 | context "when file given as File" do 21 | let(:file) { fixture("the-http-gem.info").open } 22 | after { file.close } 23 | it { is_expected.to eq fixture("the-http-gem.info").size } 24 | end 25 | 26 | context "when file given as IO" do 27 | let(:file) { StringIO.new "привет мир!" } 28 | it { is_expected.to eq 20 } 29 | end 30 | end 31 | 32 | describe "#to_s" do 33 | subject { form_file.to_s } 34 | 35 | context "when file given as a String" do 36 | let(:file) { fixture("the-http-gem.info").to_s } 37 | it { is_expected.to eq fixture("the-http-gem.info").read(:mode => "rb") } 38 | 39 | it "rewinds content" do 40 | content = form_file.read 41 | expect(form_file.to_s).to eq content 42 | expect(form_file.read).to eq content 43 | end 44 | end 45 | 46 | context "when file given as a Pathname" do 47 | let(:file) { fixture("the-http-gem.info") } 48 | it { is_expected.to eq fixture("the-http-gem.info").read(:mode => "rb") } 49 | 50 | it "rewinds content" do 51 | content = form_file.read 52 | expect(form_file.to_s).to eq content 53 | expect(form_file.read).to eq content 54 | end 55 | end 56 | 57 | context "when file given as File" do 58 | let(:file) { fixture("the-http-gem.info").open("rb") } 59 | after { file.close } 60 | it { is_expected.to eq fixture("the-http-gem.info").read(:mode => "rb") } 61 | 62 | it "rewinds content" do 63 | content = form_file.read 64 | expect(form_file.to_s).to eq content 65 | expect(form_file.read).to eq content 66 | end 67 | end 68 | 69 | context "when file given as IO" do 70 | let(:file) { StringIO.new "привет мир!" } 71 | it { is_expected.to eq "привет мир!" } 72 | 73 | it "rewinds content" do 74 | content = form_file.read 75 | expect(form_file.to_s).to eq content 76 | expect(form_file.read).to eq content 77 | end 78 | end 79 | end 80 | 81 | describe "#read" do 82 | subject { form_file.read } 83 | 84 | context "when file given as a String" do 85 | let(:file) { fixture("the-http-gem.info").to_s } 86 | it { is_expected.to eq fixture("the-http-gem.info").read(:mode => "rb") } 87 | end 88 | 89 | context "when file given as a Pathname" do 90 | let(:file) { fixture("the-http-gem.info") } 91 | it { is_expected.to eq fixture("the-http-gem.info").read(:mode => "rb") } 92 | end 93 | 94 | context "when file given as File" do 95 | let(:file) { fixture("the-http-gem.info").open("rb") } 96 | after { file.close } 97 | it { is_expected.to eq fixture("the-http-gem.info").read(:mode => "rb") } 98 | end 99 | 100 | context "when file given as IO" do 101 | let(:file) { StringIO.new "привет мир!" } 102 | it { is_expected.to eq "привет мир!" } 103 | end 104 | end 105 | 106 | describe "#rewind" do 107 | context "when file given as a String" do 108 | let(:file) { fixture("the-http-gem.info").to_s } 109 | 110 | it "rewinds the underlying IO object" do 111 | content = form_file.read 112 | form_file.rewind 113 | expect(form_file.read).to eq content 114 | end 115 | end 116 | 117 | context "when file given as a Pathname" do 118 | let(:file) { fixture("the-http-gem.info") } 119 | 120 | it "rewinds the underlying IO object" do 121 | content = form_file.read 122 | form_file.rewind 123 | expect(form_file.read).to eq content 124 | end 125 | end 126 | 127 | context "when file given as File" do 128 | let(:file) { fixture("the-http-gem.info").open("rb") } 129 | after { file.close } 130 | 131 | it "rewinds the underlying IO object" do 132 | content = form_file.read 133 | form_file.rewind 134 | expect(form_file.read).to eq content 135 | end 136 | end 137 | 138 | context "when file given as IO" do 139 | let(:file) { StringIO.new "привет мир!" } 140 | 141 | it "rewinds the underlying IO object" do 142 | content = form_file.read 143 | form_file.rewind 144 | expect(form_file.read).to eq content 145 | end 146 | end 147 | end 148 | 149 | describe "#filename" do 150 | subject { form_file.filename } 151 | 152 | context "when file given as a String" do 153 | let(:file) { fixture("the-http-gem.info").to_s } 154 | 155 | it { is_expected.to eq ::File.basename file } 156 | 157 | context "and filename given with options" do 158 | let(:opts) { { :filename => "foobar.txt" } } 159 | it { is_expected.to eq "foobar.txt" } 160 | end 161 | end 162 | 163 | context "when file given as a Pathname" do 164 | let(:file) { fixture("the-http-gem.info") } 165 | 166 | it { is_expected.to eq ::File.basename file } 167 | 168 | context "and filename given with options" do 169 | let(:opts) { { :filename => "foobar.txt" } } 170 | it { is_expected.to eq "foobar.txt" } 171 | end 172 | end 173 | 174 | context "when file given as File" do 175 | let(:file) { fixture("the-http-gem.info").open } 176 | after { file.close } 177 | 178 | it { is_expected.to eq "the-http-gem.info" } 179 | 180 | context "and filename given with options" do 181 | let(:opts) { { :filename => "foobar.txt" } } 182 | it { is_expected.to eq "foobar.txt" } 183 | end 184 | end 185 | 186 | context "when file given as IO" do 187 | let(:file) { StringIO.new } 188 | 189 | it { is_expected.to eq "stream-#{file.object_id}" } 190 | 191 | context "and filename given with options" do 192 | let(:opts) { { :filename => "foobar.txt" } } 193 | it { is_expected.to eq "foobar.txt" } 194 | end 195 | end 196 | end 197 | 198 | describe "#content_type" do 199 | let(:file) { StringIO.new } 200 | subject { form_file.content_type } 201 | 202 | it { is_expected.to eq "application/octet-stream" } 203 | 204 | context "when it was given with options" do 205 | let(:opts) { { :content_type => "application/json" } } 206 | it { is_expected.to eq "application/json" } 207 | end 208 | end 209 | 210 | describe "#mime_type" do 211 | it "should be an alias of #content_type" do 212 | expect(described_class.instance_method(:mime_type)) 213 | .to eq(described_class.instance_method(:content_type)) 214 | end 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /spec/lib/http/form_data/multipart_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe HTTP::FormData::Multipart do 4 | subject(:form_data) { HTTP::FormData::Multipart.new params } 5 | 6 | let(:file) { HTTP::FormData::File.new fixture "the-http-gem.info" } 7 | let(:params) { { :foo => :bar, :baz => file } } 8 | let(:boundary) { /-{21}[a-f0-9]{42}/ } 9 | 10 | describe "#to_s" do 11 | def disposition(params) 12 | params = params.map { |k, v| "#{k}=#{v.inspect}" }.join("; ") 13 | "Content-Disposition: form-data; #{params}" 14 | end 15 | 16 | let(:crlf) { "\r\n" } 17 | 18 | it "properly generates multipart data" do 19 | boundary_value = form_data.boundary 20 | 21 | expect(form_data.to_s).to eq([ 22 | "--#{boundary_value}#{crlf}", 23 | "#{disposition 'name' => 'foo'}#{crlf}", 24 | "#{crlf}bar#{crlf}", 25 | "--#{boundary_value}#{crlf}", 26 | "#{disposition 'name' => 'baz', 'filename' => file.filename}#{crlf}", 27 | "Content-Type: #{file.content_type}#{crlf}", 28 | "#{crlf}#{file}#{crlf}", 29 | "--#{boundary_value}--#{crlf}" 30 | ].join) 31 | end 32 | 33 | it "rewinds content" do 34 | content = form_data.read 35 | expect(form_data.to_s).to eq content 36 | expect(form_data.read).to eq content 37 | end 38 | 39 | context "with user-defined boundary" do 40 | subject(:form_data) do 41 | HTTP::FormData::Multipart.new params, :boundary => "my-boundary" 42 | end 43 | 44 | it "uses the given boundary" do 45 | expect(form_data.to_s).to eq([ 46 | "--my-boundary#{crlf}", 47 | "#{disposition 'name' => 'foo'}#{crlf}", 48 | "#{crlf}bar#{crlf}", 49 | "--my-boundary#{crlf}", 50 | "#{disposition 'name' => 'baz', 'filename' => file.filename}#{crlf}", 51 | "Content-Type: #{file.content_type}#{crlf}", 52 | "#{crlf}#{file}#{crlf}", 53 | "--my-boundary--#{crlf}" 54 | ].join) 55 | end 56 | end 57 | 58 | context "with filename set to nil" do 59 | let(:part) { HTTP::FormData::Part.new("s", :content_type => "mime/type") } 60 | let(:form_data) { HTTP::FormData::Multipart.new({ :foo => part }) } 61 | 62 | it "doesn't include a filename" do 63 | boundary_value = form_data.content_type[/(#{boundary})$/, 1] 64 | 65 | expect(form_data.to_s).to eq([ 66 | "--#{boundary_value}#{crlf}", 67 | "#{disposition 'name' => 'foo'}#{crlf}", 68 | "Content-Type: #{part.content_type}#{crlf}", 69 | "#{crlf}s#{crlf}", 70 | "--#{boundary_value}--#{crlf}" 71 | ].join) 72 | end 73 | end 74 | 75 | context "with content type set to nil" do 76 | let(:part) { HTTP::FormData::Part.new("s") } 77 | let(:form_data) { HTTP::FormData::Multipart.new({ :foo => part }) } 78 | 79 | it "doesn't include a filename" do 80 | boundary_value = form_data.content_type[/(#{boundary})$/, 1] 81 | 82 | expect(form_data.to_s).to eq([ 83 | "--#{boundary_value}#{crlf}", 84 | "#{disposition 'name' => 'foo'}#{crlf}", 85 | "#{crlf}s#{crlf}", 86 | "--#{boundary_value}--#{crlf}" 87 | ].join) 88 | end 89 | end 90 | end 91 | 92 | describe "#size" do 93 | it "returns bytesize of multipart data" do 94 | expect(form_data.size).to eq form_data.to_s.bytesize 95 | end 96 | end 97 | 98 | describe "#read" do 99 | it "returns multipart data" do 100 | expect(form_data.read).to eq form_data.to_s 101 | end 102 | end 103 | 104 | describe "#rewind" do 105 | it "rewinds the multipart data IO" do 106 | form_data.read 107 | form_data.rewind 108 | expect(form_data.read).to eq form_data.to_s 109 | end 110 | end 111 | 112 | describe "#content_type" do 113 | subject { form_data.content_type } 114 | 115 | let(:content_type) { %r{^multipart/form-data; boundary=#{boundary}$} } 116 | 117 | it { is_expected.to match(content_type) } 118 | 119 | context "with user-defined boundary" do 120 | let(:form_data) do 121 | HTTP::FormData::Multipart.new params, :boundary => "my-boundary" 122 | end 123 | 124 | it "includes the given boundary" do 125 | expect(form_data.content_type) 126 | .to eq "multipart/form-data; boundary=my-boundary" 127 | end 128 | end 129 | end 130 | 131 | describe "#content_length" do 132 | subject { form_data.content_length } 133 | it { is_expected.to eq form_data.to_s.bytesize } 134 | end 135 | 136 | describe "#boundary" do 137 | it "returns a new boundary" do 138 | expect(form_data.boundary).to match(boundary) 139 | end 140 | 141 | context "with user-defined boundary" do 142 | let(:form_data) do 143 | HTTP::FormData::Multipart.new params, :boundary => "my-boundary" 144 | end 145 | 146 | it "returns the given boundary" do 147 | expect(form_data.boundary).to eq "my-boundary" 148 | end 149 | end 150 | end 151 | 152 | describe ".generate_boundary" do 153 | it "returns a string suitable as a multipart boundary" do 154 | expect(form_data.class.generate_boundary).to match(boundary) 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /spec/lib/http/form_data/part_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe HTTP::FormData::Part do 4 | let(:body) { "" } 5 | let(:opts) { {} } 6 | subject(:part) { HTTP::FormData::Part.new(body, **opts) } 7 | 8 | describe "#size" do 9 | subject { part.size } 10 | 11 | context "when body given as a String" do 12 | let(:body) { "привет мир!" } 13 | it { is_expected.to eq 20 } 14 | end 15 | end 16 | 17 | describe "#to_s" do 18 | subject! { part.to_s } 19 | 20 | context "when body given as String" do 21 | let(:body) { "привет мир!" } 22 | it { is_expected.to eq "привет мир!" } 23 | 24 | it "rewinds content" do 25 | content = part.read 26 | expect(part.to_s).to eq content 27 | expect(part.read).to eq content 28 | end 29 | end 30 | end 31 | 32 | describe "#read" do 33 | subject { part.read } 34 | 35 | context "when body given as String" do 36 | let(:body) { "привет мир!" } 37 | it { is_expected.to eq "привет мир!" } 38 | end 39 | end 40 | 41 | describe "#rewind" do 42 | context "when body given as String" do 43 | let(:body) { "привет мир!" } 44 | 45 | it "rewinds the underlying IO object" do 46 | part.read 47 | part.rewind 48 | expect(part.read).to eq "привет мир!" 49 | end 50 | end 51 | end 52 | 53 | describe "#filename" do 54 | subject { part.filename } 55 | 56 | it { is_expected.to eq nil } 57 | 58 | context "when it was given with options" do 59 | let(:opts) { { :filename => "foobar.txt" } } 60 | it { is_expected.to eq "foobar.txt" } 61 | end 62 | end 63 | 64 | describe "#content_type" do 65 | subject { part.content_type } 66 | 67 | it { is_expected.to eq nil } 68 | 69 | context "when it was given with options" do 70 | let(:opts) { { :content_type => "application/json" } } 71 | it { is_expected.to eq "application/json" } 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/lib/http/form_data/urlencoded_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe HTTP::FormData::Urlencoded do 4 | let(:data) { { "foo[bar]" => "test" } } 5 | subject(:form_data) { HTTP::FormData::Urlencoded.new data } 6 | 7 | describe "#content_type" do 8 | subject { form_data.content_type } 9 | it { is_expected.to eq "application/x-www-form-urlencoded" } 10 | end 11 | 12 | describe "#content_length" do 13 | subject { form_data.content_length } 14 | it { is_expected.to eq form_data.to_s.bytesize } 15 | 16 | context "with unicode chars" do 17 | let(:data) { { "foo[bar]" => "тест" } } 18 | it { is_expected.to eq form_data.to_s.bytesize } 19 | end 20 | end 21 | 22 | describe "#to_s" do 23 | subject { form_data.to_s } 24 | it { is_expected.to eq "foo%5Bbar%5D=test" } 25 | 26 | context "with unicode chars" do 27 | let(:data) { { "foo[bar]" => "тест" } } 28 | it { is_expected.to eq "foo%5Bbar%5D=%D1%82%D0%B5%D1%81%D1%82" } 29 | end 30 | 31 | it "rewinds content" do 32 | content = form_data.read 33 | expect(form_data.to_s).to eq content 34 | expect(form_data.read).to eq content 35 | end 36 | end 37 | 38 | describe "#size" do 39 | it "returns bytesize of multipart data" do 40 | expect(form_data.size).to eq form_data.to_s.bytesize 41 | end 42 | end 43 | 44 | describe "#read" do 45 | it "returns multipart data" do 46 | expect(form_data.read).to eq form_data.to_s 47 | end 48 | end 49 | 50 | describe "#rewind" do 51 | it "rewinds the multipart data IO" do 52 | form_data.read 53 | form_data.rewind 54 | expect(form_data.read).to eq form_data.to_s 55 | end 56 | end 57 | 58 | describe ".encoder=" do 59 | before { described_class.encoder = ::JSON.method(:dump) } 60 | after { described_class.encoder = ::URI.method(:encode_www_form) } 61 | 62 | it "switches form encoder implementation" do 63 | expect(form_data.to_s).to eq('{"foo[bar]":"test"}') 64 | end 65 | end 66 | 67 | context "with custom instance level encoder" do 68 | let(:encoder) { proc { |data| ::JSON.dump(data) } } 69 | subject(:form_data) do 70 | HTTP::FormData::Urlencoded.new(data, :encoder => encoder) 71 | end 72 | 73 | it "uses encoder passed to initializer" do 74 | expect(form_data.to_s).to eq('{"foo[bar]":"test"}') 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/lib/http/form_data_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe HTTP::FormData do 4 | describe ".create" do 5 | subject { HTTP::FormData.create params } 6 | 7 | context "when form has no files" do 8 | let(:params) { { :foo => :bar } } 9 | it { is_expected.to be_a HTTP::FormData::Urlencoded } 10 | end 11 | 12 | context "when form has at least one file param" do 13 | let(:file) { HTTP::FormData::File.new(fixture("the-http-gem.info").to_s) } 14 | let(:params) { { :foo => :bar, :baz => file } } 15 | it { is_expected.to be_a HTTP::FormData::Multipart } 16 | end 17 | 18 | context "when form has file in an array param" do 19 | let(:file) { HTTP::FormData::File.new(fixture("the-http-gem.info").to_s) } 20 | let(:params) { { :foo => :bar, :baz => [file] } } 21 | it { is_expected.to be_a HTTP::FormData::Multipart } 22 | end 23 | end 24 | 25 | describe ".ensure_hash" do 26 | subject(:ensure_hash) { HTTP::FormData.ensure_hash data } 27 | 28 | context "when Hash given" do 29 | let(:data) { { :foo => :bar } } 30 | it { is_expected.to eq :foo => :bar } 31 | end 32 | 33 | context "when #to_h given" do 34 | let(:data) { double(:to_h => { :foo => :bar }) } 35 | it { is_expected.to eq :foo => :bar } 36 | end 37 | 38 | context "when nil given" do 39 | let(:data) { nil } 40 | it { is_expected.to eq({}) } 41 | end 42 | 43 | context "when neither Hash nor #to_h given" do 44 | let(:data) { double } 45 | it "fails with HTTP::FormData::Error" do 46 | expect { ensure_hash }.to raise_error HTTP::FormData::Error 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "./support/simplecov" 4 | require_relative "./support/fuubar" unless ENV["CI"] 5 | 6 | require "http/form_data" 7 | require "support/fixtures_helper" 8 | 9 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 10 | RSpec.configure do |config| 11 | config.expect_with :rspec do |expectations| 12 | # This option will default to `true` in RSpec 4. It makes the `description` 13 | # and `failure_message` of custom matchers include text for helper methods 14 | # defined using `chain`, e.g.: 15 | # be_bigger_than(2).and_smaller_than(4).description 16 | # # => "be bigger than 2 and smaller than 4" 17 | # ...rather than: 18 | # # => "be bigger than 2" 19 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 20 | end 21 | 22 | config.mock_with :rspec do |mocks| 23 | # Prevents you from mocking or stubbing a method that does not exist on 24 | # a real object. This is generally recommended, and will default to 25 | # `true` in RSpec 4. 26 | mocks.verify_partial_doubles = true 27 | end 28 | 29 | # These two settings work together to allow you to limit a spec run 30 | # to individual examples or groups you care about by tagging them with 31 | # `:focus` metadata. When nothing is tagged with `:focus`, all examples 32 | # get run. 33 | config.filter_run :focus 34 | config.run_all_when_everything_filtered = true 35 | 36 | # Limits the available syntax to the non-monkey patched syntax that is 37 | # recommended. For more details, see: 38 | # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax 39 | # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 40 | # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching 41 | config.disable_monkey_patching! 42 | 43 | # This setting enables warnings. It's recommended, but in some cases may 44 | # be too noisy due to issues in dependencies. 45 | config.warnings = true 46 | 47 | # Many RSpec users commonly either run the entire suite or an individual 48 | # file, and it's useful to allow more verbose output when running an 49 | # individual spec file. 50 | if config.files_to_run.one? 51 | # Use the documentation formatter for detailed output, 52 | # unless a formatter has already been configured 53 | # (e.g. via a command-line flag). 54 | config.default_formatter = "doc" 55 | end 56 | 57 | # Print the 10 slowest examples and example groups at the 58 | # end of the spec run, to help surface which specs are running 59 | # particularly slow. 60 | config.profile_examples = 10 61 | 62 | # Run specs in random order to surface order dependencies. If you find an 63 | # order dependency and want to debug it, you can fix the order by providing 64 | # the seed, which is printed after each run. 65 | # --seed 1234 66 | config.order = :random 67 | 68 | # Seed global randomization in this process using the `--seed` CLI option. 69 | # Setting this allows you to use `--seed` to deterministically reproduce 70 | # test failures related to randomization by passing the same `--seed` value 71 | # as the one that triggered the failure. 72 | Kernel.srand config.seed 73 | 74 | # Include common helpers 75 | config.include FixturesHelper 76 | end 77 | -------------------------------------------------------------------------------- /spec/support/fixtures_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pathname" 4 | 5 | module FixturesHelper 6 | def fixture(filename) 7 | fixtures_root.join filename 8 | end 9 | 10 | def fixtures_root 11 | @fixtures_root ||= Pathname.new(__FILE__).join("../../fixtures").realpath 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/fuubar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fuubar" 4 | 5 | RSpec.configure do |config| 6 | # Use Fuubar instafail-alike formatter, unless a formatter has already been 7 | # configured (e.g. via a command-line flag). 8 | config.default_formatter = "Fuubar" 9 | 10 | # Disable auto-refresh of the fuubar progress bar to avoid surprises during 11 | # debugiing. And simply because there's next to absolutely no point in having 12 | # this turned on. 13 | # 14 | # > By default fuubar will automatically refresh the bar (and therefore 15 | # > the ETA) every second. Unfortunately this doesn't play well with things 16 | # > like debuggers. When you're debugging, having a bar show up every second 17 | # > is undesireable. 18 | # 19 | # See: https://github.com/thekompanee/fuubar#disabling-auto-refresh 20 | config.fuubar_auto_refresh = false 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/simplecov.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | 5 | if ENV["CI"] 6 | require "simplecov-lcov" 7 | 8 | SimpleCov::Formatter::LcovFormatter.config do |config| 9 | config.report_with_single_file = true 10 | config.lcov_file_name = "lcov.info" 11 | end 12 | 13 | SimpleCov.formatter = SimpleCov::Formatter::LcovFormatter 14 | end 15 | 16 | SimpleCov.start do 17 | add_filter "/spec/" 18 | minimum_coverage 80 19 | end 20 | --------------------------------------------------------------------------------