diff --git a/jujube-benchmark/Readme.md b/jujube-benchmark/Readme.md index 1766334..3ede392 100644 --- a/jujube-benchmark/Readme.md +++ b/jujube-benchmark/Readme.md @@ -1,5 +1,5 @@ # Benchmark Module -All tests run in a 2017 MacBook Pro 13" with 16GB of RAM and AdoptOpenJDK 11.0.3+7. +All tests run in a 2017 MacBook Pro 13" with 16GB of RAM, NVME storage and AdoptOpenJDK 11.0.3+7. All tests had a max heap setting (-xmx) of only 64MB. ## Results diff --git a/jujube-core/pom.xml b/jujube-core/pom.xml index a18978f..2da8ba0 100644 --- a/jujube-core/pom.xml +++ b/jujube-core/pom.xml @@ -13,7 +13,7 @@ org.apache.httpcomponents.core5 httpcore5-h2 - 5.0-beta11 + ${httpComponentsCore.version} org.slf4j @@ -47,7 +47,7 @@ org.apache.httpcomponents.client5 httpclient5 - 5.0-beta7 + 5.0 test diff --git a/jujube-core/src/main/java/org/ophion/jujube/Jujube.java b/jujube-core/src/main/java/org/ophion/jujube/Jujube.java index 6d7aa2c..e47c1c9 100644 --- a/jujube-core/src/main/java/org/ophion/jujube/Jujube.java +++ b/jujube-core/src/main/java/org/ophion/jujube/Jujube.java @@ -21,7 +21,6 @@ import java.util.Objects; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; /** * Just enough logic to turn Apache Http Core into something suited for micro services @@ -58,6 +57,14 @@ public void start() { .setCanonicalHostName(config.getServerConfig().getCanonicalHostName()) .setVersionPolicy(config.getServerConfig().getVersionPolicy()); + if (config.getServerConfig().getH2StreamListener() != null) { + bootstrap.setStreamListener(config.getServerConfig().getH2StreamListener()); + } + + if (config.getServerConfig().getHttp1StreamListener() != null) { + bootstrap.setStreamListener(config.getServerConfig().getHttp1StreamListener()); + } + config.routes() .forEach((k, v) -> bootstrap.register(k, () -> new JujubeServerExchangeHandler(config, v))); @@ -83,11 +90,18 @@ public void start() { } public void stop() { - System.out.println("> HTTP server is shutting down, awaiting in-flight requests..."); - instance.close(CloseMode.GRACEFUL); - instance.initiateShutdown(); + var shutdownDelay = config.getServerConfig().getShutDownDelay(); + System.out.println(String.format("> HTTP server is shutting down, awaiting %s for in-flight requests...", Durations.humanize(shutdownDelay))); try { - instance.awaitShutdown(TimeValue.of(100, TimeUnit.MILLISECONDS)); + if (shutdownDelay.isZero()) { + instance.close(CloseMode.IMMEDIATE); + instance.initiateShutdown(); + instance.awaitShutdown(TimeValue.ZERO_MILLISECONDS); + } else { + instance.close(CloseMode.GRACEFUL); + instance.initiateShutdown(); + instance.awaitShutdown(TimeValue.ofMilliseconds(shutdownDelay.toMillis())); + } } catch (InterruptedException e) { throw new IllegalStateException(e); } diff --git a/jujube-core/src/main/java/org/ophion/jujube/config/ServerConfig.java b/jujube-core/src/main/java/org/ophion/jujube/config/ServerConfig.java index d36d4d9..870fd70 100644 --- a/jujube-core/src/main/java/org/ophion/jujube/config/ServerConfig.java +++ b/jujube-core/src/main/java/org/ophion/jujube/config/ServerConfig.java @@ -3,6 +3,7 @@ import org.apache.hc.core5.function.Callback; import org.apache.hc.core5.function.Supplier; import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http.impl.Http1StreamListener; import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler; import org.apache.hc.core5.http.nio.ssl.BasicServerTlsStrategy; import org.apache.hc.core5.http.nio.ssl.TlsStrategy; @@ -10,6 +11,7 @@ import org.apache.hc.core5.http.protocol.UriPatternOrderedMatcher; import org.apache.hc.core5.http2.HttpVersionPolicy; import org.apache.hc.core5.http2.config.H2Config; +import org.apache.hc.core5.http2.impl.nio.H2StreamListener; import org.apache.hc.core5.http2.ssl.H2ServerTlsStrategy; import org.apache.hc.core5.reactor.IOReactorConfig; import org.apache.hc.core5.ssl.SSLContextBuilder; @@ -22,6 +24,7 @@ import javax.net.ssl.SSLContext; import java.security.KeyStore; import java.security.cert.Certificate; +import java.time.Duration; import java.util.concurrent.TimeUnit; public class ServerConfig { @@ -36,6 +39,9 @@ public class ServerConfig { private TlsStrategy tlsStrategy; private String canonicalHostName; private Http1Config http1Config = Http1Config.DEFAULT; + private H2StreamListener h2StreamListener; + private Http1StreamListener http1StreamListener; + private Duration shutDownDelay = Duration.ofMillis(100); public ServerConfig() { this.ioReactorConfig = IOReactorConfig.custom() @@ -67,6 +73,22 @@ public ServerConfig() { } } + public H2StreamListener getH2StreamListener() { + return h2StreamListener; + } + + public void setH2StreamListener(H2StreamListener h2StreamListener) { + this.h2StreamListener = h2StreamListener; + } + + public Http1StreamListener getHttp1StreamListener() { + return http1StreamListener; + } + + public void setHttp1StreamListener(Http1StreamListener http1StreamListener) { + this.http1StreamListener = http1StreamListener; + } + public DataSize getRequestEntityLimit() { return requestEntityLimit; } @@ -158,4 +180,12 @@ public Http1Config getHttp1Config() { public void setHttp1Config(Http1Config http1Config) { this.http1Config = http1Config; } + + public Duration getShutDownDelay() { + return shutDownDelay; + } + + public void setShutDownDelay(Duration shutDownDelay) { + this.shutDownDelay = shutDownDelay; + } } diff --git a/jujube-core/src/main/java/org/ophion/jujube/http/HttpResponses.java b/jujube-core/src/main/java/org/ophion/jujube/http/HttpResponses.java deleted file mode 100644 index aed9562..0000000 --- a/jujube-core/src/main/java/org/ophion/jujube/http/HttpResponses.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.ophion.jujube.http; - -import org.ophion.jujube.response.JujubeHttpResponse; - -public class HttpResponses { - public static JujubeHttpResponse ok() { - return new JujubeHttpResponse(); - } - - public static JujubeHttpResponse ok(String message) { - return new JujubeHttpResponse(message); - } -} diff --git a/jujube-core/src/main/java/org/ophion/jujube/internal/ContentAwareRequestConsumer.java b/jujube-core/src/main/java/org/ophion/jujube/internal/ContentAwareRequestConsumer.java deleted file mode 100644 index 5006098..0000000 --- a/jujube-core/src/main/java/org/ophion/jujube/internal/ContentAwareRequestConsumer.java +++ /dev/null @@ -1,139 +0,0 @@ -package org.ophion.jujube.internal; - -import org.apache.hc.core5.concurrent.FutureCallback; -import org.apache.hc.core5.http.*; -import org.apache.hc.core5.http.nio.AsyncEntityConsumer; -import org.apache.hc.core5.http.nio.AsyncRequestConsumer; -import org.apache.hc.core5.http.nio.CapacityChannel; -import org.apache.hc.core5.http.nio.entity.NoopEntityConsumer; -import org.apache.hc.core5.http.protocol.HttpContext; -import org.ophion.jujube.config.JujubeConfig; -import org.ophion.jujube.internal.util.Loggers; -import org.ophion.jujube.util.DataSize; -import org.slf4j.Logger; - -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; - -public class ContentAwareRequestConsumer implements AsyncRequestConsumer> { - private static final Logger LOG = Loggers.build(); - private final AtomicReference exceptionHolder; - private AsyncEntityConsumer consumer; - private JujubeConfig config; - private long bytesProcessed = 0; - private FutureCallback> resultCallback; - private boolean isDiscarding = false; - - @SuppressWarnings("unchecked") - public ContentAwareRequestConsumer(JujubeConfig config, EntityDetails details, AtomicReference exceptionRef) { - this.config = config; - this.exceptionHolder = exceptionRef; - - consumer = (AsyncEntityConsumer) new NoopEntityConsumer(); - - if (details != null) { - boolean isContentGreaterThanLimit = details.getContentLength() > config.getServerConfig().getRequestEntityLimit().toBytes(); - - if (!isContentGreaterThanLimit && details.getContentType() != null) { - var contentType = ContentType.parse(details.getContentType()); - if (contentType.isSameMimeType(ContentType.MULTIPART_FORM_DATA)) { - consumer = (AsyncEntityConsumer) new MultipartEntityConsumer(); - } else { - consumer = (AsyncEntityConsumer) new SizeAwareEntityConsumer(); - } - } - } - } - - @Override - public void consumeRequest(HttpRequest request, - EntityDetails entityDetails, - HttpContext context, - FutureCallback> resultCallback) throws HttpException, IOException { - - if (entityDetails != null) { - this.resultCallback = resultCallback; - - consumer.streamStart(entityDetails, new FutureCallback<>() { - @Override - public void completed(T body) { - final Message result = new Message<>(request, body); - if (resultCallback != null) { - resultCallback.completed(result); - } - } - - @Override - public void failed(Exception ex) { - if (resultCallback != null) { - resultCallback.failed(ex); - } - } - - @Override - public void cancelled() { - if (resultCallback != null) { - resultCallback.cancelled(); - } - } - }); - } else { - final Message result = new Message<>(request, null); - if (resultCallback != null) { - resultCallback.completed(result); - } - } - } - - @Override - public void failed(Exception cause) { - releaseResources(); - LOG.error("error while processing content ", cause); - throw new IllegalStateException(cause); - } - - @Override - public void updateCapacity(CapacityChannel capacityChannel) throws IOException { - consumer.updateCapacity(capacityChannel); - } - - @Override - public void consume(ByteBuffer src) throws IOException { - if (isDiscarding) { - if (LOG.isDebugEnabled()) { - LOG.debug("discarding {} bytes", src.limit()); - } - return; - } - // we need to do this here before consuming the byte buffer: - bytesProcessed += src.limit(); - - consumer.consume(src); - - var limit = config.getServerConfig().getRequestEntityLimit(); - - if (bytesProcessed > limit.toBytes()) { - var message = String.format("> ERROR: request entity size limit exceeded, stopped after processing %s and limit is %s - aborting request.", - DataSize.bytes(bytesProcessed), limit); - System.err.println(message); - exceptionHolder.set(new RequestEntityLimitExceeded(message)); - - // discarding remaining bits - isDiscarding = true; - releaseResources(); - resultCallback.completed(null); - } - } - - @Override - public void streamEnd(List trailers) throws HttpException, IOException { - consumer.streamEnd(trailers); - } - - @Override - public void releaseResources() { - consumer.releaseResources(); - } -} diff --git a/jujube-core/src/main/java/org/ophion/jujube/internal/JujubeServerExchangeHandler.java b/jujube-core/src/main/java/org/ophion/jujube/internal/JujubeServerExchangeHandler.java index 00947f9..020c3c8 100644 --- a/jujube-core/src/main/java/org/ophion/jujube/internal/JujubeServerExchangeHandler.java +++ b/jujube-core/src/main/java/org/ophion/jujube/internal/JujubeServerExchangeHandler.java @@ -16,6 +16,8 @@ import org.ophion.jujube.context.ParameterSource; import org.ophion.jujube.context.PrimitiveParameter; import org.ophion.jujube.http.MultipartEntity; +import org.ophion.jujube.internal.consumers.ContentAwareRequestConsumer; +import org.ophion.jujube.internal.consumers.RequestEntityLimitExceeded; import org.ophion.jujube.internal.util.Loggers; import org.ophion.jujube.response.HttpResponseRequestTooLarge; import org.ophion.jujube.response.HttpResponseServerError; @@ -48,7 +50,7 @@ public JujubeServerExchangeHandler(JujubeConfig config, RouteHandler handler) { protected AsyncRequestConsumer> supplyConsumer(HttpRequest request, EntityDetails entityDetails, HttpContext context) throws HttpException { LOG.debug("handling request: {}", request.toString()); - return new ContentAwareRequestConsumer<>(config, entityDetails, exceptionRef); + return new ContentAwareRequestConsumer(config, entityDetails, exceptionRef); } @Override @@ -83,7 +85,6 @@ protected void handle(Message requestMessage, AsyncServ response = new HttpResponseServerError(); } } - } // handling response: diff --git a/jujube-core/src/main/java/org/ophion/jujube/internal/consumers/ContentAwareRequestConsumer.java b/jujube-core/src/main/java/org/ophion/jujube/internal/consumers/ContentAwareRequestConsumer.java new file mode 100644 index 0000000..9f97205 --- /dev/null +++ b/jujube-core/src/main/java/org/ophion/jujube/internal/consumers/ContentAwareRequestConsumer.java @@ -0,0 +1,80 @@ +package org.ophion.jujube.internal.consumers; + +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.nio.AsyncEntityConsumer; +import org.apache.hc.core5.http.nio.entity.NoopEntityConsumer; +import org.apache.hc.core5.http.nio.support.BasicRequestConsumer; +import org.ophion.jujube.config.JujubeConfig; +import org.ophion.jujube.internal.util.Loggers; +import org.ophion.jujube.util.DataSize; +import org.slf4j.Logger; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Content request consumer that limits the number of bytes processing and delegates processing to content-aware + * consumers. + */ +public class ContentAwareRequestConsumer extends BasicRequestConsumer { + private static final Logger LOG = Loggers.build(); + private final AtomicReference exceptionHolder; + private JujubeConfig config; + private long bytesProcessed = 0; + private boolean isDiscarding = false; + + @SuppressWarnings("unchecked") + public ContentAwareRequestConsumer(JujubeConfig config, EntityDetails details, AtomicReference exceptionRef) { + super(() -> { + if (details != null) { + boolean isContentGreaterThanLimit = details.getContentLength() > config.getServerConfig().getRequestEntityLimit().toBytes(); + + if (!isContentGreaterThanLimit && details.getContentType() != null) { + var contentType = ContentType.parse(details.getContentType()); + if (contentType.isSameMimeType(ContentType.MULTIPART_FORM_DATA)) { + return new MultipartEntityConsumer(); + } else { + return new SizeAwareEntityConsumer(); + } + } + } + + //noinspection rawtypes + return (AsyncEntityConsumer) new NoopEntityConsumer(); + }); + this.config = config; + this.exceptionHolder = exceptionRef; + + } + + @Override + public void consume(ByteBuffer src) throws IOException { + //TODO: at some point we could give users the control of whether this should discard of just release + // resources and rop the connection + if (isDiscarding) { + if (LOG.isDebugEnabled()) { + LOG.debug("discarding {} bytes", src.limit()); + } + return; + } + // we need to do this here before consuming the byte buffer: + bytesProcessed += src.limit(); + super.consume(src); + + LOG.debug("consumed {}", bytesProcessed); + var limit = config.getServerConfig().getRequestEntityLimit(); + + if (bytesProcessed > limit.toBytes()) { + var message = String.format("> ERROR: request entity size limit exceeded, stopped after processing %s and limit is %s - aborting request.", + DataSize.bytes(bytesProcessed), limit); + System.err.println(message); + exceptionHolder.set(new RequestEntityLimitExceeded(message)); + + // discarding remaining bits + isDiscarding = true; + } + } +} diff --git a/jujube-core/src/main/java/org/ophion/jujube/internal/MultipartEntityConsumer.java b/jujube-core/src/main/java/org/ophion/jujube/internal/consumers/MultipartEntityConsumer.java similarity index 89% rename from jujube-core/src/main/java/org/ophion/jujube/internal/MultipartEntityConsumer.java rename to jujube-core/src/main/java/org/ophion/jujube/internal/consumers/MultipartEntityConsumer.java index 2b4fa4d..5950364 100644 --- a/jujube-core/src/main/java/org/ophion/jujube/internal/MultipartEntityConsumer.java +++ b/jujube-core/src/main/java/org/ophion/jujube/internal/consumers/MultipartEntityConsumer.java @@ -1,4 +1,4 @@ -package org.ophion.jujube.internal; +package org.ophion.jujube.internal.consumers; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.HttpEntity; @@ -18,7 +18,7 @@ import java.util.Collections; import java.util.List; -public class MultipartEntityConsumer extends AbstractBinAsyncEntityConsumer { +class MultipartEntityConsumer extends AbstractBinAsyncEntityConsumer { private static final Logger LOG = Loggers.build(); private MultipartChunkDecoder decoder; private ContentType contentType; @@ -51,18 +51,20 @@ public void onBinaryPart(PartMetadata metadata, Path contents) { } @Override - protected HttpEntity generateContent() { + protected HttpEntity generateContent() throws IOException { return new MultipartEntity(Collections.unmodifiableList(parts), this.contentType, null); } @Override protected int capacityIncrement() { + //TODO: pull this limit from the underlying decoder|config return Integer.MAX_VALUE; } @Override protected void data(ByteBuffer src, boolean endOfStream) throws IOException { decoder.decode(src, endOfStream); + src.clear(); } @Override diff --git a/jujube-core/src/main/java/org/ophion/jujube/internal/RequestEntityLimitExceeded.java b/jujube-core/src/main/java/org/ophion/jujube/internal/consumers/RequestEntityLimitExceeded.java similarity index 80% rename from jujube-core/src/main/java/org/ophion/jujube/internal/RequestEntityLimitExceeded.java rename to jujube-core/src/main/java/org/ophion/jujube/internal/consumers/RequestEntityLimitExceeded.java index 8eeeea1..39f3b4d 100644 --- a/jujube-core/src/main/java/org/ophion/jujube/internal/RequestEntityLimitExceeded.java +++ b/jujube-core/src/main/java/org/ophion/jujube/internal/consumers/RequestEntityLimitExceeded.java @@ -1,4 +1,4 @@ -package org.ophion.jujube.internal; +package org.ophion.jujube.internal.consumers; import org.apache.hc.core5.http.HttpException; diff --git a/jujube-core/src/main/java/org/ophion/jujube/internal/SizeAwareEntityConsumer.java b/jujube-core/src/main/java/org/ophion/jujube/internal/consumers/SizeAwareEntityConsumer.java similarity index 87% rename from jujube-core/src/main/java/org/ophion/jujube/internal/SizeAwareEntityConsumer.java rename to jujube-core/src/main/java/org/ophion/jujube/internal/consumers/SizeAwareEntityConsumer.java index e2dc19e..ba279b9 100644 --- a/jujube-core/src/main/java/org/ophion/jujube/internal/SizeAwareEntityConsumer.java +++ b/jujube-core/src/main/java/org/ophion/jujube/internal/consumers/SizeAwareEntityConsumer.java @@ -1,4 +1,4 @@ -package org.ophion.jujube.internal; +package org.ophion.jujube.internal.consumers; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.HttpEntity; @@ -13,7 +13,7 @@ import java.io.IOException; import java.nio.ByteBuffer; -public class SizeAwareEntityConsumer extends AbstractBinAsyncEntityConsumer { +class SizeAwareEntityConsumer extends AbstractBinAsyncEntityConsumer { private static final Logger LOG = Loggers.build(); private final TieredOutputStream buffer; private ContentType contentType; @@ -35,7 +35,7 @@ protected HttpEntity generateContent() throws IOException { @Override protected int capacityIncrement() { - return Integer.MAX_VALUE; + return (int) buffer.getLimit().toBytes(); } @Override @@ -46,6 +46,7 @@ protected void data(ByteBuffer src, boolean endOfStream) throws IOException { @Override public void releaseResources() { + LOG.debug("releasing resources"); try { buffer.close(); } catch (IOException e) { diff --git a/jujube-core/src/main/java/org/ophion/jujube/internal/util/JSON.java b/jujube-core/src/main/java/org/ophion/jujube/internal/util/JSON.java new file mode 100644 index 0000000..64fea9f --- /dev/null +++ b/jujube-core/src/main/java/org/ophion/jujube/internal/util/JSON.java @@ -0,0 +1,94 @@ +package org.ophion.jujube.internal.util; + +import java.beans.*; +import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Crude, yet self-sufficient, JSON encoding. + */ +public final class JSON { + // we ignore all base object properties: + // TODO: convert this into an array + private static List PROPERTIES_TO_IGNORE; + + static { + try { + var info = Introspector.getBeanInfo(Object.class); + var pds = info.getPropertyDescriptors(); + PROPERTIES_TO_IGNORE = Stream.of(pds) + .map(FeatureDescriptor::getName) + .collect(Collectors.toUnmodifiableList()); + + } catch (IntrospectionException e) { + throw new IllegalStateException(e); + } + } + + public static String stringify(Map contents) { + StringBuilder buffer = new StringBuilder(); + buffer.append("{"); + + Iterator> it = contents.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry pair = it.next(); + buffer.append("\""); + buffer.append(pair.getKey().toString()); + buffer.append("\""); + buffer.append(":"); + buffer.append("\""); + buffer.append(pair.getValue().toString()); + buffer.append("\""); + + if (it.hasNext()) { + buffer.append(", "); + } + } + buffer.append("}"); + + return buffer.toString(); + } + + public static String stringify(Object contents) { + StringBuilder buffer = new StringBuilder(); + buffer.append("{"); + + try { + BeanInfo info = Introspector.getBeanInfo(contents.getClass()); + var pds = info.getPropertyDescriptors(); + + Iterator it = Arrays.asList(pds).iterator(); + while (it.hasNext()) { + var pd = it.next(); + + if (PROPERTIES_TO_IGNORE.contains(pd.getName())) { + continue; + } + + buffer.append("\""); + buffer.append(pd.getDisplayName()); + buffer.append("\""); + buffer.append(":"); + buffer.append("\""); + buffer.append(pd.getReadMethod().invoke(contents).toString()); + buffer.append("\""); + + if (it.hasNext()) { + buffer.append(", "); + } + } + + + } catch (IntrospectionException | InvocationTargetException | IllegalAccessException e) { + throw new IllegalArgumentException(e); + } + + buffer.append("}"); + return buffer.toString(); + } +} diff --git a/jujube-core/src/main/java/org/ophion/jujube/internal/util/TieredOutputStream.java b/jujube-core/src/main/java/org/ophion/jujube/internal/util/TieredOutputStream.java index b665ec5..07e1d61 100644 --- a/jujube-core/src/main/java/org/ophion/jujube/internal/util/TieredOutputStream.java +++ b/jujube-core/src/main/java/org/ophion/jujube/internal/util/TieredOutputStream.java @@ -144,7 +144,7 @@ public String getContentAsText(Charset cs) throws IOException { } /** - * Returns the path of the underlying file backing this stream. If this stream has not yet spilled out to its file + * Returns the path of the underlying file backing this stream. If this stream has not yet spilled over to its file * based tier, we perform a full dump first. * For a more efficient way to iterate over this buffer see @{link getContentAsStream}. * @@ -209,4 +209,8 @@ private void writeBufferToChannel() throws IOException { channel.force(true); } } + + public DataSize getLimit() { + return limit; + } } diff --git a/jujube-core/src/main/java/org/ophion/jujube/response/HttpResponses.java b/jujube-core/src/main/java/org/ophion/jujube/response/HttpResponses.java new file mode 100644 index 0000000..b7a1c4a --- /dev/null +++ b/jujube-core/src/main/java/org/ophion/jujube/response/HttpResponses.java @@ -0,0 +1,19 @@ +package org.ophion.jujube.response; + +import org.apache.hc.core5.http.HttpStatus; + +public class HttpResponses { + public static JujubeHttpResponse ok() { + return new JujubeHttpResponse(); + } + + public static JujubeHttpResponse ok(String message) { + return new JujubeHttpResponse(message); + } + + public static JujubeHttpResponse badRequest(String message) { + var r = new JujubeHttpResponse(HttpStatus.SC_BAD_REQUEST); + r.setContent(message); + return r; + } +} diff --git a/jujube-core/src/test/java/org/ophion/jujube/IntegrationTest.java b/jujube-core/src/test/java/org/ophion/jujube/IntegrationTest.java index b99b07e..4229058 100644 --- a/jujube-core/src/test/java/org/ophion/jujube/IntegrationTest.java +++ b/jujube-core/src/test/java/org/ophion/jujube/IntegrationTest.java @@ -26,6 +26,7 @@ import java.security.KeyManagementException; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; +import java.time.Duration; import java.util.Random; import java.util.concurrent.TimeUnit; @@ -71,6 +72,8 @@ void setUp() throws UnknownHostException, URISyntaxException { .setSelectInterval(TimeValue.of(100, TimeUnit.MILLISECONDS)) .build() ); + // for testing purposes we want to shut down as quickly as possible + config.getServerConfig().setShutDownDelay(Duration.ZERO); server = new Jujube(config); endpoint = URIBuilder.localhost().setPort(config.getServerConfig().getListenPort()).setScheme("https").build(); } diff --git a/jujube-core/src/test/java/org/ophion/jujube/MultiPartPostTest.java b/jujube-core/src/test/java/org/ophion/jujube/MultiPartPostTest.java index 8eb47ba..def66cd 100644 --- a/jujube-core/src/test/java/org/ophion/jujube/MultiPartPostTest.java +++ b/jujube-core/src/test/java/org/ophion/jujube/MultiPartPostTest.java @@ -15,6 +15,7 @@ import java.io.IOException; import java.nio.file.Files; +import java.util.concurrent.atomic.AtomicInteger; class MultiPartPostTest extends IntegrationTest { private static final Logger LOG = Loggers.build(); @@ -53,11 +54,13 @@ void shouldHandleMultiPartFormPosts() throws IOException { @Test void shouldStreamLargeFiles() throws IOException { var size = DataSize.megabytes(10); + AtomicInteger counter = new AtomicInteger(); config.route("/post", ctx -> { try { var file = (FileParameter) ctx.getParameter("file", ParameterSource.FORM).orElseThrow(); long bytes = Files.size(file.asPath()); Assertions.assertEquals(size.toBytes(), bytes); + counter.incrementAndGet(); } catch (IOException e) { throw new IllegalStateException(e); } @@ -80,5 +83,7 @@ void shouldStreamLargeFiles() throws IOException { Assertions.assertEquals(200, response.getCode()); return true; }); + + Assertions.assertEquals(1, counter.get()); } } diff --git a/jujube-core/src/test/java/org/ophion/jujube/SuspiciousBehaviorTest.java b/jujube-core/src/test/java/org/ophion/jujube/SuspiciousBehaviorTest.java index a9dc8b9..c8d4a9b 100644 --- a/jujube-core/src/test/java/org/ophion/jujube/SuspiciousBehaviorTest.java +++ b/jujube-core/src/test/java/org/ophion/jujube/SuspiciousBehaviorTest.java @@ -61,10 +61,8 @@ void shouldLimitPostSize() throws IOException { @Test void shouldLimitPostSizeByContentLength() throws IOException { config.route("/post", ctx -> { - Assertions.assertFalse(ctx.getParameter("file", ParameterSource.FORM).isPresent()); - var response = new JujubeHttpResponse("w00t"); - response.setCode(200); - return response; + Assertions.fail("request should not be processed"); + return null; }); config.getServerConfig().setRequestEntityLimit(DataSize.bytes(0)); diff --git a/jujube-core/src/test/java/org/ophion/jujube/internal/util/JSONTest.java b/jujube-core/src/test/java/org/ophion/jujube/internal/util/JSONTest.java new file mode 100644 index 0000000..7d57413 --- /dev/null +++ b/jujube-core/src/test/java/org/ophion/jujube/internal/util/JSONTest.java @@ -0,0 +1,49 @@ +package org.ophion.jujube.internal.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +class JSONTest { + @Test + void shouldConvertToJson() { + var json = JSON.stringify(Map.of("name", "Bob", "color", "blue")); + Assertions.assertEquals("abc", json); + } + + @Test + void shouldConvertBeansToJson() { + var json = JSON.stringify(new Human("Bob", 42, "secret")); + Assertions.assertEquals("{\"age\":\"42\", \"name\":\"Bob\"}", json); + + } + + private static class Human { + private String name; + private int age; + private String internal; + + public Human(String name, int age, String internal) { + this.name = name; + this.age = age; + this.internal = internal; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } + } +} diff --git a/jujube-example/src/main/java/org/ophion/jujube/example/resources/ChecksumResource.java b/jujube-example/src/main/java/org/ophion/jujube/example/resources/ChecksumResource.java index 94a9663..4581451 100644 --- a/jujube-example/src/main/java/org/ophion/jujube/example/resources/ChecksumResource.java +++ b/jujube-example/src/main/java/org/ophion/jujube/example/resources/ChecksumResource.java @@ -7,8 +7,8 @@ import org.ophion.jujube.context.JujubeHttpContext; import org.ophion.jujube.context.ParameterSource; import org.ophion.jujube.http.HttpConstraints; -import org.ophion.jujube.http.HttpResponses; import org.ophion.jujube.response.ClientError; +import org.ophion.jujube.response.HttpResponses; import org.ophion.jujube.response.JujubeHttpResponse; import java.io.BufferedInputStream; @@ -16,26 +16,46 @@ import java.nio.file.Files; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.Security; +import java.util.Arrays; +import java.util.stream.Collectors; /** * Sample resource that calculates the checksum of a file. + *

