Documentation

Client pattern

The canonical AppmintClient class — HTTP wrapper, JWT interceptor, BaseModel unwrap, retry.

Every reference app under appmint_go/ reimplements the same HTTP client. This page is the canonical version, distilled from appmint_mobile/lib/services/http_client.dart and the consumer apps. Drop it into lib/services/http_client.dart and the rest of the SDK assumes it exists.

The client is a singleton. It owns the access token, the orgId, and the in-flight refresh state, and it exposes one method per HTTP verb. Every other service — auth, leads, products, orders, chat — calls into it.

What the client does

  • Builds the base URL from the environment config.
  • Attaches Authorization: Bearer <jwt> and orgid headers automatically.
  • Logs requests and responses in debug mode (off in release).
  • Catches 401 Unauthorized, refreshes the token once via a callback, and retries.
  • On second 401, calls a force-logout callback and throws.
  • Maps non-2xx responses to readable exceptions.
  • Provides get/post/put/patch/delete shortcuts.

Full implementation

// lib/services/http_client.dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config/environment.dart';

class AppmintHttpClient {
  static final AppmintHttpClient _instance = AppmintHttpClient._internal();
  factory AppmintHttpClient() => _instance;
  AppmintHttpClient._internal();

  String? _accessToken;
  String? _orgId;
  bool _refreshing = false;

  /// Set by AuthProvider — called when a 401 indicates an expired token.
  /// Returns the new access token, or null if refresh failed.
  Future<String?> Function()? onTokenRefresh;

  /// Set by AuthProvider — called when refresh has failed and we need to
  /// kick the user back to the login screen.
  VoidCallback? onForceLogout;

  String get baseUrl => EnvironmentConfig.appengineEndpoint;

  void setAuth(String accessToken, String orgId) {
    _accessToken = accessToken;
    _orgId = orgId;
  }

  void clearAuth() {
    _accessToken = null;
    _orgId = null;
  }

  /// Auth headers exposed for callers that build their own http.Request
  /// (multipart uploads, for example).
  Map<String, String> getAuthHeaders() {
    final headers = <String, String>{};
    if (_accessToken != null && _accessToken!.isNotEmpty) {
      headers['Authorization'] = 'Bearer $_accessToken';
    }
    if (_orgId != null) headers['orgid'] = _orgId!;
    return headers;
  }

  Map<String, String> _buildHeaders({bool requireAuth = true}) {
    final headers = <String, String>{
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    };
    if (requireAuth) {
      if (_accessToken == null || _accessToken!.isEmpty || _orgId == null) {
        throw Exception('Authentication required. Please sign in first.');
      }
      headers['Authorization'] = 'Bearer $_accessToken';
      headers['orgid'] = _orgId!;
    } else if (_orgId != null) {
      headers['orgid'] = _orgId!;
    }
    return headers;
  }

  Future<http.Response> _execute(
    String method,
    Uri uri,
    Map<String, String> headers,
    Map<String, dynamic>? body,
  ) async {
    final timeout = Duration(milliseconds: EnvironmentConfig.connectionTimeout);
    final encoded = body != null ? jsonEncode(body) : null;

    switch (method.toUpperCase()) {
      case 'GET':
        return http.get(uri, headers: headers).timeout(timeout);
      case 'POST':
        return http.post(uri, headers: headers, body: encoded).timeout(timeout);
      case 'PUT':
        return http.put(uri, headers: headers, body: encoded).timeout(timeout);
      case 'PATCH':
        return http.patch(uri, headers: headers, body: encoded).timeout(timeout);
      case 'DELETE':
        return http.delete(uri, headers: headers).timeout(timeout);
      default:
        throw Exception('Unsupported method: $method');
    }
  }

