client cleanup. add user endpoint. initial specs.

This commit is contained in:
ZippyDev 2020-07-26 15:31:22 -06:00
parent 938dc32747
commit 3a9d9cf6c7
17 changed files with 745 additions and 15 deletions

View File

@ -2,7 +2,9 @@ PATH
remote: .
specs:
btcpay (0.1.0)
flexirest (~> 1.9, < 2.0)
activesupport (> 5)
multi_json (~> 1.15)
rest-client (~> 2.1)
GEM
remote: https://rubygems.org/
@ -22,16 +24,15 @@ GEM
safe_yaml (~> 1.0.0)
diff-lcs (1.4.4)
docile (1.3.2)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
flexirest (1.9.16)
activesupport
crack
faraday
mime-types
multi_json
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
factory_bot (6.1.0)
activesupport (>= 5.0.0)
hashdiff (1.0.1)
i18n (1.8.3)
http-accept (1.7.0)
http-cookie (1.0.3)
domain_name (~> 0.5)
i18n (1.8.5)
concurrent-ruby (~> 1.0)
method_source (1.0.0)
mime-types (3.3.1)
@ -39,7 +40,7 @@ GEM
mime-types-data (3.2020.0512)
minitest (5.14.1)
multi_json (1.15.0)
multipart-post (2.1.1)
netrc (0.11.0)
parallel (1.19.2)
parser (2.7.1.4)
ast (~> 2.4.1)
@ -50,6 +51,11 @@ GEM
rainbow (3.0.0)
rake (12.3.3)
regexp_parser (1.7.1)
rest-client (2.1.0)
http-accept (>= 1.7.0, < 2.0)
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
rexml (3.2.4)
rspec (3.9.0)
rspec-core (~> 3.9.0)
@ -84,6 +90,9 @@ GEM
thread_safe (0.3.6)
tzinfo (1.2.7)
thread_safe (~> 0.1)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.7)
unicode-display_width (1.7.0)
vcr (6.0.0)
webmock (3.8.3)
@ -98,6 +107,7 @@ PLATFORMS
DEPENDENCIES
btcpay!
bundler (~> 2.0)
factory_bot (~> 6.1)
pry (> 0)
rake (~> 12.0)
rspec (~> 3.0)

View File

@ -22,7 +22,58 @@ Or install it yourself as:
## Usage
TODO: Write usage instructions here
#### Required Params
##### Auth Token
At least one of the following auth tokens are required. Auth tokens can be created via the following:
1. `auth_token`
- Scoped Api Tokens can be created via `/Manage/APIKeys`
- `BTCPAY_AUTH_TOKEN` environment variable can also be used
1. `basic_auth_token`
- Legacy Api Key can be created per store via `/stores/{store-id}/Tokens`
- `BTCPAY_BASIC_AUTH_TOKEN` environment variable can also be used
##### Base Url
A `base_url` is required to interact with the server.
- `BTCPAY_BASE_URL` environment variable can also be used
```ruby
client = BtcPay.new(auth_token: 'foobar', base_url: 'http://localhost:49392')
```
### Response
A response consists of the following accessible attributes:
1. `#body` - rubified response body
1. `#code` - response status code
1. `#headers` - response headers
1. `#raw` - unaltered response body
1. `#status` - `:success`/`:failure`
### Request object types
All endpoints are accessed via namespaced Api resource. Example: `client.users.create({ email: 'foo@bar.com', password: 'password', isAdministrator: false })`
#### Users:
1. `#me(**opts)`
- alias: `get`, `show`
1. `#create(payload, **opts)`
### Environment Variables
`btcpay` can be initialized with either arguments or ENV:
| Variable | Description | Default |
| --------------------------|:------------------------|:--------:|
| `BTCPAY_AUTH_TOKEN` | BtcPay Auth Token | - |
| `BTCPAY_BASIC_AUTH_TOKEN` | BtcPay Basic Auth Token | - |
| `BTCPAY_BASE_URL` | BtcPay Base Url | - |
### BtcPay Docker Compose
@ -42,7 +93,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/btcpay.
Bug reports and pull requests are welcome on Gitlab at https://gitlab.com/snogrammer/btcpay.
## License

