From 08c7b09610bdf99ed51e3c1aafc5da16435a4ff4 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:30:53 +0800 Subject: [PATCH 1/4] refactor(fetch): align request and response init semantics with web --- CHANGELOG.md | 7 ++ README.md | 12 ++- example/main.dart | 17 ++-- lib/src/fetch/request.dart | 113 ++++++++++++------------ lib/src/fetch/response.dart | 140 ++++++++++++------------------ test/public_api_surface_test.dart | 11 ++- test/request_response_test.dart | 67 +++++++------- 7 files changed, 174 insertions(+), 193 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd9b86f..bc25862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## Next + +- BREAKING: Aligned `Request` and `Response` constructor/factory parameter + semantics with Fetch/Web by introducing `RequestInit` and `ResponseInit`. +- BREAKING: Reworked request/response convenience constructors to use + web-aligned positional body/init argument order. + ## 0.2.0 - BREAKING: Reworked `Blob` to a `block`-backed implementation and removed diff --git a/README.md b/README.md index 0fe91fd..97e47c0 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This package focuses on the **type and semantics layer** only. It does not imple ## Features -- Fetch-style primitives: `Request`, `Response`, `Headers`, `URLSearchParams`, `Blob`, `File`, `FormData` +- Fetch-style primitives: `Request`, `RequestInit`, `Response`, `ResponseInit`, `Headers`, `URLSearchParams`, `Blob`, `File`, `FormData` - Protocol helpers: `HttpMethod`, `HttpStatus`, `HttpVersion`, `MimeType` - Consistent body-read semantics (single-consume), clone semantics, and header normalization - Designed as a shared HTTP type layer for downstream client/server frameworks @@ -42,7 +42,7 @@ The goal is to provide stable and reusable HTTP types and behavior contracts. | Category | Types | | --- | --- | | Protocol | `HttpMethod`, `HttpStatus`, `HttpVersion`, `MimeType` | -| Message | `Request`, `Response`, `BodyMixin`, `BodyInit` | +| Message | `Request`, `RequestInit`, `Response`, `ResponseInit`, `BodyMixin`, `BodyInit` | | Header/URL | `Headers`, `URLSearchParams` | | Binary/Form | `Blob`, `File`, `FormData` | @@ -54,13 +54,12 @@ import 'package:ht/ht.dart'; Future main() async { final request = Request.json( Uri.parse('https://api.example.com/tasks'), - method: HttpMethod.post.value, - body: {'title': 'rewrite ht'}, + {'title': 'rewrite ht'}, ); final response = Response.json( {'ok': true}, - status: HttpStatus.created, + ResponseInit(status: HttpStatus.created), ); print(request.method); // POST @@ -109,8 +108,7 @@ Future main() async { final body = block.Block(['hello'], type: 'text/plain'); final request = Request( Uri.parse('https://example.com'), - method: 'POST', - body: body, + RequestInit(method: 'POST', body: body), ); print(request.headers.get('content-type')); // text/plain diff --git a/example/main.dart b/example/main.dart index 5f3693f..a74376f 100644 --- a/example/main.dart +++ b/example/main.dart @@ -3,20 +3,21 @@ import 'dart:convert'; import 'package:ht/ht.dart'; Future main() async { - final request = Request.json( - Uri.parse('https://api.example.com/tasks'), - method: HttpMethod.post.value, - body: {'title': 'Ship ht', 'priority': 'high'}, - ); + final request = Request.json(Uri.parse('https://api.example.com/tasks'), { + 'title': 'Ship ht', + 'priority': 'high', + }); print('Request: ${request.method} ${request.url}'); print('Request content-type: ${request.headers.get('content-type')}'); print('Request body: ${await request.text()}'); final response = Response( - status: HttpStatus.created, - body: jsonEncode({'ok': true, 'id': 'task_123'}), - headers: Headers({'content-type': MimeType.json.toString()}), + jsonEncode({'ok': true, 'id': 'task_123'}), + ResponseInit( + status: HttpStatus.created, + headers: Headers({'content-type': MimeType.json.toString()}), + ), ); print('Response status: ${response.status} ${response.statusText}'); diff --git a/lib/src/fetch/request.dart b/lib/src/fetch/request.dart index 8245166..57e2e06 100644 --- a/lib/src/fetch/request.dart +++ b/lib/src/fetch/request.dart @@ -5,12 +5,37 @@ import 'form_data.dart'; import 'headers.dart'; import 'url_search_params.dart'; +/// Initialization options for [Request], aligned with Fetch `RequestInit`. +final class RequestInit { + RequestInit({this.method, Headers? headers, this.body}) + : headers = headers?.clone(); + + final String? method; + final Headers? headers; + final Object? body; + + RequestInit copyWith({ + String? method, + Headers? headers, + Object? body = _sentinel, + }) { + final hasBody = !identical(body, _sentinel); + return RequestInit( + method: method ?? this.method, + headers: headers ?? this.headers?.clone(), + body: hasBody ? body : this.body, + ); + } + + static const Object _sentinel = Object(); +} + /// Fetch-like HTTP request model. class Request with BodyMixin { - Request(this.url, {String method = 'GET', Headers? headers, Object? body}) - : method = _normalizeMethod(method), - headers = headers?.clone() ?? Headers(), - bodyData = BodyData.fromInit(body) { + Request(this.url, [RequestInit? init]) + : method = _normalizeMethod(init?.method ?? 'GET'), + headers = init?.headers?.clone() ?? Headers(), + bodyData = BodyData.fromInit(init?.body) { _validateMethodAndBody(); _applyDefaultBodyHeaders(); } @@ -22,68 +47,38 @@ class Request with BodyMixin { required this.bodyData, }); - factory Request.text( - Uri url, { - String method = 'POST', - Headers? headers, - required String body, - }) { - return Request(url, method: method, headers: headers, body: body); + factory Request.text(Uri url, String body, [RequestInit? init]) { + return Request(url, _coerceInit(init, body: body)); } - factory Request.json( - Uri url, { - String method = 'POST', - Headers? headers, - required Object? body, - }) { - final nextHeaders = headers?.clone() ?? Headers(); + factory Request.json(Uri url, Object? body, [RequestInit? init]) { + final nextInit = _coerceInit(init, body: json.encode(body)); + final nextHeaders = nextInit.headers?.clone() ?? Headers(); if (!nextHeaders.has('content-type')) { nextHeaders.set('content-type', 'application/json; charset=utf-8'); } - return Request( - url, - method: method, - headers: nextHeaders, - body: json.encode(body), - ); + return Request(url, nextInit.copyWith(headers: nextHeaders)); } - factory Request.bytes( - Uri url, { - String method = 'POST', - Headers? headers, - required List body, - }) { - return Request(url, method: method, headers: headers, body: body); + factory Request.bytes(Uri url, List body, [RequestInit? init]) { + return Request(url, _coerceInit(init, body: body)); } - factory Request.stream( - Uri url, { - String method = 'POST', - Headers? headers, - required Stream> body, - }) { - return Request(url, method: method, headers: headers, body: body); + factory Request.stream(Uri url, Stream> body, [RequestInit? init]) { + return Request(url, _coerceInit(init, body: body)); } factory Request.searchParams( - Uri url, { - String method = 'POST', - Headers? headers, - required URLSearchParams body, - }) { - return Request(url, method: method, headers: headers, body: body); + Uri url, + URLSearchParams body, [ + RequestInit? init, + ]) { + return Request(url, _coerceInit(init, body: body)); } - factory Request.formData( - Uri url, { - String method = 'POST', - Headers? headers, - required FormData body, - }) { - return Request(url, method: method, headers: headers, body: body); + factory Request.formData(Uri url, FormData body, [RequestInit? init]) { + return Request(url, _coerceInit(init, body: body)); } /// Target URL. @@ -119,14 +114,24 @@ class Request with BodyMixin { final hasBody = !identical(body, _sentinel); return Request( url ?? this.url, - method: method ?? this.method, - headers: headers ?? this.headers.clone(), - body: hasBody ? body : bodyData.clone(), + RequestInit( + method: method ?? this.method, + headers: headers ?? this.headers.clone(), + body: hasBody ? body : bodyData.clone(), + ), ); } static const Object _sentinel = Object(); + static RequestInit _coerceInit(RequestInit? init, {required Object? body}) { + return RequestInit( + method: init?.method ?? 'POST', + headers: init?.headers?.clone(), + body: body, + ); + } + static String _normalizeMethod(String value) { final normalized = value.trim().toUpperCase(); if (normalized.isEmpty) { diff --git a/lib/src/fetch/response.dart b/lib/src/fetch/response.dart index fa44d0d..0d79aba 100644 --- a/lib/src/fetch/response.dart +++ b/lib/src/fetch/response.dart @@ -4,18 +4,39 @@ import '../core/http_status.dart'; import 'body.dart'; import 'headers.dart'; +/// Initialization options for [Response], aligned with Fetch `ResponseInit`. +final class ResponseInit { + ResponseInit({this.status, this.statusText, Headers? headers}) + : headers = headers?.clone(); + + final int? status; + final String? statusText; + final Headers? headers; + + ResponseInit copyWith({int? status, String? statusText, Headers? headers}) { + return ResponseInit( + status: status ?? this.status, + statusText: statusText ?? this.statusText, + headers: headers ?? this.headers?.clone(), + ); + } +} + /// Fetch-like HTTP response model. class Response with BodyMixin { - Response({ + Response([Object? body, ResponseInit? init]) + : this._create(body, init, url: null, redirected: false); + + Response._create( Object? body, - int status = HttpStatus.ok, - String? statusText, - Headers? headers, - this.url, - this.redirected = false, - }) : status = _validateStatus(status), - statusText = statusText ?? HttpStatus.reasonPhrase(status), - headers = headers?.clone() ?? Headers(), + ResponseInit? init, { + required this.url, + required this.redirected, + }) : status = _validateStatus(init?.status ?? HttpStatus.ok), + statusText = + init?.statusText ?? + HttpStatus.reasonPhrase(init?.status ?? HttpStatus.ok), + headers = init?.headers?.clone() ?? Headers(), bodyData = BodyData.fromInit(body) { _applyDefaultBodyHeaders(); } @@ -29,70 +50,27 @@ class Response with BodyMixin { required this.redirected, }); - factory Response.text( - String body, { - int status = HttpStatus.ok, - String? statusText, - Headers? headers, - Uri? url, - bool redirected = false, - }) { - return Response( - body: body, - status: status, - statusText: statusText, - headers: headers, - url: url, - redirected: redirected, - ); + factory Response.text(String body, [ResponseInit? init]) { + return Response(body, init); } - factory Response.json( - Object? body, { - int status = HttpStatus.ok, - String? statusText, - Headers? headers, - Uri? url, - bool redirected = false, - }) { - final nextHeaders = headers?.clone() ?? Headers(); + factory Response.json(Object? body, [ResponseInit? init]) { + final nextHeaders = init?.headers?.clone() ?? Headers(); if (!nextHeaders.has('content-type')) { nextHeaders.set('content-type', 'application/json; charset=utf-8'); } return Response( - body: json.encode(body), - status: status, - statusText: statusText, - headers: nextHeaders, - url: url, - redirected: redirected, + json.encode(body), + (init ?? ResponseInit()).copyWith(headers: nextHeaders), ); } - factory Response.bytes( - List body, { - int status = HttpStatus.ok, - String? statusText, - Headers? headers, - Uri? url, - bool redirected = false, - }) { - return Response( - body: body, - status: status, - statusText: statusText, - headers: headers, - url: url, - redirected: redirected, - ); + factory Response.bytes(List body, [ResponseInit? init]) { + return Response(body, init); } - factory Response.redirect( - Uri location, { - int status = HttpStatus.found, - Headers? headers, - }) { + factory Response.redirect(Uri location, [int status = HttpStatus.found]) { if (!const {301, 302, 303, 307, 308}.contains(status)) { throw ArgumentError.value( status, @@ -101,30 +79,24 @@ class Response with BodyMixin { ); } - final nextHeaders = (headers?.clone() ?? Headers()) - ..set('location', location.toString()); + final nextHeaders = Headers()..set('location', location.toString()); - return Response( - status: status, - headers: nextHeaders, + return Response._create( + null, + ResponseInit(status: status, headers: nextHeaders), url: location, redirected: true, ); } - factory Response.empty({ - int status = HttpStatus.noContent, - String? statusText, - Headers? headers, - Uri? url, - bool redirected = false, - }) { + factory Response.empty([ResponseInit? init]) { return Response( - status: status, - statusText: statusText, - headers: headers, - url: url, - redirected: redirected, + null, + ResponseInit( + status: init?.status ?? HttpStatus.noContent, + statusText: init?.statusText, + headers: init?.headers, + ), ); } @@ -163,11 +135,13 @@ class Response with BodyMixin { }) { final hasBody = !identical(body, _sentinel); - return Response( - body: hasBody ? body : bodyData.clone(), - status: status ?? this.status, - statusText: statusText ?? this.statusText, - headers: headers ?? this.headers.clone(), + return Response._create( + hasBody ? body : bodyData.clone(), + ResponseInit( + status: status ?? this.status, + statusText: statusText ?? this.statusText, + headers: headers ?? this.headers.clone(), + ), url: url ?? this.url, redirected: redirected ?? this.redirected, ); diff --git a/test/public_api_surface_test.dart b/test/public_api_surface_test.dart index 1b123f3..2cb2e3b 100644 --- a/test/public_api_surface_test.dart +++ b/test/public_api_surface_test.dart @@ -8,6 +8,8 @@ void main() { const status = HttpStatus.ok; const version = HttpVersion.http11; final mime = MimeType.json; + final requestInit = RequestInit(method: method.value); + final responseInit = ResponseInit(status: status); final headers = Headers({'content-type': mime.toString()}); final params = URLSearchParams('a=1'); @@ -19,12 +21,11 @@ void main() { final request = Request.formData( Uri.parse('https://example.com/upload'), - method: method.value, - headers: headers, - body: form, + form, + requestInit.copyWith(headers: headers), ); - final response = Response(body: blockBody, status: status); + final response = Response(blockBody, responseInit); final BodyInit init = 'x'; @@ -34,6 +35,8 @@ void main() { expect(params.get('a'), '1'); expect(await blob.text(), 'hello'); expect(file.name, 'hello.txt'); + expect(requestInit.method, 'POST'); + expect(responseInit.status, 200); expect(request.headers.has('content-type'), isTrue); expect(await multipart.bytes(), isNotEmpty); expect(await response.text(), 'block-body'); diff --git a/test/request_response_test.dart b/test/request_response_test.dart index 1a76788..7004dd7 100644 --- a/test/request_response_test.dart +++ b/test/request_response_test.dart @@ -7,10 +7,7 @@ import 'package:test/test.dart'; void main() { group('Request', () { test('json request infers headers', () { - final request = Request.json( - Uri.parse('https://example.com'), - body: {'x': 1}, - ); + final request = Request.json(Uri.parse('https://example.com'), {'x': 1}); expect(request.method, 'POST'); expect( @@ -27,7 +24,7 @@ void main() { final request = Request.searchParams( Uri.parse('https://example.com'), - body: params, + params, ); expect( @@ -40,10 +37,7 @@ void main() { test('form-data request infers multipart headers', () async { final form = FormData()..append('name', 'alice'); - final request = Request.formData( - Uri.parse('https://example.com'), - body: form, - ); + final request = Request.formData(Uri.parse('https://example.com'), form); expect( request.headers.get('content-type'), @@ -57,8 +51,7 @@ void main() { final body = block.Block(['hello'], type: 'text/custom'); final request = Request( Uri.parse('https://example.com'), - method: 'POST', - body: body, + RequestInit(method: 'POST', body: body), ); expect(request.headers.get('content-type'), 'text/custom'); @@ -68,8 +61,10 @@ void main() { test('cannot attach body to GET/HEAD/TRACE', () { expect( - () => - Request(Uri.parse('https://example.com'), method: 'GET', body: 'x'), + () => Request( + Uri.parse('https://example.com'), + RequestInit(method: 'GET', body: 'x'), + ), throwsArgumentError, ); }); @@ -77,7 +72,7 @@ void main() { test('clone duplicates unread stream body', () async { final request = Request.stream( Uri.parse('https://example.com'), - body: Stream>.fromIterable(>[ + Stream>.fromIterable(>[ utf8.encode('hello '), utf8.encode('world'), ]), @@ -91,7 +86,7 @@ void main() { test('body stream marks bodyUsed and is single-consume', () async { final request = Request.text( Uri.parse('https://example.com'), - body: 'streamed', + 'streamed', ); expect(request.bodyUsed, isFalse); @@ -102,10 +97,7 @@ void main() { }); test('body can only be consumed once', () async { - final request = Request.text( - Uri.parse('https://example.com'), - body: 'once', - ); + final request = Request.text(Uri.parse('https://example.com'), 'once'); expect(await request.text(), 'once'); await expectLater(request.text(), throwsStateError); @@ -115,8 +107,7 @@ void main() { final source = Headers({'x-id': '1'}); final request = Request( Uri.parse('https://example.com'), - method: 'POST', - headers: source, + RequestInit(method: 'POST', headers: source), ); source.set('x-id', '2'); @@ -127,10 +118,7 @@ void main() { }); test('copyWith clones body when omitted and can replace body', () async { - final request = Request.text( - Uri.parse('https://example.com'), - body: 'payload', - ); + final request = Request.text(Uri.parse('https://example.com'), 'payload'); final copied = request.copyWith(method: 'PUT'); final replaced = request.copyWith(method: 'PATCH', body: 'next'); @@ -143,14 +131,14 @@ void main() { }); test('copyWith without body fails after body has been consumed', () async { - final request = Request.text(Uri.parse('https://example.com'), body: 'x'); + final request = Request.text(Uri.parse('https://example.com'), 'x'); await request.text(); expect(() => request.copyWith(method: 'PUT'), throwsStateError); }); test('clone fails after body has been consumed', () async { - final request = Request.text(Uri.parse('https://example.com'), body: 'x'); + final request = Request.text(Uri.parse('https://example.com'), 'x'); await request.text(); expect(() => request.clone(), throwsStateError); @@ -168,8 +156,7 @@ void main() { expect( () => Request( Uri.parse('https://example.com'), - method: 'POST', - body: DateTime(2024), + RequestInit(method: 'POST', body: DateTime(2024)), ), throwsArgumentError, ); @@ -191,7 +178,7 @@ void main() { test('accepts block body and infers content headers', () async { final body = block.Block(['payload'], type: 'application/custom'); - final response = Response(body: body); + final response = Response(body); expect(response.headers.get('content-type'), 'application/custom'); expect(response.headers.get('content-length'), '7'); @@ -216,14 +203,14 @@ void main() { test('redirect factory rejects non-redirect status', () { expect( - () => Response.redirect(Uri.parse('https://example.com'), status: 200), + () => Response.redirect(Uri.parse('https://example.com'), 200), throwsArgumentError, ); }); test('constructor clones headers input', () { final source = Headers({'x-id': '1'}); - final response = Response.text('ok', headers: source); + final response = Response.text('ok', ResponseInit(headers: source)); source.set('x-id', '2'); response.headers.set('x-other', 'v'); @@ -243,7 +230,7 @@ void main() { test( 'copyWith clones body when omitted and supports body override', () async { - final response = Response.text('payload', status: 200); + final response = Response.text('payload', ResponseInit(status: 200)); final copied = response.copyWith(status: 201); final replaced = response.copyWith(body: 'other'); final emptied = response.copyWith(body: null); @@ -265,8 +252,8 @@ void main() { test('blob type prefers explicit content-type header', () async { final response = Response( - body: 'hello', - headers: Headers({'content-type': 'application/custom'}), + 'hello', + ResponseInit(headers: Headers({'content-type': 'application/custom'})), ); final blob = await response.blob(); @@ -275,8 +262,14 @@ void main() { }); test('validates status range', () { - expect(() => Response(status: 99), throwsArgumentError); - expect(() => Response(status: 600), throwsArgumentError); + expect( + () => Response(null, ResponseInit(status: 99)), + throwsArgumentError, + ); + expect( + () => Response(null, ResponseInit(status: 600)), + throwsArgumentError, + ); }); }); } From 87c858a3144cae4d13211af85e33cd280549651c Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:31:49 +0800 Subject: [PATCH 2/4] refactor(fetch): remove non-web copyWith APIs --- lib/src/fetch/request.dart | 43 ++++++------------------------- lib/src/fetch/response.dart | 38 ++++----------------------- test/public_api_surface_test.dart | 2 +- test/request_response_test.dart | 43 ------------------------------- 4 files changed, 14 insertions(+), 112 deletions(-) diff --git a/lib/src/fetch/request.dart b/lib/src/fetch/request.dart index 57e2e06..d48c8af 100644 --- a/lib/src/fetch/request.dart +++ b/lib/src/fetch/request.dart @@ -13,21 +13,6 @@ final class RequestInit { final String? method; final Headers? headers; final Object? body; - - RequestInit copyWith({ - String? method, - Headers? headers, - Object? body = _sentinel, - }) { - final hasBody = !identical(body, _sentinel); - return RequestInit( - method: method ?? this.method, - headers: headers ?? this.headers?.clone(), - body: hasBody ? body : this.body, - ); - } - - static const Object _sentinel = Object(); } /// Fetch-like HTTP request model. @@ -58,7 +43,14 @@ class Request with BodyMixin { nextHeaders.set('content-type', 'application/json; charset=utf-8'); } - return Request(url, nextInit.copyWith(headers: nextHeaders)); + return Request( + url, + RequestInit( + method: nextInit.method, + headers: nextHeaders, + body: nextInit.body, + ), + ); } factory Request.bytes(Uri url, List body, [RequestInit? init]) { @@ -105,25 +97,6 @@ class Request with BodyMixin { ); } - Request copyWith({ - Uri? url, - String? method, - Headers? headers, - Object? body = _sentinel, - }) { - final hasBody = !identical(body, _sentinel); - return Request( - url ?? this.url, - RequestInit( - method: method ?? this.method, - headers: headers ?? this.headers.clone(), - body: hasBody ? body : bodyData.clone(), - ), - ); - } - - static const Object _sentinel = Object(); - static RequestInit _coerceInit(RequestInit? init, {required Object? body}) { return RequestInit( method: init?.method ?? 'POST', diff --git a/lib/src/fetch/response.dart b/lib/src/fetch/response.dart index 0d79aba..efcdf48 100644 --- a/lib/src/fetch/response.dart +++ b/lib/src/fetch/response.dart @@ -12,14 +12,6 @@ final class ResponseInit { final int? status; final String? statusText; final Headers? headers; - - ResponseInit copyWith({int? status, String? statusText, Headers? headers}) { - return ResponseInit( - status: status ?? this.status, - statusText: statusText ?? this.statusText, - headers: headers ?? this.headers?.clone(), - ); - } } /// Fetch-like HTTP response model. @@ -62,7 +54,11 @@ class Response with BodyMixin { return Response( json.encode(body), - (init ?? ResponseInit()).copyWith(headers: nextHeaders), + ResponseInit( + status: init?.status, + statusText: init?.statusText, + headers: nextHeaders, + ), ); } @@ -125,30 +121,6 @@ class Response with BodyMixin { ); } - Response copyWith({ - Object? body = _sentinel, - int? status, - String? statusText, - Headers? headers, - Uri? url, - bool? redirected, - }) { - final hasBody = !identical(body, _sentinel); - - return Response._create( - hasBody ? body : bodyData.clone(), - ResponseInit( - status: status ?? this.status, - statusText: statusText ?? this.statusText, - headers: headers ?? this.headers.clone(), - ), - url: url ?? this.url, - redirected: redirected ?? this.redirected, - ); - } - - static const Object _sentinel = Object(); - static int _validateStatus(int status) { HttpStatus.validate(status); return status; diff --git a/test/public_api_surface_test.dart b/test/public_api_surface_test.dart index 2cb2e3b..cee0859 100644 --- a/test/public_api_surface_test.dart +++ b/test/public_api_surface_test.dart @@ -22,7 +22,7 @@ void main() { final request = Request.formData( Uri.parse('https://example.com/upload'), form, - requestInit.copyWith(headers: headers), + RequestInit(method: requestInit.method, headers: headers), ); final response = Response(blockBody, responseInit); diff --git a/test/request_response_test.dart b/test/request_response_test.dart index 7004dd7..6e13a2f 100644 --- a/test/request_response_test.dart +++ b/test/request_response_test.dart @@ -117,26 +117,6 @@ void main() { expect(source.has('x-other'), isFalse); }); - test('copyWith clones body when omitted and can replace body', () async { - final request = Request.text(Uri.parse('https://example.com'), 'payload'); - - final copied = request.copyWith(method: 'PUT'); - final replaced = request.copyWith(method: 'PATCH', body: 'next'); - - expect(copied.method, 'PUT'); - expect(replaced.method, 'PATCH'); - expect(await request.text(), 'payload'); - expect(await copied.text(), 'payload'); - expect(await replaced.text(), 'next'); - }); - - test('copyWith without body fails after body has been consumed', () async { - final request = Request.text(Uri.parse('https://example.com'), 'x'); - await request.text(); - - expect(() => request.copyWith(method: 'PUT'), throwsStateError); - }); - test('clone fails after body has been consumed', () async { final request = Request.text(Uri.parse('https://example.com'), 'x'); await request.text(); @@ -227,29 +207,6 @@ void main() { expect(await clone.text(), 'payload'); }); - test( - 'copyWith clones body when omitted and supports body override', - () async { - final response = Response.text('payload', ResponseInit(status: 200)); - final copied = response.copyWith(status: 201); - final replaced = response.copyWith(body: 'other'); - final emptied = response.copyWith(body: null); - - expect(copied.status, 201); - expect(await response.text(), 'payload'); - expect(await copied.text(), 'payload'); - expect(await replaced.text(), 'other'); - expect(await emptied.bytes(), isEmpty); - }, - ); - - test('copyWith without body fails after body has been consumed', () async { - final response = Response.text('x'); - await response.text(); - - expect(() => response.copyWith(status: 201), throwsStateError); - }); - test('blob type prefers explicit content-type header', () async { final response = Response( 'hello', From 8d6c3751415344bab6b9b03c7093cf0b6423b0c0 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:32:05 +0800 Subject: [PATCH 3/4] refactor(core): replace final classes with open classes --- lib/src/core/http_status.dart | 2 +- lib/src/core/mime_type.dart | 4 ++-- lib/src/fetch/body.dart | 2 +- lib/src/fetch/form_data.dart | 2 +- lib/src/fetch/headers.dart | 2 +- lib/src/fetch/request.dart | 2 +- lib/src/fetch/response.dart | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/src/core/http_status.dart b/lib/src/core/http_status.dart index 6c06746..707cf78 100644 --- a/lib/src/core/http_status.dart +++ b/lib/src/core/http_status.dart @@ -1,5 +1,5 @@ /// HTTP status-code helpers and well-known constants. -final class HttpStatus { +class HttpStatus { const HttpStatus._(); static const int continue_ = 100; diff --git a/lib/src/core/mime_type.dart b/lib/src/core/mime_type.dart index 369b1c7..e10027d 100644 --- a/lib/src/core/mime_type.dart +++ b/lib/src/core/mime_type.dart @@ -4,7 +4,7 @@ import 'package:http_parser/http_parser.dart' as http_parser; import 'package:mime/mime.dart' as mime; /// Thrown when a MIME type cannot be created from input. -final class MimeTypeFormatException implements FormatException { +class MimeTypeFormatException implements FormatException { MimeTypeFormatException(this.message, [this.source]); @override @@ -21,7 +21,7 @@ final class MimeTypeFormatException implements FormatException { } /// Immutable MIME type value object. -final class MimeType { +class MimeType { MimeType( String type, String subtype, [ diff --git a/lib/src/fetch/body.dart b/lib/src/fetch/body.dart index 5565cc9..fbf3ca1 100644 --- a/lib/src/fetch/body.dart +++ b/lib/src/fetch/body.dart @@ -38,7 +38,7 @@ mixin BodyMixin { } /// Internal body storage that supports cloning and one-time consumption. -final class BodyData { +class BodyData { BodyData.empty() : _present = false, _bytes = null, diff --git a/lib/src/fetch/form_data.dart b/lib/src/fetch/form_data.dart index c13bf39..1c7f72e 100644 --- a/lib/src/fetch/form_data.dart +++ b/lib/src/fetch/form_data.dart @@ -7,7 +7,7 @@ import 'blob.dart'; import 'file.dart'; /// Multipart body payload generated from [FormData]. -final class MultipartBody { +class MultipartBody { MultipartBody._({ required Stream Function() streamFactory, required this.boundary, diff --git a/lib/src/fetch/headers.dart b/lib/src/fetch/headers.dart index ea05844..e652dff 100644 --- a/lib/src/fetch/headers.dart +++ b/lib/src/fetch/headers.dart @@ -143,7 +143,7 @@ class Headers extends IterableBase> { } } -final class _HeaderEntry { +class _HeaderEntry { const _HeaderEntry({ required this.originalName, required this.normalizedName, diff --git a/lib/src/fetch/request.dart b/lib/src/fetch/request.dart index d48c8af..dbee8dd 100644 --- a/lib/src/fetch/request.dart +++ b/lib/src/fetch/request.dart @@ -6,7 +6,7 @@ import 'headers.dart'; import 'url_search_params.dart'; /// Initialization options for [Request], aligned with Fetch `RequestInit`. -final class RequestInit { +class RequestInit { RequestInit({this.method, Headers? headers, this.body}) : headers = headers?.clone(); diff --git a/lib/src/fetch/response.dart b/lib/src/fetch/response.dart index efcdf48..9bb2b3d 100644 --- a/lib/src/fetch/response.dart +++ b/lib/src/fetch/response.dart @@ -5,7 +5,7 @@ import 'body.dart'; import 'headers.dart'; /// Initialization options for [Response], aligned with Fetch `ResponseInit`. -final class ResponseInit { +class ResponseInit { ResponseInit({this.status, this.statusText, Headers? headers}) : headers = headers?.clone(); From 38ffff3d3c6cea180fb0d5e9cb88731efc427e23 Mon Sep 17 00:00:00 2001 From: Seven Du <5564821+medz@users.noreply.github.com> Date: Thu, 12 Mar 2026 03:14:07 +0800 Subject: [PATCH 4/4] fix(fetch): align response semantics with fetch behavior --- lib/src/fetch/request.dart | 32 ++++++++++++++++++++------------ lib/src/fetch/response.dart | 19 ++++++++++++++----- test/request_response_test.dart | 16 ++++++++++++++-- 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/lib/src/fetch/request.dart b/lib/src/fetch/request.dart index dbee8dd..f622df5 100644 --- a/lib/src/fetch/request.dart +++ b/lib/src/fetch/request.dart @@ -17,10 +17,20 @@ class RequestInit { /// Fetch-like HTTP request model. class Request with BodyMixin { - Request(this.url, [RequestInit? init]) - : method = _normalizeMethod(init?.method ?? 'GET'), - headers = init?.headers?.clone() ?? Headers(), - bodyData = BodyData.fromInit(init?.body) { + Request(Uri url, [RequestInit? init]) + : this._create( + url: url, + method: init?.method ?? 'GET', + headers: init?.headers?.clone() ?? Headers(), + bodyData: BodyData.fromInit(init?.body), + ); + + Request._create({ + required this.url, + required String method, + required this.headers, + required this.bodyData, + }) : method = _normalizeMethod(method) { _validateMethodAndBody(); _applyDefaultBodyHeaders(); } @@ -38,18 +48,16 @@ class Request with BodyMixin { factory Request.json(Uri url, Object? body, [RequestInit? init]) { final nextInit = _coerceInit(init, body: json.encode(body)); - final nextHeaders = nextInit.headers?.clone() ?? Headers(); + final nextHeaders = nextInit.headers ?? Headers(); if (!nextHeaders.has('content-type')) { nextHeaders.set('content-type', 'application/json; charset=utf-8'); } - return Request( - url, - RequestInit( - method: nextInit.method, - headers: nextHeaders, - body: nextInit.body, - ), + return Request._create( + url: url, + method: nextInit.method ?? 'POST', + headers: nextHeaders, + bodyData: BodyData.fromInit(nextInit.body), ); } diff --git a/lib/src/fetch/response.dart b/lib/src/fetch/response.dart index 9bb2b3d..746ccbe 100644 --- a/lib/src/fetch/response.dart +++ b/lib/src/fetch/response.dart @@ -25,11 +25,16 @@ class Response with BodyMixin { required this.url, required this.redirected, }) : status = _validateStatus(init?.status ?? HttpStatus.ok), - statusText = - init?.statusText ?? - HttpStatus.reasonPhrase(init?.status ?? HttpStatus.ok), + statusText = init?.statusText ?? '', headers = init?.headers?.clone() ?? Headers(), bodyData = BodyData.fromInit(body) { + if (bodyData.hasBody && _statusDisallowsBody(status)) { + throw ArgumentError.value( + status, + 'status', + 'Responses with status $status must not include a body', + ); + } _applyDefaultBodyHeaders(); } @@ -80,8 +85,8 @@ class Response with BodyMixin { return Response._create( null, ResponseInit(status: status, headers: nextHeaders), - url: location, - redirected: true, + url: null, + redirected: false, ); } @@ -126,6 +131,10 @@ class Response with BodyMixin { return status; } + static bool _statusDisallowsBody(int status) { + return status == 204 || status == 205 || status == 304; + } + void _applyDefaultBodyHeaders() { if (!bodyData.hasBody) { return; diff --git a/test/request_response_test.dart b/test/request_response_test.dart index 6e13a2f..d7f1d02 100644 --- a/test/request_response_test.dart +++ b/test/request_response_test.dart @@ -148,6 +148,7 @@ void main() { final response = Response.json({'ok': true}); expect(response.status, 200); + expect(response.statusText, ''); expect(response.ok, isTrue); expect( response.headers.get('content-type'), @@ -169,14 +170,15 @@ void main() { final response = Response.empty(); expect(response.status, HttpStatus.noContent); - expect(response.statusText, 'No Content'); + expect(response.statusText, ''); expect(response.body, isNull); expect(await response.bytes(), isEmpty); }); test('redirect response sets location and redirect metadata', () { final response = Response.redirect(Uri.parse('https://example.com/next')); - expect(response.redirected, isTrue); + expect(response.redirected, isFalse); + expect(response.url, isNull); expect(response.headers.get('location'), 'https://example.com/next'); expect(response.status, 302); }); @@ -228,5 +230,15 @@ void main() { throwsArgumentError, ); }); + + test('rejects body for null-body statuses', () { + for (final status in const [204, 205, 304]) { + expect( + () => Response('payload', ResponseInit(status: status)), + throwsArgumentError, + reason: 'status=$status', + ); + } + }); }); }