From 0d206c97bba2b888718141b11e05a7491cde4733 Mon Sep 17 00:00:00 2001 From: Gaston Thea Date: Tue, 15 Jul 2025 11:44:28 -0300 Subject: [PATCH] HttpResponse adapter --- .../HttpResponseConnectionAdapter.java | 391 ++++++++++++++++++ .../HttpResponseConnectionAdapterTest.java | 374 +++++++++++++++++ 2 files changed, 765 insertions(+) create mode 100644 src/main/java/io/split/android/client/network/HttpResponseConnectionAdapter.java create mode 100644 src/test/java/io/split/android/client/network/HttpResponseConnectionAdapterTest.java diff --git a/src/main/java/io/split/android/client/network/HttpResponseConnectionAdapter.java b/src/main/java/io/split/android/client/network/HttpResponseConnectionAdapter.java new file mode 100644 index 000000000..f1ebea5c7 --- /dev/null +++ b/src/main/java/io/split/android/client/network/HttpResponseConnectionAdapter.java @@ -0,0 +1,391 @@ +package io.split.android.client.network; + +import androidx.annotation.NonNull; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.Permission; +import java.security.cert.Certificate; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; + +/** + * Adapter that wraps an HttpResponse as an HttpURLConnection. + *

+ * This is only used to adapt the response from the CONNECT method. + */ +class HttpResponseConnectionAdapter extends HttpsURLConnection { + + private final HttpResponse mResponse; + private final URL mUrl; + private final Certificate[] mServerCertificates; + + /** + * Creates an adapter that wraps an HttpResponse as an HttpURLConnection. + * + * @param url The URL of the request + * @param response The HTTP response from the SSL proxy + * @param serverCertificates The server certificates from the SSL connection + */ + HttpResponseConnectionAdapter(@NonNull URL url, + @NonNull HttpResponse response, + Certificate[] serverCertificates) { + super(url); + mUrl = url; + mResponse = response; + mServerCertificates = serverCertificates; + } + + @Override + public int getResponseCode() throws IOException { + return mResponse.getHttpStatus(); + } + + @Override + public String getResponseMessage() throws IOException { + // Map common HTTP status codes to messages + switch (mResponse.getHttpStatus()) { + case 200: + return "OK"; + case 400: + return "Bad Request"; + case 401: + return "Unauthorized"; + case 403: + return "Forbidden"; + case 404: + return "Not Found"; + case 500: + return "Internal Server Error"; + default: + return "HTTP " + mResponse.getHttpStatus(); + } + } + + @Override + public InputStream getInputStream() throws IOException { + if (mResponse.getHttpStatus() >= 400) { + throw new IOException("HTTP " + mResponse.getHttpStatus()); + } + String data = mResponse.getData(); + if (data == null) { + data = ""; + } + return new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public InputStream getErrorStream() { + if (mResponse.getHttpStatus() >= 400) { + String data = mResponse.getData(); + if (data == null) { + data = ""; + } + return new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)); + } + return null; + } + + @Override + public void connect() throws IOException { + // Already connected + } + + @Override + public boolean usingProxy() { + return true; + } + + @Override + public void disconnect() { + } + + // Required abstract method implementations for HTTPS connection + @Override + public String getCipherSuite() { + return null; + } + + @Override + public Certificate[] getLocalCertificates() { + return null; + } + + @Override + public Certificate[] getServerCertificates() { + // Return the server certificates from the SSL connection + return mServerCertificates; + } + + // Minimal implementations for other required methods + @Override + public void setRequestMethod(String method) { + } + + @Override + public String getRequestMethod() { + return "GET"; + } + + @Override + public void setInstanceFollowRedirects(boolean followRedirects) { + } + + @Override + public boolean getInstanceFollowRedirects() { + return true; + } + + @Override + public void setDoOutput(boolean doOutput) { + } + + @Override + public boolean getDoOutput() { + return false; + } + + @Override + public void setDoInput(boolean doInput) { + } + + @Override + public boolean getDoInput() { + return true; + } + + @Override + public void setUseCaches(boolean useCaches) { + } + + @Override + public boolean getUseCaches() { + return false; + } + + @Override + public void setIfModifiedSince(long ifModifiedSince) { + } + + @Override + public long getIfModifiedSince() { + return 0; + } + + @Override + public void setDefaultUseCaches(boolean defaultUseCaches) { + } + + @Override + public boolean getDefaultUseCaches() { + return false; + } + + @Override + public void setRequestProperty(String key, String value) { + } + + @Override + public void addRequestProperty(String key, String value) { + } + + @Override + public String getRequestProperty(String key) { + return null; + } + + @Override + public Map> getRequestProperties() { + return null; + } + + @Override + public String getHeaderField(String name) { + if (name == null) { + return null; + } + Map> headers = getHeaderFields(); + List values = headers.get(name.toLowerCase()); + + return (values != null && !values.isEmpty()) ? values.get(0) : null; + } + + @Override + public Map> getHeaderFields() { + Map> headers = new HashMap<>(); + + // Add synthetic headers based on response data + String contentType = getContentType(); + if (contentType != null) { + headers.put("content-type", Collections.singletonList(contentType)); + } + + long contentLength = getContentLengthLong(); + if (contentLength >= 0) { + headers.put("content-length", Collections.singletonList(String.valueOf(contentLength))); + } + + String contentEncoding = getContentEncoding(); + if (contentEncoding != null) { + headers.put("content-encoding", Collections.singletonList(contentEncoding)); + } + + try { + headers.put("status", Collections.singletonList(getResponseCode() + " " + getResponseMessage())); + } catch (IOException e) { + // Ignore if we can't get response code + } + + return headers; + } + + @Override + public int getHeaderFieldInt(String name, int defaultValue) { + String value = getHeaderField(name); + if (value != null) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + // Fall through to default + } + } + return defaultValue; + } + + @Override + public long getHeaderFieldDate(String name, long defaultValue) { + // We don't have actual date headers + if ("date".equalsIgnoreCase(name)) { + return System.currentTimeMillis(); + } + return defaultValue; + } + + @Override + public String getHeaderFieldKey(int n) { + Map> headers = getHeaderFields(); + if (n >= 0 && n < headers.size()) { + return (String) headers.keySet().toArray()[n]; + } + return null; + } + + @Override + public String getHeaderField(int n) { + String key = getHeaderFieldKey(n); + return key != null ? getHeaderField(key) : null; + } + + @Override + public long getContentLengthLong() { + String data = mResponse.getData(); + if (data == null) { + return 0; + } + return data.getBytes(StandardCharsets.UTF_8).length; + } + + @Override + public String getContentType() { + // Try to detect content type from response data, default to JSON for API responses + String data = mResponse.getData(); + if (data == null || data.trim().isEmpty()) { + return null; + } + String trimmed = data.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + return "application/json; charset=utf-8"; + } + if (trimmed.startsWith("<")) { + return "text/html; charset=utf-8"; + } + return "text/plain; charset=utf-8"; + } + + @Override + public String getContentEncoding() { + return "utf-8"; + } + + @Override + public long getExpiration() { + return 0; + } + + @Override + public long getDate() { + return System.currentTimeMillis(); + } + + @Override + public long getLastModified() { + return 0; + } + + @Override + public URL getURL() { + return mUrl; + } + + @Override + public int getContentLength() { + long length = getContentLengthLong(); + return length > Integer.MAX_VALUE ? -1 : (int) length; + } + + @Override + public Permission getPermission() throws IOException { + return null; + } + + @Override + public OutputStream getOutputStream() throws IOException { + throw new IOException("Output not supported for SSL proxy responses"); + } + + @Override + public void setConnectTimeout(int timeout) { + } + + @Override + public int getConnectTimeout() { + return 0; + } + + @Override + public void setReadTimeout(int timeout) { + } + + @Override + public int getReadTimeout() { + return 0; + } + + @Override + public void setHostnameVerifier(HostnameVerifier v) { + } + + @Override + public HostnameVerifier getHostnameVerifier() { + return null; + } + + @Override + public void setSSLSocketFactory(SSLSocketFactory sf) { + } + + @Override + public SSLSocketFactory getSSLSocketFactory() { + return null; + } +} diff --git a/src/test/java/io/split/android/client/network/HttpResponseConnectionAdapterTest.java b/src/test/java/io/split/android/client/network/HttpResponseConnectionAdapterTest.java new file mode 100644 index 000000000..5baa35e52 --- /dev/null +++ b/src/test/java/io/split/android/client/network/HttpResponseConnectionAdapterTest.java @@ -0,0 +1,374 @@ +package io.split.android.client.network; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.cert.Certificate; +import java.util.List; +import java.util.Map; + +public class HttpResponseConnectionAdapterTest { + + @Mock + private HttpResponse mMockResponse; + + @Mock + private Certificate mMockCertificate; + + private URL mTestUrl; + private Certificate[] mTestCertificates; + private HttpResponseConnectionAdapter mAdapter; + + @Before + public void setUp() throws MalformedURLException { + mMockCertificate = mock(Certificate.class); + mMockResponse = mock(HttpResponse.class); + mTestUrl = new URL("https://example.com/test"); + mTestCertificates = new Certificate[]{mMockCertificate}; + } + + @Test + public void responseCodeIsValueFromResponse() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(200); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + assertEquals(200, mAdapter.getResponseCode()); + } + + @Test + public void successfulResponse() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(200); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + assertEquals("OK", mAdapter.getResponseMessage()); + } + + @Test + public void status400Response() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(400); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + assertEquals("Bad Request", mAdapter.getResponseMessage()); + } + + @Test + public void status401Response() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(401); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + assertEquals("Unauthorized", mAdapter.getResponseMessage()); + } + + @Test + public void status403Response() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(403); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + assertEquals("Forbidden", mAdapter.getResponseMessage()); + } + + @Test + public void status404Response() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(404); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + assertEquals("Not Found", mAdapter.getResponseMessage()); + } + + @Test + public void status500Response() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(500); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + assertEquals("Internal Server Error", mAdapter.getResponseMessage()); + } + + @Test + public void statusUnknownResponse() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(418); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + assertEquals("HTTP 418", mAdapter.getResponseMessage()); + } + + @Test + public void successfulInputStream() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(200); + when(mMockResponse.getData()).thenReturn("test data"); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + InputStream inputStream = mAdapter.getInputStream(); + assertNotNull(inputStream); + + byte[] buffer = new byte[1024]; + int bytesRead = inputStream.read(buffer); + String result = new String(buffer, 0, bytesRead, StandardCharsets.UTF_8); + assertEquals("test data", result); + } + + @Test + public void nullDataInputStream() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(200); + when(mMockResponse.getData()).thenReturn(null); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + InputStream inputStream = mAdapter.getInputStream(); + assertNotNull(inputStream); + + byte[] buffer = new byte[1024]; + int bytesRead = inputStream.read(buffer); + assertEquals(-1, bytesRead); + } + + @Test(expected = IOException.class) + public void inputStreamErrorStatusThrows() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(400); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + mAdapter.getInputStream(); + } + + @Test(expected = IOException.class) + public void inputStream500Throws() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(500); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + mAdapter.getInputStream(); + } + + @Test + public void status400ErrorStream() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(400); + when(mMockResponse.getData()).thenReturn("error message"); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + InputStream errorStream = mAdapter.getErrorStream(); + assertNotNull(errorStream); + + byte[] buffer = new byte[1024]; + int bytesRead = errorStream.read(buffer); + String result = new String(buffer, 0, bytesRead, StandardCharsets.UTF_8); + assertEquals("error message", result); + } + + @Test + public void errorStreamStatus500() throws IOException { + when(mMockResponse.getHttpStatus()).thenReturn(500); + when(mMockResponse.getData()).thenReturn(null); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + InputStream errorStream = mAdapter.getErrorStream(); + assertNotNull(errorStream); + + byte[] buffer = new byte[1024]; + int bytesRead = errorStream.read(buffer); + assertEquals(-1, bytesRead); // Empty stream + } + + @Test + public void errorStreamIsNullForSuccessful() { + when(mMockResponse.getHttpStatus()).thenReturn(200); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + InputStream errorStream = mAdapter.getErrorStream(); + assertNull(errorStream); + } + + @Test + public void usingProxyIsAlwaysTrue() { + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + assertTrue(mAdapter.usingProxy()); // This is only used for Proxy + } + + @Test + public void getServerCertificatesReturnsCerts() { + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + Certificate[] certificates = mAdapter.getServerCertificates(); + assertSame(mTestCertificates, certificates); + assertEquals(1, certificates.length); + assertSame(mMockCertificate, certificates[0]); + } + + @Test + public void nullServerCertificates() { + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, null); + + Certificate[] certificates = mAdapter.getServerCertificates(); + assertNull(certificates); + } + + @Test + public void contentTypeIsJsonForJsonData() { + when(mMockResponse.getData()).thenReturn("{\"key\": \"value\"}"); + when(mMockResponse.getHttpStatus()).thenReturn(200); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + String contentType = mAdapter.getHeaderField("content-type"); + assertEquals("application/json; charset=utf-8", contentType); + } + + @Test + public void nullHeaderField() { + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + String result = mAdapter.getHeaderField(null); + assertNull(result); + } + + @Test + public void getHeaderIsCaseInsensitive() { + when(mMockResponse.getData()).thenReturn("{\"key\": \"value\"}"); + when(mMockResponse.getHttpStatus()).thenReturn(200); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + String contentType1 = mAdapter.getHeaderField("Content-Type"); + String contentType2 = mAdapter.getHeaderField("CONTENT-TYPE"); + String contentType3 = mAdapter.getHeaderField("content-type"); + + assertEquals("application/json; charset=utf-8", contentType1); + assertEquals("application/json; charset=utf-8", contentType2); + assertEquals("application/json; charset=utf-8", contentType3); + } + + @Test + public void generatedHeaderFieldsCanBeRetrieved() throws IOException { + when(mMockResponse.getData()).thenReturn("{\"test\": \"data\"}"); + when(mMockResponse.getHttpStatus()).thenReturn(200); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + Map> headers = mAdapter.getHeaderFields(); + assertNotNull(headers); + + assertTrue(headers.containsKey("content-type")); + assertEquals("application/json; charset=utf-8", headers.get("content-type").get(0)); + + assertTrue(headers.containsKey("content-length")); + assertEquals("16", headers.get("content-length").get(0)); + + assertTrue(headers.containsKey("content-encoding")); + assertEquals("utf-8", headers.get("content-encoding").get(0)); + + assertTrue(headers.containsKey("status")); + assertEquals("200 OK", headers.get("status").get(0)); + } + + @Test + public void getContentLengthWithData() { + when(mMockResponse.getData()).thenReturn("Hello World"); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + long length = mAdapter.getContentLengthLong(); + assertEquals(11, length); // "Hello World" is 11 bytes + } + + @Test + public void getContentLengthWithNullData() { + when(mMockResponse.getData()).thenReturn(null); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + long length = mAdapter.getContentLengthLong(); + assertEquals(0, length); + } + + @Test + public void getContentLengthEmptyData() { + when(mMockResponse.getData()).thenReturn(""); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + long length = mAdapter.getContentLengthLong(); + assertEquals(0, length); + } + + @Test + public void getContentTypeJsonData() { + when(mMockResponse.getData()).thenReturn("{\"key\": \"value\"}"); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + String contentType = mAdapter.getContentType(); + assertEquals("application/json; charset=utf-8", contentType); + } + + @Test + public void getContentTypeJsonArray() { + when(mMockResponse.getData()).thenReturn("[{\"key\": \"value\"}]"); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + String contentType = mAdapter.getContentType(); + assertEquals("application/json; charset=utf-8", contentType); + } + + @Test + public void getContentTypeHtmlData() { + when(mMockResponse.getData()).thenReturn("Test"); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + String contentType = mAdapter.getContentType(); + assertEquals("text/html; charset=utf-8", contentType); + } + + @Test + public void getContentTypeText() { + when(mMockResponse.getData()).thenReturn("Plain text content"); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + String contentType = mAdapter.getContentType(); + assertEquals("text/plain; charset=utf-8", contentType); + } + + @Test + public void getContentTypeNullDataHasNoContentType() { + when(mMockResponse.getData()).thenReturn(null); + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + String contentType = mAdapter.getContentType(); + assertNull(contentType); + } + + @Test + public void testGetContentEncoding() { + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + String encoding = mAdapter.getContentEncoding(); + assertEquals("utf-8", encoding); + } + + @Test + public void testGetDate() { + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + long currentTime = System.currentTimeMillis(); + long date = mAdapter.getDate(); + + // Should be close to current time (within 1 second) + assertTrue(Math.abs(date - currentTime) < 1000); + } + + @Test + public void urlCanBeRetrieved() { + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + + URL url = mAdapter.getURL(); + assertSame(mTestUrl, url); + } + + @Test(expected = IOException.class) + public void getOutputStreamThrows() throws IOException { + mAdapter = new HttpResponseConnectionAdapter(mTestUrl, mMockResponse, mTestCertificates); + mAdapter.getOutputStream(); + } +}