View File

@ -28,9 +28,12 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
spec.require_paths = ['lib']
spec.add_dependency 'flexirest', '~> 1.9', '< 2.0'
spec.add_dependency 'activesupport', '> 5'
spec.add_dependency 'multi_json', '~> 1.15'
spec.add_dependency 'rest-client', '~> 2.1'
spec.add_development_dependency 'bundler', '~> 2.0'
spec.add_development_dependency 'factory_bot', '~> 6.1'
spec.add_development_dependency 'pry', '> 0'
spec.add_development_dependency 'rubocop', '> 0'
spec.add_development_dependency 'simplecov', '> 0'

View File

@ -2,7 +2,16 @@
require 'btcpay/version'
require 'btcpay/client/config'
require 'btcpay/client/base'
module BtcPay
class Error < StandardError; end
# Your code goes here...
module_function
def new(**args)
config = BtcPay::Client::Config.new(**args)
BtcPay::Client::Base.new(config: config, **args)
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
module BtcPay
module Client
module Api
class Base
def initialize(client:, logger:)
@client = client
@logger = logger
end
protected
def base_path
raise NotImplementedError.new
end
def path(*args)
request_path = args.prepend(base_path.presence).compact.join('/')
request_path[0].eql?('/') ? request_path : '/' + request_path
end
private
attr_reader :client, :logger
end
end
end
end
require_relative './users'

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
module BtcPay
module Client
module Api
class Users < Base
PATH = '/users'
# @see https://docs.btcpayserver.org/API/Greenfield/v1/#operation/Users_GetCurrentUser
def me(**opts)
client.get(path('me'), options: opts)
end
alias get me
alias show me
# @see https://docs.btcpayserver.org/API/Greenfield/v1/#tag/Users/paths/~1api~1v1~1users/post
def create(payload, **opts)
client.post(path, payload: payload, options: opts)
end
protected
def base_path
PATH
end
end
end
end
end

129
lib/btcpay/client/base.rb Normal file
View File

@ -0,0 +1,129 @@
# frozen_string_literal: true
require 'active_support'
require 'active_support/core_ext/object'
require 'rest_client'
require_relative './result'
require_relative './api/base'
module BtcPay
module Client
class Error < RuntimeError
def initialize(message)
super(message)
end
end
class Base
API_PATH = '/api/v1'
DEFAULT_TIMEOUT = 10
# @param config [BtcPay::Client::Config]
# @param logger [Logger]
# @param timeout [Integer] Defaults to DEFAULT_TIMEOUT
def initialize(config:, logger: Logger.new(STDOUT), timeout: DEFAULT_TIMEOUT, **_kwargs)
@config = config
@logger = logger
@timeout = timeout
end
# GET request
#
# @param uri [String]
# @param options [Hash]
# @param headers [Hash]
# @return [Result]
def get(uri, options: {}, headers: {})
request(uri, method: :get, options: options, headers: headers)
end
# POST request
#
# @param uri [String]
# @param payload [Hash]
# @param options [Hash]
# @param headers [Hash]
# @return [Result]
def post(uri, payload:, options: {}, headers: {})
data = payload.is_a?(Hash) ? payload.to_json : payload
request(uri, method: :post, payload: data, options: options, headers: headers)
end
def users
@users ||= Api::Users.new(client: self, logger: logger)
end
private
attr_reader :config, :logger
# @return [Hash]
def default_headers
{
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'User-Agent' => config.user_agent,
'Accept-Encoding' => 'deflate, gzip',
'Authorization' => config.authorization
}
end
# @yield [Result]
def request(uri, method:, payload: nil, options: {}, headers: {})
options ||= {}
headers ||= {}
url = URI(config.base_url)
url.path = API_PATH + uri
url.query = CGI.unescape(options.to_query).presence
params = {
method: method,
url: url.to_s,
payload: payload,
headers: default_headers.merge(headers),
open_timeout: open_timeout,
timeout: timeout
}.compact
response = RestClient::Request.execute(params)
logger.debug(message: 'GET Request', url: url, options: options, status: response.code)
result = success?(response.code) ? Result.success(response) : Result.failed(response)
logger.warn(error: 'Request Error', code: result.code, body: result.raw) if result.failure?
result
rescue ::RestClient::GatewayTimeout
raise Error.new('Gateway timeout')
rescue ::RestClient::RequestTimeout
raise Error.new('Request timeout')
rescue ::RestClient::Exception => e
handle_error(e)
end
# Handle errors
# @param error [Error]
# @return [Result]
def handle_error(error)
logger.error(error: 'Request Exception', code: error.response.code, message: error.message)
Result.failed(response)
end
# @return [Integer]
def open_timeout
@open_timeout || DEFAULT_TIMEOUT
end
# @return [Integer]
def timeout
@timeout || DEFAULT_TIMEOUT
end
def success?(response_code)
response_code.in?(200..299)
end
end
end
end

