Skip to content

Commit aa42e78

Browse files
feat: Add StorageClient (#2275)
1 parent b74e807 commit aa42e78

File tree

4 files changed

+410
-0
lines changed

4 files changed

+410
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* See the NOTICE file distributed with this work for additional
5+
* information regarding copyright ownership.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.appium.java_client.plugins.storage;
18+
19+
import org.openqa.selenium.WebDriverException;
20+
import org.openqa.selenium.json.Json;
21+
import org.openqa.selenium.remote.ErrorCodec;
22+
import org.openqa.selenium.remote.codec.AbstractHttpResponseCodec;
23+
import org.openqa.selenium.remote.codec.w3c.W3CHttpResponseCodec;
24+
import org.openqa.selenium.remote.http.ClientConfig;
25+
import org.openqa.selenium.remote.http.Contents;
26+
import org.openqa.selenium.remote.http.HttpClient;
27+
import org.openqa.selenium.remote.http.HttpHeader;
28+
import org.openqa.selenium.remote.http.HttpMethod;
29+
import org.openqa.selenium.remote.http.HttpRequest;
30+
import org.openqa.selenium.remote.http.HttpResponse;
31+
import org.openqa.selenium.remote.http.WebSocket;
32+
33+
import java.io.File;
34+
import java.net.MalformedURLException;
35+
import java.net.URI;
36+
import java.net.URISyntaxException;
37+
import java.net.URL;
38+
import java.nio.charset.StandardCharsets;
39+
import java.util.List;
40+
import java.util.Map;
41+
import java.util.Optional;
42+
import java.util.concurrent.CountDownLatch;
43+
import java.util.concurrent.TimeUnit;
44+
import java.util.concurrent.atomic.AtomicReference;
45+
import java.util.stream.Collectors;
46+
47+
import static io.appium.java_client.plugins.storage.StorageUtils.calcSha1Digest;
48+
import static io.appium.java_client.plugins.storage.StorageUtils.streamFileToWebSocket;
49+
50+
/**
51+
* This is a Java implementation of the Appium server storage plugin client.
52+
* See <a href="https://github.com/appium/appium/blob/master/packages/storage-plugin/README.md">the plugin README</a>
53+
* for more details.
54+
*/
55+
public class StorageClient {
56+
public static final String PREFIX = "/storage";
57+
private final Json json = new Json();
58+
private final AbstractHttpResponseCodec responseCodec = new W3CHttpResponseCodec();
59+
private final ErrorCodec errorCodec = ErrorCodec.createDefault();
60+
61+
private final URL baseUrl;
62+
private final HttpClient httpClient;
63+
64+
public StorageClient(URL baseUrl) {
65+
this.baseUrl = baseUrl;
66+
this.httpClient = HttpClient.Factory.createDefault().createClient(baseUrl);
67+
}
68+
69+
public StorageClient(ClientConfig clientConfig) {
70+
this.httpClient = HttpClient.Factory.createDefault().createClient(clientConfig);
71+
this.baseUrl = clientConfig.baseUrl();
72+
}
73+
74+
/**
75+
* Adds a local file to the server storage.
76+
* The remote file name is be set to the same value as the local file name.
77+
*
78+
* @param file File instance.
79+
*/
80+
public void add(File file) {
81+
add(file, file.getName());
82+
}
83+
84+
/**
85+
* Adds a local file to the server storage.
86+
*
87+
* @param file File instance.
88+
* @param name The remote file name.
89+
*/
90+
public void add(File file, String name) {
91+
var request = new HttpRequest(HttpMethod.POST, formatPath(baseUrl, PREFIX, "add").toString());
92+
var httpResponse = httpClient.execute(setJsonPayload(request, Map.of(
93+
"name", name,
94+
"sha1", calcSha1Digest(file)
95+
)));
96+
Map<String, Object> value = requireResponseValue(httpResponse);
97+
final var wsTtlMs = (Long) value.get("ttlMs");
98+
//noinspection unchecked
99+
var wsInfo = (Map<String, Object>) value.get("ws");
100+
var streamWsPathname = (String) wsInfo.get("stream");
101+
var eventWsPathname = (String) wsInfo.get("events");
102+
final var completion = new CountDownLatch(1);
103+
final var lastException = new AtomicReference<Throwable>(null);
104+
try (var streamWs = httpClient.openSocket(
105+
new HttpRequest(HttpMethod.POST, formatPath(baseUrl, streamWsPathname).toString()),
106+
new WebSocket.Listener() {}
107+
); var eventsWs = httpClient.openSocket(
108+
new HttpRequest(HttpMethod.POST, formatPath(baseUrl, eventWsPathname).toString()),
109+
new EventWsListener(lastException, completion)
110+
)) {
111+
streamFileToWebSocket(file, streamWs);
112+
streamWs.close();
113+
if (!completion.await(wsTtlMs, TimeUnit.MILLISECONDS)) {
114+
throw new IllegalStateException(String.format(
115+
"Could not receive a confirmation about adding '%s' to the server storage within %sms timeout",
116+
name, wsTtlMs
117+
));
118+
}
119+
var exc = lastException.get();
120+
if (exc != null) {
121+
throw exc instanceof RuntimeException ? (RuntimeException) exc : new WebDriverException(exc);
122+
}
123+
} catch (InterruptedException e) {
124+
throw new WebDriverException(e);
125+
}
126+
}
127+
128+
/**
129+
* Lists items that exist in the storage.
130+
*
131+
* @return All storage items.
132+
*/
133+
public List<StorageItem> list() {
134+
var request = new HttpRequest(HttpMethod.GET, formatPath(baseUrl, PREFIX, "list").toString());
135+
var httpResponse = httpClient.execute(request);
136+
List<Map<String, Object>> items = requireResponseValue(httpResponse);
137+
return items.stream().map(item -> new StorageItem(
138+
(String) item.get("name"),
139+
(String) item.get("path"),
140+
(Long) item.get("size")
141+
)).collect(Collectors.toList());
142+
}
143+
144+
/**
145+
* Deletes an item from the server storage.
146+
*
147+
* @param name The name of the item to be deleted.
148+
* @return true if the dletion was successful.
149+
*/
150+
public boolean delete(String name) {
151+
var request = new HttpRequest(HttpMethod.POST, formatPath(baseUrl, PREFIX, "delete").toString());
152+
var httpResponse = httpClient.execute(setJsonPayload(request, Map.of(
153+
"name", name
154+
)));
155+
return requireResponseValue(httpResponse);
156+
}
157+
158+
/**
159+
* Resets all items of the server storage.
160+
*/
161+
public void reset() {
162+
var request = new HttpRequest(HttpMethod.POST, formatPath(baseUrl, PREFIX, "reset").toString());
163+
var httpResponse = httpClient.execute(request);
164+
requireResponseValue(httpResponse);
165+
}
166+
167+
private static URL formatPath(URL url, String... suffixes) {
168+
if (suffixes.length == 0) {
169+
return url;
170+
}
171+
try {
172+
var uri = url.toURI();
173+
var updatedPath = (uri.getPath() + "/" + String.join("/", suffixes)).replaceAll("(/{2,})", "/");
174+
return new URI(
175+
uri.getScheme(),
176+
uri.getAuthority(),
177+
uri.getHost(),
178+
uri.getPort(),
179+
updatedPath,
180+
uri.getQuery(),
181+
uri.getFragment()
182+
).toURL();
183+
} catch (URISyntaxException | MalformedURLException e) {
184+
throw new IllegalArgumentException(e);
185+
}
186+
}
187+
188+
private HttpRequest setJsonPayload(HttpRequest request, Map<String, Object> payload) {
189+
var strData = json.toJson(payload);
190+
var data = strData.getBytes(StandardCharsets.UTF_8);
191+
request.setHeader(HttpHeader.ContentLength.getName(), String.valueOf(data.length));
192+
request.setHeader(HttpHeader.ContentType.getName(), "application/json; charset=utf-8");
193+
request.setContent(Contents.bytes(data));
194+
return request;
195+
}
196+
197+
private <T> T requireResponseValue(HttpResponse httpResponse) {
198+
var response = responseCodec.decode(httpResponse);
199+
var value = response.getValue();
200+
if (value instanceof WebDriverException) {
201+
throw (WebDriverException) value;
202+
}
203+
//noinspection unchecked
204+
return (T) response.getValue();
205+
}
206+
207+
private final class EventWsListener implements WebSocket.Listener {
208+
private final AtomicReference<Throwable> lastException;
209+
private final CountDownLatch completion;
210+
211+
public EventWsListener(AtomicReference<Throwable> lastException, CountDownLatch completion) {
212+
this.lastException = lastException;
213+
this.completion = completion;
214+
}
215+
216+
@Override
217+
public void onBinary(byte[] data) {
218+
extractException(new String(data, StandardCharsets.UTF_8)).ifPresent(lastException::set);
219+
completion.countDown();
220+
}
221+
222+
@Override
223+
public void onText(CharSequence data) {
224+
extractException(data.toString()).ifPresent(lastException::set);
225+
completion.countDown();
226+
}
227+
228+
@Override
229+
public void onError(Throwable cause) {
230+
lastException.set(cause);
231+
completion.countDown();
232+
}
233+
234+
private Optional<WebDriverException> extractException(String payload) {
235+
try {
236+
Map<String, Object> record = json.toType(payload, Json.MAP_TYPE);
237+
//noinspection unchecked
238+
var value = (Map<String, Object>) record.get("value");
239+
if ((Boolean) value.get("success")) {
240+
return Optional.empty();
241+
}
242+
return Optional.of(errorCodec.decode(record));
243+
} catch (Exception e) {
244+
return Optional.of(new WebDriverException(payload, e));
245+
}
246+
}
247+
}
248+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package io.appium.java_client.plugins.storage;
2+
3+
import lombok.Value;
4+
5+
@Value
6+
public class StorageItem {
7+
String name;
8+
String path;
9+
long size;
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* See the NOTICE file distributed with this work for additional
5+
* information regarding copyright ownership.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.appium.java_client.plugins.storage;
18+
19+
import org.openqa.selenium.remote.http.WebSocket;
20+
21+
import java.io.BufferedInputStream;
22+
import java.io.File;
23+
import java.io.FileInputStream;
24+
import java.io.IOException;
25+
import java.io.UncheckedIOException;
26+
import java.security.MessageDigest;
27+
import java.security.NoSuchAlgorithmException;
28+
import java.util.Formatter;
29+
30+
public class StorageUtils {
31+
private static final int BUFFER_SIZE = 0xFFFF;
32+
33+
private StorageUtils() {
34+
}
35+
36+
/**
37+
* Calculates SHA1 hex digest of the given file.
38+
*
39+
* @param source The file instance to calculate the hash for.
40+
* @return Hash digest represented as a string of hexadecimal numbers.
41+
*/
42+
public static String calcSha1Digest(File source) {
43+
MessageDigest sha1sum;
44+
try {
45+
sha1sum = MessageDigest.getInstance("SHA-1");
46+
} catch (NoSuchAlgorithmException e) {
47+
throw new IllegalStateException(e);
48+
}
49+
var buffer = new byte[BUFFER_SIZE];
50+
int bytesRead;
51+
try (var in = new BufferedInputStream(new FileInputStream(source))) {
52+
while ((bytesRead = in.read(buffer)) != -1) {
53+
sha1sum.update(buffer, 0, bytesRead);
54+
}
55+
} catch (IOException e) {
56+
throw new UncheckedIOException(e);
57+
}
58+
return byteToHex(sha1sum.digest());
59+
}
60+
61+
/**
62+
* Feeds the content of the given file to the provided web socket.
63+
*
64+
* @param source The source file instance.
65+
* @param socket The destination web socket.
66+
*/
67+
public static void streamFileToWebSocket(File source, WebSocket socket) {
68+
var buffer = new byte[BUFFER_SIZE];
69+
int bytesRead;
70+
try (var in = new BufferedInputStream(new FileInputStream(source))) {
71+
while ((bytesRead = in.read(buffer)) != -1) {
72+
var currentBuffer = new byte[bytesRead];
73+
System.arraycopy(buffer, 0, currentBuffer, 0, bytesRead);
74+
socket.sendBinary(currentBuffer);
75+
}
76+
} catch (IOException e) {
77+
throw new UncheckedIOException(e);
78+
}
79+
}
80+
81+
private static String byteToHex(final byte[] hash) {
82+
var formatter = new Formatter();
83+
for (byte b : hash) {
84+
formatter.format("%02x", b);
85+
}
86+
var result = formatter.toString();
87+
formatter.close();
88+
return result;
89+
}
90+
}

0 commit comments

Comments
 (0)