All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.launchdarkly.testhelpers.httptest.impl.NanoHttpdServerDelegate Maven / Gradle / Ivy

The newest version!
package com.launchdarkly.testhelpers.httptest.impl;

import com.google.common.collect.ImmutableMap;
import com.launchdarkly.testhelpers.httptest.Handler;
import com.launchdarkly.testhelpers.httptest.HttpServer;
import com.launchdarkly.testhelpers.httptest.RequestContext;
import com.launchdarkly.testhelpers.httptest.RequestInfo;
import com.launchdarkly.testhelpers.httptest.ServerTLSConfiguration;

import org.nanohttpd.protocols.http.IHTTPSession;
import org.nanohttpd.protocols.http.NanoHTTPD;
import org.nanohttpd.protocols.http.response.IStatus;
import org.nanohttpd.protocols.http.response.Response;
import org.nanohttpd.protocols.http.response.Status;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.net.URI;
import java.nio.charset.Charset;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

import javax.net.ssl.KeyManagerFactory;

class NanoHttpdServerDelegate implements HttpServer.Delegate {
  private final ServerImpl server;
  
  public NanoHttpdServerDelegate(int port, Handler handler, ServerTLSConfiguration tlsConfig) {
    server = new ServerImpl(port, handler, tlsConfig); // NanoHTTPD will pick a port for us if this is zero
  }
  
  @Override
  public void close() throws IOException {
    server.closeAllConnections();
    server.stop();
  }

  @Override
  public int start() throws IOException {
    server.start();
    return server.getListeningPort();
  }

  private static final class ServerImpl extends NanoHTTPD {
    private final Handler handler;
    
    ServerImpl(int port, Handler handler, ServerTLSConfiguration tlsConfig) {
      super(port);
      this.handler = handler;
      
      if (tlsConfig != null) {
        try {
          char[] fakePassword = "secret".toCharArray();
          KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
          keyStore.load(null);
          keyStore.setCertificateEntry("localhost", tlsConfig.getCertificate());
          keyStore.setEntry("localhost",
              new KeyStore.PrivateKeyEntry(tlsConfig.getPrivateKey(), new Certificate[] { tlsConfig.getCertificate() }),
              new KeyStore.PasswordProtection(fakePassword));
          KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
          keyManagerFactory.init(keyStore, fakePassword);
          makeSecure(NanoHTTPD.makeSSLSocketFactory(keyStore, keyManagerFactory), null);
        } catch (Exception e) {
          throw new RuntimeException(e);
        }
      }
    }
    
    @Override
    public Response serve(IHTTPSession session) {
      // We need to call the handler on a separate thread so that we can support chunked streaming.
      // NanoHTTPD doesn't have an imperative "start writing the response" method; instead, we need
      // to return the response to it, and *then* if there is additional streaming content, the
      // handler will continue writing it.
      CompletableFuture responseReceiver = new CompletableFuture<>();
      RequestContextImpl ctx = new RequestContextImpl(makeRequestInfo(session), responseReceiver);
      
      new Thread(() -> {
        try {
          handler.apply(ctx);
          ctx.commit();
        } catch (Exception e) {
          responseReceiver.completeExceptionally(e);
        }
      }).start();
      
      try {
        Response response = responseReceiver.get();
        return response;
      } catch (ExecutionException e) {
        throw new RuntimeException(e.getCause());
      } catch (InterruptedException e) {
        throw new RuntimeException(e.getCause());
      }
    }
   
    private RequestInfo makeRequestInfo(IHTTPSession session) {
      String path = session.getUri(); // NanoHTTPD calls this the URI but it's really the path
      String query = session.getQueryParameterString();
      String queryWithPrefix = query == null || query.isEmpty() ? "" : ("?" + query); 
      URI requestUri = URI.create(getBaseUri() + path + queryWithPrefix);
      ImmutableMap.Builder headers = ImmutableMap.builder();
      String body = "";
      int contentLength = 0;
      
      for (Map.Entry h: session.getHeaders().entrySet()) {
        headers.put(h.getKey().toLowerCase(), h.getValue());
        if (h.getKey().equalsIgnoreCase("content-length")) {
          contentLength = Integer.parseInt(h.getValue());           
        }
      }
      if (contentLength > 0) {
        try {
          InputStream bodyStream = session.getInputStream();
          byte[] data = new byte[contentLength];
          int n = bodyStream.read(data);
          body = new String(data, 0, n, Charset.forName("UTF-8"));
        } catch (Exception e) {
          throw new RuntimeException(e);
        }
      }
      
      return new RequestInfo(session.getMethod().toString(), requestUri, path,
          queryWithPrefix.isEmpty() ? null : queryWithPrefix,
          headers.build(), body);
    }
    
