diff --git a/README.md b/README.md index 1ee97b8..f2bce41 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,21 @@ api = Kintone::Api.new("example.cybozu.com", "Administrator", "cybozu") # Use token authentication api = Kintone::Api.new("example.cybozu.com", "authtoken") + +# Use OAuth authentication +api = Kintone::OAuthApi.new("example.cybozu.com", "access_token") +# if set oauth options below, you can refresh the access_token. +oauth_options = { + client_id: 'client_id', + client_secret: 'client_secret', + refresh_token: 'refresh_token', + expires_at: 1599921045 +} +api = Kintone::OAuthApi.new("example.cybozu.com", "access_token", oauth_options) +# get new token. +api.refresh! +api.access_token.token +# => "new_access_token" ``` ### Supported API diff --git a/kintone.gemspec b/kintone.gemspec index a862a66..bb44ba4 100644 --- a/kintone.gemspec +++ b/kintone.gemspec @@ -18,6 +18,7 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] + spec.add_runtime_dependency 'oauth2', '>=1.4.4' spec.add_runtime_dependency 'faraday', '>=0.9.2' spec.add_runtime_dependency 'faraday_middleware', '>=0.10.0' diff --git a/lib/kintone.rb b/lib/kintone.rb index 8c0f3fa..b1db285 100644 --- a/lib/kintone.rb +++ b/lib/kintone.rb @@ -1,5 +1,6 @@ require 'kintone/version' require 'kintone/api' +require 'kintone/oauth_api' require 'kintone/type' module Kintone diff --git a/lib/kintone/oauth_api.rb b/lib/kintone/oauth_api.rb new file mode 100644 index 0000000..ec560b2 --- /dev/null +++ b/lib/kintone/oauth_api.rb @@ -0,0 +1,102 @@ +require 'oauth2' + +class Kintone::OAuthApi < Kintone::Api + # @return [OAuth2::AccessToken] + attr_reader :access_token + + # @param [String] domain the kintone's domain (e.g. 'foobar.cybozu.com'). + # @param [String] token the Access Token value. + # @param [Hash] opts the options to create the Access Token with. + # @option opts [String] :client_id (nil) the client_id value. + # @option opts [String] :client_secret (nil) the client_secret value. + # @option opts [String] :refresh_token (nil) the refresh_token value. + # @option opts [FixNum, String] :expires_at (nil) the epoch time in seconds in which AccessToken will expire. + # @yield [builder] The Faraday connection builder + def initialize(domain, token, opts = {}, &block) + client_options = { + site: "https://#{domain}", + token_url: '/oauth2/token', + authorize_url: '/oauth2/authorization', + connection_build: connection_builder(&block) + } + client = ::OAuth2::Client.new(opts[:client_id], opts[:client_secret], client_options) + @access_token = ::OAuth2::AccessToken.new(client, token, refresh_token: opts[:refresh_token], expires_at: opts[:expires_at]) + end + + def refresh! + @access_token = @access_token.refresh! + end + + def get(url, params = {}) + opts = request_options(params: params, headers: nil) + request(:get, url, opts) + end + + def post(url, body) + json_body = body.to_json if body.respond_to?(:to_json) + opts = request_options(body: json_body) + request(:post, url, opts) + end + + def put(url, body) + json_body = body.to_json if body.respond_to?(:to_json) + opts = request_options(body: json_body) + request(:put, url, opts) + end + + def delete(url, body = nil) + json_body = body.to_json if body.respond_to?(:to_json) + opts = request_options(body: json_body) + request(:delete, url, opts) + end + + def post_file(url, path, content_type, original_filename) + body = { file: Faraday::UploadIO.new(path, content_type, original_filename) } + headers = { 'Content-Type' => 'multipart/form-data' } + opts = request_options(body: body, headers: headers) + res = request(:post, url, opts) + res['fileKey'] + end + + private + + def connection_builder(&block) + lambda { |con| + con.request :url_encoded + con.request :multipart + # NOTE: comment out for avoiding following bug at OAuth2 v1.4.4. + # In 2.x the bug will be fixed. + # refer to https://github.com/oauth-xx/oauth2/pull/380 + # con.response :json, content_type: /\bjson$/ + block.call(con) if block_given? + } + end + + def request(verb, url, opts) + response = @access_token.request(verb, url, opts) + validate_response(response) + rescue OAuth2::Error => e + response = e.response + raise Kintone::KintoneError.new(response.body, response.status) + end + + def validate_response(response, expected_status = 200) + if response.status != expected_status + raise Kintone::KintoneError.new(response.body, response.status) + end + + JSON.parse(response.body) + end + + def request_options(params: nil, body: nil, headers: default_headers) + opts = {} + opts[:headers] = headers + opts[:params] = params if params + opts[:body] = body if body + opts + end + + def default_headers + { 'Content-Type' => 'application/json' } + end +end diff --git a/spec/kintone/oauth_api_spec.rb b/spec/kintone/oauth_api_spec.rb new file mode 100644 index 0000000..9b0c338 --- /dev/null +++ b/spec/kintone/oauth_api_spec.rb @@ -0,0 +1,191 @@ +require 'spec_helper' + +describe Kintone::OAuthApi do + let(:domain) { 'www.example.com' } + let(:path) { '/k/v1/path' } + let(:token) { 'access_token' } + let(:request_verb) { nil } + let(:default_request_headers) { { 'Authorization' => "Bearer #{token}" } } + let(:option_request_headers) { nil } + let(:params) { nil } + let(:body) { nil } + let(:response_body) { nil } + let(:response_status) { 200 } + + let(:add_stub_request) do + url = "https://#{domain}#{path}" + url += "?#{URI.encode_www_form(params)}" if params && !params.empty? + + stub_request(request_verb, url) \ + .with do |req| + req.body = body.to_json if body + headers = default_request_headers + headers.merge! option_request_headers if option_request_headers + req.headers = headers + end + .to_return( + body: response_body.to_json, + status: response_status, + headers: { 'Content-type' => 'application/json' } + ) + end + + context 'specify token argument only' do + let(:target) { Kintone::OAuthApi.new(domain, token) } + + describe '#get' do + before(:each) do + add_stub_request + end + let(:request_verb) { :get } + let(:response_body) { { abc: 'def' } } + let(:response_status) { 200 } + subject { target.get(path, params) } + + context 'with some params' do + let(:params) { { 'p1' => 'abc', 'p2' => 'def' } } + it { is_expected.to eq 'abc' => 'def' } + end + + context 'with empty params' do + let(:params) { {} } + it { is_expected.to eq 'abc' => 'def' } + end + + context 'with nil params' do + let(:params) { nil } + it { is_expected.to eq 'abc' => 'def' } + end + + context 'fail to request when unexpected response status returns' do + let(:response_status) { 201 } + before(:each) do + add_stub_request + end + it { expect { subject }.to raise_error Kintone::KintoneError } + end + + context 'fail to request' do + let(:response_status) { 500 } + let(:response_body) { { message: '不正なJSON文字列です。', id: '1505999166-897850006', code: 'CB_IJ01' } } + before(:each) do + add_stub_request + end + it { expect { subject }.to raise_error Kintone::KintoneError } + end + end + + describe '#post' do + before(:each) do + add_stub_request + end + let(:request_verb) { :post } + let(:response_status) { 200 } + let(:response_body) { { abc: 'def' } } + let(:params) { nil } + let(:body) { { p1: 'abc', p2: 'def' } } + let(:option_headers) { { 'Content-Type' => 'application/json' } } + subject { target.post(path, body) } + + it { is_expected.to eq 'abc' => 'def' } + end + + describe '#put' do + before(:each) do + add_stub_request + end + let(:request_verb) { :put } + let(:response_status) { 200 } + let(:response_body) { { abc: 'def' } } + let(:params) { nil } + let(:body) { { p1: 'abc', p2: 'def' } } + let(:option_headers) { { 'Content-Type' => 'application/json' } } + subject { target.put(path, body) } + + it { is_expected.to eq 'abc' => 'def' } + end + + describe '#delete' do + before(:each) do + add_stub_request + end + let(:request_verb) { :delete } + let(:response_status) { 200 } + let(:response_body) { { abc: 'def' } } + let(:params) { nil } + let(:body) { { p1: 'abc', p2: 'def' } } + let(:option_headers) { { 'Content-Type' => 'application/json' } } + subject { target.delete(path, body) } + + it { is_expected.to eq 'abc' => 'def' } + end + + describe '#post_file' do + before(:each) do + add_stub_request + expect(Faraday::UploadIO).to receive(:new).with(file_path, content_type, original_filename).and_return(attachment) + end + + subject { target.post_file(path, file_path, content_type, original_filename) } + let(:file_path) { '/path/to/file.txt' } + let(:content_type) { 'text/plain' } + let(:original_filename) { 'fileName.txt' } + let(:attachment) { double('attachment') } + let(:request_verb) { :post } + let(:response_status) { 200 } + let(:response_body) { { fileKey: 'abc' } } + let(:option_headers) { { 'Content-Type' => %r{multipart/form-data} } } + + it { is_expected.to eq 'abc' } + end + + describe '#refresh!' do + subject { target.refresh! } + it { expect { subject }.to raise_error RuntimeError } + end + end + + context 'specify token and oauth_options arguments' do + let(:target) { Kintone::OAuthApi.new(domain, token, oauth_options) } + let(:oauth_options) do + { + client_id: 'client_id', + client_secret: 'client_secret', + refresh_token: 'refresh_token', + expires_at: 1_598_886_000 + } + end + let(:access_token_response_body) do + { + access_token: 'new_access_token', + token_type: 'bearer', + expires_in: 3600, + scope: 'k:app_record:read k:app_record:write k:app_settings:read k:app_settings:write k:file:read k:file:write' + } + end + describe '#refresh!' do + before(:each) do + url = 'https://www.example.com/oauth2/token' + stub_request(:post, url)\ + .with( + body: { + client_id: oauth_options[:client_id], + client_secret: oauth_options[:client_secret], + grant_type: 'refresh_token', + refresh_token: oauth_options[:refresh_token] + }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + ) + .to_return( + body: access_token_response_body.to_json, + status: 200, + headers: { 'Content-type' => 'application/json' } + ) + end + subject { target.refresh!.token } + it { is_expected.to eq 'new_access_token' } + end + end +end