/* * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License version 2 only, as * published by the Free Software Foundation. * * This code is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License * version 2 for more details (a copy is included in the LICENSE file that * accompanied this code). * * You should have received a copy of the GNU General Public License version * 2 along with this work; if not, write to the Free Software Foundation, * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. * * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA * or visit www.oracle.com if you need additional information or have any * questions. */ /* * @test * @summary Tests that all response body is delivered to the BodySubscriber * before an abortive error terminates the flow * @modules java.net.http/jdk.internal.net.http.common * @library /test/lib * @build jdk.test.lib.net.SimpleSSLContext * @run testng/othervm ResponseBodyBeforeError */ import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.UncheckedIOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.HttpResponse.BodySubscriber; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutionException; import java.util.concurrent.Flow; import jdk.test.lib.net.SimpleSSLContext; import org.testng.annotations.AfterTest; import org.testng.annotations.BeforeTest; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLServerSocketFactory; import static java.lang.System.err; import static java.lang.System.out; import static java.net.http.HttpClient.Builder.NO_PROXY; import static java.net.http.HttpResponse.BodyHandlers.ofString; import static java.nio.charset.StandardCharsets.US_ASCII; import static java.nio.charset.StandardCharsets.UTF_8; import static org.testng.Assert.assertEquals; import static org.testng.Assert.fail; public class ResponseBodyBeforeError { ReplyingServer variableLengthServer; ReplyingServer variableLengthHttpsServer; ReplyingServer fixedLengthServer; ReplyingServer fixedLengthHttpsServer; String httpURIVarLen; String httpsURIVarLen; String httpURIFixLen; String httpsURIFixLen; SSLContext sslContext; static final String EXPECTED_RESPONSE_BODY = "

Heading

Some Text

"; @DataProvider(name = "sanity") public Object[][] sanity() { return new Object[][]{ { httpURIVarLen + "?length=all" }, { httpsURIVarLen + "?length=all" }, { httpURIFixLen + "?length=all" }, { httpsURIFixLen + "?length=all" }, }; } @Test(dataProvider = "sanity") void sanity(String url) throws Exception { HttpClient client = HttpClient.newBuilder() .proxy(NO_PROXY) .sslContext(sslContext) .build(); HttpRequest request = HttpRequest.newBuilder(URI.create(url)).build(); HttpResponse response = client.send(request, ofString()); String body = response.body(); assertEquals(body, EXPECTED_RESPONSE_BODY); client.sendAsync(request, ofString()) .thenApply(resp -> resp.body()) .thenAccept(b -> assertEquals(b, EXPECTED_RESPONSE_BODY)) .join(); } @DataProvider(name = "uris") public Object[][] variants() { Object[][] cases = new Object[][] { // The length query string is the total number of response body // bytes in the reply, before the server closes the connection. The // second arg is a partial-expected-body that the body subscriber // should receive before onError is invoked. { httpURIFixLen + "?length=0", "" }, { httpURIFixLen + "?length=1", "<" }, { httpURIFixLen + "?length=2", "

Hea" }, { httpURIFixLen + "?length=31", "

Heading

" }, { httpsURIFixLen + "?length=0", "" }, { httpsURIFixLen + "?length=1", "<" }, { httpsURIFixLen + "?length=2", "

Hea" }, { httpsURIFixLen + "?length=31", "

Heading

" }, // accounts for chunk framing { httpURIVarLen + "?length=0", "" }, { httpURIVarLen + "?length=1", "" }, { httpURIVarLen + "?length=2", "" }, { httpURIVarLen + "?length=4", "<" }, { httpURIVarLen + "?length=5", "" }, { httpURIVarLen + "?length=21", "" }, // boundary around chunk framing { httpURIVarLen + "?length=22", "" }, { httpURIVarLen + "?length=23", "" }, { httpURIVarLen + "?length=24", "" }, { httpURIVarLen + "?length=25", "" }, { httpURIVarLen + "?length=26", "" }, { httpURIVarLen + "?length=27", "<" }, { httpURIVarLen + "?length=51", "

Heading

" }, { httpsURIVarLen + "?length=0", "" }, { httpsURIVarLen + "?length=1", "" }, { httpsURIVarLen + "?length=2", "" }, { httpsURIVarLen + "?length=4", "<" }, { httpsURIVarLen + "?length=5", "" }, { httpsURIVarLen + "?length=21", "" }, { httpsURIVarLen + "?length=22", "" }, { httpsURIVarLen + "?length=23", "" }, { httpsURIVarLen + "?length=24", "" }, { httpsURIVarLen + "?length=25", "" }, { httpsURIVarLen + "?length=26", "" }, { httpsURIVarLen + "?length=27", "<" }, { httpsURIVarLen + "?length=51", "

Heading

" }, }; List list = new ArrayList<>(); Arrays.asList(cases).stream() .map(e -> new Object[] {e[0], e[1], true}) // reuse client .forEach(list::add); Arrays.asList(cases).stream() .map(e -> new Object[] {e[0], e[1], false}) // do not reuse client .forEach(list::add); return list.stream().toArray(Object[][]::new); } static final int ITERATION_COUNT = 3; static final ReferenceTracker TRACKER = ReferenceTracker.INSTANCE; @Test(dataProvider = "uris") void testSynchronousAllRequestBody(String url, String expectedPatrialBody, boolean sameClient) throws Exception { out.print("---\n"); HttpClient client = null; try { for (int i = 0; i < ITERATION_COUNT; i++) { if (!sameClient || client == null) { client = HttpClient.newBuilder() .proxy(NO_PROXY) .sslContext(sslContext) .build(); TRACKER.track(client); System.gc(); } HttpRequest request = HttpRequest.newBuilder(URI.create(url)).build(); CustomBodySubscriber bs = new CustomBodySubscriber(); try { HttpResponse response = client.send(request, r -> bs); String body = response.body(); out.println(response + ": " + body); fail("UNEXPECTED RESPONSE: " + response); } catch (IOException expected) { String pm = bs.receivedAsString(); out.println("partial body received: " + pm); assertEquals(pm, expectedPatrialBody); } } } finally { client = null; System.gc(); var error = TRACKER.checkShutdown(1000); if (error != null) throw error; } } @Test(dataProvider = "uris") void testAsynchronousAllRequestBody(String url, String expectedPatrialBody, boolean sameClient) throws Exception { out.print("---\n"); HttpClient client = null; try { for (int i = 0; i < ITERATION_COUNT; i++) { if (!sameClient || client == null) { client = HttpClient.newBuilder() .proxy(NO_PROXY) .sslContext(sslContext) .build(); System.gc(); TRACKER.track(client); } HttpRequest request = HttpRequest.newBuilder(URI.create(url)).build(); CustomBodySubscriber bs = new CustomBodySubscriber(); try { HttpResponse response = client.sendAsync(request, r -> bs).get(); String body = response.body(); out.println(response + ": " + body); fail("UNEXPECTED RESPONSE: " + response); } catch (ExecutionException ee) { if (ee.getCause() instanceof IOException) { String pm = bs.receivedAsString(); out.println("partial body received: " + pm); assertEquals(pm, expectedPatrialBody); } else { throw ee; } } } } finally { client = null; System.gc(); var error = TRACKER.checkShutdown(1000); if (error != null) throw error; } } static final class CustomBodySubscriber implements BodySubscriber { Flow.Subscription subscription; private final List received = new ArrayList<>(); private final CompletableFuture cf = new CompletableFuture<>(); @Override public CompletionStage getBody() { return cf; } @Override public void onSubscribe(Flow.Subscription subscription) { out.println("CustomBodySubscriber got onSubscribe: "); this.subscription = subscription; subscription.request(1); } @Override public void onNext(List items) { out.println("CustomBodySubscriber got onNext: " + items); received.addAll(items); subscription.request(1); } @Override public void onError(Throwable expected) { out.println("CustomBodySubscriber got expected: " + expected); cf.completeExceptionally(expected); } String receivedAsString() { int size = received.stream().mapToInt(ByteBuffer::remaining).sum(); byte[] res = new byte[size]; int from = 0; for (ByteBuffer b : received) { int l = b.remaining(); b.get(res, from, l); from += l; } return new String(res, UTF_8); } @Override public void onComplete() { out.println("CustomBodySubscriber got complete: "); assert false : "Unexpected onComplete"; } } // -- infra /** * A server that replies with headers and a, possibly partial, reply, before * closing the connection. The number of body bytes of written, is * controllable through the "length" query string param in the requested * URI. */ static abstract class ReplyingServer extends Thread implements Closeable { private final String name; private final ServerSocket ss; private volatile boolean closed; private ReplyingServer(String name) throws IOException { super(name); this.name = name; ss = newServerSocket(); ss.bind(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)); this.start(); } protected ServerSocket newServerSocket() throws IOException { return new ServerSocket(); } abstract String responseHeaders(); abstract String responseBody(); @Override public void run() { int maxUnexpected = 10; // if we get there too often we may // want to reassess the diagnosis while (!closed) { boolean accepted = false; try (Socket s = ss.accept()) { out.print(name + ": got connection "); InputStream is = s.getInputStream(); URI requestMethod = readRequestMethod(is); out.print(requestMethod + " "); URI uriPath = readRequestPath(is); out.println(uriPath); readRequestHeaders(is); String query = uriPath.getRawQuery(); if (query == null) { throw new IOException("expected query not found in: " + uriPath); } String qv = query.split("=")[1]; int len; if (qv.equals("all")) { len = responseBody().getBytes(US_ASCII).length; } else { len = Integer.parseInt(qv); } // if we get an exception past this point // we will rethrow it accepted = true; OutputStream os = s.getOutputStream(); os.write(responseHeaders().getBytes(US_ASCII)); out.println(name + ": headers written, writing " + len + " body bytes"); byte[] responseBytes = responseBody().getBytes(US_ASCII); for (int i = 0; i < len; i++) { os.write(responseBytes[i]); os.flush(); } } catch (IOException | RuntimeException e) { if (!closed) { if (--maxUnexpected <= 0 || accepted) { if (e instanceof IOException io) throw new UncheckedIOException(io); else throw (RuntimeException) e; } out.println("ignoring unexpected exception: " + e); continue; } } } } static final byte[] requestEnd = new byte[] { '\r', '\n', '\r', '\n' }; // Read the request method static URI readRequestMethod(InputStream is) throws IOException { StringBuilder sb = new StringBuilder(); int r; while ((r = is.read()) != -1 && r != 0x20) { sb.append((char)r); } return URI.create(sb.toString()); } // Read the request URI path static URI readRequestPath(InputStream is) throws IOException { StringBuilder sb = new StringBuilder(); int r; while ((r = is.read()) != -1 && r != 0x20) { sb.append((char)r); } return URI.create(sb.toString()); } // Read until the end of a HTTP request headers static void readRequestHeaders(InputStream is) throws IOException { int requestEndCount = 0, r; while ((r = is.read()) != -1) { if (r == requestEnd[requestEndCount]) { requestEndCount++; if (requestEndCount == 4) { break; } } else { requestEndCount = 0; } } } public int getPort() { return ss.getLocalPort(); } @Override public void close() { if (closed) return; closed = true; try { ss.close(); } catch (IOException e) { throw new UncheckedIOException("Unexpected", e); } } } /** A server that issues a possibly-partial chunked reply. */ static class PlainVariableLengthServer extends ReplyingServer { static final String CHUNKED_RESPONSE_BODY = "6\r\n"+ "\r\n" + "6\r\n"+ "\r\n" + "10\r\n"+ "

Heading

\r\n" + "10\r\n"+ "

Some Text

\r\n" + "7\r\n"+ "\r\n" + "7\r\n"+ "\r\n" + "0\r\n"+ "\r\n"; static final String RESPONSE_HEADERS = "HTTP/1.1 200 OK\r\n" + "Content-Type: text/html; charset=utf-8\r\n" + "Transfer-Encoding: chunked\r\n" + "Connection: close\r\n\r\n"; PlainVariableLengthServer() throws IOException { super("PlainVariableLengthServer"); } protected PlainVariableLengthServer(String name) throws IOException { super(name); } @Override String responseHeaders( ) { return RESPONSE_HEADERS; } @Override String responseBody( ) { return CHUNKED_RESPONSE_BODY; } } /** A server that issues a, possibly-partial, chunked reply over SSL */ static final class SSLVariableLengthServer extends PlainVariableLengthServer { SSLVariableLengthServer() throws IOException { super("SSLVariableLengthServer"); } @Override public ServerSocket newServerSocket() throws IOException { return SSLServerSocketFactory.getDefault().createServerSocket(); } } /** A server that issues a, possibly-partial, fixed-length reply. */ static class PlainFixedLengthServer extends ReplyingServer { static final String RESPONSE_BODY = EXPECTED_RESPONSE_BODY; static final String RESPONSE_HEADERS = "HTTP/1.1 200 OK\r\n" + "Content-Type: text/html; charset=utf-8\r\n" + "Content-Length: " + RESPONSE_BODY.length() + "\r\n" + "Connection: close\r\n\r\n"; PlainFixedLengthServer() throws IOException { super("PlainFixedLengthServer"); } protected PlainFixedLengthServer(String name) throws IOException { super(name); } @Override String responseHeaders( ) { return RESPONSE_HEADERS; } @Override String responseBody( ) { return RESPONSE_BODY; } } /** A server that issues a, possibly-partial, fixed-length reply over SSL */ static final class SSLFixedLengthServer extends PlainFixedLengthServer { SSLFixedLengthServer() throws IOException { super("SSLFixedLengthServer"); } @Override public ServerSocket newServerSocket() throws IOException { return SSLServerSocketFactory.getDefault().createServerSocket(); } } static String serverAuthority(ReplyingServer server) { return InetAddress.getLoopbackAddress().getHostName() + ":" + server.getPort(); } @BeforeTest public void setup() throws Exception { sslContext = new SimpleSSLContext().get(); if (sslContext == null) throw new AssertionError("Unexpected null sslContext"); SSLContext.setDefault(sslContext); variableLengthServer = new PlainVariableLengthServer(); httpURIVarLen = "http://" + serverAuthority(variableLengthServer) + "/http1/variable/foo"; variableLengthHttpsServer = new SSLVariableLengthServer(); httpsURIVarLen = "https://" + serverAuthority(variableLengthHttpsServer) + "/https1/variable/bar"; fixedLengthServer = new PlainFixedLengthServer(); httpURIFixLen = "http://" + serverAuthority(fixedLengthServer) + "/http1/fixed/baz"; fixedLengthHttpsServer = new SSLFixedLengthServer(); httpsURIFixLen = "https://" + serverAuthority(fixedLengthHttpsServer) + "/https1/fixed/foz"; } @AfterTest public void teardown() throws Exception { variableLengthServer.close(); variableLengthHttpsServer.close(); fixedLengthServer.close(); fixedLengthHttpsServer.close(); } }