monart_http 0.2.1
monart_http: ^0.2.1 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
- Latest API reference: https://bvicenzo.github.io/monart_http/
Installation #
dependencies:
monart: ^0.1.1
monart_http: ^0.2.0
import 'package:monart_http/monart_http.dart';
Quick start #
// 1. Declare a shared configuration for each HTTP client
final githubConfig = HttpClientConfiguration(
baseUri: 'https://api.github.com',
connectTimeout: Duration(seconds: 5),
receiveTimeout: Duration(seconds: 15),
logger: DevToolsHttpLogger(), // 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:
final marketListApiConfig = HttpClientConfiguration(
baseUri: 'https://api.marketlist.example.com',
defaultHeaders: {'Accept': 'application/json'},
connectTimeout: Duration(seconds: 10),
receiveTimeout: Duration(seconds: 30),
logger: DevToolsHttpLogger(),
cache: InMemoryHttpCache(expiresIn: Duration(minutes: 5)),
);
| 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 |
logger |
NullHttpLogger() |
Logs requests, responses, and errors |
cache |
NullHttpCache() |
Caches responses to avoid redundant requests |
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 #
Inject an HttpLogger into HttpClientConfiguration to observe every request, response, and network error. The library ships with two implementations:
NullHttpLogger— does nothing (default).DevToolsHttpLogger— logs to the Flutter DevTools Logging tab viadart:developer. Logs are ephemeral and never appear in production builds.
final config = HttpClientConfiguration(
baseUri: 'https://api.example.com',
logger: DevToolsHttpLogger(),
);
DevToolsHttpLogger output format:
→ 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)
Custom implementations
Implement HttpLogger to forward to any backend:
// Forward HTTP errors to Sentry
class SentryHttpLogger implements HttpLogger {
@override
void logRequest({required String method, required Uri uri, Object? body}) {}
@override
void logResponse({
required String method,
required Uri uri,
required int statusCode,
String? statusMessage,
Object? body,
required int elapsedMilliseconds,
}) {}
@override
void logError({
required String method,
required Uri uri,
required String errorType,
required int elapsedMilliseconds,
}) {
Sentry.captureMessage('$method $uri — $errorType (${elapsedMilliseconds}ms)');
}
}
// Delegate to package:logging so the app controls format and filtering
class AppHttpLogger implements HttpLogger {
const AppHttpLogger(this._logger);
final Logger _logger; // package:logging
@override
void logRequest({required String method, required Uri uri, Object? body}) =>
_logger.fine('→ $method $uri');
@override
void logResponse({
required String method,
required Uri uri,
required int statusCode,
String? statusMessage,
Object? body,
required int elapsedMilliseconds,
}) =>
_logger.fine('← $statusCode (${elapsedMilliseconds}ms)');
@override
void logError({
required String method,
required Uri uri,
required String errorType,
required int elapsedMilliseconds,
}) =>
_logger.warning('✗ $method $uri — $errorType (${elapsedMilliseconds}ms)');
}
Filtering sensitive fields
Use the decorator pattern to strip sensitive fields before they reach any logger:
class FilteringHttpLogger implements HttpLogger {
const FilteringHttpLogger(this._delegate, {required this.sensitiveFields});
final HttpLogger _delegate;
final Set<String> sensitiveFields;
@override
void logRequest({required String method, required Uri uri, Object? body}) =>
_delegate.logRequest(method: method, uri: uri, body: _filtered(body));
@override
void logResponse({
required String method,
required Uri uri,
required int statusCode,
String? statusMessage,
Object? body,
required int elapsedMilliseconds,
}) =>
_delegate.logResponse(
method: method,
uri: uri,
statusCode: statusCode,
statusMessage: statusMessage,
body: _filtered(body),
elapsedMilliseconds: elapsedMilliseconds,
);
@override
void logError({
required String method,
required Uri uri,
required String errorType,
required int elapsedMilliseconds,
}) =>
_delegate.logError(method: method, uri: uri, errorType: errorType, elapsedMilliseconds: elapsedMilliseconds);
Object? _filtered(Object? body) {
if (body is! Map<String, dynamic>) return body;
return {
for (final entry in body.entries)
entry.key: sensitiveFields.contains(entry.key) ? '[FILTERED]' : entry.value,
};
}
}
// Usage: wraps any other logger, hides passwords and tokens
final config = HttpClientConfiguration(
baseUri: 'https://api.example.com',
logger: FilteringHttpLogger(
DevToolsHttpLogger(),
sensitiveFields: {'password', 'token', 'secret'},
),
);
Caching #
Inject an HttpCache into HttpClientConfiguration to avoid redundant network requests. The library ships with two implementations:
NullHttpCache— always executes the request (default).InMemoryHttpCache— stores responses in memory for the duration of the process, keyed by method + path + query + body. Expired entries are evicted on the next access to the same key.
final config = HttpClientConfiguration(
baseUri: 'https://api.example.com',
cache: InMemoryHttpCache(expiresIn: Duration(minutes: 5)),
);
Custom implementations
Implement HttpCache to persist responses beyond the process lifetime:
// Persist cache to disk with Hive — survives app restarts
class HiveHttpCache implements HttpCache {
const HiveHttpCache({required this.box});
final Box<String> box; // Hive box storing JSON-encoded responses
@override
Future<T> fetch<T>(String key, Future<T> Function() request, {Duration? expiresIn}) async {
final cached = box.get(key);
if (cached != null) return jsonDecode(cached) as T;
final value = await request();
await box.put(key, jsonEncode(value));
return value;
}
}
// Distributed cache with Redis — for server-side Dart
class RedisHttpCache implements HttpCache {
const RedisHttpCache({required this.client});
final RedisClient client;
@override
Future<T> fetch<T>(String key, Future<T> Function() request, {Duration? expiresIn}) async {
final cached = await client.get(key);
if (cached != null) return jsonDecode(cached) as T;
final value = await request();
await client.setEx(key, expiresIn ?? const Duration(minutes: 5), jsonEncode(value));
return value;
}
}
Extending HttpClientConfiguration #
For APIs that require shared state — such as auth tokens or tenant identifiers — subclass HttpClientConfiguration and expose the extra fields. Services that use the subclass receive the full configuration through HttpServiceBase.configuration:
class AuthenticatedApiConfig extends HttpClientConfiguration {
const AuthenticatedApiConfig({
required super.baseUri,
required this.accessToken,
super.logger,
super.cache,
super.defaultHeaders,
super.connectTimeout,
super.receiveTimeout,
});
final String accessToken;
}
class OrderFindService extends HttpServiceBase<Order> {
const OrderFindService({required this.orderId, required this.config});
final String orderId;
final AuthenticatedApiConfig config;
@override
HttpClientConfiguration get configuration => config;
@override
Map<String, String> get headers => {'Authorization': 'Bearer ${config.accessToken}'};
// ... other overrides
}
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.2.0
git push origin v0.2.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.2.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.