openc-asana 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +4 -0
  3. data/.gitignore +13 -0
  4. data/.rspec +4 -0
  5. data/.rubocop.yml +11 -0
  6. data/.travis.yml +12 -0
  7. data/.yardopts +5 -0
  8. data/CODE_OF_CONDUCT.md +13 -0
  9. data/Gemfile +21 -0
  10. data/Guardfile +86 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +355 -0
  13. data/Rakefile +65 -0
  14. data/examples/Gemfile +6 -0
  15. data/examples/Gemfile.lock +59 -0
  16. data/examples/api_token.rb +21 -0
  17. data/examples/cli_app.rb +25 -0
  18. data/examples/events.rb +38 -0
  19. data/examples/omniauth_integration.rb +54 -0
  20. data/lib/asana.rb +12 -0
  21. data/lib/asana/authentication.rb +8 -0
  22. data/lib/asana/authentication/oauth2.rb +42 -0
  23. data/lib/asana/authentication/oauth2/access_token_authentication.rb +51 -0
  24. data/lib/asana/authentication/oauth2/bearer_token_authentication.rb +32 -0
  25. data/lib/asana/authentication/oauth2/client.rb +50 -0
  26. data/lib/asana/authentication/token_authentication.rb +20 -0
  27. data/lib/asana/client.rb +124 -0
  28. data/lib/asana/client/configuration.rb +165 -0
  29. data/lib/asana/errors.rb +92 -0
  30. data/lib/asana/http_client.rb +155 -0
  31. data/lib/asana/http_client/environment_info.rb +53 -0
  32. data/lib/asana/http_client/error_handling.rb +103 -0
  33. data/lib/asana/http_client/response.rb +32 -0
  34. data/lib/asana/resource_includes/attachment_uploading.rb +33 -0
  35. data/lib/asana/resource_includes/collection.rb +68 -0
  36. data/lib/asana/resource_includes/event.rb +51 -0
  37. data/lib/asana/resource_includes/event_subscription.rb +14 -0
  38. data/lib/asana/resource_includes/events.rb +103 -0
  39. data/lib/asana/resource_includes/registry.rb +63 -0
  40. data/lib/asana/resource_includes/resource.rb +103 -0
  41. data/lib/asana/resource_includes/response_helper.rb +14 -0
  42. data/lib/asana/resources.rb +14 -0
  43. data/lib/asana/resources/attachment.rb +44 -0
  44. data/lib/asana/resources/project.rb +154 -0
  45. data/lib/asana/resources/story.rb +64 -0
  46. data/lib/asana/resources/tag.rb +120 -0
  47. data/lib/asana/resources/task.rb +300 -0
  48. data/lib/asana/resources/team.rb +55 -0
  49. data/lib/asana/resources/user.rb +72 -0
  50. data/lib/asana/resources/workspace.rb +91 -0
  51. data/lib/asana/ruby2_0_0_compatibility.rb +3 -0
  52. data/lib/asana/version.rb +5 -0
  53. data/lib/templates/index.js +8 -0
  54. data/lib/templates/resource.ejs +225 -0
  55. data/openc-asana.gemspec +32 -0
  56. data/package.json +7 -0
  57. metadata +200 -0
