openc-asana 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.codeclimate.yml +4 -0
- data/.gitignore +13 -0
- data/.rspec +4 -0
- data/.rubocop.yml +11 -0
- data/.travis.yml +12 -0
- data/.yardopts +5 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +21 -0
- data/Guardfile +86 -0
- data/LICENSE.txt +21 -0
- data/README.md +355 -0
- data/Rakefile +65 -0
- data/examples/Gemfile +6 -0
- data/examples/Gemfile.lock +59 -0
- data/examples/api_token.rb +21 -0
- data/examples/cli_app.rb +25 -0
- data/examples/events.rb +38 -0
- data/examples/omniauth_integration.rb +54 -0
- data/lib/asana.rb +12 -0
- data/lib/asana/authentication.rb +8 -0
- data/lib/asana/authentication/oauth2.rb +42 -0
- data/lib/asana/authentication/oauth2/access_token_authentication.rb +51 -0
- data/lib/asana/authentication/oauth2/bearer_token_authentication.rb +32 -0
- data/lib/asana/authentication/oauth2/client.rb +50 -0
- data/lib/asana/authentication/token_authentication.rb +20 -0
- data/lib/asana/client.rb +124 -0
- data/lib/asana/client/configuration.rb +165 -0
- data/lib/asana/errors.rb +92 -0
- data/lib/asana/http_client.rb +155 -0
- data/lib/asana/http_client/environment_info.rb +53 -0
- data/lib/asana/http_client/error_handling.rb +103 -0
- data/lib/asana/http_client/response.rb +32 -0
- data/lib/asana/resource_includes/attachment_uploading.rb +33 -0
- data/lib/asana/resource_includes/collection.rb +68 -0
- data/lib/asana/resource_includes/event.rb +51 -0
- data/lib/asana/resource_includes/event_subscription.rb +14 -0
- data/lib/asana/resource_includes/events.rb +103 -0
- data/lib/asana/resource_includes/registry.rb +63 -0
- data/lib/asana/resource_includes/resource.rb +103 -0
- data/lib/asana/resource_includes/response_helper.rb +14 -0
- data/lib/asana/resources.rb +14 -0
- data/lib/asana/resources/attachment.rb +44 -0
- data/lib/asana/resources/project.rb +154 -0
- data/lib/asana/resources/story.rb +64 -0
- data/lib/asana/resources/tag.rb +120 -0
- data/lib/asana/resources/task.rb +300 -0
- data/lib/asana/resources/team.rb +55 -0
- data/lib/asana/resources/user.rb +72 -0
- data/lib/asana/resources/workspace.rb +91 -0
- data/lib/asana/ruby2_0_0_compatibility.rb +3 -0
- data/lib/asana/version.rb +5 -0
- data/lib/templates/index.js +8 -0
- data/lib/templates/resource.ejs +225 -0
- data/openc-asana.gemspec +32 -0
- data/package.json +7 -0
- 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
|