View File

@ -0,0 +1,54 @@
# frozen_string_literal: true
module BtcPay
module Client
class Config
AUTH_TOKEN_TYPE = 'token'
BASIC_TOKEN_TYPE = 'Basic'
attr_reader :authorization, :auth_token, :basic_auth_token, :base_url, :user_agent
def initialize(**kwargs)
@base_url = load_url(kwargs[:base_url])
@user_agent = kwargs[:user_agent] || "btcpay_ruby/#{BtcPay::VERSION}"
load_auth_token(kwargs)
set_authorization
end
def to_h
{
auth_token: auth_token,
basic_auth_token: basic_auth_token,
base_url: base_url,
user_agent: user_agent
}.compact
end
private
def load_url(url)
base_url = url || ENV['BTCPAY_BASE_URL'].to_s
uri = URI(base_url)
return uri.to_s if uri.scheme && uri.host
raise ArgumentError.new('invalid base_url')
end
def load_auth_token(kwargs)
@auth_token = kwargs[:auth_token] || ENV['BTCPAY_AUTH_TOKEN']
@basic_auth_token = kwargs[:basic_auth_token] || ENV['BTCPAY_BASIC_AUTH_TOKEN']
raise ArgumentError.new('auth_token or basic_auth_token required') unless @auth_token || @basic_auth_token
end
def set_authorization
@authorization = if @auth_token
"#{AUTH_TOKEN_TYPE} #{@auth_token}"
elsif @basic_auth_token
"#{BASIC_TOKEN_TYPE} #{@basic_auth_token}"
end
end
end
end
end

View File

@ -0,0 +1,76 @@
# frozen_string_literal: true
require 'active_support/core_ext/hash/indifferent_access'
require 'multi_json'
module BtcPay
module Client
##
# Status object to capture result from an HTTP request
#
# Gives callers context of the result and allows them to
# implement successful strategies to handle success/failure
class Result
def self.success(response)
new(:success, response)
end
def self.failed(response)
new(:failed, response)
end
attr_reader :body, :code, :headers, :raw, :status
def initialize(status, response)
@raw = raw_parse(response.body)
@body = rubify_body
@code = response.code
@headers = response.headers # e.g. "Content-Type" will become :content_type.
@status = status
end
def success?
status == :success
end
def failure?
!success?
end
def to_h
{
status: status,
headers: headers,
code: code,
body: body
}
end
alias to_hash to_h
private
def method_missing(method, *args, &blk)
to_h.send(method, *args, &blk) || super
end
def respond_to_missing?(method, include_private = false)
to_h.respond_to?(method) || super
end
# @param body [JSON] Raw JSON body
def raw_parse(body)
MultiJson.load(body).with_indifferent_access
rescue StandardError => e
raise ResponseBodyParseError.new(error: 'JSON parse error', message: e.message, body: body)
end
def rubify_body
raw.deep_transform_keys { |key| key.to_s.underscore }.with_indifferent_access
end
class ResponseBodyParseError < StandardError; end
end
end
end

View File

