Skip to content

Commit a89f987

Browse files
feat(audience): optional AudienceConfig.BaseUrl override
Addresses review feedback on #709 from @nattb8: match the Web/Pixel SDK pattern of letting callers override the API base URL. BaseUrl is null by default. A null override keeps the existing key-prefix derivation — "pk_imapik-test-" keys resolve to Sandbox and every other key resolves to Production — so there is no behaviour change for studios that never set this field. Integrations that need a different backend (for example the Immutable-internal Dev API at https://api.dev.immutable.com) pass the URL directly; no public env token is surfaced. - AudienceConfig: new BaseUrl string? property. - Constants: BaseUrl / MessagesUrl / ConsentUrl / DataUrl accept an optional baseUrlOverride; override wins when non-empty, falls back to key-prefix derivation when null or empty. - HttpTransport: ctor accepts baseUrlOverride and threads it into MessagesUrl. - ImmutableAudience.Init passes config.BaseUrl into HttpTransport; DeleteData and SyncConsentToBackend pass it into DataUrl / ConsentUrl. - Tests: BaseUrl resolution cases (test key, non-test key, null key, override wins, empty-string override falls back) and an end-to-end HttpTransport test confirming the override wins over a sandbox- derived key. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 08ba2cd commit a89f987

6 files changed

Lines changed: 91 additions & 9 deletions

File tree

src/Packages/Audience/Runtime/AudienceConfig.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ public class AudienceConfig
1010
// Studio API key. Required — Init throws if null.
1111
public string? PublishableKey { get; set; }
1212

13+
// Override the default API base URL. When null, keys starting with
14+
// "pk_imapik-test-" resolve to Sandbox and all other keys resolve
15+
// to Production. Set explicitly to target a different backend.
16+
public string? BaseUrl { get; set; }
17+
1318
// Initial consent level.
1419
public ConsentLevel Consent { get; set; } = ConsentLevel.None;
1520

src/Packages/Audience/Runtime/Core/Constants.cs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,22 @@ internal static class Constants
2626

2727
internal const string PublishableKeyHeader = "x-immutable-publishable-key";
2828

29-
internal static string MessagesUrl(string? publishableKey) => BaseUrl(publishableKey) + MessagesPath;
30-
internal static string ConsentUrl(string? publishableKey) => BaseUrl(publishableKey) + ConsentPath;
31-
internal static string DataUrl(string? publishableKey) => BaseUrl(publishableKey) + DataPath;
29+
internal static string MessagesUrl(string? publishableKey, string? baseUrlOverride = null) =>
30+
BaseUrl(publishableKey, baseUrlOverride) + MessagesPath;
31+
internal static string ConsentUrl(string? publishableKey, string? baseUrlOverride = null) =>
32+
BaseUrl(publishableKey, baseUrlOverride) + ConsentPath;
33+
internal static string DataUrl(string? publishableKey, string? baseUrlOverride = null) =>
34+
BaseUrl(publishableKey, baseUrlOverride) + DataPath;
3235

33-
internal static string BaseUrl(string? publishableKey) =>
34-
publishableKey != null && publishableKey.StartsWith(TestKeyPrefix)
36+
// Override wins when non-empty; otherwise test keys map to Sandbox
37+
// and every other key maps to Production. Matches @imtbl/audience.
38+
internal static string BaseUrl(string? publishableKey, string? baseUrlOverride = null)
39+
{
40+
if (!string.IsNullOrEmpty(baseUrlOverride)) return baseUrlOverride!;
41+
return publishableKey != null && publishableKey.StartsWith(TestKeyPrefix)
3542
? SandboxBaseUrl
3643
: ProductionBaseUrl;
44+
}
3745
}
3846

3947
// Message type values written to (and read back from) the "type" field.

src/Packages/Audience/Runtime/ImmutableAudience.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ public static void Init(AudienceConfig config)
132132

133133
_store = new DiskStore(config.PersistentDataPath);
134134
_queue = new EventQueue(_store, config.FlushIntervalSeconds, config.FlushSize);
135-
_transport = new HttpTransport(_store, config.PublishableKey, config.OnError, config.HttpHandler);
135+
_transport = new HttpTransport(_store, config.PublishableKey, config.BaseUrl, config.OnError, config.HttpHandler);
136136
_controlClient = config.HttpHandler != null
137137
? new HttpClient(config.HttpHandler, disposeHandler: false)
138138
: new HttpClient();
@@ -388,7 +388,7 @@ public static Task DeleteData(string? userId = null)
388388
query = "anonymousId=" + Uri.EscapeDataString(anonymousId);
389389
}
390390

391-
var url = Constants.DataUrl(config.PublishableKey) + "?" + query;
391+
var url = Constants.DataUrl(config.PublishableKey, config.BaseUrl) + "?" + query;
392392
var onError = config.OnError;
393393
var publishableKey = config.PublishableKey;
394394
var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None;
@@ -550,7 +550,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev
550550
var client = _controlClient;
551551
if (client == null) return;
552552

