Add `BaseResponseWithUrl.url` (#1109)

diff --git a/pkgs/http/CHANGELOG.md b/pkgs/http/CHANGELOG.md
index 90eb36a..9421143 100644
--- a/pkgs/http/CHANGELOG.md
+++ b/pkgs/http/CHANGELOG.md
@@ -1,6 +1,7 @@
-## 1.2.0-wip
+## 1.2.0
 
 * Add `MockClient.pngResponse`, which makes it easier to fake image responses.
+* Added the ability to fetch the URL of the response through `BaseResponseWithUrl`.
 * Add the ability to get headers as a `Map<String, List<String>` to
   `BaseResponse`.
 
diff --git a/pkgs/http/lib/http.dart b/pkgs/http/lib/http.dart
index bd039c8..da35b23 100644
--- a/pkgs/http/lib/http.dart
+++ b/pkgs/http/lib/http.dart
@@ -16,7 +16,8 @@
 
 export 'src/base_client.dart';
 export 'src/base_request.dart';
-export 'src/base_response.dart' show BaseResponse, HeadersWithSplitValues;
+export 'src/base_response.dart'
+    show BaseResponse, BaseResponseWithUrl, HeadersWithSplitValues;
 export 'src/byte_stream.dart';
 export 'src/client.dart' hide zoneClient;
 export 'src/exception.dart';
@@ -25,7 +26,7 @@
 export 'src/request.dart';
 export 'src/response.dart';
 export 'src/streamed_request.dart';
-export 'src/streamed_response.dart';
+export 'src/streamed_response.dart' show StreamedResponse;
 
 /// Sends an HTTP HEAD request with the given headers to the given URL.
 ///
diff --git a/pkgs/http/lib/src/base_request.dart b/pkgs/http/lib/src/base_request.dart
index 70a7869..4b165c7 100644
--- a/pkgs/http/lib/src/base_request.dart
+++ b/pkgs/http/lib/src/base_request.dart
@@ -132,13 +132,25 @@
     try {
       var response = await client.send(this);
       var stream = onDone(response.stream, client.close);
-      return StreamedResponse(ByteStream(stream), response.statusCode,
-          contentLength: response.contentLength,
-          request: response.request,
-          headers: response.headers,
-          isRedirect: response.isRedirect,
-          persistentConnection: response.persistentConnection,
-          reasonPhrase: response.reasonPhrase);
+
+      if (response case BaseResponseWithUrl(:final url)) {
+        return StreamedResponseV2(ByteStream(stream), response.statusCode,
+            contentLength: response.contentLength,
+            request: response.request,
+            headers: response.headers,
+            isRedirect: response.isRedirect,
+            url: url,
+            persistentConnection: response.persistentConnection,
+            reasonPhrase: response.reasonPhrase);
+      } else {
+        return StreamedResponse(ByteStream(stream), response.statusCode,
+            contentLength: response.contentLength,
+            request: response.request,
+            headers: response.headers,
+            isRedirect: response.isRedirect,
+            persistentConnection: response.persistentConnection,
+            reasonPhrase: response.reasonPhrase);
+      }
     } catch (_) {
       client.close();
       rethrow;
diff --git a/pkgs/http/lib/src/base_response.dart b/pkgs/http/lib/src/base_response.dart
index e1796e1..0527461 100644
--- a/pkgs/http/lib/src/base_response.dart
+++ b/pkgs/http/lib/src/base_response.dart
@@ -4,6 +4,9 @@
 
 import 'base_client.dart';
 import 'base_request.dart';
+import 'client.dart';
+import 'response.dart';
+import 'streamed_response.dart';
 
 /// The base class for HTTP responses.
 ///
@@ -71,6 +74,37 @@
   }
 }
 
+/// A [BaseResponse] with a [url] field.
+///
+/// [Client] methods that return a [BaseResponse] subclass, such as [Response]
+/// or [StreamedResponse], **may** return a [BaseResponseWithUrl].
+///
+/// For example:
+///
+/// ```dart
+/// final client = Client();
+/// final response = client.get(Uri.https('example.com', '/'));
+/// Uri? finalUri;
+/// if (response case BaseResponseWithUrl(:final url)) {
+///   finalUri = url;
+/// }
+/// // Do something with `finalUri`.
+/// client.close();
+/// ```
+///
+/// [url] will be added to [BaseResponse] when `package:http` version 2 is
+/// released and this mixin will be deprecated.
+abstract interface class BaseResponseWithUrl implements BaseResponse {
+  /// The [Uri] of the response returned by the server.
+  ///
+  /// If no redirects were followed, [url] will be the same as the requested
+  /// [Uri].
+  ///
+  /// If redirects were followed, [url] will be the [Uri] of the last redirect
+  /// that was followed.
+  abstract final Uri url;
+}
+
 /// "token" as defined in RFC 2616, 2.2
 /// See https://datatracker.ietf.org/doc/html/rfc2616#section-2.2
 const _tokenChars = r"!#$%&'*+\-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ^_`"