@ -4,4 +4,11 @@ RSpec.describe BtcPay do
it 'has a version number' do
expect(BtcPay::VERSION).to eq('0.1.0')
end
describe '.new' do
it do
expect(described_class.new(auth_token: '123', base_url: 'http://localhost'))
.to be_a(BtcPay::Client::Base)
end
end
end

140
spec/client/base_spec.rb Normal file
View File

@ -0,0 +1,140 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BtcPay::Client::Base do
let(:config) { create(:config) }
let(:subject) { described_class.new(config: config) }
describe '.configuration' do
it 'returns host' do
expect(subject.host).to eq(host)
end
it 'returns api_key' do
expect(subject.api_key).to eq(api_key)
end
it 'returns sandbox' do
expect(subject.sandbox).to eq(false)
end
# skipped due to logger being mocked
xit 'returns default logger' do
expect(subject.logger).to be_a(Logger)
end
it 'raises error when api_key missing' do
expect { described_class.new }.to raise_error(KeyError, 'key not found: "CMC_PRO_API_KEY"')
end
context 'host' do
it 'defaults to production environment' do
subject = described_class.new(api_key: api_key)
expect(subject.sandbox).to eq(false)
expect(subject.host).to eq('https://pro-api.coinmarketcap.com/v1')
end
it 'defaults to production environment' do
subject = described_class.new(api_key: api_key, sandbox: true)
expect(subject.sandbox).to eq(true)
expect(subject.host).to eq('https://sandbox.coinmarketcap.com/v1')
end
end
end
describe 'services' do
it '.cryptocurrency' do
expect(subject.cryptocurrency).to be_a(CoinMarketPro::Endpoint::Cryptocurrency)
end
it '.exchange' do
expect(subject.exchange).to be_a(CoinMarketPro::Endpoint::Exchange)
end
it '.global_metrics' do
expect(subject.global_metrics).to be_a(CoinMarketPro::Endpoint::GlobalMetrics)
end
it '.tools' do
expect(subject.tools).to be_a(CoinMarketPro::Endpoint::Tools)
end
end
describe '.default_headers' do
it do
expect(subject.default_headers).to eq(
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'User-Agent' => "coin_market_pro/#{CoinMarketPro::VERSION}",
'Accept-Encoding' => 'deflate, gzip',
'X-CMC_PRO_API_KEY' => api_key
)
end
end
describe '.open_timeout' do
it { expect(subject.open_timeout).to eq(10) }
end
describe '.timeout' do
it { expect(subject.timeout).to eq(10) }
end
describe '.handle_error' do
let(:response) { double('response') }
let(:error) { double('error') }
let(:code) { 400 }
let(:body) do
{
data: { id: 1 },
status: { code: code }
}
end
it 'returns failure status response' do
allow(response).to receive(:headers)
allow(response).to receive(:body).and_return(body.to_json)
expect(response).to receive(:code).at_least(:once).and_return(code)
expect(error).to receive(:message).and_return('message')
expect(error).to receive(:response).and_return(response)
status = subject.handle_error(error)
expect(status.failure?).to eq(true)
end
end
describe '.get' do
let(:response) { double('response') }
let(:body) do
{
data: { id: 1 },
status: { code: 200 }
}
end
before do
allow(RestClient::Request).to receive(:execute).and_return(response)
allow(response).to receive(:body).and_return(body.to_json)
allow(response).to receive(:headers)
end
it 'returns successful status response' do
expect(response).to receive(:code).at_least(:once).and_return(200)
status = subject.get(path)
expect(status.success?).to eq(true)
end
context 'error' do
it 'returns failure status response' do
expect(response).to receive(:code).at_least(:once).and_return(400)
status = subject.get(path)
expect(status.failure?).to eq(true)
end
end
end
end

