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