feat: add JmapClient session client (Step 3)
Parses the JMAP Session object (RFC 8620 §2): fetches GET {jmapUrl},
extracts apiUrl and primary accountId, and wraps API calls via
call(methodCalls) which POSTs to apiUrl with Basic Auth.
Handles relative apiUrl, primaryAccounts fallback, and top-level
JMAP error responses. Covered by unit tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
c6fb5154fb
commit
0054322068
+1
-1
@@ -67,7 +67,7 @@ pending_changes (
|
||||
)
|
||||
```
|
||||
|
||||
### Step 3 — JMAP session client `[ ]`
|
||||
### Step 3 — JMAP session client `[x]`
|
||||
|
||||
Implement `JmapSession`: parse the JMAP Session object from `GET {jmapUrl}`,
|
||||
extract `apiUrl`, primary `accountId`, and capabilities. Store nothing extra in the
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
const _using = [
|
||||
'urn:ietf:params:jmap:core',
|
||||
'urn:ietf:params:jmap:mail',
|
||||
];
|
||||
|
||||
/// A connected JMAP session. Fetch via [JmapClient.connect].
|
||||
///
|
||||
/// Parses the JMAP Session object (RFC 8620 §2), stores the resolved
|
||||
/// [apiUrl] and server-side [accountId], and wraps API calls in [call].
|
||||
class JmapClient {
|
||||
JmapClient._({
|
||||
required http.Client httpClient,
|
||||
required String credentials,
|
||||
required Uri apiUrl,
|
||||
required String accountId,
|
||||
}) : _httpClient = httpClient,
|
||||
_credentials = credentials,
|
||||
_apiUrl = apiUrl,
|
||||
_accountId = accountId;
|
||||
|
||||
final http.Client _httpClient;
|
||||
final String _credentials;
|
||||
final Uri _apiUrl;
|
||||
final String _accountId;
|
||||
|
||||
String get accountId => _accountId;
|
||||
|
||||
/// Fetches the JMAP Session object from [jmapUrl] and returns a connected
|
||||
/// client. Throws [JmapException] on HTTP errors or missing capabilities.
|
||||
static Future<JmapClient> connect({
|
||||
required http.Client httpClient,
|
||||
required Uri jmapUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final credentials = base64.encode(utf8.encode('$username:$password'));
|
||||
final resp = await httpClient.get(
|
||||
jmapUrl,
|
||||
headers: {'Authorization': 'Basic $credentials'},
|
||||
).timeout(const Duration(seconds: 10));
|
||||
|
||||
if (resp.statusCode == 401 || resp.statusCode == 403) {
|
||||
throw JmapException('Authentication failed (HTTP ${resp.statusCode})');
|
||||
}
|
||||
if (resp.statusCode != 200) {
|
||||
throw JmapException('Session fetch failed (HTTP ${resp.statusCode})');
|
||||
}
|
||||
|
||||
final session = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final apiUrl = _extractApiUrl(session, jmapUrl);
|
||||
final accountId = _extractAccountId(session);
|
||||
|
||||
return JmapClient._(
|
||||
httpClient: httpClient,
|
||||
credentials: credentials,
|
||||
apiUrl: apiUrl,
|
||||
accountId: accountId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Issues a JMAP API request with [methodCalls].
|
||||
///
|
||||
/// Each call is a triple `[methodName, arguments, callId]`.
|
||||
/// Returns the raw `methodResponses` list from the server.
|
||||
///
|
||||
/// Throws [JmapException] on HTTP errors or a top-level JMAP error response.
|
||||
Future<List<dynamic>> call(List<List<dynamic>> methodCalls) async {
|
||||
final body = jsonEncode({
|
||||
'using': _using,
|
||||
'methodCalls': methodCalls,
|
||||
});
|
||||
|
||||
final resp = await _httpClient
|
||||
.post(
|
||||
_apiUrl,
|
||||
headers: {
|
||||
'Authorization': 'Basic $_credentials',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body,
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
if (resp.statusCode != 200) {
|
||||
throw JmapException('API call failed (HTTP ${resp.statusCode})');
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
|
||||
// Top-level error (e.g. unknownCapability)
|
||||
if (decoded.containsKey('type')) {
|
||||
throw JmapException(
|
||||
'JMAP error: ${decoded['type']} — ${decoded['description'] ?? ''}');
|
||||
}
|
||||
|
||||
return decoded['methodResponses'] as List<dynamic>;
|
||||
}
|
||||
|
||||
static Uri _extractApiUrl(Map<String, dynamic> session, Uri sessionUri) {
|
||||
final raw = session['apiUrl'] as String?;
|
||||
if (raw == null || raw.isEmpty) {
|
||||
throw JmapException('Session missing apiUrl');
|
||||
}
|
||||
// apiUrl may be relative (RFC 8620 §2 allows it)
|
||||
return sessionUri.resolve(raw);
|
||||
}
|
||||
|
||||
static String _extractAccountId(Map<String, dynamic> session) {
|
||||
final primaryAccounts =
|
||||
session['primaryAccounts'] as Map<String, dynamic>?;
|
||||
final id = primaryAccounts?['urn:ietf:params:jmap:mail'] as String? ??
|
||||
primaryAccounts?['urn:ietf:params:jmap:core'] as String?;
|
||||
if (id != null) return id;
|
||||
|
||||
// Fall back to first account in the accounts map
|
||||
final accounts = session['accounts'] as Map<String, dynamic>?;
|
||||
if (accounts != null && accounts.isNotEmpty) {
|
||||
return accounts.keys.first;
|
||||
}
|
||||
throw JmapException('Session has no usable accountId');
|
||||
}
|
||||
}
|
||||
|
||||
class JmapException implements Exception {
|
||||
JmapException(this.message);
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'JmapException: $message';
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
import 'package:sharedinbox/data/jmap/jmap_client.dart';
|
||||
|
||||
const _sessionUrl = 'https://jmap.example.com/.well-known/jmap';
|
||||
const _apiUrl = 'https://jmap.example.com/api/';
|
||||
const _accountId = 'u1';
|
||||
|
||||
Map<String, dynamic> _sessionBody({String? apiUrl, String? accountId}) => {
|
||||
'apiUrl': apiUrl ?? _apiUrl,
|
||||
'accounts': {
|
||||
accountId ?? _accountId: {
|
||||
'name': 'alice@example.com',
|
||||
'isPersonal': true,
|
||||
'isReadOnly': false,
|
||||
'accountCapabilities': {},
|
||||
},
|
||||
},
|
||||
'primaryAccounts': {
|
||||
'urn:ietf:params:jmap:core': accountId ?? _accountId,
|
||||
'urn:ietf:params:jmap:mail': accountId ?? _accountId,
|
||||
},
|
||||
'capabilities': {},
|
||||
'username': 'alice@example.com',
|
||||
'state': 'st1',
|
||||
};
|
||||
|
||||
http.Client _sessionClient({
|
||||
int sessionStatus = 200,
|
||||
Map<String, dynamic>? sessionBody,
|
||||
int apiStatus = 200,
|
||||
dynamic apiBody,
|
||||
}) {
|
||||
return MockClient((req) async {
|
||||
if (req.url.path.contains('well-known')) {
|
||||
return http.Response(
|
||||
jsonEncode(sessionBody ?? _sessionBody()), sessionStatus);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode(apiBody ??
|
||||
{
|
||||
'sessionState': 'st1',
|
||||
'methodResponses': [],
|
||||
}),
|
||||
apiStatus);
|
||||
});
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('JmapClient.connect', () {
|
||||
test('parses apiUrl and accountId from session', () async {
|
||||
final client = await JmapClient.connect(
|
||||
httpClient: _sessionClient(),
|
||||
jmapUrl: Uri.parse(_sessionUrl),
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
);
|
||||
expect(client.accountId, _accountId);
|
||||
});
|
||||
|
||||
test('falls back to first account when primaryAccounts missing', () async {
|
||||
final body = _sessionBody()..remove('primaryAccounts');
|
||||
final client = await JmapClient.connect(
|
||||
httpClient: _sessionClient(sessionBody: body),
|
||||
jmapUrl: Uri.parse(_sessionUrl),
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
);
|
||||
expect(client.accountId, _accountId);
|
||||
});
|
||||
|
||||
test('throws JmapException on 401', () async {
|
||||
expect(
|
||||
() => JmapClient.connect(
|
||||
httpClient: _sessionClient(sessionStatus: 401),
|
||||
jmapUrl: Uri.parse(_sessionUrl),
|
||||
username: 'alice',
|
||||
password: 'wrong',
|
||||
),
|
||||
throwsA(isA<JmapException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws JmapException on non-200 non-auth error', () async {
|
||||
expect(
|
||||
() => JmapClient.connect(
|
||||
httpClient: _sessionClient(sessionStatus: 503),
|
||||
jmapUrl: Uri.parse(_sessionUrl),
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
),
|
||||
throwsA(isA<JmapException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws JmapException when apiUrl is missing', () async {
|
||||
final body = _sessionBody()..remove('apiUrl');
|
||||
expect(
|
||||
() => JmapClient.connect(
|
||||
httpClient: _sessionClient(sessionBody: body),
|
||||
jmapUrl: Uri.parse(_sessionUrl),
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
),
|
||||
throwsA(isA<JmapException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws JmapException when no accounts exist', () async {
|
||||
final body = {
|
||||
'apiUrl': _apiUrl,
|
||||
'accounts': <String, dynamic>{},
|
||||
'primaryAccounts': <String, dynamic>{},
|
||||
'capabilities': {},
|
||||
'username': 'alice@example.com',
|
||||
'state': 'st1',
|
||||
};
|
||||
expect(
|
||||
() => JmapClient.connect(
|
||||
httpClient: _sessionClient(sessionBody: body),
|
||||
jmapUrl: Uri.parse(_sessionUrl),
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
),
|
||||
throwsA(isA<JmapException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('JmapClient.call', () {
|
||||
Future<JmapClient> connected({
|
||||
int apiStatus = 200,
|
||||
dynamic apiBody,
|
||||
}) =>
|
||||
JmapClient.connect(
|
||||
httpClient: _sessionClient(apiStatus: apiStatus, apiBody: apiBody),
|
||||
jmapUrl: Uri.parse(_sessionUrl),
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
);
|
||||
|
||||
test('returns methodResponses on success', () async {
|
||||
final responses = [
|
||||
['Mailbox/get', <String, dynamic>{'state': 'st2', 'list': []}, '0']
|
||||
];
|
||||
final client = await connected(
|
||||
apiBody: {'sessionState': 'st1', 'methodResponses': responses});
|
||||
final result = await client.call([
|
||||
['Mailbox/get', {'accountId': _accountId, 'ids': null}, '0']
|
||||
]);
|
||||
expect(result, hasLength(1));
|
||||
expect((result[0] as List<dynamic>)[0], 'Mailbox/get');
|
||||
});
|
||||
|
||||
test('throws JmapException on non-200 API response', () async {
|
||||
final client = await connected(apiStatus: 500);
|
||||
expect(
|
||||
() => client.call([
|
||||
['Mailbox/get', {'accountId': _accountId}, '0']
|
||||
]),
|
||||
throwsA(isA<JmapException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws JmapException on top-level JMAP error', () async {
|
||||
final client = await connected(
|
||||
apiBody: {'type': 'unknownCapability', 'description': 'oops'});
|
||||
expect(
|
||||
() => client.call([
|
||||
['Mailbox/get', {'accountId': _accountId}, '0']
|
||||
]),
|
||||
throwsA(isA<JmapException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user