asana 0.0.6 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (67) hide show
  1. checksums.yaml +9 -9
  2. data/.codeclimate.yml +4 -0
  3. data/.gitignore +12 -20
  4. data/.rspec +4 -0
  5. data/.rubocop.yml +18 -0
  6. data/.travis.yml +12 -0
  7. data/.yardopts +5 -0
  8. data/CODE_OF_CONDUCT.md +13 -0
  9. data/Gemfile +17 -0
  10. data/Guardfile +85 -4
  11. data/LICENSE.txt +21 -0
  12. data/README.md +264 -135
  13. data/Rakefile +62 -7
  14. data/asana.gemspec +27 -21
  15. data/examples/Gemfile +6 -0
  16. data/examples/Gemfile.lock +56 -0
  17. data/examples/api_token.rb +21 -0
  18. data/examples/cli_app.rb +25 -0
  19. data/examples/events.rb +38 -0
  20. data/examples/omniauth_integration.rb +54 -0
  21. data/lib/asana.rb +8 -11
  22. data/lib/asana/authentication.rb +8 -0
  23. data/lib/asana/authentication/oauth2.rb +42 -0
  24. data/lib/asana/authentication/oauth2/access_token_authentication.rb +51 -0
  25. data/lib/asana/authentication/oauth2/bearer_token_authentication.rb +32 -0
  26. data/lib/asana/authentication/oauth2/client.rb +50 -0
  27. data/lib/asana/authentication/token_authentication.rb +20 -0
  28. data/lib/asana/client.rb +124 -0
  29. data/lib/asana/client/configuration.rb +165 -0
  30. data/lib/asana/errors.rb +90 -0
  31. data/lib/asana/http_client.rb +155 -0
  32. data/lib/asana/http_client/environment_info.rb +53 -0
  33. data/lib/asana/http_client/error_handling.rb +103 -0
  34. data/lib/asana/http_client/response.rb +32 -0
  35. data/lib/asana/resources.rb +11 -0
  36. data/lib/asana/resources/attachment.rb +44 -0
  37. data/lib/asana/resources/attachment_uploading.rb +33 -0
  38. data/lib/asana/resources/collection.rb +68 -0
  39. data/lib/asana/resources/event.rb +49 -0
  40. data/lib/asana/resources/event_subscription.rb +12 -0
  41. data/lib/asana/resources/events.rb +101 -0
  42. data/lib/asana/resources/project.rb +145 -19
  43. data/lib/asana/resources/registry.rb +62 -0
  44. data/lib/asana/resources/resource.rb +103 -0
  45. data/lib/asana/resources/response_helper.rb +14 -0
  46. data/lib/asana/resources/story.rb +58 -7
  47. data/lib/asana/resources/tag.rb +111 -19
  48. data/lib/asana/resources/task.rb +284 -57
  49. data/lib/asana/resources/team.rb +55 -0
  50. data/lib/asana/resources/user.rb +65 -10
  51. data/lib/asana/resources/workspace.rb +79 -34
  52. data/lib/asana/ruby2_0_0_compatibility.rb +3 -0
  53. data/lib/asana/version.rb +3 -1
  54. data/lib/templates/index.js +8 -0
  55. data/lib/templates/resource.ejs +225 -0
  56. data/package.json +7 -0
  57. metadata +91 -51
  58. data/LICENSE +0 -22
  59. data/lib/asana/config.rb +0 -23
  60. data/lib/asana/resource.rb +0 -52
  61. data/spec/asana/resources/project_spec.rb +0 -63
  62. data/spec/asana/resources/story_spec.rb +0 -39
  63. data/spec/asana/resources/tag_spec.rb +0 -63
  64. data/spec/asana/resources/task_spec.rb +0 -95
  65. data/spec/asana/resources/user_spec.rb +0 -64
  66. data/spec/asana/resources/workspace_spec.rb +0 -108
  67. data/spec/spec_helper.rb +0 -9
