Repository: cfug/dio (dart)

Issue:
Conditionally allows relative path for `baseUrl`
### Package

dio

### Version

5.4.0

### Operating-System

Web

### Output of `flutter doctor -v`

_No response_

### Dart Version

3.1.5

### Steps to Reproduce

Run the following snippet with `dart run`:
```dart
void main() {
  final dio = Dio(BaseOptions(baseUrl: 'api/'));
}
```

### Expected Result

A new Dio instance is created with the given `baseUrl` and can be used as usual.
It is a normal situation for web apps to separate their API under a separate endpoint group: `https://example.com/api/...`.

### Actual Result

The code above produces the following Assertion Error:
```
Unhandled exception:
'package:dio/src/options.dart': Failed assertion: line 145 pos 16: 'baseUrl.isEmpty || Uri.parse(baseUrl).host.isNotEmpty': is not true.
#0      _AssertionError._doThrowNew (dart:core-patch/errors_patch.dart:51:61)
#1      _AssertionError._throwNew (dart:core-patch/errors_patch.dart:40:5)
#2      new BaseOptions (package:dio/src/options.dart:145:16)
#3      main (package:anthill_front/src/shared/utils/http_client.dart:8:19)
#4      _delayEntrypointInvocation.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:296:19)
#5      _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:189:12)

Process finished with exit code 255
```

The exception can be avoided if one changes the `baseUrl` in the BaseOptions after it was created:
```dart
final dio = Dio(BaseOptions()..baseUrl = 'api/');
```
Which is quite strange and not expected solution.

Pull request:
Resolves #2087.

### New Pull Request Checklist

- [x] I have read the [Documentation](https://pub.dev/documentation/dio/latest/)
- [x] I have searched for a similar pull request in the [project](https://github.com/cfug/dio/pulls) and found none
- [x] I have updated this branch with the latest `main` branch to avoid conflicts (via merge from master or rebase)
- [x] I have added the required tests to prove the fix/feature I'm adding
- [x] I have updated the documentation (if necessary)
- [x] I have run the tests without failures
- [x] I have updated the `CHANGELOG.md` in the corresponding package

Patch:
diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md
index b4913ed83..3167d7386 100644
--- a/dio/CHANGELOG.md
+++ b/dio/CHANGELOG.md
@@ -9,6 +9,7 @@ See the [Migration Guide][] for the complete breaking changes list.**
 - Fix `receiveTimeout` for streamed responses.
 - Fix cancellation for streamed responses and downloads when using `IOHttpClientAdapter`.
 - Fix receive progress for streamed responses and downloads when using `IOHttpClientAdapter`.
+- Support relative `baseUrl` on the Web platform.
 
 ## 5.4.0
 
diff --git a/dio/lib/src/options.dart b/dio/lib/src/options.dart
index 8a816ae98..aa8d2f96d 100644
--- a/dio/lib/src/options.dart
+++ b/dio/lib/src/options.dart
@@ -92,8 +92,16 @@ typedef RequestEncoder = FutureOr<List<int>> Function(
 
 /// The mixin class for options that provides common attributes.
 mixin OptionsMixin {
-  /// Request base url, it can contain sub paths like: https://pub.dev/api/.
-  late String baseUrl;
+  /// Request base url, it can be multiple forms:
+  ///  - Contains sub paths, e.g. `https://pub.dev/api/`.
+  ///  - Relative path on Web, e.g. `api/`.
+  String get baseUrl => _baseUrl;
+  late String _baseUrl;
+
+  set baseUrl(String value) {
+    assert(value.isEmpty || kIsWeb || Uri.parse(value).host.isNotEmpty);
+    _baseUrl = value;
+  }
 
   /// Common query parameters.
   ///
@@ -141,9 +149,7 @@ class BaseOptions extends _RequestConfig with OptionsMixin {
     RequestEncoder? requestEncoder,
     ResponseDecoder? responseDecoder,
     ListFormat? listFormat,
-  })  : assert(connectTimeout == null || !connectTimeout.isNegative),
-        assert(baseUrl.isEmpty || Uri.parse(baseUrl).host.isNotEmpty),
-        super(
+  }) : super(
           method: method,
           receiveTimeout: receiveTimeout,
           sendTimeout: sendTimeout,
@@ -161,8 +167,8 @@ class BaseOptions extends _RequestConfig with OptionsMixin {
           responseDecoder: responseDecoder,
           listFormat: listFormat,
         ) {
-    this.queryParameters = queryParameters ?? {};
     this.baseUrl = baseUrl;
+    this.queryParameters = queryParameters ?? {};
     this.connectTimeout = connectTimeout;
   }
 
diff --git a/dio/lib/src/utils.dart b/dio/lib/src/utils.dart
index 0073bd7b0..04f7e44c1 100644
--- a/dio/lib/src/utils.dart
+++ b/dio/lib/src/utils.dart
@@ -6,6 +6,11 @@ import 'dart:developer' as dev;
 import 'options.dart';
 import 'parameter.dart';
 
+// See https://github.com/flutter/flutter/pull/112122.
+const kIsWeb = bool.hasEnvironment('dart.library.js_util')
+    ? bool.fromEnvironment('dart.library.js_util')
+    : identical(0, 0.0);
+
 // For the web platform, an inline `bool.fromEnvironment` translates to
 // `core.bool.fromEnvironment` instead of correctly being replaced by the
 // constant value found in the environment at build time.


Test patch:
diff --git a/dio/test/options_test.dart b/dio/test/options_test.dart
index f7d7a8464..159e597ed 100644
--- a/dio/test/options_test.dart
+++ b/dio/test/options_test.dart
@@ -470,18 +470,23 @@ void main() {
   });
 
   test('option invalid base url', () {
-    final opt1 = 'blob:http://localhost/xyz123';
-    final opt2 = 'https://pub.dev';
-    final opt3 = 'https://';
-    final opt4 = 'https://loremipsum/';
-    final opt5 = '';
-    final opt6 = 'pub.dev';
-    expect(Uri.parse(opt1).host.isNotEmpty, false);
-    expect(Uri.parse(opt2).host.isNotEmpty, true);
-    expect(Uri.parse(opt3).host.isNotEmpty, false);
-    expect(Uri.parse(opt4).host.isNotEmpty, true);
-    expect(Uri.parse(opt5).host.isNotEmpty, false);
-    expect(Uri.parse(opt6).host.isNotEmpty, false);
+    final invalidUrls = <String>[
+      'blob:http://localhost/xyz123',
+      'https://',
+      'pub.dev',
+    ];
+    final validUrls = <String>[
+      '',
+      'https://pub.dev',
+      'https://loremipsum/',
+      if (kIsWeb) 'api/',
+    ];
+    for (final url in invalidUrls) {
+      expect(() => BaseOptions(baseUrl: url), throwsA(isA<AssertionError>()));
+    }
+    for (final url in validUrls) {
+      expect(BaseOptions(baseUrl: url), isA<BaseOptions>());
+    }
   });
 
   test('Throws when using invalid methods', () async {