soundcloud-client 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,23 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ coverage
6
+ InstalledFiles
7
+ lib/bundler/man
8
+ pkg
9
+ rdoc
10
+ spec/reports
11
+ test/tmp
12
+ test/version_tmp
13
+ tmp
14
+
15
+ # YARD artifacts
16
+ .yardoc
17
+ _yardoc
18
+ doc/
19
+
20
+ Gemfile.lock
21
+ bin/*
22
+ .DS_Store
23
+
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --colour
2
+ --format doc
3
+ --require ./spec/spec_helper.rb
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ #! /usr/bin/env ruby
2
+ source "http://rubygems.org"
3
+
4
+ # Specify your gem's dependencies in soundcloud.gemspec
5
+ gemspec
@@ -0,0 +1,200 @@
1
+ Soundcloud API Client
2
+ =====================
3
+
4
+ This is a configurable client for SoundCloud, based on Faraday.
5
+
6
+ The base class is Soundcloud::API, which reflects the documented API as
7
+ instance methods.
8
+
9
+ The API might be configured (at the class level) with client credentials
10
+ to enable OAuth2 token retrieval, but the usage is just at the instance level
11
+ when a token is available.
12
+
13
+ To be able to use persistent connections and reduce the overall time to
14
+ connect the server, the connection object (Faraday::Connection) is stored
15
+ in the class when possible, and each instance will use the same connection.
16
+
17
+
18
+ API configuration
19
+ -----------------
20
+
21
+ To configure the API credentials and other parameters, the API.configure method
22
+ must be used.
23
+
24
+ Example:
25
+
26
+ Soundcloud::API.configure(mime: "application/json",
27
+ host: "https://api.soundcloud.com",
28
+ adapter: :net_http,
29
+ client_id: "0123456789abcdef0123456789abcdef"
30
+ client_secret = "0123456789abcdef0123456789abcdef"
31
+ redirect_uri = "http://example.com/redirect_path"
32
+ )
33
+
34
+ All options are optional, where :mime and :host defaults are those specified
35
+ above, and the default adapter (for Faraday) is `Faraday.default_adapter`, so
36
+ it can be configured globally if needed.
37
+
38
+ If the client\_id, client\_secret or redirect\_uri are missing, the API won't
39
+ be able to provide the url for authorization, nor to exchange temporary user
40
+ credentials with permanent ones.
41
+
42
+
43
+ API access
44
+ ----------
45
+
46
+ All documented API endpoints are mapped as methods on API instances. Thus,
47
+
48
+ @api.tracks(filter: "downloadable", offset: 50)
49
+
50
+ will retrieve the second page of downloadable tracks, while
51
+
52
+ @api.user_favorites(user)
53
+
54
+ will retrieve the first page of favorites for the specified user. To create
55
+ a new object, une must use for instance
56
+
57
+ @api.create_track(title: "Track title", asset_data: some_uploadio_instance)
58
+
59
+
60
+ Positional arguments (which are required to build the request path) can be
61
+ passed either as plain IDs (string or integer, it doesn't matter) or as hashes,
62
+ in which case an "id" or :id key is used.
63
+
64
+ In the same way, the returned objects are hashes, representing the domain
65
+ objects. Those same objects can be used again as positional arguments for
66
+ parametrized queries, and also as content arguments for methods which send
67
+ data to SoundCloud.
68
+
69
+
70
+ User authorization
71
+ ------------------
72
+
73
+ To access user's data, one must initalize the API instance with a OAuth2 token:
74
+
75
+ @api = Soundcloud::API.new("1-12345-12345678-0123456789abcdef")
76
+
77
+ This instance will be able to retrieve user's priavte data, and also to send
78
+ modifying commands, like favoriting a track, following a user, uploading a
79
+ track etc.
80
+
81
+
82
+ Data retrieval
83
+ --------------
84
+
85
+ All of the following endpoints are represented as methods, with the corresponding
86
+ subresources:
87
+
88
+ - `@api.users(options)` => "/users"
89
+ - `@api.user(user_or_id)` => "/users/_id_"
90
+ - `@api.user_tracks(user_or_id, options)` => "/users/_id_/tracks"
91
+ - `@api.user_playlists(user_or_id, options)` => "/users/_id_/playlists"
92
+ - `@api.user_web_profiles(user_or_id, options)` => "/users/_id_/web-profiles"
93
+ - `@api.user_followings(user_or_id, options)` => "/users/_id_/followings"
94
+ - `@api.user_followers(user_or_id, options)` => "/users/_id_/followers"
95
+ - `@api.user_comments(user_or_id, options)` => "/users/_id_/comments"
96
+ - `@api.user_favorites(user_or_id, options)` => "/users/_id_/favorites"
97
+ - `@api.user_connections` => "/users/me/connections"
98
+ - `@api.user_activities` => "/users/me/activities"
99
+ - `@api.tracks(options)` => "/tracks"
100
+ - `@api.track(track_or_id)` => "/tracks/_id_"
101
+ - `@api.track_comments(track_or_id, options)` => "/tracks/_id_/comments"
102
+ - ...
103
+
104
+ and so on. Running the spec will provide a list of all the retrieval methods.
105
+
106
+ The data is returned as hashes or lists of hashes, whose keys follow strictly
107
+ what provided by the SoundCloud servers. The same hashes can be used as
108
+ positional arguments (so a hash retrieved in the response of `@api.users()`
109
+ can be used as argument to `@api.user_favorites(user_or_id)`...).
110
+
111
+ If a hash is not available it can either be constructed on the fly (only its
112
+ `"id"` or `:id` keys are used) or just the ID may pe provided.
113
+
114
+ Some methods have special names, as for tracks and playlists, where
115
+ `@api.track_shared_to_users(track)` maps to "/tracks/_id_/shared-to/users", as
116
+ the last slash does not separate an actual subresource.
117
+
118
+ There are a couple of boolean query methods:
119
+
120
+ - `@api.favorite?(track, user="me")` will return truthy if the specified user
121
+ (defaulting to the current user) has the track among its favorites.
122
+ - `@api.follow?(user, who="me")` will return truthy if the specified user
123
+ (defaulting to the current user) is following the given one.
124
+
125
+
126
+ Modifying data
127
+ --------------
128
+
129
+ For each of the "/playlists", "/tracks" and "/groups" resources, three
130
+ additional methods are provided:
131
+
132
+ - `@api.create_track(data)` => `POST /tracks` with the track in the request body
133
+ - `@api.update_track(data)` => `PUT /tracks/id` with the track in the request body and the track ID in the URL
134
+ - `@api.delete_track(data)` => `DELETE /tracks/id` with the track ID in the URL
135
+
136
+ The `data` parameter must be a hash of attributes, with the same form as the one
137
+ returned from retrieving objects as above. In the case of `POST /tracks`,
138
+ the `"asset_data"` or `:asset_data` key must be an instance of UploadIO (from
139
+ Faraday or from multipart-post gem) or a simpler File instance if possible.
140
+
141
+ Beware that in the actual requests, the body before serialization will be similar to
142
+ `{"track" => data}`. The serialization is done by `Faraday::Utils.build_nested_query`
143
+ so the resulting string is exactly what expected by Soundcloud servers. Array
144
+ elements are serialized as separate keys (or prefixes) with the string "[]" appended
145
+ to the parameter name, and hashes are serialized as separate keys, with the bracketed
146
+ key appended to te parameter name. So
147
+
148
+ @api.create_playlist(title: "My Title", tracks: [{id: 123}, {id: 124}])
149
+
150
+ will be converted (before encoding) into
151
+
152
+ playlist[title]=My Title&playlist[tracks][][id]=123&playlist[tracks][][id]=124
153
+
154
+
155
+
156
+ As for retrieval, special methods are provided for flagging objects (with respect to the current user):
157
+
158
+ - `@api.favorite!(track)`
159
+ - `@api.unfavorite!(track)`
160
+ - `@api.follow!(user)`
161
+ - `@api.unfollow!(user)`
162
+
163
+
164
+ Errors
165
+ ------
166
+
167
+ In case of HTTP errors in the range 400-599, a corresponding error is raised,
168
+ which inherits from `Soundcloud::Error::Error` and contains the original
169
+ response data in its `response` attribute.
170
+
171
+ Specific errors can be rescued, for instance Soundcloud::Error::NotFound will
172
+ rescue from 404 error codes (which is probably desirable in some cases).
173
+
174
+
175
+ More configuration
176
+ ------------------
177
+
178
+ The API instance can be configured with more options if needed. Currently the
179
+ supported options are
180
+
181
+ - `:verbose` if truthy, a logging middleware is added to the stack, so details
182
+ about the request and response headers, and the full response body are shown in
183
+ the log file.
184
+
185
+ - `:stubs` if truthy, it must act like a Faraday::Adapter::Test::Stubs instance
186
+ (it can duck-type it if necessary), and in that case the conection will use a
187
+ test adapter, instead of doing actual HTTP requests. All the needed requests
188
+ must be declared in the stubs object, as in
189
+
190
+ @stubs = Faraday::Adapter::Test::Stubs.new
191
+ @api = Soundcloud::API.new("no token needed in this case...", stubs: @stubs)
192
+ @stubs.get("/tracks") do
193
+ [
194
+ 200, # status code
195
+ {"Content-Type" => "application/json"}, # response headers
196
+ '[{"id":123,"kind":"track","title":"Track Title"}]' # response body
197
+ ]
198
+ end
199
+ @api.tracks.should == [ {"id" => 123, "kind" => "track", "title" => "Track Title"} ]
200
+
@@ -0,0 +1 @@
1
+ require 'bundler/gem_tasks'
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,8 @@
1
+ module Soundcloud
2
+ VERSION = File.read(File.expand_path("../VERSION", File.dirname(__FILE__))).to_s.strip
3
+
4
+ [:API, :Error, :Middleware].each do |konst|
5
+ autoload konst, "soundcloud/#{konst.to_s.downcase}"
6
+ end
7
+
8
+ end
@@ -0,0 +1,202 @@
1
+ require 'faraday'
2
+ require 'faraday_middleware'
3
+
4
+ require 'soundcloud/error'
5
+ require 'soundcloud/middleware'
6
+
7
+
8
+ module Soundcloud
9
+
10
+ class API
11
+
12
+ DEFAULT_ACCEPT = "application/json".freeze
13
+ DEFAULT_HOST = 'https://api.soundcloud.com'
14
+ AUTHORIZE_URL = 'https://soundcloud.com/connect'
15
+ TOKEN_PATH = '/oauth2/token'
16
+
17
+ class << self
18
+ attr_reader :client_id, :client_secret, :redirect_uri
19
+ attr_reader :mime, :adapter, :host
20
+
21
+ def configure(options={})
22
+ @client_id = options[:client_id]
23
+ @client_secret = options[:client_secret]
24
+ @redirect_uri = options[:redirect_uri]
25
+ @mime = options[:mime] if options.key? :mime
26
+ @host = options[:host] if options.key? :host
27
+ @adapter = options[:adapter] if options[:adapter]
28
+ end
29
+
30
+ def user_agent
31
+ "Soundcloud client v#{Soundcloud::VERSION}"
32
+ end
33
+
34
+ # Establish a class-wide connection, possibly to be able to use
35
+ # persistent connections among requests and event-based things.
36
+ def connection(opts={})
37
+ @@connections ||= {}
38
+ @@connections[opts] ||= Faraday.new(self.host, headers: {accept: self.mime, user_agent: self.user_agent}) do |builder|
39
+ builder.request :multipart
40
+ builder.request :url_encoded
41
+ builder.use Soundcloud::Middleware::RaiseError
42
+ builder.response :json, content_type: /\bjson$/
43
+ builder.response :logger if opts[:verbose]
44
+ builder.adapter self.adapter
45
+ end
46
+ end
47
+
48
+ def authorize_url
49
+ raise Soundcloud::Error::Unauthorized, "Missing configuration" unless client_id and client_secret and redirect_uri
50
+ "https://#{AUTHORIZE_URL}?response_type=code&client_id=#{client_id}&redirect_uri=#{URI.escape redirect_uri}&scope=non-expiring"
51
+ end
52
+
53
+ def exchange_temporary_credentials(code, stubs=nil)
54
+ raise Soundcloud::Error::Unauthorized, "Missing configuration" unless client_id and client_secret and redirect_uri
55
+ begin
56
+ parameters = {
57
+ client_id: client_id, client_secret: client_secret, redirect_uri: redirect_uri,
58
+ grant_type: 'authorization_code', code: code
59
+ }
60
+ Faraday.new do |builder|
61
+ builder.request :url_encoded
62
+ builder.response :json, content_type: /\bjson$/
63
+ builder.adapter *Array(stubs ? [:test, stubs] : self.adapter)
64
+ end.post("#{host}#{TOKEN_PATH}") do |request|
65
+ request.body parameters
66
+ end
67
+ rescue
68
+ raise Soundcloud::Error::Unauthorized, "<#{$!.class}: #{$!}>"
69
+ end
70
+ end
71
+
72
+ end
73
+
74
+ self.configure(mime: DEFAULT_ACCEPT,
75
+ host: DEFAULT_HOST,
76
+ adapter: Faraday.default_adapter)
77
+
78
+
79
+ attr_accessor :stubs, :verbose
80
+ attr_accessor :token, :host
81
+
82
+ # Options
83
+ # :verbose whether to print request/response. Request bodies are printed too
84
+ # :mime Content of the Accept header, indicates the format of the response. Default is json
85
+ # :stubs Instance of Faraday::Adapter::Test::Stubs, which will be used instead of the default adapter.
86
+ def initialize(token, opts={})
87
+ self.token = token
88
+ self.verbose = !!opts[:verbose]
89
+ self.stubs = opts[:stubs]
90
+ end
91
+
92
+
93
+ def connection(options={})
94
+ options[:verbose] = self.verbose unless options.key?(:verbose) or !self.verbose
95
+ stubs = options.delete(:stubs) || self.stubs
96
+ conn = self.class.connection(options)
97
+ if stubs
98
+ conn = conn.dup
99
+ conn.builder.handlers.delete_at -1
100
+ conn.builder.adapter :test, self.stubs
101
+ end
102
+ conn
103
+ end
104
+
105
+
106
+ # RESTful API definitions:
107
+
108
+ SUBRESOURCE_MAP = {
109
+ user: [:tracks, :playlists, :groups, :web_profiles, :followings, :followers, :comments, :favorites, :connections, :activities],
110
+ track: [:comments, :favoriters, [:shared_to_users, "shared-to/users"], [:shared_to_emails, "shared-to/emails"], :secret_token],
111
+ playlist: [[:shared_to_users, "shared-to/users"], [:shared_to_emails, "shared-to/emails"], :secret_token],
112
+ group: [:moderators, :members, :contributors, :users, :tracks, :pending_tracks, :contributions],
113
+ app: [:tracks]
114
+ }
115
+
116
+ [:user, :track, :playlist, :group, :app, :comment].each do |resource|
117
+ define_method(resource) do |ref|
118
+ get("/#{resource}s/#{id_of ref}").body
119
+ end
120
+ define_method("#{resource}s") do |opts={}|
121
+ get("/#{resource}s", opts).body
122
+ end
123
+ SUBRESOURCE_MAP.fetch(resource, []).each do |subresource|
124
+ subresource, path = subresource if subresource.is_a? Array
125
+ path ||= subresource.to_s.gsub("_", "-")
126
+ define_method("#{resource}_#{subresource}") do |ref, opts={}|
127
+ get("/#{resource}s/#{id_of ref}/#{path}", opts).body
128
+ end
129
+ end
130
+ end
131
+
132
+ # CRUD
133
+
134
+ [:playlist, :track, :group].each do |resource|
135
+ define_method("create_#{resource}") do |item|
136
+ post("/#{resource}s", {resource => item}).body
137
+ end
138
+ define_method("update_#{resource}") do |item|
139
+ put("/#{resource}s/#{id_of item}", {resource => item}).body
140
+ end
141
+ define_method("delete_#{resource}") do |item|
142
+ delete("/#{resource}s/#{id_of item}").body
143
+ end
144
+ end
145
+
146
+
147
+ def favorite!(track)
148
+ put "/users/me/favorites/#{id_of track}"
149
+ end
150
+ alias :favorite :favorite!
151
+
152
+ def unfavorite!(track)
153
+ delete "/users/me/favorites/#{id_of track}"
154
+ end
155
+ alias :unfavorite :unfavorite!
156
+
157
+ def favorite?(track, who="me")
158
+ get "/users/#{id_of who}/favorites/#{id_of track}"
159
+ rescue Soundcloud::Error::NotFound
160
+ false
161
+ end
162
+
163
+ def follow!(user)
164
+ put "/users/me/followings/#{id_of user}"
165
+ end
166
+
167
+ def unfollow!(user)
168
+ delete "/users/me/followings/#{id_of user}"
169
+ rescue Soundcloud::Error::NotFound
170
+ false
171
+ end
172
+
173
+ def follow?(user, who="me")
174
+ get "/users/#{id_of who}/followings/#{id_of user}"
175
+ rescue Soundcloud::Error::NotFound
176
+ false
177
+ end
178
+
179
+ def id_of(item)
180
+ id = item["id"] || item[:id] if item.is_a?(Hash)
181
+ id || item
182
+ end
183
+
184
+ def auth_header
185
+ { authorization: "OAuth #{self.token}" } if self.token and !self.token.empty?
186
+ end
187
+
188
+ Faraday::Connection::METHODS.each do |method|
189
+ define_method(method) do |url=nil, params=nil, headers=nil|
190
+ if auth_header
191
+ if headers
192
+ headers.update(auth_header)
193
+ else
194
+ headers = {}.merge(auth_header)
195
+ end
196
+ end
197
+ connection.send method, url, params, headers
198
+ end
199
+ end
200
+
201
+ end
202
+ end