├── .github └── workflow │ └── ruby.yml ├── .gitignore ├── .rspec ├── Gemfile ├── Gemfile.lock ├── LICENSE.md ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── uri-builder.rb └── uri │ ├── builder.rb │ └── builder │ └── version.rb ├── sig └── uri │ └── transform.rbs ├── spec ├── spec_helper.rb └── uri │ └── builder_spec.rb └── uri-transform.gemspec /.github/workflow/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ main ] 13 | pull_request: 14 | branches: [ main ] 15 | 16 | jobs: 17 | test: 18 | 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | ruby-version: ['2.7', '3.0'] 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Set up Ruby 27 | # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby, 28 | # change this to (see https://github.com/ruby/setup-ruby#versioning): 29 | # uses: ruby/setup-ruby@v1 30 | uses: ruby/setup-ruby@473e4d8fe5dd94ee328fdfca9f8c9c7afc9dae5e 31 | with: 32 | ruby-version: ${{ matrix.ruby-version }} 33 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 34 | - name: Run tests 35 | run: bundle exec rake 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in uri-builder.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | 10 | gem "rspec", "~> 3.0" 11 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | uri-builder (0.1.13) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | diff-lcs (1.5.1) 10 | rake (13.1.0) 11 | rspec (3.13.0) 12 | rspec-core (~> 3.13.0) 13 | rspec-expectations (~> 3.13.0) 14 | rspec-mocks (~> 3.13.0) 15 | rspec-core (3.13.0) 16 | rspec-support (~> 3.13.0) 17 | rspec-expectations (3.13.0) 18 | diff-lcs (>= 1.2.0, < 2.0) 19 | rspec-support (~> 3.13.0) 20 | rspec-mocks (3.13.0) 21 | diff-lcs (>= 1.2.0, < 2.0) 22 | rspec-support (~> 3.13.0) 23 | rspec-support (3.13.1) 24 | 25 | PLATFORMS 26 | arm64-darwin-21 27 | arm64-darwin-22 28 | arm64-darwin-23 29 | arm64-darwin-24 30 | x86_64-darwin-20 31 | 32 | DEPENDENCIES 33 | rake (~> 13.0) 34 | rspec (~> 3.0) 35 | uri-builder! 36 | 37 | BUNDLED WITH 38 | 2.3.13 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2024 Bradley Gessler 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # URI::Builder 2 | 3 | URI builder makes working with URLs in Ruby a little less awkward by chaining methods calls that alter the URL. It looks like this: 4 | 5 | ```ruby 6 | URI.build("https://www.example.com/api/v1").path("/api/v2").query(search: "great books").uri 7 | ``` 8 | 9 | Or if you prefer a block format that automatically converts back to an URI object after the transformation. 10 | 11 | ```ruby 12 | URI.build("https://www.example.com/api/v1") { it.path("/api/v2").query search: "great books" } 13 | ``` 14 | 15 | Compare that to: 16 | 17 | ```ruby 18 | uri = URI("https://www.example.com/api/v1") 19 | uri.path = "/api/v2" 20 | uri.query = URI.encode_www_form(search: "great books") 21 | uri 22 | ``` 23 | 24 | There's even a shortcut for working with URLs from ENV vars: 25 | 26 | ```ruby 27 | URI.env("API_URL").path("/people/search").query(first_name: "Brad") 28 | ``` 29 | 30 | Compare that to: 31 | 32 | ```ruby 33 | uri = URI ENV.fetch("API_URL") 34 | uri.path = "/people/search" 35 | uri.query = URI.encode_www_form(first_name: "Brad") 36 | uri 37 | ``` 38 | 39 | Paths may be traversed with various methods: 40 | 41 | ```ruby 42 | # initialize base URL 43 | uri = URI.build("https://www.example.com/api/v1") 44 | 45 | uri.join("books/search").query(search: "great books").uri 46 | # => # 47 | 48 | uri.parent.join("v2/articles/search").query(search: "great books").uri 49 | # => # 50 | 51 | uri.root.join("about").uri 52 | # => # 53 | ``` 54 | 55 | Compare that to: 56 | 57 | ```ruby 58 | URI("https://www.example.com/api/v1").tap do |uri| 59 | uri.path = uri.path + "/books/search" 60 | uri.query = URI.encode_www_form(search: "great books") 61 | end 62 | ``` 63 | 64 | Each chain creates a duplicate of the original URL, so you can transform away without worrying about thrashing the original URL object. 65 | 66 | ## Installation 67 | 68 | Install the gem and add to the application's Gemfile by executing: 69 | 70 | $ bundle add uri-builder 71 | 72 | If bundler is not being used to manage dependencies, install the gem by executing: 73 | 74 | $ gem install uri-builder 75 | 76 | ## Development 77 | 78 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 79 | 80 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 81 | 82 | ## Contributing 83 | 84 | Bug reports and pull requests are welcome on GitHub at https://github.com/rubymonolith/uri-builder. 85 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "uri/builder" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/uri-builder.rb: -------------------------------------------------------------------------------- 1 | require "uri/builder" -------------------------------------------------------------------------------- /lib/uri/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "builder/version" 4 | require "uri" 5 | 6 | module URI 7 | module Builder 8 | class Error < StandardError; end 9 | 10 | class DSL 11 | attr_reader :uri 12 | 13 | def initialize(uri) 14 | @uri = uri.clone 15 | end 16 | 17 | [:host, :fragment, :port].each do |property| 18 | define_method property do |value| 19 | update property, value 20 | end 21 | end 22 | 23 | def clear_fragment 24 | update :fragment, nil 25 | end 26 | 27 | def scheme(value) 28 | if @uri.scheme 29 | # Handles URLs without schemes, like https://example.com/foo 30 | target_scheme = URI.scheme_list[value.upcase] 31 | args = Hash[target_scheme.component.map { |attr| [ attr, @uri.send(attr) ] }] 32 | @uri = target_scheme.build(**args) 33 | else 34 | # Handles URLs without schemes, like example.com/foo 35 | uri = URI.parse("#{value}://#{@uri.path}") 36 | (uri.component - %i[host path scheme]).each do |component| 37 | uri.send "#{component}=", @uri.send(component) 38 | end 39 | @uri = uri 40 | end 41 | self 42 | end 43 | 44 | def query(value) 45 | value = case value 46 | when Hash, Array 47 | build_query value 48 | else 49 | value 50 | end 51 | 52 | update :query, value 53 | end 54 | 55 | def clear_query 56 | update :query, nil 57 | end 58 | 59 | def segments 60 | @uri.path.split("/").reject(&:empty?) 61 | end 62 | 63 | def join(*segments) 64 | path(*self.segments, *segments) 65 | end 66 | 67 | def path(*segments) 68 | update :path, ::File.join("/", *segments.compact.map(&:to_s)) 69 | end 70 | 71 | def root 72 | path "/" 73 | end 74 | alias :clear_path :root 75 | 76 | def trailing_slash 77 | update :path, @uri.path.concat("/") unless @uri.path.end_with?("/") 78 | end 79 | 80 | def clear_trailing_slash 81 | update :path, @uri.path.chomp("/") if @uri.path.end_with?("/") and not root? 82 | end 83 | 84 | def root? 85 | @uri.path == "/" 86 | end 87 | 88 | def parent 89 | *parents, _ = segments 90 | root? ? root : path(*parents) 91 | end 92 | 93 | def to_s 94 | uri.to_s 95 | end 96 | 97 | def to_str 98 | uri.to_str 99 | end 100 | 101 | private 102 | def update(property, value) 103 | @uri.send "#{property}=", value 104 | self 105 | end 106 | 107 | def build_query(params, prefix = nil) 108 | case params 109 | when Hash 110 | params.map { |key, value| 111 | new_prefix = prefix ? "#{prefix}[#{key}]" : key.to_s 112 | build_query(value, new_prefix) 113 | }.reject(&:empty?).join('&') 114 | when Array 115 | params.map.with_index { |value, _index| 116 | new_prefix = "#{prefix}[]" 117 | build_query(value, new_prefix) 118 | }.reject(&:empty?).join('&') 119 | else 120 | "#{prefix}=#{URI.encode_www_form_component(params.to_s)}" 121 | end 122 | end 123 | end 124 | end 125 | 126 | def build 127 | Builder::DSL.new self 128 | end 129 | 130 | def self.build(value) 131 | if block_given? 132 | URI(value).build.tap do |uri| 133 | yield uri 134 | end.uri 135 | else 136 | URI(value).build 137 | end 138 | end 139 | 140 | def self.env(key, default = nil) 141 | build ENV.fetch key, default 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/uri/builder/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module URI 4 | module Build 5 | VERSION = "0.1.13" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /sig/uri/transform.rbs: -------------------------------------------------------------------------------- 1 | module URI 2 | module Build 3 | VERSION: String 4 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "uri/builder" 4 | 5 | RSpec.configure do |config| 6 | # Enable flags like --only-failures and --next-failure 7 | config.example_status_persistence_file_path = ".rspec_status" 8 | 9 | # Disable RSpec exposing methods globally on `Module` and `main` 10 | config.disable_monkey_patching! 11 | 12 | config.expect_with :rspec do |c| 13 | c.syntax = :expect 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/uri/builder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe URI::Builder::DSL do 4 | let(:builder) { URI.build("https://example.com/foo/bar?fizz=buzz#super") } 5 | let(:uri) { builder.uri } 6 | 7 | describe ".build" do 8 | context "block given" do 9 | subject do 10 | URI.build("example.com") do |u| 11 | u.scheme "https" 12 | u.path "/about" 13 | end 14 | end 15 | it "returns URI" do 16 | expect(subject).to be_a URI 17 | end 18 | it "modifies URI" do 19 | expect(subject.to_s).to eql "https://example.com/about" 20 | end 21 | end 22 | context "no block given" do 23 | subject do 24 | URI.build("example.com") 25 | end 26 | it "returns URI::Builder" do 27 | expect(subject).to be_a URI::Builder::DSL 28 | end 29 | end 30 | end 31 | 32 | describe "#host" do 33 | before { builder.host("www.example.com") } 34 | subject { uri.host } 35 | it { is_expected.to eql "www.example.com" } 36 | end 37 | 38 | describe "#scheme" do 39 | before { builder.scheme("https") } 40 | describe "value" do 41 | subject { uri.scheme } 42 | it { is_expected.to eql "https" } 43 | end 44 | describe "class" do 45 | subject { uri.class } 46 | it { is_expected.to eql URI::HTTPS } 47 | end 48 | context "generic" do 49 | let(:builder) { URI.build("example.com/foo/bar?fizz=buzz#super") } 50 | subject { uri.to_s } 51 | it { is_expected.to eql "https://example.com/foo/bar?fizz=buzz#super" } 52 | end 53 | end 54 | 55 | describe "#path" do 56 | let(:path) { "/fizz/buzz" } 57 | before { builder.path(*path) } 58 | subject { uri.path } 59 | it { is_expected.to eql "/fizz/buzz" } 60 | context "without leading /" do 61 | let(:path) { "fizz/buzz" } 62 | it { is_expected.to eql "/fizz/buzz" } 63 | end 64 | context "with trailing /" do 65 | let(:path) { "fizz/buzz/" } 66 | it { is_expected.to eql "/fizz/buzz/" } 67 | end 68 | context "blank" do 69 | let(:path) { "" } 70 | it { is_expected.to eql "/" } 71 | end 72 | context "nil" do 73 | before { builder.path(nil) } 74 | it { is_expected.to eql "/" } 75 | end 76 | context "23" do 77 | let(:path) { 23 } 78 | it { is_expected.to eql "/23" } 79 | end 80 | context "[nil, 23, '', :dog]" do 81 | let(:path) { [ nil, 23, '', :dog ] } 82 | it { is_expected.to eql "/23/dog" } 83 | end 84 | end 85 | 86 | describe "#clear_path" do 87 | before { builder.path("/fizz/buzz").clear_path } 88 | subject { uri.path } 89 | it { is_expected.to eql "/" } 90 | end 91 | 92 | describe "#root" do 93 | before { builder.path("/fizz/buzz").root } 94 | subject { uri.path } 95 | it { is_expected.to eql "/" } 96 | end 97 | 98 | describe "#join" do 99 | before { builder.path("/fizz/buzz").join("/foo/bar") } 100 | subject { uri.path } 101 | it { is_expected.to eql "/fizz/buzz/foo/bar" } 102 | end 103 | 104 | describe "#parent" do 105 | context "/foo/bar" do 106 | before { builder.path("/foo/bar").parent } 107 | subject { uri.path } 108 | it { is_expected.to eql "/foo" } 109 | end 110 | context "/foo" do 111 | before { builder.path("/foo").parent } 112 | subject { uri.path } 113 | it { is_expected.to eql "/" } 114 | end 115 | context "/" do 116 | before { builder.path("/").parent } 117 | subject { uri.path } 118 | it { is_expected.to eql "/" } 119 | end 120 | end 121 | 122 | 123 | describe "#trailing_slash" do 124 | before { builder.path(*path).trailing_slash } 125 | subject { uri.path } 126 | context "/foo/bar" do 127 | let(:path) { "/foo/bar" } 128 | it { is_expected.to eql "/foo/bar/" } 129 | end 130 | context "/foo/bar/" do 131 | let(:path) { "/foo/bar/" } 132 | it { is_expected.to eql "/foo/bar/" } 133 | end 134 | context "/" do 135 | let(:path) { "/" } 136 | it { is_expected.to eql "/" } 137 | end 138 | end 139 | 140 | describe "#clear_trailing_slash" do 141 | before { builder.path(*path).clear_trailing_slash } 142 | subject { uri.path } 143 | context "/fizz/buzz" do 144 | let(:path) { "/fizz/buzz" } 145 | it { is_expected.to eql "/fizz/buzz" } 146 | end 147 | context "/fizz/buzz/" do 148 | let(:path) { "/fizz/buzz/" } 149 | it { is_expected.to eql "/fizz/buzz" } 150 | end 151 | context "/" do 152 | let(:path) { "/" } 153 | it { is_expected.to eql "/" } 154 | end 155 | end 156 | 157 | describe "#query" do 158 | before { builder.query(foo: "bar") } 159 | subject { uri.query } 160 | it { is_expected.to eql "foo=bar" } 161 | 162 | describe "nested hashes and arrays" do 163 | before { 164 | builder.query( 165 | foo: { 166 | bar: [ 167 | {fizz: "buzz"}, 168 | %w[a b c], 169 | "fun" 170 | ], 171 | } 172 | ) 173 | } 174 | it { is_expected.to eql "foo[bar][][fizz]=buzz&foo[bar][][]=a&foo[bar][][]=b&foo[bar][][]=c&foo[bar][]=fun" } 175 | end 176 | end 177 | 178 | describe "#clear_query" do 179 | before { builder.query(fizz: "buzz").clear_query } 180 | subject { uri.query } 181 | it { is_expected.to be_nil } 182 | end 183 | 184 | describe "#fragment" do 185 | before { builder.fragment("duper") } 186 | subject { uri.fragment } 187 | it { is_expected.to eql "duper" } 188 | end 189 | 190 | describe "#clear_fragment" do 191 | before { builder.fragment("duper").clear_fragment } 192 | subject { uri.fragment } 193 | it { is_expected.to be_nil } 194 | end 195 | 196 | describe "#port" do 197 | before { builder.port(9000) } 198 | subject { uri.port } 199 | it { is_expected.to eql 9000 } 200 | end 201 | 202 | describe "#to_str" do 203 | subject { uri.to_str } 204 | it { is_expected.to eql "https://example.com/foo/bar?fizz=buzz#super" } 205 | end 206 | end 207 | -------------------------------------------------------------------------------- /uri-transform.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/uri/builder/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "uri-builder" 7 | spec.version = URI::Build::VERSION 8 | spec.authors = ["Brad Gessler"] 9 | spec.email = ["bradgessler@gmail.com"] 10 | spec.licenses = ["MIT"] 11 | 12 | spec.summary = "Build URIs via chains" 13 | spec.description = spec.summary 14 | spec.homepage = "https://github.com/rubymonolith/uri-builder" 15 | spec.required_ruby_version = ">= 2.6.0" 16 | 17 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 18 | 19 | spec.metadata["homepage_uri"] = spec.homepage 20 | spec.metadata["source_code_uri"] = spec.homepage 21 | spec.metadata["changelog_uri"] = spec.homepage 22 | 23 | # Specify which files should be added to the gem when it is released. 24 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 25 | spec.files = Dir.chdir(__dir__) do 26 | `git ls-files -z`.split("\x0").reject do |f| 27 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 28 | end 29 | end 30 | spec.bindir = "exe" 31 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 32 | spec.require_paths = ["lib"] 33 | 34 | # Uncomment to register a new dependency of your gem 35 | # spec.add_dependency "example-gem", "~> 1.0" 36 | 37 | # For more information and examples about making a new gem, check out our 38 | # guide at: https://bundler.io/guides/creating_gem.html 39 | end 40 | --------------------------------------------------------------------------------