+ * $ curl -F "file=@/Users/rafael/Downloads/output.pdf" -vk https://localhost:8080/checksum/ */ public class ChecksumResource { public JujubeHttpResponse post(JujubeHttpContext ctx) throws NoSuchAlgorithmException, IOException { HttpConstraints.onlyAllowMediaType(ContentType.MULTIPART_FORM_DATA, ctx); - HttpConstraints.onlyAllowMethod(Method.POST, ctx); + + if (Method.GET.isSame(ctx.getRequest().getMethod())) { + var availableHashes = Arrays.stream(Security.getProviders()) + .flatMap(provider -> provider.getServices().stream()) + .filter(s -> MessageDigest.class.getSimpleName().equals(s.getType())) + .map(Provider.Service::getAlgorithm) + .collect(Collectors.joining(",")); + + var resp = String.format("Checksum calculating resource, please send us a file, and an optional hash to use. Available hashes: %s", + availableHashes); + return HttpResponses.ok(resp); + } var file = (FileParameter) ctx.getParameter("file", ParameterSource.FORM) .orElseThrow(() -> new ClientError("Oops, you must supply a file argument")); - var hash = ctx.getParameter("file", ParameterSource.FORM); + var hash = ctx.getParameter("hash", ParameterSource.FORM); + var digest = MessageDigest.getInstance("SHA-256"); - var digest = MessageDigest.getInstance("sha256"); + try { + if (hash.isPresent()) { + digest = MessageDigest.getInstance(hash.get().asText()); + } - if (hash.isPresent()) { - digest = MessageDigest.getInstance(hash.get().asText()); + } catch (NoSuchAlgorithmException ex) { + return HttpResponses.badRequest(String.format("error: %s \n", ex.getMessage())); } - try (var ins = new BufferedInputStream(Files.newInputStream(file.asPath()))) { while (ins.available() > 0) { digest.update((byte) ins.read()); @@ -43,6 +63,8 @@ public JujubeHttpResponse post(JujubeHttpContext ctx) throws NoSuchAlgorithmExce } var checksum = Hex.encodeHexString(digest.digest()); - return HttpResponses.ok(String.format("checksum: %s", checksum)); + return HttpResponses.ok(String.format("checksum:%s \n", checksum)); + + } } diff --git a/jujube-example/src/main/java/org/ophion/jujube/example/resources/EchoResource.java b/jujube-example/src/main/java/org/ophion/jujube/example/resources/EchoResource.java index 1de9bf3..4b8cdc2 100644 --- a/jujube-example/src/main/java/org/ophion/jujube/example/resources/EchoResource.java +++ b/jujube-example/src/main/java/org/ophion/jujube/example/resources/EchoResource.java @@ -16,9 +16,9 @@ public JujubeHttpResponse hello(JujubeHttpContext ctx) { } var param = ctx.getParameter("name", ParameterSource.FORM) - .orElseThrow(() -> new ClientError("visitor param is required")); + .orElseThrow(() -> new ClientError("name param is required")); - return new JujubeHttpResponse("Well, hello there:" + param.asText()); + return new JujubeHttpResponse(String.format("Well, hello there: %s!\n", param.asText())); } public JujubeHttpResponse notFound(JujubeHttpContext ctx) { diff --git a/pom.xml b/pom.xml index 3f415ad..777ad67 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,7 @@ UTF-8 3.8.1 3.0.0-M4 + 5.0