diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 1439d15..2a4b8e1 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -36,19 +36,19 @@ jobs: -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/trycourier/mobile-automation-tests/dispatches \ -d "$(jq -n \ - --arg et 'iOS-Flutter' \ - --arg prnum '${{ github.event.pull_request.number }}' \ - --arg sh '${{ github.event.pull_request.head.sha }}' \ - '{event_type:$et, client_payload:{pr:$prnum, sha:$sh}}')" + --arg et 'iOS-Flutter' \ + --arg prnum '${{ github.event.pull_request.number }}' \ + --arg sh '${{ github.event.pull_request.head.sha }}' \ + '{event_type:$et, client_payload:{pr:$prnum, sha:$sh}}')" curl -sS --fail-with-body -X POST \ -H "Authorization: token ${{ secrets.AUTOMATION_ACCESS_TOKEN }}" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/trycourier/mobile-automation-tests/dispatches \ -d "$(jq -n \ - --arg et 'Android-Flutter' \ - --arg prnum '${{ github.event.pull_request.number }}' \ - --arg sh '${{ github.event.pull_request.head.sha }}' \ - '{event_type:$et, client_payload:{pr:$prnum, sha:$sh}}')" + --arg et 'Android-Flutter' \ + --arg prnum '${{ github.event.pull_request.number }}' \ + --arg sh '${{ github.event.pull_request.head.sha }}' \ + '{event_type:$et, client_payload:{pr:$prnum, sha:$sh}}')" tests-manual: if: github.event_name == 'workflow_dispatch' diff --git a/Docs/1_Authentication.md b/Docs/1_Authentication.md index 2564a42..089f2e8 100644 --- a/Docs/1_Authentication.md +++ b/Docs/1_Authentication.md @@ -93,6 +93,16 @@ final userId = "your_user_id"; await Courier.shared.signIn(userId: userId, accessToken: jwt); ``` +For EU-hosted workspaces, pass the built-in EU endpoint preset: + +```dart +await Courier.shared.signIn( + userId: userId, + accessToken: jwt, + apiUrls: CourierApiUrls.forRegion(CourierApiRegion.eu), +); +``` + If the token is expired, you can generate a new one from your endpoint and call `Courier.shared.signIn(...)` again. You will need to check the token manually for expiration or generate a new one when the user views a specific screen in your app. It is up to you to handle token expiration and refresh based on your security needs. ## 4. Sign your user out diff --git a/Docs/5_Client.md b/Docs/5_Client.md index 0c68639..306be9a 100644 --- a/Docs/5_Client.md +++ b/Docs/5_Client.md @@ -14,6 +14,7 @@ final client = CourierClient( userId: 'user_id', connectionId: 'connection_id', // Optional. Used for inbox websocket tenantId: 'tenant_id', // Optional. Used for scoping a client to a specific tenant + apiUrls: CourierApiUrls.forRegion(CourierApiRegion.eu), // Optional. Use for EU-hosted workspaces showLogs: true, // Optional. Defaults to your current kDebugMode ); diff --git a/README.md b/README.md index 0df32bf..268df97 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,18 @@ Your app must support at least gradle `8.4`   +## EU endpoints + +If your workspace uses EU-hosted Courier endpoints, pass the built-in EU preset through `apiUrls`. + +```dart +await Courier.shared.signIn( + userId: "your_user_id", + accessToken: jwt, + apiUrls: CourierApiUrls.forRegion(CourierApiRegion.eu), +); +``` + # Getting Started These are all the available features of the SDK. diff --git a/android/src/main/kotlin/com/courier/courier_flutter/SharedMethodHandler.kt b/android/src/main/kotlin/com/courier/courier_flutter/SharedMethodHandler.kt index a20685f..b778c0e 100644 --- a/android/src/main/kotlin/com/courier/courier_flutter/SharedMethodHandler.kt +++ b/android/src/main/kotlin/com/courier/courier_flutter/SharedMethodHandler.kt @@ -1,6 +1,7 @@ package com.courier.courier_flutter import com.courier.android.Courier +import com.courier.android.client.CourierClient import com.courier.android.models.CourierAuthenticationListener import com.courier.android.models.CourierInboxListener import com.courier.android.models.remove @@ -63,7 +64,13 @@ internal class SharedMethodHandler(channel: CourierFlutterChannel, private val b "userId" to options.userId, "connectionId" to options.connectionId, "tenantId" to options.tenantId, - "showLogs" to options.showLogs + "showLogs" to options.showLogs, + "apiUrls" to mapOf( + "rest" to options.apiUrls.rest, + "graphql" to options.apiUrls.graphql, + "inboxGraphql" to options.apiUrls.inboxGraphql, + "inboxWebSocket" to options.apiUrls.inboxWebSocket + ) ) result.success(client) @@ -99,12 +106,14 @@ internal class SharedMethodHandler(channel: CourierFlutterChannel, private val b val accessToken = params.extract("accessToken") as String val clientKey = params["clientKey"] as? String val showLogs = params.extract("showLogs") as Boolean + val apiUrls = (params["apiUrls"] as? HashMap<*, *>)?.toApiUrls() Courier.shared.signIn( userId = userId, tenantId = tenantId, accessToken = accessToken, clientKey = clientKey, + apiUrls = apiUrls ?: CourierClient.ApiUrls(), showLogs = showLogs, ) @@ -507,4 +516,4 @@ internal class SharedMethodHandler(channel: CourierFlutterChannel, private val b } -} \ No newline at end of file +} diff --git a/android/src/main/kotlin/com/courier/courier_flutter/Utils.kt b/android/src/main/kotlin/com/courier/courier_flutter/Utils.kt index 06c1542..c424ec0 100644 --- a/android/src/main/kotlin/com/courier/courier_flutter/Utils.kt +++ b/android/src/main/kotlin/com/courier/courier_flutter/Utils.kt @@ -54,6 +54,7 @@ internal fun HashMap<*, *>.toClient(): CourierClient { val clientKey = options["clientKey"] as? String val connectionId = options["connectionId"] as? String val tenantId = options["tenantId"] as? String + val apiUrls = (options["apiUrls"] as? HashMap<*, *>)?.toApiUrls() return CourierClient( jwt = jwt, @@ -61,11 +62,22 @@ internal fun HashMap<*, *>.toClient(): CourierClient { userId = userId, connectionId = connectionId, tenantId = tenantId, + apiUrls = apiUrls ?: CourierClient.ApiUrls(), showLogs = showLogs ) } +internal fun HashMap<*, *>.toApiUrls(): CourierClient.ApiUrls { + val defaults = CourierClient.ApiUrls() + return CourierClient.ApiUrls( + rest = this["rest"] as? String ?: defaults.rest, + graphql = this["graphql"] as? String ?: defaults.graphql, + inboxGraphql = this["inboxGraphql"] as? String ?: defaults.inboxGraphql, + inboxWebSocket = this["inboxWebSocket"] as? String ?: defaults.inboxWebSocket + ) +} + // Stringify internal fun Any.toJson(): String { @@ -76,4 +88,4 @@ internal fun Any.toJson(): String { internal inline fun Map<*, *>.extract(key: String): T { return this[key] as? T ?: throw MissingParameter(key) -} \ No newline at end of file +} diff --git a/ios/Classes/CourierSharedMethodHandler.swift b/ios/Classes/CourierSharedMethodHandler.swift index e815c3c..b317c8a 100644 --- a/ios/Classes/CourierSharedMethodHandler.swift +++ b/ios/Classes/CourierSharedMethodHandler.swift @@ -48,7 +48,13 @@ internal class CourierSharedMethodHandler: CourierFlutterMethodHandler, FlutterP "userId": options.userId, "connectionId": options.connectionId, "tenantId": options.tenantId, - "showLogs": options.showLogs + "showLogs": options.showLogs, + "apiUrls": [ + "rest": options.apiUrls.rest, + "graphql": options.apiUrls.graphql, + "inboxGraphql": options.apiUrls.inboxGraphql, + "inboxWebSocket": options.apiUrls.inboxWebSocket + ] ] result(dict.compactMapValues { $0 }) @@ -75,12 +81,14 @@ internal class CourierSharedMethodHandler: CourierFlutterMethodHandler, FlutterP let accessToken: String = try params.extract("accessToken") let clientKey = params["clientKey"] as? String let showLogs: Bool = try params.extract("showLogs") + let apiUrls = (params["apiUrls"] as? [String: Any])?.toCourierApiUrls() await Courier.shared.signIn( userId: userId, tenantId: tenantId, accessToken: accessToken, clientKey: clientKey, + baseUrls: apiUrls ?? CourierClient.ApiUrls(), showLogs: showLogs ) diff --git a/ios/Classes/Utils.swift b/ios/Classes/Utils.swift index 468bb1c..cbc62c1 100644 --- a/ios/Classes/Utils.swift +++ b/ios/Classes/Utils.swift @@ -124,6 +124,7 @@ internal extension Dictionary { let clientKey = options["clientKey"] as? String let connectionId = options["connectionId"] as? String let tenantId = options["tenantId"] as? String + let apiUrls = (options["apiUrls"] as? [String: Any])?.toCourierApiUrls() return CourierClient( jwt: jwt, @@ -131,6 +132,7 @@ internal extension Dictionary { userId: userId, connectionId: connectionId, tenantId: tenantId, + baseUrls: apiUrls ?? CourierClient.ApiUrls(), showLogs: showLogs ) @@ -145,6 +147,20 @@ internal extension Dictionary { } +internal extension Dictionary where Key == String, Value == Any { + + func toCourierApiUrls() -> CourierClient.ApiUrls { + let defaults = CourierClient.ApiUrls() + return CourierClient.ApiUrls( + rest: self["rest"] as? String ?? defaults.rest, + graphql: self["graphql"] as? String ?? defaults.graphql, + inboxGraphql: self["inboxGraphql"] as? String ?? defaults.inboxGraphql, + inboxWebSocket: self["inboxWebSocket"] as? String ?? defaults.inboxWebSocket + ) + } + +} + internal extension Error { func toFlutterError() -> FlutterError { diff --git a/lib/client/courier_api_urls.dart b/lib/client/courier_api_urls.dart new file mode 100644 index 0000000..66e083c --- /dev/null +++ b/lib/client/courier_api_urls.dart @@ -0,0 +1,53 @@ +enum CourierApiRegion { us, eu } + +class CourierApiUrls { + final String rest; + final String graphql; + final String inboxGraphql; + final String inboxWebSocket; + + const CourierApiUrls({ + this.rest = 'https://api.courier.com', + this.graphql = 'https://api.courier.com/client/q', + this.inboxGraphql = 'https://inbox.courier.io/q', + this.inboxWebSocket = 'wss://realtime.courier.io', + }); + + const CourierApiUrls.us() + : rest = 'https://api.courier.com', + graphql = 'https://api.courier.com/client/q', + inboxGraphql = 'https://inbox.courier.io/q', + inboxWebSocket = 'wss://realtime.courier.io'; + + const CourierApiUrls.eu() + : rest = 'https://api.eu.courier.com', + graphql = 'https://api.eu.courier.com/client/q', + inboxGraphql = 'https://inbox.eu.courier.io/q', + inboxWebSocket = 'wss://realtime.eu.courier.io'; + + factory CourierApiUrls.forRegion(CourierApiRegion region) { + return region == CourierApiRegion.eu + ? const CourierApiUrls.eu() + : const CourierApiUrls.us(); + } + + factory CourierApiUrls.fromJson(Map json) { + return CourierApiUrls( + rest: json['rest'] as String? ?? const CourierApiUrls().rest, + graphql: json['graphql'] as String? ?? const CourierApiUrls().graphql, + inboxGraphql: + json['inboxGraphql'] as String? ?? const CourierApiUrls().inboxGraphql, + inboxWebSocket: json['inboxWebSocket'] as String? ?? + const CourierApiUrls().inboxWebSocket, + ); + } + + Map toJson() { + return { + 'rest': rest, + 'graphql': graphql, + 'inboxGraphql': inboxGraphql, + 'inboxWebSocket': inboxWebSocket, + }; + } +} diff --git a/lib/client/courier_client.dart b/lib/client/courier_client.dart index 70e2507..2dc59ca 100644 --- a/lib/client/courier_client.dart +++ b/lib/client/courier_client.dart @@ -1,5 +1,6 @@ import 'package:courier_flutter/courier_flutter_channels.dart'; import 'package:courier_flutter/client/brand_client.dart'; +import 'package:courier_flutter/client/courier_api_urls.dart'; import 'package:courier_flutter/client/inbox_client.dart'; import 'package:courier_flutter/client/preference_client.dart'; import 'package:courier_flutter/client/token_client.dart'; @@ -16,6 +17,7 @@ class CourierClientOptions { final String? connectionId; final String? tenantId; final bool showLogs; + final CourierApiUrls? apiUrls; CourierClientOptions({ required this.id, @@ -25,6 +27,7 @@ class CourierClientOptions { this.connectionId, this.tenantId, required this.showLogs, + this.apiUrls, }); Map toJson() { @@ -35,6 +38,7 @@ class CourierClientOptions { 'connectionId': connectionId, 'tenantId': tenantId, 'showLogs': showLogs, + 'apiUrls': apiUrls?.toJson(), }; } @@ -88,6 +92,7 @@ class CourierClient { required String userId, String? connectionId, String? tenantId, + CourierApiUrls? apiUrls, bool? showLogs, }) : options = CourierClientOptions( id: const Uuid().v4(), @@ -97,6 +102,7 @@ class CourierClient { connectionId: connectionId, tenantId: tenantId, showLogs: showLogs ?? kDebugMode, + apiUrls: apiUrls, ); Future add() async { diff --git a/lib/courier_flutter.dart b/lib/courier_flutter.dart index 8a4036d..c3b24f1 100644 --- a/lib/courier_flutter.dart +++ b/lib/courier_flutter.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:courier_flutter/courier_flutter_channels.dart'; +import 'package:courier_flutter/client/courier_api_urls.dart'; import 'package:courier_flutter/client/courier_client.dart'; import 'package:courier_flutter/courier_provider.dart'; import 'package:courier_flutter/ios_foreground_notification_presentation_options.dart'; @@ -18,6 +19,7 @@ import 'package:uuid/uuid.dart'; export 'models/inbox_message.dart'; export 'models/inbox_action.dart'; export 'ios_foreground_notification_presentation_options.dart'; +export 'client/courier_api_urls.dart'; class Courier extends CourierChannelManager { @@ -204,6 +206,9 @@ class Courier extends CourierChannelManager { userId: options['userId'], tenantId: options['tenantId'], connectionId: options['connectionId'], + apiUrls: options['apiUrls'] == null + ? null + : CourierApiUrls.fromJson(options['apiUrls']), showLogs: options['showLogs'], ); } @@ -232,6 +237,7 @@ class Courier extends CourierChannelManager { required String accessToken, String? clientKey, String? tenantId, + CourierApiUrls? apiUrls, bool? showLogs }) async { _isDebugging = showLogs ?? kDebugMode; @@ -241,6 +247,7 @@ class Courier extends CourierChannelManager { 'accessToken': accessToken, 'clientKey': clientKey, 'showLogs': _isDebugging, + 'apiUrls': apiUrls?.toJson(), }); } @@ -521,7 +528,7 @@ abstract class CourierChannelManager extends PlatformInterface { throw UnimplementedError('signOut() has not been implemented.'); } - Future signIn({ required String userId, required String accessToken, String? clientKey, String? tenantId, bool? showLogs }) async { + Future signIn({ required String userId, required String accessToken, String? clientKey, String? tenantId, CourierApiUrls? apiUrls, bool? showLogs }) async { throw UnimplementedError('signIn() has not been implemented.'); } @@ -640,4 +647,4 @@ abstract class CourierChannelManager extends PlatformInterface { bool isUITestsActive = false; -} \ No newline at end of file +} diff --git a/test/courier_api_urls_test.dart b/test/courier_api_urls_test.dart new file mode 100644 index 0000000..65fe4b8 --- /dev/null +++ b/test/courier_api_urls_test.dart @@ -0,0 +1,66 @@ +import 'package:courier_flutter/client/courier_api_urls.dart'; +import 'package:courier_flutter/client/courier_client.dart'; +import 'package:courier_flutter/courier_flutter.dart'; +import 'package:courier_flutter/courier_flutter_channels.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('CourierApiUrls', () { + test('returns the EU preset', () { + final apiUrls = CourierApiUrls.forRegion(CourierApiRegion.eu); + + expect(apiUrls.rest, 'https://api.eu.courier.com'); + expect(apiUrls.graphql, 'https://api.eu.courier.com/client/q'); + expect(apiUrls.inboxGraphql, 'https://inbox.eu.courier.io/q'); + expect(apiUrls.inboxWebSocket, 'wss://realtime.eu.courier.io'); + }); + + test('serializes apiUrls into client options', () { + final client = CourierClient( + userId: 'user-123', + apiUrls: const CourierApiUrls.eu(), + showLogs: true, + ); + + expect(client.options.toJson()['apiUrls'], { + 'rest': 'https://api.eu.courier.com', + 'graphql': 'https://api.eu.courier.com/client/q', + 'inboxGraphql': 'https://inbox.eu.courier.io/q', + 'inboxWebSocket': 'wss://realtime.eu.courier.io', + }); + }); + + test('passes apiUrls through shared signIn', () async { + MethodCall? lastCall; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + CourierFlutterChannels.shared, + (call) async { + lastCall = call; + return null; + }, + ); + + await Courier.shared.signIn( + userId: 'user-123', + accessToken: 'jwt', + apiUrls: const CourierApiUrls.eu(), + ); + + expect(lastCall?.method, 'auth.sign_in'); + expect((lastCall?.arguments as Map)['apiUrls'], { + 'rest': 'https://api.eu.courier.com', + 'graphql': 'https://api.eu.courier.com/client/q', + 'inboxGraphql': 'https://inbox.eu.courier.io/q', + 'inboxWebSocket': 'wss://realtime.eu.courier.io', + }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(CourierFlutterChannels.shared, null); + }); + }); +}