Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

### Unreleased
* Added Policies resource for managing application policies
* Added Rules resource for managing inbox rules and listing rule evaluations
* Added Lists resource support for creating application lists
* Added Workspaces resource for managing workspaces, auto-grouping, manual assignment, `default`, `policy_id`, and `rule_ids`
* Added Domains resource and `ServiceAccountSigner` support for signed Service Account Manage Domains requests, including canonical signed bodies, encoded domain paths, info, and verify operations
* Added Applications update support (PATCH /v3/applications)
* Corrected RedirectUris update verb from PUT to PATCH
* Fixed HTTParty content type issue when request body is nil - POST, PUT, PATCH, and DELETE now default to empty object to ensure Content-Type: application/json is sent (#536)
* Added support for request_body parameter on DELETE (e.g. cancellation_reason for bookings) (#536)

### [6.7.1]
* Fix large attachment handling with string keys and custom content_ids

Expand Down Expand Up @@ -378,4 +389,3 @@
* various test cleanups ([Steven Harman](https://github.com/stevenharman))

[full changelog](https://github.com/nylas/nylas-ruby/compare/v1.0.0...v1.1.0)

6 changes: 6 additions & 0 deletions lib/nylas.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
require_relative "nylas/config"

require_relative "nylas/handler/http_client"
require_relative "nylas/handler/service_account_signer"

require_relative "nylas/resources/applications"
require_relative "nylas/resources/attachments"
Expand All @@ -24,11 +25,16 @@
require_relative "nylas/resources/events"
require_relative "nylas/resources/folders"
require_relative "nylas/resources/grants"
require_relative "nylas/resources/lists"
require_relative "nylas/resources/messages"
require_relative "nylas/resources/notetakers"
require_relative "nylas/resources/smart_compose"
require_relative "nylas/resources/threads"
require_relative "nylas/resources/redirect_uris"
require_relative "nylas/resources/policies"
require_relative "nylas/resources/rules"
require_relative "nylas/resources/workspaces"
require_relative "nylas/resources/domains"
require_relative "nylas/resources/webhooks"
require_relative "nylas/resources/scheduler"

Expand Down
36 changes: 36 additions & 0 deletions lib/nylas/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
require_relative "resources/webhooks"
require_relative "resources/applications"
require_relative "resources/folders"
require_relative "resources/lists"
require_relative "resources/notetakers"
require_relative "resources/scheduler"

Expand Down Expand Up @@ -99,6 +100,13 @@ def grants
Grants.new(self)
end

# The list resources for your Nylas application.
#
# @return [Nylas::Lists] List resources for your Nylas application
def lists
Lists.new(self)
end

# The message resources for your Nylas application.
#
# @return [Nylas::Messages] Message resources for your Nylas application
Expand All @@ -113,6 +121,34 @@ def threads
Threads.new(self)
end

# The policy resources for your Nylas application.
#
# @return [Nylas::Policies] Policy resources for your Nylas application.
def policies
Policies.new(self)
end

# The rule resources for your Nylas application.
#
# @return [Nylas::Rules] Rule resources for your Nylas application.
def rules
Rules.new(self)
end

# The workspace resources for your Nylas application.
#
# @return [Nylas::Workspaces] Workspace resources for your Nylas application.
def workspaces
Workspaces.new(self)
end

# The domain resources for your Nylas application.
#
# @return [Nylas::Domains] Domain resources for your Nylas application.
def domains
Domains.new(self)
end

# The webhook resources for your Nylas application.
#
# @return [Nylas::Webhooks] Webhook resources for your Nylas application.
Expand Down
44 changes: 28 additions & 16 deletions lib/nylas/handler/api_operations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ module Get
#
# @param path [String] Destination path for the call.
# @param query_params [Hash, {}] Query params to pass to the call.
# @param headers [Hash, {}] Additional HTTP headers to include in the payload.
# @return [Array([Hash, Array], String, Hash)] Nylas data object, API Request ID, and response headers.
def get(path:, query_params: {})
response = get_raw(path: path, query_params: query_params)
def get(path:, query_params: {}, headers: {})
response = get_raw(path: path, query_params: query_params, headers: headers)

[response[:data], response[:request_id], response[:headers]]
end
Expand All @@ -26,10 +27,11 @@ def get(path:, query_params: {})
#
# @param path [String] Destination path for the call.
# @param query_params [Hash, {}] Query params to pass to the call.
# @param headers [Hash, {}] Additional HTTP headers to include in the payload.
# @return [Array<Array<Hash>, String, String, Hash>]
# Nylas data array, API Request ID, next cursor, and response headers.response headers.
def get_list(path:, query_params: {})
response = get_raw(path: path, query_params: query_params)
def get_list(path:, query_params: {}, headers: {})
response = get_raw(path: path, query_params: query_params, headers: headers)

[response[:data], response[:request_id], response[:next_cursor], response[:headers]]
end
Expand All @@ -40,16 +42,20 @@ def get_list(path:, query_params: {})
#
# @param path [String] Destination path for the call.
# @param query_params [Hash, {}] Query params to pass to the call.
# @param headers [Hash, {}] Additional HTTP headers to include in the payload.
# @return [Hash] The JSON response from the Nylas API.
def get_raw(path:, query_params: {})
execute(
def get_raw(path:, query_params: {}, headers: {})
request = {
method: :get,
path: path,
query: query_params,
payload: nil,
api_key: api_key,
timeout: timeout
)
}
request[:headers] = headers unless headers.empty?

execute(**request)
end
end

Expand All @@ -65,16 +71,18 @@ module Post
# @param request_body [Hash, nil] Request body to pass to the call.
# @param headers [Hash, {}] Additional HTTP headers to include in the payload.
# @return [Array(Hash, String, Hash)] Nylas data object, API Request ID, and response headers.
def post(path:, query_params: {}, request_body: nil, headers: {})
response = execute(
def post(path:, query_params: {}, request_body: nil, headers: {}, serialized_json_body: nil)
request = {
method: :post,
path: path,
query: query_params,
payload: request_body,
headers: headers,
api_key: api_key,
timeout: timeout
)
}
request[:serialized_json_body] = serialized_json_body unless serialized_json_body.nil?
response = execute(**request)

[response[:data], response[:request_id], response[:headers]]
end
Expand All @@ -92,16 +100,18 @@ module Put
# @param request_body [Hash, nil] Request body to pass to the call.
# @param headers [Hash, {}] Additional HTTP headers to include in the payload.
# @return Nylas data object and API Request ID.
def put(path:, query_params: {}, request_body: nil, headers: {})
response = execute(
def put(path:, query_params: {}, request_body: nil, headers: {}, serialized_json_body: nil)
request = {
method: :put,
path: path,
query: query_params,
payload: request_body,
headers: headers,
api_key: api_key,
timeout: timeout
)
}
request[:serialized_json_body] = serialized_json_body unless serialized_json_body.nil?
response = execute(**request)

[response[:data], response[:request_id]]
end
Expand All @@ -119,16 +129,18 @@ module Patch
# @param request_body [Hash, nil] Request body to pass to the call.
# @param headers [Hash, {}] Additional HTTP headers to include in the payload.
# @return Nylas data object and API Request ID.
def patch(path:, query_params: {}, request_body: nil, headers: {})
response = execute(
def patch(path:, query_params: {}, request_body: nil, headers: {}, serialized_json_body: nil)
request = {
method: :patch,
path: path,
query: query_params,
payload: request_body,
headers: headers,
api_key: api_key,
timeout: timeout
)
}
request[:serialized_json_body] = serialized_json_body unless serialized_json_body.nil?
response = execute(**request)

[response[:data], response[:request_id]]
end
Expand Down
18 changes: 14 additions & 4 deletions lib/nylas/handler/http_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,14 @@ module HttpClient
# @param query [Hash, {}] Hash of names and values to include in the query section of the URI
# fragment.
# @param payload [Hash, nil] Body to send with the request.
# @param serialized_json_body [String, nil] Pre-serialized JSON body to send as-is.
# @param api_key [Hash, nil] API key to send with the request.
# @return [Object] Parsed JSON response from the API.
def execute(method:, path:, timeout:, headers: {}, query: {}, payload: nil, api_key: nil)
def execute(method:, path:, timeout:, headers: {}, query: {}, payload: nil, api_key: nil,
serialized_json_body: nil)
request = build_request(method: method, path: path, headers: headers,
query: query, payload: payload, api_key: api_key, timeout: timeout)
query: query, payload: payload, api_key: api_key, timeout: timeout,
serialized_json_body: serialized_json_body)
begin
httparty_execute(**request) do |response, _request, result|
content_type = nil
Expand Down Expand Up @@ -91,20 +94,25 @@ def download_request(path:, timeout:, headers: {}, query: {}, api_key: nil, &blo
# @param query [Hash, {}] Hash of names and values to include in the query section of the URI
# fragment.
# @param payload [Hash, nil] Body to send with the request.
# @param serialized_json_body [String, nil] Pre-serialized JSON body to send as-is.
# @param timeout [Integer, nil] Timeout value to send with the request.
# @param api_key [Hash, nil] API key to send with the request.
# @return [Object] The request information after processing. This includes an updated payload
# and headers.
def build_request(
method:, path: nil, headers: {}, query: {}, payload: nil, timeout: nil, api_key: nil
method:, path: nil, headers: {}, query: {}, payload: nil, timeout: nil, api_key: nil,
serialized_json_body: nil
)
url = build_url(path, query)
resulting_headers = default_headers.merge(headers).merge(auth_header(api_key))

# Check for multipart flag using both string and symbol keys for backwards compatibility
is_multipart = !payload.nil? && (payload["multipart"] || payload[:multipart])

if !payload.nil? && !is_multipart
if !serialized_json_body.nil?
payload = serialized_json_body
resulting_headers["Content-type"] = "application/json"
elsif !payload.nil? && !is_multipart
normalize_json_encodings!(payload)
payload = payload&.to_json
resulting_headers["Content-type"] = "application/json"
Expand Down Expand Up @@ -489,6 +497,8 @@ def build_query(uri, query)

# Set the authorization header for an API query.
def auth_header(api_key)
return {} if api_key.nil?

{ "Authorization" => "Bearer #{api_key}" }
end
end
Expand Down
112 changes: 112 additions & 0 deletions lib/nylas/handler/service_account_signer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# frozen_string_literal: true

require "base64"
require "json"
require "openssl"
require "securerandom"

module Nylas
# Builds Nylas Service Account request signing headers for organization admin APIs.
#
# @see https://developer.nylas.com/docs/v3/auth/nylas-service-account/
class ServiceAccountSigner
NONCE_ALPHABET = ("a".."z").to_a.concat(("A".."Z").to_a, ("0".."9").to_a).freeze
DEFAULT_NONCE_LENGTH = 20
SIGNED_BODY_METHODS = %w[post put patch].freeze

attr_reader :private_key_id

# @param private_key_pem [String] RSA private key in PEM format.
# @param private_key_id [String] Value for the X-Nylas-Kid header.
def initialize(private_key_pem:, private_key_id:)
@private_key = self.class.load_rsa_private_key(private_key_pem)
@private_key_id = private_key_id
end

# Returns deterministic JSON with keys sorted at every object level and no extra whitespace.
#
# @param data [Hash, Array, String, Numeric, true, false, nil] Data to serialize.
# @return [String] Canonical JSON string.
def self.canonical_json(data)
JSON.generate(canonicalize(data))
end

# Loads an RSA private key from a PEM string.
#
# @param private_key_pem [String] RSA private key in PEM format.
# @return [OpenSSL::PKey::RSA]
def self.load_rsa_private_key(private_key_pem)
key = OpenSSL::PKey::RSA.new(private_key_pem)
raise ArgumentError, "Private key must be RSA private key" unless key.private?
raise ArgumentError, "Private key must be at least 2048 bits" if key.n.num_bits < 2048

key
rescue OpenSSL::PKey::PKeyError
raise ArgumentError, "Private key must be RSA PEM"
end

# Generates a cryptographically secure alphanumeric nonce.
#
# @param length [Integer] Length of the nonce to generate.
# @return [String] Generated nonce.
def self.generate_nonce(length = DEFAULT_NONCE_LENGTH)
Array.new(length) { NONCE_ALPHABET[SecureRandom.random_number(NONCE_ALPHABET.length)] }.join
end

# Builds signed headers and, for JSON body methods, the exact canonical body to send.
#
# @param method [String, Symbol] HTTP method.
# @param path [String] Relative request path, for example "/v3/admin/domains".
# @param body [Hash, nil] Request body for POST/PUT/PATCH requests.
# @param timestamp [Integer, nil] Optional Unix timestamp in seconds, mainly for tests.
# @param nonce [String, nil] Optional nonce, mainly for tests.
# @return [Array(Hash, String)] Signed headers and optional serialized JSON body.
def build_headers(method:, path:, body: nil, timestamp: nil, nonce: nil)
timestamp ||= Time.now.to_i
nonce ||= self.class.generate_nonce
method_value = method.to_s.downcase
serialized_body = nil

if SIGNED_BODY_METHODS.include?(method_value) && !body.nil?
serialized_body = self.class.canonical_json(body)
end

envelope = {
method: method_value,
nonce: nonce,
path: path,
timestamp: timestamp
}
envelope[:payload] = serialized_body if serialized_body

signature = @private_key.sign(OpenSSL::Digest.new("SHA256"), self.class.canonical_json(envelope))

[
{
"X-Nylas-Kid" => private_key_id,
"X-Nylas-Nonce" => nonce,
"X-Nylas-Timestamp" => timestamp.to_s,
"X-Nylas-Signature" => Base64.strict_encode64(signature)
},
serialized_body
]
end

class << self
private

def canonicalize(value)
case value
when Hash
value.keys.sort_by(&:to_s).each_with_object({}) do |key, result|
result[key.to_s] = canonicalize(value[key])
end
when Array
value.map { |item| canonicalize(item) }
else
value
end
end
end
end
end
Loading
Loading