  Future<dynamic> request(
    String method,
    String path, {
    Map<String, dynamic>? body,
    Map<String, String>? queryParams,
    bool requireAuth = true,
  }) async {
    // Strip empty/null params — AppEngine treats them as filters.
    Map<String, String>? clean;
    if (queryParams != null) {
      clean = Map.fromEntries(queryParams.entries.where((e) {
        final v = e.value.trim();
        return v.isNotEmpty && v != 'null' && v != 'NaN';
      }));
      if (clean.isEmpty) clean = null;
    }

    final uri = Uri.parse('$baseUrl$path').replace(queryParameters: clean);
    var headers = _buildHeaders(requireAuth: requireAuth);

    if (kDebugMode) {
      debugPrint('-> $method $path');
      if (body != null) debugPrint('   body: ${jsonEncode(body)}');
    }

    var response = await _execute(method, uri, headers, body);

    // 401 + refresh-once-then-logout pattern.
    if (response.statusCode == 401 && requireAuth && !_refreshing) {
      _refreshing = true;
      try {
        if (onTokenRefresh != null) {
          final newToken = await onTokenRefresh!();
          if (newToken != null && newToken.isNotEmpty) {
            _accessToken = newToken;
            headers = _buildHeaders(requireAuth: requireAuth);
            response = await _execute(method, uri, headers, body);
          }
        }
      } finally {
        _refreshing = false;
      }

      if (response.statusCode == 401) {
        onForceLogout?.call();
        throw Exception('Session expired. Please sign in again.');
      }
    }

    return _handleResponse(response);
  }

  dynamic _handleResponse(http.Response response) {
    final code = response.statusCode;
    if (code >= 200 && code < 300) {
      if (response.body.isEmpty) return null;
      try {
        return jsonDecode(response.body);
      } on FormatException {
        return response.body; // raw string responses (e.g. token endpoints)
      }
    }

    String message;
    switch (code) {
      case 400:
        message = 'Bad request';
        break;
      case 403:
        message = 'Forbidden — insufficient permissions';
        break;
      case 404:
        message = 'Not found';
        break;
      case 409:
        message = 'Conflict';
        break;
      default:
        if (code >= 500) {
          message = 'Server error — try again later';
        } else {
          message = 'Request failed ($code)';
        }
    }
    try {
      final decoded = jsonDecode(response.body);
      if (decoded is Map && decoded['message'] is String) {
        message = decoded['message'] as String;
      }
    } catch (_) {}

    throw Exception(message);
  }

  // Convenience methods.
  Future<dynamic> get(String path,
      {Map<String, String>? queryParams, bool requireAuth = true}) {
    return request('GET', path, queryParams: queryParams, requireAuth: requireAuth);
  }

  Future<dynamic> post(String path,
      {Map<String, dynamic>? body, bool requireAuth = true}) {
    return request('POST', path, body: body, requireAuth: requireAuth);
  }

  Future<dynamic> put(String path,
      {Map<String, dynamic>? body, bool requireAuth = true}) {
    return request('PUT', path, body: body, requireAuth: requireAuth);
  }

  Future<dynamic> patch(String path,
      {Map<String, dynamic>? body, bool requireAuth = true}) {
    return request('PATCH', path, body: body, requireAuth: requireAuth);
  }

  Future<dynamic> delete(String path, {bool requireAuth = true}) {
    return request('DELETE', path, requireAuth: requireAuth);
  }
}

final appmintHttp = AppmintHttpClient();

How the rest of the app uses it

A typed service wraps the client for each domain. Two short examples — leads (admin) and products (consumer).

Leads service (admin)

// lib/services/leads_service.dart
import '../models/lead_model.dart';
import 'http_client.dart';

class LeadsService {
  final _http = appmintHttp;

  Future<List<LeadModel>> list({int page = 1, int limit = 20}) async {
    final response = await _http.get('/crm/leads/detail', queryParams: {
      'page': page.toString(),
      'limit': limit.toString(),
    });
    final data = (response['data'] ?? response['results'] ?? []) as List;
    return data.map((j) => LeadModel.fromJson(j)).toList();
  }

  Future<LeadModel> getById(String id) async {
    final response = await _http.get('/crm/leads/detail/$id');
    return LeadModel.fromJson(response);
  }

