soundcloud-client 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +23 -0
- data/.rspec +3 -0
- data/Gemfile +5 -0
- data/README.md +200 -0
- data/Rakefile +1 -0
- data/VERSION +1 -0
- data/lib/soundcloud-client.rb +8 -0
- data/lib/soundcloud/api.rb +202 -0
- data/lib/soundcloud/error.rb +52 -0
- data/lib/soundcloud/middleware.rb +24 -0
- data/soundcloud-client.gemspec +27 -0
- data/spec/api_conformity_spec.rb +92 -0
- data/spec/soundcloud_spec.rb +113 -0
- data/spec/spec_helper.rb +2 -0
- data/spec/stubs.rb +620 -0
- metadata +143 -0
data/.gitignore
ADDED
@@ -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
data/Gemfile
ADDED
data/README.md
ADDED
@@ -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
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'bundler/gem_tasks'
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.1.0
|
@@ -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
|