monart_http 0.2.1 copy "monart_http: ^0.2.1" to clipboard
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 #

pub.dev CI

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 #


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 via dart: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.


License #

MIT

0
likes
160
points
198
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

HTTP client foundation for Dart built on monart. Create typed HTTP service objects using Railway-Oriented Programming with semantic outcomes per status code.

Repository (GitHub)
View/report issues

Topics

#http #networking #railway-oriented-programming #service-objects

License

MIT (license)

Dependencies

collection, dio, http_status, meta, monart

More

Packages that depend on monart_http