jasonroelofs/simple_aws

View on GitHub
lib/simple_aws/s3.rb

Summary

Maintainability
A
3 hrs
Test Coverage
require 'simple_aws/api'

module SimpleAWS

  ##
  # Amazon's Simple Storage Service
  #
  # http://docs.amazonwebservices.com/AmazonS3/latest/API/Welcome.html
  #
  # As S3 is much closer to a RESTful service than the other AWS APIs, all
  # calls through this API are done through these five handled HTTP METHODS:
  # GET, PUT, DELETE, POST and HEAD. When sending a request, follow exactly what
  # is described in the AWS API docs in the link above.
  #
  # So "GET Service" is
  #
  #     s3.get "/"
  #
  # When working with a specific bucket, pass in :bucket after the path:
  #
  #     s3.get "/", :bucket => "bucket_name"
  #
  #     s3.get "/?policy", :bucket => "bucket_name"
  #
  # For requests that need extra parameters, use the :params option:
  #
  #     s3.get "/object/name", :bucket => "bucket_name", :params => {
  #       "response-content-disposition" => "attachment"
  #     }
  #
  # Also use params in the cases that AWS asks for form fields, such as
  # "POST Object".
  #
  # A lot of S3 communication happens through request and response headers.
  # To specify a certian set of headers on the request, use :headers:
  #
  #     s3.get "/", :bucket => "bucket_name", :headers => {
  #       "x-amz-security-token" => "security string"
  #     }
  #
  # Many of the PUT requests require a body of some sort, sometimes XML,
  # sometimes JSON, and other times the raw file data. Use :body for this
  # information. :body is expected to be either a String containing the XML or
  # JSON information, or an object that otherwise response to #read for file
  # uploads. This API does not build XML or JSON for you right now.
  #
  #     s3.put "/object/name.txt", :bucket => "bucket_name", :body => File.open()
  #
  # This API does ensure that file data is uploaded as efficiently as possible,
  # streaming file data from disc to AWS without blowing up memory. If the
  # Content-Type header is not specified, it will be defaulted to application/octet-stream
  #
  # NOTE: Like the other parts of SimpleAWS, this API does NOT try to make the
  # AWS API better, but simply provides a cleaner, easy to use API for Ruby.
  # As such, this API does not offer streaming downloads of file data from S3.
  # That is up to you to implement at this time, by running a HEAD to get
  # Content-Length then repeated GETs using the "Range:bytes" header to specify
  # which parts to download next. You can see an example of this in samples/s3_batch_download.rb.
  #
  # Quality of Life note: if you forget the leading / (forward slash) in the path
  # of a resource when# working with a bucket, this library will catch the omission and
  # fix the path for you. Thus, the following is also a valid call:
  #
  #     s3.put "object/name.txt", :bucket => "bucket_name", :body => File.open()
  #
  # Raw file data in a response will be available in the #body method on the Response
  # returned by the method call.
  ##
  class S3 < API
    endpoint "s3"
    use_https true
    version "2006-03-01"

    ##
    # Build a full URL for the resource at +path+.
    #
    # @param path [String] The path of the resource that needs a URL
    # @param options [Hash] Options on how this URL will be generated.
    #
    #   If options includes +:expires+, this url will be a signed url. +:expires+
    #   needs to be the raw Unix timestamp at which this URL will expire, as
    #   defined in the S3 documentation.
    #
    #   Otherwise, +options+ can take anything as described above, but it
    #   will not use +:headers+ or anything related to +:body+.
    #
    # @return [String] The URL to the requested resource
    ##
    def url_for(path, options = {})
      request = build_request(:get, path, options)

      url = "#{self.uri}#{request.path}"
      sep = url =~ /\?/ ? "&" : "?"

      if request.params.any?
        params = request.params.map {|k, v| "#{k}=#{v}"}.join("&")
        url += "#{sep}#{params}"
        sep = "&"
      end

      if expires_at = options[:expires]
        # Small hack, expires is in the Date section of the
        # signing string, so we just do that here so that we don't
        # muddy up build_signature_for
        request.headers["Date"] = expires_at.to_i

        signature = "Signature=#{build_signature_for(request)}"
        key = "AWSAccessKeyId=#{self.access_key}"
        expires = "Expires=#{expires_at.to_i}"

        url += "#{sep}#{signature}&#{key}&#{expires}"
      end

      url
    end

    ##
    # Send a request using HTTP GET
    #
    # @param path [String] The path of the resource at hand
    # @param options [Hash] Options as defined above
    #
    # @return [SimpleAWS::Response] The results of the request
    #
    # @raise [SimpleAWS::UnsuccessfulResponse, SimpleAWS::UnknownErrorResponse] on response errors
    ##
    def get(path, options = {})
      call :get, path, options
    end

    ##
    # Send a request using HTTP POST
    #
    # @param path [String] The path of the resource at hand
    # @param options [Hash] Options as defined above
    #
    # @return [SimpleAWS::Response] The results of the request
    #
    # @raise [SimpleAWS::UnsuccessfulResponse, SimpleAWS::UnknownErrorResponse] on response errors
    ##
    def post(path, options = {})
      call :post, path, options
    end

    ##
    # Send a request using HTTP PUT
    #
    # @param path [String] The path of the resource at hand
    # @param options [Hash] Options as defined above
    #
    # @return [SimpleAWS::Response] The results of the request
    #
    # @raise [SimpleAWS::UnsuccessfulResponse, SimpleAWS::UnknownErrorResponse] on response errors
    ##
    def put(path, options = {})
      call :put, path, options
    end

    ##
    # Send a request using HTTP DELETE
    #
    # @param path [String] The path of the resource at hand
    # @param options [Hash] Options as defined above
    #
    # @return [SimpleAWS::Response] The results of the request
    #
    # @raise [SimpleAWS::UnsuccessfulResponse, SimpleAWS::UnknownErrorResponse] on response errors
    ##
    def delete(path, options = {})
      call :delete, path, options
    end

    ##
    # Send a request using HTTP HEAD
    #
    # @param path [String] The path of the resource at hand
    # @param options [Hash] Options as defined above
    #
    # @return [SimpleAWS::Response] The results of the request
    #
    # @raise [SimpleAWS::UnsuccessfulResponse, SimpleAWS::UnknownErrorResponse] on response errors
    ##
    def head(path, options = {})
      call :head, path, options
    end

    ##
    # Execute an HTTP request against S3.
    #
    # @param method [Symbol, String] The HTTP method to use
    # @param path [String] The path of the resource at hand
    # @param options [Hash] Options as defined above
    #
    # @return [SimpleAWS::Response] The results of the request
    #
    # @raise [SimpleAWS::UnsuccessfulResponse, SimpleAWS::UnknownErrorResponse] on response errors
    ##
    def call(method, path, options = {})
      request = self.build_request method, path, options

      connection = SimpleAWS::Connection.new self
      connection.call finish_and_sign_request(request)
    end

    ##
    # Build a request but do not send it. Helpful for debugging.
    #
    # @param method [Symbol, String] The HTTP method to use
    # @param path [String] The path of the resource at hand
    # @param options [Hash] Options as defined above
    #
    # @return [SimpleAWS::Request] Completed but not yet signed request object
    ##
    def build_request(method, path, options = {})
      if options[:bucket]
        path = "/#{options[:bucket]}/#{path}".gsub("//", "/")
      end

      request = SimpleAWS::Request.new method, self.uri, path

      (options[:params] || {}).each do |k, v|
        request.params[k] = v
      end

      (options[:headers] || {}).each do |k, v|
        request.headers[k] = v
      end

      if options[:file]
        options[:body] = options.delete(:file)
      end

      signing_params = {}
      request.params.delete_if {|k, v|
        if k =~ /^response-/i
          signing_params[k] = v
          true
        end
      }

      if signing_params.length > 0
        to_add = signing_params.map {|k, v|
          "#{k}=#{v}"
        }.join("&")

        request.path = request.path + "?#{to_add}"
      end

      request.body = options[:body]

      if request.body
        request.headers["Content-Length"] = calculate_size_of(request.body).to_s

        if request.body.respond_to?(:read)
          request.headers["Content-Type"] ||= "application/octet-stream"
          request.headers["Expect"] = "100-continue"
        end

        request.headers["Content-Type"] ||= "application/x-www-form-urlencoded"
      end

      request
    end

    def uri
      return @uri if @uri

      @uri = @use_https ? "https" : "http"
      @uri += "://#{@endpoint}"
      @uri += "-#{@region}" if @region
      @uri += ".amazonaws.com"
      @uri
    end

    protected

    def calculate_size_of(body)
      body.respond_to?(:size) ? body.size : File.size(body)
    end

    ##
    # Build and sign the final request, as per the rules here:
    # http://docs.amazonwebservices.com/AmazonS3/latest/dev/RESTAuthentication.html
    ##
    def finish_and_sign_request(request)
      request.headers["Date"] = Time.now.utc.httpdate
      request.headers["Authorization"] =
        "AWS #{self.access_key}:#{build_signature_for(request)}"

      request
    end


    def build_signature_for(request)
      amazon_headers = request.headers.select {|k, v|
        k =~ /^x-amz/i
      }.map {|k, v|
        "#{k.downcase}:#{v}".chomp
      }

      to_sign = [
        request.method.to_s.upcase,
        request.headers["Content-Md5"] || "",
        request.headers["Content-Type"] || "",
        request.headers["Date"],
        amazon_headers,
        request.path
      ].flatten.join("\n")

      Base64.encode64(
        OpenSSL::HMAC.digest("sha1", self.secret_key, to_sign)
      ).chomp
    end

  end

end