quonfig 0.0.9 → 0.0.10
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 +4 -4
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +1 -1
- data/README.md +4 -4
- data/VERSION +1 -1
- data/lib/quonfig/evaluation_details.rb +60 -0
- data/lib/quonfig/options.rb +37 -16
- data/quonfig.gemspec +5 -3
- data/test/test_details_getters.rb +242 -0
- data/test/test_http_connection.rb +6 -4
- data/test/test_options.rb +53 -22
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 94866b65b3e3c4e834897981847da01f350984b2f7126f8259318ba385b8fd77
|
|
4
|
+
data.tar.gz: bc173a4300c1475f596921de2a2d24e02e5de648503318491a602c19123dd329
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 875265c218e5a465abced6d47563ef7c86ade61ca2d98367059bc800dec55e1148badf58e60b632e468ab565ef5ec80e587b8f0fd045f5300b000f64fe12b909
|
|
7
|
+
data.tar.gz: 6725f7b13ac6ba43d0e7903963c5de4b1bbe03f1ae7e51399e4d0cc8877f6ee637ef2850e396e65c8d5c44cac2ee7cfa0b2f1724d390d43746d421d7e465c42a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.10 - 2026-05-01
|
|
4
|
+
|
|
5
|
+
- **BREAKING (env): `QUONFIG_TELEMETRY_URL` and `QUONFIG_API_URLS` env vars
|
|
6
|
+
removed.** Replaced by a single `QUONFIG_DOMAIN` env var (default
|
|
7
|
+
`quonfig.com`) that derives api, sse, and telemetry URLs uniformly. e.g.
|
|
8
|
+
`QUONFIG_DOMAIN=quonfig-staging.com` → `https://primary.quonfig-staging.com`,
|
|
9
|
+
`https://stream.primary.quonfig-staging.com`,
|
|
10
|
+
`https://telemetry.quonfig-staging.com`. Mirrors the CLI's
|
|
11
|
+
`domain-urls.ts` convention and matches sdk-go / sdk-node. Resolution order
|
|
12
|
+
(highest wins): explicit `api_urls:` / `telemetry_url:` kwargs >
|
|
13
|
+
`QUONFIG_DOMAIN` > hardcoded default. Fixes qfg-w6gg, where the prior
|
|
14
|
+
primary-prefix regex silently fell through to prod telemetry on staging
|
|
15
|
+
hosts. The new `Quonfig::Options#init` also accepts an explicit
|
|
16
|
+
`telemetry_url:` kwarg (was previously documented but not wired up).
|
|
17
|
+
- **Default `api_urls` now includes secondary.** Was `[primary]`, now
|
|
18
|
+
`[primary, secondary]` to match every other SDK and provide failover.
|
|
19
|
+
|
|
3
20
|
## 0.0.8 - 2026-04-26
|
|
4
21
|
|
|
5
22
|
- **Fix (gemspec): drop deleted `scripts/` entry from manifest** — regenerated
|
data/CLAUDE.md
CHANGED
|
@@ -26,4 +26,4 @@ integration suite cannot resolve its YAML specs.
|
|
|
26
26
|
- `QUONFIG_BACKEND_SDK_KEY` — backend SDK key for authenticated config delivery
|
|
27
27
|
- `QUONFIG_DIR` — path to a local Quonfig workspace (datadir mode)
|
|
28
28
|
- `QUONFIG_ENVIRONMENT` — which environment to evaluate (`production`, `staging`, `development`)
|
|
29
|
-
- `
|
|
29
|
+
- `QUONFIG_DOMAIN` — base domain used to derive api/sse/telemetry URLs (default `quonfig.com`). Setting `QUONFIG_DOMAIN=quonfig-staging.com` derives `https://primary.quonfig-staging.com`, `https://stream.primary.quonfig-staging.com`, and `https://telemetry.quonfig-staging.com` automatically. Explicit `api_urls:` / `telemetry_url:` kwargs override this.
|
data/README.md
CHANGED
|
@@ -114,14 +114,14 @@ client = Quonfig::Client.new # reads QUONFIG_DIR + QUONFIG_ENVIRONMENT
|
|
|
114
114
|
| `QUONFIG_BACKEND_SDK_KEY` | SDK key used to authenticate against the Quonfig API. Used when `sdk_key:` is omitted. |
|
|
115
115
|
| `QUONFIG_DIR` | Path to a workspace directory. When set, the SDK runs in datadir/offline mode. |
|
|
116
116
|
| `QUONFIG_ENVIRONMENT` | Environment name (`production`, `staging`, `development`) evaluated in datadir mode. |
|
|
117
|
-
| `
|
|
117
|
+
| `QUONFIG_DOMAIN` | Base domain used to derive api, sse, and telemetry URLs. Defaults to `quonfig.com`. Set to `quonfig-staging.com` to point at staging. Explicit `api_urls:` / `telemetry_url:` kwargs override this. |
|
|
118
118
|
|
|
119
119
|
## Constructor options
|
|
120
120
|
|
|
121
121
|
```ruby
|
|
122
122
|
Quonfig::Client.new(
|
|
123
123
|
sdk_key: '...', # required unless QUONFIG_BACKEND_SDK_KEY is set
|
|
124
|
-
api_urls: ['https://primary.quonfig.com'],
|
|
124
|
+
api_urls: ['https://primary.quonfig.com', 'https://secondary.quonfig.com'],
|
|
125
125
|
telemetry_url: 'https://telemetry.quonfig.com',
|
|
126
126
|
enable_sse: true,
|
|
127
127
|
enable_polling: false,
|
|
@@ -137,8 +137,8 @@ Quonfig::Client.new(
|
|
|
137
137
|
| Option | Type | Default | Description |
|
|
138
138
|
|-------------------|----------------------------|---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
|
|
139
139
|
| `sdk_key` | `String` | `ENV['QUONFIG_BACKEND_SDK_KEY']` | SDK key for API authentication. |
|
|
140
|
-
| `api_urls` | `Array<String>` | `[
|
|
141
|
-
| `telemetry_url` | `String` | `https://telemetry
|
|
140
|
+
| `api_urls` | `Array<String>` | `["https://primary.${QUONFIG_DOMAIN}", "https://secondary.${QUONFIG_DOMAIN}"]` | Ordered list of API base URLs to try. SSE stream URLs are derived by prepending `stream.` to each hostname. Defaults derive from `QUONFIG_DOMAIN` (default `quonfig.com`). |
|
|
141
|
+
| `telemetry_url` | `String` | `https://telemetry.${QUONFIG_DOMAIN}` | Base URL for the telemetry service. Default derives from `QUONFIG_DOMAIN`. |
|
|
142
142
|
| `enable_sse` | `Boolean` | `true` | Receive real-time updates over Server-Sent Events. |
|
|
143
143
|
| `enable_polling` | `Boolean` | `false` | Poll the API on an interval as a fallback. |
|
|
144
144
|
| `poll_interval` | `Integer` (seconds) | `60` | Polling interval when `enable_polling` is `true`. |
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.0.
|
|
1
|
+
0.0.10
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# Public details record returned by Quonfig::Client#get_*_details. Surfaces
|
|
5
|
+
# the resolution reason and (on error) an error_code/error_message alongside
|
|
6
|
+
# the resolved value, so downstream layers — most importantly the
|
|
7
|
+
# OpenFeature provider — can map the result without losing fidelity.
|
|
8
|
+
#
|
|
9
|
+
# +reason+ is one of the strings:
|
|
10
|
+
# "STATIC" — config has no targeting rules; matched value is the static default
|
|
11
|
+
# "TARGETING_MATCH" — a targeting rule matched (any non-ALWAYS_TRUE criterion)
|
|
12
|
+
# "SPLIT" — matched value came from a weighted variant
|
|
13
|
+
# "DEFAULT" — no rule matched (eval fell through)
|
|
14
|
+
# "ERROR" — evaluation failed
|
|
15
|
+
#
|
|
16
|
+
# +error_code+ (only when reason == "ERROR") is one of:
|
|
17
|
+
# "FLAG_NOT_FOUND" — the key was unknown to the store
|
|
18
|
+
# "TYPE_MISMATCH" — the resolved value didn't satisfy the requested type
|
|
19
|
+
# "GENERAL" — any other failure
|
|
20
|
+
class EvaluationDetails
|
|
21
|
+
REASON_STATIC = 'STATIC'
|
|
22
|
+
REASON_TARGETING_MATCH = 'TARGETING_MATCH'
|
|
23
|
+
REASON_SPLIT = 'SPLIT'
|
|
24
|
+
REASON_DEFAULT = 'DEFAULT'
|
|
25
|
+
REASON_ERROR = 'ERROR'
|
|
26
|
+
|
|
27
|
+
ERROR_FLAG_NOT_FOUND = 'FLAG_NOT_FOUND'
|
|
28
|
+
ERROR_TYPE_MISMATCH = 'TYPE_MISMATCH'
|
|
29
|
+
ERROR_GENERAL = 'GENERAL'
|
|
30
|
+
|
|
31
|
+
attr_reader :value, :reason, :error_code, :error_message
|
|
32
|
+
|
|
33
|
+
def initialize(value:, reason:, error_code: nil, error_message: nil)
|
|
34
|
+
@value = value
|
|
35
|
+
@reason = reason
|
|
36
|
+
@error_code = error_code
|
|
37
|
+
@error_message = error_message
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ==(other)
|
|
41
|
+
other.is_a?(EvaluationDetails) &&
|
|
42
|
+
other.value == @value &&
|
|
43
|
+
other.reason == @reason &&
|
|
44
|
+
other.error_code == @error_code &&
|
|
45
|
+
other.error_message == @error_message
|
|
46
|
+
end
|
|
47
|
+
alias eql? ==
|
|
48
|
+
|
|
49
|
+
def hash
|
|
50
|
+
[@value, @reason, @error_code, @error_message].hash
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def inspect
|
|
54
|
+
parts = ["value=#{@value.inspect}", "reason=#{@reason.inspect}"]
|
|
55
|
+
parts << "error_code=#{@error_code.inspect}" if @error_code
|
|
56
|
+
parts << "error_message=#{@error_message.inspect}" if @error_message
|
|
57
|
+
"#<Quonfig::EvaluationDetails #{parts.join(' ')}>"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/quonfig/options.rb
CHANGED
|
@@ -39,10 +39,38 @@ module Quonfig
|
|
|
39
39
|
DEFAULT_MAX_EXAMPLE_CONTEXTS = 100_000
|
|
40
40
|
DEFAULT_MAX_EVAL_SUMMARIES = 100_000
|
|
41
41
|
|
|
42
|
+
# Hardcoded fallback domain. Overridden by ENV['QUONFIG_DOMAIN'].
|
|
43
|
+
DEFAULT_DOMAIN = 'quonfig.com'
|
|
44
|
+
|
|
45
|
+
# Hardcoded fallback API URLs (used only when no QUONFIG_DOMAIN is set
|
|
46
|
+
# and no explicit api_urls are provided). Mirrors derive_api_urls(DEFAULT_DOMAIN).
|
|
42
47
|
DEFAULT_API_URLS = [
|
|
43
48
|
'https://primary.quonfig.com',
|
|
49
|
+
'https://secondary.quonfig.com',
|
|
44
50
|
].freeze
|
|
45
51
|
|
|
52
|
+
# Resolve the active domain. Reads QUONFIG_DOMAIN; falls back to
|
|
53
|
+
# DEFAULT_DOMAIN. Mirrors `cli/src/util/domain-urls.ts#getDomain`.
|
|
54
|
+
def self.domain
|
|
55
|
+
env = ENV['QUONFIG_DOMAIN']
|
|
56
|
+
env && !env.empty? ? env : DEFAULT_DOMAIN
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Derive default api_urls for a given domain. e.g. for domain
|
|
60
|
+
# `quonfig-staging.com` returns
|
|
61
|
+
# `["https://primary.quonfig-staging.com", "https://secondary.quonfig-staging.com"]`.
|
|
62
|
+
def self.derive_api_urls(domain)
|
|
63
|
+
[
|
|
64
|
+
"https://primary.#{domain}",
|
|
65
|
+
"https://secondary.#{domain}",
|
|
66
|
+
]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Derive the telemetry URL for a given domain.
|
|
70
|
+
def self.derive_telemetry_url(domain)
|
|
71
|
+
"https://telemetry.#{domain}"
|
|
72
|
+
end
|
|
73
|
+
|
|
46
74
|
# Derive the SSE stream URL for a given API URL by prepending `stream.` to
|
|
47
75
|
# the hostname. Preserves scheme, port, and path.
|
|
48
76
|
#
|
|
@@ -58,6 +86,7 @@ module Quonfig
|
|
|
58
86
|
|
|
59
87
|
private def init(
|
|
60
88
|
api_urls: nil,
|
|
89
|
+
telemetry_url: nil,
|
|
61
90
|
sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'],
|
|
62
91
|
environment: ENV['QUONFIG_ENVIRONMENT'],
|
|
63
92
|
datadir: ENV['QUONFIG_DIR'],
|
|
@@ -104,16 +133,19 @@ module Quonfig
|
|
|
104
133
|
@collect_example_contexts = false
|
|
105
134
|
@collect_max_example_contexts = 0
|
|
106
135
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
136
|
+
# URL resolution order (highest wins):
|
|
137
|
+
# 1. Explicit kwargs (api_urls:, telemetry_url:)
|
|
138
|
+
# 2. ENV['QUONFIG_DOMAIN'] -> derives all three
|
|
139
|
+
# 3. Hardcoded DEFAULT_DOMAIN ('quonfig.com')
|
|
140
|
+
domain = Quonfig::Options.domain
|
|
110
141
|
|
|
111
|
-
@api_urls = Array(api_urls ||
|
|
142
|
+
@api_urls = Array(api_urls || Quonfig::Options.derive_api_urls(domain))
|
|
143
|
+
.map { |url| remove_trailing_slash(url) }
|
|
112
144
|
|
|
113
145
|
@sse_api_urls = @api_urls.map { |url| Quonfig::Options.derive_stream_url(url) }
|
|
114
146
|
@config_api_urls = @api_urls
|
|
115
147
|
|
|
116
|
-
@telemetry_destination =
|
|
148
|
+
@telemetry_destination = telemetry_url || Quonfig::Options.derive_telemetry_url(domain)
|
|
117
149
|
|
|
118
150
|
case context_upload_mode
|
|
119
151
|
when :none
|
|
@@ -188,16 +220,5 @@ module Quonfig
|
|
|
188
220
|
def remove_trailing_slash(url)
|
|
189
221
|
url.end_with?('/') ? url[0..-2] : url
|
|
190
222
|
end
|
|
191
|
-
|
|
192
|
-
# Derive a telemetry URL from the configured api_urls by swapping the
|
|
193
|
-
# primary/secondary host prefix for `telemetry` on a *.quonfig.com host.
|
|
194
|
-
# Falls back to https://telemetry.quonfig.com if no URL matches.
|
|
195
|
-
def derive_telemetry_destination(api_urls)
|
|
196
|
-
api_urls.each do |api_url|
|
|
197
|
-
match = api_url.match(%r{\Ahttps?://(?:primary|secondary)\.([^/]*quonfig\.com)}i)
|
|
198
|
-
return "https://telemetry.#{match[1]}" if match
|
|
199
|
-
end
|
|
200
|
-
'https://telemetry.quonfig.com'
|
|
201
|
-
end
|
|
202
223
|
end
|
|
203
224
|
end
|
data/quonfig.gemspec
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
# DO NOT EDIT THIS FILE DIRECTLY
|
|
3
3
|
# Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec'
|
|
4
4
|
# -*- encoding: utf-8 -*-
|
|
5
|
-
# stub: quonfig 0.0.
|
|
5
|
+
# stub: quonfig 0.0.10 ruby lib
|
|
6
6
|
|
|
7
7
|
Gem::Specification.new do |s|
|
|
8
8
|
s.name = "quonfig".freeze
|
|
9
|
-
s.version = "0.0.
|
|
9
|
+
s.version = "0.0.10".freeze
|
|
10
10
|
|
|
11
11
|
s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
|
|
12
12
|
s.require_paths = ["lib".freeze]
|
|
13
13
|
s.authors = ["Jeff Dwyer".freeze]
|
|
14
|
-
s.date = "2026-
|
|
14
|
+
s.date = "2026-05-01"
|
|
15
15
|
s.description = "Quonfig \u2014 feature flags and live config, stored as files in git.".freeze
|
|
16
16
|
s.email = "jeff@quonfig.com".freeze
|
|
17
17
|
s.extra_rdoc_files = [
|
|
@@ -69,6 +69,7 @@ Gem::Specification.new do |s|
|
|
|
69
69
|
"lib/quonfig/errors/type_mismatch_error.rb",
|
|
70
70
|
"lib/quonfig/errors/uninitialized_error.rb",
|
|
71
71
|
"lib/quonfig/evaluation.rb",
|
|
72
|
+
"lib/quonfig/evaluation_details.rb",
|
|
72
73
|
"lib/quonfig/evaluator.rb",
|
|
73
74
|
"lib/quonfig/exponential_backoff.rb",
|
|
74
75
|
"lib/quonfig/fixed_size_hash.rb",
|
|
@@ -121,6 +122,7 @@ Gem::Specification.new do |s|
|
|
|
121
122
|
"test/test_context_shape.rb",
|
|
122
123
|
"test/test_context_shape_aggregator.rb",
|
|
123
124
|
"test/test_datadir.rb",
|
|
125
|
+
"test/test_details_getters.rb",
|
|
124
126
|
"test/test_dev_context.rb",
|
|
125
127
|
"test/test_duration.rb",
|
|
126
128
|
"test/test_encryption.rb",
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
# Coverage for the *_details getters on Quonfig::Client and Quonfig::BoundClient.
|
|
6
|
+
# These return Quonfig::EvaluationDetails carrying an OpenFeature-aligned
|
|
7
|
+
# +reason+ and (on the error path) error_code / error_message. They never raise.
|
|
8
|
+
class TestDetailsGetters < Minitest::Test
|
|
9
|
+
Details = Quonfig::EvaluationDetails
|
|
10
|
+
|
|
11
|
+
INTEGRATION_FIXTURE_DIR = File.expand_path(
|
|
12
|
+
'../../integration-test-data/data/integration-tests', __dir__
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# ---- Helpers --------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
def fixture_client
|
|
18
|
+
skip "integration-test-data sibling missing at #{INTEGRATION_FIXTURE_DIR}" unless Dir.exist?(INTEGRATION_FIXTURE_DIR)
|
|
19
|
+
|
|
20
|
+
Quonfig::Client.new(
|
|
21
|
+
Quonfig::Options.new(
|
|
22
|
+
datadir: INTEGRATION_FIXTURE_DIR,
|
|
23
|
+
environment: 'Production',
|
|
24
|
+
enable_sse: false,
|
|
25
|
+
enable_polling: false
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# An ALWAYS_TRUE-only config: no targeting rules anywhere -> STATIC.
|
|
31
|
+
def make_static_config(key:, value:, type:)
|
|
32
|
+
{
|
|
33
|
+
'id' => '1',
|
|
34
|
+
'key' => key,
|
|
35
|
+
'type' => 'config',
|
|
36
|
+
'valueType' => type,
|
|
37
|
+
'sendToClientSdk' => false,
|
|
38
|
+
'default' => {
|
|
39
|
+
'rules' => [
|
|
40
|
+
{
|
|
41
|
+
'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
|
|
42
|
+
'value' => { 'type' => type, 'value' => value }
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
'environment' => nil
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def static_client(key:, value:, type:)
|
|
51
|
+
store = Quonfig::ConfigStore.new
|
|
52
|
+
store.set(key, make_static_config(key: key, value: value, type: type))
|
|
53
|
+
Quonfig::Client.new(Quonfig::Options.new, store: store)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# ---- STATIC reason --------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def test_get_bool_details_static_reason_for_always_true_only_config
|
|
59
|
+
client = static_client(key: 'plain', value: true, type: 'bool')
|
|
60
|
+
details = client.get_bool_details('plain')
|
|
61
|
+
assert_kind_of Details, details
|
|
62
|
+
assert_equal true, details.value
|
|
63
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
64
|
+
assert_nil details.error_code
|
|
65
|
+
assert_nil details.error_message
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def test_get_string_details_static_reason
|
|
69
|
+
client = static_client(key: 'plain.s', value: 'hi', type: 'string')
|
|
70
|
+
details = client.get_string_details('plain.s')
|
|
71
|
+
assert_equal 'hi', details.value
|
|
72
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_static_reason_via_integration_fixture_always_true
|
|
76
|
+
client = fixture_client
|
|
77
|
+
details = client.get_bool_details('always.true')
|
|
78
|
+
assert_equal true, details.value
|
|
79
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
80
|
+
ensure
|
|
81
|
+
client&.stop
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# ---- TARGETING_MATCH reason ----------------------------------------
|
|
85
|
+
|
|
86
|
+
def test_targeting_match_reason_via_integration_fixture
|
|
87
|
+
client = fixture_client
|
|
88
|
+
details = client.get_bool_details('of.targeting', context: { 'user' => { 'plan' => 'pro' } })
|
|
89
|
+
assert_equal true, details.value
|
|
90
|
+
assert_equal Details::REASON_TARGETING_MATCH, details.reason
|
|
91
|
+
ensure
|
|
92
|
+
client&.stop
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def test_targeting_match_falls_through_to_default_branch_still_targeting
|
|
96
|
+
# The of.targeting fixture has a property-match rule + an ALWAYS_TRUE
|
|
97
|
+
# fallback. Even when the fallback wins, the *config* has targeting rules,
|
|
98
|
+
# so wire_reason / of_reason returns TARGETING_MATCH.
|
|
99
|
+
client = fixture_client
|
|
100
|
+
details = client.get_bool_details('of.targeting', context: { 'user' => { 'plan' => 'free' } })
|
|
101
|
+
assert_equal false, details.value
|
|
102
|
+
assert_equal Details::REASON_TARGETING_MATCH, details.reason
|
|
103
|
+
ensure
|
|
104
|
+
client&.stop
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# ---- SPLIT reason ---------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def test_split_reason_via_integration_fixture
|
|
110
|
+
client = fixture_client
|
|
111
|
+
# Pick a deterministic targetingKey so the outcome is reproducible.
|
|
112
|
+
details = client.get_string_details('of.weighted', context: { 'user' => { 'id' => 'user-123' } })
|
|
113
|
+
assert_equal Details::REASON_SPLIT, details.reason
|
|
114
|
+
assert_includes %w[variant-a variant-b], details.value
|
|
115
|
+
ensure
|
|
116
|
+
client&.stop
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ---- DEFAULT reason -------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def test_default_reason_when_no_rule_matches
|
|
122
|
+
# Config exists but no rule matches against the empty context.
|
|
123
|
+
config = {
|
|
124
|
+
'id' => '9',
|
|
125
|
+
'key' => 'no.match.here',
|
|
126
|
+
'type' => 'config',
|
|
127
|
+
'valueType' => 'string',
|
|
128
|
+
'sendToClientSdk' => false,
|
|
129
|
+
'default' => {
|
|
130
|
+
'rules' => [
|
|
131
|
+
{
|
|
132
|
+
'criteria' => [
|
|
133
|
+
{
|
|
134
|
+
'propertyName' => 'user.plan',
|
|
135
|
+
'operator' => 'PROP_IS_ONE_OF',
|
|
136
|
+
'valueToMatch' => { 'type' => 'string_list', 'value' => ['enterprise'] }
|
|
137
|
+
}
|
|
138
|
+
],
|
|
139
|
+
'value' => { 'type' => 'string', 'value' => 'gold' }
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
},
|
|
143
|
+
'environment' => nil
|
|
144
|
+
}
|
|
145
|
+
store = Quonfig::ConfigStore.new
|
|
146
|
+
store.set('no.match.here', config)
|
|
147
|
+
client = Quonfig::Client.new(Quonfig::Options.new, store: store)
|
|
148
|
+
|
|
149
|
+
details = client.get_string_details('no.match.here')
|
|
150
|
+
assert_nil details.value
|
|
151
|
+
assert_equal Details::REASON_DEFAULT, details.reason
|
|
152
|
+
assert_nil details.error_code
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# ---- ERROR / FLAG_NOT_FOUND ----------------------------------------
|
|
156
|
+
|
|
157
|
+
def test_flag_not_found_returns_error_details
|
|
158
|
+
client = Quonfig::Client.new(Quonfig::Options.new, store: Quonfig::ConfigStore.new)
|
|
159
|
+
details = client.get_bool_details('does.not.exist')
|
|
160
|
+
assert_nil details.value
|
|
161
|
+
assert_equal Details::REASON_ERROR, details.reason
|
|
162
|
+
assert_equal Details::ERROR_FLAG_NOT_FOUND, details.error_code
|
|
163
|
+
refute_nil details.error_message
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def test_flag_not_found_for_string_details
|
|
167
|
+
client = Quonfig::Client.new(Quonfig::Options.new, store: Quonfig::ConfigStore.new)
|
|
168
|
+
details = client.get_string_details('nope')
|
|
169
|
+
assert_equal Details::REASON_ERROR, details.reason
|
|
170
|
+
assert_equal Details::ERROR_FLAG_NOT_FOUND, details.error_code
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ---- ERROR / TYPE_MISMATCH -----------------------------------------
|
|
174
|
+
|
|
175
|
+
def test_type_mismatch_for_int_when_value_is_string
|
|
176
|
+
client = static_client(key: 'wrong.type', value: 'oops', type: 'string')
|
|
177
|
+
details = client.get_int_details('wrong.type')
|
|
178
|
+
assert_nil details.value
|
|
179
|
+
assert_equal Details::REASON_ERROR, details.reason
|
|
180
|
+
assert_equal Details::ERROR_TYPE_MISMATCH, details.error_code
|
|
181
|
+
refute_nil details.error_message
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def test_type_mismatch_for_bool_when_value_is_string
|
|
185
|
+
client = static_client(key: 'bool.miss', value: 'true', type: 'string')
|
|
186
|
+
details = client.get_bool_details('bool.miss')
|
|
187
|
+
assert_equal Details::REASON_ERROR, details.reason
|
|
188
|
+
assert_equal Details::ERROR_TYPE_MISMATCH, details.error_code
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def test_type_mismatch_for_string_list_when_value_is_string
|
|
192
|
+
client = static_client(key: 'list.miss', value: 'a,b', type: 'string')
|
|
193
|
+
details = client.get_string_list_details('list.miss')
|
|
194
|
+
assert_equal Details::REASON_ERROR, details.reason
|
|
195
|
+
assert_equal Details::ERROR_TYPE_MISMATCH, details.error_code
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# ---- json passes through unchanged ---------------------------------
|
|
199
|
+
|
|
200
|
+
def test_get_json_details_returns_hash_with_static_reason
|
|
201
|
+
payload = { 'a' => 1, 'b' => [1, 2] }
|
|
202
|
+
client = static_client(key: 'shape', value: payload, type: 'json')
|
|
203
|
+
details = client.get_json_details('shape')
|
|
204
|
+
assert_equal payload, details.value
|
|
205
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# ---- Float / Int success path -------------------------------------
|
|
209
|
+
|
|
210
|
+
def test_get_int_details_static_reason
|
|
211
|
+
client = static_client(key: 'i', value: 42, type: 'int')
|
|
212
|
+
details = client.get_int_details('i')
|
|
213
|
+
assert_equal 42, details.value
|
|
214
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def test_get_float_details_static_reason
|
|
218
|
+
client = static_client(key: 'f', value: 3.14, type: 'double')
|
|
219
|
+
details = client.get_float_details('f')
|
|
220
|
+
assert_in_delta 3.14, details.value, 1e-9
|
|
221
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def test_get_string_list_details_static_reason
|
|
225
|
+
client = static_client(key: 'sl', value: %w[a b], type: 'string_list')
|
|
226
|
+
details = client.get_string_list_details('sl')
|
|
227
|
+
assert_equal %w[a b], details.value
|
|
228
|
+
assert_equal Details::REASON_STATIC, details.reason
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# ---- BoundClient mirror --------------------------------------------
|
|
232
|
+
|
|
233
|
+
def test_bound_client_get_bool_details_passes_context
|
|
234
|
+
client = fixture_client
|
|
235
|
+
bound = client.in_context('user' => { 'plan' => 'pro' })
|
|
236
|
+
details = bound.get_bool_details('of.targeting')
|
|
237
|
+
assert_equal true, details.value
|
|
238
|
+
assert_equal Details::REASON_TARGETING_MATCH, details.reason
|
|
239
|
+
ensure
|
|
240
|
+
client&.stop
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -65,15 +65,17 @@ module Quonfig
|
|
|
65
65
|
refute(Options::DEFAULT_API_URLS.any? { |s| s.include?('reforge.com') })
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
def
|
|
69
|
-
with_env('
|
|
70
|
-
assert_equal 'https://
|
|
68
|
+
def test_telemetry_destination_honors_quonfig_domain_env
|
|
69
|
+
with_env('QUONFIG_DOMAIN', 'quonfig-staging.com') do
|
|
70
|
+
assert_equal 'https://telemetry.quonfig-staging.com',
|
|
71
71
|
Options.new.telemetry_destination
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
def test_telemetry_destination_default
|
|
76
|
-
|
|
76
|
+
with_env('QUONFIG_DOMAIN', nil) do
|
|
77
|
+
assert_equal 'https://telemetry.quonfig.com', Options.new.telemetry_destination
|
|
78
|
+
end
|
|
77
79
|
end
|
|
78
80
|
end
|
|
79
81
|
end
|
data/test/test_options.rb
CHANGED
|
@@ -5,23 +5,13 @@ require 'test_helper'
|
|
|
5
5
|
class TestOptions < Minitest::Test
|
|
6
6
|
API_KEY = 'abcdefg'
|
|
7
7
|
|
|
8
|
-
def test_api_urls_override_env_var
|
|
9
|
-
assert_equal Quonfig::Options::DEFAULT_API_URLS, Quonfig::Options.new.api_urls
|
|
10
|
-
|
|
11
|
-
# blank doesn't take effect
|
|
12
|
-
with_env('QUONFIG_API_URLS', '') do
|
|
13
|
-
assert_equal Quonfig::Options::DEFAULT_API_URLS, Quonfig::Options.new.api_urls
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# non-blank does take effect
|
|
17
|
-
with_env('QUONFIG_API_URLS', 'https://override.example.com') do
|
|
18
|
-
assert_equal ["https://override.example.com"], Quonfig::Options.new.api_urls
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
22
8
|
def test_default_api_urls_point_to_quonfig
|
|
9
|
+
# DEFAULT_API_URLS is the hardcoded fallback when neither QUONFIG_DOMAIN
|
|
10
|
+
# nor an explicit api_urls: kwarg is set. Domain-derivation happens at
|
|
11
|
+
# construction time, not at constant load time — see derive_api_urls.
|
|
23
12
|
assert_equal [
|
|
24
13
|
'https://primary.quonfig.com',
|
|
14
|
+
'https://secondary.quonfig.com',
|
|
25
15
|
], Quonfig::Options::DEFAULT_API_URLS
|
|
26
16
|
end
|
|
27
17
|
|
|
@@ -150,18 +140,59 @@ class TestOptions < Minitest::Test
|
|
|
150
140
|
assert_equal 0, options.collect_max_shapes
|
|
151
141
|
end
|
|
152
142
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
143
|
+
# ---- QUONFIG_DOMAIN tests (qfg-w6gg) ----
|
|
144
|
+
# A single env var `QUONFIG_DOMAIN` governs api, sse, and telemetry URL
|
|
145
|
+
# derivation. Resolution order (highest wins): explicit kwargs >
|
|
146
|
+
# QUONFIG_DOMAIN > hardcoded default 'quonfig.com'.
|
|
147
|
+
|
|
148
|
+
def test_default_domain_is_quonfig_com
|
|
149
|
+
with_env('QUONFIG_DOMAIN', nil) do
|
|
150
|
+
options = Quonfig::Options.new
|
|
151
|
+
assert_equal [
|
|
152
|
+
'https://primary.quonfig.com',
|
|
153
|
+
'https://secondary.quonfig.com',
|
|
154
|
+
], options.api_urls
|
|
155
|
+
assert_equal 'https://telemetry.quonfig.com', options.telemetry_destination
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def test_quonfig_domain_env_var_derives_all_urls
|
|
160
|
+
with_env('QUONFIG_DOMAIN', 'quonfig-staging.com') do
|
|
161
|
+
options = Quonfig::Options.new
|
|
162
|
+
assert_equal [
|
|
163
|
+
'https://primary.quonfig-staging.com',
|
|
164
|
+
'https://secondary.quonfig-staging.com',
|
|
165
|
+
], options.api_urls
|
|
166
|
+
assert_equal [
|
|
167
|
+
'https://stream.primary.quonfig-staging.com',
|
|
168
|
+
'https://stream.secondary.quonfig-staging.com',
|
|
169
|
+
], options.sse_api_urls
|
|
170
|
+
assert_equal 'https://telemetry.quonfig-staging.com', options.telemetry_destination
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def test_explicit_api_urls_override_quonfig_domain
|
|
175
|
+
with_env('QUONFIG_DOMAIN', 'quonfig-staging.com') do
|
|
176
|
+
options = Quonfig::Options.new(api_urls: ['http://localhost:8080'])
|
|
177
|
+
assert_equal ['http://localhost:8080'], options.api_urls
|
|
156
178
|
end
|
|
157
179
|
end
|
|
158
180
|
|
|
159
|
-
def
|
|
160
|
-
|
|
181
|
+
def test_explicit_telemetry_url_overrides_quonfig_domain
|
|
182
|
+
with_env('QUONFIG_DOMAIN', 'quonfig-staging.com') do
|
|
183
|
+
options = Quonfig::Options.new(telemetry_url: 'http://localhost:6555')
|
|
184
|
+
assert_equal 'http://localhost:6555', options.telemetry_destination
|
|
185
|
+
end
|
|
161
186
|
end
|
|
162
187
|
|
|
163
|
-
def
|
|
164
|
-
|
|
165
|
-
|
|
188
|
+
def test_quonfig_telemetry_url_env_var_no_longer_read
|
|
189
|
+
# QUONFIG_TELEMETRY_URL has been removed. Setting it must not affect
|
|
190
|
+
# anything; the default (quonfig.com) wins.
|
|
191
|
+
with_env('QUONFIG_DOMAIN', nil) do
|
|
192
|
+
with_env('QUONFIG_TELEMETRY_URL', 'https://should-be-ignored.example.com') do
|
|
193
|
+
assert_equal 'https://telemetry.quonfig.com',
|
|
194
|
+
Quonfig::Options.new.telemetry_destination
|
|
195
|
+
end
|
|
196
|
+
end
|
|
166
197
|
end
|
|
167
198
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: quonfig
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jeff Dwyer
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -228,6 +228,7 @@ files:
|
|
|
228
228
|
- lib/quonfig/errors/type_mismatch_error.rb
|
|
229
229
|
- lib/quonfig/errors/uninitialized_error.rb
|
|
230
230
|
- lib/quonfig/evaluation.rb
|
|
231
|
+
- lib/quonfig/evaluation_details.rb
|
|
231
232
|
- lib/quonfig/evaluator.rb
|
|
232
233
|
- lib/quonfig/exponential_backoff.rb
|
|
233
234
|
- lib/quonfig/fixed_size_hash.rb
|
|
@@ -280,6 +281,7 @@ files:
|
|
|
280
281
|
- test/test_context_shape.rb
|
|
281
282
|
- test/test_context_shape_aggregator.rb
|
|
282
283
|
- test/test_datadir.rb
|
|
284
|
+
- test/test_details_getters.rb
|
|
283
285
|
- test/test_dev_context.rb
|
|
284
286
|
- test/test_duration.rb
|
|
285
287
|
- test/test_encryption.rb
|