monart_http 0.1.0
monart_http: ^0.1.0 copied to clipboard
HTTP client foundation for Dart built on monart. Create typed HTTP service objects using Railway-Oriented Programming with semantic outcomes per status code.
monart_http #
HTTP client foundation for Dart built on monart. Create typed HTTP service objects where you declare what — verb, path, configuration, and how to map the response — and the base handles how: request dispatch, status-code-to-outcome mapping, and network error handling. Every call returns a Future<Result<Value>>, never throws.
Inspired by the Ruby gem f_http_client.
Documentation #
- Full API reference: pub.dev/documentation/monart_http — available after publication
- Latest API reference: https://bvicenzo.github.io/monart_http/
Installation #
dependencies:
monart: ^0.1.1
monart_http: ^0.1.0
import 'package:monart_http/monart_http.dart';
Quick start #
// 1. Declare a shared configuration for each HTTP client
const githubConfig = HttpClientConfiguration(
baseUri: 'https://api.github.com',
connectTimeout: Duration(seconds: 5),
receiveTimeout: Duration(seconds: 15),
logStrategy: LogStrategy.stdout, // logs to Flutter DevTools in debug
);
// 2. Declare one service object per HTTP operation
class GithubUserFindService extends HttpServiceBase<GithubUser> {
const GithubUserFindService({required this.username});
final String username;
@override
HttpMethod get method => HttpMethod.get;
@override
String get pathTemplate => '/users/:username';
@override
Map<String, String> get pathParams => {'username': username};
@override
HttpClientConfiguration get configuration => githubConfig;
@override
GithubUser fromResponse(HttpResponse response) =>
GithubUser.fromJson(response.body as Map<String, dynamic>);
}
// 3. Call it
await GithubUserFindService(username: 'dart-lang')
.runAsync()
.onSuccessOf('ok', (user) => print(user.name))
.onFailureOf('notFound', (_) => print('User not found'))
.onFailureOf('timeout', (_) => print('Request timed out'))
.onFailure((outcome, _) => print('Unexpected: $outcome'));
Usage #
HttpClientConfiguration #
Define a configuration once per HTTP client and share it across all its service objects:
const marketListApiConfig = HttpClientConfiguration(
baseUri: 'https://api.marketlist.example.com',
defaultHeaders: {'Accept': 'application/json'},
connectTimeout: Duration(seconds: 10),
receiveTimeout: Duration(seconds: 30),
logStrategy: LogStrategy.stdout,
);
| Field | Default | Description |
|---|---|---|
baseUri |
required | Base URL prepended to every path |
defaultHeaders |
{} |
Headers sent on every request |
connectTimeout |
10s |
Time to establish connection |
receiveTimeout |
30s |
Time to receive the full response |
logStrategy |
none |
stdout enables logging to Flutter DevTools |
HttpServiceBase #
Subclass HttpServiceBase<Value> and declare the five required members:
class OrderCreateService extends HttpServiceBase<Order> {
const OrderCreateService({required this.order});
final Order order;
@override
HttpMethod get method => HttpMethod.post;
@override
String get pathTemplate => '/orders';
@override
HttpClientConfiguration get configuration => marketListApiConfig;
@override
Object? get body => order.toJson();
@override
Order fromResponse(HttpResponse response) =>
Order.fromJson(response.body as Map<String, dynamic>);
}
Path parameters
Use :paramName placeholders in pathTemplate and provide values in pathParams:
class OrderFindService extends HttpServiceBase<Order> {
const OrderFindService({required this.orderId});
final String orderId;
@override
HttpMethod get method => HttpMethod.get;
@override
String get pathTemplate => '/orders/:orderId';
@override
Map<String, String> get pathParams => {'orderId': orderId};
@override
HttpClientConfiguration get configuration => marketListApiConfig;
@override
Order fromResponse(HttpResponse response) =>
Order.fromJson(response.body as Map<String, dynamic>);
}
Query parameters
Override queryParams to append query string parameters:
@override
Map<String, dynamic> get queryParams => {'page': page, 'per_page': perPage};
Per-request headers
Override headers to add headers that take precedence over defaultHeaders:
@override
Map<String, String> get headers => {'Authorization': 'Bearer $token'};
Outcomes #
runAsync() returns a Future<Result<Value>>. On success the result carries the value from fromResponse. On a failed HTTP status or network error the result is a Failure.
HTTP status codes are mapped to semantic outcome names. Most codes resolve to two outcomes — a specific name and a family name:
| Status | Outcomes |
|---|---|
| 200 | ['ok', 'successful'] |
| 201 | ['created', 'successful'] |
| 204 | ['noContent', 'successful'] |
| 400 | ['badRequest', 'clientError'] |
| 401 | ['unauthorized', 'clientError'] |
| 403 | ['forbidden', 'clientError'] |
| 404 | ['notFound', 'clientError'] |
| 409 | ['conflict', 'clientError'] |
| 422 | ['unprocessableContent', 'clientError'] |
| 429 | ['tooManyRequests', 'clientError'] |
| 500 | ['internalServerError', 'serverError'] |
| 503 | ['serviceUnavailable', 'serverError'] |
You can match either outcome in onSuccessOf / onFailureOf:
await OrderCreateService(order: order)
.runAsync()
.onSuccessOf('created', (order) => navigateToOrder(order))
.onFailureOf('unprocessableContent', (_, response) => showValidationErrors(response))
.onFailureOf('clientError', (_, __) => showGenericClientError())
.onFailureOf('timeout', (_) => showTimeoutBanner())
.onFailure((outcome, _) => logger.error('Unexpected: $outcome'));
Network errors map to:
| Exception | Outcome |
|---|---|
| Connection / send / receive timeout | timeout |
| SSL certificate failure | sslError |
| No network / refused connection | connectionError |
| Request cancelled | requestCancelled |
| Any other exception | uncaughtError |
Logging #
Set logStrategy: LogStrategy.stdout on a configuration to enable request/response logging. Logs are sent to the Flutter DevTools Logging tab via dart:developer — they never appear in production builds.
→ GET https://api.example.com/users/42
← 200 OK (31ms)
{id: 42, name: Alice}
→ POST https://api.example.com/sessions
{email: alice@example.com, password: secret}
← 422 Unprocessable Content (18ms)
{errors: {email: [has already been taken]}}
✗ GET https://api.example.com/orders — connectionError (0ms)
Testing #
Use http_mock_adapter to stub HTTP responses in tests. Every HttpServiceBase exposes a testDio getter that returns the internal Dio instance:
import 'package:http_mock_adapter/http_mock_adapter.dart';
import 'package:monart/monart_testing.dart';
void main() {
late GithubUserFindService service;
late DioAdapter adapter;
setUp(() {
service = const GithubUserFindService(username: 'dart-lang');
adapter = DioAdapter(dio: service.testDio);
});
it('succeeds with the user on 200', () async {
adapter.onGet('/users/dart-lang', (server) => server.reply(200, {'login': 'dart-lang'}));
expect(
await service.runAsync(),
haveSucceededWith(['ok', 'successful']),
);
});
it('fails with notFound on 404', () async {
adapter.onGet('/users/dart-lang', (server) => server.reply(404, null));
expect(
await service.runAsync(),
haveFailedWith(['notFound', 'clientError']),
);
});
}
testDio is annotated @visibleForTesting — it only appears in test files and does not form part of the public API.
Contributing #
Running tests and analysis locally #
dart pub get
dart analyze --fatal-infos
dart test
Workflows #
CI runs automatically on every push to master and on every pull request. Tests and static analysis are executed against three SDK versions — 3.1.0 (the declared minimum), stable, and beta. Only stable is required to pass; the other two run with continue-on-error.
Release is triggered by pushing a version tag:
git tag v0.1.0
git push origin v0.1.0
The workflow runs the full test suite and creates a GitHub Release with auto-generated release notes.
Docs are deployed to GitHub Pages automatically when the version: line in pubspec.yaml changes on master. To force a deploy, go to Actions → "Deploy Docs" → "Run workflow".
Publish is always manual. After the release tag exists, go to Actions → "Publish to pub.dev" → "Run workflow", enter the version (e.g. 0.1.0), and confirm. The workflow checks out that exact tag, re-runs tests and analysis, does a --dry-run, and only then publishes.
Publishing uses OIDC (Trusted Publishers) — no tokens stored as secrets. First-time setup: on pub.dev go to "My pub.dev" → "Trusted publishers" and add github.com/bvicenzo/monart_http.
License #
MIT