Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 5 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` |

Expand All @@ -54,13 +54,12 @@ import 'package:ht/ht.dart';
Future<void> 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
Expand Down Expand Up @@ -109,8 +108,7 @@ Future<void> main() async {
final body = block.Block(<Object>['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
Expand Down
17 changes: 9 additions & 8 deletions example/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,21 @@ import 'dart:convert';
import 'package:ht/ht.dart';

Future<void> 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}');
Expand Down
2 changes: 1 addition & 1 deletion lib/src/core/http_status.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// HTTP status-code helpers and well-known constants.
final class HttpStatus {
class HttpStatus {
const HttpStatus._();

static const int continue_ = 100;
Expand Down
4 changes: 2 additions & 2 deletions lib/src/core/mime_type.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,7 +21,7 @@ final class MimeTypeFormatException implements FormatException {
}

/// Immutable MIME type value object.
final class MimeType {
class MimeType {
MimeType(
String type,
String subtype, [
Expand Down
2 changes: 1 addition & 1 deletion lib/src/fetch/body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion lib/src/fetch/form_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import 'blob.dart';
import 'file.dart';

/// Multipart body payload generated from [FormData].
final class MultipartBody {
class MultipartBody {
MultipartBody._({
required Stream<Uint8List> Function() streamFactory,
required this.boundary,
Expand Down
2 changes: 1 addition & 1 deletion lib/src/fetch/headers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ class Headers extends IterableBase<MapEntry<String, String>> {
}
}

final class _HeaderEntry {
class _HeaderEntry {
const _HeaderEntry({
required this.originalName,
required this.normalizedName,
Expand Down
112 changes: 49 additions & 63 deletions lib/src/fetch/request.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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<int> body,
}) {
return Request(url, method: method, headers: headers, body: body);
factory Request.bytes(Uri url, List<int> body, [RequestInit? init]) {
return Request(url, _coerceInit(init, body: body));
}

factory Request.stream(
Uri url, {
String method = 'POST',
Headers? headers,
required Stream<List<int>> body,
}) {
return Request(url, method: method, headers: headers, body: body);
factory Request.stream(Uri url, Stream<List<int>> 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.
Expand All @@ -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) {
Expand Down
Loading
Loading