├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── examples ├── publisher-example │ ├── README.md │ └── publisher-example.rb └── subscriber-example │ ├── README.md │ └── subscriber-example.rb ├── gcpc.gemspec ├── lib ├── gcpc.rb └── gcpc │ ├── config.rb │ ├── publisher.rb │ ├── publisher │ ├── base_interceptor.rb │ ├── engine.rb │ ├── engine │ │ ├── batch_engine.rb │ │ └── chained_interceptor.rb │ └── topic_client.rb │ ├── subscriber.rb │ ├── subscriber │ ├── base_handler.rb │ ├── base_interceptor.rb │ ├── default_logger.rb │ ├── engine.rb │ ├── handle_engine.rb │ └── subscription_client.rb │ └── version.rb ├── renovate.json └── spec ├── gcpc ├── publisher │ ├── engine_spec.rb │ └── topic_client_spec.rb ├── publisher_spec.rb ├── subscriber │ ├── engine_spec.rb │ ├── handle_engine_spec.rb │ └── subscription_client_spec.rb └── subscriber_spec.rb ├── gcpc_spec.rb ├── spec_helper.rb └── support └── pubsub_resource_manager.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | ruby-version: ['3.2', '3.1', '3.0'] 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Ruby ${{ matrix.ruby-version }} 15 | uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby-version }} 18 | bundler-cache: true 19 | - name: Run rspec 20 | run: bundle exec rspec 21 | - name: run pubsub emulator 22 | run: docker run -d -p 8085:8085 -it gcr.io/google.com/cloudsdktool/google-cloud-cli:latest gcloud beta emulators pubsub start --host-port=0.0.0.0:8085 23 | - name: Run rspec with emulator 24 | run: bundle exec rspec --tag emulator 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | 4 | 5 | 6 | 7 | ## 0.0.8 8 | 9 | - Add an API for heartbeat https://github.com/wantedly/gcpc/pull/25 10 | 11 | ## 0.0.7 12 | 13 | - Add explicit loading of forwardable https://github.com/wantedly/gcpc/pull/10 14 | - Add ruby 2.7 as a test target https://github.com/wantedly/gcpc/pull/12 15 | 16 | ## 0.0.6 17 | 18 | - Add test of interceptors https://github.com/wantedly/gcpc/pull/7 19 | - Add publish_batch https://github.com/wantedly/gcpc/pull/6 20 | 21 | ## 0.0.5 22 | 23 | - Add publish_async https://github.com/wantedly/gcpc/pull/5 24 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in gcpc.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | gcpc (0.0.8) 5 | google-cloud-pubsub 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | addressable (2.8.5) 11 | public_suffix (>= 2.0.2, < 6.0) 12 | coderay (1.1.3) 13 | concurrent-ruby (1.2.2) 14 | diff-lcs (1.3) 15 | faraday (2.7.10) 16 | faraday-net_http (>= 2.0, < 3.1) 17 | ruby2_keywords (>= 0.0.4) 18 | faraday-net_http (3.0.2) 19 | faraday-retry (2.2.0) 20 | faraday (~> 2.0) 21 | gapic-common (0.19.1) 22 | faraday (>= 1.9, < 3.a) 23 | faraday-retry (>= 1.0, < 3.a) 24 | google-protobuf (~> 3.14) 25 | googleapis-common-protos (>= 1.3.12, < 2.a) 26 | googleapis-common-protos-types (>= 1.3.1, < 2.a) 27 | googleauth (~> 1.0) 28 | grpc (~> 1.36) 29 | google-cloud-core (1.6.0) 30 | google-cloud-env (~> 1.0) 31 | google-cloud-errors (~> 1.0) 32 | google-cloud-env (1.6.0) 33 | faraday (>= 0.17.3, < 3.0) 34 | google-cloud-errors (1.3.1) 35 | google-cloud-pubsub (2.15.4) 36 | concurrent-ruby (~> 1.1) 37 | google-cloud-core (~> 1.5) 38 | google-cloud-pubsub-v1 (~> 0.8) 39 | retriable (~> 3.1) 40 | google-cloud-pubsub-v1 (0.17.3) 41 | gapic-common (>= 0.19.1, < 2.a) 42 | google-cloud-errors (~> 1.0) 43 | google-iam-v1 (>= 0.4, < 2.a) 44 | google-iam-v1 (0.5.2) 45 | gapic-common (>= 0.19.1, < 2.a) 46 | google-cloud-errors (~> 1.0) 47 | grpc-google-iam-v1 (~> 1.1) 48 | google-protobuf (3.23.4) 49 | googleapis-common-protos (1.4.0) 50 | google-protobuf (~> 3.14) 51 | googleapis-common-protos-types (~> 1.2) 52 | grpc (~> 1.27) 53 | googleapis-common-protos-types (1.8.0) 54 | google-protobuf (~> 3.18) 55 | googleauth (1.7.0) 56 | faraday (>= 0.17.3, < 3.a) 57 | jwt (>= 1.4, < 3.0) 58 | memoist (~> 0.16) 59 | multi_json (~> 1.11) 60 | os (>= 0.9, < 2.0) 61 | signet (>= 0.16, < 2.a) 62 | grpc (1.56.2) 63 | google-protobuf (~> 3.23) 64 | googleapis-common-protos-types (~> 1.0) 65 | grpc-google-iam-v1 (1.3.0) 66 | google-protobuf (~> 3.18) 67 | googleapis-common-protos (~> 1.4) 68 | grpc (~> 1.41) 69 | jwt (2.7.1) 70 | memoist (0.16.2) 71 | method_source (1.0.0) 72 | multi_json (1.15.0) 73 | os (1.1.4) 74 | pry (0.13.1) 75 | coderay (~> 1.1) 76 | method_source (~> 1.0) 77 | public_suffix (5.0.3) 78 | rake (13.0.1) 79 | retriable (3.1.2) 80 | rspec (3.8.0) 81 | rspec-core (~> 3.8.0) 82 | rspec-expectations (~> 3.8.0) 83 | rspec-mocks (~> 3.8.0) 84 | rspec-core (3.8.0) 85 | rspec-support (~> 3.8.0) 86 | rspec-expectations (3.8.2) 87 | diff-lcs (>= 1.2.0, < 2.0) 88 | rspec-support (~> 3.8.0) 89 | rspec-mocks (3.8.0) 90 | diff-lcs (>= 1.2.0, < 2.0) 91 | rspec-support (~> 3.8.0) 92 | rspec-support (3.8.0) 93 | ruby2_keywords (0.0.5) 94 | signet (0.17.0) 95 | addressable (~> 2.8) 96 | faraday (>= 0.17.5, < 3.a) 97 | jwt (>= 1.5, < 3.0) 98 | multi_json (~> 1.10) 99 | 100 | PLATFORMS 101 | ruby 102 | 103 | DEPENDENCIES 104 | bundler 105 | gcpc! 106 | pry 107 | rake (~> 13.0) 108 | rspec (~> 3.2) 109 | 110 | BUNDLED WITH 111 | 2.2.5 112 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Nao Minami 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gcpc 2 | 3 | **G**oogle **C**loud **P**ub/Sub **C**lient for Ruby. 4 | 5 | Gcpc provides the implementation of the publisher / subscriber for [Google Cloud Pub/Sub](https://cloud.google.com/pubsub/). You can add some functionality to the publisher / subscriber as interceptors. 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ```ruby 12 | gem 'gcpc' 13 | ``` 14 | 15 | And then execute: 16 | 17 | $ bundle 18 | 19 | Or install it yourself as: 20 | 21 | $ gem install gcpc 22 | 23 | ## Usage 24 | 25 | `gcpc` have publisher and subscriber implementation of Google Cloud Pub/Sub. 26 | 27 | ### Publisher 28 | 29 | To use `Gcpc::Publisher`, pleaese initialize `Gcpc::Publisher` with some configurations. 30 | 31 | ```ruby 32 | publisher = Gcpc::Publisher.new( 33 | project_id: "", 34 | topic: "", 35 | credentials: "/path/to/credentials", 36 | ) 37 | ``` 38 | 39 | Then, simply call `Gcpc::Publisher#publish` to post a message! 40 | 41 | ```ruby 42 | publisher.publish("") 43 | ``` 44 | 45 | #### Interceptors 46 | 47 | By using interceptors, you can add some functionality to the publisher. 48 | 49 | For example, you can add logging functionality by adding `LogInterceptor` as shown below. 50 | 51 | ```ruby 52 | class LogInterceptor < Gcpc::Publisher::BaseInterceptor 53 | MyLogger = Logger.new(STDOUT) 54 | 55 | # @param [String] data 56 | # @param [Hash] attributes 57 | def publish(data, attributes) 58 | MyLogger.info "[Interceptor Log] publish data: \"#{data}\", attributes: #{attributes}" 59 | yield data, attributes 60 | end 61 | end 62 | 63 | publisher = Gcpc::Publisher.new( 64 | project_id: "", 65 | topic: "", 66 | interceptors: [LogInterceptor], 67 | credentials: "/path/to/credentials", 68 | ) 69 | 70 | publisher.publish("") 71 | ``` 72 | 73 | #### Publisher Example 74 | A full example code is in [publisher-example](./examples/publisher-example). Please see it. 75 | 76 | ### Subscriber 77 | 78 | To use `Gcpc::Subscriber`, pleaese initialize `Gcpc::Subscriber` with some configurations. 79 | 80 | ```ruby 81 | subscriber = Gcpc::Subscriber.new( 82 | project_id: "", 83 | subscription: "", 84 | credentials: "/path/to/credentials", 85 | ) 86 | ``` 87 | 88 | Then, call `Gcpc::Subscriber#handle` to register a handler. A registered handler executes `#handle` callback for each published message. 89 | 90 | ```ruby 91 | class NopHandler < Gcpc::Subscriber::BaseHandler 92 | # @param [String] data 93 | # @param [Hash] attributes 94 | # @param [Google::Cloud::Pubsub::ReceivedMessage] message 95 | def handle(data, attributes, message) 96 | # Do nothing. Consume only. 97 | end 98 | end 99 | 100 | subscriber.handle(NopHandler) 101 | ``` 102 | 103 | To start subscribing, please call `Gcpc::Subscriber#start`. It does not return, and run subscribing loops in it. 104 | 105 | ```ruby 106 | subscriber.run 107 | ``` 108 | 109 | #### Signal Handling 110 | 111 | By default, you can stop a subscriber process by sending `SIGINT`, `SIGTERM`, or `SIGKILL` signals. 112 | 113 | If you want to use other signals, please pass signals to `#run`. 114 | 115 | ```ruby 116 | subscriber.run(['SIGINT', 'SIGTERM', 'SIGSTOP', 'SIGTSTP']) 117 | ``` 118 | 119 | #### Interceptors 120 | 121 | By using interceptors, you can add some functionality to the subscriber. 122 | 123 | For example, you can add logging functionality by adding `LogInterceptor` as shown below. 124 | 125 | ```ruby 126 | class LogInterceptor < Gcpc::Subscriber::BaseInterceptor 127 | MyLogger = Logger.new(STDOUT) 128 | 129 | # @param [String] data 130 | # @param [Hash] attributes 131 | # @param [Google::Cloud::Pubsub::ReceivedMessage] message 132 | def handle(data, attributes, message) 133 | MyLogger.info "[Interceptor Log] subscribed a message: #{message}" 134 | yield data, attributes, message 135 | end 136 | end 137 | 138 | subscriber = Gcpc::Subscriber.new( 139 | project_id: "", 140 | subscription: "", 141 | interceptors: [LogInterceptor], 142 | credentials: "/path/to/credentials", 143 | ) 144 | ``` 145 | 146 | #### Subscriber Example 147 | A full example code is in [subscriber-example](./examples/subscriber-example). Please see it. 148 | 149 | ## Development 150 | 151 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 152 | 153 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 154 | 155 | ## Contributing 156 | 157 | Bug reports and pull requests are welcome on GitHub at https://github.com/wantedly/gcpc. 158 | 159 | ## License 160 | 161 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 162 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | task :default => :spec 3 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "gcpc" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | require "pry" 10 | Pry.start 11 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /examples/publisher-example/README.md: -------------------------------------------------------------------------------- 1 | ## publisher example 2 | 3 | An example code of `Gcpc::Publisher`. 4 | 5 | If you want to try `Gcpc`, please execute commands below in the root of this repository. 6 | 7 | ``` 8 | $ gcloud beta emulators pubsub start # Please install Cloud Pub/Sub emulator from https://cloud.google.com/pubsub/docs/emulator for executing this. 9 | $ bundle exec ruby examples/subscriber-example/subscriber-example.rb 10 | $ bundle exec ruby examples/publisher-example/publisher-example.rb 11 | ``` 12 | -------------------------------------------------------------------------------- /examples/publisher-example/publisher-example.rb: -------------------------------------------------------------------------------- 1 | require "gcpc" 2 | 3 | # Please execute commands below. 4 | # 5 | # ``` 6 | # $ gcloud beta emulators pubsub start 7 | # $ bundle exec ruby examples/subscriber-example/subscriber-example.rb 8 | # $ bundle exec ruby examples/publisher-example/publisher-example.rb 9 | # ``` 10 | 11 | PROJECT_ID = "project-example-1" 12 | TOPIC_NAME = "topic-example-1" 13 | 14 | class LogInterceptor < Gcpc::Publisher::BaseInterceptor 15 | MyLogger = Logger.new(STDOUT) 16 | 17 | def publish(data, attributes) 18 | MyLogger.info "[Interceptor Log] publish data: \"#{data}\", attributes: #{attributes}" 19 | yield data, attributes 20 | end 21 | end 22 | 23 | def main 24 | publisher = Gcpc::Publisher.new( 25 | project_id: PROJECT_ID, 26 | topic: TOPIC_NAME, 27 | interceptors: [LogInterceptor], 28 | emulator_host: "localhost:8085", 29 | ) 30 | data = ARGV[0] || "message payload" 31 | attributes = { publisher: "publisher-example" } 32 | publisher.publish(data, attributes) 33 | end 34 | 35 | main 36 | -------------------------------------------------------------------------------- /examples/subscriber-example/README.md: -------------------------------------------------------------------------------- 1 | ## subscriber example 2 | 3 | An example code of `Gcpc::Subscriber`. 4 | 5 | If you want to try `Gcpc`, please execute commands below in the root of this repository. 6 | 7 | ``` 8 | $ gcloud beta emulators pubsub start # Please install Cloud Pub/Sub emulator from https://cloud.google.com/pubsub/docs/emulator for executing this. 9 | $ bundle exec ruby examples/subscriber-example/subscriber-example.rb 10 | $ bundle exec ruby examples/publisher-example/publisher-example.rb 11 | ``` 12 | -------------------------------------------------------------------------------- /examples/subscriber-example/subscriber-example.rb: -------------------------------------------------------------------------------- 1 | require "gcpc" 2 | 3 | # Please execute commands below. 4 | # 5 | # ``` 6 | # $ gcloud beta emulators pubsub start 7 | # $ bundle exec ruby examples/subscriber-example/subscriber-example.rb 8 | # $ bundle exec ruby examples/publisher-example/publisher-example.rb 9 | # ``` 10 | 11 | PROJECT_ID = "project-example-1" 12 | TOPIC_NAME = "topic-example-1" 13 | SUBSCRIPTION_NAME = "subscription-example-1" 14 | 15 | MyLogger = Logger.new(STDOUT) 16 | 17 | class LogInterceptor < Gcpc::Subscriber::BaseInterceptor 18 | def handle(data, attributes, message) 19 | MyLogger.info "[Interceptor Log] #{message.inspect}" 20 | MyLogger.info "[Interceptor Log] data: #{data}" 21 | MyLogger.info "[Interceptor Log] attributes: #{attributes}" 22 | yield data, attributes, message 23 | end 24 | end 25 | 26 | class LogHandler < Gcpc::Subscriber::BaseHandler 27 | def handle(data, attributes, message) 28 | MyLogger.info "[Handler Log] #{message.inspect}" 29 | MyLogger.info "[Handler Log] data: #{data}" 30 | MyLogger.info "[Handler Log] attributes: #{attributes}" 31 | end 32 | end 33 | 34 | # We create topic and subscription only for demonstration. 35 | def with_setup_subscription(&block) 36 | project = Google::Cloud::Pubsub.new( 37 | project_id: PROJECT_ID, 38 | emulator_host: "localhost:8085", 39 | ) 40 | if (topic = project.topic(TOPIC_NAME)).nil? 41 | # Create a topic if necessary 42 | topic = project.create_topic(TOPIC_NAME) 43 | end 44 | if (subscription = topic.subscription(SUBSCRIPTION_NAME)).nil? 45 | # Create a subscription if necessary 46 | subscription = topic.create_subscription(SUBSCRIPTION_NAME) 47 | end 48 | 49 | yield 50 | 51 | ensure 52 | topic.delete 53 | subscription.delete 54 | end 55 | 56 | def run 57 | subscriber = Gcpc::Subscriber.new( 58 | project_id: PROJECT_ID, 59 | subscription: SUBSCRIPTION_NAME, 60 | interceptors: [LogInterceptor], 61 | emulator_host: "localhost:8085", 62 | ) 63 | subscriber.handle(LogHandler) 64 | subscriber.run 65 | end 66 | 67 | def main 68 | with_setup_subscription do 69 | run 70 | end 71 | end 72 | 73 | main 74 | -------------------------------------------------------------------------------- /gcpc.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "gcpc/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "gcpc" 8 | spec.version = Gcpc::VERSION 9 | spec.authors = ["Nao Minami"] 10 | spec.email = ["south37777@gmail.com"] 11 | 12 | spec.summary = %q{Google Cloud Pub/Sub Client} 13 | spec.description = %q{Google Cloud Pub/Sub Client} 14 | spec.homepage = "https://github.com/wantedly/gcpc" 15 | spec.license = "MIT" 16 | 17 | # Specify which files should be added to the gem when it is released. 18 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 19 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 20 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 21 | end 22 | spec.bindir = "exe" 23 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 24 | spec.require_paths = ["lib"] 25 | 26 | spec.add_development_dependency "bundler" 27 | spec.add_development_dependency "rake", "~> 13.0" 28 | spec.add_development_dependency "rspec", "~> 3.2" 29 | spec.add_development_dependency "pry" 30 | spec.add_runtime_dependency "google-cloud-pubsub" 31 | end 32 | -------------------------------------------------------------------------------- /lib/gcpc.rb: -------------------------------------------------------------------------------- 1 | require "gcpc/version" 2 | require "gcpc/publisher" 3 | require "gcpc/subscriber" 4 | require "gcpc/config" 5 | 6 | module Gcpc 7 | end 8 | -------------------------------------------------------------------------------- /lib/gcpc/config.rb: -------------------------------------------------------------------------------- 1 | require "singleton" 2 | 3 | module Gcpc 4 | class Config 5 | include Singleton 6 | attr_reader :beat 7 | 8 | def initialize 9 | @beat = [] 10 | end 11 | 12 | def on(event, &block) 13 | raise ArgumentError, "Invalid event name: #{event}" if event != :beat 14 | 15 | @beat << block 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/gcpc/publisher.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | require "gcpc/publisher/base_interceptor" 3 | require "gcpc/publisher/engine" 4 | require "gcpc/publisher/topic_client" 5 | 6 | module Gcpc 7 | class Publisher 8 | # @param [String] project_id 9 | # @param [String] topic 10 | # @param [String, Google::Cloud::Pubsub::Credentials, nil] credentials Path 11 | # of keyfile or Google::Cloud::Pubsub::Credentials or nil. 12 | # @param [String, nil] emulator_host Emulator's host or nil. 13 | # @param [<#publish>] interceptors 14 | def initialize( 15 | project_id:, 16 | topic:, 17 | credentials: nil, 18 | emulator_host: nil, 19 | interceptors: [] 20 | ) 21 | topic_client = TopicClient.new( 22 | project_id: project_id, 23 | topic_name: topic, 24 | credentials: credentials, 25 | emulator_host: emulator_host, 26 | ) 27 | 28 | t = topic_client.get 29 | if t.nil? 30 | raise "Getting topic \"#{topic}\" from project \"#{project_id}\" failed! The topic \"#{topic}\" does not exist!" 31 | end 32 | 33 | @engine = Engine.new( 34 | topic: t, 35 | interceptors: interceptors, 36 | ) 37 | end 38 | 39 | extend Forwardable 40 | 41 | def_delegators :@engine, :publish, :publish_batch, :publish_async, :topic 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/gcpc/publisher/base_interceptor.rb: -------------------------------------------------------------------------------- 1 | # Interceptor must implement #publish. Gcpc::Publisher::BaseHandler is a base 2 | # class to implement such a class. You don't have to inherit this, this is only 3 | # for indicating interface. 4 | module Gcpc 5 | class Publisher 6 | class BaseInterceptor 7 | # @param [String] data 8 | # @param [Hash] attributes 9 | def publish(data, attributes, &block) 10 | yield data, attributes 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/gcpc/publisher/engine.rb: -------------------------------------------------------------------------------- 1 | require "gcpc/publisher/engine/batch_engine" 2 | require "gcpc/publisher/engine/chained_interceptor" 3 | 4 | module Gcpc 5 | class Publisher 6 | class Engine 7 | # @param [Google::Cloud::Pubsub::Topic] topic 8 | # @param [<#publish>] interceptors 9 | def initialize(topic:, interceptors:) 10 | @topic = topic 11 | interceptors = interceptors.map { |i| (i.class == Class) ? i.new : i } 12 | @interceptor = ChainedInterceptor.new(interceptors) 13 | end 14 | 15 | attr_reader :topic 16 | 17 | # @param [String] data 18 | # @param [Hash] attributes 19 | def publish(data, attributes = {}) 20 | d = data.dup 21 | a = attributes.dup 22 | 23 | @interceptor.intercept!(d, a) do |dd, aa| 24 | do_publish(dd, aa) 25 | end 26 | end 27 | 28 | # @param [Proc] block 29 | def publish_batch(&block) 30 | batch_engine = BatchEngine.new(topic: @topic, interceptor: @interceptor) 31 | yield batch_engine 32 | batch_engine.flush 33 | end 34 | 35 | # @param [String] data 36 | # @param [Hash] attributes 37 | def publish_async(data, attributes = {}, &block) 38 | d = data.dup 39 | a = attributes.dup 40 | 41 | @interceptor.intercept!(d, a) do |dd, aa| 42 | do_publish_async(dd, aa, &block) 43 | end 44 | end 45 | 46 | private 47 | 48 | # @param [String] data 49 | # @param [Hash] attributes 50 | def do_publish(data, attributes) 51 | @topic.publish(data, attributes) 52 | end 53 | 54 | # @param [String] data 55 | # @param [Hash] attributes 56 | # @param [Proc] block 57 | def do_publish_async(data, attributes, &block) 58 | @topic.publish_async(data, attributes, &block) 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/gcpc/publisher/engine/batch_engine.rb: -------------------------------------------------------------------------------- 1 | module Gcpc 2 | class Publisher 3 | class Engine 4 | class BatchEngine 5 | # @param [Google::Cloud::Pubsub::Topic] topic 6 | # @param [Engine::ChainedInterceptor] interceptor 7 | def initialize(topic:, interceptor:) 8 | @topic = topic 9 | @interceptor = interceptor 10 | @messages = [] # Container of data and attributes 11 | end 12 | 13 | # Enqueue a message 14 | # 15 | # @param [String] data 16 | # @param [Hash] attributes 17 | def publish(data, attributes = {}) 18 | d = data.dup 19 | a = attributes.dup 20 | 21 | @interceptor.intercept!(d, a) do |dd, aa| 22 | @messages << [dd, aa] 23 | end 24 | end 25 | 26 | # Flush all enqueued messages 27 | def flush 28 | @topic.publish do |t| 29 | @messages.each do |(data, attributes)| 30 | t.publish data, attributes 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/gcpc/publisher/engine/chained_interceptor.rb: -------------------------------------------------------------------------------- 1 | module Gcpc 2 | class Publisher 3 | class Engine 4 | class ChainedInterceptor 5 | # @param [<#publish>] interceptors 6 | def initialize(interceptors) 7 | @interceptors = interceptors 8 | end 9 | 10 | # @param [String] data 11 | # @param [Hash] attributes 12 | # @param [Proc] block 13 | def intercept!(data, attributes, &block) 14 | do_intercept!(@interceptors, data, attributes, &block) 15 | end 16 | 17 | private 18 | 19 | # @param [<#publish>] interceptors 20 | # @param [String] data 21 | # @param [Hash] attributes 22 | # @param [Proc] block 23 | def do_intercept!(interceptors, data, attributes, &block) 24 | if interceptors.size == 0 25 | return yield(data, attributes) 26 | end 27 | 28 | i = interceptors.first 29 | rest = interceptors[1..-1] 30 | 31 | i.publish(data, attributes) do |d, a| 32 | do_intercept!(rest, d, a, &block) 33 | end 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/gcpc/publisher/topic_client.rb: -------------------------------------------------------------------------------- 1 | module Gcpc 2 | class Publisher 3 | class TopicClient 4 | DEFAULT_CONNECT_TIMEOUT = 5 5 | 6 | # @param [String] project_id 7 | # @param [String] topic_name 8 | # @param [Google::Cloud::Pubsub::Credentials, String, nil] 9 | # @param [String, nil] emulator_host 10 | def initialize(project_id:, topic_name:, credentials:, emulator_host:, connect_timeout: DEFAULT_CONNECT_TIMEOUT) 11 | project = Google::Cloud::Pubsub.new( 12 | project_id: project_id, 13 | credentials: credentials, 14 | emulator_host: emulator_host, 15 | ) 16 | @project = project 17 | @topic_name = topic_name 18 | @connect_timeout = connect_timeout 19 | end 20 | 21 | # @return [Google::Cloud::Pubsub::Topic] 22 | def get 23 | t = nil 24 | Timeout.timeout(@connect_timeout) do 25 | t = @project.topic(@topic_name) 26 | end 27 | t 28 | rescue Timeout::Error => e 29 | raise "Getting topic \"#{@topic_name}\" from project \"#{@project.project_id}\" timed out!" 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/gcpc/subscriber.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | require "gcpc/subscriber/base_handler" 3 | require "gcpc/subscriber/base_interceptor" 4 | require "gcpc/subscriber/default_logger" 5 | require "gcpc/subscriber/engine" 6 | require "gcpc/subscriber/subscription_client" 7 | 8 | module Gcpc 9 | class Subscriber 10 | # @param [String] project_id 11 | # @param [String] subscription 12 | # @param [String, Google::Cloud::Pubsub::Credentials, nil] credentials Path 13 | # of keyfile or Google::Cloud::Pubsub::Credentials or nil. 14 | # @param [String, nil] emulator_host Emulator's host or nil. 15 | # @param [<#handle, #on_error>] interceptors 16 | # @param [bool] ack_immediately 17 | # @param [Logger] logger 18 | def initialize( 19 | project_id:, 20 | subscription:, 21 | credentials: nil, 22 | emulator_host: nil, 23 | interceptors: [], 24 | ack_immediately: false, 25 | logger: DefaultLogger 26 | ) 27 | subscription_client = SubscriptionClient.new( 28 | project_id: project_id, 29 | subscription_name: subscription, 30 | credentials: credentials, 31 | emulator_host: emulator_host, 32 | ) 33 | 34 | s = subscription_client.get 35 | if s.nil? 36 | raise "Getting subscription \"#{subscription}\" from project \"#{project_id}\" failed! The subscription \"#{subscription}\" does not exist!" 37 | end 38 | 39 | @engine = Engine.new( 40 | subscription: s, 41 | interceptors: interceptors, 42 | ack_immediately: ack_immediately, 43 | logger: logger, 44 | ) 45 | end 46 | 47 | extend Forwardable 48 | 49 | def_delegators :@engine, :handle, :run, :stop 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/gcpc/subscriber/base_handler.rb: -------------------------------------------------------------------------------- 1 | # Handler must implement #handle and can implement #on_error. 2 | # Gcpc::Subscriber::BaseHandler is a base class to implement such a class. 3 | # You don't have to inherit this, this is only for indicating interface. 4 | module Gcpc 5 | class Subscriber 6 | class BaseHandler 7 | # @param [String] data 8 | # @param [Hash] attributes 9 | # @param [Google::Cloud::Pubsub::ReceivedMessage] message 10 | def handle(data, attributes, message) 11 | raise NotImplementedError.new("You must implement #{self.class}##{__method__}") 12 | end 13 | 14 | # You don't need to implement #on_error if it is not necessary. 15 | # @param [Exception] error 16 | # def on_error(error) 17 | # raise NotImplementedError.new("You must implement #{self.class}##{__method__}") 18 | # end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/gcpc/subscriber/base_interceptor.rb: -------------------------------------------------------------------------------- 1 | # Interceptor must implement #handle and can implement #on_error. 2 | # Gcpc::Subscriber::BaseHandler is a base class to implement such a class. 3 | # You don't have to inherit this, this is only for indicating interface. 4 | module Gcpc 5 | class Subscriber 6 | class BaseInterceptor 7 | # @param [String] data 8 | # @param [Hash] attributes 9 | # @param [Google::Cloud::Pubsub::ReceivedMessage] message 10 | # @param [Proc] block 11 | def handle(data, attributes, message, &block) 12 | yield data, attributes, message 13 | end 14 | 15 | # You don't need to implement #on_error is it is not necessary. 16 | # @param [Exception] error 17 | # @param [Plock] block 18 | # def on_error(error, &block) 19 | # yield error 20 | # end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/gcpc/subscriber/default_logger.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | module Gcpc 4 | class Subscriber 5 | DefaultLogger = Logger.new(STDOUT) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/gcpc/subscriber/engine.rb: -------------------------------------------------------------------------------- 1 | require "gcpc/subscriber/handle_engine" 2 | 3 | module Gcpc 4 | class Subscriber 5 | class Engine 6 | WAIT_INTERVAL = 1 7 | WORKER_DEAD_THRESHOLD = 30 # second 8 | BEAT_INTERVAL = 10 9 | HEART_BEAT_WORKER_NAME = 'heartbeat-worker' 10 | 11 | # @param [Google::Cloud::Pubsub::Subscription] subscription 12 | # @param [<#handle, #on_error>] interceptors 13 | # @param [bool] ack_immediately 14 | # @param [Logger] logger 15 | def initialize( 16 | subscription:, 17 | interceptors: [], 18 | ack_immediately: false, 19 | logger: DefaultLogger 20 | ) 21 | 22 | @subscription = subscription 23 | @interceptors = interceptors 24 | @ack_immediately = ack_immediately 25 | @logger = logger 26 | @subscriber_thread_status = {} 27 | @subscriber_thread_status_mutex = Mutex.new 28 | @heartbeat_worker_thread = nil 29 | 30 | @subscriber = nil # @subscriber is created by calling `#run` 31 | @handler = nil # @handler must be registered by `#handle` 32 | 33 | @stopped_mutex = Mutex.new 34 | @stopped = false 35 | end 36 | 37 | # @param [] signals Signals which are used to shutdown subscriber 38 | # gracefully. 39 | def run(signals = ['SIGTERM', 'SIGINT']) 40 | if @handler.nil? 41 | raise "You must register handler by #handle before calling #run" 42 | end 43 | 44 | @logger.info("Starting to subscribe a subscription \"#{@subscription.name}\", will wait for background threads to start...") 45 | 46 | @subscriber = @subscription.listen do |message| 47 | handle_message(message) 48 | end 49 | @subscriber.on_error do |err| 50 | handle_error(err) 51 | end 52 | @subscriber.start 53 | 54 | @logger.info("Started") 55 | 56 | run_heartbeat_worker 57 | 58 | loop_until_receiving_signals(signals) 59 | end 60 | 61 | def stop 62 | if @subscriber.nil? 63 | raise "You must call #run before stopping" 64 | end 65 | 66 | @stopped_mutex.synchronize do 67 | # `#stop` may be called multiple times. Only first call can proceed. 68 | return if @stopped 69 | @stopped = true 70 | end 71 | 72 | @logger.info('Stopping, will wait for background threads to exit') 73 | 74 | @subscriber.stop 75 | 76 | begin 77 | @heartbeat_worker_thread&.wakeup 78 | # ThreadError exeption will be raised when the thread already dead 79 | rescue ThreadError => e 80 | @logger.error(e.message) 81 | end 82 | 83 | @heartbeat_worker_thread&.join 84 | 85 | @subscriber.wait! 86 | 87 | @logger.info('Stopped, background threads are shutdown') 88 | end 89 | 90 | # We support registrion of only one handler 91 | # @param [#handle, #on_error, Class] handler 92 | def handle(handler) 93 | @handler = HandleEngine.new( 94 | handler: handler, 95 | interceptors: @interceptors, 96 | ) 97 | end 98 | 99 | private 100 | 101 | def loop_until_receiving_signals(signals) 102 | signal_received = false 103 | signals.each do |signal| 104 | Signal.trap(signal) { signal_received = true } 105 | end 106 | while !(signal_received || stopped?) 107 | sleep WAIT_INTERVAL 108 | end 109 | 110 | stop unless stopped? 111 | end 112 | 113 | def run_heartbeat_worker 114 | @heartbeat_worker_thread = Thread.new do 115 | @logger.info("Starting heartbeat worker...") 116 | begin 117 | loop do 118 | break if stopped? 119 | 120 | next unless alive? 121 | 122 | Gcpc::Config.instance.beat.each(&:call) 123 | 124 | sleep BEAT_INTERVAL 125 | end 126 | ensure 127 | @logger.info("heartbeat worker stopped") 128 | end 129 | end 130 | 131 | @heartbeat_worker_thread.name = HEART_BEAT_WORKER_NAME 132 | end 133 | 134 | def alive? 135 | # ・When processing a message, write the thread_id and timestamp at the start time into @subscriber_thread_status, 136 | # and remove that information from @subscriber_thread_status when the processing within that thread is finished. 137 | # @subscriber_thread_status = {#=>1690757417} 138 | # ・If the processing of the message gets stuck, the key, value will not be removed from @subscriber_thread_status. 139 | # ・Since the application holds as many callback_threads as @subscriber.callback_threads with Subscription, 140 | # if the number of threads that have gotten stuck exceeds that callback_threads, it is considered that the worker unable to process Subscription queue. 141 | return false unless @subscriber && @subscriber.started? 142 | return false if @subscriber.stopped? 143 | 144 | number_of_dead_threads = @subscriber_thread_status.count { |_, v| v < Time.now.to_i - WORKER_DEAD_THRESHOLD } 145 | 146 | return @subscriber.callback_threads > number_of_dead_threads 147 | end 148 | 149 | 150 | # @param [Google::Cloud::Pubsub::ReceivedMessage] message 151 | def handle_message(message) 152 | write_heartbeat_to_subscriber_thread_status 153 | 154 | ack(message) if @ack_immediately 155 | 156 | begin 157 | worker_info("Started handling message") 158 | @handler.handle(message) 159 | worker_info("Finished hanlding message successfully") 160 | rescue => e 161 | nack(message) if !@ack_immediately 162 | raise e # exception is handled in `#handle_error` 163 | end 164 | 165 | ack(message) if !@ack_immediately 166 | end 167 | 168 | def ack(message) 169 | message.ack! 170 | cleanup_subscriber_thread_status 171 | worker_info("Acked message") 172 | end 173 | 174 | def nack(message) 175 | message.nack! 176 | cleanup_subscriber_thread_status 177 | worker_info("Nacked message") 178 | end 179 | 180 | # @param [Exception] error 181 | def handle_error(error) 182 | worker_error(error) 183 | @handler.on_error(error) 184 | end 185 | 186 | # @param [String] message 187 | def worker_info(message) 188 | @logger.info("[Worker #{Thread.current.object_id}] #{message}") 189 | end 190 | 191 | # @param [Exception] error 192 | def worker_error(error) 193 | e_str = "#{error.message}" 194 | e_str += "\n#{error.backtrace.join("\n")}" if error.backtrace 195 | @logger.error("[Worker #{Thread.current.object_id}] #{e_str}") 196 | end 197 | 198 | def stopped? 199 | @stopped_mutex.synchronize { @stopped } 200 | end 201 | 202 | def write_heartbeat_to_subscriber_thread_status 203 | begin 204 | @subscriber_thread_status_mutex.synchronize do 205 | @subscriber_thread_status[Thread.current] = Time.now.to_i 206 | end 207 | rescue ThreadError => e 208 | @logger.info("Falied to write subscriber_thread_status. thread_id: #{Thread.current.object_id}, subscriber_thread_status: #{@subscriber_thread_status}, error: #{e.message}") 209 | end 210 | end 211 | 212 | def cleanup_subscriber_thread_status 213 | begin 214 | @subscriber_thread_status_mutex.synchronize do 215 | # cleanup to avoid memory leak 216 | @subscriber_thread_status.delete(Thread.current) 217 | end 218 | rescue ThreadError => e 219 | @logger.info("Falied to cleanup subscriber_thread_status. thread_id: #{Thread.current.object_id}, subscriber_thread_status: #{@subscriber_thread_status}, error: #{e.message}") 220 | end 221 | end 222 | end 223 | end 224 | end 225 | -------------------------------------------------------------------------------- /lib/gcpc/subscriber/handle_engine.rb: -------------------------------------------------------------------------------- 1 | module Gcpc 2 | class Subscriber 3 | # HandleEngine handle messages and exceptions with interceptors. 4 | class HandleEngine 5 | # @param [#handle, #on_error, Class] handler 6 | # @param [<#handle, #on_error, Class>] interceptors 7 | def initialize(handler:, interceptors:) 8 | @handler = (handler.class == Class) ? handler.new : handler 9 | @interceptors = interceptors.map { |i| (i.class == Class) ? i.new : i } 10 | end 11 | 12 | # @param [Google::Cloud::Pubsub::ReceivedMessage] message 13 | def handle(message) 14 | d = message.data.dup 15 | a = message.attributes.dup 16 | 17 | intercept_message!(@interceptors, d, a, message) do |dd, aa, m| 18 | handle_message(dd, aa, m) 19 | end 20 | end 21 | 22 | # @param [Exception] error 23 | def on_error(error) 24 | intercept_error!(@interceptors, error) do |e| 25 | handle_on_error(e) 26 | end 27 | end 28 | 29 | private 30 | 31 | # @param [<#handle>] interceptors 32 | # @param [String] data 33 | # @param [Hash] attributes 34 | # @param [Google::Cloud::Pubsub::ReceivedMessage] message 35 | # @param [Proc] block 36 | def intercept_message!(interceptors, data, attributes, message, &block) 37 | if interceptors.size == 0 38 | return yield(data, attributes, message) 39 | end 40 | 41 | i = interceptors.first 42 | rest = interceptors[1..-1] 43 | 44 | i.handle(data, attributes, message) do |d, a, m| 45 | intercept_message!(rest, d, a, m, &block) 46 | end 47 | end 48 | 49 | # @param [<#on_error>] interceptors 50 | # @param [Exception] error 51 | # @param [Proc] block 52 | def intercept_error!(interceptors, error, &block) 53 | return yield(error) if interceptors.size == 0 54 | 55 | i = interceptors.first 56 | rest = interceptors[1..-1] 57 | 58 | if !i.respond_to?(:on_error) 59 | # If #on_error is not implemented in the interceptor, it is simply 60 | # skipped. 61 | return intercept_error!(rest, error, &block) 62 | end 63 | 64 | i.on_error(error) do |e| 65 | intercept_error!(rest, e, &block) 66 | end 67 | end 68 | 69 | # @param [String] data 70 | # @param [Hash] attributes 71 | # @param [Google::Cloud::Pubsub::ReceivedMessage] message 72 | def handle_message(data, attributes, message) 73 | @handler.handle(data, attributes, message) 74 | end 75 | 76 | # @param [Exception] error 77 | def handle_on_error(error) 78 | return if !@handler.respond_to?(:on_error) 79 | @handler.on_error(error) 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/gcpc/subscriber/subscription_client.rb: -------------------------------------------------------------------------------- 1 | require "google/cloud/pubsub" 2 | 3 | module Gcpc 4 | class Subscriber 5 | class SubscriptionClient 6 | DEFAULT_CONNECT_TIMEOUT = 5 7 | 8 | # @param [String] project_id 9 | # @param [String] subscription_name 10 | # @param [Google::Cloud::Pubsub::Credentials, String, nil] 11 | # @param [String, nil] emulator_host 12 | def initialize(project_id:, subscription_name:, credentials:, emulator_host:, connect_timeout: DEFAULT_CONNECT_TIMEOUT) 13 | project = Google::Cloud::Pubsub.new( 14 | project_id: project_id, 15 | credentials: credentials, 16 | emulator_host: emulator_host, 17 | ) 18 | @project = project 19 | @subscription_name = subscription_name 20 | @connect_timeout = connect_timeout 21 | end 22 | 23 | # @return [Google::Cloud::Pubsub::Subscription] 24 | def get 25 | t = nil 26 | Timeout.timeout(@connect_timeout) do 27 | t = @project.subscription(@subscription_name) 28 | end 29 | t 30 | rescue Timeout::Error => e 31 | raise "Getting subscription \"#{@subscription_name}\" from project \"#{@project.project_id}\" timed out!" 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/gcpc/version.rb: -------------------------------------------------------------------------------- 1 | module Gcpc 2 | VERSION = "0.0.8" 3 | end 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /spec/gcpc/publisher/engine_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "support/pubsub_resource_manager" 3 | 4 | describe Gcpc::Publisher::Engine do 5 | describe "#publish" do 6 | subject { engine.publish(data, attributes) } 7 | 8 | let(:engine) { 9 | Gcpc::Publisher::Engine.new( 10 | topic: topic, 11 | interceptors: interceptors, 12 | ) 13 | } 14 | let(:topic) { double(:topic) } 15 | let(:data) { "" } 16 | let(:attributes) { {} } 17 | 18 | context "when interceptors call yield" do 19 | let(:interceptors) { [hello_interceptor, world_interceptor] } 20 | let(:hello_interceptor) { 21 | Class.new(Gcpc::Subscriber::BaseInterceptor) do 22 | def publish(data, attributes, &block) 23 | data << "Hello" 24 | attributes.merge!(hello_interceptor: true) 25 | yield(data, attributes) 26 | end 27 | end 28 | } 29 | let(:world_interceptor) { 30 | Class.new do 31 | def publish(data, attributes, &block) 32 | data << ", World" 33 | attributes.merge!(world_interceptor: true) 34 | yield(data, attributes) 35 | end 36 | end 37 | } 38 | 39 | it "should call a topic's #publish after calling interceptors' #publish in order" do 40 | expect(topic).to receive(:publish) 41 | .with( 42 | "Hello, World", 43 | { 44 | hello_interceptor: true, 45 | world_interceptor: true, 46 | } 47 | ).once 48 | 49 | subject 50 | 51 | # topic and interceptors do not change original data and attributes 52 | expect(data).to eq "" 53 | expect(attributes).to eq({}) 54 | end 55 | end 56 | 57 | context "when interceptors do not call yield" do 58 | let(:interceptors) { [interceptor] } 59 | let(:interceptor) { 60 | Class.new do 61 | def publish(data, attributes, &block) 62 | # Do nothing 63 | end 64 | end 65 | } 66 | 67 | it "does not call a topic's #handle" do 68 | expect(topic).not_to receive(:handle) 69 | subject 70 | end 71 | end 72 | 73 | context "when emulator is running on localhost:8085", emulator: true do 74 | let(:pubsub_resource_manager) { 75 | PubsubResourceManager.new( 76 | project_id: "project-test-1", 77 | topic_name: topic_name, 78 | emulator_host: "localhost:8085", 79 | ) 80 | } 81 | let(:topic_name) { "topic-test-1" } 82 | 83 | around do |example| 84 | pubsub_resource_manager.setup_resource! 85 | example.run 86 | pubsub_resource_manager.cleanup_resource! 87 | end 88 | 89 | it "publishes messages" do 90 | topic = pubsub_resource_manager.topic 91 | engine = Gcpc::Publisher::Engine.new(topic: topic, interceptors: [id_interceptor]) 92 | r = engine.publish("data1") 93 | expect(r.data).to eq "data1" 94 | expect(r.attributes).to eq({ "id" => "1" }) 95 | end 96 | end 97 | end 98 | 99 | describe "#publish_batch" do 100 | context "when emulator is running on localhost:8085", emulator: true do 101 | let(:pubsub_resource_manager) { 102 | PubsubResourceManager.new( 103 | project_id: "project-test-1", 104 | topic_name: topic_name, 105 | emulator_host: "localhost:8085", 106 | ) 107 | } 108 | let(:topic_name) { "topic-test-1" } 109 | 110 | around do |example| 111 | pubsub_resource_manager.setup_resource! 112 | example.run 113 | pubsub_resource_manager.cleanup_resource! 114 | end 115 | 116 | it "publishes messages" do 117 | topic = pubsub_resource_manager.topic 118 | engine = Gcpc::Publisher::Engine.new(topic: topic, interceptors: [id_interceptor]) 119 | r = engine.publish_batch do |t| 120 | t.publish("data1") 121 | t.publish("data2") 122 | t.publish("data3") 123 | end 124 | expect(r.map(&:data)).to eq ["data1", "data2", "data3"] 125 | expect(r.map(&:attributes)).to eq [{ "id" => "1" }, { "id" => "1" }, { "id" => "1" }] 126 | end 127 | end 128 | end 129 | 130 | describe "#publish_async" do 131 | context "when emulator is running on localhost:8085", emulator: true do 132 | let(:pubsub_resource_manager) { 133 | PubsubResourceManager.new( 134 | project_id: "project-test-1", 135 | topic_name: topic_name, 136 | emulator_host: "localhost:8085", 137 | ) 138 | } 139 | let(:topic_name) { "topic-test-1" } 140 | let(:data) { "data" } 141 | 142 | around do |example| 143 | pubsub_resource_manager.setup_resource! 144 | example.run 145 | pubsub_resource_manager.cleanup_resource! 146 | end 147 | 148 | context "when block is given" do 149 | it "publishes messages" do 150 | topic = pubsub_resource_manager.topic 151 | engine = Gcpc::Publisher::Engine.new(topic: topic, interceptors: [id_interceptor]) 152 | q = Thread::Queue.new 153 | 3.times do |i| 154 | engine.publish_async(data) { |r| q.push(r) } 155 | end 156 | engine.topic.async_publisher.stop.wait! 157 | a = [] 158 | while (q.size > 0) 159 | a << q.pop 160 | end 161 | expect(a.map(&:succeeded?)).to eq [true, true, true] 162 | expect(a.map(&:data)).to eq [data, data, data] 163 | expect(a.map(&:attributes)).to eq [{ "id" => "1" }, { "id" => "1" }, { "id" => "1" }] 164 | end 165 | end 166 | end 167 | end 168 | 169 | def id_interceptor 170 | Class.new(Gcpc::Subscriber::BaseInterceptor) do 171 | def publish(data, attributes, &block) 172 | attributes.merge!(id: 1) 173 | yield(data, attributes) 174 | end 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /spec/gcpc/publisher/topic_client_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "support/pubsub_resource_manager" 3 | 4 | describe Gcpc::Publisher::TopicClient do 5 | describe "#get" do 6 | subject { topic_client.get } 7 | 8 | let(:topic_client) { 9 | Gcpc::Publisher::TopicClient.new( 10 | project_id: project_id, 11 | topic_name: topic_name, 12 | credentials: nil, 13 | emulator_host: emulator_host, 14 | connect_timeout:connect_timeout, 15 | ) 16 | } 17 | 18 | let(:project_id) { "project-test-1" } 19 | let(:topic_name) { "topic-test-1" } 20 | 21 | context "when emulator is running on localhost:8085", emulator: true do 22 | let(:emulator_host) { "localhost:8085" } 23 | let(:connect_timeout) { 1.0 } 24 | 25 | context "when topic does not exist" do 26 | it "returns nil" do 27 | expect(subject).to eq nil 28 | end 29 | end 30 | 31 | context "when subscription exist" do 32 | let(:pubsub_resource_manager) { 33 | PubsubResourceManager.new( 34 | project_id: project_id, 35 | topic_name: topic_name, 36 | emulator_host: emulator_host, 37 | ) 38 | } 39 | 40 | around do |example| 41 | pubsub_resource_manager.setup_resource! 42 | example.run 43 | pubsub_resource_manager.cleanup_resource! 44 | end 45 | 46 | it "returns Google::Cloud::Pubsub::Subscription" do 47 | expect(subject).to be_a Google::Cloud::Pubsub::Topic 48 | expect(subject.name).to eq "projects/project-test-1/topics/topic-test-1" 49 | end 50 | end 51 | end 52 | 53 | context "when emulator is not running" do 54 | let(:emulator_host) { "emulator:emulator_port" } 55 | let(:connect_timeout) { 0.1 } 56 | 57 | it "raises error" do 58 | expect { subject }.to raise_error( 59 | 'Getting topic "topic-test-1" from project "project-test-1" timed out!' 60 | ) 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/gcpc/publisher_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "support/pubsub_resource_manager" 3 | 4 | describe Gcpc::Publisher do 5 | describe "#new" do 6 | subject { 7 | Gcpc::Publisher.new( 8 | project_id: project_id, 9 | topic: topic_name, 10 | emulator_host: emulator_host, 11 | ) 12 | } 13 | 14 | let(:project_id) { "project-test-1" } 15 | let(:topic_name) { "topic-test-1" } 16 | 17 | context "when emulator is not running" do 18 | let(:emulator_host) { "emulator_host:emulator_port" } 19 | 20 | before do 21 | allow_any_instance_of(Gcpc::Publisher::TopicClient) 22 | .to receive(:get) 23 | .and_return(double(:topic)) 24 | end 25 | 26 | it "does not raise error" do 27 | expect { subject }.not_to raise_error 28 | end 29 | end 30 | 31 | context "when emulator is running on localhost:8085", emulator: true do 32 | let(:emulator_host) { "localhost:8085" } 33 | 34 | context "when a topic exist" do 35 | let(:pubsub_resource_manager) { 36 | PubsubResourceManager.new( 37 | project_id: project_id, 38 | topic_name: topic_name, 39 | emulator_host: emulator_host, 40 | ) 41 | } 42 | 43 | around do |example| 44 | pubsub_resource_manager.setup_resource! 45 | example.run 46 | pubsub_resource_manager.cleanup_resource! 47 | end 48 | 49 | it "does not raise error" do 50 | expect { subject }.not_to raise_error 51 | end 52 | end 53 | 54 | context "when a topic does not exist" do 55 | it "does raises error" do 56 | expect { subject }.to raise_error( 57 | 'Getting topic "topic-test-1" from project "project-test-1" failed! The topic "topic-test-1" does not exist!' 58 | ) 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/gcpc/subscriber/engine_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "support/pubsub_resource_manager" 3 | 4 | describe Gcpc::Subscriber::Engine do 5 | describe "#run and #stop" do 6 | context "when emulator is not running" do 7 | before do 8 | stub_const "FakeSubscription", Class.new 9 | class FakeSubscription 10 | def listen(&block) 11 | FakeSubscriber.new 12 | end 13 | 14 | def name 15 | "/projects//subscription/" 16 | end 17 | end 18 | 19 | stub_const "FakeSubscriber", Class.new 20 | class FakeSubscriber 21 | def on_error(&block) 22 | # Do nothing 23 | end 24 | 25 | def start 26 | # Do nothing 27 | end 28 | 29 | def stop 30 | # Do nothing 31 | end 32 | 33 | def wait! 34 | # Do nothing 35 | end 36 | end 37 | end 38 | 39 | let(:engine) { 40 | Gcpc::Subscriber::Engine.new( 41 | subscription: FakeSubscription.new, 42 | logger: Logger.new(nil), 43 | ) 44 | } 45 | 46 | before do 47 | stub_const "NopHandler", Class.new(Gcpc::Subscriber::BaseHandler) 48 | engine.handle(NopHandler) 49 | end 50 | 51 | it "must call specified methods of Subscription and Subscriber" do 52 | expect_any_instance_of(FakeSubscription).to receive(:listen) 53 | .and_return(FakeSubscriber.new) 54 | expect_any_instance_of(FakeSubscriber).to receive(:on_error).once 55 | expect_any_instance_of(FakeSubscriber).to receive(:start).once 56 | # Don't do loop in #loop_until_receiving_signals 57 | expect(engine).to receive(:loop_until_receiving_signals).once 58 | expect(engine).to receive(:run_heartbeat_worker).once 59 | 60 | engine.run 61 | 62 | expect_any_instance_of(FakeSubscriber).to receive(:stop).once 63 | expect_any_instance_of(FakeSubscriber).to receive(:wait!).once 64 | 65 | engine.stop 66 | end 67 | end 68 | 69 | context "when emulator is running on localhost:8085", emulator: true do 70 | let(:pubsub_resource_manager) { 71 | PubsubResourceManager.new( 72 | project_id: "project-test-1", 73 | topic_name: topic_name, 74 | subscription_name: subscription_name, 75 | emulator_host: "localhost:8085", 76 | ) 77 | } 78 | let(:topic_name) { "topic-test-1" } 79 | let(:subscription_name) { "subscription-test-1" } 80 | 81 | around do |example| 82 | pubsub_resource_manager.setup_resource! 83 | example.run 84 | pubsub_resource_manager.cleanup_resource! 85 | end 86 | 87 | context "when handler succeeds to handle" do 88 | before do 89 | stub_const "Handler", Class.new 90 | class Handler 91 | def initialize 92 | @handled = [] 93 | end 94 | 95 | attr_reader :handled 96 | 97 | def handle(data, attributes, message) 98 | @handled << data 99 | end 100 | end 101 | end 102 | 103 | it "calls Google::Cloud::Pubsub::Subscription#start" do 104 | subscription = pubsub_resource_manager.subscription 105 | engine = Gcpc::Subscriber::Engine.new( 106 | subscription: subscription 107 | ) 108 | handler = Handler.new 109 | engine.handle(handler) 110 | 111 | # Don't do loop in #loop_until_receiving_signals 112 | expect(engine).to receive(:loop_until_receiving_signals).once 113 | 114 | engine.run 115 | 116 | topic = pubsub_resource_manager.topic 117 | topic.publish("published payload") 118 | 119 | sleep 1 # Wait until message is subscribed 120 | 121 | heartbeat_worker_thread = Thread.list.find{ |t| t.name == Gcpc::Subscriber::Engine::HEART_BEAT_WORKER_NAME } 122 | expect(heartbeat_worker_thread.nil?).to eq false 123 | expect(heartbeat_worker_thread.alive?).to eq true 124 | 125 | expect(handler.handled.size).to eq 1 126 | expect(handler.handled.first).to eq "published payload" 127 | 128 | engine.stop 129 | 130 | expect(heartbeat_worker_thread.alive?).to eq false 131 | end 132 | end 133 | 134 | context "when handler fails to handle" do 135 | before do 136 | stub_const "Handler", Class.new 137 | class Handler 138 | def handle(data, attributes, message) 139 | raise "Failure occured in #handle!" 140 | end 141 | end 142 | end 143 | 144 | it "calls Subscriber::Engine#nack and Subscriber::Engine#handle_error" do 145 | subscription = pubsub_resource_manager.subscription 146 | engine = Gcpc::Subscriber::Engine.new( 147 | subscription: subscription 148 | ) 149 | handler = Handler.new 150 | engine.handle(handler) 151 | 152 | # Don't do loop in #loop_until_receiving_signals 153 | expect(engine).to receive(:loop_until_receiving_signals).once 154 | 155 | expect(engine).to receive(:nack).once 156 | expect(engine).to receive(:handle_error).once 157 | 158 | engine.run 159 | 160 | topic = pubsub_resource_manager.topic 161 | topic.publish("published payload") 162 | 163 | sleep 1 # Wait until message is subscribed 164 | 165 | heartbeat_worker_thread = Thread.list.find{ |t| t.name == Gcpc::Subscriber::Engine::HEART_BEAT_WORKER_NAME } 166 | expect(heartbeat_worker_thread.nil?).to eq false 167 | expect(heartbeat_worker_thread.alive?).to eq true 168 | 169 | engine.stop 170 | 171 | expect(heartbeat_worker_thread.alive?).to eq false 172 | end 173 | end 174 | end 175 | end 176 | 177 | describe "#handle" do 178 | let(:engine) { 179 | Gcpc::Subscriber::Engine.new( 180 | subscription: subscription 181 | ) 182 | } 183 | let(:subscription) { double(:subscription) } 184 | 185 | context "when handler is a object" do 186 | let(:handler) { double(:handler) } 187 | 188 | it "registers a handler" do 189 | engine.handle(handler) 190 | h = engine.instance_variable_get(:@handler) 191 | .instance_variable_get(:@handler) 192 | expect(h).to eq handler 193 | end 194 | end 195 | 196 | context "when handler is class" do 197 | before do 198 | stub_const "NopHandler", Class.new(Gcpc::Subscriber::BaseHandler) 199 | end 200 | 201 | it "registers a instantiated object" do 202 | engine.handle(NopHandler) 203 | h = engine.instance_variable_get(:@handler) 204 | .instance_variable_get(:@handler) 205 | expect(h).to be_kind_of(NopHandler) 206 | end 207 | end 208 | end 209 | 210 | describe "#handle_message" do 211 | subject { engine.send(:handle_message, message) } 212 | 213 | let(:message) { double(:message, data: "", attributes: {}) } 214 | let(:handler) { double(:handler) } 215 | 216 | before do 217 | engine.handle(handler) 218 | 219 | stub_const "OrderContainer", Class.new 220 | class << OrderContainer 221 | def append(obj) 222 | container << obj 223 | end 224 | 225 | def container 226 | @container ||= [] 227 | end 228 | end 229 | 230 | def message.ack! 231 | OrderContainer.append("message is acked") 232 | end 233 | 234 | def handler.handle(data, attributes, message) 235 | OrderContainer.append("message is handled") 236 | end 237 | end 238 | 239 | context "when ack_immediately is true" do 240 | let(:engine) { 241 | Gcpc::Subscriber::Engine.new( 242 | subscription: subscription, 243 | ack_immediately: true, 244 | logger: Logger.new(nil), 245 | ) 246 | } 247 | let(:subscription) { double(:subscription) } 248 | 249 | it "calls ack! before calling handler#handle" do 250 | subject 251 | expect(OrderContainer.container).to eq [ 252 | "message is acked", 253 | "message is handled", 254 | ] 255 | end 256 | end 257 | 258 | context "when ack_immediately is not set" do 259 | let(:engine) { 260 | Gcpc::Subscriber::Engine.new( 261 | subscription: subscription, 262 | logger: Logger.new(nil), 263 | ) 264 | } 265 | let(:subscription) { double(:subscription) } 266 | 267 | it "calls ack! after calling handler#handle" do 268 | subject 269 | expect(OrderContainer.container).to eq [ 270 | "message is handled", 271 | "message is acked", 272 | ] 273 | end 274 | end 275 | end 276 | 277 | describe "#handle_error" do 278 | subject { engine.send(:handle_error, error) } 279 | 280 | let(:engine) { 281 | Gcpc::Subscriber::Engine.new( 282 | subscription: subscription, 283 | logger: Logger.new(nil), 284 | ) 285 | } 286 | let(:subscription) { double(:subscription) } 287 | let(:error) { RuntimeError.new } 288 | let(:handler) { handler_class.new } 289 | let(:handler_class) { 290 | Class.new(Gcpc::Subscriber::BaseHandler) do 291 | def on_error(error) 292 | # Do nothing 293 | end 294 | end 295 | } 296 | 297 | before do 298 | engine.handle(handler) 299 | end 300 | 301 | it "calls handler#on_error" do 302 | expect(handler).to receive(:on_error).with(error).once 303 | subject 304 | end 305 | end 306 | end 307 | -------------------------------------------------------------------------------- /spec/gcpc/subscriber/handle_engine_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Gcpc::Subscriber::HandleEngine do 4 | describe "#handle" do 5 | subject { handle_engine.handle(received_message) } 6 | 7 | let(:handle_engine) { 8 | Gcpc::Subscriber::HandleEngine.new( 9 | handler: handler, 10 | interceptors: interceptors, 11 | ) 12 | } 13 | let(:handler) { double(:handler) } 14 | let(:received_message) { 15 | instance_double( 16 | "Google::Cloud::Pubsub::ReceivedMessage", 17 | data: "", 18 | attributes: {}, 19 | ) 20 | } 21 | 22 | context "when interceptors call yield" do 23 | let(:interceptors) { [hello_interceptor, world_interceptor] } 24 | let(:hello_interceptor) { 25 | Class.new(Gcpc::Subscriber::BaseInterceptor) do 26 | # @param [String] data 27 | # @param [Hash] attributes 28 | # @param [Google::Cloud::Pubsub::ReceivedMessage] _ 29 | # @param [Proc] block 30 | def handle(data, attributes, _, &block) 31 | data << "Hello" 32 | attributes.merge!(hello_interceptor: true) 33 | yield data, attributes, _ 34 | end 35 | end 36 | } 37 | let(:world_interceptor) { 38 | Class.new do 39 | # @param [String] data 40 | # @param [Hash] attributes 41 | # @param [Google::Cloud::Pubsub::ReceivedMessage] _ 42 | # @param [Proc] block 43 | def handle(data, attributes, _, &block) 44 | data << ", World" 45 | attributes.merge!(world_interceptor: true) 46 | yield data, attributes, _ 47 | end 48 | end 49 | } 50 | 51 | it "should call a handler's #handle after calling interceptors' #handle in order" do 52 | data = "Hello, World" 53 | attributes = { 54 | hello_interceptor: true, 55 | world_interceptor: true, 56 | } 57 | expect(handler).to receive(:handle) 58 | .with(data, attributes, received_message) 59 | .once 60 | subject 61 | 62 | # handler and interceptors do not change original_message 63 | expect(received_message.data).to eq "" 64 | expect(received_message.attributes).to eq({}) 65 | end 66 | end 67 | 68 | context "when interceptors do not call yield" do 69 | let(:interceptors) { [interceptor] } 70 | let(:interceptor) { 71 | Class.new do 72 | # @param [String] data 73 | # @param [Hash] attributes 74 | # @param [Google::Cloud::Pubsub::ReceivedMessage] _ 75 | # @param [Proc] block 76 | def handle(data, attributes, _, &block) 77 | # Do nothing 78 | end 79 | end 80 | } 81 | 82 | it "does not call a handler's #handle" do 83 | expect(handler).not_to receive(:handle) 84 | subject 85 | end 86 | end 87 | end 88 | 89 | describe "#on_error" do 90 | subject { handle_engine.on_error(error) } 91 | let(:error) { StandardError.new } 92 | 93 | context "with no interceptor" do 94 | let(:handle_engine) { 95 | Gcpc::Subscriber::HandleEngine.new( 96 | handler: handler, 97 | interceptors: [], 98 | ) 99 | } 100 | 101 | let(:handler) { handler_class.new } 102 | let(:handler_class) { 103 | Class.new(Gcpc::Subscriber::BaseHandler) do 104 | # @param [Exception] error 105 | def on_error(error) 106 | # Do nothing 107 | end 108 | end 109 | } 110 | 111 | it "calls handler#on_error" do 112 | expect(handler).to receive(:on_error).with(error).once 113 | subject 114 | end 115 | end 116 | 117 | context "with interceptor" do 118 | let(:handle_engine) { 119 | Gcpc::Subscriber::HandleEngine.new( 120 | handler: handler, 121 | interceptors: interceptors, 122 | ) 123 | } 124 | let(:handler) { handler_class.new } 125 | let(:handler_class) { 126 | Class.new(Gcpc::Subscriber::BaseHandler) do 127 | # @param [Exception] error 128 | def on_error(error) 129 | # Do nothing 130 | end 131 | end 132 | } 133 | 134 | context "when on_error is not implemented in interceptors" do 135 | let(:interceptors) { [double(:interceptor)] } 136 | 137 | it "skips interceptors" do 138 | expect(handler).to receive(:on_error).with(error).once 139 | subject 140 | end 141 | end 142 | 143 | context "when on_error is implemented in interceptors" do 144 | let(:interceptors) { [hello_interceptor, world_interceptor] } 145 | let(:hello_interceptor) { 146 | Class.new(Gcpc::Subscriber::BaseInterceptor) do 147 | # @param [Exception] error 148 | # @param [Proc] block 149 | def on_error(error, &block) 150 | InterceptorOrderContainer.append :hello_interceptor 151 | yield(error) 152 | end 153 | end 154 | } 155 | let(:world_interceptor) { 156 | Class.new(Gcpc::Subscriber::BaseInterceptor) do 157 | # @param [Exception] error 158 | # @param [Proc] block 159 | def on_error(error, &block) 160 | InterceptorOrderContainer.append :world_interceptor 161 | yield(error) 162 | end 163 | end 164 | } 165 | 166 | before do 167 | # InterceptorOrderContainer is used only for observing the order of 168 | # interceptors which are called in HandleEngine#on_error. 169 | stub_const "InterceptorOrderContainer", Class.new 170 | 171 | def InterceptorOrderContainer.append(obj) 172 | container << obj 173 | end 174 | 175 | def InterceptorOrderContainer.container 176 | @container ||= [] 177 | end 178 | end 179 | 180 | it "should call a handler's #on_error after calling interceptors' #on_error in order" do 181 | expect(handler).to receive(:on_error).with(error).once 182 | subject 183 | expect(InterceptorOrderContainer.container).to eq [ 184 | :hello_interceptor, 185 | :world_interceptor, 186 | ] 187 | end 188 | end 189 | 190 | context "when interceptors do not call yield" do 191 | let(:interceptors) { [interceptor] } 192 | let(:interceptor) { 193 | Class.new(Gcpc::Subscriber::BaseInterceptor) do 194 | # @param [Exception] error 195 | # @param [Proc] block 196 | def on_error(error, &block) 197 | # Do nothing 198 | end 199 | end 200 | } 201 | 202 | it "does not call a handler's #on_error" do 203 | expect(handler).not_to receive(:on_error) 204 | subject 205 | end 206 | end 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /spec/gcpc/subscriber/subscription_client_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "support/pubsub_resource_manager" 3 | 4 | describe Gcpc::Subscriber::SubscriptionClient do 5 | describe "#get" do 6 | subject { subscription_client.get } 7 | 8 | let(:subscription_client) { 9 | Gcpc::Subscriber::SubscriptionClient.new( 10 | project_id: project_id, 11 | subscription_name: subscription_name, 12 | credentials: nil, 13 | emulator_host: emulator_host, 14 | connect_timeout: connect_timeout, 15 | ) 16 | } 17 | let(:project_id) { "project-test-1" } 18 | let(:subscription_name) { "subscription-test-1" } 19 | 20 | context "when emulator is running on localhost:8085", emulator: true do 21 | let(:emulator_host) { "localhost:8085" } 22 | let(:connect_timeout) { 1.0 } 23 | 24 | context "when subscription does not exist" do 25 | it "returns nil" do 26 | expect(subject).to eq nil 27 | end 28 | end 29 | 30 | context "when subscription exist" do 31 | let(:pubsub_resource_manager) { 32 | PubsubResourceManager.new( 33 | project_id: project_id, 34 | topic_name: "topic-test-1", 35 | subscription_name: subscription_name, 36 | emulator_host: emulator_host, 37 | ) 38 | } 39 | 40 | around do |example| 41 | pubsub_resource_manager.setup_resource! 42 | example.run 43 | pubsub_resource_manager.cleanup_resource! 44 | end 45 | 46 | it "returns Google::Cloud::Pubsub::Subscription" do 47 | expect(subject).to be_a Google::Cloud::Pubsub::Subscription 48 | expect(subject.name).to eq "projects/project-test-1/subscriptions/subscription-test-1" 49 | end 50 | end 51 | end 52 | 53 | context "when emulator is not running" do 54 | let(:emulator_host) { "emulator:emulator_port" } 55 | let(:connect_timeout) { 0.1 } 56 | 57 | it "raises error" do 58 | expect { subject }.to raise_error( 59 | 'Getting subscription "subscription-test-1" from project "project-test-1" timed out!' 60 | ) 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/gcpc/subscriber_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "support/pubsub_resource_manager" 3 | 4 | describe Gcpc::Subscriber do 5 | describe "#new" do 6 | subject { 7 | Gcpc::Subscriber.new( 8 | project_id: project_id, 9 | subscription: subscription_name, 10 | emulator_host: emulator_host, 11 | ) 12 | } 13 | 14 | let(:project_id) { "project-test-1" } 15 | let(:subscription_name) { "subscription-test-1" } 16 | 17 | context "when emulator is not running" do 18 | let(:emulator_host) { "emulator_host:emulator_port" } 19 | 20 | before do 21 | allow_any_instance_of(Gcpc::Subscriber::SubscriptionClient) 22 | .to receive(:get) 23 | .and_return(double(:subscription)) 24 | end 25 | 26 | it "does not raise error" do 27 | expect { subject }.not_to raise_error 28 | end 29 | end 30 | 31 | context "when emulator is running on localhost:8085", emulator: true do 32 | let(:emulator_host) { "localhost:8085" } 33 | 34 | context "when a subscription exists" do 35 | let(:pubsub_resource_manager) { 36 | PubsubResourceManager.new( 37 | project_id: project_id, 38 | topic_name: "topic-test-1", 39 | subscription_name: subscription_name, 40 | emulator_host: emulator_host, 41 | ) 42 | } 43 | 44 | around do |example| 45 | pubsub_resource_manager.setup_resource! 46 | example.run 47 | pubsub_resource_manager.cleanup_resource! 48 | end 49 | 50 | it "does not raise error" do 51 | expect { subject }.not_to raise_error 52 | end 53 | end 54 | 55 | context "when a subscription does not exist" do 56 | it "raises error" do 57 | expect { subject }.to raise_error( 58 | 'Getting subscription "subscription-test-1" from project "project-test-1" failed! The subscription "subscription-test-1" does not exist!' 59 | ) 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/gcpc_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "support/pubsub_resource_manager" 3 | 4 | describe Gcpc do 5 | describe "e2e", emulator: true do 6 | let(:project_id) { "project-test-1" } 7 | let(:topic_name) { "topic-test-1" } 8 | let(:subscription_name) { "subscription-test-1" } 9 | let(:emulator_host) { "localhost:8085" } 10 | 11 | let(:pubsub_resource_manager) { 12 | PubsubResourceManager.new( 13 | project_id: project_id, 14 | topic_name: topic_name, 15 | subscription_name: subscription_name, 16 | emulator_host: emulator_host, 17 | ) 18 | } 19 | 20 | around do |example| 21 | pubsub_resource_manager.setup_resource! 22 | example.run 23 | pubsub_resource_manager.cleanup_resource! 24 | end 25 | 26 | before do 27 | allow_any_instance_of(Gcpc::Subscriber::Engine).to receive(:loop_until_receiving_signals) 28 | end 29 | 30 | it "succeeds to publish and subscribe messages" do 31 | stub_handler = double(:stub_handler) 32 | expect(stub_handler).to receive(:handle).once 33 | 34 | subscriber = Gcpc::Subscriber.new( 35 | project_id: project_id, 36 | subscription: subscription_name, 37 | emulator_host: "localhost:8085", 38 | ) 39 | subscriber.handle(stub_handler) 40 | 41 | # Start subscriber in another thread 42 | subscriber_thread = Thread.new(subscriber) do |subscriber| 43 | subscriber.run 44 | end 45 | 46 | publisher = Gcpc::Publisher.new( 47 | project_id: project_id, 48 | topic: topic_name, 49 | emulator_host: emulator_host, 50 | ) 51 | data = "message payload" 52 | attributes = { publisher: "publisher-example" } 53 | publisher.publish(data, attributes) 54 | 55 | sleep 1 # Wait for publish / subscribe a message 56 | 57 | # Stop the subscriber and its thread. 58 | subscriber.stop 59 | subscriber_thread.join 60 | end 61 | 62 | it "succeeds to publish_batch and subscribe messages" do 63 | stub_handler = double(:stub_handler) 64 | expect(stub_handler).to receive(:handle).once 65 | 66 | subscriber = Gcpc::Subscriber.new( 67 | project_id: project_id, 68 | subscription: subscription_name, 69 | emulator_host: "localhost:8085", 70 | ) 71 | subscriber.handle(stub_handler) 72 | 73 | # Start subscriber in another thread 74 | subscriber_thread = Thread.new(subscriber) do |subscriber| 75 | subscriber.run 76 | end 77 | 78 | publisher = Gcpc::Publisher.new( 79 | project_id: project_id, 80 | topic: topic_name, 81 | emulator_host: emulator_host, 82 | ) 83 | data = "message payload" 84 | attributes = { publisher: "publisher-example" } 85 | publisher.publish_batch do |t| 86 | t.publish(data, attributes) 87 | end 88 | 89 | sleep 1 # Wait for publish / subscribe a message 90 | 91 | # Stop the subscriber and its thread. 92 | subscriber.stop 93 | subscriber_thread.join 94 | end 95 | 96 | it "succeeds to publish_async and subscribe messages" do 97 | stub_handler = double(:stub_handler) 98 | expect(stub_handler).to receive(:handle).once 99 | 100 | subscriber = Gcpc::Subscriber.new( 101 | project_id: project_id, 102 | subscription: subscription_name, 103 | emulator_host: "localhost:8085", 104 | ) 105 | subscriber.handle(stub_handler) 106 | 107 | # Start subscriber in another thread 108 | subscriber_thread = Thread.new(subscriber) do |subscriber| 109 | subscriber.run 110 | end 111 | 112 | publisher = Gcpc::Publisher.new( 113 | project_id: project_id, 114 | topic: topic_name, 115 | emulator_host: emulator_host, 116 | ) 117 | data = "message payload" 118 | attributes = { publisher: "publisher-example" } 119 | publisher.publish_async(data, attributes) 120 | publisher.topic.async_publisher.stop.wait! # Wait for asynchronous publishing 121 | 122 | sleep 1 # Wait for publish / subscribe a message 123 | 124 | # Stop the subscriber and its thread. 125 | subscriber.stop 126 | subscriber_thread.join 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | # rspec-expectations config goes here. You can use an alternate 3 | # assertion/expectation library such as wrong or the stdlib/minitest 4 | # assertions if you prefer. 5 | config.expect_with :rspec do |expectations| 6 | # This option will default to `true` in RSpec 4. It makes the `description` 7 | # and `failure_message` of custom matchers include text for helper methods 8 | # defined using `chain`, e.g.: 9 | # be_bigger_than(2).and_smaller_than(4).description 10 | # # => "be bigger than 2 and smaller than 4" 11 | # ...rather than: 12 | # # => "be bigger than 2" 13 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 14 | end 15 | 16 | # rspec-mocks config goes here. You can use an alternate test double 17 | # library (such as bogus or mocha) by changing the `mock_with` option here. 18 | config.mock_with :rspec do |mocks| 19 | # Prevents you from mocking or stubbing a method that does not exist on 20 | # a real object. This is generally recommended, and will default to 21 | # `true` in RSpec 4. 22 | mocks.verify_partial_doubles = true 23 | end 24 | 25 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 26 | # have no way to turn it off -- the option exists only for backwards 27 | # compatibility in RSpec 3). It causes shared context metadata to be 28 | # inherited by the metadata hash of host groups and examples, rather than 29 | # triggering implicit auto-inclusion in groups with matching metadata. 30 | config.shared_context_metadata_behavior = :apply_to_host_groups 31 | 32 | # You can run spec with emulator by passing `--tag emulator` option. 33 | # bundle exec rspec --tag emulator 34 | config.filter_run_excluding emulator: true 35 | end 36 | 37 | require "gcpc" 38 | -------------------------------------------------------------------------------- /spec/support/pubsub_resource_manager.rb: -------------------------------------------------------------------------------- 1 | class PubsubResourceManager 2 | # @param [String] project_id 3 | # @param [String] topic_name 4 | # @param [String, nil] subscription_name 5 | # @param [String] emulator_host 6 | def initialize(project_id:, topic_name:, subscription_name: nil, emulator_host:) 7 | @project = Google::Cloud::Pubsub.new( 8 | project_id: project_id, 9 | emulator_host: emulator_host, 10 | ) 11 | @topic_name = topic_name 12 | @subscription_name = subscription_name 13 | 14 | # By calling #setup_resource!, @topic and @subscription are created 15 | @topic = nil 16 | @subscription = nil 17 | end 18 | 19 | attr_reader :topic, :subscription 20 | 21 | def setup_resource! 22 | # Create topic and subscription in emulator 23 | @topic = @project.create_topic(@topic_name) 24 | 25 | if @subscription_name 26 | @subscription = @topic.create_subscription(@subscription_name) 27 | end 28 | end 29 | 30 | def cleanup_resource! 31 | # Delete topic and subscription in emulator 32 | 33 | if @subscription_name 34 | s = @project.subscription(@subscription_name) 35 | s.delete 36 | end 37 | 38 | t = @project.topic(@topic_name) 39 | t.delete 40 | 41 | @subscription = nil 42 | @topic = nil 43 | end 44 | end 45 | --------------------------------------------------------------------------------