Skip to content

Commit dc3c05b

Browse files
committed
feat: Send platform information in user-agent with API requests, add opt-out for this feature
1 parent 404d180 commit dc3c05b

7 files changed

Lines changed: 242 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
## [Unreleased]
88
### Added
99
* Script to check our source code for license headers and a step for them in the CI.
10+
* Added system and java version information to the user-agent string that is sent with API calls, along with an opt-out.
11+
* Added method for applications that use this library to identify themselves in API requests they make.
12+
1013

1114
## [1.1.0] - 2023-01-26
1215
### Added

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,26 @@ All module functions may raise `DeepLException` or one of its subclasses. If
517517
invalid arguments are provided, they may raise the standard exceptions
518518
`IllegalArgumentException`.
519519

520+
### Writing a Plugin
521+
522+
If you use this library in an application, please identify the application with
523+
`TranslatorOptions.setAppInfo()`, which takes the name and version of the app:
524+
525+
```java
526+
class Example { // Continuing class Example from above
527+
public void configurationExample() throws Exception {
528+
TranslatorOptions options =
529+
new TranslatorOptions().setAppInfo("my-java-translation-plugin", "1.2.3");
530+
Translator translator = new Translator(authKey, options);
531+
}
532+
}
533+
```
534+
535+
This information is passed along when the library makes calls to the DeepL API.
536+
Both name and version are required. Please note that setting the `User-Agent` header
537+
via `TranslatorOptions.setHeaders()` will override this setting, if you need to use this,
538+
please manually identify your Application in the `User-Agent` header.
539+
520540
### Configuration
521541

522542
The `Translator` constructor accepts `TranslatorOptions` as a second argument,
@@ -546,6 +566,34 @@ The available options setters are:
546566
purposes. By default, the correct DeepL API (Free or Pro) is automatically
547567
selected.
548568

569+
#### Anonymous platform information
570+
571+
By default, we send some basic information about the platform the client library is running on with each request, see [here for an explanation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent). This data is completely anonymous and only used to improve our product, not track any individual users. If you do not wish to send this data, you can opt-out when creating your `Translator` object by calling the `setSendPlatformInfo()` setter on the `TranslatorOptions` like so:
572+
573+
```java
574+
class Example { // Continuing class Example from above
575+
public void configurationExample() throws Exception {
576+
TranslatorOptions options =
577+
new TranslatorOptions().setSendPlatformInfo(false);
578+
Translator translator = new Translator(authKey, options);
579+
}
580+
}
581+
```
582+
583+
You can also customize the `User-Agent` header by setting its value explicitly in the `TranslatorOptions` object via the header field. Example:
584+
585+
```java
586+
class Example { // Continuing class Example from above
587+
public void configurationExample() throws Exception {
588+
Map<String, String> headers = new HashMap<>();
589+
headers.put("User-Agent", "my custom user agent");
590+
TranslatorOptions options =
591+
new TranslatorOptions().setHeaders(headers);
592+
Translator translator = new Translator(authKey, options);
593+
}
594+
}
595+
```
596+
549597
## Issues
550598

551599
If you experience problems using the library, or would like to request a new

