soundcloud-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|