bindan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 41a496e6f91cd22b627b887d5b0cb209cd3c6d277dedbf527dd38c774d444273
4
+ data.tar.gz: 6dea83b8c98edfa91989d2b488180e250710336cfe7b6470e257cec75c292c89
5
+ SHA512:
6
+ metadata.gz: 0ffd5b03d4a1c9a76c696a514fbaa5bac9d323820dc19c88229b0ba88c6e6e893b6bd6b2e8d1659d325e4f235f906b87d14e37741ed0ba7045e11bcf8be4dcf7
7
+ data.tar.gz: 5a46f6c97809b06dc6dc4aa7b75a2e5093059b71f6baeb6fd75450f303471b7f005fee21f995787866d2341b1440a1f3d5fedd8dcfb3be98e77f81e0bf000a0e
data/.editorconfig ADDED
@@ -0,0 +1,5 @@
1
+ [*]
2
+ indent_size = 2
3
+ indent_style = space
4
+ insert_final_newline = true
5
+ trim_trailing_whitespace = true
data/.envrc ADDED
@@ -0,0 +1,2 @@
1
+ export FIRESTORE_EMULATOR_HOST=localhost:8080
2
+ export FIRESTORE_PROJECT_ID=project
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/standardrb/standard
3
+ ruby_version: 3.0
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-08-03
4
+
5
+ - Initial release
6
+
7
+ ## [0.0.0] - 2025-07-12
8
+
9
+ - start project
data/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ Copyright 2025 wtnabe
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+
7
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
+
9
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # Bindan
2
+ [![Gem Version](https://badge.fury.io/rb/bindan.svg)](https://badge.fury.io/rb/bindan)
3
+ [![CI](https://github.com/wtnabe/bindan/actions/workflows/main.yml/badge.svg)](https://github.com/wtnabe/bindan/actions/workflows/main.yml)
4
+
5
+ Bindan is a Ruby gem for building single configuration object from various sources with providers (that is bundled Google Cloud Storage and Firestore platform by default). It provides a flexible way to manage application settings, supporting lazy initialization and seamless switching between development (using emulators) and production (eg. actual GCP services) environments.
6
+
7
+ ## Features
8
+
9
+ - **Environment Transparency**: Use the same code for development (with emulators) and production.
10
+ - **Multiple Providers**: Fetch configuration from:
11
+ - Environment Variables
12
+ - Google Cloud Storage
13
+ - Google Cloud Firestore
14
+ - your custom provider
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ ```ruby
21
+ gem 'bindan'
22
+ ```
23
+
24
+ And then execute:
25
+
26
+ ```bash
27
+ $ bundle install
28
+ ```
29
+
30
+ Or install it yourself as:
31
+
32
+ ```bash
33
+ $ gem install bindan
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ Here is a basic example of how to use Bindan.
39
+
40
+ ### Dead Simple Use
41
+
42
+ Envvar built-in provider does not need extra code.
43
+
44
+ ```ruby
45
+ require "bindan"
46
+
47
+ config = Bindan.configure do |c, pr|
48
+ c.database_host = pr.env["DATABASE_HOST"] || "localhost"
49
+ end
50
+
51
+ # unless $DATABASE_HOST environment variable
52
+ puts config.database_host # => "localhost"
53
+ ```
54
+
55
+ ### with Multiple Providers
56
+
57
+ in Gemfile
58
+
59
+ ```ruby
60
+ gem "bindan"
61
+ gem "google-cloud-storage"
62
+ gem "google-cloud-firestore"
63
+ ```
64
+
65
+ and
66
+
67
+ ```ruby
68
+ require "bindan"
69
+
70
+ # Define your configuration providers
71
+ providers = {
72
+ env: Bindan::Provider::Envvar.new,
73
+ storage: Bindan::Provider::Storage.new(bucket: "my-config-bucket"),
74
+ firestore: Bindan::Provider::Firestore.new(collection: "app-settings")
75
+ }
76
+
77
+ # Configure Bindan
78
+ config = Bindan.configure(providers: providers) do |c, pr|
79
+ # Define configuration keys and how they are loaded from providers and fallback
80
+ c.database_host = pr.env["DATABASE_HOST"] || "localhost"
81
+ c.api_key = pr.storage["api_key"]
82
+ c.feature_flags = pr.firestore["feature_flags_document"]
83
+ end
84
+
85
+ # Access your configuration values
86
+ puts config.database_host # => "localhost" (or value from ENV["DATABASE_HOST"])
87
+ puts config.api_key # => (Value loaded from Google Cloud Storage)
88
+ puts config.feature_flags # => (Value loaded from Firestore)
89
+ ```
90
+
91
+ In the `configure` block, you define how each configuration key is mapped to a provider. The provider name is `providers` Hash key.
92
+
93
+ ## Development
94
+
95
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
96
+
97
+ ### Prerequisites for Development
98
+
99
+ This gem is designed to interacts with Google Cloud services as a first-class citizen. For local development and testing, it uses emulators. Please install the following tools:
100
+
101
+ 1. **Google Cloud CLI:** [Installation the gcloud CLI](https://cloud.google.com/sdk/docs/install)
102
+ 2. **Docker:** [Get Docker](https://docs.docker.com/get-docker/)
103
+ 3. **Firestore Emulator:**
104
+ ```bash
105
+ gcloud components install cloud-firestore-emulator
106
+ ```
107
+ 4. **fake-gcs-server:**
108
+ ```bash
109
+ docker pull fsouza/fake-gcs-server
110
+ ```
111
+
112
+ ### Custom Provider
113
+
114
+ implement `[]` method.
115
+
116
+ ```ruby
117
+ class CustomProvider
118
+ def [](key)
119
+ ...
120
+ end
121
+ end
122
+ ```
123
+
124
+ ### Running Tests
125
+
126
+ The test suite is configured to automatically start the emulators (including `fake-gcs-server` via Docker), run the tests against them, and shut them down. Simply run:
127
+
128
+ ```bash
129
+ bundle exec rake spec
130
+ ```
131
+
132
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
133
+
134
+ ## Contributing
135
+
136
+ Bug reports and pull requests are welcome on GitHub at https://github.com/wtnabe/bindan.
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create(:spec) do |t|
7
+ t.libs << "spec"
8
+ t.test_globs = "spec/**/*_spec.rb"
9
+ t.warning = false
10
+ end
11
+
12
+ require "standard/rake"
13
+
14
+ task default: %i[spec standard]
data/Steepfile ADDED
@@ -0,0 +1,17 @@
1
+ # D = Steep::Diagnostic
2
+ #
3
+ target :lib do
4
+ signature "sig"
5
+
6
+ check "lib" # Directory name
7
+ end
8
+
9
+ # target :test do
10
+ # unreferenced! # Skip type checking the `lib` code when types in `test` target is changed
11
+ # signature "sig/test" # Put RBS files for tests under `sig/test`
12
+ # check "test" # Type check Ruby scripts under `test`
13
+ #
14
+ # configure_code_diagnostics(D::Ruby.lenient) # Weak type checking for test code
15
+ #
16
+ # # library "pathname" # Standard libraries
17
+ # end
data/compose.yml ADDED
@@ -0,0 +1,12 @@
1
+ services:
2
+ storage:
3
+ image: fsouza/fake-gcs-server:latest
4
+ command: -scheme http -port 4443 -host 0.0.0.0
5
+ ports:
6
+ - "4443:4443"
7
+ healthcheck:
8
+ # The fake-gcs-server is ready when we can query the bucket list endpoint.
9
+ test: ["CMD", "curl", "-f", "http://localhost:4443/storage/v1/b"]
10
+ interval: 1s
11
+ timeout: 5s
12
+ retries: 20
@@ -0,0 +1,152 @@
1
+ require_relative "../error"
2
+ require "fileutils"
3
+ require "open3"
4
+ require "socket"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ module Bindan
9
+ module Emulator
10
+ #
11
+ # Represents and controls a single instance of the Google Cloud Firestore emulator process.
12
+ # This class encapsulates the logic for starting, stopping, and waiting for the emulator,
13
+ # separating process management from the test execution flow.
14
+ #
15
+ class FirestoreController
16
+ WAIT_TIMEOUT = 15 # seconds
17
+ INITIAL_BACKOFF_KEY_SEC = 0.1 # seconds
18
+ BACKOFF_MULTIPLIER = 1.5
19
+
20
+ class << self
21
+ #
22
+ # @return [Array<String, Integer>]
23
+ #
24
+ def host_and_port(host = nil, port = nil)
25
+ host, port = ENV["FIRESTORE_EMULATOR_HOST"].to_s.split(":") if ENV["FIRESTORE_EMULATOR_HOST"]
26
+
27
+ host ||= ENV["CI"] ? "0.0.0.0" : "localhost"
28
+ port ||= 8080
29
+
30
+ [host, port]
31
+ end
32
+
33
+ #
34
+ # Initiates the emulator process and returns a new instance.
35
+ #
36
+ # @param [String] import
37
+ # @param [String] export
38
+ # @param [String] host
39
+ # @param [Integer] port
40
+ # @param [bool] close_io
41
+ # @raise [Errno]
42
+ # @return [FirestoreEmulatorController] An instance to manage the emulator lifecycle.
43
+ #
44
+ def start(import: nil, export: nil, host: nil, port: nil, close_io: true)
45
+ host, port = host_and_port(host, port)
46
+
47
+ puts " Starting Firestore emulator ..." unless close_io
48
+ kill_process_if_already_exists(host: host, port: port, with_message: !close_io)
49
+
50
+ #
51
+ # This command is known to launch a background Java process and then exit.
52
+ # Trying keep process info with process group
53
+ #
54
+ cmd = "gcloud emulators firestore start --host-port=#{host}:#{port}"
55
+ cmd += " --import-data=#{import}" if import
56
+ cmd += " --export-on-exit=#{export}" if export
57
+ opts = close_io ? {out: "/dev/null", err: "/dev/null"} : {}
58
+ pid = Process.spawn(*cmd, pgroup: true, **opts) # steep:ignore
59
+
60
+ puts " Emulator process initiated." unless close_io
61
+ new(pid, host, port)
62
+ end
63
+
64
+ #
65
+ # kill process group
66
+ #
67
+ # @param [Integer] pid
68
+ # @param [bool] with_message
69
+ # @return [void]
70
+ #
71
+ def stop(pid, with_message: true)
72
+ puts "\n=> Stopping Firestore emulator..." if with_message
73
+
74
+ begin
75
+ Process.kill("TERM", -pid)
76
+ Process.wait(-pid)
77
+ rescue Errno::ESRCH, Errno::ECHILD
78
+ # Process was already killed or doesn't exist, which is fine.
79
+ end
80
+
81
+ puts " Emulator process terminated." if with_message
82
+ end
83
+
84
+ #
85
+ # @param [Integer] host
86
+ # @param [Integer] port
87
+ #
88
+ def kill_process_if_already_exists(host:, port:, with_message: true)
89
+ TCPSocket.new(host, port).close
90
+
91
+ # `lsof -Fg -i:PORT` returns PIDs of the process listening on the port.
92
+ process_ids, = Open3.capture3(*"lsof -Fg -g -s TCP:LISTEN -i :#{port}".split(" "))
93
+ process_group = process_ids.lines.map(&:chomp).find { |id| id =~ /^g([0-9]+)/ }
94
+
95
+ if process_group
96
+ id = process_group[1..].to_i # strip first letter
97
+ puts " Found emulator process group #{id} on port #{port}. Terminating." if with_message
98
+ stop(id, with_message: false)
99
+ end
100
+ rescue Errno::ECONNREFUSED
101
+ # noop
102
+ end
103
+ end # of class methods
104
+
105
+ #
106
+ # @param [Integer] pid - process group id
107
+ #
108
+ def initialize(pid, host, port)
109
+ @pid = pid
110
+ @host = host
111
+ @port = port
112
+ end
113
+
114
+ #
115
+ # waits to accessable
116
+ #
117
+ # @raise [Timeout::Error] if the emulator does not become available within the configured timeout.
118
+ # @param [number] timeout
119
+ # @param [number] backoff
120
+ # @param [number] multiplier
121
+ # @return [void]
122
+ #
123
+ def wait_available(timeout: WAIT_TIMEOUT, backoff: INITIAL_BACKOFF_KEY_SEC, multiplier: BACKOFF_MULTIPLIER, with_message: true)
124
+ last_rescue_error = nil
125
+
126
+ puts " Waiting for emulator to be ready on #{@host}:#{@port}..." if with_message
127
+ count = 1
128
+ Timeout.timeout(timeout) do
129
+ loop do
130
+ TCPSocket.new(@host, @port).close
131
+ puts " Emulator is up and listening." if with_message
132
+ return
133
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL => e
134
+ last_rescue_error = e
135
+ sleep backoff
136
+ count += 1
137
+ backoff *= multiplier**count
138
+ end
139
+ end
140
+ rescue Timeout::Error
141
+ raise last_rescue_error # steep:ignore
142
+ end
143
+
144
+ #
145
+ # @return [void]
146
+ #
147
+ def stop(with_message: true)
148
+ self.class.stop(@pid, with_message: with_message)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,119 @@
1
+ require_relative "../error"
2
+ require "fileutils"
3
+ require "timeout"
4
+ require "open3"
5
+
6
+ module Bindan
7
+ module Emulator
8
+ class GcsServerController
9
+ class ContainerCannotOpenError < Error; end
10
+
11
+ CONTAINER_NAME = "gcs-server"
12
+ WAIT_TIMEOUT = 15 # seconds
13
+ INITIAL_BACKOFF_KEY_SEC = 0.1 # seconds
14
+ BACKOFF_MULTIPLIER = 1.5
15
+
16
+ class << self
17
+ #
18
+ # @raise [Errno]
19
+ # @param [String] folder
20
+ # @param [Integer] [port]
21
+ # @param [String] [name]
22
+ # @return [GcsServerController]
23
+ #
24
+ def start(folder:, port: 4443, name: CONTAINER_NAME, close_io: true)
25
+ FileUtils.mkdir_p(folder)
26
+ unless running?(name: name)
27
+ opts = close_io ? {out: "/dev/null", err: "/dev/null"} : {}
28
+ Process.spawn( # steep:ignore
29
+ *"docker run --rm --name #{name} -p #{port}:4443 -v #{folder}:/data fsouza/fake-gcs-server -scheme http".split(" "),
30
+ **opts
31
+ )
32
+ end
33
+
34
+ new(name: name, folder: folder)
35
+ end
36
+
37
+ #
38
+ # @raise [IOError]
39
+ # @param [String] name
40
+ # @return [bool]
41
+ #
42
+ def running?(name:)
43
+ _stdin, stdout, stderr, = Open3.popen3("docker container list -f name=#{name}")
44
+
45
+ begin
46
+ # container list not only headers
47
+ stdout.readlines.size > 1
48
+ rescue IOError => e
49
+ warn "in #{self}"
50
+ warn " " + stderr.read
51
+ raise ContainerCannotOpenError.new e
52
+ end
53
+ end
54
+
55
+ #
56
+ # @param [String] name
57
+ # @raise [Errno]
58
+ # @return [void]
59
+ #
60
+ def stop(name:)
61
+ `docker container stop #{name}`
62
+ end
63
+ end # of class methods
64
+
65
+ #
66
+ # @param [String] name
67
+ # @param [String] folder
68
+ #
69
+ def initialize(name:, folder:)
70
+ @container_name = name
71
+ @folder = folder
72
+ end
73
+
74
+ #
75
+ # @return
76
+ #
77
+ def stop
78
+ self.class.stop(name: @container_name)
79
+ end
80
+
81
+ #
82
+ # @param [String] name
83
+ # @return [bool]
84
+ #
85
+ def running?
86
+ self.class.running?(name: @container_name)
87
+ end
88
+
89
+ #
90
+ # waits container to be running
91
+ #
92
+ # @raise [ContainerCannotOpenError] if the container does not become available within the configured timeout.
93
+ # @param [number] timeout
94
+ # @param [number] backoff
95
+ # @param [number] multiplier
96
+ # @return [void]
97
+ #
98
+ def wait_available(timeout: WAIT_TIMEOUT, backoff: INITIAL_BACKOFF_KEY_SEC, multiplier: BACKOFF_MULTIPLIER)
99
+ last_rescued_error = nil
100
+
101
+ count = 1
102
+ Timeout.timeout(timeout) do
103
+ loop do
104
+ if running?
105
+ return
106
+ end
107
+ rescue ContainerCannotOpenError => e
108
+ last_rescued_error = e
109
+ sleep backoff
110
+ count += 1
111
+ backoff *= multiplier**count
112
+ end
113
+ end
114
+ rescue Timeout::Error
115
+ raise last_rescued_error # steep:ignore
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,3 @@
1
+ module Bindan
2
+ class Error < StandardError; end
3
+ end
@@ -0,0 +1,27 @@
1
+ module Bindan
2
+ module Provider
3
+ class Envvar
4
+ #
5
+ # @param [Hash] env
6
+ # @param [Hash] options
7
+ #
8
+ def initialize(env = ENV.to_h, options = {})
9
+ @_env = env
10
+ @_options = options
11
+ end
12
+
13
+ #
14
+ # @raise KeyError
15
+ # @param [String] key
16
+ # @return [String]
17
+ #
18
+ def [](key)
19
+ if @_options[:raise_error]
20
+ @_env.fetch(key)
21
+ else
22
+ @_env[key]
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,87 @@
1
+ require_relative "../error"
2
+ begin
3
+ require "google/cloud/firestore"
4
+ rescue LoadError => e
5
+ warn e
6
+ module Google
7
+ module Cloud
8
+ class Firestore
9
+ def initialize(**kwargs)
10
+ end
11
+
12
+ def doc(*args)
13
+ Class.new do
14
+ def create(*args)
15
+ end
16
+
17
+ def get
18
+ Class.new do
19
+ def data
20
+ end
21
+ end.new
22
+ end
23
+ end.new
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ module Bindan
31
+ module Provider
32
+ class Firestore
33
+ class ColOrDocNotExist < Error; end
34
+
35
+ #
36
+ # @param [String] collection
37
+ # @param [bool] raise_error
38
+ # @param [Google::Cloud::Firestore] sdk
39
+ #
40
+ def initialize(collection = nil, project_id: nil, raise_error: false, sdk: ::Google::Cloud::Firestore, **kwargs)
41
+ if ENV["FIRESTORE_EMULATOR_HOST"]
42
+ project_id ||= "project"
43
+ end
44
+
45
+ @_options = {}
46
+ @collection = collection
47
+
48
+ @_options[:raise_error] = raise_error
49
+ @project_id = project_id
50
+
51
+ @_firestore = sdk.new(project_id: project_id, **kwargs)
52
+ end
53
+ attr_reader :project_id
54
+
55
+ #
56
+ # @param [Hash] pair
57
+ #
58
+ def _prepare(pair)
59
+ path, doc = pair.to_a.flatten
60
+
61
+ @_firestore.doc(path).create(doc)
62
+ end
63
+
64
+ #
65
+ # @raise ColOrDocNotExist
66
+ # @param [String] key
67
+ # @return [Hash]
68
+ #
69
+ def [](key)
70
+ doc_path =
71
+ if @collection.nil? && key.include?("/")
72
+ key
73
+ else
74
+ [@collection, key].join("/")
75
+ end
76
+
77
+ doc = @_firestore.doc(doc_path).get
78
+
79
+ if @_options[:raise_error] && !doc.data
80
+ raise ColOrDocNotExist.new doc_path
81
+ else
82
+ doc.data
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end