    private String getBaseUri() {
      return "http://" + (this.getHostname() == null ? "localhost" : this.getHostname())
          + ":" + this.getListeningPort();
    }
  }
  
  private static final class RequestContextImpl implements RequestContext {
    private final RequestInfo requestInfo;
    private final CompletableFuture responseReceiver;
    
    int status = 200;
    String contentType = null;
    Map> headers = new HashMap<>();
    
    boolean chunked = false;
    volatile Response response = null;
    PipedOutputStream chunkedPipe = null;

    RequestContextImpl(RequestInfo requestInfo, CompletableFuture responseReceiver) {
      this.requestInfo = requestInfo;
      this.responseReceiver = responseReceiver;
    }
    
    void commit() {
      if (chunked) {
        try {
          chunkedPipe.close();
        } catch (IOException e) {
          throw new RuntimeException(e);
        }
      } else {
        if (response == null) {
          // a status was set but nothing was written; call write() to force us to create a response
          write(null);
        }
        responseReceiver.complete(response);
      }      
    }
    
    @Override
    public RequestInfo getRequest() {
      return requestInfo;
    }

    @Override
    public void setStatus(int status) {
      this.status = status;
    }

    @Override
    public void setHeader(String name, String value) {
      headers.remove(name);
      addHeader(name, value);
    }

    @Override
    public void addHeader(String name, String value) {
      String lowerName = name.toLowerCase();
      List values = headers.get(lowerName);
      if (values == null) {
        values = new ArrayList<>();
        headers.put(lowerName, values);
      }
      values.add(value);
    }

    @Override
    public void setChunked() {
      if (!chunked) {
        if (response != null) {
          throw new RuntimeException("setChunked was called after writing a non-chunked response");
        }
        chunked = true;
        chunkedPipe = new PipedOutputStream();
        InputStream pipeReader;
        try {
          pipeReader = new PipedInputStream(chunkedPipe);
        } catch (IOException e) {
          throw new RuntimeException(e);
        }
        response = Response.newChunkedResponse(statusWithCode(status),
              contentType, pipeReader);
        setHeaders(response);
        response.setUseGzip(false);
        
        // We need to tell the ServerImpl code to return this response immediately to the server,
        // while the handler (which will write the actual stream data) continues executing. That's
        // what it provided this CompletableFuture for.
        responseReceiver.complete(response);
      }
    }

    @Override
    public void write(byte[] data) {
      if (chunked) {
        try {
          if (data != null) {
            chunkedPipe.write(data);
          }
          chunkedPipe.flush();
          Thread.sleep(200);
        } catch (Exception e) {
          throw new RuntimeException(e);
        }
        return;
      }

      if (response != null) {
        throw new RuntimeException("write was called twice for a non-chunked response");
      }
      if (data == null) {
        data = new byte[0];
      }
      if (data.length != 0 && contentType == null) {
        contentType = "text/plain";
      }
      response = Response.newFixedLengthResponse(statusWithCode(status),
          contentType, new ByteArrayInputStream(data), data.length);
      setHeaders(response);
    }

    @Override
    public String getPathParam(int i) {
      return null;
    }

    private void setHeaders(Response r) {
      for (Map.Entry> h: headers.entrySet()) {
        String name = h.getKey();
        String value = String.join(",", h.getValue());
        if (name.equals("content-type")) {
          r.setMimeType(value);
        } else {
          r.addHeader(name, value);
          // The name addHeader in NanoHTTPD is misleading: it replaces any previous value, so we need
          // to pre-concatenate with String.join() if we want multiple values to work.
          // https://github.com/NanoHttpd/nanohttpd/issues/629
        }
      }
    }
    
    private IStatus statusWithCode(int statusCode) {
      IStatus builtin = Status.lookup(statusCode);
      if (builtin != null) {
        return builtin;
      }
      return new IStatus() {
        @Override
        public int getRequestStatus() {
          return statusCode;
        }
        
        @Override
        public String getDescription() {
          return statusCode + " UNKNOWN";
        }
      };
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy