diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91b98640..48c7aa54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,6 +73,31 @@ jobs: working-directory: clients/js env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + publish-ruby-client: + runs-on: ubuntu-latest + needs: create-release + permissions: + packages: write + contents: read + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby 2.6 + uses: ruby/setup-ruby@477b21f02be01bcb8030d50f37cfec92bfa615b6 + with: + ruby-version: 2.6 + - run: bundle install + working-directory: clients/ruby + - name: Publish to RubyGems + run: | + mkdir -p $HOME/.gem + touch $HOME/.gem/credentials + chmod 0600 $HOME/.gem/credentials + printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials + gem build *.gemspec + gem push *.gem + env: + GEM_HOST_API_KEY: "${{secrets.RUBYGEMS_AUTH_TOKEN}}" + working-directory: clients/ruby publish-server: runs-on: ubuntu-latest needs: create-release diff --git a/.github/workflows/ruby-client-test.yaml b/.github/workflows/ruby-client-test.yaml new file mode 100644 index 00000000..9cd336a9 --- /dev/null +++ b/.github/workflows/ruby-client-test.yaml @@ -0,0 +1,21 @@ +name: Test stencil RUBY client +on: + push: + paths: + - "clients/ruby/**" + branches: + - master + pull_request: + paths: + - "clients/ruby/**" + branches: + - master +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-ruby@v1 + with: + ruby-version: '2.5' + - run: cd clients/ruby; bin/setup; rspec -fd diff --git a/clients/README.md b/clients/README.md index c2afffe0..fd8e90d5 100644 --- a/clients/README.md +++ b/clients/README.md @@ -20,3 +20,4 @@ Stencil clients abstracts handling of descriptorset file on client side. Current - [Java](java) - [Go](go) - [Javascript](js) + - [Ruby](ruby) diff --git a/clients/ruby/.gitignore b/clients/ruby/.gitignore new file mode 100644 index 00000000..72c4ecbc --- /dev/null +++ b/clients/ruby/.gitignore @@ -0,0 +1,16 @@ +.mine +*.gem +.config +.rvmrc +.yardoc +InstalledFiles +_yardoc + +.bundle +.ruby-version +doc +coverage +pkg +spec/examples.txt +tmp +Gemfile.lock \ No newline at end of file diff --git a/clients/ruby/.rspec b/clients/ruby/.rspec new file mode 100644 index 00000000..584d9ceb --- /dev/null +++ b/clients/ruby/.rspec @@ -0,0 +1,5 @@ +--backtrace +--color +--format=documentation +--order random +--require spec_helper \ No newline at end of file diff --git a/clients/ruby/Gemfile b/clients/ruby/Gemfile new file mode 100644 index 00000000..b30038a9 --- /dev/null +++ b/clients/ruby/Gemfile @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in stencil.gemspec +gemspec + +gem "rake", "~> 13.0" +gem "http", "4.4.1" +gem 'concurrent-ruby', require: 'concurrent' +gem 'protobuf' + +group :test do + gem "simplecov", ">= 0.9" + gem "rspec", "~> 3.0" + gem "rubocop", "~> 1.7" + gem "pry" + gem "google-protobuf" +end diff --git a/clients/ruby/README.md b/clients/ruby/README.md new file mode 100644 index 00000000..b75d3df0 --- /dev/null +++ b/clients/ruby/README.md @@ -0,0 +1,26 @@ +# Stencil + +Stencil ruby gem provides a store to lookup protobuf descriptors and options to keep the protobuf descriptors upto date. + +## Installation + +```ruby +gem 'stencil' +``` + +And then execute: + + $ bundle install + +Or install it yourself as: + + $ gem install stencil + +## Usage + + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). diff --git a/clients/ruby/Rakefile b/clients/ruby/Rakefile new file mode 100644 index 00000000..19241434 --- /dev/null +++ b/clients/ruby/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rubocop/rake_task" + +RuboCop::RakeTask.new + +task default: :rubocop diff --git a/clients/ruby/bin/console b/clients/ruby/bin/console new file mode 100755 index 00000000..13d1e32e --- /dev/null +++ b/clients/ruby/bin/console @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "stencil" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/clients/ruby/bin/setup b/clients/ruby/bin/setup new file mode 100755 index 00000000..ed41db96 --- /dev/null +++ b/clients/ruby/bin/setup @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +gem install bundler:1.17.3 +bundle install + +# Do any other automated setup that you need to do here diff --git a/clients/ruby/lib/stencil.rb b/clients/ruby/lib/stencil.rb new file mode 100644 index 00000000..600d36a6 --- /dev/null +++ b/clients/ruby/lib/stencil.rb @@ -0,0 +1,18 @@ +require_relative "stencil/version" +require_relative "stencil/configuration" +require_relative "stencil/constants" +require_relative "stencil/client" +require_relative "stencil/store" + +require "http" +require "concurrent/timer_task" +require "concurrent/mutable_struct" +require "protobuf" + +module Stencil + class Error < StandardError; end + class InvalidConfiguration < Error; end + class InvalidProtoClass < Error; end + class HTTPClientError < Error; end + class HTTPServerError < Error; end +end diff --git a/clients/ruby/lib/stencil/client.rb b/clients/ruby/lib/stencil/client.rb new file mode 100644 index 00000000..b4378dbe --- /dev/null +++ b/clients/ruby/lib/stencil/client.rb @@ -0,0 +1,65 @@ +module Stencil + class Client + attr_reader :root + def initialize + begin + @config = Stencil.configuration + validate_configuration(@config) + + setup_http_client + + @store = Store.new + load_descriptors + setup_store_update_job + end + end + + def get_type(proto_name) + file_descriptor_set = @store.read(@config.registry_url) + file_descriptor_set.file.each do |file_desc| + file_desc.message_type.each do |message| + if proto_name == "#{file_desc.options.java_package}.#{message.name}" + return message + end + end + end + raise InvalidProtoClass.new + end + + def close + @task.shutdown + end + + private + + def validate_configuration(configuration) + raise Stencil::InvalidConfiguration.new() if configuration.registry_url.nil? || configuration.bearer_token.nil? || configuration.bearer_token == "Bearer " + end + + def setup_http_client + @http_client = HTTP.auth(@config.bearer_token).timeout(@config.http_timeout) + end + + def load_descriptors + begin + response = @http_client.get(@config.registry_url) + if response.code != 200 + raise HTTPServerError.new("Error while fetching descriptor file: Got #{response.code} from stencil server") + end + rescue StandardError => e + raise HTTPClientError.new(e.message) + end + + file_descriptor_set = Google::Protobuf::FileDescriptorSet.decode(response.body) + @store.write(@config.registry_url, file_descriptor_set) + end + + def setup_store_update_job + begin + @task = Concurrent::TimerTask.new(execution_interval: @config.refresh_ttl_in_secs) do + load_descriptors + end + end + end + end +end \ No newline at end of file diff --git a/clients/ruby/lib/stencil/configuration.rb b/clients/ruby/lib/stencil/configuration.rb new file mode 100644 index 00000000..85353e20 --- /dev/null +++ b/clients/ruby/lib/stencil/configuration.rb @@ -0,0 +1,55 @@ +module Stencil + class Configuration + def initialize + @config = ::OpenStruct.new + end + + def registry_url + @config.registry_url + end + + def registry_url=(registry_url) + @config.registry_url = registry_url + end + + def http_timeout + @config.http_timeout || DEFAULT_TIMEOUT_IN_MS + end + + def http_timeout=(timeout) + @config.http_timeout = timeout + end + + def refresh_enabled + @config.refresh_enabled.nil? ? true : @config.refresh_enabled + end + + def refresh_enabled=(refresh_enabled = true) + @config.refresh_enabled = refresh_enabled + end + + def refresh_ttl_in_secs + @config.refresh_ttl_in_secs || DEFAULT_REFRESH_INTERVAL_IN_SECONDS + end + + def refresh_ttl_in_secs=(refresh_ttl_in_secs) + @config.refresh_ttl_in_secs = refresh_ttl_in_secs + end + + def bearer_token=(token) + @config.bearer_token = "Bearer " + token + end + + def bearer_token + @config.bearer_token + end + end + + def self.configuration + @config ||= Configuration.new + end + + def self.configure + yield(configuration) + end +end \ No newline at end of file diff --git a/clients/ruby/lib/stencil/constants.rb b/clients/ruby/lib/stencil/constants.rb new file mode 100644 index 00000000..90e779bd --- /dev/null +++ b/clients/ruby/lib/stencil/constants.rb @@ -0,0 +1,4 @@ +module Stencil + DEFAULT_TIMEOUT_IN_MS = 10000 + DEFAULT_REFRESH_INTERVAL_IN_SECONDS = 43200 +end \ No newline at end of file diff --git a/clients/ruby/lib/stencil/store.rb b/clients/ruby/lib/stencil/store.rb new file mode 100644 index 00000000..280d8b8a --- /dev/null +++ b/clients/ruby/lib/stencil/store.rb @@ -0,0 +1,18 @@ +module Stencil + class Store + def initialize + @lock = Concurrent::ReadWriteLock.new + @data = Hash.new + end + + def write(key, value) + @lock.with_write_lock do + @data.store(key, value) + end + end + + def read(key) + @data[key] + end + end +end \ No newline at end of file diff --git a/clients/ruby/lib/stencil/version.rb b/clients/ruby/lib/stencil/version.rb new file mode 100644 index 00000000..74c0d88f --- /dev/null +++ b/clients/ruby/lib/stencil/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Stencil + VERSION = "0.1.0" +end diff --git a/clients/ruby/spec/data/desc-proto-bin b/clients/ruby/spec/data/desc-proto-bin new file mode 100644 index 00000000..64e4a3b0 Binary files /dev/null and b/clients/ruby/spec/data/desc-proto-bin differ diff --git a/clients/ruby/spec/spec_helper.rb b/clients/ruby/spec/spec_helper.rb new file mode 100644 index 00000000..9da8ec7f --- /dev/null +++ b/clients/ruby/spec/spec_helper.rb @@ -0,0 +1,28 @@ +require 'simplecov' + +SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new( + [ + SimpleCov::Formatter::HTMLFormatter, + ] +) + +SimpleCov.start do + add_filter "/spec/" + minimum_coverage 80 +end + +require 'bundler/setup' +Bundler.setup + +require 'pry' +require 'stencil' +require 'webmock/rspec' + +RSpec.configure do |config| + config.filter_run_when_matching focus: true + config.disable_monkey_patching! + config.profile_examples = 10 + config.order = :random +end + +WebMock.disable_net_connect!(allow_localhost: true) \ No newline at end of file diff --git a/clients/ruby/spec/stencil/client_spec.rb b/clients/ruby/spec/stencil/client_spec.rb new file mode 100644 index 00000000..b3c4213e --- /dev/null +++ b/clients/ruby/spec/stencil/client_spec.rb @@ -0,0 +1,68 @@ +module Stencil + RSpec.describe Client do + let(:registry_url) { 'http://stencil.test' } + let(:bearer_token) { 'sample-token-123' } + let(:service_type_message) do + service_type = nil + Google::Protobuf::FileDescriptorSet.decode(File.read('spec/data/desc-proto-bin')).file.each do |file| + file.message_type.each do |msg| + if msg.name == "ServiceType" + service_type = msg + end + end + end + service_type + end + + context '#get_type' do + subject { Stencil::Client.new } + + before(:each) do + Stencil.configure do |config| + config.registry_url = registry_url + config.bearer_token = bearer_token + end + + @stencil_get_stub = stub_request(:get, registry_url). + with( + headers: { + 'Authorization' => 'Bearer ' + bearer_token, + 'Connection' => 'close', + 'Host' => 'stencil.test', + 'User-Agent' => 'http.rb/4.4.1' + }) + end + + it 'should raise error if configs are invalid' do + config = Stencil.configuration + config.bearer_token = "" + expect { subject }.to raise_error(Stencil::InvalidConfiguration) + end + + it 'should raise error if http client returns error on stencil get api' do + @stencil_get_stub.to_raise(StandardError.new('some error')) + expect { subject.get_type }.to raise_error(Stencil::HTTPClientError) + end + + it 'should raise error if http client returns 500' do + @stencil_get_stub.to_return(status: 500, body: 'Internal server error', headers: {}) + expect { subject.get_type }.to raise_error(Stencil::HTTPClientError) + end + + it 'should raise error for invalid proto type' do + @stencil_get_stub.to_return(status: 200, body: File.new('spec/data/desc-proto-bin'), headers: {}) + + proto_name = "incorrect" + expect { subject.get_type(proto_name) }.to raise_error(Stencil::InvalidProtoClass) + end + + it 'should successfully return proto type' do + @stencil_get_stub.to_return(status: 200, body: File.new('spec/data/desc-proto-bin'), headers: {}) + + proto_name = "com.gojek.esb.types.ServiceType" + actual_type = subject.get_type(proto_name) + expect(actual_type).to eq(service_type_message) + end + end + end +end \ No newline at end of file diff --git a/clients/ruby/spec/stencil/configuration_spec.rb b/clients/ruby/spec/stencil/configuration_spec.rb new file mode 100644 index 00000000..30b81966 --- /dev/null +++ b/clients/ruby/spec/stencil/configuration_spec.rb @@ -0,0 +1,74 @@ +module Stencil + RSpec.describe Configuration do + let(:configuration) { Stencil.configuration } + + it 'should set registry urls correctly' do + expect(configuration.registry_url).to eq(nil) + + expected_value = 'http://localhost:3000' + configuration.registry_url = expected_value + expect(configuration.registry_url).to eq(expected_value) + end + + + it 'should set http_timeout correctly' do + expect(configuration.http_timeout).to eq(DEFAULT_TIMEOUT_IN_MS) + + timeout = 3000 + configuration.http_timeout = timeout + expect(configuration.http_timeout).to eq(3000) + end + + it 'should set refresh_enabled correctly' do + expect(configuration.refresh_enabled).to eq(true) + + refresh_enabled = false + configuration.refresh_enabled = refresh_enabled + expect(configuration.refresh_enabled).to eq(refresh_enabled) + end + + it 'should set refresh_ttl_in_secs correctly' do + expect(configuration.refresh_ttl_in_secs).to eq(DEFAULT_REFRESH_INTERVAL_IN_SECONDS) + + refresh_ttl_in_secs = 50000 + configuration.refresh_ttl_in_secs = refresh_ttl_in_secs + expect(configuration.refresh_ttl_in_secs).to eq(refresh_ttl_in_secs) + end + + it 'should set bearer_token correctly' do + token = "sampletoken" + expected_bearer_token = "Bearer sampletoken" + configuration.bearer_token = token + expect(configuration.bearer_token).to eq(expected_bearer_token) + end + + describe '#configure' do + let(:refresh_enabled) {true} + let(:refresh_ttl_in_secs) {60000} + let(:registry_url) {"abc.com/latest"} + let(:token) {"ABCD1234"} + let(:bearer_token) {"Bearer " + token} + let(:http_timeout) {6000} + + before(:each) do + Stencil.configure do |config| + config.registry_url = registry_url + config.bearer_token = token + config.refresh_enabled = refresh_enabled + config.refresh_ttl_in_secs = refresh_ttl_in_secs + config.http_timeout = http_timeout + end + end + + subject { Stencil.configuration } + + it 'should set configuration correctly' do + expect(subject.registry_url).to eq(registry_url) + expect(subject.bearer_token).to eq(bearer_token) + expect(subject.refresh_enabled).to eq(refresh_enabled) + expect(subject.refresh_ttl_in_secs).to eq(refresh_ttl_in_secs) + expect(subject.http_timeout).to eq(http_timeout) + end + end + end +end diff --git a/clients/ruby/spec/stencil/store_spec.rb b/clients/ruby/spec/stencil/store_spec.rb new file mode 100644 index 00000000..62ac7fe0 --- /dev/null +++ b/clients/ruby/spec/stencil/store_spec.rb @@ -0,0 +1,30 @@ +module Stencil + RSpec.describe Store do + context "#read" do + let(:sample_key) { "sample_key" } + let(:sample_value) { 123 } + + it "should be able to handle concurrent reads of data" do + store = Store.new + store.write(sample_key, sample_value) + + 5.times do + Thread.start do + expect(store.read(sample_key)).to eq(sample_value) + end + end + end + end + + context "#write" do + let(:sample_key) { "sample_key" } + let(:sample_value) { 100 } + + it "should be able to handle concurrent writes of data by locking data" do + store = Store.new + store.write(sample_key, sample_value) + expect(store.read(sample_key)).to eq(sample_value) + end + end + end +end diff --git a/clients/ruby/stencil.gemspec b/clients/ruby/stencil.gemspec new file mode 100644 index 00000000..6d6f9109 --- /dev/null +++ b/clients/ruby/stencil.gemspec @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative "lib/stencil/version" + +Gem::Specification.new do |spec| + spec.name = "stencil" + spec.version = Stencil::VERSION + spec.authors = ["Daval Pargal"] + spec.email = ["davalpargal@gmail.com"] + + spec.summary = "Stencil ruby gem provides a store to lookup protobuf descriptors and options to keep the protobuf descriptors upto date." + spec.homepage = "https://odpf.gitbook.io/stencil/" + spec.required_ruby_version = ">= 2.4.0" + + spec.metadata["allowed_push_host"] = "" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/odpf/stencil" + spec.metadata["changelog_uri"] = "https://github.com/odpf/stencil/blob/master/CHANGELOG.md" + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) } + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_development_dependency "bundler", "~> 1.17.3" + spec.add_development_dependency "webmock", "~> 3.14.0" +end