Skip to content

Commit 66df466

Browse files
api: Embed platform and app info in user-agent
Generate the user-agent using `deviceInfo` and `packageInfo` from ZulipBinding. Fixes: zulip#467
1 parent 162f464 commit 66df466

File tree

4 files changed

+275
-137
lines changed

4 files changed

+275
-137
lines changed

lib/api/core.dart

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'dart:io';
44
import 'package:http/http.dart' as http;
55

66
import '../log.dart';
7+
import '../model/binding.dart';
78
import '../model/localizations.dart';
89
import 'exception.dart';
910

@@ -37,6 +38,7 @@ class ApiConnection {
3738
String? email,
3839
String? apiKey,
3940
required http.Client client,
41+
required this.useBinding,
4042
}) : assert((email != null) == (apiKey != null)),
4143
_authValue = (email != null && apiKey != null)
4244
? _authHeaderValue(email: email, apiKey: apiKey)
@@ -51,7 +53,7 @@ class ApiConnection {
5153
String? apiKey,
5254
}) : this(client: http.Client(),
5355
realmUrl: realmUrl, zulipFeatureLevel: zulipFeatureLevel,
54-
email: email, apiKey: apiKey);
56+
email: email, apiKey: apiKey, useBinding: true);
5557

5658
final Uri realmUrl;
5759