  Future<LeadModel> create(Map<String, dynamic> data) async {
    final response = await _http.post('/crm/leads/detail', body: data);
    return LeadModel.fromJson(response);
  }

  Future<LeadModel> update(String id, Map<String, dynamic> data) async {
    final response = await _http.put('/crm/leads/detail/$id', body: data);
    return LeadModel.fromJson(response);
  }

  Future<void> delete(String id) => _http.delete('/crm/leads/detail/$id');
}

Storefront service (consumer)

// lib/services/storefront_service.dart
import 'http_client.dart';

class StorefrontService {
  final _http = appmintHttp;

  Future<List<dynamic>> products({int page = 1, int pageSize = 20}) async {
    final response = await _http.get('/storefront/products', queryParams: {
      'p': page.toString(),
      'ps': pageSize.toString(),
    });
    return (response['data'] ?? response) as List;
  }

  Future<dynamic> productById(String id) =>
      _http.get('/storefront/product/$id');
}

BaseModel<T> unwrapping

AppEngine returns most records wrapped in a BaseModel\<T\> envelope:

{
  "pk": "lead-...",
  "sk": "my-org/lead",
  "datatype": "lead",
  "version": 3,
  "state": "new",
  "createdate": "...",
  "modifydate": "...",
  "data": { "name": "Acme Corp", "email": "..." }
}

Your model factory reads from .data, not the root. The reference apps codify this in a tiny mixin:

mixin BaseModelUnwrap {
  static Map<String, dynamic> unwrap(Map<String, dynamic> json) {
    final data = json['data'];
    if (data is Map<String, dynamic>) {
      return {
        ...data,
        '__pk': json['pk'],
        '__sk': json['sk'],
        '__version': json['version'],
        '__state': json['state'],
      };
    }
    return Map<String, dynamic>.from(json);
  }
}

class LeadModel {
  final String? pk;
  final String name;
  final String? email;

  LeadModel({this.pk, required this.name, this.email});

  factory LeadModel.fromJson(Map<String, dynamic> json) {
    final m = BaseModelUnwrap.unwrap(json);
    return LeadModel(
      pk: m['__pk'] as String?,
      name: m['name'] as String? ?? '',
      email: m['email'] as String?,
    );
  }
}

List endpoints wrap their pages too — usually { data: [...], total, page, pageSize }. The convention is response['data']; some older endpoints use response['results']. The reference apps fall back to both:

final list = (response['data'] ?? response['results'] ?? []) as List;

Wiring the refresh callback

The client is dumb about auth flows on purpose — it just calls onTokenRefresh when it sees a 401. The AuthProvider (covered next, in Auth) sets the callback at app start:

appmintHttp.onTokenRefresh = () async {
  try {
    final newAuth = await authService.refresh();
    return newAuth.accessToken;
  } catch (_) {
    return null;
  }
};

appmintHttp.onForceLogout = () {
  authProvider.signOut();
  navigatorKey.currentState?.pushReplacementNamed('/signin');
};

The split keeps the HTTP layer free of widget tree references — refresh logic lives where the auth state machine lives, and the client stays a transport.

Why singleton

Every reference app uses a singleton. Two reasons:

  1. The token, orgId, and refresh-in-flight flag must be shared across every service. A second instance would race the first.
  2. WebSocket services (chat, community-chat) read appmintHttp.baseUrl and the auth headers — they need the same instance the REST calls use.

If you need to test against a fake server, swap the baseUrl getter for one that reads a static field, or extract the client behind a small interface and inject a mock in tests.

What this client does NOT do

  • Pagination. List endpoints return { data, total, page, pageSize } — your service is responsible for advancing pages.
  • Caching. Responses are not cached; if you need offline reads, layer Hive on top — see Offline cache.
  • Multipart uploads. For files, use getAuthHeaders() and build a http.MultipartRequest directly — see the file upload pattern in appmint_mobile/lib/services/file_service.dart.

The next page covers signing in — both customer and staff flows — and how the access/refresh tokens land in flutter_secure_storage.