openc-asana 0.1.2

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