@@ -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,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
@@ -0,0 +1,51 @@
1
+ require_relative 'events'
2
+
3
+ module Asana
4
+ module Resources
5
+ # An _event_ is an object representing a change to a resource that was
6
+ # observed by an event subscription.
7
+ #
8
+ # In general, requesting events on a resource is faster and subject to
9
+ # higher rate limits than requesting the resource itself. Additionally,
10
+ # change events bubble up - listening to events on a project would include
11
+ # when stories are added to tasks in the project, even on subtasks.
12
+ #
13
+ # Establish an initial sync token by making a request with no sync token.
14
+ # The response will be a `412` error - the same as if the sync token had
15
+ # expired.
16
+ #
17
+ # Subsequent requests should always provide the sync token from the
18
+ # immediately preceding call.
19
+ #
20
+ # Sync tokens may not be valid if you attempt to go 'backward' in the
21
+ # history by requesting previous tokens, though re-requesting the current
22
+ # sync token is generally safe, and will always return the same results.
23
+ #
24
+ # When you receive a `412 Precondition Failed` error, it means that the sync
25
+ # token is either invalid or expired. If you are attempting to keep a set of
26
+ # data in sync, this signals you may need to re-crawl the data.
27
+ #
28
+ # Sync tokens always expire after 24 hours, but may expire sooner, depending
29
+ # on load on the service.
30
+ class Event < Resource
31
+ attr_reader :type
32
+
33
+ class << self
34
+ # Returns the plural name of the resource.
35
+ def plural_name
36
+ 'events'
37
+ end
38
+
39
+ # Public: Returns an infinite collection of events on a particular
40
+ # resource.
41
+ #
42
+ # client - [Asana::Client] the client to perform the requests.
43
+ # id - [String] the id of the resource to get events from.
44
+ # wait - [Integer] the number of seconds to wait between each poll.
45
+ def for(client, id, wait: 1)
46
+ Events.new(resource: id, client: client, wait: wait)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,14 @@
1
+ require_relative 'events'
2
+
3
+ module Asana
4
+ module Resources
5
+ # Public: Mixin to enable a resource with the ability to fetch events about
6
+ # itself.
7
+ module EventSubscription
8
+ # Public: Returns an infinite collection of events on the resource.
9
+ def events(wait: 1, options: {})
10
+ Events.new(resource: id, client: client, wait: wait, options: options)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,103 @@
1
+ require_relative 'event'
2
+
3
+ module Asana
4
+ module Resources
5
+ # Public: An infinite collection of events.
6
+ #
7
+ # Since they are infinite, if you want to filter or do other collection
8
+ # operations without blocking indefinitely you should call #lazy on them to
9
+ # turn them into a lazy collection.
10
+ #
11
+ # Examples:
12
+ #
13
+ # # Subscribes to an event stream and blocks indefinitely, printing
14
+ # # information of every event as it comes in.
15
+ # events = Events.new(resource: 'someresourceID', client: client)
16
+ # events.each do |event|
17
+ # puts [event.type, event.action]
18
+ # end
19
+ #
20
+ # # Lazily filters events as they come in and prints them.
21
+ # events = Events.new(resource: 'someresourceID', client: client)
22
+ # events.lazy.select { |e| e.type == 'task' }.each do |event|
23
+ # puts [event.type, event.action]
24
+ # end
25
+ #
26
+ class Events
27
+ include Enumerable
28
+
29
+ # Public: Initializes a new Events instance, subscribed to a resource ID.
30
+ #
31
+ # resource - [String] a resource ID. Can be a task id or a workspace id.
32
+ # client - [Asana::Client] a client to perform the requests.
33
+ # wait - [Integer] the number of seconds to wait between each poll.
34
+ # options - [Hash] the request I/O options
35
+ def initialize(resource: required('resource'),
36
+ client: required('client'),
37
+ wait: 1, options: {})
38
+ @resource = resource
39
+ @client = client
40
+ @events = []
41
+ @wait = wait
42
+ @options = options
43
+ @sync = nil
44
+ @last_poll = nil
45
+ end
46
+
47
+ # Public: Iterates indefinitely over all events happening to a particular
48
+ # resource from the @sync timestamp or from now if it is nil.
49
+ def each(&block)
50
+ if block
51
+ loop do
52
+ poll if @events.empty?
53
+ event = @events.shift
54
+ yield event if event
55
+ end
56
+ else
57
+ to_enum
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ # Internal: Polls and fetches all events that have occurred since the sync
64
+ # token was created. Updates the sync token as it comes back from the
65
+ # response.
66
+ #
67
+ # If we polled less than @wait seconds ago, we don't do anything.
68
+ #
69
+ # Notes:
70
+ #
71
+ # On the first request, the sync token is not passed (because it is
72
+ # nil). The response will be the same as for an expired sync token, and
73
+ # will include a new valid sync token.
74
+ #
75
+ # If the sync token is too old (which may happen from time to time)
76
+ # the API will return a `412 Precondition Failed` error, and include
77
+ # a fresh `sync` token in the response.
78
+ def poll
79
+ rate_limiting do
80
+ body = @client.get('/events',
81
+ params: params,
82
+ options: @options).body
83
+ @sync = body['sync']
84
+ @events += body.fetch('data', []).map do |event_data|
85
+ Event.new(event_data, client: @client)
86
+ end
87
+ end
88
+ end
89
+
90
+ # Internal: Returns the formatted params for the poll request.
91
+ def params
92
+ { resource: @resource, sync: @sync }.reject { |_, v| v.nil? }
93
+ end
94
+
95
+ # Internal: Executes a block if at least @wait seconds have passed since
96
+ # @last_poll.
97
+ def rate_limiting(&block)
98
+ return if @last_poll && Time.now - @last_poll <= @wait
99
+ block.call.tap { @last_poll = Time.now }
100
+ end
101
+ end
102
+ end
103
+ end