├── .ruby-version ├── .rspec ├── .ruby-gemset ├── .yardopts ├── lib ├── event_store_client │ ├── version.rb │ ├── mapper.rb │ ├── adapters │ │ ├── grpc │ │ │ ├── cluster │ │ │ │ ├── member.rb │ │ │ │ ├── insecure_connection.rb │ │ │ │ ├── queryless_discover.rb │ │ │ │ └── secure_connection.rb │ │ │ ├── commands │ │ │ │ ├── gossip │ │ │ │ │ └── cluster_info.rb │ │ │ │ ├── streams │ │ │ │ │ ├── link_to.rb │ │ │ │ │ ├── read.rb │ │ │ │ │ ├── link_to_multiple.rb │ │ │ │ │ ├── delete.rb │ │ │ │ │ ├── hard_delete.rb │ │ │ │ │ ├── append_multiple.rb │ │ │ │ │ ├── subscribe.rb │ │ │ │ │ └── append.rb │ │ │ │ └── command.rb │ │ │ ├── generated │ │ │ │ ├── gossip_services_pb.rb │ │ │ │ ├── monitoring_services_pb.rb │ │ │ │ ├── serverfeatures_services_pb.rb │ │ │ │ ├── monitoring_pb.rb │ │ │ │ ├── serverfeatures_pb.rb │ │ │ │ ├── streams_services_pb.rb │ │ │ │ ├── operations_services_pb.rb │ │ │ │ ├── users_services_pb.rb │ │ │ │ ├── persistent_services_pb.rb │ │ │ │ ├── projections_services_pb.rb │ │ │ │ ├── cluster_services_pb.rb │ │ │ │ ├── gossip_pb.rb │ │ │ │ └── operations_pb.rb │ │ │ ├── command_registrar.rb │ │ │ ├── shared │ │ │ │ ├── streams │ │ │ │ │ ├── process_response.rb │ │ │ │ │ └── process_responses.rb │ │ │ │ └── options │ │ │ │ │ ├── filter_options.rb │ │ │ │ │ └── stream_options.rb │ │ │ ├── options │ │ │ │ └── streams │ │ │ │ │ ├── write_options.rb │ │ │ │ │ └── read_options.rb │ │ │ ├── connection.rb │ │ │ └── discover.rb │ │ └── grpc.rb │ ├── encryption_metadata.rb │ ├── serializer │ │ ├── json.rb │ │ ├── event_deserializer.rb │ │ └── event_serializer.rb │ ├── event_class_resolver.rb │ ├── utils.rb │ ├── mapper │ │ ├── default.rb │ │ └── encrypted.rb │ ├── serialized_event.rb │ ├── data_encryptor.rb │ ├── data_decryptor.rb │ ├── config.rb │ ├── extensions │ │ └── options_extension.rb │ ├── rspec │ │ └── has_option_matcher.rb │ ├── connection │ │ └── url.rb │ ├── deserialized_event.rb │ └── errors.rb └── event_store_client.rb ├── spec ├── support │ ├── event_store_helpers.rb │ ├── encrypted_event.rb │ ├── serialized_encrypted_event.rb │ ├── dummy_logger.rb │ ├── test_helper.rb │ ├── event_helpers.rb │ └── dummy_repository.rb ├── event_store_client │ ├── adapters │ │ └── grpc │ │ │ ├── cluster │ │ │ ├── member_spec.rb │ │ │ ├── queryless_discover_spec.rb │ │ │ ├── insecure_connection_spec.rb │ │ │ └── secure_connection_spec.rb │ │ │ ├── commands │ │ │ ├── gossip │ │ │ │ └── cluster_info_spec.rb │ │ │ └── streams │ │ │ │ ├── link_to_multiple_spec.rb │ │ │ │ ├── delete_spec.rb │ │ │ │ ├── hard_delete_spec.rb │ │ │ │ ├── link_to_spec.rb │ │ │ │ └── subscribe_spec.rb │ │ │ ├── options │ │ │ └── streams │ │ │ │ ├── write_options_spec.rb │ │ │ │ └── read_options_spec.rb │ │ │ ├── connection_spec.rb │ │ │ └── shared │ │ │ ├── streams │ │ │ ├── process_response_spec.rb │ │ │ └── process_responses_spec.rb │ │ │ └── options │ │ │ └── stream_options_spec.rb │ ├── stream_not_found_error_spec.rb │ ├── error_spec.rb │ ├── encryption_metadata_spec.rb │ ├── stream_deletion_error_spec.rb │ ├── utils_spec.rb │ ├── multiple_configurations_spec.rb │ ├── event_class_resolver_spec.rb │ ├── data_encryptor_spec.rb │ ├── serializer │ │ ├── json_spec.rb │ │ └── event_deserializer_spec.rb │ ├── data_decryptor_spec.rb │ ├── wrong_expected_version_error_spec.rb │ ├── mapper │ │ └── default_spec.rb │ ├── serialized_event_spec.rb │ ├── connection │ │ └── utl_spec.rb │ ├── extensions │ │ └── options_extension_spec.rb │ ├── deserialized_event_spec.rb │ └── config_spec.rb ├── spec_helper.rb └── event_store_client_spec.rb ├── .rubocop.disabled.yml ├── .gitignore ├── cluster_shared.env ├── .rubocop.yml ├── Rakefile ├── Gemfile ├── .github └── workflows │ ├── gem-test.yml │ └── gem-push.yml ├── bin ├── console ├── setup └── rebuild_protos ├── LICENSE.txt ├── docs ├── deleting_streams.md └── encrypting_events.md ├── event_store_client.gemspec ├── .overcommit.yml └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.2.2 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | eventstore-client -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --protected --private 'lib/**/*.rb' - docs/*.md docs/**/*.md README.md LICENSE.txt -------------------------------------------------------------------------------- /lib/event_store_client/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | VERSION = '3.2.0' 5 | end 6 | -------------------------------------------------------------------------------- /lib/event_store_client/mapper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module Mapper 5 | end 6 | end 7 | 8 | require 'event_store_client/mapper/default' 9 | require 'event_store_client/mapper/encrypted' 10 | -------------------------------------------------------------------------------- /spec/support/event_store_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | class SomethingHappened < DeserializedEvent 5 | def schema 6 | Dry::Schema.Params do 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /.rubocop.disabled.yml: -------------------------------------------------------------------------------- 1 | # These are all the cops that are disabled in the default configuration. 2 | 3 | Style/Copyright: 4 | Enabled: false 5 | Rails: 6 | Enabled: false 7 | AllCops: 8 | Exclude: 9 | - 'lib/event_store_client/adapters/grpc/generated/*.rb' 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | certs/ 3 | log/*.log 4 | pkg/ 5 | spec/examples.txt 6 | spec/dummy 7 | tmp 8 | .env 9 | *.gem 10 | 11 | .docker-sync 12 | 13 | .DS_Store 14 | 15 | #rubocop qa-tools default rules 16 | .rubocop-https* 17 | 18 | # Ignore coverage report 19 | /coverage 20 | 21 | Gemfile.lock 22 | .yardoc/ 23 | -------------------------------------------------------------------------------- /cluster_shared.env: -------------------------------------------------------------------------------- 1 | EVENTSTORE_CLUSTER_SIZE=3 2 | EVENTSTORE_RUN_PROJECTIONS=All 3 | EVENTSTORE_INT_TCP_PORT=1112 4 | EVENTSTORE_HTTP_PORT=2113 5 | EVENTSTORE_TRUSTED_ROOT_CERTIFICATES_PATH=/etc/eventstore/certs/ca 6 | EVENTSTORE_DISCOVER_VIA_DNS=false 7 | EVENTSTORE_ENABLE_EXTERNAL_TCP=true 8 | EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true 9 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - https://raw.githubusercontent.com/yousty/qa-tools/master/rubocop/base/.rubocop.yml # common enabled rules 3 | - https://raw.githubusercontent.com/yousty/qa-tools/master/rubocop/base/.rubocop.disabled.yml # common disabled rules 4 | - .rubocop.disabled.yml # project specific disabled rules 5 | 6 | # project specific overwritten rules 7 | 8 | # example: 9 | # Layout/LineLength: 10 | # Max: 100 11 | # AllowHeredoc: true 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require 'bundler/setup' 5 | rescue LoadError 6 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 7 | end 8 | 9 | require 'rdoc/task' 10 | 11 | RDoc::Task.new(:rdoc) do |rdoc| 12 | rdoc.rdoc_dir = 'rdoc' 13 | rdoc.title = 'EventStoreClient' 14 | rdoc.options << '--line-numbers' 15 | rdoc.rdoc_files.include('README.md') 16 | rdoc.rdoc_files.include('lib/**/*.rb') 17 | end 18 | 19 | require 'bundler/gem_tasks' 20 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/cluster/member.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Cluster 6 | class Member 7 | include Extensions::OptionsExtension 8 | 9 | option(:host) # String 10 | option(:port) # Integer 11 | option(:active) # boolean 12 | option(:instance_id) # string 13 | option(:state) # symbol 14 | option(:failed_endpoint) { false } # boolean 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/event_store_client/encryption_metadata.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | class EncryptionMetadata 5 | def call 6 | return {} unless schema 7 | 8 | { 9 | key: schema[:key].call(data), 10 | attributes: schema[:attributes].map(&:to_sym) 11 | } 12 | end 13 | 14 | private 15 | 16 | attr_reader :data, :schema 17 | 18 | def initialize(data:, schema:) 19 | @data = data.transform_keys(&:to_sym) 20 | @schema = schema 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/encrypted_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EncryptedEvent < EventStoreClient::DeserializedEvent 4 | def schema 5 | Dry::Schema.Params do 6 | required(:user_id).value(:string) 7 | required(:first_name).value(:string) 8 | required(:last_name).value(:string) 9 | required(:profession).value(:string) 10 | end 11 | end 12 | 13 | def self.encryption_schema 14 | { 15 | key: ->(data) { data[:user_id] }, 16 | attributes: %i[first_name last_name] 17 | } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/serialized_encrypted_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SerializedEncryptedEvent 4 | attr_reader :type 5 | 6 | def metadata 7 | '{"created_at":"2019-12-05 19:37:38 +0100"}' 8 | end 9 | 10 | def data 11 | JSON.generate( 12 | user_id: 'dab48d26-e4f8-41fc-a9a8-59657e590716', 13 | first_name: 'encrypted', 14 | last_name: 'encrypted', 15 | profession: 'Jedi', 16 | encrypted: 'darthvader' 17 | ) 18 | end 19 | 20 | private 21 | 22 | def initialize(type:) 23 | @type = type 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/commands/gossip/cluster_info.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Commands 6 | module Gossip 7 | class ClusterInfo < Command 8 | use_request EventStore::Client::Empty 9 | use_service EventStore::Client::Gossip::Gossip::Stub 10 | 11 | # @api private 12 | # @see {EventStoreClient::GRPC::Client#cluster_info} 13 | def call 14 | retry_request { service.read(request.new, metadata: metadata) } 15 | end 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/cluster/member_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Cluster::Member do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new } 7 | 8 | it { is_expected.to be_a(EventStoreClient::Extensions::OptionsExtension) } 9 | it { is_expected.to have_option(:host) } 10 | it { is_expected.to have_option(:port) } 11 | it { is_expected.to have_option(:active) } 12 | it { is_expected.to have_option(:instance_id) } 13 | it { is_expected.to have_option(:state) } 14 | it { is_expected.to have_option(:failed_endpoint).with_default_value(false) } 15 | end 16 | -------------------------------------------------------------------------------- /spec/event_store_client/stream_not_found_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::StreamNotFoundError do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new(stream_name) } 7 | let(:stream_name) { 'some-stream' } 8 | 9 | it { is_expected.to be_a(EventStoreClient::Error) } 10 | 11 | describe '#message' do 12 | subject { instance.message } 13 | 14 | it { is_expected.to eq("Stream #{stream_name.inspect} does not exist.") } 15 | end 16 | 17 | describe '#stream_name' do 18 | subject { instance.stream_name } 19 | 20 | it { is_expected.to eq(stream_name) } 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Declare your gem's dependencies in event_store_client.gemspec. 6 | # Bundler will treat runtime dependencies like base dependencies, and 7 | # development dependencies will be added by default to the :development group. 8 | gemspec 9 | 10 | # Declare any dependencies that are still in development here instead of in 11 | # your gemspec. These might include edge Rails or gems from your path or 12 | # Git. Remember to move these dependencies to your gemspec before releasing 13 | # your gem to rubygems.org. 14 | 15 | # To use a debugger 16 | # gem 'byebug', group: [:development, :test] 17 | -------------------------------------------------------------------------------- /spec/support/dummy_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This logger is used in tests unless ENV["DEBUG"] is set. It is important to return Object.new - it 4 | # guards from false positive results when dev incidentally put `logger&.debug(something)` in the end 5 | # of the method, but tests passed because there were no logger set, thus, making it to return `nil`. 6 | # But, in this situation, if logger would be set - the returning result may be unexpected. 7 | class DummyLogger 8 | class << self 9 | def info(*) 10 | Object.new 11 | end 12 | 13 | def debug(*) 14 | Object.new 15 | end 16 | 17 | def warn(*) 18 | Object.new 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/commands/streams/link_to.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Commands 6 | module Streams 7 | class LinkTo < Command 8 | # @see {EventStoreClient::GRPC::Client#hard_delete_stream} 9 | def call(stream_name, event, options:, &blk) 10 | append_cmd = Append.new(config: config, **connection_options) 11 | link_event = DeserializedEvent.new( 12 | id: event.id, type: DeserializedEvent::LINK_TYPE, data: event.title 13 | ) 14 | append_cmd.call(stream_name, link_event, options: options, &blk) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/event_store_client/serializer/json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module Serializer 5 | module Json 6 | # @param data [String, Hash] 7 | # @return [Hash] 8 | def self.deserialize(data) 9 | return data if data.is_a?(Hash) 10 | 11 | result = JSON.parse(data) 12 | return result if result.is_a?(Hash) 13 | 14 | { 'message' => result } 15 | rescue JSON::ParserError 16 | { 'message' => data } 17 | end 18 | 19 | # @param data [String, Object] 20 | # @return [String] 21 | def self.serialize(data) 22 | return data.dup if data.is_a?(String) 23 | 24 | JSON.generate(data) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/cluster/insecure_connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Cluster 6 | class InsecureConnection < Connection 7 | # @param stub_class GRPC request stub class. E.g. EventStore::Client::Gossip::Gossip::Stub 8 | # @return instance of the given stub_class class 9 | def call(stub_class) 10 | config.logger&.debug('Using insecure connection.') 11 | stub_class.new( 12 | "#{host}:#{port}", 13 | :this_channel_is_insecure, 14 | channel_args: config.channel_args, 15 | timeout: (timeout / 1000.0 if timeout) 16 | ) 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/generated/gossip_services_pb.rb: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # Source: gossip.proto for package 'event_store.client.gossip' 3 | 4 | require 'grpc' 5 | require_relative 'gossip_pb' 6 | 7 | module EventStore 8 | module Client 9 | module Gossip 10 | module Gossip 11 | class Service 12 | 13 | include ::GRPC::GenericService 14 | 15 | self.marshal_class_method = :encode 16 | self.unmarshal_class_method = :decode 17 | self.service_name = 'event_store.client.gossip.Gossip' 18 | 19 | rpc :Read, ::EventStore::Client::Empty, ::EventStore::Client::Gossip::ClusterInfo 20 | end 21 | 22 | Stub = Service.rpc_stub_class 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/event_store_client/error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::Error do 4 | let(:instance) { described_class.new(message) } 5 | let(:message) { 'some message' } 6 | 7 | it { is_expected.to be_a(StandardError) } 8 | 9 | describe '#as_json' do 10 | subject { instance.as_json } 11 | 12 | it 'returns correct hash' do 13 | is_expected.to eq('message' => message, 'backtrace' => nil) 14 | end 15 | end 16 | 17 | describe '#to_h' do 18 | subject { instance.to_h } 19 | 20 | let(:foo) { 'foo-val' } 21 | 22 | before do 23 | instance.instance_variable_set(:@foo, foo) 24 | end 25 | 26 | it "computes a hash, based on error's details and error's variables" do 27 | is_expected.to eq(message: message, backtrace: nil, foo: foo) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/commands/gossip/cluster_info_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Commands::Gossip::ClusterInfo do 4 | subject { instance } 5 | 6 | let(:config) { EventStoreClient.config } 7 | let(:instance) { described_class.new(config: config) } 8 | 9 | it { is_expected.to be_a(EventStoreClient::GRPC::Commands::Command) } 10 | it 'uses correct params class' do 11 | expect(subject.request).to eq(EventStore::Client::Empty) 12 | end 13 | it 'uses correct service' do 14 | expect(subject.service).to be_a(EventStore::Client::Gossip::Gossip::Stub) 15 | end 16 | 17 | describe '#call' do 18 | subject { instance.call } 19 | 20 | it 'returns cluster info' do 21 | is_expected.to be_a(EventStore::Client::Gossip::ClusterInfo) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/event_store_client/encryption_metadata_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::EncryptionMetadata do 4 | let(:user_id) { SecureRandom.uuid } 5 | let(:data) do 6 | { 7 | user_id: user_id, 8 | first_name: 'Anakin', 9 | last_name: 'Skywalker', 10 | profession: 'Jedi' 11 | } 12 | end 13 | 14 | let(:schema) do 15 | { 16 | key: ->(data) { data[:user_id] }, 17 | attributes: %i[first_name last_name] 18 | } 19 | end 20 | 21 | let(:metadata) { described_class.new(data: data, schema: schema) } 22 | 23 | describe '#call' do 24 | subject { metadata.call } 25 | 26 | it 'returns transformed object' do 27 | expect(subject).to eq( 28 | key: user_id, 29 | attributes: %i[first_name last_name] 30 | ) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/generated/monitoring_services_pb.rb: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # Source: monitoring.proto for package 'event_store.client.monitoring' 3 | 4 | require 'grpc' 5 | require_relative 'monitoring_pb' 6 | 7 | module EventStore 8 | module Client 9 | module Monitoring 10 | module Monitoring 11 | class Service 12 | 13 | include ::GRPC::GenericService 14 | 15 | self.marshal_class_method = :encode 16 | self.unmarshal_class_method = :decode 17 | self.service_name = 'event_store.client.monitoring.Monitoring' 18 | 19 | rpc :Stats, ::EventStore::Client::Monitoring::StatsReq, stream(::EventStore::Client::Monitoring::StatsResp) 20 | end 21 | 22 | Stub = Service.rpc_stub_class 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.github/workflows/gem-test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches-ignore: [ release ] 6 | pull_request: 7 | branches: [ master release ] 8 | 9 | jobs: 10 | build: 11 | name: Test 12 | runs-on: ubuntu-20.04 13 | strategy: 14 | matrix: 15 | ruby-version: ['3.0', '3.1', '3.2'] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1.147.0 21 | with: 22 | ruby-version: ${{ matrix.ruby-version }} 23 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 24 | - name: Run EventStore DB 25 | run: docker-compose -f docker-compose-cluster.yml up --detach 26 | - name: Run tests 27 | run: | 28 | bundle install 29 | sleep 10 30 | TEST_COVERAGE=true bundle exec rspec 31 | -------------------------------------------------------------------------------- /spec/event_store_client/stream_deletion_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::StreamDeletionError do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new(stream_name, details: details) } 7 | let(:stream_name) { 'some-stream' } 8 | let(:details) { 'some details goes here' } 9 | 10 | it { is_expected.to be_a(EventStoreClient::Error) } 11 | 12 | describe '#message' do 13 | subject { instance.message } 14 | 15 | it { is_expected.to include("Could not delete #{stream_name.inspect} stream.") } 16 | end 17 | 18 | describe '#stream_name' do 19 | subject { instance.stream_name } 20 | 21 | it { is_expected.to eq(stream_name) } 22 | end 23 | 24 | describe '#details' do 25 | subject { instance.details } 26 | 27 | it { is_expected.to eq(details) } 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/generated/serverfeatures_services_pb.rb: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # Source: serverfeatures.proto for package 'event_store.client.server_features' 3 | 4 | require 'grpc' 5 | require_relative 'serverfeatures_pb' 6 | 7 | module EventStore 8 | module Client 9 | module ServerFeatures 10 | module ServerFeatures 11 | class Service 12 | 13 | include ::GRPC::GenericService 14 | 15 | self.marshal_class_method = :encode 16 | self.unmarshal_class_method = :decode 17 | self.service_name = 'event_store.client.server_features.ServerFeatures' 18 | 19 | rpc :GetSupportedMethods, ::EventStore::Client::Empty, ::EventStore::Client::ServerFeatures::SupportedMethods 20 | end 21 | 22 | Stub = Service.rpc_stub_class 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/event_store_client/event_class_resolver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | class EventClassResolver 5 | attr_reader :config 6 | private :config 7 | 8 | # @param config [EventStoreClient::Config] 9 | def initialize(config) 10 | @config = config 11 | end 12 | 13 | # @param event_type [String, nil] 14 | # @return [Class] 15 | def resolve(event_type) 16 | return config.event_class_resolver.(event_type) || config.default_event_class if config.event_class_resolver 17 | 18 | Object.const_get(event_type) 19 | rescue NameError, TypeError 20 | config.logger&.debug(<<~TEXT.strip) 21 | Unable to resolve class by `#{event_type}' event type. \ 22 | Picking default `#{config.default_event_class}' event class to instantiate the event. 23 | TEXT 24 | config.default_event_class 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/generated/monitoring_pb.rb: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # source: monitoring.proto 3 | 4 | require 'google/protobuf' 5 | 6 | Google::Protobuf::DescriptorPool.generated_pool.build do 7 | add_file("monitoring.proto", :syntax => :proto3) do 8 | add_message "event_store.client.monitoring.StatsReq" do 9 | optional :use_metadata, :bool, 1 10 | optional :refresh_time_period_in_ms, :uint64, 4 11 | end 12 | add_message "event_store.client.monitoring.StatsResp" do 13 | map :stats, :string, :string, 1 14 | end 15 | end 16 | end 17 | 18 | module EventStore 19 | module Client 20 | module Monitoring 21 | StatsReq = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.monitoring.StatsReq").msgclass 22 | StatsResp = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.monitoring.StatsResp").msgclass 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/event_store_client/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Metrics/AbcSize 4 | 5 | module EventStoreClient 6 | class Utils 7 | class << self 8 | # @param uuid [EventStore::Client::UUID] 9 | # @return [String] 10 | def uuid_to_str(uuid) 11 | return uuid.string unless uuid.string.empty? 12 | 13 | msb = 14 | if uuid.structured.most_significant_bits.negative? 15 | (2**64) + uuid.structured.most_significant_bits 16 | else 17 | uuid.structured.most_significant_bits 18 | end 19 | lsb = 20 | if uuid.structured.least_significant_bits.negative? 21 | (2**64) + uuid.structured.least_significant_bits 22 | else 23 | uuid.structured.least_significant_bits 24 | end 25 | (msb.to_s(16) + lsb.to_s(16)).unpack('A8A4A4A4A12').join('-') 26 | end 27 | end 28 | end 29 | end 30 | # rubocop:enable Metrics/AbcSize 31 | -------------------------------------------------------------------------------- /spec/support/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestHelper 4 | class << self 5 | def configure_grpc 6 | EventStoreClient.configure do |config| 7 | config.eventstore_url = 8 | ENV.fetch('EVENTSTORE_URL') { 'esdb://localhost:2115/?tls=false&timeout=1000' } 9 | config.logger = ENV['DEBUG'] ? Logger.new(STDOUT) : DummyLogger 10 | end 11 | end 12 | 13 | def clean_up_grpc_config 14 | EventStoreClient.instance_variable_set(:@config, nil) 15 | EventStoreClient.init_default_config 16 | EventStoreClient::GRPC::Discover.instance_variable_set(:@semaphore, nil) 17 | EventStoreClient::GRPC::Discover.instance_variable_set(:@current_member, nil) 18 | EventStoreClient::GRPC::Discover.instance_variable_set(:@exception, nil) 19 | EventStoreClient::GRPC::Discover.init_default_discover_vars 20 | end 21 | 22 | def root_path 23 | File.expand_path('../..', __dir__) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/cluster/queryless_discover.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Cluster 6 | class QuerylessDiscover 7 | NoHostError = Class.new(StandardError) 8 | 9 | attr_reader :config 10 | private :config 11 | 12 | # @param config [EventStoreClient::Config] 13 | def initialize(config:) 14 | @config = config 15 | end 16 | 17 | # @param nodes [EventStoreClient::Connection::Url::Node] 18 | # @return [EventStoreClient::GRPC::Cluster::Member] 19 | def call(nodes) 20 | raise NoHostError, 'No host setup' if nodes.empty? 21 | 22 | Member.new(host: nodes.first.host, port: nodes.first.port).tap do |member| 23 | config.logger&.debug( 24 | "Picking #{member.host}:#{member.port} member without cluster discovery." 25 | ) 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/event_store_client/utils_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::Utils do 4 | describe '.uuid_to_str' do 5 | subject { described_class.uuid_to_str(uuid) } 6 | 7 | let(:uuid) { EventStore::Client::UUID.new(string: uuid_str) } 8 | let(:uuid_str) { SecureRandom.uuid } 9 | 10 | context 'when object contains string representation of UUID' do 11 | it 'returns it' do 12 | is_expected.to eq(uuid_str) 13 | end 14 | end 15 | 16 | context 'when object is structured representation of UUID' do 17 | let(:uuid) do 18 | EventStore::Client::UUID.new( 19 | structured: { most_significant_bits: msb, least_significant_bits: lsb } 20 | ) 21 | end 22 | let(:msb) { 8789121028307961377 } 23 | let(:lsb) { -8393389205121028596 } 24 | 25 | it 'returns string representation of UUID' do 26 | is_expected.to eq('79f93b02-2c36-4621-8b84-b0fcef29ae0c') 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # USAGE: 5 | # docker build -t yousty/esc:test 6 | # gem install docker-sync 7 | # docker-sync start && docker-compose up -d 8 | # docker-compose exec specs bash 9 | # bin/console 10 | # 11 | require 'bundler/setup' 12 | require 'irb' 13 | require 'pry' 14 | require 'event_store_client' 15 | 16 | EventStoreClient.configure do |config| 17 | ## url needs to match the DNS name in certificate 18 | config.eventstore_url = ENV.fetch('EVENTSTORE_URL') { 'esdb://admin:changeit@localhost:2111,localhost:2112,localhost:2113' } 19 | config.per_page = 1_000 20 | config.logger = Logger.new(STDOUT) if ENV['DEBUG'] 21 | end 22 | 23 | EventStoreClient.configure(name: :es_2) do |config| 24 | ## url needs to match the DNS name in certificate 25 | config.eventstore_url = ENV.fetch('EVENTSTORE_URL') { 'esdb://admin:changeit@localhost:2115/?tls=false' } 26 | config.per_page = 1_000 27 | config.logger = Logger.new(STDOUT) if ENV['DEBUG'] 28 | end 29 | 30 | IRB.start(__FILE__) 31 | -------------------------------------------------------------------------------- /spec/support/event_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventHelpers 4 | # @param stream_name [String] 5 | # @param event [EventStoreClient::DeserializedEvent] 6 | # @return [EventStoreClient::DeserializedEvent] 7 | def append_and_reload(stream_name, event, **options) 8 | EventStoreClient.client.append_to_stream(stream_name, event) 9 | EventStoreClient.client.read( 10 | '$all', 11 | **options.merge( 12 | options: { 13 | direction: 'Backwards', 14 | from_position: :end, 15 | max_count: 1, 16 | filter: { stream_identifier: { prefix: [stream_name] } } 17 | } 18 | ) 19 | ).first 20 | end 21 | 22 | # @param stream_name [String] 23 | def safe_read(stream_name) 24 | EventStoreClient.client.read( 25 | '$all', 26 | options: { 27 | max_count: 1_000, 28 | filter: { stream_identifier: { prefix: [stream_name] } } 29 | } 30 | ) 31 | rescue EventStoreClient::StreamNotFoundError 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/command_registrar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | class CommandRegistrar 6 | @commands = {} 7 | 8 | def self.register_request(command_klass, request:) 9 | @commands[command_klass] ||= {} 10 | @commands[command_klass][:request] = request 11 | end 12 | 13 | def self.register_service(command_klass, service:) 14 | @commands[command_klass] ||= {} 15 | @commands[command_klass][:service] = service 16 | end 17 | 18 | def self.request(command_klass) 19 | @commands.dig(command_klass, :request) 20 | end 21 | 22 | # @param command_klass [Class] 23 | # Examples: 24 | # - EventStoreClient::GRPC::Commands::Streams::Append 25 | # - EventStoreClient::GRPC::Commands::Streams::Read 26 | # @return [Object] GRPC service class 27 | def self.service(command_klass) 28 | @commands.dig(command_klass, :service) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Yousty AG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/generated/serverfeatures_pb.rb: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # source: serverfeatures.proto 3 | 4 | require 'google/protobuf' 5 | 6 | require_relative 'shared_pb' 7 | 8 | Google::Protobuf::DescriptorPool.generated_pool.build do 9 | add_file("serverfeatures.proto", :syntax => :proto3) do 10 | add_message "event_store.client.server_features.SupportedMethods" do 11 | repeated :methods, :message, 1, "event_store.client.server_features.SupportedMethod" 12 | optional :event_store_server_version, :string, 2 13 | end 14 | add_message "event_store.client.server_features.SupportedMethod" do 15 | optional :method_name, :string, 1 16 | optional :service_name, :string, 2 17 | repeated :features, :string, 3 18 | end 19 | end 20 | end 21 | 22 | module EventStore 23 | module Client 24 | module ServerFeatures 25 | SupportedMethods = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.server_features.SupportedMethods").msgclass 26 | SupportedMethod = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.server_features.SupportedMethod").msgclass 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env bash 2 | 3 | # Do any other automated setup that you need to do here 4 | 5 | if ! [ -x "$(command -v docker)" ]; then 6 | echo "Please install docker first\n" 7 | exit -1 8 | fi 9 | 10 | if ! [ -x "$(command -v docker-sync)" ]; then 11 | echo "---------------------- Installing docker-sync ----------------------" 12 | gem install docker-sync 13 | fi 14 | 15 | if ! [ -x "$(command -v docker-compose)" ]; then 16 | echo "---------------------- Installing docker-sync ----------------------" 17 | gem install docker-compose 18 | fi 19 | 20 | if ! [ -f ".env" ]; then 21 | echo "---------------------- Copying the .env file ----------------------" 22 | cp .env.example .env 23 | fi 24 | 25 | echo "\n---------------------- Generating Certs for EventStoreDB ----------------------" 26 | if ! [ -d "certs" ]; then 27 | echo "certs does not exist" 28 | sh ./create-certs.sh 29 | else 30 | echo "WARNING: 'certs' folder already exists. To refresh certificates, remove the folder first." 31 | fi 32 | 33 | echo "\n---------------------- Rebuilding the image and running server ----------------------" 34 | 35 | docker build -t yousty/esc:test . 36 | docker-sync start 37 | docker-compose run dev bundle install 38 | docker-compose up -d 39 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/cluster/queryless_discover_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Cluster::QuerylessDiscover do 4 | subject { instance } 5 | 6 | let(:config) { EventStoreClient.config } 7 | let(:instance) { described_class.new(config: config) } 8 | 9 | describe 'constants' do 10 | describe 'NoHostError' do 11 | subject { described_class::NoHostError } 12 | 13 | it { is_expected.to be < StandardError } 14 | end 15 | end 16 | 17 | describe '#call' do 18 | subject { instance.call(nodes) } 19 | 20 | let(:nodes) { [] } 21 | 22 | context 'when nodes are absent' do 23 | it 'raises error' do 24 | expect { subject }.to raise_error(described_class::NoHostError, 'No host setup') 25 | end 26 | end 27 | 28 | context 'when nodes are present' do 29 | let(:nodes) { [EventStoreClient::Connection::Url::Node.new('localhost', 3000)] } 30 | 31 | it { is_expected.to be_a(EventStoreClient::GRPC::Cluster::Member) } 32 | it 'has proper attributes' do 33 | aggregate_failures do 34 | expect(subject.host).to eq(nodes.first.host) 35 | expect(subject.port).to eq(nodes.first.port) 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/generated/streams_services_pb.rb: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # Source: streams.proto for package 'event_store.client.streams' 3 | 4 | require 'grpc' 5 | require_relative 'streams_pb' 6 | 7 | module EventStore 8 | module Client 9 | module Streams 10 | module Streams 11 | class Service 12 | 13 | include ::GRPC::GenericService 14 | 15 | self.marshal_class_method = :encode 16 | self.unmarshal_class_method = :decode 17 | self.service_name = 'event_store.client.streams.Streams' 18 | 19 | rpc :Read, ::EventStore::Client::Streams::ReadReq, stream(::EventStore::Client::Streams::ReadResp) 20 | rpc :Append, stream(::EventStore::Client::Streams::AppendReq), ::EventStore::Client::Streams::AppendResp 21 | rpc :Delete, ::EventStore::Client::Streams::DeleteReq, ::EventStore::Client::Streams::DeleteResp 22 | rpc :Tombstone, ::EventStore::Client::Streams::TombstoneReq, ::EventStore::Client::Streams::TombstoneResp 23 | rpc :BatchAppend, stream(::EventStore::Client::Streams::BatchAppendReq), stream(::EventStore::Client::Streams::BatchAppendResp) 24 | end 25 | 26 | Stub = Service.rpc_stub_class 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/commands/streams/link_to_multiple_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Commands::Streams::LinkToMultiple do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new(config: config) } 7 | let(:config) { EventStoreClient.config } 8 | 9 | it { is_expected.to be_a(EventStoreClient::GRPC::Commands::Command) } 10 | 11 | describe '#call' do 12 | subject { instance.call(stream_name, events, options: options) } 13 | 14 | let(:stream_name) { "some-stream$#{SecureRandom.uuid}" } 15 | let(:other_stream_name) { "other-stream$#{SecureRandom.uuid}" } 16 | let(:options) { {} } 17 | let(:events) do 18 | 2.times.map do 19 | event = EventStoreClient::DeserializedEvent.new(type: 'some-event', data: { foo: :bar }) 20 | append_and_reload(other_stream_name, event) 21 | end 22 | end 23 | 24 | it 'links event' do 25 | expect { subject }.to change { safe_read(stream_name)&.size }.to(2) 26 | end 27 | 28 | describe 'linked events' do 29 | subject do 30 | super() 31 | EventStoreClient.client.read(stream_name, options: { resolve_link_tos: true }) 32 | end 33 | 34 | it 'returns linked events' do 35 | is_expected.to eq(events) 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/shared/streams/process_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Shared 6 | module Streams 7 | class ProcessResponse 8 | attr_reader :config 9 | private :config 10 | 11 | # @param config [EventStoreClient::Config] 12 | def initialize(config:) 13 | @config = config 14 | end 15 | 16 | # @api private 17 | # @param response [EventStore::Client::Streams::ReadResp] 18 | # @param skip_deserialization [Boolean] 19 | # @param skip_decryption [Boolean] 20 | # @return [EventStoreClient::DeserializedEvent, EventStore::Client::Streams::ReadResp, nil] 21 | # @raise [EventStoreClient::StreamNotFoundError] 22 | def call(response, skip_deserialization, skip_decryption) 23 | non_existing_stream = response.stream_not_found&.stream_identifier&.stream_name 24 | raise StreamNotFoundError, non_existing_stream if non_existing_stream 25 | return response if skip_deserialization 26 | return unless response.event&.event 27 | 28 | config.mapper.deserialize(response.event.event, skip_decryption: skip_decryption) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/event_store_client/multiple_configurations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Multiple configuration handling' do 4 | let!(:config1) do 5 | EventStoreClient.configure do |config| 6 | config.eventstore_url = url1 7 | end 8 | end 9 | let!(:config2) do 10 | EventStoreClient.configure(name: :another_config) do |config| 11 | config.eventstore_url = url2 12 | end 13 | end 14 | let(:url1) { 'esdb://admin:changeit@localhost:2111,localhost:2112,localhost:2113' } 15 | let(:url2) { 'esdb://admin:changeit@localhost:2115/?tls=false' } 16 | let(:client1) { EventStoreClient.client } 17 | let(:client2) { EventStoreClient.client(config_name: :another_config) } 18 | 19 | describe 'reading/writing events' do 20 | let(:stream_name) { "some-stream$#{SecureRandom.uuid}" } 21 | let(:event) { EventStoreClient::DeserializedEvent.new(id: SecureRandom.uuid) } 22 | let(:read_opts) { { options: { filter: { stream_identifier: { prefix: [stream_name] } } } } } 23 | 24 | before do 25 | client2.append_to_stream(stream_name, event) 26 | end 27 | 28 | it 'reads event from correct ES server' do 29 | aggregate_failures do 30 | expect(client2.read('$all', **read_opts).map(&:id)).to eq([event.id]) 31 | expect(client1.read('$all', **read_opts)).to be_empty 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/options/streams/write_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Options 6 | module Streams 7 | class WriteOptions 8 | attr_reader :stream_name, :options 9 | private :stream_name, :options 10 | 11 | # @param stream_name [String] 12 | # @param options [Hash] 13 | # @option [Integer] :expected_revision 14 | # @option [Symbol] :expected_revision either :any, :no_stream or :stream_exists 15 | def initialize(stream_name, options) 16 | @stream_name = stream_name 17 | @options = options 18 | end 19 | 20 | # @return [Hash] see event_store.client.streams.AppendReq.Options for available options 21 | def request_options 22 | revision_opt = 23 | case options[:expected_revision] 24 | when :any, :no_stream, :stream_exists 25 | { options[:expected_revision] => EventStore::Client::Empty.new } 26 | when Integer 27 | { revision: options[:expected_revision] } 28 | else 29 | { any: EventStore::Client::Empty.new } 30 | end 31 | revision_opt.merge(stream_identifier: { stream_name: stream_name }) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /docs/deleting_streams.md: -------------------------------------------------------------------------------- 1 | # Deleting Streams 2 | 3 | ## Soft deleting streams 4 | 5 | When you read a soft deleted stream, the read raises `EventStoreClient::StreamNotFoundError` error. After deleting the stream, you are able to append to it again, continuing from where it left off. 6 | 7 | ```ruby 8 | EventStoreClient.client.delete_stream('some-stream') 9 | ``` 10 | 11 | ## Hard deleting streams 12 | 13 | A hard delete of a stream is permanent. You cannot append to the stream or recreate it. As such, you should generally soft delete streams unless you have a specific need to permanently delete the stream. 14 | 15 | ```ruby 16 | EventStoreClient.client.hard_delete_stream('some-stream') 17 | ``` 18 | 19 | ## User credentials 20 | 21 | You can provide user credentials to be used to delete the stream as follows. This will override the default credentials set on the connection. 22 | 23 | ```ruby 24 | EventStoreClient.client.delete_stream('some-stream', credentials: { username: 'admin', password: 'changeit' }) 25 | ``` 26 | 27 | ## Possible errors during stream deletion 28 | 29 | If you try to delete non-existing stream, or if you provided `:expected_revision` option with a value which doesn't match current stream's state - `EventStoreClient::StreamDeletionError` error will be raised: 30 | 31 | ```ruby 32 | begin 33 | EventStoreClient.client.delete_stream('non-existing-stream') 34 | rescue => e 35 | puts e.message 36 | end 37 | ``` 38 | -------------------------------------------------------------------------------- /lib/event_store_client/mapper/default.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Layout/LineLength 4 | 5 | module EventStoreClient 6 | module Mapper 7 | class Default 8 | attr_reader :serializer, :config 9 | private :serializer, :config 10 | 11 | # @param config [EventStoreClient::Config] 12 | # @param serializer [#serialize, #deserialize] 13 | def initialize(config:, serializer: Serializer::Json) 14 | @serializer = serializer 15 | @config = config 16 | end 17 | 18 | # @param event [EventStoreClient::DeserializedEvent] 19 | # @return [EventStoreClient::SerializedEvent] 20 | def serialize(event) 21 | Serializer::EventSerializer.call(event, serializer: serializer, config: config) 22 | end 23 | 24 | # @param event_or_raw_event [EventStoreClient::DeserializedEvent, EventStore::Client::Streams::ReadResp::ReadEvent::RecordedEvent, EventStore::Client::PersistentSubscriptions::ReadResp::ReadEvent::RecordedEvent] 25 | # @return event [EventStoreClient::DeserializedEvent] 26 | def deserialize(event_or_raw_event, **) 27 | return event_or_raw_event if event_or_raw_event.is_a?(EventStoreClient::DeserializedEvent) 28 | 29 | Serializer::EventDeserializer.call( 30 | event_or_raw_event, config: config, serializer: serializer 31 | ) 32 | end 33 | end 34 | end 35 | end 36 | # rubocop:enable Layout/LineLength 37 | -------------------------------------------------------------------------------- /.github/workflows/gem-push.yml: -------------------------------------------------------------------------------- 1 | name: Test & Publish 2 | 3 | on: 4 | push: 5 | branches: [ release ] 6 | 7 | jobs: 8 | build: 9 | name: Test 10 | runs-on: ubuntu-20.04 11 | strategy: 12 | matrix: 13 | ruby-version: ['3.0', '3.1', '3.2'] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1.147.0 19 | with: 20 | ruby-version: ${{ matrix.ruby-version }} 21 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 22 | - name: Run EventStore DB 23 | run: docker-compose -f docker-compose-cluster.yml up --detach 24 | - name: Run tests 25 | run: | 26 | bundle install 27 | sleep 10 28 | bundle exec rspec 29 | push: 30 | needs: build 31 | name: Publish to RubyGems 32 | runs-on: ubuntu-20.04 33 | 34 | steps: 35 | - uses: actions/checkout@v2 36 | - name: Set up Ruby 37 | uses: ruby/setup-ruby@v1.147.0 38 | with: 39 | ruby-version: 3.2 40 | - name: Publish 41 | run: | 42 | mkdir -p $HOME/.gem 43 | touch $HOME/.gem/credentials 44 | chmod 0600 $HOME/.gem/credentials 45 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 46 | gem build *.gemspec 47 | gem push *.gem 48 | env: 49 | GEM_HOST_API_KEY: ${{secrets.RUBYGEMS_AUTH_TOKEN}} 50 | -------------------------------------------------------------------------------- /spec/support/dummy_repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DummyRepository 4 | Message = Struct.new(:attributes) 5 | 6 | class Key 7 | attr_accessor :iv, :cipher, :id 8 | def initialize(id:, **) 9 | @id = id 10 | end 11 | 12 | def attributes 13 | {} 14 | end 15 | end 16 | 17 | # A simple implementation of in-memory key repo storage and simple encryptor/decryptor. Don't do 18 | # this in your real implementation! It is here just to emulate encryption/decryption lifecycle. 19 | class << self 20 | attr_accessor :repository 21 | 22 | def reset 23 | self.repository = {} 24 | end 25 | 26 | def encrypt(str) 27 | Base64.encode64(str) 28 | end 29 | 30 | def decrypt(str) 31 | Base64.decode64(str) 32 | end 33 | end 34 | reset 35 | 36 | def find(user_id) 37 | Dry::Monads::Success(Key.new(id: user_id)) 38 | end 39 | 40 | def encrypt(key:, message:) 41 | self.class.repository[key.id] = self.class.encrypt(message) 42 | message = Message.new({ message: self.class.repository[key.id] }) 43 | Dry::Monads::Success(message) 44 | end 45 | 46 | def decrypt(key:, message:) 47 | decrypted = 48 | if self.class.repository[key.id] 49 | self.class.decrypt(self.class.repository[key.id]) 50 | else 51 | {}.to_json 52 | end 53 | message = Message.new({ message: decrypted }) 54 | Dry::Monads::Success(message) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/generated/operations_services_pb.rb: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # Source: operations.proto for package 'event_store.client.operations' 3 | 4 | require 'grpc' 5 | require_relative 'operations_pb' 6 | 7 | module EventStore 8 | module Client 9 | module Operations 10 | module Operations 11 | class Service 12 | 13 | include ::GRPC::GenericService 14 | 15 | self.marshal_class_method = :encode 16 | self.unmarshal_class_method = :decode 17 | self.service_name = 'event_store.client.operations.Operations' 18 | 19 | rpc :StartScavenge, ::EventStore::Client::Operations::StartScavengeReq, ::EventStore::Client::Operations::ScavengeResp 20 | rpc :StopScavenge, ::EventStore::Client::Operations::StopScavengeReq, ::EventStore::Client::Operations::ScavengeResp 21 | rpc :Shutdown, ::EventStore::Client::Empty, ::EventStore::Client::Empty 22 | rpc :MergeIndexes, ::EventStore::Client::Empty, ::EventStore::Client::Empty 23 | rpc :ResignNode, ::EventStore::Client::Empty, ::EventStore::Client::Empty 24 | rpc :SetNodePriority, ::EventStore::Client::Operations::SetNodePriorityReq, ::EventStore::Client::Empty 25 | rpc :RestartPersistentSubscriptions, ::EventStore::Client::Empty, ::EventStore::Client::Empty 26 | end 27 | 28 | Stub = Service.rpc_stub_class 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/event_store_client/serialized_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | class SerializedEvent 5 | include Extensions::OptionsExtension 6 | 7 | option(:id) 8 | option(:data) 9 | option(:custom_metadata) 10 | option(:metadata) 11 | option(:serializer) 12 | 13 | # Constructs a hash that can be passed directly in the proposed_message attribute of the append 14 | # request, or it can be used to instantiate the raw EventStore event. 15 | # Example: 16 | # ```ruby 17 | # serialized_event = EventStoreClient::SerializedEvent.new( 18 | # id: 'some id', 19 | # data: { foo: :bar }, 20 | # custom_metadata: { bar: :baz }, 21 | # metadata: { baz: :foo }, 22 | # serializer: EventStoreClient::Serializer::Json 23 | # ) 24 | # # Compute proposed_message 25 | # EventStore::Client::Streams::AppendReq::ProposedMessage.new( 26 | # serialized_event.to_grpc 27 | # ) 28 | # # Compute raw event 29 | # EventStore::Client::Streams::ReadResp::ReadEvent::RecordedEvent.new( 30 | # serialized_event.to_grpc 31 | # ) 32 | # ``` 33 | # @return [Hash] 34 | def to_grpc 35 | { 36 | id: { string: id }, 37 | data: serializer.serialize(data).force_encoding('ASCII-8BIT'), 38 | custom_metadata: serializer.serialize(custom_metadata).force_encoding('ASCII-8BIT'), 39 | metadata: metadata 40 | } 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/commands/streams/read.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Commands 6 | module Streams 7 | class Read < Command 8 | use_request EventStore::Client::Streams::ReadReq 9 | use_service EventStore::Client::Streams::Streams::Stub 10 | 11 | # @api private 12 | # @see {EventStoreClient::GRPC::Client#read} 13 | def call(stream_name, options:, skip_deserialization:, skip_decryption:) 14 | options = normalize_options(stream_name, options) 15 | yield options if block_given? 16 | result = 17 | retry_request { service.read(request.new(options: options), metadata: metadata).to_a } 18 | EventStoreClient::GRPC::Shared::Streams::ProcessResponses.new(config: config).call( 19 | result, 20 | skip_deserialization, 21 | skip_decryption 22 | ) 23 | end 24 | 25 | private 26 | 27 | # @param stream_name [String] 28 | # @param options [Hash] 29 | # @return [EventStore::Client::Streams::ReadReq::Options] 30 | def normalize_options(stream_name, options) 31 | options = 32 | Options::Streams::ReadOptions. 33 | new(stream_name, options, config: config). 34 | request_options 35 | EventStore::Client::Streams::ReadReq::Options.new(options) 36 | end 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/generated/users_services_pb.rb: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # Source: users.proto for package 'event_store.client.users' 3 | 4 | require 'grpc' 5 | require_relative 'users_pb' 6 | 7 | module EventStore 8 | module Client 9 | module Users 10 | module Users 11 | class Service 12 | 13 | include ::GRPC::GenericService 14 | 15 | self.marshal_class_method = :encode 16 | self.unmarshal_class_method = :decode 17 | self.service_name = 'event_store.client.users.Users' 18 | 19 | rpc :Create, ::EventStore::Client::Users::CreateReq, ::EventStore::Client::Users::CreateResp 20 | rpc :Update, ::EventStore::Client::Users::UpdateReq, ::EventStore::Client::Users::UpdateResp 21 | rpc :Delete, ::EventStore::Client::Users::DeleteReq, ::EventStore::Client::Users::DeleteResp 22 | rpc :Disable, ::EventStore::Client::Users::DisableReq, ::EventStore::Client::Users::DisableResp 23 | rpc :Enable, ::EventStore::Client::Users::EnableReq, ::EventStore::Client::Users::EnableResp 24 | rpc :Details, ::EventStore::Client::Users::DetailsReq, stream(::EventStore::Client::Users::DetailsResp) 25 | rpc :ChangePassword, ::EventStore::Client::Users::ChangePasswordReq, ::EventStore::Client::Users::ChangePasswordResp 26 | rpc :ResetPassword, ::EventStore::Client::Users::ResetPasswordReq, ::EventStore::Client::Users::ResetPasswordResp 27 | end 28 | 29 | Stub = Service.rpc_stub_class 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/shared/streams/process_responses.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Shared 6 | module Streams 7 | class ProcessResponses 8 | attr_reader :config 9 | private :config 10 | 11 | # @param config [EventStoreClient::Config] 12 | def initialize(config:) 13 | @config = config 14 | end 15 | 16 | # @api private 17 | # @param responses [Array] 18 | # @param skip_deserialization [Boolean] 19 | # @param skip_decryption [Boolean] 20 | # @return [Array, Array] 21 | # @raise [EventStoreClient::StreamNotFoundError] 22 | def call(responses, skip_deserialization, skip_decryption) 23 | non_existing_stream = responses.first&.stream_not_found&.stream_identifier&.stream_name 24 | raise StreamNotFoundError, non_existing_stream if non_existing_stream 25 | return responses if skip_deserialization 26 | 27 | responses.map do |response| 28 | # It could be for 29 | # example. Such responses should be skipped. See generated files for more info. 30 | next unless response.event&.event 31 | 32 | config.mapper.deserialize(response.event.event, skip_decryption: skip_decryption) 33 | end.compact 34 | end 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/commands/streams/link_to_multiple.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Naming/PredicateName 4 | 5 | module EventStoreClient 6 | module GRPC 7 | module Commands 8 | module Streams 9 | class LinkToMultiple < Command 10 | # @api private 11 | # @see {EventStoreClient::GRPC::Client#link_to} 12 | def call(stream_name, events, options:, &blk) 13 | link_cmd = Commands::Streams::LinkTo.new(config: config, **connection_options) 14 | events.map.with_index do |event, index| 15 | link_cmd.call(stream_name, event, options: options) do |req_opts, proposed_msg_opts| 16 | req_opts.options.revision += index if has_revision_option?(req_opts.options) 17 | yield(req_opts, proposed_msg_opts) if blk 18 | end 19 | end 20 | end 21 | 22 | private 23 | 24 | # Even if #revision is not set explicitly - its value defaults to 0. Thus, you can't 25 | # detect whether #revision is set just by calling #revision method. Instead - check if 26 | # option does not set #no_stream, #any or #stream_exists options - they are self-exclusive 27 | # options and only one of them can be active at a time 28 | # @param options [EventStore::Client::Streams::AppendReq::Options] 29 | # @return [Boolean] 30 | def has_revision_option?(options) 31 | [options.no_stream, options.any, options.stream_exists].all?(&:nil?) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | end 38 | # rubocop:enable Naming/PredicateName 39 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/commands/streams/delete.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Commands 6 | module Streams 7 | class Delete < Command 8 | use_request EventStore::Client::Streams::DeleteReq 9 | use_service EventStore::Client::Streams::Streams::Stub 10 | 11 | # @api private 12 | # @see {EventStoreClient::GRPC::Client#delete_stream} 13 | def call(stream_name, options:, &blk) 14 | options = normalize_options(stream_name, options) 15 | yield options if blk 16 | retry_request { service.delete(request.new(options: options), metadata: metadata) } 17 | rescue ::GRPC::FailedPrecondition => e 18 | # GRPC::FailedPrecondition may happen for several reasons. For example, stream may not 19 | # be existing, or :expected_revision option value does not match the current state of 20 | # the stream. So, re-raise our own error, and pass there the original message - just in 21 | # case. 22 | raise StreamDeletionError.new(stream_name, details: e.message) 23 | end 24 | 25 | private 26 | 27 | # @param stream_name [String] 28 | # @param options [Hash] 29 | # @return [EventStore::Client::Streams::TombstoneReq::Options] 30 | def normalize_options(stream_name, options) 31 | opts = Options::Streams::WriteOptions.new(stream_name, options).request_options 32 | EventStore::Client::Streams::DeleteReq::Options.new(opts) 33 | end 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/event_store_client/data_encryptor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | class DataEncryptor 5 | def call 6 | return encrypted_data if encryption_metadata.empty? 7 | 8 | key_id = encryption_metadata[:key] 9 | res = key_repository.find(key_id) 10 | res = res.failure? ? key_repository.create(key_id) : res 11 | key = res.value! 12 | 13 | encryption_metadata[:iv] = key.attributes[:iv] 14 | encrypt_attributes( 15 | key: key, 16 | data: encrypted_data, 17 | attributes: encryption_metadata[:attributes].map(&:to_s) 18 | ) 19 | end 20 | 21 | attr_reader :encrypted_data, :encryption_metadata 22 | 23 | private 24 | 25 | attr_reader :key_repository 26 | 27 | def initialize(data:, schema:, repository:) 28 | @encrypted_data = deep_dup(data).transform_keys!(&:to_s) 29 | @key_repository = repository 30 | @encryption_metadata = EncryptionMetadata.new(data: data, schema: schema).call 31 | end 32 | 33 | def encrypt_attributes(key:, data:, attributes:) 34 | text = JSON.generate(data.select { |hash_key, _value| attributes.include?(hash_key.to_s) }) 35 | encrypted = key_repository.encrypt(key: key, message: text).value! 36 | attributes.each { |att| data[att.to_s] = 'es_encrypted' if data.key?(att.to_s) } 37 | data['es_encrypted'] = encrypted.attributes[:message] 38 | data 39 | end 40 | 41 | def deep_dup(hash) 42 | return hash unless hash.instance_of?(Hash) 43 | 44 | dupl = hash.dup 45 | dupl.each { |k, v| dupl[k] = v.instance_of?(Hash) ? deep_dup(v) : v } 46 | dupl 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/commands/streams/hard_delete.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Commands 6 | module Streams 7 | class HardDelete < Command 8 | use_request EventStore::Client::Streams::TombstoneReq 9 | use_service EventStore::Client::Streams::Streams::Stub 10 | 11 | # @api private 12 | # @see {EventStoreClient::GRPC::Client#hard_delete_stream} 13 | def call(stream_name, options:, &blk) 14 | options = normalize_options(stream_name, options) 15 | yield options if blk 16 | retry_request { service.delete(request.new(options: options), metadata: metadata) } 17 | rescue ::GRPC::FailedPrecondition => e 18 | # GRPC::FailedPrecondition may happen for several reasons. For example, stream may not 19 | # be existing, or :expected_revision option value does not match the current state of 20 | # the stream. So, re-raise our own error, and pass there the original message - just in 21 | # case. 22 | raise StreamDeletionError.new(stream_name, details: e.message) 23 | end 24 | 25 | private 26 | 27 | # @param stream_name [String] 28 | # @param options [Hash] 29 | # @return [EventStore::Client::Streams::TombstoneReq::Options] 30 | def normalize_options(stream_name, options) 31 | opts = Options::Streams::WriteOptions.new(stream_name, options).request_options 32 | EventStore::Client::Streams::TombstoneReq::Options.new(opts) 33 | end 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/commands/streams/delete_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Commands::Streams::Delete do 4 | subject { instance } 5 | 6 | let(:config) { EventStoreClient.config } 7 | let(:instance) { described_class.new(config: config) } 8 | 9 | it { is_expected.to be_a(EventStoreClient::GRPC::Commands::Command) } 10 | it 'uses correct params class' do 11 | expect(instance.request).to eq(EventStore::Client::Streams::DeleteReq) 12 | end 13 | it 'uses correct service' do 14 | expect(instance.service).to be_a(EventStore::Client::Streams::Streams::Stub) 15 | end 16 | 17 | describe '#call' do 18 | subject { instance.call(stream_name, options: {}) } 19 | 20 | let(:stream_name) { "some-stream$#{SecureRandom.uuid}" } 21 | 22 | describe 'deleting existing stream' do 23 | let(:event) do 24 | EventStoreClient::DeserializedEvent.new( 25 | id: SecureRandom.uuid, type: 'some-event', data: { foo: :bar } 26 | ) 27 | end 28 | 29 | before do 30 | EventStoreClient.client.append_to_stream(stream_name, event) 31 | end 32 | 33 | it 'deletes stream' do 34 | expect { subject }.to change { 35 | EventStoreClient.client.read(stream_name) rescue nil 36 | }.from(kind_of(Array)).to(nil) 37 | end 38 | it { is_expected.to be_a(EventStore::Client::Streams::DeleteResp) } 39 | end 40 | 41 | describe 'deleting non-existing stream' do 42 | it 'raises error' do 43 | expect { subject }.to( 44 | raise_error(EventStoreClient::StreamDeletionError, a_string_including(stream_name)) 45 | ) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/commands/streams/append_multiple.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Naming/PredicateName 4 | 5 | module EventStoreClient 6 | module GRPC 7 | module Commands 8 | module Streams 9 | class AppendMultiple < Command 10 | # @api private 11 | # @see {EventStoreClient::GRPC::Client#append_to_stream} 12 | def call(stream_name, events, options:, &blk) 13 | events.map.with_index do |event, index| 14 | Commands::Streams::Append.new( 15 | config: config, **connection_options 16 | ).call( 17 | stream_name, event, options: options 18 | ) do |req_opts, proposed_msg_opts| 19 | req_opts.options.revision += index if has_revision_option?(req_opts.options) 20 | 21 | yield(req_opts, proposed_msg_opts) if blk 22 | end 23 | end 24 | end 25 | 26 | private 27 | 28 | # Even if #revision is not set explicitly - its value defaults to 0. Thus, you can't 29 | # detect whether #revision is set just by calling #revision method. Instead - check if 30 | # option does not set #no_stream, #any or #stream_exists options - they are self-exclusive 31 | # options and only one of them can be active at a time 32 | # @param options [EventStore::Client::Streams::AppendReq::Options] 33 | # @return [Boolean] 34 | def has_revision_option?(options) 35 | [options.no_stream, options.any, options.stream_exists].all?(&:nil?) 36 | end 37 | end 38 | end 39 | end 40 | end 41 | end 42 | # rubocop:enable Naming/PredicateName 43 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/commands/streams/hard_delete_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Commands::Streams::HardDelete do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new(config: config) } 7 | let(:config) { EventStoreClient.config } 8 | 9 | it { is_expected.to be_a(EventStoreClient::GRPC::Commands::Command) } 10 | it 'uses correct params class' do 11 | expect(instance.request).to eq(EventStore::Client::Streams::TombstoneReq) 12 | end 13 | it 'uses correct service' do 14 | expect(instance.service).to be_a(EventStore::Client::Streams::Streams::Stub) 15 | end 16 | 17 | describe '#call' do 18 | subject { instance.call(stream_name, options: {}) } 19 | 20 | let(:stream_name) { "some-stream$#{SecureRandom.uuid}" } 21 | 22 | describe 'deleting existing stream' do 23 | let(:event) do 24 | EventStoreClient::DeserializedEvent.new( 25 | id: SecureRandom.uuid, type: 'some-event', data: { foo: :bar } 26 | ) 27 | end 28 | 29 | before do 30 | EventStoreClient.client.append_to_stream(stream_name, event) 31 | end 32 | 33 | it 'deletes stream' do 34 | expect { subject }.to change { 35 | EventStoreClient.client.read(stream_name) rescue nil 36 | }.from(kind_of(Array)).to(nil) 37 | end 38 | it { is_expected.to be_a(EventStore::Client::Streams::DeleteResp) } 39 | end 40 | 41 | describe 'deleting non-existing stream' do 42 | it 'raises error' do 43 | expect { subject }.to( 44 | raise_error(EventStoreClient::StreamDeletionError, a_string_including(stream_name)) 45 | ) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/cluster/insecure_connection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Cluster::InsecureConnection do 4 | subject { instance } 5 | 6 | let(:options) { { config: EventStoreClient.config } } 7 | let(:instance) { described_class.new(**options) } 8 | let(:member) { EventStoreClient::GRPC::Cluster::Member.new(host: 'host.local', port: 1234) } 9 | 10 | before do 11 | allow(EventStoreClient::GRPC::Discover).to receive(:current_member).and_return(member) 12 | end 13 | 14 | it { is_expected.to be_a(EventStoreClient::GRPC::Connection) } 15 | 16 | describe '.secure?' do 17 | subject { described_class.secure? } 18 | 19 | it { is_expected.to eq(false) } 20 | end 21 | 22 | describe '#call' do 23 | subject { instance.call(stub_class) } 24 | 25 | let(:stub_class) { EventStore::Client::Gossip::Gossip::Stub } 26 | 27 | before do 28 | EventStoreClient.config.eventstore_url.timeout = 1001 29 | end 30 | 31 | it { is_expected.to be_a(stub_class) } 32 | it 'has correct host' do 33 | host = subject.instance_variable_get(:@host) 34 | expect(host).to eq("#{member.host}:#{member.port}") 35 | end 36 | it 'has correct timeout' do 37 | timeout = subject.instance_variable_get(:@timeout) 38 | expect(timeout).to eq(EventStoreClient.config.eventstore_url.timeout / 1000.0) 39 | end 40 | 41 | describe 'real request' do 42 | subject { super().read(EventStore::Client::Empty.new) } 43 | 44 | before do 45 | allow(EventStoreClient::GRPC::Discover).to receive(:current_member).and_call_original 46 | end 47 | 48 | it 'does not raise any errors' do 49 | expect { subject }.not_to raise_error 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/event_store_client/data_decryptor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | class DataDecryptor 5 | KeyNotFoundError = Class.new(StandardError) 6 | 7 | def call 8 | return encrypted_data if encryption_metadata.empty? 9 | result = find_key(encryption_metadata['key']) 10 | return encrypted_data unless result.success? 11 | 12 | decrypt_attributes( 13 | key: result.value!, 14 | data: encrypted_data, 15 | attributes: encryption_metadata['attributes'] 16 | ) 17 | end 18 | 19 | private 20 | 21 | attr_reader :key_repository, :encryption_metadata, :encrypted_data 22 | 23 | def initialize(data:, schema:, repository:) 24 | @encrypted_data = deep_dup(data).transform_keys!(&:to_s) 25 | @key_repository = repository 26 | @encryption_metadata = schema&.transform_keys(&:to_s) || {} 27 | end 28 | 29 | def decrypt_attributes(key:, data:, attributes: {}) # rubocop:disable Lint/UnusedMethodArgument 30 | return data unless key 31 | 32 | res = key_repository.decrypt(key: key, message: data['es_encrypted']) 33 | return data if res.failure? 34 | 35 | decrypted_text = res.value! 36 | decrypted = JSON.parse(decrypted_text.attributes[:message]).transform_keys(&:to_s) 37 | decrypted.each { |k, value| data[k] = value if data.key?(k) } 38 | data.delete('es_encrypted') 39 | data 40 | end 41 | 42 | def deep_dup(hash) 43 | return hash unless hash.instance_of?(Hash) 44 | 45 | dupl = hash.dup 46 | dupl.each { |k, v| dupl[k] = v.instance_of?(Hash) ? deep_dup(v) : v } 47 | dupl 48 | end 49 | 50 | # @return [Dry::Monads::Result] 51 | def find_key(identifier) 52 | key_repository.find(identifier) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/generated/persistent_services_pb.rb: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # Source: persistent.proto for package 'event_store.client.persistent_subscriptions' 3 | 4 | require 'grpc' 5 | require_relative 'persistent_pb' 6 | 7 | module EventStore 8 | module Client 9 | module PersistentSubscriptions 10 | module PersistentSubscriptions 11 | class Service 12 | 13 | include ::GRPC::GenericService 14 | 15 | self.marshal_class_method = :encode 16 | self.unmarshal_class_method = :decode 17 | self.service_name = 'event_store.client.persistent_subscriptions.PersistentSubscriptions' 18 | 19 | rpc :Create, ::EventStore::Client::PersistentSubscriptions::CreateReq, ::EventStore::Client::PersistentSubscriptions::CreateResp 20 | rpc :Update, ::EventStore::Client::PersistentSubscriptions::UpdateReq, ::EventStore::Client::PersistentSubscriptions::UpdateResp 21 | rpc :Delete, ::EventStore::Client::PersistentSubscriptions::DeleteReq, ::EventStore::Client::PersistentSubscriptions::DeleteResp 22 | rpc :Read, stream(::EventStore::Client::PersistentSubscriptions::ReadReq), stream(::EventStore::Client::PersistentSubscriptions::ReadResp) 23 | rpc :GetInfo, ::EventStore::Client::PersistentSubscriptions::GetInfoReq, ::EventStore::Client::PersistentSubscriptions::GetInfoResp 24 | rpc :ReplayParked, ::EventStore::Client::PersistentSubscriptions::ReplayParkedReq, ::EventStore::Client::PersistentSubscriptions::ReplayParkedResp 25 | rpc :List, ::EventStore::Client::PersistentSubscriptions::ListReq, ::EventStore::Client::PersistentSubscriptions::ListResp 26 | rpc :RestartSubsystem, ::EventStore::Client::Empty, ::EventStore::Client::Empty 27 | end 28 | 29 | Stub = Service.rpc_stub_class 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/generated/projections_services_pb.rb: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # Source: projections.proto for package 'event_store.client.projections' 3 | 4 | require 'grpc' 5 | require_relative 'projections_pb' 6 | 7 | module EventStore 8 | module Client 9 | module Projections 10 | module Projections 11 | class Service 12 | 13 | include ::GRPC::GenericService 14 | 15 | self.marshal_class_method = :encode 16 | self.unmarshal_class_method = :decode 17 | self.service_name = 'event_store.client.projections.Projections' 18 | 19 | rpc :Create, ::EventStore::Client::Projections::CreateReq, ::EventStore::Client::Projections::CreateResp 20 | rpc :Update, ::EventStore::Client::Projections::UpdateReq, ::EventStore::Client::Projections::UpdateResp 21 | rpc :Delete, ::EventStore::Client::Projections::DeleteReq, ::EventStore::Client::Projections::DeleteResp 22 | rpc :Statistics, ::EventStore::Client::Projections::StatisticsReq, stream(::EventStore::Client::Projections::StatisticsResp) 23 | rpc :Disable, ::EventStore::Client::Projections::DisableReq, ::EventStore::Client::Projections::DisableResp 24 | rpc :Enable, ::EventStore::Client::Projections::EnableReq, ::EventStore::Client::Projections::EnableResp 25 | rpc :Reset, ::EventStore::Client::Projections::ResetReq, ::EventStore::Client::Projections::ResetResp 26 | rpc :State, ::EventStore::Client::Projections::StateReq, ::EventStore::Client::Projections::StateResp 27 | rpc :Result, ::EventStore::Client::Projections::ResultReq, ::EventStore::Client::Projections::ResultResp 28 | rpc :RestartSubsystem, ::EventStore::Client::Empty, ::EventStore::Client::Empty 29 | end 30 | 31 | Stub = Service.rpc_stub_class 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /event_store_client.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path('lib', __dir__) 4 | 5 | # Maintain your gem's version: 6 | require 'event_store_client/version' 7 | 8 | # Describe your gem and declare its dependencies: 9 | Gem::Specification.new do |spec| 10 | spec.name = 'event_store_client' 11 | spec.version = EventStoreClient::VERSION 12 | spec.authors = ['Sebastian Wilgosz'] 13 | spec.email = ['sebastian@driggl.com'] 14 | spec.homepage = 'https://github.com/yousty/event_store_client' 15 | spec.summary = 'Ruby integration for https://eventstore.org' 16 | spec.description = 'Easy to use client for event-sources applications written in ruby' 17 | spec.license = 'MIT' 18 | spec.required_ruby_version = '>= 3.0.0' 19 | 20 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 21 | # to allow pushing to a single host or delete this section to allow pushing to any host. 22 | if spec.respond_to?(:metadata) 23 | spec.metadata['allowed_push_host'] = 'https://rubygems.org' 24 | else 25 | raise 'RubyGems 2.0 or newer is required to protect against ' \ 26 | 'public gem pushes.' 27 | end 28 | 29 | spec.files = Dir['{app,config,db,lib}/**/*', 'LICENSE.txt', 'Rakefile', 'README.md', 'docs/**/*'] 30 | 31 | spec.add_dependency 'grpc', '~> 1.0' 32 | 33 | spec.add_development_dependency 'pry', '~> 0.14' 34 | spec.add_development_dependency 'rspec', '~> 3.12' 35 | spec.add_development_dependency 'simplecov', '~> 0.21' 36 | spec.add_development_dependency 'simplecov-formatter-badge', '~> 0.1' 37 | spec.add_development_dependency 'rake', '~> 13.0' 38 | spec.add_development_dependency 'grpc-tools', '~> 1.46' 39 | spec.add_development_dependency 'timecop', '~> 0.9.5' 40 | spec.add_development_dependency 'dry-schema', '~> 1.13.0' 41 | spec.add_development_dependency 'dry-monads', '~> 1.6' 42 | end 43 | -------------------------------------------------------------------------------- /spec/event_store_client/event_class_resolver_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::EventClassResolver do 4 | let(:instance) { described_class.new(config) } 5 | let(:config) { EventStoreClient.config } 6 | 7 | describe '#resolve' do 8 | subject { instance.resolve(event_type) } 9 | 10 | let(:event_type) { 'SomeEvent' } 11 | 12 | context 'when event type matches existing class' do 13 | let(:event_class) { Class.new(EventStoreClient::DeserializedEvent) } 14 | 15 | before do 16 | stub_const(event_type, event_class) 17 | end 18 | 19 | it { is_expected.to eq(SomeEvent) } 20 | end 21 | 22 | context 'when event type is nil' do 23 | let(:event_type) { nil } 24 | 25 | it { is_expected.to eq(EventStoreClient::DeserializedEvent) } 26 | end 27 | 28 | context 'when event type is something else' do 29 | let(:event_type) { Object.new } 30 | 31 | it { is_expected.to eq(EventStoreClient::DeserializedEvent) } 32 | end 33 | 34 | context 'when custom default event class is provided' do 35 | before do 36 | stub_const('SomeClass', Class.new) 37 | config.default_event_class = SomeClass 38 | end 39 | 40 | it { is_expected.to eq(SomeClass) } 41 | end 42 | 43 | context 'when custom event class resolver is defined' do 44 | before do 45 | stub_const('SomeClass', Class.new(EventStoreClient::DeserializedEvent)) 46 | config.event_class_resolver = ->(event_type) { SomeClass } 47 | end 48 | 49 | it 'respects it' do 50 | is_expected.to eq(SomeClass) 51 | end 52 | end 53 | 54 | context 'when custom event class resolver returns nil' do 55 | before do 56 | config.event_class_resolver = ->(event_type) { } 57 | end 58 | 59 | it { is_expected.to eq(EventStoreClient::DeserializedEvent) } 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/generated/cluster_services_pb.rb: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # Source: cluster.proto for package 'event_store.cluster' 3 | 4 | require 'grpc' 5 | require_relative 'cluster_pb' 6 | 7 | module EventStore 8 | module Cluster 9 | module Gossip 10 | class Service 11 | 12 | include ::GRPC::GenericService 13 | 14 | self.marshal_class_method = :encode 15 | self.unmarshal_class_method = :decode 16 | self.service_name = 'event_store.cluster.Gossip' 17 | 18 | rpc :Update, ::EventStore::Cluster::GossipRequest, ::EventStore::Cluster::ClusterInfo 19 | rpc :Read, ::EventStore::Client::Empty, ::EventStore::Cluster::ClusterInfo 20 | end 21 | 22 | Stub = Service.rpc_stub_class 23 | end 24 | module Elections 25 | class Service 26 | 27 | include ::GRPC::GenericService 28 | 29 | self.marshal_class_method = :encode 30 | self.unmarshal_class_method = :decode 31 | self.service_name = 'event_store.cluster.Elections' 32 | 33 | rpc :ViewChange, ::EventStore::Cluster::ViewChangeRequest, ::EventStore::Client::Empty 34 | rpc :ViewChangeProof, ::EventStore::Cluster::ViewChangeProofRequest, ::EventStore::Client::Empty 35 | rpc :Prepare, ::EventStore::Cluster::PrepareRequest, ::EventStore::Client::Empty 36 | rpc :PrepareOk, ::EventStore::Cluster::PrepareOkRequest, ::EventStore::Client::Empty 37 | rpc :Proposal, ::EventStore::Cluster::ProposalRequest, ::EventStore::Client::Empty 38 | rpc :Accept, ::EventStore::Cluster::AcceptRequest, ::EventStore::Client::Empty 39 | rpc :LeaderIsResigning, ::EventStore::Cluster::LeaderIsResigningRequest, ::EventStore::Client::Empty 40 | rpc :LeaderIsResigningOk, ::EventStore::Cluster::LeaderIsResigningOkRequest, ::EventStore::Client::Empty 41 | end 42 | 43 | Stub = Service.rpc_stub_class 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/event_store_client/data_encryptor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::DataEncryptor do 4 | describe '#call' do 5 | subject { instance.call } 6 | 7 | let(:instance) { described_class.new(data: data, schema: schema, repository: repository) } 8 | let(:repository) { DummyRepository.new } 9 | let(:key_repository) { DummyRepository.new } 10 | let(:user_id) { SecureRandom.uuid } 11 | let(:data) do 12 | { 13 | user_id: user_id, 14 | first_name: 'Anakin', 15 | last_name: 'Skywalker', 16 | profession: 'Jedi' 17 | } 18 | end 19 | let(:schema) do 20 | { 21 | key: ->(data) { data[:user_id] }, 22 | attributes: %i[first_name last_name] 23 | } 24 | end 25 | let(:message_to_encrypt) { data.slice(:first_name, :last_name).to_json } 26 | 27 | it 'returns encrypted data' do 28 | expect(subject).to eq( 29 | 'user_id' => user_id, 30 | 'first_name' => 'es_encrypted', 31 | 'last_name' => 'es_encrypted', 32 | 'profession' => 'Jedi', 33 | 'es_encrypted' => DummyRepository.encrypt(message_to_encrypt) 34 | ) 35 | end 36 | it 'updates the encrypted data reader' do 37 | subject 38 | expect(instance.encrypted_data).to eq( 39 | 'user_id' => user_id, 40 | 'first_name' => 'es_encrypted', 41 | 'last_name' => 'es_encrypted', 42 | 'profession' => 'Jedi', 43 | 'es_encrypted' => DummyRepository.encrypt(message_to_encrypt) 44 | ) 45 | end 46 | it 'skips the encryption of non-existing keys' do 47 | schema[:attributes] << :side 48 | subject 49 | expect(instance.encrypted_data).to eq( 50 | 'user_id' => user_id, 51 | 'first_name' => 'es_encrypted', 52 | 'last_name' => 'es_encrypted', 53 | 'profession' => 'Jedi', 54 | 'es_encrypted' => DummyRepository.encrypt(message_to_encrypt) 55 | ) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/event_store_client/serializer/json_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::Serializer::Json do 4 | describe '.deserialize' do 5 | subject { described_class.deserialize(data) } 6 | 7 | let(:data) { { foo: :bar } } 8 | 9 | context 'when data is a hash' do 10 | it 'returns it' do 11 | is_expected.to eq(data) 12 | end 13 | end 14 | 15 | context 'when data is a string' do 16 | let(:data) { { foo: :bar }.to_json } 17 | 18 | context 'when it is a correct JSON string' do 19 | context 'when it is parsed into a hash' do 20 | it { is_expected.to eq('foo' => 'bar') } 21 | end 22 | 23 | context 'when it is parsed in something else' do 24 | let(:data) { [:foo, :bar].to_json } 25 | 26 | it 'wraps it into a hash' do 27 | is_expected.to eq('message' => ['foo', 'bar']) 28 | end 29 | end 30 | end 31 | 32 | context 'when it is an incorrect JSON' do 33 | let(:data) { 'foo' } 34 | 35 | it 'wraps it into a hash' do 36 | is_expected.to eq('message' => data) 37 | end 38 | end 39 | end 40 | 41 | context 'when data is something else' do 42 | let(:data) { Object.new } 43 | 44 | it 'raises error' do 45 | expect { subject }.to raise_error(TypeError) 46 | end 47 | end 48 | end 49 | 50 | describe '.serialize' do 51 | subject { described_class.serialize(data) } 52 | 53 | let(:data) { 'some data' } 54 | 55 | context 'when data is a string' do 56 | it 'returns it' do 57 | is_expected.to eq(data) 58 | end 59 | it 'dups it' do 60 | expect(subject.__id__).not_to eq(data.__id__) 61 | end 62 | it { is_expected.not_to be_frozen } 63 | end 64 | 65 | context 'when data is something else' do 66 | let(:data) { { foo: :bar } } 67 | 68 | it 'serializes it' do 69 | is_expected.to eq(data.to_json) 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/commands/streams/link_to_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Commands::Streams::LinkTo do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new(config: config) } 7 | let(:config) { EventStoreClient.config } 8 | 9 | it { is_expected.to be_a(EventStoreClient::GRPC::Commands::Command) } 10 | 11 | describe '#call' do 12 | subject { instance.call(stream_name, event, options: options) } 13 | 14 | let(:stream_name) { "some-stream$#{SecureRandom.uuid}" } 15 | let(:other_stream_name) { "other-stream$#{SecureRandom.uuid}" } 16 | let(:options) { {} } 17 | let(:event) do 18 | event = EventStoreClient::DeserializedEvent.new(type: 'some-event', data: { foo: :bar }) 19 | append_and_reload(other_stream_name, event) 20 | end 21 | 22 | it 'links event' do 23 | expect { subject }.to change { safe_read(stream_name)&.size }.to(1) 24 | end 25 | 26 | describe 'linked event' do 27 | subject { super(); safe_read(stream_name).last } 28 | 29 | it 'has the same event id' do 30 | expect(subject.id).to eq(event.id) 31 | end 32 | it 'has special event property' do 33 | expect(subject.type).to eq('$>') 34 | end 35 | it 'resolves to the stream it was appended' do 36 | expect(subject.stream_name).to eq(stream_name) 37 | end 38 | it 'has its own title' do 39 | aggregate_failures do 40 | expect(subject.title).not_to be_empty 41 | expect(subject.title).to include(stream_name) 42 | expect(subject.title).not_to eq(event.title) 43 | end 44 | end 45 | it 'has proper metadata', timecop: true do 46 | expect(subject.metadata).to( 47 | include( 48 | 'type' => '$>', 'content-type' => 'application/json' 49 | ) 50 | ) 51 | end 52 | it 'has proper data' do 53 | expect(subject.data).to eq('message' => event.title) 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /.overcommit.yml: -------------------------------------------------------------------------------- 1 | # Use this file to configure the Overcommit hooks you wish to use. This will 2 | # extend the default configuration defined in: 3 | # https://github.com/brigade/overcommit/blob/master/config/default.yml 4 | # 5 | # At the topmost level of this YAML file is a key representing type of hook 6 | # being run (e.g. pre-commit, commit-msg, etc.). Within each type you can 7 | # customize each hook, such as whether to only run it on certain files (via 8 | # `include`), whether to only display output if it fails (via `quiet`), etc. 9 | # 10 | # For a complete list of hooks, see: 11 | # https://github.com/brigade/overcommit/tree/master/lib/overcommit/hook 12 | # 13 | # For a complete list of options that you can use to customize hooks, see: 14 | # https://github.com/brigade/overcommit#configuration 15 | # 16 | # Uncomment the following lines to make the configuration take effect. 17 | 18 | PreCommit: 19 | RuboCop: 20 | enabled: true 21 | on_warn: pass # Treat all warnings as failures 22 | problem_on_unmodified_line: warn 23 | 24 | CommitMsg: 25 | MessageFormat: 26 | enabled: true 27 | description: "Check commit message matches expected pattern" 28 | pattern: '((v\d{1,3}\.\d{1,2}\.\d{1,2})|(Cleanup|Remove|Fix|Refactor|Change|Add|Move|Rename|Upgrade|Downgrade|Revert|Merge|Release|Enable|Disable|Activate|Deactivate))\s?.*' 29 | expected_pattern_message: " " 30 | sample_message: "Change title image alignment" 31 | # 32 | # TrailingWhitespace: 33 | # enabled: true 34 | # exclude: 35 | # - '**/db/structure.sql' # Ignore trailing whitespace in generated files 36 | # 37 | #PostCheckout: 38 | # ALL: # Special hook name that customizes all hooks of this type 39 | # quiet: true # Change all post-checkout hooks to only display output on failure 40 | # 41 | # IndexTags: 42 | # enabled: true # Generate a tags file with `ctags` each time HEAD changes 43 | -------------------------------------------------------------------------------- /docs/encrypting_events.md: -------------------------------------------------------------------------------- 1 | # Encrypting Events 2 | 3 | To encrypt/decrypt events payload, you can use an encrypted mapper. 4 | 5 | 6 | ```ruby 7 | EventStoreClient.configure do |config| 8 | config.mapper = EventStoreClient::Mapper::Encrypted.new(key_repository, config: config) 9 | end 10 | ``` 11 | 12 | The Encrypted mapper uses the encryption key repository to encrypt data in your events according to the event definition. 13 | 14 | Here is the minimal repository interface for this to work. 15 | 16 | ```ruby 17 | class DummyRepository 18 | class Key 19 | attr_accessor :iv, :cipher, :id 20 | def initialize(id:, **) 21 | @id = id 22 | end 23 | end 24 | 25 | def find(user_id) 26 | Key.new(id: user_id) 27 | end 28 | 29 | def encrypt(*) 30 | 'darthvader' 31 | end 32 | 33 | def decrypt(*) 34 | { first_name: 'Anakin', last_name: 'Skywalker'} 35 | end 36 | end 37 | ``` 38 | 39 | Now, having that, you only need to define the event encryption schema: 40 | 41 | ```ruby 42 | class EncryptedEvent < EventStoreClient::DeserializedEvent 43 | def schema 44 | Dry::Schema.Params do 45 | required(:user_id).value(:string) 46 | required(:first_name).value(:string) 47 | required(:last_name).value(:string) 48 | required(:profession).value(:string) 49 | end 50 | end 51 | 52 | def self.encryption_schema 53 | { 54 | key: ->(data) { data['user_id'] }, 55 | attributes: %i[first_name last_name email] 56 | } 57 | end 58 | end 59 | 60 | event = EncryptedEvent.new( 61 | user_id: SecureRandom.uuid, 62 | first_name: 'Anakin', 63 | last_name: 'Skywalker', 64 | profession: 'Jedi' 65 | ) 66 | ``` 67 | 68 | When you publish this event, the eventstore will store this payload: 69 | 70 | ```ruby 71 | { 72 | 'data' => { 73 | 'user_id' => 'dab48d26-e4f8-41fc-a9a8-59657e590716', 74 | 'first_name' => 'encrypted', 75 | 'last_name' => 'encrypted', 76 | 'profession' => 'Jedi', 77 | 'encrypted' => '2345l423lj1#$!lkj24f1' 78 | }, 79 | type: 'EncryptedEvent' 80 | metadata: { ... } 81 | } 82 | ``` 83 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/commands/streams/subscribe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Commands 6 | module Streams 7 | class Subscribe < Command 8 | use_request EventStore::Client::Streams::ReadReq 9 | use_service EventStore::Client::Streams::Streams::Stub 10 | 11 | def initialize(**conn_options) 12 | # Subscriptions should never be timed out 13 | super(**conn_options.merge(timeout: nil)) 14 | end 15 | 16 | # @api private 17 | # @see {EventStoreClient::GRPC::Client#read} 18 | def call(stream_name, handler:, options:, skip_deserialization:, skip_decryption:) 19 | options = normalize_options(stream_name, options) 20 | yield options if block_given? 21 | 22 | callback = proc do |response| 23 | result = Shared::Streams::ProcessResponse.new(config: config).call( 24 | response, 25 | skip_deserialization, 26 | skip_decryption 27 | ) 28 | 29 | handler.call(result) if result 30 | end 31 | retry_request do 32 | service.read(request.new(options: options), metadata: metadata, &callback) 33 | end 34 | end 35 | 36 | private 37 | 38 | # @param stream_name [String] 39 | # @param options [Hash] 40 | # @return [EventStore::Client::Streams::ReadReq::Options] 41 | def normalize_options(stream_name, options) 42 | options = 43 | Options::Streams::ReadOptions. 44 | new(stream_name, options, config: config). 45 | request_options 46 | EventStore::Client::Streams::ReadReq::Options.new(options).tap do |opts| 47 | opts.subscription = 48 | EventStore::Client::Streams::ReadReq::Options::SubscriptionOptions.new 49 | end 50 | end 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'grpc' 4 | 5 | # Load all generated by google-protobuf files 6 | Dir[File.expand_path('grpc/generated/*.rb', __dir__)].each { |f| require f } 7 | 8 | require 'event_store_client/adapters/grpc/options/streams/read_options' 9 | require 'event_store_client/adapters/grpc/options/streams/write_options' 10 | 11 | require 'event_store_client/adapters/grpc/shared/options/stream_options' 12 | require 'event_store_client/adapters/grpc/shared/options/filter_options' 13 | require 'event_store_client/adapters/grpc/shared/streams/process_response' 14 | require 'event_store_client/adapters/grpc/shared/streams/process_responses' 15 | 16 | require 'event_store_client/adapters/grpc/connection' 17 | require 'event_store_client/adapters/grpc/discover' 18 | require 'event_store_client/adapters/grpc/cluster/insecure_connection' 19 | require 'event_store_client/adapters/grpc/cluster/secure_connection' 20 | require 'event_store_client/adapters/grpc/cluster/queryless_discover' 21 | require 'event_store_client/adapters/grpc/cluster/gossip_discover' 22 | require 'event_store_client/adapters/grpc/cluster/member' 23 | 24 | require 'event_store_client/adapters/grpc/command_registrar' 25 | require 'event_store_client/adapters/grpc/commands/command' 26 | 27 | require 'event_store_client/adapters/grpc/commands/gossip/cluster_info' 28 | 29 | require 'event_store_client/adapters/grpc/commands/streams/append' 30 | require 'event_store_client/adapters/grpc/commands/streams/append_multiple' 31 | require 'event_store_client/adapters/grpc/commands/streams/delete' 32 | require 'event_store_client/adapters/grpc/commands/streams/hard_delete' 33 | require 'event_store_client/adapters/grpc/commands/streams/link_to' 34 | require 'event_store_client/adapters/grpc/commands/streams/link_to_multiple' 35 | require 'event_store_client/adapters/grpc/commands/streams/read' 36 | require 'event_store_client/adapters/grpc/commands/streams/read_paginated' 37 | require 'event_store_client/adapters/grpc/commands/streams/subscribe' 38 | 39 | require 'event_store_client/adapters/grpc/client' 40 | -------------------------------------------------------------------------------- /lib/event_store_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'set' 5 | require 'securerandom' 6 | 7 | require 'event_store_client/errors' 8 | require 'event_store_client/serializer/json' 9 | require 'event_store_client/serializer/event_serializer' 10 | require 'event_store_client/serializer/event_deserializer' 11 | 12 | require 'event_store_client/extensions/options_extension' 13 | 14 | require 'event_store_client/utils' 15 | 16 | require 'event_store_client/connection/url' 17 | require 'event_store_client/connection/url_parser' 18 | require 'event_store_client/deserialized_event' 19 | require 'event_store_client/serialized_event' 20 | require 'event_store_client/event_class_resolver' 21 | require 'event_store_client/config' 22 | 23 | require 'event_store_client/mapper' 24 | 25 | require 'event_store_client/adapters/grpc' 26 | 27 | module EventStoreClient 28 | class << self 29 | # @param name [Symbol, String] 30 | def configure(name: :default) 31 | yield(config(name)) if block_given? 32 | end 33 | 34 | # @param name [Symbol, String] 35 | # @return [EventStoreClient::Config] 36 | def config(name = :default) 37 | @config[name] ||= Config.new(name: name) 38 | end 39 | 40 | # @param config_name [Symbol, String] 41 | # @return [EventStore::GRPC::Client] 42 | def client(config_name: :default) 43 | GRPC::Client.new(_config(config_name)) 44 | end 45 | 46 | # @return [void] 47 | def init_default_config 48 | @config = { default: Config.new } 49 | end 50 | 51 | private 52 | 53 | # @param config [Symbol, String] 54 | # @return [EventStoreClient::Config] 55 | # @raise [RuntimeError] 56 | def _config(config) 57 | return @config[config] if @config[config] 58 | 59 | error_message = <<~TEXT 60 | Could not find #{config.inspect} config. You can define it in next way: 61 | EventStoreClient.configure(name: #{config.inspect}) do |config| 62 | # your config goes here 63 | end 64 | TEXT 65 | raise error_message 66 | end 67 | end 68 | init_default_config 69 | end 70 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/options/streams/write_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Options::Streams::WriteOptions do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new(stream_name, options) } 7 | let(:stream_name) { 'some=stream' } 8 | let(:options) { {} } 9 | 10 | describe '#request_options' do 11 | subject { instance.request_options } 12 | 13 | context 'when options is empty' do 14 | it 'returns default value' do 15 | is_expected.to( 16 | eq(stream_identifier: { stream_name: stream_name }, any: EventStore::Client::Empty.new) 17 | ) 18 | end 19 | end 20 | 21 | context 'when :expected_revision option is :any' do 22 | let(:options) { { expected_revision: :any } } 23 | 24 | it 'recognizes it' do 25 | is_expected.to( 26 | eq(stream_identifier: { stream_name: stream_name }, any: EventStore::Client::Empty.new) 27 | ) 28 | end 29 | end 30 | 31 | context 'when :expected_revision option is :no_stream' do 32 | let(:options) { { expected_revision: :no_stream } } 33 | 34 | it 'recognizes it' do 35 | is_expected.to( 36 | eq( 37 | stream_identifier: { stream_name: stream_name }, 38 | no_stream: EventStore::Client::Empty.new 39 | ) 40 | ) 41 | end 42 | end 43 | 44 | context 'when :expected_revision option is :stream_exists' do 45 | let(:options) { { expected_revision: :stream_exists } } 46 | 47 | it 'recognizes it' do 48 | is_expected.to( 49 | eq( 50 | stream_identifier: { stream_name: stream_name }, 51 | stream_exists: EventStore::Client::Empty.new 52 | ) 53 | ) 54 | end 55 | end 56 | 57 | context 'when :expected_revision option is Integer' do 58 | let(:options) { { expected_revision: 123 } } 59 | 60 | it 'recognizes it' do 61 | is_expected.to( 62 | eq(stream_identifier: { stream_name: stream_name }, revision: 123) 63 | ) 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/event_store_client/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | class Config 5 | include Extensions::OptionsExtension 6 | 7 | CHANNEL_ARGS_DEFAULTS = { 8 | # These three options reduce delays between failed requests. 9 | 'grpc.min_reconnect_backoff_ms' => 100, # milliseconds 10 | 'grpc.max_reconnect_backoff_ms' => 100, # milliseconds 11 | 'grpc.initial_reconnect_backoff_ms' => 100 # milliseconds 12 | }.freeze 13 | 14 | option(:eventstore_url) { 'esdb://localhost:2113' } 15 | option(:per_page) { 20 } 16 | option(:mapper) { Mapper::Default.new(config: self) } 17 | option(:default_event_class) { DeserializedEvent } 18 | option(:logger) 19 | option(:skip_deserialization) { false } 20 | option(:skip_decryption) { false } 21 | # GRPC-specific connection options. This hash will be passed into the `:channel_args` argument 22 | # of a Stub class of your request. More GRPC options can be found here 23 | # https://github.com/grpc/grpc/blob/master/include/grpc/impl/codegen/grpc_types.h 24 | option(:channel_args) # Hash 25 | option(:name) { :default } 26 | option(:event_class_resolver) # Proc that excepts a string and returns a class 27 | 28 | def eventstore_url=(value) 29 | @eventstore_url = 30 | if value.is_a?(Connection::Url) 31 | value 32 | else 33 | Connection::UrlParser.new.call(value) 34 | end 35 | end 36 | 37 | # @param logger [Logger, nil] 38 | # @return [Logger, nil] 39 | def logger=(logger) 40 | ::GRPC.define_singleton_method :logger do 41 | @logger ||= logger.nil? ? ::GRPC::DefaultLogger::NoopLogger.new : logger 42 | end 43 | @logger = logger 44 | end 45 | 46 | # @param val [Hash, nil] 47 | # @return [Hash] 48 | def channel_args=(val) 49 | channel_args = CHANNEL_ARGS_DEFAULTS.merge(val&.transform_keys(&:to_s) || {}) 50 | # This options always defaults to `0`. This is because `event_store_client` implements its 51 | # own retry functional. 52 | channel_args['grpc.enable_retries'] = 0 53 | @channel_args = channel_args 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/generated/gossip_pb.rb: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # source: gossip.proto 3 | 4 | require 'google/protobuf' 5 | 6 | require_relative 'shared_pb' 7 | 8 | Google::Protobuf::DescriptorPool.generated_pool.build do 9 | add_file("gossip.proto", :syntax => :proto3) do 10 | add_message "event_store.client.gossip.ClusterInfo" do 11 | repeated :members, :message, 1, "event_store.client.gossip.MemberInfo" 12 | end 13 | add_message "event_store.client.gossip.EndPoint" do 14 | optional :address, :string, 1 15 | optional :port, :uint32, 2 16 | end 17 | add_message "event_store.client.gossip.MemberInfo" do 18 | optional :instance_id, :message, 1, "event_store.client.UUID" 19 | optional :time_stamp, :int64, 2 20 | optional :state, :enum, 3, "event_store.client.gossip.MemberInfo.VNodeState" 21 | optional :is_alive, :bool, 4 22 | optional :http_end_point, :message, 5, "event_store.client.gossip.EndPoint" 23 | end 24 | add_enum "event_store.client.gossip.MemberInfo.VNodeState" do 25 | value :Initializing, 0 26 | value :DiscoverLeader, 1 27 | value :Unknown, 2 28 | value :PreReplica, 3 29 | value :CatchingUp, 4 30 | value :Clone, 5 31 | value :Follower, 6 32 | value :PreLeader, 7 33 | value :Leader, 8 34 | value :Manager, 9 35 | value :ShuttingDown, 10 36 | value :Shutdown, 11 37 | value :ReadOnlyLeaderless, 12 38 | value :PreReadOnlyReplica, 13 39 | value :ReadOnlyReplica, 14 40 | value :ResigningLeader, 15 41 | end 42 | end 43 | end 44 | 45 | module EventStore 46 | module Client 47 | module Gossip 48 | ClusterInfo = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.gossip.ClusterInfo").msgclass 49 | EndPoint = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.gossip.EndPoint").msgclass 50 | MemberInfo = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.gossip.MemberInfo").msgclass 51 | MemberInfo::VNodeState = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.gossip.MemberInfo.VNodeState").enummodule 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/event_store_client/data_decryptor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::DataDecryptor do 4 | describe '#call' do 5 | subject { instance.call } 6 | 7 | let(:instance) { described_class.new(data: data, schema: schema, repository: repository) } 8 | let(:repository) { DummyRepository.new } 9 | let(:key_repository) { DummyRepository.new } 10 | let(:user_id) { SecureRandom.uuid } 11 | let(:data) do 12 | { 13 | 'user_id' => user_id, 14 | 'first_name' => 'es_encrypted', 15 | 'last_name' => 'es_encrypted', 16 | 'profession' => 'Jedi', 17 | 'es_encrypted' => DummyRepository.encrypt(message_to_encrypt) 18 | } 19 | end 20 | let(:decrypted_data) do 21 | { 22 | 'user_id' => user_id, 23 | 'first_name' => 'Anakin', 24 | 'last_name' => 'Skywalker', 25 | 'profession' => 'Jedi' 26 | } 27 | end 28 | let(:schema) do 29 | { 30 | key: user_id, 31 | attributes: %i[first_name last_name] 32 | } 33 | end 34 | let(:message_to_encrypt) { decrypted_data.slice('first_name', 'last_name').to_json } 35 | 36 | before do 37 | DummyRepository.new.encrypt( 38 | key: DummyRepository::Key.new(id: user_id), 39 | message: message_to_encrypt 40 | ) 41 | end 42 | 43 | it 'returns decrypted data' do 44 | expect(subject).to eq(decrypted_data) 45 | end 46 | it 'skips the decryption of non-existing keys' do 47 | schema[:attributes] << :side 48 | expect(subject).to eq(decrypted_data) 49 | end 50 | 51 | context 'when key is not found' do 52 | let(:failure) { Dry::Monads::Failure('some failure') } 53 | 54 | before do 55 | allow(repository).to receive(:find).and_return(failure) 56 | end 57 | 58 | it 'returns data as is' do 59 | expect(subject).to eq(data) 60 | end 61 | end 62 | 63 | context 'when data has not been encrypted (schema is nil)' do 64 | let(:instance) do 65 | described_class.new(data: decrypted_data, schema: nil, repository: repository) 66 | end 67 | 68 | it 'returns decrypted data' do 69 | expect(subject).to eq(decrypted_data) 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/event_store_client/wrong_expected_version_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::WrongExpectedVersionError do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new(wrong_expected_version, caused_by: caused_by) } 7 | let(:wrong_expected_version) { EventStore::Client::Streams::AppendResp::WrongExpectedVersion.new } 8 | let(:caused_by) { EventStoreClient::DeserializedEvent.new(id: '123') } 9 | 10 | it { is_expected.to be_a(EventStoreClient::Error) } 11 | 12 | describe '#wrong_expected_version' do 13 | subject { instance.wrong_expected_version } 14 | 15 | it { is_expected.to eq(wrong_expected_version) } 16 | end 17 | 18 | describe '#caused_by' do 19 | subject { instance.caused_by } 20 | 21 | it { is_expected.to eq(caused_by) } 22 | end 23 | 24 | describe '#message' do 25 | subject { instance.message } 26 | 27 | context 'when "expected stream to exist" error is returned' do 28 | before do 29 | wrong_expected_version.expected_stream_exists = EventStore::Client::Empty.new 30 | end 31 | 32 | it { is_expected.to eq("Expected stream to exist, but it doesn't.") } 33 | end 34 | 35 | context 'when "expected no stream to exist" error is returned' do 36 | before do 37 | wrong_expected_version.expected_no_stream = EventStore::Client::Empty.new 38 | end 39 | 40 | it { is_expected.to eq("Expected stream to be absent, but it actually exists.") } 41 | end 42 | 43 | context 'when stream revision is set, but stream does not exist' do 44 | before do 45 | wrong_expected_version.expected_revision = 1 46 | wrong_expected_version.current_no_stream = EventStore::Client::Empty.new 47 | end 48 | 49 | it { is_expected.to eq("Stream revision 1 is expected, but stream does not exist.") } 50 | end 51 | 52 | context 'when stream revision does not match the expected revision' do 53 | before do 54 | wrong_expected_version.current_revision = 1 55 | wrong_expected_version.expected_revision = 2 56 | end 57 | 58 | it { is_expected.to eq("Stream revision 2 is expected, but actual stream revision is 1.") } 59 | end 60 | 61 | context 'unhandled case' do 62 | it { is_expected.to eq(described_class.to_s) } 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'grpc' 4 | require 'base64' 5 | require 'net/http' 6 | 7 | module EventStoreClient 8 | module GRPC 9 | class Connection 10 | class << self 11 | # Resolve which connection class we instantiate, based on config.eventstore_url.tls config 12 | # option. If :new method is called from SecureConnection or InsecureConnection class - then 13 | # that particular class will be instantiated despite on config.eventstore_url.tls config 14 | # option. Example: 15 | # ```ruby 16 | # config.eventstore_url.tls = true 17 | # Connection.new # => # 18 | # 19 | # config.eventstore_url.tls = false 20 | # Connection.new # => # 21 | # 22 | # Cluster::SecureConnection.new 23 | # # => # 24 | # Cluster::InsecureConnection.new 25 | # # => # 26 | # ``` 27 | # @param config [EventStoreClient::Config] 28 | def new(config:, **options) 29 | return super unless self == Connection 30 | 31 | if config.eventstore_url.tls 32 | Cluster::SecureConnection.new(config: config, **options) 33 | else 34 | Cluster::InsecureConnection.new(config: config, **options) 35 | end 36 | end 37 | 38 | # Checks if connection class is secure 39 | # @return [Boolean] 40 | def secure? 41 | self == Cluster::SecureConnection 42 | end 43 | end 44 | 45 | include Extensions::OptionsExtension 46 | 47 | option(:host) { Discover.current_member(config: config).host } 48 | option(:port) { Discover.current_member(config: config).port } 49 | option(:username) { config.eventstore_url.username } 50 | option(:password) { config.eventstore_url.password } 51 | option(:timeout) { config.eventstore_url.timeout } 52 | 53 | attr_reader :config 54 | private :config 55 | 56 | def initialize(config:, **options) 57 | @config = config 58 | super 59 | end 60 | 61 | def call(stub_class) 62 | raise NotImplementedError 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/commands/streams/append.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Commands 6 | module Streams 7 | class Append < Command 8 | use_request EventStore::Client::Streams::AppendReq 9 | use_service EventStore::Client::Streams::Streams::Stub 10 | 11 | # @api private 12 | # @see {EventStoreClient::GRPC::Client#append_to_stream} 13 | def call(stream_name, event, options:, &blk) 14 | payload = 15 | [ 16 | request.new(options: options(stream_name, options)), 17 | request.new(proposed_message: proposed_message(event)) 18 | ] 19 | yield(*payload) if blk 20 | response = retry_request(skip_retry: config.eventstore_url.throw_on_append_failure) do 21 | service.append(payload, metadata: metadata) 22 | end 23 | validate_response(response, caused_by: event) 24 | end 25 | 26 | private 27 | 28 | # @param event [EventStoreClient::DeserializedEvent] 29 | # @return [EventStore::Client::Streams::AppendReq::ProposedMessage] 30 | def proposed_message(event) 31 | serialized_event = config.mapper.serialize(event) 32 | EventStore::Client::Streams::AppendReq::ProposedMessage.new(serialized_event.to_grpc) 33 | end 34 | 35 | # @param stream_name [String] 36 | # @param options [Hash] 37 | # @return [EventStore::Client::Streams::AppendReq::Options] 38 | def options(stream_name, options) 39 | opts = Options::Streams::WriteOptions.new(stream_name, options).request_options 40 | EventStore::Client::Streams::AppendReq::Options.new(opts) 41 | end 42 | 43 | # @param resp [EventStore::Client::Streams::AppendResp] 44 | # @param caused_by [EventStoreClient::DeserializedEvent] 45 | # @return [EventStore::Client::Streams::AppendResp] 46 | # @raise [EventStoreClient::WrongExpectedVersionError] 47 | def validate_response(resp, caused_by:) 48 | return resp if resp.success 49 | 50 | error = WrongExpectedVersionError.new( 51 | resp.wrong_expected_version, 52 | caused_by: caused_by 53 | ) 54 | raise error 55 | end 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/event_store_client/mapper/default_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::Mapper::Default do 4 | let(:instance) { described_class.new(serializer: serializer, config: config) } 5 | let(:config) { EventStoreClient.config } 6 | let(:serializer) { EventStoreClient::Serializer::Json } 7 | 8 | describe '#serialize' do 9 | subject { instance.serialize(event) } 10 | 11 | let(:event) { EventStoreClient::DeserializedEvent.new(data: { foo: :bar }) } 12 | 13 | before do 14 | allow(EventStoreClient::Serializer::EventSerializer).to receive(:call).and_call_original 15 | end 16 | 17 | it { is_expected.to be_a(EventStoreClient::SerializedEvent) } 18 | it 'has correct structure' do 19 | aggregate_failures do 20 | expect(subject.id).to be_a(String) 21 | expect(subject.data).to eq('foo' => 'bar') 22 | expect(subject.custom_metadata).to match(hash_including('created_at')) 23 | expect(subject.metadata).to match(hash_including('content-type', 'type')) 24 | end 25 | end 26 | it 'serializes it using EventSerializer' do 27 | subject 28 | expect(EventStoreClient::Serializer::EventSerializer).to( 29 | have_received(:call).with(event, serializer: serializer, config: config) 30 | ) 31 | end 32 | end 33 | 34 | describe '#deserialize' do 35 | subject { instance.deserialize(event) } 36 | 37 | let(:event) { EventStoreClient::DeserializedEvent.new(data: { foo: :bar }) } 38 | 39 | context 'when event is a DeserializedEvent' do 40 | it 'returns that event' do 41 | is_expected.to eq(event) 42 | end 43 | end 44 | 45 | context 'when event is a raw event' do 46 | subject { instance.deserialize(raw_event) } 47 | 48 | let(:stream_name) { "some-stream$#{SecureRandom.uuid}" } 49 | let(:raw_event) do 50 | append_and_reload(stream_name, event, skip_deserialization: true).event.event 51 | end 52 | 53 | before do 54 | allow(EventStoreClient::Serializer::EventDeserializer).to receive(:call).and_call_original 55 | end 56 | 57 | it { is_expected.to be_a(EventStoreClient::DeserializedEvent) } 58 | it 'deserializes it using EventDeserializer' do 59 | subject 60 | expect(EventStoreClient::Serializer::EventDeserializer).to( 61 | have_received(:call).with(raw_event, serializer: serializer, config: config) 62 | ) 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/event_store_client/serialized_event_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::SerializedEvent do 4 | subject { instance } 5 | 6 | let(:instance) do 7 | described_class.new( 8 | id: 'some-id', 9 | data: data, 10 | custom_metadata: custom_metadata, 11 | metadata: { 'bar' => 'foo' }, 12 | serializer: serializer 13 | ) 14 | end 15 | let(:custom_metadata) { { 'baz' => 'bar' } } 16 | let(:data) { { 'foo' => 'bar' } } 17 | let(:serializer) { EventStoreClient::Serializer::Json } 18 | 19 | it { is_expected.to be_a(EventStoreClient::Extensions::OptionsExtension) } 20 | it { is_expected.to have_option(:id) } 21 | it { is_expected.to have_option(:data) } 22 | it { is_expected.to have_option(:custom_metadata) } 23 | it { is_expected.to have_option(:metadata) } 24 | it { is_expected.to have_option(:serializer) } 25 | 26 | describe '#to_grpc' do 27 | subject { instance.to_grpc } 28 | 29 | shared_examples 'acceptable by GRPC' do 30 | it 'is acceptable by GRPC' do 31 | msg_klass = EventStore::Client::Streams::AppendReq::ProposedMessage 32 | event_klass = EventStore::Client::Streams::AppendReq::ProposedMessage 33 | aggregate_failures do 34 | expect { msg_klass.new(subject) }.not_to raise_error 35 | expect { event_klass.new(subject) }.not_to raise_error 36 | end 37 | end 38 | end 39 | 40 | it { is_expected.to be_a(Hash) } 41 | it 'has correct attributes' do 42 | aggregate_failures do 43 | expect(subject[:id]).to eq(string: instance.id) 44 | expect(subject[:data]).to eq(serializer.serialize(data)) 45 | expect(subject[:custom_metadata]).to eq(serializer.serialize(custom_metadata)) 46 | expect(subject[:metadata]).to eq(instance.metadata) 47 | end 48 | end 49 | it_behaves_like 'acceptable by GRPC' 50 | 51 | context 'when data contains chars that can not be converted to ASCII-8BIT' do 52 | let(:data) { { 'foo' => 'Zürich' } } 53 | 54 | it 'converts it to ASCII-8BIT' do 55 | expect(subject[:data].encoding.to_s).to eq('ASCII-8BIT') 56 | end 57 | it_behaves_like 'acceptable by GRPC' 58 | end 59 | 60 | context 'when customdata contains chars that can not be converted to ASCII-8BIT' do 61 | let(:custom_metadata) { { 'baz' => 'Zürich' } } 62 | 63 | it 'converts it to ASCII-8BIT' do 64 | expect(subject[:custom_metadata].encoding.to_s).to eq('ASCII-8BIT') 65 | end 66 | it_behaves_like 'acceptable by GRPC' 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/event_store_client/serializer/event_deserializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Metrics/AbcSize, Layout/LineLength 4 | 5 | module EventStoreClient 6 | module Serializer 7 | class EventDeserializer 8 | class << self 9 | # @param raw_event [EventStore::Client::Streams::ReadResp::ReadEvent::RecordedEvent, EventStore::Client::PersistentSubscriptions::ReadResp::ReadEvent::RecordedEvent] 10 | # @param config [EventStoreClient::Config] 11 | # @param serializer [#serialize, #deserialize] 12 | # @return [EventStoreClient::DeserializedEvent] 13 | def call(raw_event, config:, serializer: Serializer::Json) 14 | new(config: config, serializer: serializer).call(raw_event) 15 | end 16 | end 17 | 18 | attr_reader :serializer, :config 19 | private :serializer, :config 20 | 21 | # @param serializer [#serialize, #deserialize] 22 | # @param config [EventStoreClient::Config] 23 | def initialize(serializer:, config:) 24 | @serializer = serializer 25 | @config = config 26 | end 27 | 28 | # @param raw_event [EventStore::Client::Streams::ReadResp::ReadEvent::RecordedEvent, EventStore::Client::PersistentSubscriptions::ReadResp::ReadEvent::RecordedEvent] 29 | # @return [EventStoreClient::DeserializedEvent] 30 | def call(raw_event) 31 | data = serializer.deserialize(normalize_serialized(raw_event.data)) 32 | custom_metadata = serializer.deserialize(normalize_serialized(raw_event.custom_metadata)) 33 | metadata = raw_event.metadata.to_h 34 | 35 | event_class = EventStoreClient::EventClassResolver.new(config).resolve(metadata['type']) 36 | event_class.new( 37 | skip_validation: true, 38 | id: raw_event.id.string, 39 | title: "#{raw_event.stream_revision}@#{raw_event.stream_identifier.stream_name}", 40 | type: metadata['type'], 41 | data: data, 42 | metadata: metadata, 43 | custom_metadata: custom_metadata, 44 | stream_revision: raw_event.stream_revision, 45 | commit_position: raw_event.commit_position, 46 | prepare_position: raw_event.prepare_position, 47 | stream_name: raw_event.stream_identifier.stream_name 48 | ) 49 | end 50 | 51 | private 52 | 53 | # @param raw_data [String] 54 | # @return [String] 55 | def normalize_serialized(raw_data) 56 | return serializer.serialize({}) if raw_data.empty? 57 | 58 | raw_data 59 | end 60 | end 61 | end 62 | end 63 | # rubocop:enable Metrics/AbcSize, Layout/LineLength 64 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/connection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Connection do 4 | subject { instance } 5 | 6 | let(:config) { EventStoreClient.config } 7 | let(:instance) { described_class.allocate } 8 | let(:current_member) { EventStoreClient::GRPC::Cluster::Member.new(host: 'localhost', port: 301) } 9 | 10 | before do 11 | allow(EventStoreClient::GRPC::Discover).to receive(:current_member).and_return(current_member) 12 | end 13 | 14 | it { is_expected.to be_a(EventStoreClient::Extensions::OptionsExtension) } 15 | 16 | describe 'options' do 17 | before do 18 | allow(instance).to receive(:config).and_return(config) 19 | end 20 | 21 | it { is_expected.to have_option(:host) } 22 | it { is_expected.to have_option(:port) } 23 | it { is_expected.to have_option(:username) } 24 | it { is_expected.to have_option(:password) } 25 | it { is_expected.to have_option(:timeout) } 26 | 27 | describe 'default :host value' do 28 | subject { instance.host } 29 | 30 | it { is_expected.to eq(current_member.host) } 31 | end 32 | 33 | describe 'default :port value' do 34 | subject { instance.port } 35 | 36 | it { is_expected.to eq(current_member.port) } 37 | end 38 | 39 | describe 'default :username value' do 40 | subject { instance.username } 41 | 42 | it { is_expected.to eq(config.eventstore_url.username) } 43 | end 44 | 45 | describe 'default :password value' do 46 | subject { instance.password } 47 | 48 | it { is_expected.to eq(config.eventstore_url.password) } 49 | end 50 | 51 | describe 'default :timeout value' do 52 | subject { instance.timeout } 53 | 54 | it { is_expected.to eq(config.eventstore_url.timeout) } 55 | end 56 | end 57 | 58 | describe '.new' do 59 | subject { described_class.new(config: config) } 60 | 61 | context 'when tls config option is set to true' do 62 | before do 63 | config.eventstore_url.tls = true 64 | end 65 | 66 | it { is_expected.to be_a(EventStoreClient::GRPC::Cluster::SecureConnection) } 67 | end 68 | 69 | context 'when tls config option is set to false' do 70 | it { is_expected.to be_a(EventStoreClient::GRPC::Cluster::InsecureConnection) } 71 | end 72 | end 73 | 74 | describe '.secure?' do 75 | subject { described_class.secure? } 76 | 77 | it { is_expected.to eq(false) } 78 | end 79 | 80 | describe '#call' do 81 | subject { instance.call(stub_class) } 82 | 83 | let(:stub_class) { double('some class') } 84 | 85 | it { expect { subject }.to raise_error(NotImplementedError) } 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/cluster/secure_connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Cluster 6 | class SecureConnection < Connection 7 | CertificateLookupError = Class.new(StandardError) 8 | 9 | # @param stub_class GRPC request stub class. E.g. EventStore::Client::Gossip::Gossip::Stub 10 | # @return instance of the given stub_class class 11 | def call(stub_class) 12 | config.logger&.debug("Using secure connection with credentials #{username}:#{password}.") 13 | stub_class.new( 14 | "#{host}:#{port}", 15 | channel_credentials, 16 | channel_args: config.channel_args, 17 | timeout: (timeout / 1000.0 if timeout) 18 | ) 19 | end 20 | 21 | private 22 | 23 | # @return [GRPC::Core::ChannelCredentials] 24 | def channel_credentials 25 | certificate = 26 | if config.eventstore_url.tls_ca_file 27 | config.logger&.debug('Picking certificate from tlsCAFile option.') 28 | File.read(config.eventstore_url.tls_ca_file) 29 | else 30 | config.logger&.debug('Resolving certificate from current member.') 31 | cert.to_s 32 | end 33 | 34 | ::GRPC::Core::ChannelCredentials.new(certificate) 35 | end 36 | 37 | # rubocop:disable Metrics/AbcSize 38 | 39 | # @return [String, nil] returns the X.509 certificates the server presented 40 | # @raise [EventStoreClient::GRPC::Cluster::SecureConnection::CertificateLookupError] 41 | def cert 42 | retries = 0 43 | 44 | begin 45 | Net::HTTP.start(host, port, use_ssl: true, verify_mode: verify_mode, &:peer_cert) 46 | rescue SocketError => e 47 | attempts = config.eventstore_url.ca_lookup_attempts 48 | sleep config.eventstore_url.ca_lookup_interval / 1000.0 49 | retries += 1 50 | if retries <= attempts 51 | config.logger&.debug("Failed to lookup certificate. Reason: #{e.class}. Retying.") 52 | retry 53 | end 54 | raise( 55 | CertificateLookupError, 56 | "Failed to get X.509 certificate after #{attempts} attempts." 57 | ) 58 | end 59 | end 60 | # rubocop:enable Metrics/AbcSize 61 | 62 | # @return [Integer] SSL verify mode 63 | def verify_mode 64 | return OpenSSL::SSL::VERIFY_PEER if config.eventstore_url.tls_verify_cert 65 | 66 | OpenSSL::SSL::VERIFY_NONE 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/generated/operations_pb.rb: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # source: operations.proto 3 | 4 | require 'google/protobuf' 5 | 6 | require_relative 'shared_pb' 7 | 8 | Google::Protobuf::DescriptorPool.generated_pool.build do 9 | add_file("operations.proto", :syntax => :proto3) do 10 | add_message "event_store.client.operations.StartScavengeReq" do 11 | optional :options, :message, 1, "event_store.client.operations.StartScavengeReq.Options" 12 | end 13 | add_message "event_store.client.operations.StartScavengeReq.Options" do 14 | optional :thread_count, :int32, 1 15 | optional :start_from_chunk, :int32, 2 16 | end 17 | add_message "event_store.client.operations.StopScavengeReq" do 18 | optional :options, :message, 1, "event_store.client.operations.StopScavengeReq.Options" 19 | end 20 | add_message "event_store.client.operations.StopScavengeReq.Options" do 21 | optional :scavenge_id, :string, 1 22 | end 23 | add_message "event_store.client.operations.ScavengeResp" do 24 | optional :scavenge_id, :string, 1 25 | optional :scavenge_result, :enum, 2, "event_store.client.operations.ScavengeResp.ScavengeResult" 26 | end 27 | add_enum "event_store.client.operations.ScavengeResp.ScavengeResult" do 28 | value :Started, 0 29 | value :InProgress, 1 30 | value :Stopped, 2 31 | end 32 | add_message "event_store.client.operations.SetNodePriorityReq" do 33 | optional :priority, :int32, 1 34 | end 35 | end 36 | end 37 | 38 | module EventStore 39 | module Client 40 | module Operations 41 | StartScavengeReq = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.operations.StartScavengeReq").msgclass 42 | StartScavengeReq::Options = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.operations.StartScavengeReq.Options").msgclass 43 | StopScavengeReq = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.operations.StopScavengeReq").msgclass 44 | StopScavengeReq::Options = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.operations.StopScavengeReq.Options").msgclass 45 | ScavengeResp = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.operations.ScavengeResp").msgclass 46 | ScavengeResp::ScavengeResult = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.operations.ScavengeResp.ScavengeResult").enummodule 47 | SetNodePriorityReq = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("event_store.client.operations.SetNodePriorityReq").msgclass 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/options/streams/read_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Options::Streams::ReadOptions do 4 | subject { instance } 5 | 6 | let(:config) { EventStoreClient.config } 7 | let(:instance) { described_class.new(stream_name, options, config: config) } 8 | let(:stream_name) { 'some-stream' } 9 | let(:options) { {} } 10 | 11 | describe '#request_options' do 12 | subject { instance.request_options } 13 | 14 | it { is_expected.to be_a(Hash) } 15 | it 'has default value' do 16 | aggregate_failures 'default values' do 17 | expect(subject[:stream]).to( 18 | eq(start: EventStore::Client::Empty.new, stream_identifier: { stream_name: stream_name }) 19 | ) 20 | expect(subject).to include(read_direction: nil) 21 | expect(subject[:count]).to eq(EventStoreClient.config.per_page) 22 | expect(subject).to include(resolve_links: nil) 23 | expect(subject[:no_filter]).to eq(EventStore::Client::Empty.new) 24 | expect(subject[:uuid_option]).to eq(string: EventStore::Client::Empty.new) 25 | end 26 | end 27 | 28 | context 'when :direction option is provided' do 29 | let(:options) { { direction: 'Backwards' } } 30 | 31 | it 'recognizes it' do 32 | expect(subject[:read_direction]).to eq('Backwards') 33 | end 34 | end 35 | 36 | context 'when :max_count option is provided' do 37 | let(:options) { { max_count: 100_500 } } 38 | 39 | it 'recognizes it' do 40 | expect(subject[:count]).to eq(100_500) 41 | end 42 | end 43 | 44 | context 'when :resolve_link_tos option is provided' do 45 | let(:options) { { resolve_link_tos: true } } 46 | 47 | it 'recognizes it' do 48 | expect(subject[:resolve_links]).to eq(true) 49 | end 50 | end 51 | 52 | context 'when :from_revision option is provided' do 53 | let(:options) { { from_revision: :end } } 54 | 55 | it 'recognizes it' do 56 | expect(subject[:stream][:end]).to eq(EventStore::Client::Empty.new) 57 | end 58 | end 59 | 60 | context 'when :from_position option is provided' do 61 | let(:options) { { from_position: :end } } 62 | let(:stream_name) { '$all' } 63 | 64 | it 'recognizes it' do 65 | expect(subject[:all][:end]).to eq(EventStore::Client::Empty.new) 66 | end 67 | end 68 | 69 | context 'when :filter option is provided' do 70 | let(:options) { { filter: { stream_identifier: { prefix: ['lol'] } } } } 71 | 72 | it 'recognizes it' do 73 | expect(subject[:filter]).to include(options[:filter]) 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/shared/streams/process_response_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Shared::Streams::ProcessResponse do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new(config: config) } 7 | let(:config) { EventStoreClient.config } 8 | let(:mapper) { config.mapper } 9 | 10 | describe '#call' do 11 | subject { instance.call(response, skip_deserialization, skip_decryption) } 12 | 13 | let(:response) do 14 | EventStore::Client::Streams::ReadResp.new( 15 | stream_not_found: { stream_identifier: { stream_name: 'some-stream' } } 16 | ) 17 | end 18 | let(:skip_deserialization) { false } 19 | let(:skip_decryption) { false } 20 | 21 | context 'when stream is not found' do 22 | it 'raises error' do 23 | expect { subject }.to( 24 | raise_error(EventStoreClient::StreamNotFoundError, a_string_including('some-stream')) 25 | ) 26 | end 27 | end 28 | 29 | context 'when response is not a RecordedEvent' do 30 | let(:response) do 31 | EventStore::Client::Streams::ReadResp.new(confirmation: { subscription_id: 'some-id' }) 32 | end 33 | 34 | context 'when skip_deserialization is false' do 35 | it { is_expected.to be_nil } 36 | end 37 | 38 | context 'when skip_deserialization is true' do 39 | let(:skip_deserialization) { true } 40 | 41 | it 'returns response as it is' do 42 | expect(subject).to eq(response) 43 | end 44 | end 45 | end 46 | 47 | context 'when response is a RecordedEvent' do 48 | let(:response) do 49 | EventStore::Client::Streams::ReadResp.new(event: { event: recorded_event}) 50 | end 51 | let(:recorded_event) do 52 | EventStore::Client::Streams::ReadResp::ReadEvent::RecordedEvent.new( 53 | id: EventStore::Client::UUID.new(string: 'some-id'), 54 | stream_identifier: { stream_name: 'some-stream' }, 55 | metadata: { type: 'some-event' } 56 | ) 57 | end 58 | 59 | it 'returns deserialized event' do 60 | expect(subject).to be_a(EventStoreClient::DeserializedEvent) 61 | end 62 | 63 | context 'when skip_decryption is true' do 64 | let(:skip_decryption) { true } 65 | 66 | before do 67 | allow(mapper).to receive(:deserialize).and_call_original 68 | end 69 | 70 | it 'takes it into account' do 71 | subject 72 | expect(mapper).to( 73 | have_received(:deserialize).with(recorded_event, skip_decryption: skip_decryption) 74 | ) 75 | end 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if ENV['TEST_COVERAGE'] == 'true' 4 | require 'simplecov' 5 | require 'simplecov-formatter-badge' 6 | 7 | SimpleCov.profiles.define 'event-store-client' do 8 | add_filter 'spec/' 9 | add_filter '/version.rb' 10 | track_files 'lib/**/*.rb' 11 | end 12 | 13 | SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new( 14 | [ 15 | SimpleCov::Formatter::HTMLFormatter, 16 | SimpleCov::Formatter::BadgeFormatter 17 | ] 18 | ) 19 | 20 | # Target 21 | # SimpleCov.minimum_coverage 90 22 | 23 | SimpleCov.start 'event-store-client' 24 | end 25 | 26 | require 'pry' 27 | require 'event_store_client' 28 | require 'timecop' 29 | require 'dry-schema' 30 | require 'dry-monads' 31 | require 'dry/monads/result' 32 | require 'event_store_client/rspec/has_option_matcher' 33 | 34 | Dir[File.join(File.expand_path('.', __dir__), 'support/**/*.rb')].each { |f| require f } 35 | 36 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 37 | RSpec.configure do |config| 38 | config.expect_with :rspec do |expectations| 39 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 40 | end 41 | 42 | # rspec-mocks config goes here. You can use an alternate test double 43 | # library (such as bogus or mocha) by changing the `mock_with` option here. 44 | config.mock_with :rspec do |mocks| 45 | # Prevents you from mocking or stubbing a method that does not exist on 46 | # a real object. This is generally recommended, and will default to 47 | # `true` in RSpec 4. 48 | mocks.verify_partial_doubles = true 49 | end 50 | 51 | config.shared_context_metadata_behavior = :apply_to_host_groups 52 | 53 | # Allows RSpec to persist some state between runs in order to support 54 | # the `--only-failures` and `--next-failure` CLI options. We recommend 55 | # you configure your source control system to ignore this file. 56 | config.example_status_persistence_file_path = 'tmp/spec/examples.txt' 57 | config.disable_monkey_patching! 58 | config.warnings = false 59 | 60 | config.default_formatter = 'doc' if config.files_to_run.one? 61 | config.profile_examples = 10 62 | 63 | config.order = :random 64 | 65 | Kernel.srand config.seed 66 | 67 | config.before do 68 | TestHelper.configure_grpc 69 | end 70 | 71 | config.after do 72 | TestHelper.clean_up_grpc_config 73 | DummyRepository.reset 74 | end 75 | 76 | config.around(timecop: :itself.to_proc) do |example| 77 | if example.metadata[:timecop].is_a?(Time) 78 | Timecop.freeze(example.metadata[:timecop]) { example.run } 79 | else 80 | Timecop.freeze { example.run } 81 | end 82 | end 83 | config.include EventHelpers 84 | end 85 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/commands/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Commands 6 | class Command 7 | class << self 8 | def use_request(request_klass) 9 | CommandRegistrar.register_request(self, request: request_klass) 10 | end 11 | 12 | def use_service(service_klass) 13 | CommandRegistrar.register_service(self, service: service_klass) 14 | end 15 | end 16 | 17 | attr_reader :connection, :config 18 | private :connection, :config 19 | 20 | # @param config [EventStoreClient::Config] 21 | # @param conn_options [Hash] 22 | # @option conn_options [String] :host 23 | # @option conn_options [Integer] :port 24 | # @option conn_options [String] :username 25 | # @option conn_options [String] :password 26 | def initialize(config:, **conn_options) 27 | @config = config 28 | @connection = EventStoreClient::GRPC::Connection.new(config: config, **conn_options) 29 | end 30 | 31 | # Override it in your implementation of command. 32 | def call 33 | raise NotImplementedError 34 | end 35 | 36 | # @return [Hash] 37 | def metadata 38 | return {} unless connection.class.secure? 39 | 40 | credentials = 41 | Base64.encode64("#{connection.username}:#{connection.password}").delete("\n") 42 | { 'authorization' => "Basic #{credentials}" } 43 | end 44 | 45 | # @return GRPC params class to be used in the request. 46 | # E.g.EventStore::Client::Streams::ReadReq 47 | def request 48 | CommandRegistrar.request(self.class) 49 | end 50 | 51 | # @return GRPC request stub class. E.g. EventStore::Client::Streams::Streams::Stub 52 | def service 53 | connection.call(CommandRegistrar.service(self.class)) 54 | end 55 | 56 | # @return [Hash] connection options' hash 57 | def connection_options 58 | @connection.options_hash 59 | end 60 | 61 | private 62 | 63 | def retry_request(skip_retry: false) 64 | return yield if skip_retry 65 | 66 | retries = 0 67 | begin 68 | yield 69 | rescue ::GRPC::Unavailable => e 70 | sleep config.eventstore_url.grpc_retry_interval / 1000.0 71 | retries += 1 72 | if retries <= config.eventstore_url.grpc_retry_attempts 73 | config.logger&.debug("Request failed. Reason: #{e.class}. Retying.") 74 | retry 75 | end 76 | Discover.current_member(config: config)&.failed_endpoint = true 77 | raise 78 | end 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Run tests](https://github.com/yousty/event_store_client/workflows/Run%20tests/badge.svg?branch=master&event=push) 2 | [![Gem Version](https://badge.fury.io/rb/event_store_client.svg)](https://badge.fury.io/rb/event_store_client) 3 | 4 | # EventStoreClient 5 | 6 | An easy-to use GRPC API client for connecting ruby applications with [EventStoreDB](https://eventstore.com/). 7 | 8 | ## Requirements 9 | 10 | `event_store_client` gem requires: 11 | 12 | - ruby 3.0 or newer. 13 | - EventstoreDB version `>= "20.*"`. 14 | 15 | ## Installation 16 | Add this line to your application's Gemfile: 17 | 18 | ```ruby 19 | gem 'event_store_client' 20 | ``` 21 | 22 | And then execute: 23 | ```bash 24 | $ bundle 25 | ``` 26 | 27 | Or install it yourself as: 28 | ```bash 29 | $ gem install event_store_client 30 | ``` 31 | 32 | ## Usage 33 | 34 | Before you start, make sure you are connecting to a running EventStoreDB instance. For a detailed guide see: 35 | [EventStoreServerSetup](https://github.com/yousty/event_store_client/blob/master/docs/eventstore_server_setup.md) 36 | 37 | See documentation chapters for the usage reference: 38 | 39 | - [Configuration](docs/configuration.md) 40 | - [Appending events](docs/appending_events.md) 41 | - [Reading events](docs/reading_events.md) 42 | - [Catch-up subscriptions](docs/catch_up_subscriptions.md) 43 | - [Linking events](docs/linking_events.md) 44 | - [Deleting streams](docs/deleting_streams.md) 45 | - [Encrypting events](docs/encrypting_events.md) 46 | 47 | ### Subscriptions 48 | 49 | We have written a gem that helps you to manage and handle Catch-up Subscriptions - `event_store_subscription`. You could check it [here](https://github.com/yousty/event_store_subscriptions). 50 | 51 | ## Contributing 52 | 53 | Do you want to contribute? Welcome! 54 | 55 | 1. Fork repository 56 | 2. Create Issue 57 | 3. Create PR ;) 58 | 59 | ### Re-generating GRPC files from Proto 60 | 61 | If you need to re-generate GRPC files from [Proto](https://github.com/EventStore/EventStore/tree/master/src/Protos/Grpc) files - there is a tool to do it. Just run this command: 62 | 63 | ```shell 64 | bin/rebuild_protos 65 | ``` 66 | 67 | ### Running tests and development console 68 | 69 | You will have to install Docker first. It is needed to run EventStore DB. You can run EventStore DB with this command: 70 | 71 | ```shell 72 | docker-compose -f docker-compose-cluster.yml up 73 | ``` 74 | 75 | Now you can enter the dev console by running `bin/console` or run tests by running `rspec` command. 76 | 77 | ### Publishing a new version 78 | 79 | 1. Push commit with updated `version.rb` file to the `release` branch. The new version will be automatically pushed to [rubygems](https://rubygems.org). 80 | 2. Create release on github including change log. 81 | 82 | ## License 83 | 84 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 85 | -------------------------------------------------------------------------------- /lib/event_store_client/extensions/options_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module Extensions 5 | # A very simple extension that implements a DLS for adding attr_accessors with default values, 6 | # and assigning their values during object initialization. 7 | # Example. Let's say you frequently do something like this: 8 | # ```ruby 9 | # class SomeClass 10 | # attr_accessor :attr1, :attr2, :attr3, :attr4 11 | # 12 | # def initialize(opts = {}) 13 | # @attr1 = opts[:attr1] || 'Attr 1 value' 14 | # @attr2 = opts[:attr2] || 'Attr 2 value' 15 | # @attr3 = opts[:attr3] || do_some_calc 16 | # @attr4 = opts[:attr4] 17 | # end 18 | # 19 | # def do_some_calc 20 | # end 21 | # end 22 | # 23 | # SomeClass.new(attr1: 'hihi', attr4: 'byebye') 24 | # ``` 25 | # 26 | # You can replace the code above using the OptionsExtension: 27 | # ```ruby 28 | # class SomeClass 29 | # include EventStoreClient::Extensions::OptionsExtension 30 | # 31 | # option(:attr1) { 'Attr 1 value' } 32 | # option(:attr2) { 'Attr 2 value' } 33 | # option(:attr3) { do_some_calc } 34 | # option(:attr4) 35 | # end 36 | # 37 | # SomeClass.new(attr1: 'hihi', attr4: 'byebye') 38 | # ``` 39 | module OptionsExtension 40 | module ClassMethods 41 | # @param opt_name [Symbol] option name 42 | # @param blk [Proc] provide define value using block. It will be later evaluated in the 43 | # context of your object to determine the default value of the option 44 | # @return [Symbol] 45 | def option(opt_name, &blk) 46 | self.options = (options + Set.new([opt_name])).freeze 47 | attr_writer opt_name 48 | 49 | define_method opt_name do 50 | result = instance_variable_get(:"@#{opt_name}") 51 | return result if instance_variable_defined?(:"@#{opt_name}") 52 | 53 | instance_exec(&blk) if blk 54 | end 55 | end 56 | 57 | def inherited(klass) 58 | super 59 | klass.options = Set.new(options).freeze 60 | end 61 | end 62 | 63 | def self.included(klass) 64 | klass.singleton_class.attr_accessor(:options) 65 | klass.options = Set.new.freeze 66 | klass.extend(ClassMethods) 67 | end 68 | 69 | def initialize(**options) 70 | self.class.options.each do |option| 71 | # init default values of options 72 | value = options.key?(option) ? options[option] : public_send(option) 73 | public_send("#{option}=", value) 74 | end 75 | end 76 | 77 | # Construct a hash from options, where key is the option's name and the value is option's 78 | # value 79 | # @return [Hash] 80 | def options_hash 81 | self.class.options.each_with_object({}) do |option, res| 82 | res[option] = public_send(option) 83 | end 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/event_store_client/rspec/has_option_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This matcher is defined to test options which are defined by using 4 | # EventStoreClient::Extensions::OptionsExtension option. Example: 5 | # Let's say you have next class 6 | # class SomeClass 7 | # include EventStoreClient::Extensions::OptionsExtension 8 | # 9 | # option(:some_opt) { '1' } 10 | # end 11 | # 12 | # To test that its instance has the proper option with the proper default value you can use this 13 | # matcher: 14 | # RSpec.describe SomeClass do 15 | # subject { described_class.new } 16 | # 17 | # # Check that :some_opt is present 18 | # it { is_expected.to have_option(:some_opt) } 19 | # # Check that :some_opt is present and has the correct default value 20 | # it { is_expected.to have_option(:some_opt).with_default_value('1') } 21 | # end 22 | # 23 | # If you have more complex implementation of default value of your option - you should handle it 24 | # customly. For example: 25 | # class SomeClass 26 | # include EventStoreClient::Extensions::OptionsExtension 27 | # 28 | # option(:some_opt) { calc_value } 29 | # end 30 | # You could test it like so: 31 | # RSpec.described SomeClass do 32 | # let(:instance) { described_class.new } 33 | # 34 | # describe ':some_opt default value' do 35 | # subject { instance.some_opt } 36 | # 37 | # let(:value) { 'some val' } 38 | # 39 | # before do 40 | # allow(instance).to receive(:calc_value).and_return(value) 41 | # end 42 | # 43 | # it { is_expected.to eq(value) } 44 | # end 45 | # end 46 | RSpec::Matchers.define :has_option do |option_name| 47 | match do |obj| 48 | option_presence = obj.class.respond_to?(:options) && obj.class.options.include?(option_name) 49 | if @default_value 50 | option_presence && obj.class.allocate.public_send(option_name) == @default_value 51 | else 52 | option_presence 53 | end 54 | end 55 | 56 | failure_message do |obj| 57 | option_presence = obj.class.respond_to?(:options) && obj.class.options.include?(option_name) 58 | if option_presence && @default_value 59 | msg = "Expected #{obj.class} to have `#{option_name.inspect}' option with #{@default_value.inspect}" 60 | msg += ' default value, but default value is' 61 | msg += " #{obj.class.allocate.public_send(option_name).inspect}" 62 | else 63 | msg = "Expected #{obj} to have `#{option_name.inspect}' option." 64 | end 65 | 66 | msg 67 | end 68 | 69 | description do 70 | expected_list = RSpec::Matchers::EnglishPhrasing.list(expected) 71 | sentences = 72 | @chained_method_clauses.map do |(method_name, method_args)| 73 | next '' if method_name == :required_kwargs 74 | 75 | english_name = RSpec::Matchers::EnglishPhrasing.split_words(method_name) 76 | arg_list = RSpec::Matchers::EnglishPhrasing.list(method_args) 77 | " #{english_name}#{arg_list}" 78 | end.join 79 | 80 | "have#{expected_list} option#{sentences}" 81 | end 82 | 83 | chain :with_default_value do |val| 84 | @default_value = val 85 | end 86 | end 87 | 88 | RSpec::Matchers.alias_matcher :have_option, :has_option 89 | -------------------------------------------------------------------------------- /spec/event_store_client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient do 4 | describe '.configure' do 5 | it 'yields config' do 6 | expect { |b| described_class.configure(&b) }.to yield_with_args(described_class.config) 7 | end 8 | 9 | context 'when config name is provided' do 10 | let!(:config) { described_class.config(:some_config) } 11 | 12 | it 'yields correct config' do 13 | expect { |b| described_class.configure(name: :some_config, &b) }.to( 14 | yield_with_args(config) 15 | ) 16 | end 17 | end 18 | end 19 | 20 | describe '.config' do 21 | subject { described_class.config } 22 | 23 | before do 24 | described_class.instance_variable_set(:@config, {}) 25 | end 26 | 27 | it { is_expected.to be_a(described_class::Config) } 28 | it 'memorizes the config object' do 29 | expect(subject.__id__).to eq(described_class.config.__id__) 30 | end 31 | it 'persists the result under :default config' do 32 | expect { subject }.to change { 33 | described_class.instance_variable_get(:@config) 34 | }.to(hash_including(:default)) 35 | end 36 | 37 | context 'when config name is provided' do 38 | subject { described_class.config(:some_config) } 39 | 40 | it { is_expected.to be_a(described_class::Config) } 41 | it 'memorizes the config object' do 42 | expect(subject.__id__).to eq(described_class.config(:some_config).__id__) 43 | end 44 | it 'persists the result under :some_config config' do 45 | expect { subject }.to change { 46 | described_class.instance_variable_get(:@config) 47 | }.to(hash_including(:some_config)) 48 | end 49 | end 50 | end 51 | 52 | describe '.client' do 53 | subject { described_class.client } 54 | 55 | it { is_expected.to be_a(described_class::GRPC::Client) } 56 | it 'has default config' do 57 | expect(subject.send(:config)).to eq(described_class.config) 58 | end 59 | 60 | context 'when config name is given' do 61 | subject { described_class.client(config_name: config_name) } 62 | 63 | let(:config_name) { :some_config } 64 | 65 | context 'when config exists' do 66 | let!(:config) { described_class.config(config_name) } 67 | 68 | it 'uses that config' do 69 | expect(subject.send(:config)).to eq(config) 70 | end 71 | end 72 | 73 | context 'when config does not exist' do 74 | it 'raises error' do 75 | expect { subject }.to( 76 | raise_error(RuntimeError, /Could not find #{config_name.inspect} config/) 77 | ) 78 | end 79 | end 80 | end 81 | end 82 | 83 | describe '.init_default_config' do 84 | subject { described_class.init_default_config } 85 | 86 | before do 87 | described_class.instance_variable_set(:@config, nil) 88 | end 89 | 90 | it 'assigns default config value' do 91 | expect { subject }.to change { 92 | described_class.instance_variable_get(:@config) 93 | }.from(nil).to(hash_including(default: instance_of(EventStoreClient::Config))) 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/event_store_client/connection/utl_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::Connection::Url do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new } 7 | 8 | it { is_expected.to be_a(EventStoreClient::Extensions::OptionsExtension) } 9 | it { is_expected.to have_option(:dns_discover).with_default_value(false) } 10 | it { is_expected.to have_option(:username).with_default_value('admin') } 11 | it { is_expected.to have_option(:password).with_default_value('changeit') } 12 | it { is_expected.to have_option(:throw_on_append_failure).with_default_value(true) } 13 | it { is_expected.to have_option(:tls).with_default_value(true) } 14 | it { is_expected.to have_option(:tls_verify_cert).with_default_value(false) } 15 | it { is_expected.to have_option(:tls_ca_file) } 16 | it { is_expected.to have_option(:ca_lookup_interval).with_default_value(100) } 17 | it { is_expected.to have_option(:ca_lookup_attempts).with_default_value(3) } 18 | it { is_expected.to have_option(:gossip_timeout).with_default_value(200) } 19 | it { is_expected.to have_option(:max_discover_attempts).with_default_value(10) } 20 | it { is_expected.to have_option(:discover_interval).with_default_value(100) } 21 | it { is_expected.to have_option(:timeout) } 22 | it do 23 | is_expected.to( 24 | have_option(:node_preference).with_default_value(described_class::NODE_PREFERENCES.first) 25 | ) 26 | end 27 | it { is_expected.to have_option(:nodes).with_default_value(Set.new) } 28 | it { is_expected.to have_option(:grpc_retry_attempts).with_default_value(3) } 29 | it { is_expected.to have_option(:grpc_retry_interval).with_default_value(100) } 30 | 31 | describe 'constants' do 32 | describe 'NODE_PREFERENCES' do 33 | subject { described_class::NODE_PREFERENCES } 34 | 35 | it { is_expected.to eq(%i(Leader Follower ReadOnlyReplica)) } 36 | it { is_expected.to be_frozen } 37 | end 38 | 39 | describe 'Node' do 40 | subject { described_class::Node } 41 | 42 | it { is_expected.to be < Struct } 43 | 44 | describe 'instance' do 45 | subject { described_class::Node.new(host, port) } 46 | 47 | let(:host) { 'localhost' } 48 | let(:port) { 2111 } 49 | 50 | it 'has proper attributes' do 51 | aggregate_failures do 52 | expect(subject.host).to eq(host) 53 | expect(subject.port).to eq(port) 54 | end 55 | end 56 | end 57 | end 58 | end 59 | 60 | describe '#==' do 61 | subject { instance == another_instance } 62 | 63 | let(:another_instance) { described_class.new } 64 | 65 | context 'when comparing with another EventStoreClient::Connection::Url' do 66 | context 'when they match' do 67 | it { is_expected.to eq(true) } 68 | end 69 | 70 | context 'when they do not match' do 71 | before do 72 | another_instance.tls = false 73 | end 74 | 75 | it { is_expected.to eq(false) } 76 | end 77 | end 78 | 79 | context 'when comparing with another object' do 80 | let(:another_instance) { Object.new } 81 | 82 | it { is_expected.to eq(false) } 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/discover.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Metrics/CyclomaticComplexity 4 | 5 | module EventStoreClient 6 | module GRPC 7 | class Discover 8 | class << self 9 | # @param config [EventStoreClient::Config] 10 | # @return [EventStoreClient::GRPC::Cluster::Member] 11 | def current_member(config:) 12 | @exception[config.name] = nil 13 | return @current_member[config.name] if member_alive?(@current_member[config.name]) 14 | 15 | semaphore(config.name).synchronize do 16 | current_member = @current_member[config.name] 17 | raise @exception[config.name] if @exception[config.name] 18 | return current_member if member_alive?(current_member) 19 | 20 | failed_member = current_member&.failed_endpoint ? current_member : nil 21 | begin 22 | @current_member[config.name] = new(config: config).call(failed_member: failed_member) 23 | rescue StandardError => e 24 | @exception[config.name] = e 25 | @current_member[config.name] = nil 26 | raise 27 | end 28 | end 29 | 30 | @current_member[config.name] 31 | end 32 | 33 | # @param member [EventStoreClient::GRPC::Cluster::Member, nil] 34 | # @return [Boolean] 35 | def member_alive?(member) 36 | return false if member&.failed_endpoint 37 | 38 | !member.nil? 39 | end 40 | 41 | # @return [void] 42 | def init_default_discover_vars 43 | @exception = {} 44 | @current_member = {} 45 | @semaphore = {} 46 | end 47 | 48 | private 49 | 50 | # @param config_name [String, Symbol] 51 | # @return [Thread::Mutex] 52 | def semaphore(config_name) 53 | @semaphore[config_name] ||= Thread::Mutex.new 54 | end 55 | end 56 | 57 | init_default_discover_vars 58 | 59 | attr_reader :config 60 | private :config 61 | 62 | # @param config [EventStoreClient::Config] 63 | def initialize(config:) 64 | @config = config 65 | end 66 | 67 | # @param failed_member [EventStoreClient::GRPC::Cluster::Member, nil] 68 | # @return [EventStoreClient::GRPC::Cluster::Member] 69 | def call(failed_member: nil) 70 | if needs_discover? 71 | discovery = 72 | Cluster::GossipDiscover.new(config: config).call(nodes, failed_member: failed_member) 73 | return discovery 74 | end 75 | 76 | Cluster::QuerylessDiscover.new(config: config).call(config.eventstore_url.nodes.to_a) 77 | end 78 | 79 | private 80 | 81 | # @return [Array] 82 | def nodes 83 | return [config.eventstore_url.nodes.first] if config.eventstore_url.dns_discover 84 | 85 | config.eventstore_url.nodes.to_a 86 | end 87 | 88 | # @return [Boolean] 89 | def needs_discover? 90 | config.eventstore_url.dns_discover || config.eventstore_url.nodes.size > 1 91 | end 92 | end 93 | end 94 | end 95 | # rubocop:enable Metrics/CyclomaticComplexity 96 | -------------------------------------------------------------------------------- /bin/rebuild_protos: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | msg = <<~TEXT 5 | \e[31mThis script is temporary disabled. Protos were adjusted manually. See PR https://github.com/EventStore/EventStore/pull/3671. If it was merged - you can re-enable it and re-generate Protos.\e[0m 6 | TEXT 7 | puts msg 8 | exit(0) 9 | require 'fileutils' 10 | 11 | ROOT = File.expand_path('../', __dir__) 12 | TMP_DIR = File.join(ROOT, 'tmp') 13 | ES_DB_ARCHIVE_PATH = File.join(TMP_DIR, 'es_db.zip') 14 | PROTOS_DIR = File.join(TMP_DIR, 'Protos') 15 | GENERATED_FILES_DIR = File.join(ROOT, 'lib/event_store_client/adapters/grpc/generated') 16 | 17 | def download_es 18 | # curl -L -o es_db.zip https://github.com/EventStore/EventStore/archive/refs/heads/master.zip 19 | command = ['curl'] 20 | command.push('-L') # follow redirects 21 | command.push("-o#{ES_DB_ARCHIVE_PATH}") # define downloaded file name 22 | command.push('https://github.com/EventStore/EventStore/archive/refs/heads/master.zip') 23 | puts command.join(' ') 24 | puts 25 | Kernel.system(*command) 26 | end 27 | 28 | def unzip_protos 29 | # unzip -j es_db.zip "EventStore-master/src/Protos/Grpc/*" -d Protos 30 | command = ['unzip'] 31 | command.push('-j') # do not preserve paths from archive 32 | command.push(ES_DB_ARCHIVE_PATH) 33 | command.push('EventStore-master/src/Protos/Grpc/*') # path to protos inside archive 34 | command.push("-d#{PROTOS_DIR}") # a directory where to unarchive Protos 35 | puts command.join(' ') 36 | puts 37 | Kernel.system(*command) 38 | end 39 | 40 | # Generates GRPC files from Protos 41 | def generate_from_protos 42 | Dir[File.join(PROTOS_DIR, '*.proto')].each do |proto_file| 43 | command = ['grpc_tools_ruby_protoc'] 44 | command.push("-I#{PROTOS_DIR}") 45 | command.push("--ruby_out=#{GENERATED_FILES_DIR}") 46 | command.push("--grpc_out=#{GENERATED_FILES_DIR}") 47 | command.push("#{proto_file}") 48 | puts command.join(' ') 49 | puts 50 | Kernel.system(*command) 51 | end 52 | end 53 | 54 | # grpc_tools_ruby_protoc tool generates files, but it does not respect paths when inserting requires 55 | # Example 56 | # cluster_pb.rb file depends on shared_pb.rb. Instead 57 | # ```ruby 58 | # require 'relative/path/to/shared_pb' 59 | # ``` 60 | # or 61 | # ```ruby 62 | # require_relative 'shared_pb' 63 | # ``` 64 | # it puts 65 | # ```ruby 66 | # require 'shared_pb' 67 | # ``` 68 | # Obviously, that won't work. Fix such cases with simple script. 69 | def adjust_requires 70 | generated_files = Dir[File.join(GENERATED_FILES_DIR, '*.rb')] 71 | require_names = generated_files.map { |f| File.basename(f).gsub(/\.rb\z/, '') } 72 | generated_files.each do |path| 73 | content = File.read(path) 74 | file = File.open(path, 'w') 75 | require_names.each do |name| 76 | content.gsub!(%{require '#{name}'}, %{require_relative '#{name}'}) 77 | content.gsub!(%{require "#{name}"}, %{require_relative '#{name}'}) 78 | end 79 | file.write(content) 80 | file.close 81 | end 82 | end 83 | 84 | FileUtils.rm_rf(GENERATED_FILES_DIR) 85 | FileUtils.mkdir_p(GENERATED_FILES_DIR) 86 | FileUtils.mkdir_p(PROTOS_DIR) 87 | 88 | download_es 89 | unzip_protos 90 | generate_from_protos 91 | adjust_requires 92 | -------------------------------------------------------------------------------- /lib/event_store_client/connection/url.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module Connection 5 | # Structured representation of connection string. You should not use it directly. If you would 6 | # like to parse connection string into an instance of this class - use 7 | # EventStoreClient::Connection::UrlParser instead. 8 | # @api private 9 | class Url 10 | include Extensions::OptionsExtension 11 | 12 | NODE_PREFERENCES = %i[Leader Follower ReadOnlyReplica].freeze 13 | Node = Struct.new(:host, :port) 14 | 15 | # This option will allow you to perform the discovery by only one host 16 | # https://developers.eventstore.com/server/v21.10/cluster.html#cluster-with-dns 17 | option(:dns_discover) { false } 18 | option(:username) { 'admin' } 19 | option(:password) { 'changeit' } 20 | # Defines if append request should raise error immediately. If set to `false`, in case of 21 | # server error - request will be retried. 22 | option(:throw_on_append_failure) { true } 23 | # Whether to use secure connection 24 | option(:tls) { true } 25 | # Whether to verify a certificate 26 | option(:tls_verify_cert) { false } 27 | # A path to certificate file 28 | option(:tls_ca_file) 29 | # Interval between X.509 certificate lookup attempts. This option is useful when you set tls 30 | # option to true, but you didn't provide tls_ca_file option. In this case the certificate 31 | # will be retrieved using Net::HTTP#peer_cert method. 32 | option(:ca_lookup_interval) { 100 } # milliseconds 33 | # Number of attempts of lookup of X.509 certificate 34 | option(:ca_lookup_attempts) { 3 } 35 | # Discovery request timeout. Only useful when there are several nodes or when dns_discover 36 | # option is true 37 | option(:gossip_timeout) { 200 } # milliseconds 38 | # Max attempts before giving up to find a suitable cluster member. Only useful when there are 39 | # several nodes or when dns_discover option is true 40 | option(:max_discover_attempts) { 10 } 41 | # Interval between discover attempts 42 | option(:discover_interval) { 100 } # milliseconds 43 | # Response timeout 44 | option(:timeout) # milliseconds 45 | # During the discovery - set which state will be taken in prio during cluster members look up 46 | option(:node_preference) { NODE_PREFERENCES.first } 47 | # A list of nodes to discover. It is represented as an array of 48 | # EventStoreClient::Connection::Url::Node instances 49 | option(:nodes) { Set.new } 50 | # Number of time to retry GRPC request. Does not apply to discover request. Final number of 51 | # requests in cases of error will be initial request + grpc_retry_attempts. 52 | option(:grpc_retry_attempts) { 3 } 53 | # Delay between GRPC request retries 54 | option(:grpc_retry_interval) { 100 } # milliseconds 55 | 56 | # @param other [EventStoreClient::Connection::Url, Object] 57 | # @return [Boolean] 58 | def ==(other) 59 | return false unless other.is_a?(Url) 60 | 61 | options_hash == other.options_hash 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/shared/options/filter_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Shared 6 | module Options 7 | class FilterOptions 8 | attr_reader :options 9 | private :options 10 | 11 | # See event_store.client.streams.ReadReq.Options.FilterOptions in streams_pb.rb generated 12 | # file for more info(for persisted subscription the structure is the same) 13 | # @param filter_options [Hash, nil] 14 | # @option [Integer] :checkpointIntervalMultiplier 15 | # @option [Integer] :max 16 | # @option [Boolean] :count 17 | # @option [Hash] :stream_identifier filter events by stream name using Regexp or String. 18 | # Examples: 19 | # ```ruby 20 | # # Return events streams names of which end with number 21 | # new(stream_identifier: { regex: /.*\d$/.to_s }) 22 | # # Return events streams names of which start from 'some-stream-1' or 'some-stream-2' 23 | # # strings 24 | # new(stream_identifier: { prefix: ['some-stream-1', 'some-stream-2'] }) 25 | # ``` 26 | # @option [Hash] :event_type filter events by event name using Regexp or String. 27 | # Examples: 28 | # ```ruby 29 | # # Return events names of which end with number 30 | # new(event_type: { regex: /.*\d$/.to_s }) 31 | # # Return events names of which start from 'some-event-1' or 'some-event-2' 32 | # # strings 33 | # new(event_type: { prefix: ['some-event-1', 'some-event-2'] }) 34 | # ``` 35 | def initialize(filter_options) 36 | @options = filter_options 37 | end 38 | 39 | # See :filter_option in persistent_pb.rb or in streams_pb.rb generated files 40 | # @return [Hash] 41 | def request_options 42 | request_options = {} 43 | case options 44 | in { stream_identifier: { regex: String } } | { stream_identifier: { prefix: Array } } | 45 | { event_type: { regex: String } } | { event_type: { prefix: Array } } 46 | request_options[:filter] = options 47 | add_window_options(request_options) 48 | else 49 | request_options[:no_filter] = EventStore::Client::Empty.new 50 | end 51 | request_options 52 | end 53 | 54 | private 55 | 56 | # Define how frequently "checkpoint" event should be produced. Its value is calculated 57 | # by multiplying max by checkpointIntervalMultiplier. 58 | # Example: 59 | # Given max 32 and multiplier 2 - "checkpoint" event will be produced on each 60 | # 64' event 61 | # These options are only useful when subscribing to the stream 62 | # @return [void] 63 | def add_window_options(request_options) 64 | request_options[:filter][:max] ||= 100 65 | if request_options[:filter][:count] 66 | request_options[:filter][:count] = EventStore::Client::Empty.new 67 | end 68 | request_options[:filter][:checkpointIntervalMultiplier] ||= 1 69 | end 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/shared/streams/process_responses_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Shared::Streams::ProcessResponses do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new(config: config) } 7 | let(:config) { EventStoreClient.config } 8 | let(:mapper) { config.mapper } 9 | 10 | describe '#call' do 11 | subject { instance.call(responses, skip_deserialization, skip_decryption) } 12 | 13 | let(:not_found_resp) do 14 | EventStore::Client::Streams::ReadResp.new( 15 | stream_not_found: { stream_identifier: { stream_name: 'some-stream' } } 16 | ) 17 | end 18 | let(:responses) { [not_found_resp] } 19 | let(:skip_deserialization) { false } 20 | let(:skip_decryption) { false } 21 | 22 | context 'when stream is not found' do 23 | it 'raises error' do 24 | expect { subject }.to( 25 | raise_error(EventStoreClient::StreamNotFoundError, a_string_including('some-stream')) 26 | ) 27 | end 28 | end 29 | 30 | context 'when responses is empty' do 31 | let(:responses) { [] } 32 | 33 | it 'returns it' do 34 | expect(subject).to eq(responses) 35 | end 36 | end 37 | 38 | context 'when skip_deserialization is false' do 39 | let(:confirmation_resp) do 40 | EventStore::Client::Streams::ReadResp.new(confirmation: { subscription_id: 'some-id' }) 41 | end 42 | let(:responses) { [confirmation_resp] } 43 | 44 | context 'when responses does not contain RecordedEvent' do 45 | it 'returns empty array' do 46 | expect(subject).to eq([]) 47 | end 48 | end 49 | 50 | context 'when responses contain RecordedEvent' do 51 | let(:responses) do 52 | [confirmation_resp, event_resp] 53 | end 54 | let(:event_resp) do 55 | EventStore::Client::Streams::ReadResp.new(event: { event: recorded_event}) 56 | end 57 | let(:recorded_event) do 58 | EventStore::Client::Streams::ReadResp::ReadEvent::RecordedEvent.new( 59 | id: EventStore::Client::UUID.new(string: 'some-id'), 60 | stream_identifier: { stream_name: 'some-stream' }, 61 | metadata: { type: 'some-event' } 62 | ) 63 | end 64 | 65 | it 'returns only RecordedEvent-s in the result' do 66 | expect(subject.map(&:id)).to eq([recorded_event.id.string]) 67 | end 68 | 69 | describe 'skip_decryption option' do 70 | let(:skip_decryption) { true } 71 | 72 | before do 73 | allow(mapper).to receive(:deserialize).and_call_original 74 | end 75 | 76 | it 'takes it into account' do 77 | subject 78 | expect(mapper).to( 79 | have_received(:deserialize).with(recorded_event, skip_decryption: skip_decryption) 80 | ) 81 | end 82 | end 83 | end 84 | end 85 | 86 | context 'when skip_deserialization is true' do 87 | let(:confirmation_resp) do 88 | EventStore::Client::Streams::ReadResp.new(confirmation: { subscription_id: 'some-id' }) 89 | end 90 | let(:responses) { [confirmation_resp] } 91 | let(:skip_deserialization) { true } 92 | 93 | it 'returns responses as is' do 94 | expect(subject).to eq(responses) 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/event_store_client/mapper/encrypted.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Metrics/AbcSize, Layout/LineLength, Style/IfUnlessModifier 4 | 5 | require 'event_store_client/encryption_metadata' 6 | require 'event_store_client/data_encryptor' 7 | require 'event_store_client/data_decryptor' 8 | 9 | module EventStoreClient 10 | module Mapper 11 | # Transforms given event's data and encrypts/decrypts selected subset of data 12 | # based on encryption schema stored in the event itself. 13 | class Encrypted 14 | MissingEncryptionKey = Class.new(StandardError) 15 | 16 | attr_reader :key_repository, :serializer, :config 17 | private :key_repository, :serializer, :config 18 | 19 | # @param key_repository [#find, #create, #encrypt, #decrypt] 20 | # See spec/support/dummy_repository.rb for the example of simple in-memory implementation 21 | # @param config [EventStoreClient::Config] 22 | # @param serializer [#serialize, #deserialize] 23 | def initialize(key_repository, config:, serializer: Serializer::Json) 24 | @key_repository = key_repository 25 | @config = config 26 | @serializer = serializer 27 | end 28 | 29 | # @param event [EventStoreClient::DeserializedEvent] 30 | # @return [Hash] 31 | def serialize(event) 32 | # Links don't need to be encrypted 33 | return Default.new(serializer: serializer, config: config).serialize(event) if event.link? 34 | 35 | serialized = Serializer::EventSerializer.call(event, serializer: serializer, config: config) 36 | encryption_schema = 37 | if event.class.respond_to?(:encryption_schema) 38 | event.class.encryption_schema 39 | end 40 | 41 | encryptor = EventStoreClient::DataEncryptor.new( 42 | data: serialized.data, 43 | schema: encryption_schema, 44 | repository: key_repository 45 | ) 46 | encryptor.call 47 | serialized.data = encryptor.encrypted_data 48 | serialized.custom_metadata['encryption'] = encryptor.encryption_metadata 49 | serialized 50 | end 51 | 52 | # Decrypts the given event's subset of data. 53 | # @param event_or_raw_event [EventStoreClient::DeserializedEvent, EventStore::Client::Streams::ReadResp::ReadEvent::RecordedEvent, EventStore::Client::PersistentSubscriptions::ReadResp::ReadEvent::RecordedEvent] 54 | # @param skip_decryption [Boolean] 55 | # @return event [EventStoreClient::DeserializedEvent] 56 | def deserialize(event_or_raw_event, skip_decryption: false) 57 | if skip_decryption 58 | return Default.new(serializer: serializer, config: config).deserialize(event_or_raw_event) 59 | end 60 | 61 | event = 62 | if event_or_raw_event.is_a?(EventStoreClient::DeserializedEvent) 63 | event_or_raw_event 64 | else 65 | Serializer::EventDeserializer.call( 66 | event_or_raw_event, config: config, serializer: serializer 67 | ) 68 | end 69 | 70 | decrypted_data = 71 | EventStoreClient::DataDecryptor.new( 72 | data: event.data, 73 | schema: event.custom_metadata['encryption'], 74 | repository: key_repository 75 | ).call 76 | event.class.new(**event.to_h.merge(data: decrypted_data, skip_validation: true)) 77 | end 78 | end 79 | end 80 | end 81 | # rubocop:enable Metrics/AbcSize, Layout/LineLength, Style/IfUnlessModifier 82 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/shared/options/stream_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module GRPC 5 | module Shared 6 | module Options 7 | class StreamOptions 8 | attr_reader :stream_name, :options 9 | private :stream_name, :options 10 | 11 | # @param stream_name [String] 12 | # @param options [Hash] 13 | # @option options [Integer, Symbol] :from_revision. If number is provided - it is threaded 14 | # as starting revision number. Alternatively you can provide :start or :end value to 15 | # define a stream revision. **Use this option when stream name is a normal stream name** 16 | # @option options [Hash, Symbol] :from_position. If hash is provided - you should supply 17 | # it with :commit_position and/or :prepare_position keys. Alternatively you can provide 18 | # :start or :end value to define a stream position. **Use this option when stream name 19 | # is "$all"** 20 | def initialize(stream_name, options) 21 | @stream_name = stream_name 22 | @options = options 23 | end 24 | 25 | # @return [Hash] 26 | def request_options 27 | stream_name == '$all' ? all_stream : stream 28 | end 29 | 30 | private 31 | 32 | # @return [Hash] 33 | # Examples: 34 | # ```ruby 35 | # { all: { start: EventStore::Client::Empty.new } } 36 | # ``` 37 | # ```ruby 38 | # { all: { end: EventStore::Client::Empty.new } } 39 | # ``` 40 | # ```ruby 41 | # { all: { position: { commit_position: 1, prepare_position: 1 } } } 42 | # ``` 43 | def all_stream 44 | position_opt = 45 | case options[:from_position] 46 | when :start, :end 47 | { options[:from_position] => EventStore::Client::Empty.new } 48 | when Hash 49 | { position: options[:from_position] } 50 | else 51 | { start: EventStore::Client::Empty.new } 52 | end 53 | { all: position_opt } 54 | end 55 | 56 | # @return [Hash] 57 | # Examples: 58 | # ```ruby 59 | # { stream: { 60 | # start: EventStore::Client::Empty.new, 61 | # stream_identifier: { stream_name: 'some-stream' } 62 | # } 63 | # } 64 | # ``` 65 | # ```ruby 66 | # { stream: { 67 | # end: EventStore::Client::Empty.new, 68 | # stream_identifier: { stream_name: 'some-stream' } 69 | # } 70 | # } 71 | # ``` 72 | # ```ruby 73 | # { stream: { revision: 1, stream_identifier: { stream_name: 'some-stream' } } } 74 | # ``` 75 | def stream 76 | revision_opt = 77 | case options[:from_revision] 78 | when :start, :end 79 | { options[:from_revision] => EventStore::Client::Empty.new } 80 | when Integer 81 | { revision: options[:from_revision] } 82 | else 83 | { start: EventStore::Client::Empty.new } 84 | end 85 | { stream: revision_opt.merge(stream_identifier: { stream_name: stream_name }) } 86 | end 87 | end 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/shared/options/stream_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Shared::Options::StreamOptions do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new(stream_name, options) } 7 | let(:stream_name) { 'some-stream' } 8 | let(:options) { {} } 9 | 10 | describe '#request_options' do 11 | subject { instance.request_options } 12 | 13 | context 'when stream name is a regular name' do 14 | context 'when options are not provided' do 15 | it 'returns default value' do 16 | is_expected.to( 17 | eq( 18 | stream: { 19 | stream_identifier: { stream_name: stream_name }, 20 | start: EventStore::Client::Empty.new 21 | } 22 | ) 23 | ) 24 | end 25 | end 26 | 27 | context 'when :from_revision option is provided' do 28 | let(:options) { { from_revision: :start } } 29 | 30 | context 'when :from_revision is :start' do 31 | it 'recognizes it' do 32 | expect(subject[:stream]).to include(start: EventStore::Client::Empty.new) 33 | end 34 | end 35 | 36 | context 'when :from_revision is :end' do 37 | let(:options) { { from_revision: :end } } 38 | 39 | it 'recognizes it' do 40 | expect(subject[:stream]).to include(end: EventStore::Client::Empty.new) 41 | end 42 | end 43 | 44 | context 'when :from_revision is Integer' do 45 | let(:options) { { from_revision: 123 } } 46 | 47 | it 'recognizes it' do 48 | expect(subject[:stream]).to include(revision: 123) 49 | end 50 | end 51 | end 52 | 53 | context 'when :from_position option is provided' do 54 | let(:options) { { from_position: 123 } } 55 | 56 | it 'does not recognize it' do 57 | expect(subject[:stream]).not_to include(:position) 58 | end 59 | end 60 | end 61 | 62 | context 'when stream name is $all' do 63 | let(:stream_name) { '$all' } 64 | 65 | context 'when options are not provided' do 66 | it 'returns default value' do 67 | is_expected.to eq(all: {start: EventStore::Client::Empty.new}) 68 | end 69 | end 70 | 71 | context 'when :from_position option is provided' do 72 | let(:options) { { from_position: :start } } 73 | 74 | context 'when :from_position is :start' do 75 | it 'recognizes it' do 76 | expect(subject[:all]).to eq(start: EventStore::Client::Empty.new) 77 | end 78 | end 79 | 80 | context 'when :from_position is :end' do 81 | let(:options) { { from_position: :end } } 82 | 83 | it 'recognizes it' do 84 | expect(subject[:all]).to eq(end: EventStore::Client::Empty.new) 85 | end 86 | end 87 | 88 | context 'when :from_position is Hash' do 89 | let(:options) { { from_position: { commit_position: 123, prepare_position: 123 } } } 90 | 91 | it 'recognizes it' do 92 | expect(subject[:all]).to eq(position: { commit_position: 123, prepare_position: 123 }) 93 | end 94 | end 95 | end 96 | 97 | context 'when :from_revision option is provided' do 98 | let(:options) { { from_revision: 123 } } 99 | 100 | it 'does not recognize it' do 101 | expect(subject[:all]).not_to include(:revision) 102 | end 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/event_store_client/deserialized_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | class DeserializedEvent 5 | LINK_TYPE = '$>' 6 | 7 | InvalidDataError = Class.new(StandardError) 8 | private_constant :InvalidDataError 9 | 10 | attr_reader :id, :type, :title, :data, :metadata, :custom_metadata, :stream_name, 11 | :stream_revision, :prepare_position, :commit_position 12 | 13 | # @args [Hash] opts 14 | # @option opts [Boolean] :skip_validation 15 | # @option opts [UUID] :id 16 | # @option opts [Hash] :data 17 | # @option opts [Hash] :metadata 18 | # @option opts [String] :type 19 | # @option opts [String] :title 20 | # @option opts [String] :stream_name 21 | # @option opts [Integer] :stream_revision 22 | # @option opts [Integer] :prepare_position 23 | # @option opts [Integer] :commit_position 24 | # @option opts [UUID] :id 25 | # 26 | def initialize(args = {}) 27 | validate(args[:data]) unless args[:skip_validation] 28 | 29 | @data = args.fetch(:data) { {} } 30 | @type = args[:type] || self.class.name 31 | @metadata = 32 | args.fetch(:metadata) { {} }. 33 | merge( 34 | 'type' => @type, 35 | 'content-type' => payload_content_type 36 | ) 37 | @custom_metadata = args[:custom_metadata] || {} 38 | @stream_name = args[:stream_name] 39 | @stream_revision = args[:stream_revision] 40 | @prepare_position = args[:prepare_position] 41 | @commit_position = args[:commit_position] 42 | @title = args[:title] 43 | @id = args[:id] 44 | end 45 | 46 | # event schema 47 | def schema; end 48 | 49 | # content type of the event data 50 | def payload_content_type 51 | 'application/json' 52 | end 53 | 54 | # Implements comparison of `EventStoreClient::DeserializedEvent`-s. Two events matches if all of 55 | # their attributes matches 56 | # @param other [Object, EventStoreClient::DeserializedEvent] 57 | # @return [Boolean] 58 | def ==(other) 59 | return false unless other.is_a?(EventStoreClient::DeserializedEvent) 60 | 61 | meaningful_attrs(to_h) == meaningful_attrs(other.to_h) 62 | end 63 | 64 | # @return [Hash] 65 | def to_h 66 | instance_variables.each_with_object({}) do |var, result| 67 | key = var.to_s 68 | key[0] = '' # remove @ sign 69 | result[key.to_sym] = instance_variable_get(var) 70 | end 71 | end 72 | 73 | # Detect whether an event is a link event 74 | # @return [Boolean] 75 | def link? 76 | type == LINK_TYPE 77 | end 78 | 79 | # Detect whether an event is a system event 80 | # @return [Boolean] 81 | def system? 82 | return false unless type 83 | 84 | type.start_with?('$') 85 | end 86 | 87 | private 88 | 89 | # When comparing two events - we drop commit_position and prepare_position from attributes list 90 | # to compare. This is because their value are different when retrieving the same event from 91 | # '$all' stream and from specific stream. 92 | # @param hash [Hash] 93 | # @return [Hash] 94 | def meaningful_attrs(hash) 95 | hash.delete_if { |key, _val| %i(commit_position prepare_position).include?(key) } 96 | end 97 | 98 | def validate(data) 99 | return unless schema 100 | 101 | validation = schema.call(data || {}) 102 | 103 | return unless validation.errors.any? 104 | 105 | raise(InvalidDataError.new(message: "#{schema.class.name} #{validation.errors.to_h}")) 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/event_store_client/extensions/options_extension_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::Extensions::OptionsExtension do 4 | let(:dummy_class) do 5 | klass = Class.new 6 | klass.include described_class 7 | klass 8 | end 9 | let(:instance) { dummy_class.allocate } 10 | 11 | describe 'defining option' do 12 | subject { dummy_class.option(option) } 13 | 14 | let(:option) { :some_opt } 15 | 16 | it 'defines reader' do 17 | subject 18 | expect(instance).to respond_to(option) 19 | end 20 | it 'defines writer' do 21 | subject 22 | expect(instance).to respond_to("#{option}=") 23 | end 24 | it 'adds that option to the options list' do 25 | expect { subject }.to change { dummy_class.options }.to(Set.new([option])) 26 | end 27 | 28 | context 'when block is provided' do 29 | subject { dummy_class.option(option, &blk) } 30 | 31 | let(:blk) { proc { 'some-value' } } 32 | 33 | it 'defines default value of option' do 34 | subject 35 | expect(instance.public_send(option)).to eq(blk.call) 36 | end 37 | end 38 | end 39 | 40 | describe 'defining options in inherited class' do 41 | let(:child) { Class.new(dummy_class) } 42 | let(:child_of_child) { Class.new(child) } 43 | 44 | before do 45 | dummy_class.option(:parent_opt) 46 | child.option(:child_opt) 47 | child_of_child.option(:child_of_child_opt) 48 | end 49 | 50 | it 'inherits all options from parent to the child correctly' do 51 | expect(child.options).to eq(Set.new([:parent_opt, :child_opt])) 52 | end 53 | it 'inherits all options from parent to the child of child correctly' do 54 | expect(child_of_child.options).to( 55 | eq(Set.new([:parent_opt, :child_opt, :child_of_child_opt])) 56 | ) 57 | end 58 | it 'freezes options sets of children' do 59 | aggregate_failures do 60 | expect(child.options).to be_frozen 61 | expect(child_of_child.options).to be_frozen 62 | end 63 | end 64 | end 65 | 66 | describe '.options' do 67 | subject { dummy_class.options } 68 | 69 | it { is_expected.to be_a(Set) } 70 | it { is_expected.to be_frozen } 71 | end 72 | 73 | describe '#options_hash' do 74 | subject { instance.options_hash } 75 | 76 | before do 77 | dummy_class.option(:opt_1) { 'opt-1-value' } 78 | dummy_class.option(:opt_2) { 'opt-2-value' } 79 | end 80 | 81 | it 'returns hash representation of options' do 82 | is_expected.to eq(opt_1: 'opt-1-value', opt_2: 'opt-2-value') 83 | end 84 | end 85 | 86 | describe 'reader' do 87 | subject { instance.public_send(option) } 88 | 89 | let(:option) { :some_option } 90 | 91 | before do 92 | dummy_class.option(option) 93 | end 94 | 95 | context 'when default value is not set' do 96 | it { is_expected.to be_nil } 97 | end 98 | 99 | context 'when default value is set' do 100 | let(:blk) { proc { 'some-value' } } 101 | 102 | before do 103 | dummy_class.option(option, &blk) 104 | end 105 | 106 | it 'returns it' do 107 | is_expected.to eq(blk.call) 108 | end 109 | 110 | describe 'context of default value' do 111 | let(:blk) { proc { some_instance_method } } 112 | 113 | before do 114 | dummy_class.define_method(:some_instance_method) { 'some-instance-method-value' } 115 | end 116 | 117 | it 'processes it correctly' do 118 | is_expected.to eq('some-instance-method-value') 119 | end 120 | end 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/cluster/secure_connection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Cluster::SecureConnection do 4 | subject { instance } 5 | 6 | let(:options) { { config: EventStoreClient.config } } 7 | let(:instance) { described_class.new(**options) } 8 | let(:member) { EventStoreClient::GRPC::Cluster::Member.new(host: 'host.local', port: 1234) } 9 | 10 | before do 11 | allow(EventStoreClient::GRPC::Discover).to receive(:current_member).and_return(member) 12 | end 13 | 14 | it { is_expected.to be_a(EventStoreClient::GRPC::Connection) } 15 | 16 | describe 'constants' do 17 | describe 'CertificateLookupError' do 18 | subject { described_class::CertificateLookupError } 19 | 20 | it { is_expected.to be < StandardError } 21 | end 22 | end 23 | 24 | describe '.secure?' do 25 | subject { described_class.secure? } 26 | 27 | it { is_expected.to be_truthy } 28 | end 29 | 30 | describe '#call' do 31 | subject { instance.call(stub_class) } 32 | 33 | let(:stub_class) { EventStore::Client::Streams::Streams::Stub } 34 | 35 | before do 36 | EventStoreClient.config.eventstore_url.timeout = 1001 37 | end 38 | 39 | describe 'with stubbed certificate' do 40 | before do 41 | allow(instance).to receive(:cert).and_return(nil) 42 | end 43 | 44 | it { is_expected.to be_a(stub_class) } 45 | it 'has correct host' do 46 | host = subject.instance_variable_get(:@host) 47 | expect(host).to eq("#{member.host}:#{member.port}") 48 | end 49 | it 'has correct timeout' do 50 | timeout = subject.instance_variable_get(:@timeout) 51 | expect(timeout).to eq(EventStoreClient.config.eventstore_url.timeout / 1000.0) 52 | end 53 | end 54 | 55 | describe 'real request' do 56 | subject { super().read(request_options, metadata: metadata).first } 57 | 58 | let(:config) { EventStoreClient.config } 59 | let(:request_options) do 60 | options = EventStoreClient::GRPC::Options::Streams::ReadOptions.new( 61 | '$all', {}, config: config 62 | ).request_options 63 | options = EventStore::Client::Streams::ReadReq::Options.new(options) 64 | EventStore::Client::Streams::ReadReq.new( 65 | options: options 66 | ) 67 | end 68 | let(:metadata) do 69 | creds = 70 | Base64.encode64("#{instance.username}:#{instance.password}").delete("\n") 71 | { 'authorization' => "Basic #{creds}" } 72 | end 73 | 74 | before do 75 | allow(EventStoreClient::GRPC::Discover).to receive(:current_member).and_call_original 76 | EventStoreClient.config.eventstore_url = 77 | 'esdb://localhost:2111,localhost:2112,localhost:2113/?tls=true' 78 | end 79 | 80 | it 'does not raise any errors' do 81 | expect { subject }.not_to raise_error 82 | end 83 | 84 | context 'when credentials are invalid' do 85 | before do 86 | instance.username = 'anon' 87 | end 88 | 89 | it 'raises auth error' do 90 | expect { subject }.to raise_error(GRPC::Unauthenticated) 91 | end 92 | end 93 | 94 | context 'when certificate is provided' do 95 | before do 96 | EventStoreClient.config.eventstore_url.tls_ca_file = 97 | File.join(TestHelper.root_path, 'certs/ca/ca.crt') 98 | # stub this method to prevent false-positive result in case if handling of custom CA file 99 | # is not properly handled 100 | allow(instance).to receive(:cert) 101 | end 102 | 103 | it 'does not raise any errors' do 104 | expect { subject }.not_to raise_error 105 | end 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/event_store_client/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | class Error < StandardError 5 | # @return [Hash] 6 | def as_json(*) 7 | to_h.transform_keys(&:to_s) 8 | end 9 | 10 | # @return [Hash] 11 | def to_h 12 | hash = 13 | instance_variables.each_with_object({}) do |var, result| 14 | key = var.to_s 15 | key[0] = '' # remove @ sign 16 | result[key.to_sym] = instance_variable_get(var) 17 | end 18 | hash[:message] = message 19 | hash[:backtrace] = backtrace 20 | hash 21 | end 22 | end 23 | 24 | class StreamNotFoundError < Error 25 | attr_reader :stream_name 26 | 27 | # @param stream_name [String] 28 | def initialize(stream_name) 29 | @stream_name = stream_name 30 | super("Stream #{stream_name.inspect} does not exist.") 31 | end 32 | end 33 | 34 | class WrongExpectedVersionError < Error 35 | attr_reader :wrong_expected_version, :caused_by 36 | 37 | # @param wrong_expected_version [EventStore::Client::Streams::AppendResp::WrongExpectedVersion] 38 | # @param caused_by [EventStoreClient::DeserializedEvent] an event on which 39 | # WrongExpectedVersionError error happened. It can be useful when appending array of events - 40 | # based on it you will know which events were appended and which weren't. 41 | def initialize(wrong_expected_version, caused_by:) 42 | @wrong_expected_version = wrong_expected_version 43 | @caused_by = caused_by 44 | super(user_friendly_message) 45 | end 46 | 47 | private 48 | 49 | # @return [String] 50 | def user_friendly_message 51 | return expected_stream_exists if wrong_expected_version.expected_stream_exists 52 | return expected_no_stream if wrong_expected_version.expected_no_stream 53 | return current_no_stream if wrong_expected_version.current_no_stream 54 | unless wrong_expected_version.expected_revision == wrong_expected_version.current_revision 55 | return unmatched_stream_revision 56 | end 57 | 58 | # Unhandled case. Could happen if something else would be added to proto and I don't add it 59 | # here. 60 | self.class.to_s 61 | end 62 | 63 | # @return [String] 64 | def expected_stream_exists 65 | "Expected stream to exist, but it doesn't." 66 | end 67 | 68 | # @return [String] 69 | def expected_no_stream 70 | "Expected stream to be absent, but it actually exists." 71 | end 72 | 73 | # @return [String] 74 | def current_no_stream 75 | <<~TEXT.strip 76 | Stream revision #{wrong_expected_version.expected_revision.inspect} is expected, but \ 77 | stream does not exist. 78 | TEXT 79 | end 80 | 81 | # @return [String] 82 | def unmatched_stream_revision 83 | <<~TEXT.strip 84 | Stream revision #{wrong_expected_version.expected_revision.inspect} is expected, but \ 85 | actual stream revision is #{wrong_expected_version.current_revision.inspect}. 86 | TEXT 87 | end 88 | end 89 | 90 | class StreamDeletionError < Error 91 | attr_reader :stream_name, :details 92 | 93 | # @param stream_name [String] 94 | # @param details [String] 95 | def initialize(stream_name, details:) 96 | @stream_name = stream_name 97 | @details = details 98 | super(user_friendly_message) 99 | end 100 | 101 | # @return [String] 102 | def user_friendly_message 103 | <<~TEXT.strip 104 | Could not delete #{stream_name.inspect} stream. It seems that a stream with that \ 105 | name does not exist, has already been deleted or its state does not match the \ 106 | provided :expected_revision option. Please check #details for more info. 107 | TEXT 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/event_store_client/deserialized_event_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::DeserializedEvent do 4 | let(:instance) do 5 | described_class.new( 6 | data: { 7 | 'user_id' => '7b5eb54c-122a-45e9-8d76-d15dfc2d8ece', 'title' => 'Something happened' 8 | }, 9 | type: 'some-event', 10 | metadata: { 11 | 'type' => 'some-event', 12 | 'content-type' => 'application/json', 13 | 'created' => '16577190979005637' 14 | }, 15 | custom_metadata: { 16 | 'created_at' => '2022-07-13 16:31:37 +0300' 17 | }, 18 | stream_name: 'some-stream', 19 | stream_revision: 195, 20 | prepare_position: 270566, 21 | commit_position: 270566, 22 | title: '195@some-stream', 23 | id: '6a71275a-afc3-4493-9797-57d19bd812d0' 24 | ) 25 | end 26 | 27 | describe 'constants' do 28 | describe 'LINK_TYPE' do 29 | subject { described_class::LINK_TYPE } 30 | 31 | it { is_expected.to eq('$>') } 32 | it { is_expected.to be_frozen } 33 | end 34 | end 35 | 36 | describe '#==' do 37 | subject { instance == another_event } 38 | 39 | let(:another_event) { Object.new } 40 | 41 | context 'when comparing with incompatible object' do 42 | it { is_expected.to eq(false) } 43 | end 44 | 45 | context 'when comparing with EventStoreClient::DeserializedEvent' do 46 | let(:another_event) { described_class.new(id: instance.id) } 47 | 48 | context 'when some of attributes mismatch' do 49 | it { is_expected.to eq(false) } 50 | end 51 | 52 | context 'when all attributes matches' do 53 | let(:another_event) { described_class.new(instance.to_h) } 54 | 55 | it { is_expected.to eq(true) } 56 | end 57 | 58 | context "when commit/prepare positions don't match" do 59 | let(:another_event) { described_class.new(instance.to_h.merge(position)) } 60 | let(:position) do 61 | { commit_position: 18446744073709551615, prepare_position: 18446744073709551615 } 62 | end 63 | 64 | it 'does not use them during comparison' do 65 | is_expected.to eq(true) 66 | end 67 | end 68 | end 69 | end 70 | 71 | describe '#to_h' do 72 | subject { instance.to_h } 73 | 74 | it { is_expected.to be_a(Hash) } 75 | it 'returns hash representation of its attributes' do 76 | aggregate_failures do 77 | expect(subject[:id]).to eq(instance.id) 78 | expect(subject[:data]).to eq(instance.data) 79 | expect(subject[:type]).to eq(instance.type) 80 | expect(subject[:title]).to eq(instance.title) 81 | expect(subject[:metadata]).to eq(instance.metadata) 82 | expect(subject[:custom_metadata]).to eq(instance.custom_metadata) 83 | expect(subject[:stream_name]).to eq(instance.stream_name) 84 | expect(subject[:stream_revision]).to eq(instance.stream_revision) 85 | expect(subject[:prepare_position]).to eq(instance.prepare_position) 86 | expect(subject[:commit_position]).to eq(instance.commit_position) 87 | end 88 | end 89 | end 90 | 91 | describe '#link?' do 92 | subject { instance.link? } 93 | 94 | context 'when event is a regular event' do 95 | it { is_expected.to eq(false) } 96 | end 97 | 98 | context 'when event is a link event' do 99 | let(:instance) { described_class.new(type: '$>') } 100 | 101 | it { is_expected.to eq(true) } 102 | end 103 | end 104 | 105 | describe '#system?' do 106 | subject { instance.system? } 107 | 108 | context 'when event is a regular event' do 109 | it { is_expected.to eq(false) } 110 | end 111 | 112 | context 'when event is a system event' do 113 | let(:instance) { described_class.new(type: '$metadata') } 114 | 115 | it { is_expected.to eq(true) } 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/event_store_client/serializer/event_deserializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::Serializer::EventDeserializer do 4 | let(:instance) { described_class.new(serializer: serializer, config: config) } 5 | let(:serializer) { EventStoreClient::Serializer::Json } 6 | let(:config) { EventStoreClient.config } 7 | 8 | describe '.call' do 9 | subject { described_class.call(raw_event, serializer: serializer, config: config) } 10 | 11 | let(:raw_event) do 12 | EventStore::Client::Streams::ReadResp::ReadEvent::RecordedEvent.new( 13 | id: { string: 'some-id' }, 14 | stream_identifier: { stream_name: 'some-stream' } 15 | ) 16 | end 17 | 18 | it 'deserializes given raw event' do 19 | is_expected.to be_a(EventStoreClient::DeserializedEvent) 20 | end 21 | end 22 | 23 | describe '#call' do 24 | subject { instance.call(raw_event) } 25 | 26 | let(:raw_event) do 27 | EventStore::Client::Streams::ReadResp::ReadEvent::RecordedEvent.new( 28 | id: { string: id }, 29 | stream_identifier: { stream_name: stream_name }, 30 | stream_revision: stream_revision, 31 | prepare_position: prepare_position, 32 | commit_position: commit_position, 33 | metadata: metadata, 34 | custom_metadata: serializer.serialize(custom_metadata), 35 | data: serializer.serialize(data) 36 | ) 37 | end 38 | let(:id) { SecureRandom.uuid } 39 | let(:stream_name) { 'some-stream' } 40 | let(:stream_revision) { rand(10) } 41 | let(:prepare_position) { rand(1_000..2_000) } 42 | let(:commit_position) { rand(1_000..2_000) } 43 | let(:metadata) { { 'foo' => 'bar', 'type' => event_type } } 44 | let(:custom_metadata) { { 'bar' => 'baz' } } 45 | let(:data) { { 'baz' => 'foo' } } 46 | let(:event_type) { 'some-event' } 47 | 48 | it { is_expected.to be_a(EventStoreClient::DeserializedEvent) } 49 | it 'deserializes it correctly' do 50 | aggregate_failures do 51 | expect(subject.id).to eq(id) 52 | expect(subject.stream_name).to eq(stream_name) 53 | expect(subject.stream_revision).to eq(stream_revision) 54 | expect(subject.prepare_position).to eq(prepare_position) 55 | expect(subject.commit_position).to eq(commit_position) 56 | expect(subject.metadata).to eq(metadata.merge('content-type' => 'application/json')) 57 | expect(subject.custom_metadata).to eq(custom_metadata) 58 | expect(subject.title).to eq("#{stream_revision}@#{stream_name}") 59 | expect(subject.data).to eq(data) 60 | expect(subject.type).to eq(event_type) 61 | end 62 | end 63 | 64 | context 'when event type matches existing class' do 65 | let(:event_type) { 'SomeEvent' } 66 | let(:event_class) { Class.new(EventStoreClient::DeserializedEvent) } 67 | 68 | before do 69 | stub_const(event_type, event_class) 70 | end 71 | 72 | it { is_expected.to be_a(SomeEvent) } 73 | end 74 | 75 | context 'when event type is absent' do 76 | let(:metadata) { { 'foo' => 'bar' } } 77 | 78 | it { is_expected.to be_a(EventStoreClient::DeserializedEvent) } 79 | end 80 | 81 | context 'when data is absent' do 82 | before do 83 | raw_event.data = '' 84 | end 85 | 86 | it 'defaults it to empty hash' do 87 | expect(subject.data).to eq({}) 88 | end 89 | end 90 | 91 | context 'when data is absent' do 92 | before do 93 | raw_event.custom_metadata = '' 94 | end 95 | 96 | it 'assigns correct metadata value' do 97 | expect(subject.metadata).to eq(metadata.merge('content-type' => 'application/json')) 98 | end 99 | end 100 | 101 | context "when event's data does not match event's schema" do 102 | let(:event_type) { 'EncryptedEvent' } 103 | 104 | it 'deserializes it' do 105 | is_expected.to be_a(EncryptedEvent) 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/event_store_client/serializer/event_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventStoreClient 4 | module Serializer 5 | class EventSerializer 6 | # So far there are only these keys can be persisted in the metadata. You can pass **whatever** 7 | # you want into a metadata hash, but all keys, except these - will be rejected. Define 8 | # whitelisted keys and cut unwanted keys explicitly(later in this class). 9 | ALLOWED_EVENT_METADATA = %w[type content-type].freeze 10 | 11 | class << self 12 | # @param event [EventStoreClient::DeserializedEvent] 13 | # @param config [EventStoreClient::Config] 14 | # @param serializer [#serialize, #deserialize] 15 | # @return [EventStoreClient::SerializedEvent] 16 | def call(event, config:, serializer: Serializer::Json) 17 | new(serializer: serializer, config: config).call(event) 18 | end 19 | end 20 | 21 | attr_reader :serializer, :config 22 | private :serializer, :config 23 | 24 | # @param serializer [#serialize, #deserialize] 25 | # @param config [EventStoreClient::Config] 26 | def initialize(serializer:, config:) 27 | @serializer = serializer 28 | @config = config 29 | end 30 | 31 | # @param event [EventStoreClient::DeserializedEvent] 32 | # @return [EventStoreClient::SerializedEvent] 33 | def call(event) 34 | SerializedEvent.new( 35 | id: event.id || SecureRandom.uuid, 36 | data: data(event), 37 | metadata: metadata(event), 38 | custom_metadata: custom_metadata(event), 39 | serializer: serializer 40 | ) 41 | end 42 | 43 | private 44 | 45 | # @param event [EventStoreClient::DeserializedEvent] 46 | # @return [Hash] 47 | def metadata(event) 48 | metadata = serializer.deserialize(serializer.serialize(event.metadata)) 49 | # 'created' is returned in the metadata hash of the event when reading from a stream. It, 50 | # however, can not be overridden - it is always defined automatically by EventStore db when 51 | # appending new event. Thus, just ignore it - no need even to mention it in the 52 | # #log_metadata_difference method's message. 53 | metadata = metadata.slice(*(metadata.keys - ['created'])) 54 | filtered_metadata = metadata.slice(*ALLOWED_EVENT_METADATA) 55 | log_metadata_difference(metadata) unless filtered_metadata == metadata 56 | filtered_metadata 57 | end 58 | 59 | # Compute custom metadata for the event. **Exactly these** values you can see in ES admin's 60 | # web UI under "Metadata" section of the event. 61 | # @param event [EventStoreClient::DeserializedEvent] 62 | # @return [Hash] 63 | def custom_metadata(event) 64 | custom_metadata = serializer.deserialize(serializer.serialize(event.custom_metadata)) 65 | custom_metadata['created_at'] ||= Time.now.utc.to_s 66 | custom_metadata 67 | end 68 | 69 | # @param event [EventStoreClient::DeserializedEvent] 70 | # @return [Hash, String] 71 | def data(event) 72 | # Link events are special events. They contain special string value which shouldn't be 73 | # serialized. 74 | return event.data if event.link? 75 | 76 | serializer.deserialize(serializer.serialize(event.data)) 77 | end 78 | 79 | # @param metadata [Hash] 80 | # @return [void] 81 | def log_metadata_difference(metadata) 82 | rest_hash = metadata.slice(*(metadata.keys - ALLOWED_EVENT_METADATA)) 83 | debug_message = <<~TEXT 84 | Next keys were filtered from metadata during serialization: \ 85 | #{(metadata.keys - ALLOWED_EVENT_METADATA).map(&:inspect).join(', ')}. If you would like \ 86 | to provide your custom values in the metadata - please provide them via custom_metadata. \ 87 | Example: EventStoreClient::DeserializedEvent.new(custom_metadata: #{rest_hash.inspect}) 88 | TEXT 89 | config.logger&.debug(debug_message) 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/event_store_client/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::Config do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new } 7 | 8 | it { is_expected.to be_a(EventStoreClient::Extensions::OptionsExtension) } 9 | 10 | describe 'constants' do 11 | describe 'CHANNEL_ARGS_DEFAULTS' do 12 | subject { described_class::CHANNEL_ARGS_DEFAULTS } 13 | 14 | it do 15 | is_expected.to( 16 | eq( 17 | 'grpc.min_reconnect_backoff_ms' => 100, 18 | 'grpc.max_reconnect_backoff_ms' => 100, 19 | 'grpc.initial_reconnect_backoff_ms' => 100 20 | ) 21 | ) 22 | end 23 | it { is_expected.to be_frozen } 24 | end 25 | end 26 | 27 | describe 'options' do 28 | it { is_expected.to have_option(:per_page).with_default_value(20) } 29 | it do 30 | is_expected.to( 31 | have_option(:default_event_class).with_default_value(EventStoreClient::DeserializedEvent) 32 | ) 33 | end 34 | it { is_expected.to have_option(:logger) } 35 | it { is_expected.to have_option(:eventstore_url) } 36 | it { is_expected.to have_option(:mapper) } 37 | it { is_expected.to have_option(:skip_deserialization).with_default_value(false) } 38 | it { is_expected.to have_option(:skip_decryption).with_default_value(false) } 39 | it { is_expected.to have_option(:channel_args) } 40 | 41 | describe 'eventstore_url default value' do 42 | subject { instance.eventstore_url } 43 | 44 | it 'has correct value' do 45 | is_expected.to( 46 | eq(EventStoreClient::Connection::UrlParser.new.call('esdb://localhost:2113')) 47 | ) 48 | end 49 | end 50 | 51 | describe 'mapper default value' do 52 | subject { instance.mapper } 53 | 54 | it { is_expected.to be_a(EventStoreClient::Mapper::Default) } 55 | end 56 | 57 | describe 'channel_args default value' do 58 | subject { instance.channel_args } 59 | 60 | it 'has correct value' do 61 | is_expected.to( 62 | eq( 63 | 'grpc.min_reconnect_backoff_ms' => 100, 64 | 'grpc.max_reconnect_backoff_ms' => 100, 65 | 'grpc.initial_reconnect_backoff_ms' => 100, 66 | 'grpc.enable_retries' => 0 67 | ) 68 | ) 69 | end 70 | 71 | context 'when custom value for those keys are provided' do 72 | before do 73 | instance.channel_args = { 'grpc.max_reconnect_backoff_ms' => 300} 74 | end 75 | 76 | it 'reverse-merges them' do 77 | is_expected.to( 78 | eq( 79 | 'grpc.min_reconnect_backoff_ms' => 100, 80 | 'grpc.max_reconnect_backoff_ms' => 300, 81 | 'grpc.initial_reconnect_backoff_ms' => 100, 82 | 'grpc.enable_retries' => 0 83 | ) 84 | ) 85 | end 86 | end 87 | 88 | context 'when custom value for "grpc.enable_retries" setting is provided' do 89 | before do 90 | instance.channel_args = { 'grpc.enable_retries' => 1 } 91 | end 92 | 93 | it 'ignores it' do 94 | is_expected.to( 95 | eq( 96 | 'grpc.min_reconnect_backoff_ms' => 100, 97 | 'grpc.max_reconnect_backoff_ms' => 100, 98 | 'grpc.initial_reconnect_backoff_ms' => 100, 99 | 'grpc.enable_retries' => 0 100 | ) 101 | ) 102 | end 103 | end 104 | 105 | context 'when key in the custom hash is provided as symbol' do 106 | before do 107 | instance.channel_args = { 'grpc.max_reconnect_backoff_ms': 300} 108 | end 109 | 110 | it 'transforms it into a string' do 111 | is_expected.to( 112 | eq( 113 | 'grpc.min_reconnect_backoff_ms' => 100, 114 | 'grpc.max_reconnect_backoff_ms' => 300, 115 | 'grpc.initial_reconnect_backoff_ms' => 100, 116 | 'grpc.enable_retries' => 0 117 | ) 118 | ) 119 | end 120 | end 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/event_store_client/adapters/grpc/options/streams/read_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Metrics/AbcSize 4 | 5 | module EventStoreClient 6 | module GRPC 7 | module Options 8 | module Streams 9 | class ReadOptions 10 | attr_reader :options, :stream_name, :config 11 | private :options, :stream_name, :config 12 | 13 | # @param stream_name [String] 14 | # @param options [Hash] 15 | # @option [String] :direction 'Forwards' or 'Backwards' 16 | # @option [Integer] :max_count 17 | # @option [Boolean] :resolve_link_tos 18 | # @option [Symbol] :from_revision :start or :end. Works only for regular streams. 19 | # @option [Integer] :from_revision revision number to start from. Remember, that all reads 20 | # are inclusive and all subscribes are exclusive. This means if you provide revision 21 | # number when reading from stream - the first event will be an event of revision number 22 | # you provided. And, when subscribing on stream - the first event will be an event next 23 | # to the event of revision number you provided. Works only for regular streams. 24 | # @option [Symbol] :from_position :start or :end. Works only for $all streams. 25 | # @option [Hash] :from_position provided a hash with either both :commit_position and 26 | # :prepare_position keys or with one of them to define the starting position. Remember, 27 | # that all reads are inclusive and all subscribes are exclusive. This means if you 28 | # provide position number when reading from stream - the first event will be an event of 29 | # position number you provided. And, when subscribing on stream - the first event will 30 | # be an event next to the event of position number you provided. Works only for $all 31 | # streams. Unlike :from_revision - :commit_position and :prepare_position should contain 32 | # values of existing event. 33 | # Example: 34 | # ```ruby 35 | # new('some-stream', from_position: { commit_position: 1024, prepare_position: 1024 }) 36 | # ``` 37 | # @option [Hash] :filter see 38 | # {EventStoreClient::GRPC::Shared::Options::FilterOptions#initialize} for available 39 | # values 40 | # @param config [EventStoreClient::Config] 41 | def initialize(stream_name, options, config:) 42 | @stream_name = stream_name 43 | @options = options 44 | @config = config 45 | end 46 | 47 | # @return [Hash] see event_store.client.streams.ReadReq.Options for available options 48 | def request_options 49 | request_options = {} 50 | request_options.merge!( 51 | Shared::Options::StreamOptions.new(stream_name, options).request_options 52 | ) 53 | request_options[:read_direction] = options[:direction] 54 | request_options[:count] = options[:max_count] || config.per_page 55 | request_options[:resolve_links] = options[:resolve_link_tos] 56 | request_options.merge!( 57 | Shared::Options::FilterOptions.new(options[:filter]).request_options 58 | ) 59 | # This option means how event#id would look like in the response. If you provided 60 | # :string key, then #id will be a normal UUID string. If you provided :structured 61 | # key, then #id will be an instance of EventStore::Client::UUID::Structured class. 62 | # Note: for some reason if you don't provide this option - the request hangs forever 63 | # Examples: 64 | # 67 | # 68 | request_options[:uuid_option] = { string: EventStore::Client::Empty.new } 69 | request_options 70 | end 71 | end 72 | end 73 | end 74 | end 75 | end 76 | # rubocop:enable Metrics/AbcSize 77 | -------------------------------------------------------------------------------- /spec/event_store_client/adapters/grpc/commands/streams/subscribe_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe EventStoreClient::GRPC::Commands::Streams::Subscribe do 4 | subject { instance } 5 | 6 | let(:instance) { described_class.new(config: config) } 7 | let(:config) { EventStoreClient.config } 8 | 9 | it { is_expected.to be_a(EventStoreClient::GRPC::Commands::Command) } 10 | it 'uses correct params class' do 11 | expect(instance.request).to eq(EventStore::Client::Streams::ReadReq) 12 | end 13 | it 'uses correct service' do 14 | expect(instance.service).to be_a(EventStore::Client::Streams::Streams::Stub) 15 | end 16 | 17 | describe '#call' do 18 | subject do 19 | Thread.new do 20 | instance.call( 21 | stream_name, 22 | handler: handler, 23 | options: options, 24 | skip_deserialization: skip_deserialization, 25 | skip_decryption: skip_decryption 26 | ) 27 | end 28 | end 29 | 30 | let(:stream_name) { "some-stream$#{SecureRandom.uuid}" } 31 | let(:handler) do 32 | proc { |response| responses.push(response) } 33 | end 34 | let(:options) { {} } 35 | let(:skip_deserialization) { true } 36 | let(:skip_decryption) { false } 37 | let(:responses) { [] } 38 | let(:event) do 39 | EventStoreClient::DeserializedEvent.new( 40 | id: SecureRandom.uuid, type: 'some-event', data: { foo: :bar } 41 | ) 42 | end 43 | 44 | after do 45 | subject.kill 46 | end 47 | 48 | it 'triggers handler when new event arrives' do 49 | subject 50 | sleep 0.5 51 | expect { 52 | EventStoreClient.client.append_to_stream(stream_name, event) 53 | sleep 0.5 54 | }.to change { responses.size }.by(1) 55 | end 56 | 57 | describe 'received events' do 58 | subject do 59 | thread = super() 60 | # Wait for subscription to initialize and receive it first event 61 | sleep 0.5 62 | # Append our own event and wait for it to arrive into our accumulator 63 | EventStoreClient.client.append_to_stream(stream_name, event) 64 | thread 65 | end 66 | 67 | it 'contains confirmation event' do 68 | subject 69 | sleep 0.5 70 | expect(responses.first.confirmation).to( 71 | be_a(EventStore::Client::Streams::ReadResp::SubscriptionConfirmation) 72 | ) 73 | end 74 | it 'contains the event, sent by us' do 75 | subject 76 | sleep 0.5 77 | expect(responses.last.event.event.id.string).to eq(event.id) 78 | end 79 | end 80 | 81 | describe 'subscribing on $all' do 82 | let(:stream_name) { '$all' } 83 | let(:options) { { filter: { stream_identifier: { prefix: [event_stream_name] } } } } 84 | let(:event_stream_name) {"some-stream$#{SecureRandom.uuid}" } 85 | 86 | it 'triggers handler when new event arrives' do 87 | subject 88 | sleep 0.5 89 | expect { 90 | EventStoreClient.client.append_to_stream(event_stream_name, event) 91 | sleep 0.5 92 | }.to change { responses.size }.by(1) 93 | end 94 | 95 | describe 'received events' do 96 | subject do 97 | thread = super() 98 | # Wait for subscription to initialize and receive it first event 99 | sleep 0.5 100 | # Append our own event and wait for it to arrive into our accumulator 101 | EventStoreClient.client.append_to_stream(event_stream_name, event) 102 | thread 103 | end 104 | 105 | it 'contains confirmation event' do 106 | subject 107 | sleep 0.5 108 | expect(responses.first.confirmation).to( 109 | be_a(EventStore::Client::Streams::ReadResp::SubscriptionConfirmation) 110 | ) 111 | end 112 | it 'contains the event, sent by us' do 113 | subject 114 | sleep 0.5 115 | meaningful_events = responses.select { |r| r.event&.event } 116 | expect(meaningful_events.last.event.event.id.string).to eq(event.id) 117 | end 118 | end 119 | end 120 | end 121 | end 122 | --------------------------------------------------------------------------------