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/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 8245166..f622df5 100644 --- a/lib/src/fetch/request.dart +++ b/lib/src/fetch/request.dart @@ -5,12 +5,32 @@ import 'form_data.dart'; import 'headers.dart'; import 'url_search_params.dart'; +/// Initialization options for [Request], aligned with Fetch `RequestInit`. +class RequestInit { + RequestInit({this.method, Headers? headers, this.body}) + : headers = headers?.clone(); + + final String? method; + final Headers? headers; + final Object? body; +} + /// 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(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(); } @@ -22,68 +42,43 @@ 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 ?? Headers(); if (!nextHeaders.has('content-type')) { nextHeaders.set('content-type', 'application/json; charset=utf-8'); } - return Request( - url, - method: method, + return Request._create( + url: url, + method: nextInit.method ?? 'POST', headers: nextHeaders, - body: json.encode(body), + bodyData: BodyData.fromInit(nextInit.body), ); } - 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. @@ -110,23 +105,14 @@ 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, - method: method ?? this.method, - headers: headers ?? this.headers.clone(), - body: hasBody ? body : bodyData.clone(), + static RequestInit _coerceInit(RequestInit? init, {required Object? body}) { + return RequestInit( + method: init?.method ?? 'POST', + headers: init?.headers?.clone(), + body: body, ); } - static const Object _sentinel = Object(); - 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..746ccbe 100644 --- a/lib/src/fetch/response.dart +++ b/lib/src/fetch/response.dart @@ -4,19 +4,37 @@ import '../core/http_status.dart'; import 'body.dart'; import 'headers.dart'; +/// Initialization options for [Response], aligned with Fetch `ResponseInit`. +class ResponseInit { + ResponseInit({this.status, this.statusText, Headers? headers}) + : headers = headers?.clone(); + + final int? status; + final String? statusText; + final Headers? headers; +} + /// 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 ?? '', + 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(); } @@ -29,70 +47,31 @@ 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), + ResponseInit( + status: init?.status, + statusText: init?.statusText, + 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 +80,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, - url: location, - redirected: true, + return Response._create( + null, + ResponseInit(status: status, headers: nextHeaders), + url: null, + redirected: false, ); } - 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, + ), ); } @@ -153,33 +126,15 @@ 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( - body: hasBody ? body : bodyData.clone(), - 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; } + static bool _statusDisallowsBody(int status) { + return status == 204 || status == 205 || status == 304; + } + void _applyDefaultBodyHeaders() { if (!bodyData.hasBody) { return; diff --git a/test/public_api_surface_test.dart b/test/public_api_surface_test.dart index 1b123f3..cee0859 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(method: requestInit.method, 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..d7f1d02 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'); @@ -126,31 +117,8 @@ 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'), - body: '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'), body: '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 +136,7 @@ void main() { expect( () => Request( Uri.parse('https://example.com'), - method: 'POST', - body: DateTime(2024), + RequestInit(method: 'POST', body: DateTime(2024)), ), throwsArgumentError, ); @@ -181,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'), @@ -191,7 +159,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'); @@ -202,28 +170,29 @@ 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); }); 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'); @@ -240,33 +209,10 @@ void main() { expect(await clone.text(), 'payload'); }); - test( - 'copyWith clones body when omitted and supports body override', - () async { - final response = Response.text('payload', 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( - body: 'hello', - headers: Headers({'content-type': 'application/custom'}), + 'hello', + ResponseInit(headers: Headers({'content-type': 'application/custom'})), ); final blob = await response.blob(); @@ -275,8 +221,24 @@ 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, + ); + }); + + 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', + ); + } }); }); }