deepl-java/build.gradle.kts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ plugins {
88
group = "com.deepl.api"
99
version = "1.1.0"
1010

11+
val sharedManifest = the<JavaPluginConvention>().manifest {
12+
attributes (
13+
"Implementation-Title" to "Gradle",
14+
"Implementation-Version" to version
15+
)
16+
}
1117
java {
1218
sourceCompatibility = JavaVersion.VERSION_1_8
1319
targetCompatibility = JavaVersion.VERSION_1_8
@@ -20,6 +26,7 @@ repositories {
2026
dependencies {
2127
implementation("org.jetbrains:annotations:20.1.0")
2228
testImplementation("org.junit.jupiter:junit-jupiter:5.8.1")
29+
testImplementation("org.mockito:mockito-inline:4.11.0")
2330

2431
api("org.apache.commons:commons-math3:3.6.1")
2532

@@ -42,11 +49,17 @@ spotless {
4249
tasks.register<Jar>("sourcesJar") {
4350
archiveClassifier.set("sources")
4451
from(sourceSets.main.get().allJava)
52+
manifest = project.the<JavaPluginConvention>().manifest {
53+
from(sharedManifest)
54+
}
4555
}
4656

4757
tasks.register<Jar>("javadocJar") {
4858
archiveClassifier.set("javadoc")
4959
from(tasks.javadoc.get().destinationDir)
60+
manifest = project.the<JavaPluginConvention>().manifest {
61+
from(sharedManifest)
62+
}
5063
}
5164

5265
publishing {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright 2023 DeepL SE (https://www.deepl.com)
2+
// Use of this source code is governed by an MIT
3+
// license that can be found in the LICENSE file.
4+
package com.deepl.api;
5+
6+
public class AppInfo {
7+
private String appName;
8+
private String appVersion;
9+
10+
public AppInfo(String appName, String appVersion) {
11+
this.appName = appName;
12+
this.appVersion = appVersion;
13+
}
14+
15+
public String getAppName() {
16+
return appName;
17+
}
18+
19+
public String getAppVersion() {
20+
return appVersion;
21+
}
22+
}

deepl-java/src/main/java/com/deepl/api/Translator.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ public Translator(String authKey, TranslatorOptions options) throws IllegalArgum
5555
headers.putAll(options.getHeaders());
5656
}
5757
headers.putIfAbsent("Authorization", "DeepL-Auth-Key " + authKey);
58-
headers.putIfAbsent("User-Agent", "deepl-java/1.1.0");
58+
headers.putIfAbsent(
59+
"User-Agent",
60+
constructUserAgentString(options.getSendPlatformInfo(), options.getAppInfo()));
5961

6062
this.httpClientWrapper =
6163
new HttpClientWrapper(
@@ -76,6 +78,27 @@ public Translator(String authKey) throws IllegalArgumentException {
7678
this(authKey, new TranslatorOptions());
7779
}
7880

81+
/**
82+
* Builds the user-agent String which contains platform information.
83+
*
84+
* @return A string containing the client library version, java version and operating system.
85+
*/
86+
private String constructUserAgentString(boolean sendPlatformInfo, AppInfo appInfo) {
87+
StringBuilder sb = new StringBuilder();
88+
sb.append("deepl-java/1.1.0");
89+
if (sendPlatformInfo) {
90+
sb.append(" (");
91+
Properties props = System.getProperties();
92+
sb.append(props.get("os.name") + "-" + props.get("os.version") + "-" + props.get("os.arch"));
93+
sb.append(") java/");
94+
sb.append(props.get("java.version"));
95+
}
96+
if (appInfo != null) {
97+
sb.append(" " + appInfo.getAppName() + "/" + appInfo.getAppVersion());
98+
}
99+
return sb.toString();
100+
}
101+
79102
/**
80103
* Determines if the given DeepL Authentication Key belongs to an API Free account.
81104
*

deepl-java/src/main/java/com/deepl/api/TranslatorOptions.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public class TranslatorOptions {
2424
@Nullable private Proxy proxy = null;
2525
@Nullable private Map<String, String> headers = null;
2626
@Nullable private String serverUrl = null;
27+
private boolean sendPlatformInfo = true;
28+
@Nullable private AppInfo appInfo = null;
2729

2830
/**
2931
* Set the maximum number of failed attempts that {@link Translator} will retry, per request. By
@@ -69,6 +71,26 @@ public TranslatorOptions setServerUrl(String serverUrl) {
6971
return this;
7072
}
7173

74+
/**
75+
* Set whether to send basic platform information with each API call to improve DeepL products.
76+
* Defaults to `true`, set to `false` to opt out. This option will be overriden if a
77+
* `'User-agent'` header is present in this objects `headers`.
78+
*/
79+
public TranslatorOptions setSendPlatformInfo(boolean sendPlatformInfo) {
80+
this.sendPlatformInfo = sendPlatformInfo;
81+
return this;
82+
}
83+
84+
/**
85+
* Set an identifier and a version for the program/plugin that uses this Client Library. Example:
86+
* `Translator t = new Translator(myAuthKey, new TranslatorOptions()
87+
* .setAppInfo('deepl-hadoop-plugin', '1.2.0'))
88+
*/
89+
public TranslatorOptions setAppInfo(String appName, String appVersion) {
90+
this.appInfo = new AppInfo(appName, appVersion);
91+
return this;
92+
}
93+
7294
/** Gets the current maximum number of retries. */
7395
public int getMaxRetries() {
7496
return maxRetries;
@@ -93,4 +115,14 @@ public Duration getTimeout() {
93115
public @Nullable String getServerUrl() {
94116
return serverUrl;
95117
}
118+
119+
/** Gets the `sendPlatformInfo` option */
120+
public boolean getSendPlatformInfo() {
121+
return sendPlatformInfo;
122+
}
123+
124+
/** Gets the `appInfo` identifiers */
125+
public @Nullable AppInfo getAppInfo() {
126+
return appInfo;
127+
}
96128
}

deepl-java/src/test/java/com/deepl/api/GeneralTest.java

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,19 @@
33
// license that can be found in the LICENSE file.
44
package com.deepl.api;
55

6+
import static org.mockito.Mockito.*;
7+
68
import java.io.*;
79
import java.net.*;
810
import java.time.*;
911
import java.util.*;
12+
import java.util.stream.Stream;
1013
import org.junit.jupiter.api.*;
14+
import org.junit.jupiter.params.ParameterizedTest;
15+
import org.junit.jupiter.params.provider.Arguments;
16+
import org.junit.jupiter.params.provider.MethodSource;
17+
import org.mockito.MockedConstruction;
18+
import org.mockito.Mockito;
1119

1220
class GeneralTest extends TestBase {
1321

@@ -237,4 +245,96 @@ void testUsageTeamDocumentLimit() throws Exception {
237245
Assertions.assertNotNull(usage.getTeamDocument());
238246
Assertions.assertTrue(usage.getTeamDocument().limitReached());
239247
}
248+
249+
@ParameterizedTest
250+
@MethodSource("provideUserAgentTestData")
251+
void testUserAgent(
252+
SessionOptions sessionOptions,
253+
TranslatorOptions translatorOptions,
254+
Iterable<String> requiredStrings,
255+
Iterable<String> blocklistedStrings)
256+
throws Exception {
257+
Map<String, String> headers = new HashMap<>();
258+
HttpURLConnection con = Mockito.mock(HttpURLConnection.class);
259+
Mockito.doAnswer(
260+
invocation -> {
261+
String key = (String) invocation.getArgument(0);
262+
String value = (String) invocation.getArgument(1);
263+
headers.put(key, value);
264+
return null;
265+
})
266+
.when(con)
267+
.setRequestProperty(Mockito.any(String.class), Mockito.any(String.class));
268+
Mockito.when(con.getResponseCode()).thenReturn(200);
269+
try (MockedConstruction<URL> mockUrl =
270+
Mockito.mockConstruction(
271+
URL.class,
272+
(mock, context) -> {
273+
Mockito.when(mock.openConnection()).thenReturn(con);
274+
})) {
275+
Translator translator = createTranslator(sessionOptions, translatorOptions);
276+
Usage usage = translator.getUsage();
277+
String userAgentHeader = headers.get("User-Agent");
278+
for (String s : requiredStrings) {
279+
Assertions.assertTrue(
280+
userAgentHeader.contains(s),
281+
String.format(
282+
"Expected User-Agent header to contain %s\nActual:\n%s", s, userAgentHeader));
283+
}
284+
for (String n : blocklistedStrings) {
285+
Assertions.assertFalse(
286+
userAgentHeader.contains(n),
287+
String.format(
288+
"Expected User-Agent header not to contain %s\nActual:\n%s", n, userAgentHeader));
289+
}
290+
}
291+
}
292+
293+
// Session options & Translator options: Used to construct the `Translator`
294+
// Next arg: List of Strings that must be contained in the user agent header
295+
// Last arg: List of Strings that must not be contained in the user agent header
296+
private static Stream<? extends Arguments> provideUserAgentTestData() {
297+
Map<String, String> testHeaders = new HashMap<>();
298+
testHeaders.put("User-Agent", "my custom user agent");
299+
Iterable<String> lightPlatformInfo = Arrays.asList("deepl-java/");
300+
Iterable<String> lightPlatformInfoWithAppInfo =
301+
Arrays.asList("deepl", "my-java-translation-plugin/1.2.3");
302+
Iterable<String> detailedPlatformInfo = Arrays.asList(" java/", "(");
303+
Iterable<String> detailedPlatformInfoWithAppInfo =
304+
Arrays.asList(" java/", "(", "my-java-translation-plugin/1.2.3");
305+
Iterable<String> customUserAgent = Arrays.asList("my custom user agent");
306+
Iterable<String> noStrings = new ArrayList<String>();
307+
return Stream.of(
308+
Arguments.of(
309+
new SessionOptions(), new TranslatorOptions(), detailedPlatformInfo, noStrings),
310+
Arguments.of(
311+
new SessionOptions(),
312+
new TranslatorOptions().setSendPlatformInfo(false),
313+
lightPlatformInfo,
314+
detailedPlatformInfo),
315+
Arguments.of(
316+
new SessionOptions(),
317+
new TranslatorOptions().setHeaders(testHeaders),
318+
customUserAgent,
319+
detailedPlatformInfo),
320+
Arguments.of(
321+
new SessionOptions(),
322+
new TranslatorOptions().setAppInfo("my-java-translation-plugin", "1.2.3"),
323+
detailedPlatformInfoWithAppInfo,
324+
noStrings),
325+
Arguments.of(
326+
new SessionOptions(),
327+
new TranslatorOptions()
328+
.setSendPlatformInfo(false)
329+
.setAppInfo("my-java-translation-plugin", "1.2.3"),
330+
lightPlatformInfoWithAppInfo,
331+
detailedPlatformInfo),
332+
Arguments.of(
333+
new SessionOptions(),
334+
new TranslatorOptions()
335+
.setHeaders(testHeaders)
336+
.setAppInfo("my-java-translation-plugin", "1.2.3"),
337+
customUserAgent,
338+
detailedPlatformInfoWithAppInfo));
339+
}
240340
}

0 commit comments

Comments
 (0)