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>andorgidheaders 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/deleteshortcuts.
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:
- The token, orgId, and refresh-in-flight flag must be shared across every service. A second instance would race the first.
- WebSocket services (chat, community-chat) read
appmintHttp.baseUrland 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 ahttp.MultipartRequestdirectly — see the file upload pattern inappmint_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.