diff --git a/README.md b/README.md index 21ed4c0..ac3bef6 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,11 @@ final sdk = await GBSDKBuilderApp( // Advanced Features remoteEval: false, // Server-side evaluation encryptionKey: "...", // For encrypted features + + // Networking (see "Custom Hosts and Request Headers") + streamingHost: "https://proxy.example.com", + apiHostRequestHeaders: {'X-Gateway-Auth': gatewayToken}, + streamingHostRequestHeaders: {'X-Gateway-Auth': gatewayToken}, ).initialize(); ``` @@ -484,6 +489,38 @@ final sdk = await GBSDKBuilderApp( // No need to restart the app or refresh manually ``` +### Custom Hosts and Request Headers + +By default every request goes to `hostURL` with only the headers the SDK manages itself. Two options +cover deployments where that is not enough — both match the JavaScript SDK. + +**A separate streaming host.** Set `streamingHost` when the SSE endpoint is served somewhere other +than the API, for example features from a CDN while streaming goes through a GrowthBook Proxy. It +applies only to the streaming connection; features and remote evaluation stay on `hostURL`, and +without it streaming uses `hostURL` as before. + +**Custom request headers.** `apiHostRequestHeaders` is sent with the features request and with every +remote-evaluation request; `streamingHostRequestHeaders` is sent with the streaming connection, +including its reconnects. Typical uses are an authenticated gateway in front of a self-hosted +GrowthBook, or proxy directives such as `X-Accel-Buffering` for SSE through nginx. + +```dart +final sdk = await GBSDKBuilderApp( + apiKey: "your_api_key", + hostURL: "https://cdn.example.com", + streamingHost: "https://proxy.example.com", + backgroundSync: true, + // Read secrets from your configuration, never hardcode them + apiHostRequestHeaders: {'X-Gateway-Auth': gatewayToken}, + streamingHostRequestHeaders: {'X-Accel-Buffering': 'no'}, +).initialize(); +``` + +`User-Agent`, `If-None-Match` and `Cache-Control` are managed by the SDK — supplying any of them, in +any letter case, throws an `ArgumentError` from `initialize()` instead of silently breaking ETag +revalidation. A `streamingHost` that is not an absolute `http`/`https` URL is rejected the same way, +so a misconfiguration is visible at startup rather than as a failed fetch later. + ### Tracking Plugins Plugins observe SDK lifecycle events (feature evaluated, experiment viewed) and can implement custom side effects such as forwarding events to an analytics backend. The SDK ships with `GrowthBookTrackingPlugin`, which batches events and sends them to the GrowthBook ingest endpoint. diff --git a/lib/src/Features/feature_data_source.dart b/lib/src/Features/feature_data_source.dart index efe5cfe..1fd49d4 100644 --- a/lib/src/Features/feature_data_source.dart +++ b/lib/src/Features/feature_data_source.dart @@ -43,6 +43,7 @@ class FeatureDataSource { FeaturedDataModel.fromJson(response), ), onError, + headers: context.streamingHostRequestHeaders, ) : await client.consumeGetRequest( _getEndpoint( @@ -52,6 +53,7 @@ class FeatureDataSource { FeaturedDataModel.fromJson(response), ), onError, + headers: context.apiHostRequestHeaders, ); } @@ -74,6 +76,9 @@ class FeatureDataSource { FeaturedDataModel.fromJson(response), ), onError, + // Remote evaluation runs against the API host, so it carries the same + // headers as the features request. + headers: context.apiHostRequestHeaders, ); } @@ -81,7 +86,12 @@ class FeatureDataSource { {required GBContext context, FeatureRefreshStrategy featureRefreshStrategy = FeatureRefreshStrategy.STALE_WHILE_REVALIDATE}) { - return FeatureURLBuilder.buildUrl(context.hostURL, context.apiKey, - featureRefreshStrategy: featureRefreshStrategy); + final url = + featureRefreshStrategy == FeatureRefreshStrategy.SERVER_SENT_EVENTS + ? context.getStreamingURL() + : context.getFeaturesURL(); + // A missing host or client key is a configuration error the SDK cannot + // recover from here; the empty URL surfaces it as a request failure. + return url ?? ''; } } diff --git a/lib/src/Model/context.dart b/lib/src/Model/context.dart index c7b7efb..8a2ea50 100644 --- a/lib/src/Model/context.dart +++ b/lib/src/Model/context.dart @@ -1,4 +1,5 @@ import 'package:growthbook_sdk_flutter/growthbook_sdk_flutter.dart'; +import 'package:growthbook_sdk_flutter/src/Utils/feature_url_builder.dart'; /// Defines the GrowthBook context. class GBContext { @@ -20,6 +21,9 @@ class GBContext { this.backgroundSync = false, this.savedGroups, this.url, + this.streamingHost, + this.apiHostRequestHeaders, + this.streamingHostRequestHeaders, }); /// Registered API key for GrowthBook SDK. @@ -31,6 +35,18 @@ class GBContext { /// Host URL for GrowthBook String? hostURL; + /// Host serving the streaming (server-sent events) endpoint, when it differs + /// from [hostURL] — a GrowthBook Proxy in front of a CDN, for example. + /// Falls back to [hostURL] when null. + String? streamingHost; + + /// Headers sent with every features request and remote-evaluation request. + /// Meant for deployments behind an authenticated gateway or proxy. + Map? apiHostRequestHeaders; + + /// Headers sent with the streaming connection, including its reconnects. + Map? streamingHostRequestHeaders; + /// Switch to globally disable all experiments. Default true. bool? enabled; @@ -77,20 +93,23 @@ class GBContext { ///A URL string that is used for experiment evaluation, as well as forcing feature values. String? url; - String? getFeaturesURL() => _buildUrl('/api/features/'); - - String? getRemoteEvalUrl() => _buildUrl('/api/eval/'); - - String? _buildUrl(String endpoint) { - if (hostURL == null || apiKey == null) return null; - - final uri = Uri.parse(hostURL!); - - var basePath = uri.path; - while (basePath.endsWith('/')) { - basePath = basePath.substring(0, basePath.length - 1); - } - - return uri.replace(path: '$basePath$endpoint$apiKey').toString(); - } + String? getFeaturesURL() => _urlBuilder.buildUrl(apiKey); + + String? getRemoteEvalUrl() => _urlBuilder.buildUrl( + apiKey, + featureRefreshStrategy: + FeatureRefreshStrategy.SERVER_SENT_REMOTE_FEATURE_EVAL, + ); + + /// Streaming endpoint, served from [streamingHost] when one is configured and + /// from [hostURL] otherwise. + String? getStreamingURL() => _urlBuilder.buildUrl( + apiKey, + featureRefreshStrategy: FeatureRefreshStrategy.SERVER_SENT_EVENTS, + ); + + FeatureURLBuilder get _urlBuilder => FeatureURLBuilder( + apiHost: hostURL, + streamingHost: streamingHost, + ); } diff --git a/lib/src/Network/network.dart b/lib/src/Network/network.dart index 24c7043..645840f 100644 --- a/lib/src/Network/network.dart +++ b/lib/src/Network/network.dart @@ -11,24 +11,30 @@ typedef OnError = void Function(Object error, StackTrace stackTrace); abstract class BaseClient { const BaseClient(); + /// [headers] carries the caller-configured request headers + /// (`apiHostRequestHeaders` / `streamingHostRequestHeaders`). They are added + /// to the request, never in place of the headers the SDK manages itself. Future consumeGetRequest( String url, OnSuccess onSuccess, - OnError onError, - ); + OnError onError, { + Map? headers, + }); Future consumePostRequest( String baseUrl, Map params, OnSuccess onSuccess, - OnError onError, - ); + OnError onError, { + Map? headers, + }); Future consumeSseConnections( String url, OnSuccess onSuccess, - OnError onError, - ); + OnError onError, { + Map? headers, + }); } class DioClient extends BaseClient { @@ -47,12 +53,16 @@ class DioClient extends BaseClient { required String url, required OnSuccess onSuccess, required OnError onError, + Map? headers, }) async { try { log('Establishing SSE connection to: $url'); final resp = await _dio.get( url, - options: Options(responseType: ResponseType.stream), + options: Options( + responseType: ResponseType.stream, + headers: headers == null ? null : Map.from(headers), + ), ); final data = resp.data; @@ -96,6 +106,7 @@ class DioClient extends BaseClient { url: url, onError: onError, onSuccess: onSuccess, + headers: headers, ); } }, @@ -115,23 +126,27 @@ class DioClient extends BaseClient { Future consumeGetRequest( String url, OnSuccess onSuccess, - OnError onError, - ) async { + OnError onError, { + Map? headers, + }) async { try { - final headers = {}; + // Caller headers first, so the SDK-managed ones below always win. Names + // the SDK manages are rejected at configuration time anyway; this keeps + // the guarantee even for a client constructed some other way. + final requestHeaders = {...?headers}; if (_featuresRegex.hasMatch(url)) { final etag = _etagCache.get(url); if (etag != null) { - headers["If-None-Match"] = etag; + requestHeaders["If-None-Match"] = etag; } - headers["Cache-Control"] = "max-age=3600"; + requestHeaders["Cache-Control"] = "max-age=3600"; } final response = await _dio.get( url, options: Options( - headers: headers, + headers: requestHeaders, validateStatus: (status) => status != null && ((status >= 200 && status < 300) || status == 304), @@ -172,12 +187,14 @@ class DioClient extends BaseClient { Future consumeSseConnections( String url, OnSuccess onSuccess, - OnError onError, - ) async { + OnError onError, { + Map? headers, + }) async { await listenAndRetry( url: url, onError: onError, onSuccess: onSuccess, + headers: headers, ); } @@ -186,14 +203,18 @@ class DioClient extends BaseClient { String baseUrl, Map params, OnSuccess onSuccess, - OnError onError, - ) async { + OnError onError, { + Map? headers, + }) async { try { Response response = await _dio.post( baseUrl, data: params, options: Options( headers: { + // Caller headers first: the content negotiation below describes the + // body this method sends and must not be replaced. + ...?headers, 'Content-Type': 'application/json', 'Accept': 'application/json', }, diff --git a/lib/src/Utils/feature_url_builder.dart b/lib/src/Utils/feature_url_builder.dart index a4a3cf2..0a089b9 100644 --- a/lib/src/Utils/feature_url_builder.dart +++ b/lib/src/Utils/feature_url_builder.dart @@ -1,32 +1,68 @@ // ignore_for_file: constant_identifier_names +/// Builds every endpoint the SDK talks to, so the host rules live in one place. +/// +/// Streaming may be served from a different host than the API — a GrowthBook +/// Proxy in front of a CDN, for example — so [streamingHost] is applied to the +/// server-sent events endpoint and falls back to [apiHost] when it is not set, +/// mirroring the reference JS SDK's `getApiHosts()`. class FeatureURLBuilder { static const String featurePath = "api/features"; static const String eventsPath = "sub"; static const String remoteEvalPath = "api/eval"; - static String buildUrl( - String? hostUrl, - String? apiKey, { + const FeatureURLBuilder({ + required this.apiHost, + this.streamingHost, + }); + + /// Host serving the features and remote-evaluation endpoints. + final String? apiHost; + + /// Host serving the streaming endpoint. Falls back to [apiHost] when null. + final String? streamingHost; + + /// Returns the endpoint for [featureRefreshStrategy], or null when the host + /// or [clientKey] is missing. + /// + /// A path on the host is preserved, so an SDK deployed under + /// `https://proxy.example.com/growthbook` keeps that prefix; trailing slashes + /// on the host are dropped rather than doubled. + String? buildUrl( + String? clientKey, { FeatureRefreshStrategy featureRefreshStrategy = FeatureRefreshStrategy.STALE_WHILE_REVALIDATE, }) { - String endpoint = ''; + final host = + featureRefreshStrategy == FeatureRefreshStrategy.SERVER_SENT_EVENTS + ? (streamingHost ?? apiHost) + : apiHost; + + if (host == null || host.isEmpty || clientKey == null) return null; + + final uri = Uri.tryParse(host); + if (uri == null) return null; + + var basePath = uri.path; + while (basePath.endsWith('/')) { + basePath = basePath.substring(0, basePath.length - 1); + } + + return uri + .replace( + path: '$basePath/${_endpoint(featureRefreshStrategy)}/$clientKey') + .toString(); + } + + static String _endpoint(FeatureRefreshStrategy featureRefreshStrategy) { switch (featureRefreshStrategy) { case FeatureRefreshStrategy.STALE_WHILE_REVALIDATE: - endpoint = featurePath; - break; + return featurePath; case FeatureRefreshStrategy.SERVER_SENT_EVENTS: - endpoint = eventsPath; - break; + return eventsPath; case FeatureRefreshStrategy.SERVER_SENT_REMOTE_FEATURE_EVAL: - endpoint = remoteEvalPath; - break; + return remoteEvalPath; } - String baseUrlWithFeaturePath = - hostUrl!.endsWith('/') ? '$hostUrl$endpoint' : '$hostUrl/$endpoint'; - - return '$baseUrlWithFeaturePath/$apiKey'; } } diff --git a/lib/src/growth_book_sdk.dart b/lib/src/growth_book_sdk.dart index ab5c2e4..cae3043 100644 --- a/lib/src/growth_book_sdk.dart +++ b/lib/src/growth_book_sdk.dart @@ -29,11 +29,31 @@ class GBSDKBuilderApp { this.backgroundSync = false, this.remoteEval = false, this.ttlSeconds = 60, - this.url}); + this.url, + this.streamingHost, + this.apiHostRequestHeaders, + this.streamingHostRequestHeaders}); final String apiKey; final String? encryptionKey; final String hostURL; + + /// Host serving the streaming (server-sent events) endpoint when it differs + /// from [hostURL] — for example streaming through a GrowthBook Proxy while + /// features are served from a CDN. Defaults to [hostURL]. + final String? streamingHost; + + /// Headers added to every features request and remote-evaluation request, for + /// deployments behind an authenticated gateway or proxy. + /// + /// The SDK manages `User-Agent`, `If-None-Match` and `Cache-Control` itself; + /// supplying any of them throws an [ArgumentError] rather than silently + /// breaking conditional requests. + final Map? apiHostRequestHeaders; + + /// Headers added to the streaming connection, including its reconnects. + /// Same reserved names as [apiHostRequestHeaders]. + final Map? streamingHostRequestHeaders; final bool enable; final bool qaMode; final Map? attributes; @@ -55,10 +75,21 @@ class GBSDKBuilderApp { final List _plugins = []; Future initialize() async { + _validateHostAndHeaders(); + final gbContext = GBContext( apiKey: apiKey, encryptionKey: encryptionKey, hostURL: hostURL, + streamingHost: streamingHost, + // Copied so later edits to the caller's maps cannot change the headers + // requests are already being sent with. + apiHostRequestHeaders: apiHostRequestHeaders == null + ? null + : Map.from(apiHostRequestHeaders!), + streamingHostRequestHeaders: streamingHostRequestHeaders == null + ? null + : Map.from(streamingHostRequestHeaders!), enabled: enable, qaMode: qaMode, attributes: attributes, @@ -84,6 +115,53 @@ class GBSDKBuilderApp { return gb; } + /// Header names the SDK sets itself. Letting a caller supply them would + /// silently break conditional requests (ETag revalidation) or the cache + /// directives, so they are rejected instead of merged. + static const _reservedHeaderNames = { + 'user-agent', + 'if-none-match', + 'cache-control', + }; + + /// Rejects a malformed [streamingHost] and reserved header names before any + /// request is built, so misconfiguration surfaces at startup rather than as a + /// failed fetch later. + void _validateHostAndHeaders() { + final host = streamingHost; + if (host != null) { + final uri = Uri.tryParse(host); + final isHttpUrl = uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.hasAuthority; + if (!isHttpUrl) { + throw ArgumentError.value( + host, + 'streamingHost', + 'must be an absolute http or https URL', + ); + } + } + + _rejectReservedHeaders(apiHostRequestHeaders, 'apiHostRequestHeaders'); + _rejectReservedHeaders( + streamingHostRequestHeaders, 'streamingHostRequestHeaders'); + } + + void _rejectReservedHeaders(Map? headers, String field) { + if (headers == null) return; + + for (final name in headers.keys) { + if (_reservedHeaderNames.contains(name.toLowerCase())) { + throw ArgumentError.value( + name, + field, + 'is managed by the SDK and cannot be overridden', + ); + } + } + } + /// Registers a legacy refresh handler that only receives a boolean. /// /// Prefer [setRefreshHandlerV2] which also receives the [GBError] that diff --git a/test/common_test/gb_sdk_request_headers_test.dart b/test/common_test/gb_sdk_request_headers_test.dart new file mode 100644 index 0000000..15e0cdf --- /dev/null +++ b/test/common_test/gb_sdk_request_headers_test.dart @@ -0,0 +1,245 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:growthbook_sdk_flutter/growthbook_sdk_flutter.dart'; +import 'package:growthbook_sdk_flutter/src/Cache/caching_manager.dart'; + +import '../mocks/network_mock.dart'; + +void main() { + group('GrowthBookSDK — custom request headers and streaming host', () { + const testApiKey = 'sdk-abc123'; + const testHostURL = 'https://cdn.example.com'; + const streamingHostURL = 'https://proxy.example.com'; + final cachingManager = CachingManager(); + + tearDown(() => cachingManager.clearCache()); + + Future buildSdk( + _HeaderRecordingClient client, { + bool remoteEval = false, + bool backgroundSync = false, + String? streamingHost, + Map? apiHostRequestHeaders, + Map? streamingHostRequestHeaders, + }) { + return GBSDKBuilderApp( + apiKey: testApiKey, + hostURL: testHostURL, + attributes: {'id': 'user-1'}, + client: client, + growthBookTrackingCallBack: (_) {}, + backgroundSync: backgroundSync, + remoteEval: remoteEval, + streamingHost: streamingHost, + apiHostRequestHeaders: apiHostRequestHeaders, + streamingHostRequestHeaders: streamingHostRequestHeaders, + ).initialize(); + } + + // ------------------------------------------------------------------------- + // apiHostRequestHeaders + // ------------------------------------------------------------------------- + group('apiHostRequestHeaders', () { + test('are sent with the features request', () async { + final client = _HeaderRecordingClient(); + await buildSdk(client, + apiHostRequestHeaders: {'X-Gateway-Auth': 'token-1'}); + + expect(client.gets, isNotEmpty); + expect(client.gets.last.headers?['X-Gateway-Auth'], 'token-1'); + }); + + test('are sent with the remote-evaluation request', () async { + final client = _HeaderRecordingClient(); + await buildSdk(client, + remoteEval: true, + apiHostRequestHeaders: {'X-Gateway-Auth': 'token-1'}); + + expect(client.posts, isNotEmpty); + expect(client.posts.last.headers?['X-Gateway-Auth'], 'token-1'); + }); + + test('are not sent to the streaming connection', () async { + final client = _HeaderRecordingClient(); + await buildSdk(client, + backgroundSync: true, + apiHostRequestHeaders: {'X-Gateway-Auth': 'token-1'}); + + expect(client.streams, isNotEmpty); + expect(client.streams.last.headers?.containsKey('X-Gateway-Auth'), + isNot(isTrue)); + }); + + test('nothing is added when no headers are configured', () async { + final client = _HeaderRecordingClient(); + await buildSdk(client); + + expect(client.gets.last.headers, anyOf(isNull, isEmpty)); + }); + + /// Callers keep a reference to the map they passed in; changing it later + /// must not change what is already being sent. + test('a later edit of the caller map does not change the headers', + () async { + final client = _HeaderRecordingClient(); + final headers = {'X-Gateway-Auth': 'token-1'}; + final sdk = await buildSdk(client, apiHostRequestHeaders: headers); + + headers['X-Gateway-Auth'] = 'token-2'; + headers['X-Added-Later'] = 'nope'; + await sdk.refresh(); + + expect(client.gets.last.headers?['X-Gateway-Auth'], 'token-1'); + expect(client.gets.last.headers?.containsKey('X-Added-Later'), isFalse); + }); + }); + + // ------------------------------------------------------------------------- + // streamingHost + streamingHostRequestHeaders + // ------------------------------------------------------------------------- + group('streamingHost', () { + test('SSE connects to it when configured', () async { + final client = _HeaderRecordingClient(); + await buildSdk(client, + backgroundSync: true, streamingHost: streamingHostURL); + + expect(client.streams.last.url, '$streamingHostURL/sub/$testApiKey'); + }); + + test('SSE falls back to the API host when not configured', () async { + final client = _HeaderRecordingClient(); + await buildSdk(client, backgroundSync: true); + + expect(client.streams.last.url, '$testHostURL/sub/$testApiKey'); + }); + + test('features keep using the API host', () async { + final client = _HeaderRecordingClient(); + await buildSdk(client, + backgroundSync: true, streamingHost: streamingHostURL); + + expect(client.gets.last.url, '$testHostURL/api/features/$testApiKey'); + }); + + test('remote evaluation keeps using the API host', () async { + final client = _HeaderRecordingClient(); + await buildSdk(client, + remoteEval: true, + backgroundSync: true, + streamingHost: streamingHostURL); + + expect(client.posts.last.url, '$testHostURL/api/eval/$testApiKey'); + }); + + test('streamingHostRequestHeaders are applied to the SSE request', + () async { + final client = _HeaderRecordingClient(); + await buildSdk(client, + backgroundSync: true, + streamingHost: streamingHostURL, + streamingHostRequestHeaders: {'X-Accel-Buffering': 'no'}); + + expect(client.streams.last.headers?['X-Accel-Buffering'], 'no'); + }); + + test('streaming headers are not sent to the API host', () async { + final client = _HeaderRecordingClient(); + await buildSdk(client, + backgroundSync: true, + streamingHostRequestHeaders: {'X-Accel-Buffering': 'no'}); + + expect(client.gets.last.headers?.containsKey('X-Accel-Buffering'), + isNot(isTrue)); + }); + }); + + // ------------------------------------------------------------------------- + // Validation + // ------------------------------------------------------------------------- + group('validation', () { + Future expectArgumentError(Future Function() build, + {required String mentioning}) async { + await expectLater( + build(), + throwsA(isA() + .having((e) => e.toString(), 'message', contains(mentioning))), + ); + } + + test('rejects a streamingHost that is not an absolute http URL', + () async { + for (final invalid in ['not a url', 'ftp://example.com', '/sub']) { + await expectArgumentError( + () => buildSdk(_HeaderRecordingClient(), streamingHost: invalid), + mentioning: 'streamingHost', + ); + } + }); + + test('accepts http and https streaming hosts', () async { + await expectLater( + buildSdk(_HeaderRecordingClient(), + streamingHost: 'http://localhost:3300'), + completes, + ); + }); + + test('rejects reserved header names, whatever their case', () async { + for (final reserved in [ + 'If-None-Match', + 'cache-control', + 'USER-AGENT', + ]) { + await expectArgumentError( + () => buildSdk(_HeaderRecordingClient(), + apiHostRequestHeaders: {reserved: 'x'}), + mentioning: 'apiHostRequestHeaders', + ); + await expectArgumentError( + () => buildSdk(_HeaderRecordingClient(), + streamingHostRequestHeaders: {reserved: 'x'}), + mentioning: 'streamingHostRequestHeaders', + ); + } + }); + }); + }); +} + +class _Request { + _Request(this.url, this.headers); + + final String url; + final Map? headers; +} + +/// Records the URL and headers of every call the SDK makes. +class _HeaderRecordingClient implements BaseClient { + final List<_Request> gets = []; + final List<_Request> posts = []; + final List<_Request> streams = []; + + @override + Future consumeGetRequest( + String url, OnSuccess onSuccess, OnError onError, + {Map? headers}) async { + gets.add(_Request(url, headers)); + await onSuccess(jsonDecode(MockResponse.successResponse)); + } + + @override + Future consumePostRequest(String baseUrl, Map params, + OnSuccess onSuccess, OnError onError, + {Map? headers}) async { + posts.add(_Request(baseUrl, headers)); + await onSuccess(jsonDecode(MockResponse.successResponse)); + } + + @override + Future consumeSseConnections( + String url, OnSuccess onSuccess, OnError onError, + {Map? headers}) async { + streams.add(_Request(url, headers)); + } +} diff --git a/test/common_test/utility_classes_test.dart b/test/common_test/utility_classes_test.dart index cef9650..07325c4 100644 --- a/test/common_test/utility_classes_test.dart +++ b/test/common_test/utility_classes_test.dart @@ -91,9 +91,10 @@ void main() { // FeatureURLBuilder — SERVER_SENT_REMOTE_FEATURE_EVAL branch // ------------------------------------------------------------------------- group('FeatureURLBuilder', () { + const builder = FeatureURLBuilder(apiHost: 'https://example.com/'); + test('builds URL for SERVER_SENT_REMOTE_FEATURE_EVAL strategy', () { - final url = FeatureURLBuilder.buildUrl( - 'https://example.com/', + final url = builder.buildUrl( 'my-key', featureRefreshStrategy: FeatureRefreshStrategy.SERVER_SENT_REMOTE_FEATURE_EVAL, @@ -103,14 +104,59 @@ void main() { }); test('builds URL for SERVER_SENT_EVENTS strategy', () { - final url = FeatureURLBuilder.buildUrl( - 'https://example.com/', + final url = builder.buildUrl( 'my-key', featureRefreshStrategy: FeatureRefreshStrategy.SERVER_SENT_EVENTS, ); expect(url, contains('sub')); expect(url, endsWith('my-key')); }); + + test('builds URL for the features endpoint', () { + expect(builder.buildUrl('my-key'), + 'https://example.com/api/features/my-key'); + }); + + test('keeps a path on the host instead of replacing it', () { + const hosted = FeatureURLBuilder(apiHost: 'https://example.com/gb/'); + expect(hosted.buildUrl('my-key'), + 'https://example.com/gb/api/features/my-key'); + }); + + test('returns null without a host or a client key', () { + expect(const FeatureURLBuilder(apiHost: null).buildUrl('my-key'), isNull); + expect(builder.buildUrl(null), isNull); + }); + + test('streams from streamingHost when it is set', () { + const split = FeatureURLBuilder( + apiHost: 'https://cdn.example.com', + streamingHost: 'https://proxy.example.com', + ); + + expect( + split.buildUrl('my-key', + featureRefreshStrategy: FeatureRefreshStrategy.SERVER_SENT_EVENTS), + 'https://proxy.example.com/sub/my-key', + ); + // Everything else stays on the API host. + expect(split.buildUrl('my-key'), + 'https://cdn.example.com/api/features/my-key'); + expect( + split.buildUrl('my-key', + featureRefreshStrategy: + FeatureRefreshStrategy.SERVER_SENT_REMOTE_FEATURE_EVAL), + 'https://cdn.example.com/api/eval/my-key', + ); + }); + + test('streams from the API host when streamingHost is not set', () { + expect( + builder.buildUrl('my-key', + featureRefreshStrategy: FeatureRefreshStrategy.SERVER_SENT_EVENTS), + 'https://example.com/sub/my-key', + ); + }); }); // ------------------------------------------------------------------------- diff --git a/test/mocks/network_mock.dart b/test/mocks/network_mock.dart index b76aaff..1066020 100644 --- a/test/mocks/network_mock.dart +++ b/test/mocks/network_mock.dart @@ -10,7 +10,8 @@ class MockNetworkClient implements BaseClient { @override Future consumeGetRequest( - String url, OnSuccess onSuccess, OnError onError) async { + String url, OnSuccess onSuccess, OnError onError, + {Map? headers}) async { if (notModified) { // Simulate 304 Not Modified: neither callback is called return; @@ -34,7 +35,8 @@ class MockNetworkClient implements BaseClient { @override Future consumePostRequest(String baseUrl, Map params, - OnSuccess onSuccess, OnError onError) async { + OnSuccess onSuccess, OnError onError, + {Map? headers}) async { if (!error) { final pseudoResponse = jsonDecode(MockResponse.successResponse); await onSuccess(pseudoResponse); @@ -54,7 +56,8 @@ class MockNetworkClient implements BaseClient { @override Future consumeSseConnections( - String url, OnSuccess onSuccess, OnError onError) async { + String url, OnSuccess onSuccess, OnError onError, + {Map? headers}) async { if (!error) { final pseudoResponse = jsonDecode(MockResponse.successResponse); await onSuccess(pseudoResponse);