diff --git a/lib/httpx.rb b/lib/httpx.rb new file mode 100644 index 00000000..bcd7b51c --- /dev/null +++ b/lib/httpx.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "httpx/version" + +require "httpx/errors" +require "httpx/timeout/null" +require "httpx/options" +require "httpx/chainable" + +module HTTPX + extend Chainable +end diff --git a/lib/httpx/chainable.rb b/lib/httpx/chainable.rb new file mode 100644 index 00000000..d75ec8bb --- /dev/null +++ b/lib/httpx/chainable.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require "base64" + +module HTTPX + module Chainable + %i[head get post put delete trace options connect patch].each do |meth| + define_method meth do |uri, options = {}| + request meth, uri, options + end + end + + # Make an HTTP request with the given verb + # @param uri + # @option options [Hash] + def request(verb, uri, options = {}) + branch(options).request verb, uri + end + + # @overload timeout(options = {}) + # Syntax sugar for `timeout(:per_operation, options)` + # @overload timeout(klass, options = {}) + # Adds a timeout to the request. + # @param [#to_sym] klass + # either :null, :global, or :per_operation + # @param [Hash] options + # @option options [Float] :read Read timeout + # @option options [Float] :write Write timeout + # @option options [Float] :connect Connect timeout + def timeout(klass, options = {}) # rubocop:disable Style/OptionHash + if klass.is_a? Hash + options = klass + klass = :per_operation + end + + klass = case klass.to_sym + when :null then HTTPX::Timeout::Null + when :global then HTTPX::Timeout::Global + when :per_operation then HTTPX::Timeout::PerOperation + else raise ArgumentError, "Unsupported Timeout class: #{klass}" + end + + %i[read write connect].each do |k| + next unless options.key? k + options["#{k}_timeout".to_sym] = options.delete k + end + + branch default_options.merge( + :timeout_class => klass, + :timeout_options => options + ) + end + + # @overload persistent(host, timeout: 5) + # Flags as persistent + # @param [String] host + # @option [Integer] timeout Keep alive timeout + # @raise [Request::Error] if Host is invalid + # @return [HTTPX::Client] Persistent client + # @overload persistent(host, timeout: 5, &block) + # Executes given block with persistent client and automatically closes + # connection at the end of execution. + # + # @example + # + # def keys(users) + # HTTP.persistent("https://github.com") do |http| + # users.map { |u| http.get("/#{u}.keys").to_s } + # end + # end + # + # # same as + # + # def keys(users) + # http = HTTP.persistent "https://github.com" + # users.map { |u| http.get("/#{u}.keys").to_s } + # ensure + # http.close if http + # end + # + # + # @yieldparam [HTTPX::Client] client Persistent client + # @return [Object] result of last expression in the block + def persistent(host, timeout: 5) + options = {:keep_alive_timeout => timeout} + p_client = branch default_options.merge(options).with_persistent host + return p_client unless block_given? + yield p_client + ensure + p_client.close if p_client + end + + # Make a request through an HTTP proxy + # @param [Array] proxy + # @raise [Request::Error] if HTTP proxy is invalid + def via(*proxy) + proxy_hash = {} + proxy_hash[:proxy_address] = proxy[0] if proxy[0].is_a?(String) + proxy_hash[:proxy_port] = proxy[1] if proxy[1].is_a?(Integer) + proxy_hash[:proxy_username] = proxy[2] if proxy[2].is_a?(String) + proxy_hash[:proxy_password] = proxy[3] if proxy[3].is_a?(String) + proxy_hash[:proxy_headers] = proxy[2] if proxy[2].is_a?(Hash) + proxy_hash[:proxy_headers] = proxy[4] if proxy[4].is_a?(Hash) + + raise(RequestError, "invalid HTTP proxy: #{proxy_hash}") unless (2..5).cover?(proxy_hash.keys.size) + + branch default_options.with_proxy(proxy_hash) + end + alias through via + + # Make client follow redirects. + # @param opts + # @return [HTTPX::Client] + # @see Redirector#initialize + def follow(options = {}) # rubocop:disable Style/OptionHash + branch default_options.with_follow options + end + + # Make a request with the given headers + # @param headers + def headers(headers) + branch default_options.with_headers(headers) + end + + # Make a request with the given cookies + def cookies(cookies) + branch default_options.with_cookies(cookies) + end + + # Force a specific encoding for response body + def encoding(encoding) + branch default_options.with_encoding(encoding) + end + + # Accept the given MIME type(s) + # @param type + def accept(type) + headers Headers::ACCEPT => MimeType.normalize(type) + end + + # Make a request with the given Authorization header + # @param [#to_s] value Authorization header value + def auth(value) + headers Headers::AUTHORIZATION => value.to_s + end + + # Make a request with the given Basic authorization header + # @see http://tools.ietf.org/html/rfc2617 + # @param [#fetch] opts + # @option opts [#to_s] :user + # @option opts [#to_s] :pass + def basic_auth(opts) + user = opts.fetch :user + pass = opts.fetch :pass + + auth("Basic " + Base64.strict_encode64("#{user}:#{pass}")) + end + + # Get options for HTTP + # @return [HTTPX::Options] + def default_options + @default_options ||= HTTPX::Options.new + end + + # Set options for HTTP + # @param opts + # @return [HTTPX::Options] + def default_options=(opts) + @default_options = HTTPX::Options.new(opts) + end + + # Set TCP_NODELAY on the socket + def nodelay + branch default_options.with_nodelay(true) + end + + # Turn on given features. Available features are: + # * auto_inflate + # * auto_deflate + # @param features + def use(*features) + branch default_options.with_features(features) + end + + private + + # :nodoc: + def branch(options) + HTTPX::Client.new(options) + end + end +end + diff --git a/lib/httpx/errors.rb b/lib/httpx/errors.rb new file mode 100644 index 00000000..4e9d4bc0 --- /dev/null +++ b/lib/httpx/errors.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module HTTPX + # Generic error + class Error < StandardError; end + + # Generic Connection error + class ConnectionError < Error; end + + # Generic Request error + class RequestError < Error; end + + # Generic Response error + class ResponseError < Error; end + + # Requested to do something when we're in the wrong state + class StateError < ResponseError; end + + # Generic Timeout error + class TimeoutError < Error; end + + # Header value is of unexpected format (similar to Net::HTTPHeaderSyntaxError) + class HeaderError < Error; end +end + diff --git a/lib/httpx/headers.rb b/lib/httpx/headers.rb new file mode 100644 index 00000000..4629aa45 --- /dev/null +++ b/lib/httpx/headers.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +module HTTPX + class Headers + EMPTY = [].freeze # :nodoc: + + # Matches valid header field name according to RFC. + # @see http://tools.ietf.org/html/rfc7230#section-3.2 + COMPLIANT_NAME_RE = /^[A-Za-z0-9!#\$%&'*+\-.^_`|~]+$/ + + def initialize(h = nil) + @headers = {} + return unless h + h.each do |field, value| + @headers[normalize(field)] = Array(value) + end + end + + # cloned initialization + def initialize_clone(orig) + super + @headers = orig.instance_variable_get(:@headers).clone + end + + # dupped initialization + def initialize_dup(orig) + super + @headers = orig.instance_variable_get(:@headers).dup + end + + # freezes the headers hash + def freeze + @headers.freeze + super + end + + # merges headers with another header-quack. + # the merge rule is, if the header already exists, + # ignore what the +other+ headers has. Otherwise, set + # + def merge(other) + # TODO: deep-copy + headers = dup + other.each do |field, value| + headers[field] = value + end + headers + end + + # returns the comma-separated values of the header field + # identified by +field+, or nil otherwise. + # + def [](field) + a = @headers[normalize(field)] || return + a.join(",") + end + + # sets +value+ (if not nil) as single value for the +field+ header. + # + def []=(field, value) + return unless value + val = case value + when Array + value.map { |f| String(f) } + else + [String(value)] + end + @headers[normalize(field)] = val + end + + # deletes all values associated with +field+ header. + # + def delete(field) + canonical = normalize(field) + @headers.delete(canonical) if @headers.key?(canonical) + end + + # adds additional +value+ to the existing, for header +field+. + # + def add(field, value) + @headers[normalize(field)] ||= [] + val = case value + when Array + value.map { |f| String(f) } + else + [String(value)] + end + @headers[normalize(field)] += val + end + + # helper to be used when adding an header field as a value to another field + # + # h2_headers.add_header("vary", "accept-encoding") + # h2_headers["vary"] #=> "accept-encoding" + # h1_headers.add_header("vary", "accept-encoding") + # h1_headers["vary"] #=> "Accept-Encoding" + # + alias_method :add_header, :add + + # returns the enumerable headers store in pairs of header field + the values in + # the comma-separated string format + # + def each + return enum_for(__method__) { @headers.size } unless block_given? + @headers.each do |field, value| + next if value.empty? + value.each do |val| + yield(field, val) + end + end + self + end + + # the headers store in Hash format + def to_hash + Hash[to_a] + end + + # the headers store in array of pairs format + def to_a + Array(each) + end + + # headers as string + def to_s + @headers.to_s + end + + def ==(other) + to_hash == self.class.new(other).to_hash + end + + # this is internal API and doesn't abide to other public API + # guarantees, like normalizing strings. + # Please do not use this outside of core! + # + def key?(normalized_key) + @headers.key?(normalized_key) + end + + # returns the values for the +field+ header in array format. + # This method is more internal, and for this reason doesn't try + # to "correct" the user input, i.e. it doesn't normalize the key. + # + def get(field) + @headers[normalize(field)] || EMPTY + end + + private + + # this method is only here because there's legacy around using explicit + # canonical header fields (ex: Content-Length), although the spec states that the header + # fields are case-insensitive. + # + # This only normalizes a +field+ if passed a canonical HTTP/1 header +field+. + # + def normalize(field) + normalized = String(field).downcase + + return normalized if normalized =~ COMPLIANT_NAME_RE + + raise HeaderError, "Invalid HTTP header field name: #{field.inspect}" + end + end +end + diff --git a/lib/httpx/options.rb b/lib/httpx/options.rb new file mode 100644 index 00000000..4c60bcbd --- /dev/null +++ b/lib/httpx/options.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "httpx/headers" + +module HTTPX + class Options + @default_timeout_class = HTTPX::Timeout::Null + + class << self + attr_accessor :default_timeout_class + + def new(options = {}) + return options if options.is_a?(self) + super + end + + def defined_options + @defined_options ||= [] + end + + protected + + def def_option(name, &interpreter) + defined_options << name.to_sym + interpreter ||= lambda { |v| v } + + attr_accessor name + protected :"#{name}=" + + define_method(:"with_#{name}") do |value| + dup { |opts| opts.send(:"#{name}=", instance_exec(value, &interpreter)) } + end + end + end + + def initialize(options = {}) # rubocop:disable Style/OptionHash + defaults = { + :proxy => {}, + :timeout_class => self.class.default_timeout_class, + :timeout_options => {}, + :ssl => {}, + :keep_alive_timeout => 5, + :headers => {}, + :cookies => {}, + } + + opts_w_defaults = defaults.merge(options) + opts_w_defaults[:headers] = HTTPX::Headers.new(opts_w_defaults[:headers]) + opts_w_defaults.each { |(k, v)| self[k] = v } + end + + def_option :headers do |headers| + self.headers.merge(headers) + end + + def_option :cookies do |cookies| + cookies.each_with_object(self.cookies.dup) do |(k, v), jar| + cookie = k.is_a?(Cookie) ? k : Cookie.new(k.to_s, v.to_s) + jar[cookie.name] = cookie.cookie_value + end + end + + %w[ + proxy params form json body follow response + ssl_context ssl + keep_alive_timeout timeout_class timeout_options + ].each do |method_name| + def_option method_name + end + + def follow=(value) + @follow = + case + when !value then nil + when true == value then {} + when value.respond_to?(:fetch) then value + else argument_error! "Unsupported follow options: #{value}" + end + end + + def merge(other) + h1 = to_hash + h2 = other.to_hash + + merged = h1.merge(h2) do |k, v1, v2| + case k + when :headers + v1.merge(v2) + else + v2 + end + end + + self.class.new(merged) + end + + def to_hash + hash_pairs = self.class. + defined_options. + flat_map { |opt_name| [opt_name, send(opt_name)] } + Hash[*hash_pairs] + end + + def dup + dupped = super + yield(dupped) if block_given? + dupped + end + + protected + + def []=(option, val) + send(:"#{option}=", val) + end + + private + + def argument_error!(message) + raise(Error, message, caller(1..-1)) + end + end +end diff --git a/lib/httpx/timeout/null.rb b/lib/httpx/timeout/null.rb new file mode 100644 index 00000000..8b456406 --- /dev/null +++ b/lib/httpx/timeout/null.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module HTTPX + module Timeout + class Null + end + end +end diff --git a/lib/httpx/version.rb b/lib/httpx/version.rb new file mode 100644 index 00000000..a7b8d9f4 --- /dev/null +++ b/lib/httpx/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module HTTPX + VERSION = "0.0.0" +end diff --git a/spec/httpx/headers_spec.rb b/spec/httpx/headers_spec.rb new file mode 100644 index 00000000..ac93b273 --- /dev/null +++ b/spec/httpx/headers_spec.rb @@ -0,0 +1,247 @@ +# frozen_string_literal: true + +RSpec.describe HTTPX::Headers do + subject(:headers) { described_class.new } + + it "can become enumerable" do + expect(headers).to respond_to(:each) + end + + describe "#[]=" do + it "sets header value" do + headers["accept"] = "application/json" + expect(headers["accept"]).to eq "application/json" + end + + it "normalizes header name" do + headers["Content-Type"] = "application/json" + expect(headers["content-type"]).to eq "application/json" + end + + it "overwrites previous value" do + headers["set-cookie"] = "hoo=ray" + headers["set-cookie"] = "woo=hoo" + expect(headers["set-cookie"]).to eq "woo=hoo" + end + + it "allows set multiple values" do + headers["set-cookie"] = "hoo=ray" + headers["set-cookie"] = %w[hoo=ray woo=hoo] + expect(headers.get("set-cookie")).to eq %w[hoo=ray woo=hoo] + end + + it "fails with empty header name" do + expect { headers[""] = "foo bar" }. + to raise_error HTTPX::HeaderError + end + + it "fails with invalid header name" do + expect { headers["foo bar"] = "baz" }. + to raise_error HTTPX::HeaderError + end + end + + describe "#delete" do + before { headers["content-type"] = "application/json" } + + it "removes given header" do + headers.delete("content-type") + expect(headers["content-type"]).to be_nil + end + + it "normalizes header name" do + headers.delete("Content-Type") + expect(headers["content-type"]).to be_nil + end + + it "fails with empty header name" do + expect { headers.delete("") }. + to raise_error HTTPX::HeaderError + end + + it "fails with invalid header name" do + expect { headers.delete("foo bar") }. + to raise_error HTTPX::HeaderError + end + end + + describe "#add" do + it "sets header value" do + headers.add "Accept", "application/json" + expect(headers["accept"]).to eq "application/json" + end + + it "normalizes header name" do + headers.add "Content-Type", "application/json" + expect(headers["content-type"]).to eq "application/json" + end + + it "appends new value if header exists" do + headers.add "set-cookie", "hoo=ray" + headers.add "set-cookie", "woo=hoo" + expect(headers.get("set-cookie")).to eq %w[hoo=ray woo=hoo] + end + + it "allows append multiple values" do + headers.add "set-cookie", "hoo=ray" + headers.add "set-cookie", %w[woo=hoo yup=pie] + expect(headers.get("set-cookie")).to eq %w[hoo=ray woo=hoo yup=pie] + end + + it "fails with empty header name" do + expect { headers.add("", "foobar") }. + to raise_error HTTPX::HeaderError + end + + it "fails with invalid header name" do + expect { headers.add("foo bar", "baz") }. + to raise_error HTTPX::HeaderError + end + end + + describe "#get" do + before { headers["Content-Type"] = "application/json" } + + it "returns array of associated values" do + expect(headers.get("content-type")).to eq %w[application/json] + end + + it "normalizes header name" do + expect(headers.get("Content-Type")).to eq %w[application/json] + end + + context "when header does not exists" do + it "returns empty array" do + expect(headers.get("accept")).to eq [] + end + end + + it "fails with empty header name" do + expect { headers.get("") }. + to raise_error HTTPX::HeaderError + end + + it "fails with invalid header name" do + expect { headers.get("foo bar") }. + to raise_error HTTPX::HeaderError + end + end + + describe "#[]" do + context "when header does not exists" do + it "returns nil" do + expect(headers["accept"]).to be_nil + end + end + + context "when header has a single value" do + before { headers["content-type"] = "application/json" } + + it "normalizes header name" do + expect(headers["content-type"]).to_not be_nil + end + + it "returns it returns a single value" do + expect(headers["content-type"]).to eq "application/json" + end + end + + context "when header has a multiple values" do + before do + headers.add "set-cookie", "hoo=ray" + headers.add "set-cookie", "woo=hoo" + end + + it "normalizes header name" do + expect(headers["set-cookie"]).to_not be_nil + end + + it "returns array of associated values" do + expect(headers.get("set-cookie")).to eq %w[hoo=ray woo=hoo] + end + end + end + + describe "#each" do + before do + headers.add "set-cookie", "hoo=ray" + headers.add "content-type", "application/json" + headers.add "set-cookie", "woo=hoo" + end + + it "yields each key/value pair separatedly" do + expect { |b| headers.each(&b) }.to yield_control.exactly(3).times + end + + it "yields headers in the same order they were added" do + expect { |b| headers.each(&b) }.to yield_successive_args( + %w[set-cookie hoo=ray], + %w[set-cookie woo=hoo], + %w[content-type application/json], + ) + end + + it "returns self instance if block given" do + expect(headers.each { |*| }).to be headers + end + + it "returns Enumerator if no block given" do + expect(headers.each).to be_a Enumerator + end + end + + describe "#dup" do + before { headers["content-type"] = "application/json" } + + subject(:dupped) { headers.dup } + + it { is_expected.to be_a described_class } + it { is_expected.not_to be headers } + + it "has headers copied" do + expect(dupped["content-type"]).to eq "application/json" + end + + context "modifying a copy" do + before { dupped["content-type"] = "text/plain" } + + it "modifies dupped copy" do + expect(dupped["content-type"]).to eq "text/plain" + end + + it "does not affects original headers" do + expect(headers["content-type"]).to eq "application/json" + end + end + end + + describe "#merge" do + before do + headers["host"] = "example.com" + headers["accept"] = "application/json" + end + + subject(:merged) do + headers.merge "accept" => "plain/text", "cookie" => %w[hoo=ray woo=hoo] + end + + it { is_expected.to be_a described_class } + it { is_expected.not_to be headers } + + it "does not affects original headers" do + expect(merged).to_not eq headers + end + + it "leaves headers not presented in other as is" do + expect(merged["host"]).to eq "example.com" + end + + it "overwrites existing values" do + expect(merged["accept"]).to eq "plain/text" + end + + it "appends other headers, not presented in base" do + expect(merged.get("cookie")).to eq %w[hoo=ray woo=hoo] + end + end +end diff --git a/spec/httpx/options_spec.rb b/spec/httpx/options_spec.rb new file mode 100644 index 00000000..13c1379e --- /dev/null +++ b/spec/httpx/options_spec.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +RSpec.describe HTTPX::Options do + subject { described_class.new(:response => :body) } + + it "has reader methods for attributes" do + expect(subject.response).to eq(:body) + end + + it "coerces to a Hash" do + expect(subject.to_hash).to be_a(Hash) + end + + describe "body" do + let(:opts) { HTTPX::Options.new } + + it "defaults to nil" do + expect(opts.body).to be nil + end + + it "may be specified with with_body" do + opts2 = opts.with_body("foo") + expect(opts.body).to be nil + expect(opts2.body).to eq("foo") + end + end + + describe "form" do + let(:opts) { HTTPX::Options.new } + + it "defaults to nil" do + expect(opts.form).to be nil + end + + it "may be specified with with_form_data" do + opts2 = opts.with_form(:foo => 42) + expect(opts.form).to be nil + expect(opts2.form).to eq(:foo => 42) + end + end + + describe "headers" do + let(:opts) { HTTPX::Options.new } + + it "defaults to be empty" do + expect(opts.headers.to_a).to be_empty + end + + it "may be specified with with_headers" do + opts2 = opts.with_headers("accept" => "json") + expect(opts.headers.to_a).to be_empty + expect(opts2.headers).to eq([%w[Accept json]]) + end + end + + describe "json" do + let(:opts) { HTTPX::Options.new } + + it "defaults to nil" do + expect(opts.json).to be nil + end + + it "may be specified with with_json data" do + opts2 = opts.with_json(:foo => 42) + expect(opts.json).to be nil + expect(opts2.json).to eq(:foo => 42) + end + end + + describe "merge" do + let(:opts) { HTTPX::Options.new } + + it "supports a Hash" do + old_response = opts.response + expect(opts.merge(:response => :body).response).to eq(:body) + expect(opts.response).to eq(old_response) + end + + it "supports another Options" do + merged = opts.merge(HTTPX::Options.new(:response => :body)) + expect(merged.response).to eq(:body) + end + + it "merges as excepted in complex cases" do + # FIXME: yuck :( + + foo = HTTPX::Options.new( + :response => :body, + :params => {:baz => "bar"}, + :form => {:foo => "foo"}, + :body => "body-foo", + :json => {:foo => "foo"}, + :headers => {"accept" => "json", "foo" => "foo"}, + :proxy => {}, + ) + + bar = HTTPX::Options.new( + :response => :parsed_body, + :params => {:plop => "plip"}, + :form => {:bar => "bar"}, + :body => "body-bar", + :json => {:bar => "bar"}, + :keep_alive_timeout => 10, + :headers => {"accept" => "xml", "bar" => "bar"}, + :timeout_options => {:foo => :bar}, + :ssl => {:foo => "bar"}, + :proxy => {:proxy_address => "127.0.0.1", :proxy_port => 8080} + ) + + expect(foo.merge(bar).to_hash).to eq( + :response => :parsed_body, + :timeout_class => described_class.default_timeout_class, + :timeout_options => {:foo => :bar}, + :params => {:plop => "plip"}, + :form => {:bar => "bar"}, + :body => "body-bar", + :json => {:bar => "bar"}, + :keep_alive_timeout => 10, + :ssl => {:foo => "bar"}, + :headers => HTTPX::Headers.new({"Foo" => "foo", "Accept" => "xml", "Bar" => "bar"}), + :proxy => {:proxy_address => "127.0.0.1", :proxy_port => 8080}, + :follow => nil, + :ssl_context => nil, + :cookies => {}, + ) + end + end + + describe "new" do + it "supports a Options instance" do + opts = HTTPX::Options.new + expect(HTTPX::Options.new(opts)).to eq(opts) + end + + context "with a Hash" do + it "coerces :response correctly" do + opts = HTTPX::Options.new(:response => :object) + expect(opts.response).to eq(:object) + end + + it "coerces :headers correctly" do + opts = HTTPX::Options.new(:headers => {"accept" => "json"}) + expect(opts.headers).to eq([%w[Accept json]]) + end + + it "coerces :proxy correctly" do + opts = HTTPX::Options.new(:proxy => {:proxy_address => "127.0.0.1", :proxy_port => 8080}) + expect(opts.proxy).to eq(:proxy_address => "127.0.0.1", :proxy_port => 8080) + end + + it "coerces :form correctly" do + opts = HTTPX::Options.new(:form => {:foo => 42}) + expect(opts.form).to eq(:foo => 42) + end + end + end + + describe "proxy" do + let(:opts) { HTTPX::Options.new } + + it "defaults to {}" do + expect(opts.proxy).to eq({}) + end + + it "may be specified with with_proxy" do + opts2 = opts.with_proxy(:proxy_address => "127.0.0.1", :proxy_port => 8080) + expect(opts.proxy).to eq({}) + expect(opts2.proxy).to eq(:proxy_address => "127.0.0.1", :proxy_port => 8080) + end + + it "accepts proxy address, port, username, and password" do + opts2 = opts.with_proxy(:proxy_address => "127.0.0.1", :proxy_port => 8080, :proxy_username => "username", :proxy_password => "password") + expect(opts2.proxy).to eq(:proxy_address => "127.0.0.1", :proxy_port => 8080, :proxy_username => "username", :proxy_password => "password") + end + end +end