@@ -69,6 +71,36 @@ class ApiConnection {
6971
/// * API docs at <https://zulip.com/api/changelog>.
7072
int? zulipFeatureLevel;
7173

74+
/// Enabled or disables the use of a user-agent generated via [ZulipBinding].
75+
///
76+
/// When set to true, the user-agent will be generated using
77+
/// [ZulipBinding.deviceInfo] and [ZulipBinding.packageInfo].
78+
/// Otherwise, a fallback user-agent [_kFallbackUserAgentHeader] will be used.
79+
final bool useBinding;
80+
81+
Map<String, String>? _cachedUserAgentHeader;
82+
83+
void addUserAgent(http.BaseRequest request) {
84+
if (!useBinding) {
85+
request.headers.addAll(_kFallbackUserAgentHeader);
86+
return;
87+
}
88+
89+
if (_cachedUserAgentHeader != null) {
90+
request.headers.addAll(_cachedUserAgentHeader!);
91+
return;
92+
}
93+
94+
final deviceInfo = ZulipBinding.instance.syncDeviceInfo;
95+
final packageInfo = ZulipBinding.instance.syncPackageInfo;
96+
if (deviceInfo == null || packageInfo == null) {
97+
request.headers.addAll(_kFallbackUserAgentHeader);
98+
return;
99+
}
100+
_cachedUserAgentHeader = _buildUserAgentHeader(deviceInfo, packageInfo);
101+
request.headers.addAll(_cachedUserAgentHeader!);
102+
}
103+
72104
final String? _authValue;
73105

74106
void addAuth(http.BaseRequest request) {
@@ -85,14 +117,15 @@ class ApiConnection {
85117
http.BaseRequest request, {String? overrideUserAgent}) async {
86118
assert(_isOpen);
87119

88-
assert(debugLog("${request.method} ${request.url}"));
89-
90120
addAuth(request);
91-
request.headers.addAll(userAgentHeader());
92121
if (overrideUserAgent != null) {
93122
request.headers['User-Agent'] = overrideUserAgent;
123+
} else {
124+
addUserAgent(request);
94125
}
95126

127+
assert(debugLog("${request.method} ${request.url} ${request.headers['User-Agent']}"));
128+
96129
final http.StreamedResponse response;
97130
try {
98131
response = await _client.send(request);
@@ -213,10 +246,43 @@ Map<String, String> authHeader({required String email, required String apiKey})
213246
};
214247
}
215248

249+
const _kFallbackUserAgentHeader = {'User-Agent': 'ZulipFlutter'};
250+
216251
Map<String, String> userAgentHeader() {
252+
final deviceInfo = ZulipBinding.instance.syncDeviceInfo;
253+
final packageInfo = ZulipBinding.instance.syncPackageInfo;
254+
if (deviceInfo == null || packageInfo == null) {
255+
return _kFallbackUserAgentHeader;
256+
}
257+
return _buildUserAgentHeader(deviceInfo, packageInfo);
258+
}
259+
260+
Map<String, String> _buildUserAgentHeader(BaseDeviceInfo deviceInfo, PackageInfo packageInfo) {
261+
final osInfo = switch (deviceInfo) {
262+
AndroidDeviceInfo(
263+
:var release) => 'Android $release', // "Android 14"
264+
IosDeviceInfo(
265+
:var systemVersion) => 'iOS $systemVersion', // "iOS 17.4"
266+
MacOsDeviceInfo(
267+
:var majorVersion,
268+
:var minorVersion,
269+
:var patchVersion) => 'macOS $majorVersion.$minorVersion.$patchVersion', // "macOS 14.5.0"
270+
WindowsDeviceInfo() => 'Windows', // "Windows"
271+
LinuxDeviceInfo(
272+
:var name,
273+
:var versionId) => 'Linux; $name${versionId != null ? ' $versionId' : ''}', // "Linux; Fedora Linux 40" or "Linux; Fedora Linux"
274+
_ => throw UnimplementedError(),
275+
};
276+
final PackageInfo(:version, :buildNumber) = packageInfo;
277+
278+
// Possible examples:
279+
// 'ZulipFlutter/0.0.15+15 (Android 14)'
280+
// 'ZulipFlutter/0.0.15+15 (iOS 17.4)'
281+
// 'ZulipFlutter/0.0.15+15 (macOS 14.5.0)'
282+
// 'ZulipFlutter/0.0.15+15 (Windows)'
283+
// 'ZulipFlutter/0.0.15+15 (Linux; Fedora Linux 40)'
217284
return {
218-
// TODO(#467) include platform, platform version, and app version
219-
'User-Agent': 'ZulipFlutter',
285+
'User-Agent': 'ZulipFlutter/$version+$buildNumber ($osInfo)',
220286
};
221287
}
222288

test/api/core_test.dart

Lines changed: 105 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,34 @@ import 'package:http/http.dart' as http;
66
import 'package:test/scaffolding.dart';
77
import 'package:zulip/api/core.dart';
88
import 'package:zulip/api/exception.dart';
9+
import 'package:zulip/model/binding.dart';
910
import 'package:zulip/model/localizations.dart';
1011

12+
import '../model/binding.dart';
1113
import '../stdlib_checks.dart';
1214
import 'exception_checks.dart';
1315
import 'fake_api.dart';
1416
import '../example_data.dart' as eg;
1517

1618
void main() {
19+
TestZulipBinding.ensureInitialized();
20+
tearDown(testBinding.reset);
21+
1722
test('ApiConnection.get', () async {
1823
Future<void> checkRequest(Map<String, dynamic>? params, String expectedRelativeUrl) {
19-
return FakeApiConnection.with_(account: eg.selfAccount, (connection) async {
20-
connection.prepare(json: {});
21-
await connection.get(kExampleRouteName, (json) => json, 'example/route', params);
22-
check(connection.lastRequest!).isA<http.Request>()
23-
..method.equals('GET')
24-
..url.asString.equals('${eg.realmUrl.origin}$expectedRelativeUrl')
25-
..headers.deepEquals({
26-
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
27-
...userAgentHeader(),
28-
})
29-
..body.equals('');
24+
return FakeApiConnection.with_(account: eg.selfAccount,
25+
useBinding: true,
26+
(connection) async {
27+
connection.prepare(json: {});
28+
await connection.get(kExampleRouteName, (json) => json, 'example/route', params);
29+
check(connection.lastRequest!).isA<http.Request>()
30+
..method.equals('GET')
31+
..url.asString.equals('${eg.realmUrl.origin}$expectedRelativeUrl')
32+
..headers.deepEquals({
33+
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
34+
...userAgentHeader(),
35+
})
36+
..body.equals('');
3037
});
3138
}
3239

@@ -47,19 +54,21 @@ void main() {
4754

4855
test('ApiConnection.post', () async {
4956
Future<void> checkRequest(Map<String, dynamic>? params, String expectedBody, {bool expectContentType = true}) {
50-
return FakeApiConnection.with_(account: eg.selfAccount, (connection) async {
51-
connection.prepare(json: {});
52-
await connection.post(kExampleRouteName, (json) => json, 'example/route', params);
53-
check(connection.lastRequest!).isA<http.Request>()
54-
..method.equals('POST')
55-
..url.asString.equals('${eg.realmUrl.origin}/api/v1/example/route')
56-
..headers.deepEquals({
57-
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
58-
...userAgentHeader(),
59-
if (expectContentType)
60-
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
61-
})
62-
..body.equals(expectedBody);
57+
return FakeApiConnection.with_(account: eg.selfAccount,
58+
useBinding: true,
59+
(connection) async {
60+
connection.prepare(json: {});
61+
await connection.post(kExampleRouteName, (json) => json, 'example/route', params);
62+
check(connection.lastRequest!).isA<http.Request>()
63+
..method.equals('POST')
64+
..url.asString.equals('${eg.realmUrl.origin}/api/v1/example/route')
65+
..headers.deepEquals({
66+
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
67+
...userAgentHeader(),
68+
if (expectContentType)
69+
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
70+
})
71+
..body.equals(expectedBody);
6372
});
6473
}
6574

@@ -78,26 +87,28 @@ void main() {
7887

7988
test('ApiConnection.postFileFromStream', () async {
8089
Future<void> checkRequest(List<List<int>> content, int length, String? filename) {
81-
return FakeApiConnection.with_(account: eg.selfAccount, (connection) async {
82-
connection.prepare(json: {});
83-
await connection.postFileFromStream(
84-
kExampleRouteName, (json) => json, 'example/route',
85-
Stream.fromIterable(content), length, filename: filename);
86-
check(connection.lastRequest!).isA<http.MultipartRequest>()
87-
..method.equals('POST')
88-
..url.asString.equals('${eg.realmUrl.origin}/api/v1/example/route')
89-
..headers.deepEquals({
90-
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
91-
...userAgentHeader(),
92-
})
93-
..fields.deepEquals({})
94-
..files.single.which((it) => it
95-
..field.equals('file')
96-
..length.equals(length)
97-
..filename.equals(filename)
98-
..has<Future<List<int>>>((f) => f.finalize().toBytes(), 'contents')
99-
.completes((it) => it.deepEquals(content.expand((l) => l)))
100-
);
90+
return FakeApiConnection.with_(account: eg.selfAccount,
91+
useBinding: true,
92+
(connection) async {
93+
connection.prepare(json: {});
94+
await connection.postFileFromStream(
95+
kExampleRouteName, (json) => json, 'example/route',
96+
Stream.fromIterable(content), length, filename: filename);
97+
check(connection.lastRequest!).isA<http.MultipartRequest>()
98+
..method.equals('POST')
99+
..url.asString.equals('${eg.realmUrl.origin}/api/v1/example/route')
100+
..headers.deepEquals({
101+
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
102+
...userAgentHeader(),
103+
})
104+
..fields.deepEquals({})
105+
..files.single.which((it) => it
106+
..field.equals('file')
107+
..length.equals(length)
108+
..filename.equals(filename)
109+
..has<Future<List<int>>>((f) => f.finalize().toBytes(), 'contents')
110+
.completes((it) => it.deepEquals(content.expand((l) => l)))
111+
);
101112
});
102113
}
103114

@@ -113,19 +124,21 @@ void main() {
113124

114125
test('ApiConnection.delete', () async {
115126
Future<void> checkRequest(Map<String, dynamic>? params, String expectedBody, {bool expectContentType = true}) {
116-
return FakeApiConnection.with_(account: eg.selfAccount, (connection) async {
117-
connection.prepare(json: {});
118-
await connection.delete(kExampleRouteName, (json) => json, 'example/route', params);
119-
check(connection.lastRequest!).isA<http.Request>()
120-
..method.equals('DELETE')
121-
..url.asString.equals('${eg.realmUrl.origin}/api/v1/example/route')
122-
..headers.deepEquals({
123-
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
124-
...userAgentHeader(),
125-
if (expectContentType)
126-
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
127-
})
128-
..body.equals(expectedBody);
127+
return FakeApiConnection.with_(account: eg.selfAccount,
128+
useBinding: true,
129+
(connection) async {
130+
connection.prepare(json: {});
131+
await connection.delete(kExampleRouteName, (json) => json, 'example/route', params);
132+
check(connection.lastRequest!).isA<http.Request>()
133+
..method.equals('DELETE')
134+
..url.asString.equals('${eg.realmUrl.origin}/api/v1/example/route')
135+
..headers.deepEquals({
136+
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
137+
...userAgentHeader(),
138+
if (expectContentType)
139+
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
140+
})
141+
..body.equals(expectedBody);
129142
});
130143
}
131144

@@ -308,6 +321,41 @@ void main() {
308321
check(st.toString()).contains("distinctivelyNamedFromJson");
309322
}
310323
});
324+
325+
group('ApiConnection user-agent', () {
326+
Future<void> checkUserAgent(String expectedUserAgent) async {
327+
return FakeApiConnection.with_(account: eg.selfAccount,
328+
useBinding: true,
329+
(connection) async {
330+
connection.prepare(json: {});
331+
await connection.get(kExampleRouteName, (json) => json, 'example/route', null);
332+
check(connection.lastRequest!).isA<http.Request>()
333+
.headers.deepEquals({
334+
...authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey),
335+
...{'User-Agent': expectedUserAgent},
336+
});
337+
});
338+
}
339+
340+
final packageInfo = PackageInfo(version: '0.0.1', buildNumber: '1');
341+
342+
final testCases = [
343+
('ZulipFlutter/0.0.1+1 (Android 14)', AndroidDeviceInfo(release: '14', sdkInt: 34), packageInfo),
344+
('ZulipFlutter/0.0.1+1 (iOS 17.4)', IosDeviceInfo(systemVersion: '17.4'), packageInfo),
345+
('ZulipFlutter/0.0.1+1 (macOS 14.5.0)', MacOsDeviceInfo(majorVersion: 14, minorVersion: 5, patchVersion: 0), packageInfo),
346+
('ZulipFlutter/0.0.1+1 (Windows)', WindowsDeviceInfo(), packageInfo),
347+
('ZulipFlutter/0.0.1+1 (Linux; Fedora Linux 40)', LinuxDeviceInfo(name: 'Fedora Linux', versionId: '40'), packageInfo),
348+
('ZulipFlutter/0.0.1+1 (Linux; Fedora Linux)', LinuxDeviceInfo(name: 'Fedora Linux', versionId: null), packageInfo),
349+
];
350+
351+
for (final (userAgent, deviceInfo, packageInfo) in testCases) {
352+
test('matches $userAgent', () async {
353+
testBinding.deviceInfoResult = deviceInfo;
354+
testBinding.packageInfoResult = packageInfo;
355+
await checkUserAgent(userAgent);
356+
});
357+
}
358+
});
311359
}
312360

313361
class DistinctiveError extends Error {

test/api/fake_api.dart

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,27 +134,31 @@ class FakeApiConnection extends ApiConnection {
134134
int? zulipFeatureLevel = eg.futureZulipFeatureLevel,
135135
String? email,
136136
String? apiKey,
137+
bool useBinding = false,
137138
}) : this._(
138139
realmUrl: realmUrl ?? eg.realmUrl,
139140
zulipFeatureLevel: zulipFeatureLevel,
140141
email: email,
141142
apiKey: apiKey,
142143
client: FakeHttpClient(),
144+
useBinding: useBinding,
143145
);
144146

145-
FakeApiConnection.fromAccount(Account account)
147+
FakeApiConnection.fromAccount(Account account, bool useBinding)
146148
: this(
147149
realmUrl: account.realmUrl,
148150
zulipFeatureLevel: account.zulipFeatureLevel,
149151
email: account.email,
150-
apiKey: account.apiKey);
152+
apiKey: account.apiKey,
153+
useBinding: useBinding);
151154

152155
FakeApiConnection._({
153156
required super.realmUrl,
154157
required super.zulipFeatureLevel,
155158
super.email,
156159
super.apiKey,
157160
required this.client,
161+
required super.useBinding,
158162
}) : super(client: client);
159163

160164
final FakeHttpClient client;
@@ -171,12 +175,16 @@ class FakeApiConnection extends ApiConnection {
171175
Uri? realmUrl,
172176
int? zulipFeatureLevel = eg.futureZulipFeatureLevel,
173177
Account? account,
178+
bool useBinding = false,
174179
}) async {
175180
assert((account == null)
176181
|| (realmUrl == null && zulipFeatureLevel == eg.futureZulipFeatureLevel));
177182
final connection = (account != null)
178-
? FakeApiConnection.fromAccount(account)
179-
: FakeApiConnection(realmUrl: realmUrl, zulipFeatureLevel: zulipFeatureLevel);
183+
? FakeApiConnection.fromAccount(account, useBinding)
184+
: FakeApiConnection(
185+
realmUrl: realmUrl,
186+
zulipFeatureLevel: zulipFeatureLevel,
187+
useBinding: useBinding);
180188
try {
181189
return await fn(connection);
182190
} finally {

0 commit comments

Comments
 (0)