View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BtcPay::Client::Config do
let(:config) { build(:config) }
describe '#initialize' do
context '#base_url' do
it do
subject = described_class.new(**config.to_h)
expect(subject.base_url).to eq(config.base_url)
end
it { expect { described_class.new(auth_token: 123) }.to raise_error(ArgumentError, 'invalid base_url') }
end
context '#authorization' do
it do
expect { described_class.new(base_url: config.base_url) }
.to raise_error(ArgumentError, 'auth_token or basic_auth_token required')
end
context 'auth_token' do
it do
subject = described_class.new(**config.to_h)
expect(subject.auth_token).to eq(config.auth_token)
expect(subject.authorization).to eq("token #{config.auth_token}")
end
end
context 'basic_auth_token' do
let(:config) { build(:config, :basic_auth_token) }
it do
subject = described_class.new(**config.to_h)
expect(subject.basic_auth_token).to eq(config.basic_auth_token)
expect(subject.authorization).to eq("Basic #{config.basic_auth_token}")
end
end
end
end
describe '#to_h' do
subject { described_class.new(**config.to_h) }
it 'returns hash' do
expect(subject.to_h).to eq({
auth_token: config.auth_token,
basic_auth_token: config.basic_auth_token,
base_url: config.base_url,
user_agent: config.user_agent
}.compact)
end
end
end

View File

@ -0,0 +1,90 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe CoinMarketPro::Client::Result do # rubocop:disable Metrics/BlockLength
let(:response) { double('response') }
let(:code) { double('code') }
let(:body) do
{
data: {
test: 'test'
},
status: {
code: 0
}
}
end
let(:headers) do
{
content_type: 'application/json'
}
end
before(:each) do
allow(response).to receive(:body).and_return(body.to_json)
allow(response).to receive(:code).and_return(code)
allow(response).to receive(:headers).and_return(headers)
end
describe '.success' do
it 'captures response and sets status to success' do
result = described_class.success(response)
expect(result).to be_a(CoinMarketPro::Client::Result)
expect(result.success?).to eq(true)
expect(result.failure?).to eq(false)
expect(result.body).to eq(body[:data])
expect(result.code).to eq(code)
end
end
describe '.failed' do
it 'captures response and sets status to failed' do
result = described_class.failed(response)
expect(result).to be_a(CoinMarketPro::Client::Result)
expect(result.success?).to eq(false)
expect(result.failure?).to eq(true)
expect(result.body).to eq(body[:data])
expect(result.code).to eq(code)
end
end
describe '#success?' do
let(:result) { described_class.success(response) }
it 'returns true if success' do
expect(result.success?).to eq(true)
expect(result.failure?).to eq(false)
end
end
describe '#failure?' do
let(:result) { described_class.failed(response) }
it 'returns true if failed' do
expect(result.failure?).to eq(true)
expect(result.success?).to eq(false)
end
end
describe '#to_h' do
it 'returns a hash of values' do
result = described_class.success(response)
expect(result.to_h).to eq(code: code, headers: headers, body: body[:data], status: body[:status].merge(result: :success))
end
end
describe '#to_hash' do
it 'returns a hash of values' do
result = described_class.success(response)
expect(result.to_hash).to eq(code: code, headers: headers, body: body[:data], status: body[:status].merge(result: :success))
end
it 'can be called implicitly' do
result = described_class.success(response)
expect(result[:code]).to eq code
end
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
FactoryBot.define do
factory :users_payload, class: Hash do
email { 'foo@bar.com' }
password { 'password' }
isAdministrator { false }
end
end

21
spec/factories/config.rb Normal file
View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
FactoryBot.define do
factory :config, class: BtcPay::Client::Config do
base_url { 'http://localhost:49392' }
auth_token # default trait
initialize_with { new(**attributes) }
trait :auth_token do
auth_token { '9133b8ef3ae9a4b7f2d9a6efef1d5cf738067c68' }
basic_auth_token { nil }
end
trait :basic_auth_token do
auth_token { nil }
basic_auth_token { 'TODO' }
end
end
end

View File

@ -7,6 +7,9 @@ require 'bundler/setup'
require 'btcpay'
require 'webmock/rspec'
require 'pry'
Dir[File.join(__dir__, 'support', '**', '*.rb')].sort.each { |f| require f }
RSpec.configure do |config|
# Enable flags like --only-failures and --next-failure

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
require 'factory_bot'
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
config.before(:suite) do
FactoryBot.find_definitions
end
end