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.
@@ -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