@@ -0,0 +1,155 @@
1
+ require 'faraday'
2
+ require 'faraday_middleware'
3
+ require 'faraday_middleware/multi_json'
4
+
5
+ require_relative 'http_client/error_handling'
6
+ require_relative 'http_client/environment_info'
7
+ require_relative 'http_client/response'
8
+
9
+ module Asana
10
+ # Internal: Wrapper over Faraday that abstracts authentication, request
11
+ # parsing and common options.
12
+ class HttpClient
13
+ # Internal: The API base URI.
14
+ BASE_URI = 'https://app.asana.com/api/1.0'
15
+
16
+ # Public: Initializes an HttpClient to make requests to the Asana API.
17
+ #
18
+ # authentication - [Asana::Authentication] An authentication strategy.
19
+ # adapter - [Symbol, Proc] A Faraday adapter, eiter a Symbol for
20
+ # registered adapters or a Proc taking a builder for a
21
+ # custom one. Defaults to Faraday.default_adapter.
22
+ # user_agent - [String] The user agent. Defaults to "ruby-asana vX.Y.Z".
23
+ # config - [Proc] An optional block that yields the Faraday builder
24
+ # object for customization.
25
+ def initialize(authentication: required('authentication'),
26
+ adapter: nil,
27
+ user_agent: nil,
28
+ debug_mode: false,
29
+ &config)
30
+ @authentication = authentication
31
+ @adapter = adapter || Faraday.default_adapter
32
+ @environment_info = EnvironmentInfo.new(user_agent)
33
+ @debug_mode = debug_mode
34
+ @config = config
35
+ end
36
+
37
+ # Public: Performs a GET request against the API.
38
+ #
39
+ # resource_uri - [String] the resource URI relative to the base Asana API
40
+ # URL, e.g "/users/me".
41
+ # params - [Hash] the request parameters
42
+ # options - [Hash] the request I/O options
43
+ #
44
+ # Returns an [Asana::HttpClient::Response] if everything went well.
45
+ # Raises [Asana::Errors::APIError] if anything went wrong.
46
+ def get(resource_uri, params: {}, options: {})
47
+ opts = options.reduce({}) do |acc, (k, v)|
48
+ acc.tap do |hash|
49
+ hash[:"opt_#{k}"] = v.is_a?(Array) ? v.join(',') : v
50
+ end
51
+ end
52
+ perform_request(:get, resource_uri, params.merge(opts))
53
+ end
54
+
55
+ # Public: Performs a PUT request against the API.
56
+ #
57
+ # resource_uri - [String] the resource URI relative to the base Asana API
58
+ # URL, e.g "/users/me".
59
+ # body - [Hash] the body to PUT.
60
+ # options - [Hash] the request I/O options
61
+ #
62
+ # Returns an [Asana::HttpClient::Response] if everything went well.
63
+ # Raises [Asana::Errors::APIError] if anything went wrong.
64
+ def put(resource_uri, body: {}, options: {})
65
+ params = { data: body }.merge(options.empty? ? {} : { options: options })
66
+ perform_request(:put, resource_uri, params)
67
+ end
68
+
69
+ # Public: Performs a POST request against the API.
70
+ #
71
+ # resource_uri - [String] the resource URI relative to the base Asana API
72
+ # URL, e.g "/tags".
73
+ # body - [Hash] the body to POST.
74
+ # upload - [Faraday::UploadIO] an upload object to post as multipart.
75
+ # Defaults to nil.
76
+ # options - [Hash] the request I/O options
77
+ #
78
+ # Returns an [Asana::HttpClient::Response] if everything went well.
79
+ # Raises [Asana::Errors::APIError] if anything went wrong.
80
+ def post(resource_uri, body: {}, upload: nil, options: {})
81
+ params = { data: body }.merge(options.empty? ? {} : { options: options })
82
+ if upload
83
+ perform_request(:post, resource_uri, params.merge(file: upload)) do |c|
84
+ c.request :multipart
85
+ end
86
+ else
87
+ perform_request(:post, resource_uri, params)
88
+ end
89
+ end
90
+
91
+ # Public: Performs a DELETE request against the API.
92
+ #
93
+ # resource_uri - [String] the resource URI relative to the base Asana API
94
+ # URL, e.g "/tags".
95
+ #
96
+ # Returns an [Asana::HttpClient::Response] if everything went well.
97
+ # Raises [Asana::Errors::APIError] if anything went wrong.
98
+ def delete(resource_uri)
99
+ perform_request(:delete, resource_uri)
100
+ end
101
+
102
+ private
103
+
104
+ def connection(&request_config)
105
+ Faraday.new do |builder|
106
+ @authentication.configure(builder)
107
+ @environment_info.configure(builder)
108
+ request_config.call(builder) if request_config
109
+ configure_format(builder)
110
+ add_middleware(builder)
111
+ @config.call(builder) if @config
112
+ use_adapter(builder, @adapter)
113
+ end
114
+ end
115
+
116
+ def perform_request(method, resource_uri, body = {}, &request_config)
117
+ handling_errors do
118
+ url = BASE_URI + resource_uri
119
+ log_request(method, url, body) if @debug_mode
120
+ Response.new(connection(&request_config).public_send(method, url, body))
121
+ end
122
+ end
123
+
124
+ def configure_format(builder)
125
+ builder.request :multi_json
126
+ builder.response :multi_json
127
+ end
128
+
129
+ def add_middleware(builder)
130
+ builder.use Faraday::Response::RaiseError
131
+ builder.use FaradayMiddleware::FollowRedirects
132
+ end
133
+
134
+ def use_adapter(builder, adapter)
135
+ case adapter
136
+ when Symbol
137
+ builder.adapter(adapter)
138
+ when Proc
139
+ adapter.call(builder)
140
+ end
141
+ end
142
+
143
+ def handling_errors(&request)
144
+ ErrorHandling.handle(&request)
145
+ end
146
+
147
+ def log_request(method, url, body)
148
+ STDERR.puts format('[%s] %s %s (%s)',
149
+ self.class,
150
+ method.to_s.upcase,
151
+ url,
152
+ body.inspect)
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,53 @@
1
+ require_relative '../version'
2
+ require 'openssl'
3
+
4
+ module Asana
5
+ class HttpClient
6
+ # Internal: Adds environment information to a Faraday request.
7
+ class EnvironmentInfo
8
+ # Internal: The default user agent to use in all requests to the API.
9
+ USER_AGENT = "ruby-asana v#{Asana::VERSION}"
10
+
11
+ def initialize(user_agent = nil)
12
+ @user_agent = user_agent || USER_AGENT
13
+ @openssl_version = OpenSSL::OPENSSL_VERSION
14
+ @client_version = Asana::VERSION
15
+ @os = os
16
+ end
17
+
18
+ # Public: Augments a Faraday connection with information about the
19
+ # environment.
20
+ def configure(builder)
21
+ builder.headers[:user_agent] = @user_agent
22
+ builder.headers[:"X-Asana-Client-Lib"] = header
23
+ end
24
+
25
+ private
26
+
27
+ def header
28
+ { os: @os,
29
+ language: 'ruby',
30
+ language_version: RUBY_VERSION,
31
+ version: @client_version,
32
+ openssl_version: @openssl_version }
33
+ .map { |k, v| "#{k}=#{v}" }.join('&')
34
+ end
35
+
36
+ # rubocop:disable Metrics/MethodLength
37
+ def os
38
+ if RUBY_PLATFORM =~ /win32/ || RUBY_PLATFORM =~ /mingw/
39
+ 'windows'
40
+ elsif RUBY_PLATFORM =~ /linux/
41
+ 'linux'
42
+ elsif RUBY_PLATFORM =~ /darwin/
43
+ 'darwin'
44
+ elsif RUBY_PLATFORM =~ /freebsd/
45
+ 'freebsd'
46
+ else
47
+ 'unknown'
48
+ end
49
+ end
50
+ # rubocop:enable Metrics/MethodLength
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,103 @@
1
+ require 'multi_json'
2
+
3
+ require_relative '../errors'
4
+
5
+ module Asana
6
+ class HttpClient
7
+ # Internal: Handles errors from the API and re-raises them as proper
8
+ # exceptions.
9
+ module ErrorHandling
10
+ include Errors
11
+
12
+ module_function
13
+
14
+ # Public: Perform a request handling any API errors correspondingly.
15
+ #
16
+ # request - [Proc] a block that will execute the request.
17
+ #
18
+ # Returns a [Faraday::Response] object.
19
+ #
20
+ # Raises [Asana::Errors::InvalidRequest] for invalid requests.
21
+ # Raises [Asana::Errors::NotAuthorized] for unauthorized requests.
22
+ # Raises [Asana::Errors::Forbidden] for forbidden requests.
23
+ # Raises [Asana::Errors::NotFound] when a resource can't be found.
24
+ # Raises [Asana::Errors::RateLimitEnforced] when the API is throttling.
25
+ # Raises [Asana::Errors::ServerError] when there's a server problem.
26
+ # Raises [Asana::Errors::APIError] when the API returns an unknown error.
27
+ #
28
+ # rubocop:disable all
29
+ def handle(&request)
30
+ request.call
31
+ rescue Faraday::ClientError => e
32
+ raise e unless e.response
33
+ case e.response[:status]
34
+ when 400 then raise invalid_request(e.response)
35
+ when 401 then raise not_authorized(e.response)
36
+ when 403 then raise forbidden(e.response)
37
+ when 404 then raise not_found(e.response)
38
+ when 412 then recover_response(e.response)
39
+ when 429 then raise rate_limit_enforced(e.response)
40
+ when 500 then raise server_error(e.response)
41
+ else raise api_error(e.response)
42
+ end
43
+ end
44
+ # rubocop:enable all
45
+
46
+ # Internal: Returns an InvalidRequest exception including a list of
47
+ # errors.
48
+ def invalid_request(response)
49
+ errors = body(response).fetch('errors', []).map { |e| e['message'] }
50
+ InvalidRequest.new(errors).tap do |exception|
51
+ exception.response = response
52
+ end
53
+ end
54
+
55
+ # Internal: Returns a NotAuthorized exception.
56
+ def not_authorized(response)
57
+ NotAuthorized.new.tap { |exception| exception.response = response }
58
+ end
59
+
60
+ # Internal: Returns a Forbidden exception.
61
+ def forbidden(response)
62
+ Forbidden.new.tap { |exception| exception.response = response }
63
+ end
64
+
65
+ # Internal: Returns a NotFound exception.
66
+ def not_found(response)
67
+ NotFound.new.tap { |exception| exception.response = response }
68
+ end
69
+
70
+ # Internal: Returns a RateLimitEnforced exception with a retry after
71
+ # field.
72
+ def rate_limit_enforced(response)
73
+ retry_after_seconds = response[:headers]['Retry-After']
74
+ RateLimitEnforced.new(retry_after_seconds).tap do |exception|
75
+ exception.response = response
76
+ end
77
+ end
78
+
79
+ # Internal: Returns a ServerError exception with a unique phrase.
80
+ def server_error(response)
81
+ phrase = body(response).fetch('errors', []).first['phrase']
82
+ ServerError.new(phrase).tap do |exception|
83
+ exception.response = response
84
+ end
85
+ end
86
+
87
+ # Internal: Returns an APIError exception.
88
+ def api_error(response)
89
+ APIError.new.tap { |exception| exception.response = response }
90
+ end
91
+
92
+ # Internal: Parser a response body from JSON.
93
+ def body(response)
94
+ MultiJson.load(response[:body])
95
+ end
96
+
97
+ def recover_response(response)
98
+ r = response.dup.tap { |res| res[:body] = body(response) }
99
+ Response.new(OpenStruct.new(env: OpenStruct.new(r)))
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,32 @@
1
+ module Asana
2
+ class HttpClient
3
+ # Internal: Represents a response from the Asana API.
4
+ class Response
5
+ # Public:
6
+ # Returns a [Faraday::Env] object for debugging.
7
+ attr_reader :faraday_env
8
+ # Public:
9
+ # Returns the [Integer] status code of the response.
10
+ attr_reader :status
11
+ # Public:
12
+ # Returns the [Hash] representing the parsed JSON body.
13
+ attr_reader :body
14
+
15
+ # Public: Wraps a Faraday response.
16
+ #
17
+ # faraday_response - [Faraday::Response] the Faraday response to wrap.
18
+ def initialize(faraday_response)
19
+ @faraday_env = faraday_response.env
20
+ @status = faraday_env.status
21
+ @body = faraday_env.body
22
+ end
23
+
24
+ # Public:
25
+ # Returns a [String] representation of the response.
26
+ def to_s
27
+ "#<Asana::HttpClient::Response status=#{@status} body=#{@body}>"
28
+ end
29
+ alias_method :inspect, :to_s
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,11 @@
1
+ require_relative 'resources/resource'
2
+ require_relative 'resources/collection'
3
+
4
+ Dir[File.join(File.dirname(__FILE__), 'resources', '*.rb')]
5
+ .each { |resource| require resource }
6
+
7
+ module Asana
8
+ # Public: Contains all the resources that the Asana API can return.
9
+ module Resources
10
+ end
11
+ end
@@ -0,0 +1,44 @@
1
+ ### WARNING: This file is auto-generated by the asana-api-meta repo. Do not
2
+ ### edit it manually.
3
+
4
+ module Asana
5
+ module Resources
6
+ # An _attachment_ object represents any file attached to a task in Asana,
7
+ # whether it's an uploaded file or one associated via a third-party service
8
+ # such as Dropbox or Google Drive.
9
+ class Attachment < Resource
10
+
11
+
12
+ attr_reader :id
13
+
14
+ class << self
15
+ # Returns the plural name of the resource.
16
+ def plural_name
17
+ 'attachments'
18
+ end
19
+
20
+ # Returns the full record for a single attachment.
21
+ #
22
+ # id - [Id] Globally unique identifier for the attachment.
23
+ #
24
+ # options - [Hash] the request I/O options.
25
+ def find_by_id(client, id, options: {})
26
+
27
+ self.new(parse(client.get("/attachments/#{id}", options: options)).first, client: client)
28
+ end
29
+
30
+ # Returns the compact records for all attachments on the task.
31
+ #
32
+ # task - [Id] Globally unique identifier for the task.
33
+ #
34
+ # per_page - [Integer] the number of records to fetch per page.
35
+ # options - [Hash] the request I/O options.
36
+ def find_by_task(client, task: required("task"), per_page: 20, options: {})
37
+ params = { limit: per_page }.reject { |_,v| v.nil? || Array(v).empty? }
38
+ Collection.new(parse(client.get("/tasks/#{task}/attachments", params: params, options: options)), type: self, client: client)
39
+ end
40
+ end
41
+
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ module Asana
2
+ module Resources
3
+ # Internal: Mixin to add the ability to upload an attachment to a specific
4
+ # Asana resource (a Task, really).
5
+ module AttachmentUploading
6
+ # Uploads a new attachment to the resource.
7
+ #
8
+ # filename - [String] the absolute path of the file to upload.
9
+ # mime - [String] the MIME type of the file
10
+ # options - [Hash] the request I/O options
11
+ # data - [Hash] extra attributes to post
12
+ #
13
+ # rubocop:disable Metrics/AbcSize
14
+ # rubocop:disable Metrics/MethodLength
15
+ def attach(filename: required('filename'),
16
+ mime: required('mime'),
17
+ options: {}, **data)
18
+ path = File.expand_path(filename)
19
+ unless File.exist?(path)
20
+ fail ArgumentError, "file #{filename} doesn't exist"
21
+ end
22
+ upload = Faraday::UploadIO.new(path, mime)
23
+ response = client.post("/#{self.class.plural_name}/#{id}/attachments",
24
+ body: data,
25
+ upload: upload,
26
+ options: options)
27
+ Attachment.new(parse(response).first, client: client)
28
+ end
29
+ # rubocop:enable Metrics/MethodLength
30
+ # rubocop:enable Metrics/AbcSize
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,68 @@
1
+ require_relative 'response_helper'
2
+
3
+ module Asana
4
+ module Resources
5
+ # Public: Represents a paginated collection of Asana resources.
6
+ class Collection
7
+ include Enumerable
8
+ include ResponseHelper
9
+
10
+ attr_reader :elements
11
+
12
+ # Public: Initializes a collection representing a page of resources of a
13
+ # given type.
14
+ #
15
+ # (elements, extra) - [Array] an (String, Hash) tuple coming from the
16
+ # response parser.
17
+ # type - [Class] the type of resource that the collection
18
+ # contains. Defaults to the generic Resource.
19
+ # client - [Asana::Client] the client to perform requests.
20
+ def initialize((elements, extra),
21
+ type: Resource,
22
+ client: required('client'))
23
+ @elements = elements.map { |elem| type.new(elem, client: client) }
24
+ @type = type
25
+ @next_page_data = extra['next_page']
26
+ @client = client
27
+ end
28
+
29
+ # Public: Iterates over the elements of the collection.
30
+ def each(&block)
31
+ if block
32
+ @elements.each(&block)
33
+ (next_page || []).each(&block)
34
+ else
35
+ to_enum
36
+ end
37
+ end
38
+
39
+ # Public: Returns the size of the collection.
40
+ def size
41
+ to_a.size
42
+ end
43
+ alias_method :length, :size
44
+
45
+ # Public: Returns a String representation of the collection.
46
+ def to_s
47
+ "#<Asana::Collection<#{@type}> " \
48
+ "[#{@elements.map(&:inspect).join(', ')}" +
49
+ (@next_page_data ? ', ...' : '') + ']>'
50
+ end
51
+
52
+ alias_method :inspect, :to_s
53
+
54
+ # Public: Returns a new Asana::Resources::Collection with the next page
55
+ # or nil if there are no more pages. Caches the result.
56
+ def next_page
57
+ if defined?(@next_page)
58
+ @next_page
59
+ else
60
+ @next_page = if @next_page_data
61
+ response = parse(@client.get(@next_page_data['path']))
62
+ self.class.new(response, type: @type, client: @client)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end