Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
branch: master
Fetching contributors…

Octocat-spinner-32-eaf2f5

Cannot retrieve contributors at this time

file 224 lines (187 sloc) 5.88 kb
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224
require 'net/http'
require 'json'

module Gumroad
  API_ROOT = "https://gumroad.com/api/v1/"

  class Client
    class << self
      attr_accessor :token, :password

      # via http://snippets.dzone.com/posts/show/11121
      def symbolize_keys arg
        case arg
        when Array
          arg.map { |elem| symbolize_keys elem }
        when Hash
          Hash[
            arg.map { |key, value|
              k = key.is_a?(String) ? key.to_sym : key
              v = symbolize_keys value
              [k,v]
            }]
        else
          arg
        end
      end
        
      def url(route)
        API_ROOT + route
      end

      def uri(route)
        URI.parse(self.url(route))
      end

      # _method is string
      def authed_request(uri, _method, params = {})
        klass = _method.capitalize

        http = Net::HTTP.new(uri.host, uri.port)
        http.use_ssl = true

        request = Net::HTTP::const_get(klass).new(uri.request_uri)
        request.set_form_data(params) if _method != "get"
        if @token and @password
          request.basic_auth(@token, @password)
        end

        response = http.request(request)
        # symbolize all hash keys (recursively)
        rep = symbolize_keys(JSON.parse(response.body))

        if !rep[:success]
          raise GumroadError, "#{(rep && (rep[:error] && rep[:error][:message]) || rep[:message]) || 'No message found.'}"
        end

        rep
      end

      # Client handles functions of the form
      # Client.[method](resource, params = {})
      def method_missing(_method, *args, &block)
        if [:post, :delete, :put].member? _method
          return authed_request(self.uri(args[0]), _method.to_s, args[1]) if args.length == 2
          return authed_request(self.uri(args[0]), _method.to_s, {}) if args.length == 1
        else
          super
        end
      end

      def get(route, params = {})
        # if we do a get, turn it's params into a query.
        route += "?".concat(params.collect { |k,v| "#{k}=#{CGI::escape(v.to_s)}" }.join('&'))
        authed_request(self.uri(route), "get", {})
      end
    end
  end

  # One could simply -walk into Mordor- send all the Link attributes
  # in the PUT request, regardless of whether they've actually changed,
  # but I wanted to a) be able to find out which attributes are dirty
  # and b) have a cleaner HTTP request.

  # There might be a better pattern to track this, but this ivar business
  # came to mind first so I went with it.
  class Dirtyable
    def self.dirty_attr(*args)
      @dirty_attrs = args
      dirty_attrs = "@dirty_attrs"

      args.each do |arg|
        ivar = "@#{arg}"
        define_method("#{arg}=") do |val|
          instance_variable_set(ivar, val)
          if (!(instance_variable_defined? dirty_attrs))
            instance_variable_set(dirty_attrs, [])
          end
          (instance_variable_get(dirty_attrs) << arg).uniq!
        end
        define_method("#{arg}") do |val|
          instance_variable_get(ivar)
        end
      end
    end

    # Takes a symbol
    # ie link.dirty? :name
    def dirty?(field)
      @dirty_attrs.member? field
    end
  end

  class Link < Dirtyable
    # According to the API spec, these can't be changed
    attr_reader :id, :currency, :short_url

    dirty_attr :name, :url, :description
    # Price is actually dirtyable as well, but we do custom validation
    # perhaps dirty_attr could take some :validation callback? but it seemed
    # like way too much fun to do that for only one field
    attr_reader :price

    def self.find_all
      Client.get("links")[:links].collect {|l| Link.new(l)}
    end

    def self.find(id)
      Link.new(Client.get("links/#{id}")[:link])
    end

    def self.create(params)
      Link.new(Client.post("links", params)[:link])
    end

    def self.destroy(id)
      Client.delete("links/#{id}")
      true # still not sure if this is appropriate?
    end

    def destroy
      Link.destroy(self.id)
    end

    def initialize(params = {})
      @dirty_attrs = []
      load(params)
    end

    def load(params = {})
      ivars = self.methods - Object.methods
      params.each do |k, v|
        if ivars.member? k
          setter = (k.to_s + "=").to_sym
          if ivars.member? setter
            self.send(setter, v)
          else
            instance_variable_set("@" + k.to_s, v)
          end
        end
      end
      @dirty_attrs = []
    end

    def save
      if @dirty_attrs.length > 0
        params = {}
        @dirty_attrs.each {|d| params[d] = self.send(d)}
        load(Client.put("links/#{self.id}", params)[:link])
      end
      self
    end

    # need to do some custom validation for price=
    # ensure it's a whole integer
    def price=(price)
      p_int = 0
      if price.is_a? Float and price % 1 != 0
        raise "Invalid price #{price}: must be whole-number integer"
      end

      begin
        p_int = Integer(price)
      rescue Exception => e
        raise "Invalid price #{price}: must be whole-number integer"
      end

      if p_int < 0
        raise "Invalid price #{price}: must be positive"
      end

      (@dirty_attrs << :price).uniq!
      @price = p_int
      return p_int
    end
  end

  class Session
    attr_reader :email, :password

    # Currently sessions only take an email and password, so didn't
    # see fit to do a initialize(params) quite yet.
    def initialize(email, password)
      @email = email
      @password = password

      # auth in
      res = Client.post("sessions", {email: @email, password: @password})
      Client.token = res[:token]
      Client.password = @password
    end

    def links
      Client.get("links")[:links].collect {|l| Link.new(l)}
    end

    def logout
      Client.delete("sessions")
      true
    end
  end

  class GumroadError < StandardError
  end
end
Something went wrong with that request. Please try again.