553-
var url = Constants.ConsentUrl(config.PublishableKey);
553+
var url = Constants.ConsentUrl(config.PublishableKey, config.BaseUrl);
554554
var publishableKey = config.PublishableKey;
555555
var onError = config.OnError;
556556
var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None;

src/Packages/Audience/Runtime/Transport/HttpTransport.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,20 @@ internal sealed class HttpTransport : IDisposable
2828

2929
// store: source of event batches.
3030
// publishableKey: sent as x-immutable-publishable-key on every request.
31+
// baseUrlOverride: explicit backend URL. Null = derive from publishableKey prefix.
3132
// onError: optional failure callback. Exceptions thrown inside it are caught.
3233
// handler / getUtcNow: test seams; null for production use.
3334
internal HttpTransport(
3435
DiskStore store,
3536
string publishableKey,
37+
string? baseUrlOverride = null,
3638
Action<AudienceError>? onError = null,
3739
HttpMessageHandler? handler = null,
3840
Func<DateTime>? getUtcNow = null)
3941
{
4042
_store = store ?? throw new ArgumentNullException(nameof(store));
4143
_publishableKey = publishableKey ?? throw new ArgumentNullException(nameof(publishableKey));
42-
_url = Constants.MessagesUrl(publishableKey);
44+
_url = Constants.MessagesUrl(publishableKey, baseUrlOverride);
4345
_onError = onError;
4446
// disposeHandler: false so the consumer can reuse their handler
4547
// across Init/Shutdown cycles (matches _controlClient's policy).

src/Packages/Audience/Tests/Runtime/ConstantsTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,54 @@ namespace Immutable.Audience.Tests
66
[TestFixture]
77
internal class ConstantsTests
88
{
9+
// -----------------------------------------------------------------
10+
// BaseUrl resolution
11+
// -----------------------------------------------------------------
12+
13+
[Test]
14+
public void BaseUrl_TestKey_ResolvesToSandbox()
15+
{
16+
Assert.AreEqual(Constants.SandboxBaseUrl,
17+
Constants.BaseUrl("pk_imapik-test-abc"));
18+
}
19+
20+
[Test]
21+
public void BaseUrl_NonTestKey_ResolvesToProduction()
22+
{
23+
Assert.AreEqual(Constants.ProductionBaseUrl,
24+
Constants.BaseUrl("pk_imapik-prod-abc"));
25+
}
26+
27+
[Test]
28+
public void BaseUrl_NullKey_ResolvesToProduction()
29+
{
30+
Assert.AreEqual(Constants.ProductionBaseUrl,
31+
Constants.BaseUrl(null));
32+
}
33+
34+
[Test]
35+
public void BaseUrl_Override_WinsOverKeyPrefix()
36+
{
37+
// Override wins even for a test-prefixed key that would
38+
// otherwise derive to Sandbox.
39+
const string custom = "https://api.dev.immutable.com";
40+
Assert.AreEqual(custom,
41+
Constants.BaseUrl("pk_imapik-test-abc", custom));
42+
}
43+
44+
[Test]
45+
public void BaseUrl_EmptyOverride_FallsBackToKeyDerivation()
46+
{
47+
// Empty-string override is treated as "no override" so the
48+
// key-prefix fallback still kicks in.
49+
Assert.AreEqual(Constants.SandboxBaseUrl,
50+
Constants.BaseUrl("pk_imapik-test-abc", ""));
51+
}
52+
53+
// -----------------------------------------------------------------
54+
// Library version
55+
// -----------------------------------------------------------------
56+
957
[Test]
1058
public void LibraryVersion_MatchesPackageJson()
1159
{

src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,25 @@ public async Task SendBatchAsync_200_UsesCorrectUrlForProdKey()
152152
StringAssert.StartsWith(Constants.ProductionBaseUrl, captured.RequestUri.ToString());
153153
}
154154

155+
[Test]
156+
public async Task SendBatchAsync_BaseUrlOverride_WinsOverKeyPrefix()
157+
{
158+
_store.Write("{\"type\":\"track\"}");
159+
160+
HttpRequestMessage captured = null;
161+
var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}",
162+
onRequest: req => captured = req);
163+
const string custom = "https://api.dev.immutable.com";
164+
// Test-prefixed key would resolve to Sandbox on its own; the
165+
// explicit override must win.
166+
using var transport = new HttpTransport(_store, "pk_imapik-test-key1",
167+
baseUrlOverride: custom, handler: handler);
168+
169+
await transport.SendBatchAsync();
170+
171+
StringAssert.StartsWith(custom, captured.RequestUri.ToString());
172+
}
173+
155174
[Test]
156175
public async Task SendBatchAsync_EmptyQueue_ReturnsFalse()
157176
{

0 commit comments

Comments
 (0)