diff --git a/pkgs/http/lib/src/browser_client.dart b/pkgs/http/lib/src/browser_client.dart
index 80db8b1..cbbada6 100644
--- a/pkgs/http/lib/src/browser_client.dart
+++ b/pkgs/http/lib/src/browser_client.dart
@@ -79,10 +79,13 @@
         return;
       }
       var body = (xhr.response as JSArrayBuffer).toDart.asUint8List();
-      completer.complete(StreamedResponse(
+      var responseUrl = xhr.responseURL;
+      var url = responseUrl.isNotEmpty ? Uri.parse(responseUrl) : request.url;
+      completer.complete(StreamedResponseV2(
           ByteStream.fromBytes(body), xhr.status,
           contentLength: body.length,
           request: request,
+          url: url,
           headers: xhr.responseHeaders,
           reasonPhrase: xhr.statusText));
     }));
diff --git a/pkgs/http/lib/src/io_client.dart b/pkgs/http/lib/src/io_client.dart
index db66b02..fe4834b 100644
--- a/pkgs/http/lib/src/io_client.dart
+++ b/pkgs/http/lib/src/io_client.dart
@@ -6,6 +6,7 @@
 
 import 'base_client.dart';
 import 'base_request.dart';
+import 'base_response.dart';
 import 'client.dart';
 import 'exception.dart';
 import 'io_streamed_response.dart';
@@ -46,6 +47,22 @@
   String toString() => 'ClientException with $cause, uri=$uri';
 }
 
+class _IOStreamedResponseV2 extends IOStreamedResponse
+    implements BaseResponseWithUrl {
+  @override
+  final Uri url;
+
+  _IOStreamedResponseV2(super.stream, super.statusCode,
+      {required this.url,
+      super.contentLength,
+      super.request,
+      super.headers,
+      super.isRedirect,
+      super.persistentConnection,
+      super.reasonPhrase,
+      super.inner});
+}
+
 /// A `dart:io`-based HTTP [Client].
 ///
 /// If there is a socket-level failure when communicating with the server
@@ -116,7 +133,7 @@
         headers[key] = values.map((value) => value.trimRight()).join(',');
       });
 
-      return IOStreamedResponse(
+      return _IOStreamedResponseV2(
           response.handleError((Object error) {
             final httpException = error as HttpException;
             throw ClientException(httpException.message, httpException.uri);
@@ -127,6 +144,9 @@
           request: request,
           headers: headers,
           isRedirect: response.isRedirect,
+          url: response.redirects.isNotEmpty
+              ? response.redirects.last.location
+              : request.url,
           persistentConnection: response.persistentConnection,
           reasonPhrase: response.reasonPhrase,
           inner: response);
diff --git a/pkgs/http/lib/src/streamed_response.dart b/pkgs/http/lib/src/streamed_response.dart
index 8cc0c76..44389d7 100644
--- a/pkgs/http/lib/src/streamed_response.dart
+++ b/pkgs/http/lib/src/streamed_response.dart
@@ -26,3 +26,20 @@
       super.reasonPhrase})
       : stream = toByteStream(stream);
 }
+
+/// This class is private to `package:http` and will be removed when
+/// `package:http` v2 is released.
+class StreamedResponseV2 extends StreamedResponse
+    implements BaseResponseWithUrl {
+  @override
+  final Uri url;
+
+  StreamedResponseV2(super.stream, super.statusCode,
+      {required this.url,
+      super.contentLength,
+      super.request,
+      super.headers,
+      super.isRedirect,
+      super.persistentConnection,
+      super.reasonPhrase});
+}
diff --git a/pkgs/http/pubspec.yaml b/pkgs/http/pubspec.yaml
index 31746fc..a531a63 100644
--- a/pkgs/http/pubspec.yaml
+++ b/pkgs/http/pubspec.yaml
@@ -1,5 +1,5 @@
 name: http
-version: 1.2.0-wip
+version: 1.2.0
 description: A composable, multi-platform, Future-based API for HTTP requests.
 repository: https://github.com/dart-lang/http/tree/master/pkgs/http
 
diff --git a/pkgs/http/test/io/request_test.dart b/pkgs/http/test/io/request_test.dart
index ac6b44c..226781f 100644
--- a/pkgs/http/test/io/request_test.dart
+++ b/pkgs/http/test/io/request_test.dart
@@ -46,15 +46,22 @@
     final response = await request.send();
 
     expect(response.statusCode, equals(302));
+    expect(
+        response,
+        isA<http.BaseResponseWithUrl>()
+            .having((r) => r.url, 'url', serverUrl.resolve('/redirect')));
   });
 
   test('with redirects', () async {
     final request = http.Request('GET', serverUrl.resolve('/redirect'));
     final response = await request.send();
-
     expect(response.statusCode, equals(200));
     final bytesString = await response.stream.bytesToString();
     expect(bytesString, parse(containsPair('path', '/')));
+    expect(
+        response,
+        isA<http.BaseResponseWithUrl>()
+            .having((r) => r.url, 'url', serverUrl.resolve('/')));
   });
 
   test('exceeding max redirects', () async {