enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles
This commit is contained in:
Generated
+116
-53
@@ -4,24 +4,37 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="refactored, pre-buffer reuse">
|
||||
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="optimized dynamic body size impl, decluttering javadocs/comments">
|
||||
<change afterPath="$PROJECT_DIR$/flash-bench/src/main/java/dev/relism/bench/StaticResourceManager.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-bench/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-bench/pom.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-bench/src/main/java/dev/relism/bench/Main.java" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServerConfiguration.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServerConfiguration.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/Main.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/Main.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/RequestParser.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/RequestParser.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/ContentType.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/ContentType.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/HttpStatus.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/HttpStatus.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/HeaderMap.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/HeaderMap.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/LazyBody.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/LazyBody.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/PathParams.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/PathParams.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/LazyBody.java" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/QueryParams.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/QueryParams.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Request.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Request.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHandler.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Response.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Response.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/SimpleHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/SimpleHandler.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/GlobalRouter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/GlobalRouter.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathViews.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathViews.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/resources/assets/html/default_404.html" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/resources/assets/html/default_404.html" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/resources/assets/html/default_exception.html" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/resources/assets/html/default_exception.html" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/RequestParserTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/RequestParserTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/HeaderMapTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/HeaderMapTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestLineTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestLineTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/ResponseTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/ResponseTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/GlobalRouterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/GlobalRouterTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/template/ErrorPagesTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/template/ErrorPagesTest.java" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -35,7 +48,7 @@
|
||||
</persistenceIdMap>
|
||||
</component>
|
||||
<component name="EmbeddingIndexingInfo">
|
||||
<option name="cachedIndexableFilesCount" value="88" />
|
||||
<option name="cachedIndexableFilesCount" value="86" />
|
||||
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
||||
</component>
|
||||
<component name="FileTemplateManagerImpl">
|
||||
@@ -73,44 +86,48 @@
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"Application.ExternalBenchmark.executor": "Run",
|
||||
"Application.Main.executor": "Run",
|
||||
"Application.dev.relism.bench.Main.executor": "Run",
|
||||
"JUnit.RequestParserTest.executor": "Run",
|
||||
"JUnit.RequestParserTest.headers_caseInsensitive.executor": "Debug",
|
||||
"Maven.FlashPractice [test].executor": "Run",
|
||||
"Maven.flash [compile].executor": "Run",
|
||||
"Maven.flash [install].executor": "Run",
|
||||
"Maven.flash [test].executor": "Run",
|
||||
"Maven.flash-bench [clean].executor": "Run",
|
||||
"Maven.flash-bench [install].executor": "Run",
|
||||
"Maven.flash-bench [validate].executor": "Run",
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.MCP Project settings loaded": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
|
||||
"git-widget-placeholder": "master",
|
||||
"ignore.virus.scanning.warn.message": "true",
|
||||
"kotlin-language-version-configured": "true",
|
||||
"last_opened_file_path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"onboarding.tips.debug.path": "C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice/flash-bench/src/main/java/dev/relism/Main.java",
|
||||
"project.structure.last.edited": "Modules",
|
||||
"project.structure.proportion": "0.15",
|
||||
"project.structure.side.proportion": "0.1150748",
|
||||
"settings.editor.selected.configurable": "ml.llm.LLMThirdPartyAiProvidersConfigurable",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"Application.ExternalBenchmark (1).executor": "Run",
|
||||
"Application.ExternalBenchmark.executor": "Run",
|
||||
"Application.Main.executor": "Run",
|
||||
"Application.dev.relism.bench.Main.executor": "Run",
|
||||
"JUnit.RequestParserTest.executor": "Run",
|
||||
"JUnit.RequestParserTest.headers_caseInsensitive.executor": "Debug",
|
||||
"Maven.FlashPractice [test].executor": "Run",
|
||||
"Maven.flash [compile].executor": "Run",
|
||||
"Maven.flash [install].executor": "Run",
|
||||
"Maven.flash [test].executor": "Run",
|
||||
"Maven.flash-bench [clean].executor": "Run",
|
||||
"Maven.flash-bench [install].executor": "Run",
|
||||
"Maven.flash-bench [package].executor": "Run",
|
||||
"Maven.flash-bench [validate].executor": "Run",
|
||||
"Maven.flash-parent [install].executor": "Run",
|
||||
"Maven.flash-parent [package].executor": "Run",
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.MCP Project settings loaded": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
|
||||
"git-widget-placeholder": "master",
|
||||
"ignore.virus.scanning.warn.message": "true",
|
||||
"kotlin-language-version-configured": "true",
|
||||
"last_opened_file_path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"onboarding.tips.debug.path": "C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice/flash-bench/src/main/java/dev/relism/Main.java",
|
||||
"project.structure.last.edited": "Modules",
|
||||
"project.structure.proportion": "0.15",
|
||||
"project.structure.side.proportion": "0.1150748",
|
||||
"settings.editor.selected.configurable": "ml.llm.LLMThirdPartyAiProvidersConfigurable",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}]]></component>
|
||||
}</component>
|
||||
<component name="RecentsManager">
|
||||
<key name="MoveFile.RECENT_KEYS">
|
||||
<recent name="C:\Users\elorc\Documents\Coding\Java\practice\Flash" />
|
||||
@@ -119,10 +136,10 @@
|
||||
<recent name="dev.relism" />
|
||||
</key>
|
||||
</component>
|
||||
<component name="RunManager" selected="Application.ExternalBenchmark">
|
||||
<configuration name="ExternalBenchmark" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
||||
<component name="RunManager" selected="Application.dev.relism.bench.Main">
|
||||
<configuration name="ExternalBenchmark (1)" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
||||
<option name="MAIN_CLASS_NAME" value="dev.relism.ExternalBenchmark" />
|
||||
<module name="flash-bench" />
|
||||
<module name="flash" />
|
||||
<extension name="coverage">
|
||||
<pattern>
|
||||
<option name="PATTERN" value="dev.relism.*" />
|
||||
@@ -133,12 +150,38 @@
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
<configuration name="ExternalBenchmark" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
||||
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.ExternalBenchmark" />
|
||||
<module name="flash-bench" />
|
||||
<extension name="coverage">
|
||||
<pattern>
|
||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
||||
<option name="ENABLED" value="true" />
|
||||
</pattern>
|
||||
</extension>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
<configuration name="ExternalBenchmark" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
||||
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.ExternalBenchmark" />
|
||||
<module name="flash" />
|
||||
<extension name="coverage">
|
||||
<pattern>
|
||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
||||
<option name="ENABLED" value="true" />
|
||||
</pattern>
|
||||
</extension>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
<configuration name="Main" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
||||
<option name="MAIN_CLASS_NAME" value="dev.relism.Main" />
|
||||
<module name="flash" />
|
||||
<extension name="coverage">
|
||||
<pattern>
|
||||
<option name="PATTERN" value="dev.relism.*" />
|
||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
||||
<option name="ENABLED" value="true" />
|
||||
</pattern>
|
||||
</extension>
|
||||
@@ -151,7 +194,7 @@
|
||||
<module name="flash-bench" />
|
||||
<extension name="coverage">
|
||||
<pattern>
|
||||
<option name="PATTERN" value="dev.relism.*" />
|
||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
||||
<option name="ENABLED" value="true" />
|
||||
</pattern>
|
||||
</extension>
|
||||
@@ -161,9 +204,10 @@
|
||||
</configuration>
|
||||
<recent_temporary>
|
||||
<list>
|
||||
<item itemvalue="Application.ExternalBenchmark" />
|
||||
<item itemvalue="Application.dev.relism.bench.Main" />
|
||||
<item itemvalue="Application.Main" />
|
||||
<item itemvalue="Application.ExternalBenchmark (1)" />
|
||||
<item itemvalue="Application.ExternalBenchmark" />
|
||||
</list>
|
||||
</recent_temporary>
|
||||
</component>
|
||||
@@ -193,7 +237,17 @@
|
||||
<workItem from="1773536904928" duration="395000" />
|
||||
<workItem from="1773537302622" duration="150000" />
|
||||
<workItem from="1773537489622" duration="5048000" />
|
||||
<workItem from="1773576241900" duration="14674000" />
|
||||
<workItem from="1773576241900" duration="22639000" />
|
||||
<workItem from="1773613172976" duration="13157000" />
|
||||
<workItem from="1773668722120" duration="389000" />
|
||||
<workItem from="1773680650110" duration="4711000" />
|
||||
<workItem from="1773701882230" duration="6975000" />
|
||||
<workItem from="1773744806275" duration="48612000" />
|
||||
<workItem from="1773831300464" duration="1538000" />
|
||||
<workItem from="1773837116508" duration="4255000" />
|
||||
<workItem from="1773847734658" duration="911000" />
|
||||
<workItem from="1773875652058" duration="5481000" />
|
||||
<workItem from="1773915392332" duration="1223000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="Initial">
|
||||
<option name="closed" value="true" />
|
||||
@@ -219,7 +273,15 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1773581516834</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="4" />
|
||||
<task id="LOCAL-00004" summary="optimized dynamic body size impl, decluttering javadocs/comments">
|
||||
<option name="closed" value="true" />
|
||||
<created>1773591259108</created>
|
||||
<option name="number" value="00004" />
|
||||
<option name="presentableId" value="LOCAL-00004" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1773591259108</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="5" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -240,7 +302,8 @@
|
||||
<MESSAGE value="Initial" />
|
||||
<MESSAGE value="pre-module refactor" />
|
||||
<MESSAGE value="refactored, pre-buffer reuse" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="refactored, pre-buffer reuse" />
|
||||
<MESSAGE value="optimized dynamic body size impl, decluttering javadocs/comments" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="optimized dynamic body size impl, decluttering javadocs/comments" />
|
||||
</component>
|
||||
<component name="XSLT-Support.FileAssociations.UIState">
|
||||
<expand />
|
||||
|
||||
+15
-37
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
@@ -11,10 +10,11 @@
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-bench</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<jmh.version>1.37</jmh.version>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<ahc.version>2.12.3</ahc.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
@@ -26,39 +26,20 @@
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjdk.jmh</groupId>
|
||||
<artifactId>jmh-generator-annprocess</artifactId>
|
||||
<version>${jmh.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.asynchttpclient</groupId>
|
||||
<artifactId>async-http-client</artifactId>
|
||||
<version>${ahc.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>flash-bench</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.openjdk.jmh</groupId>
|
||||
<artifactId>jmh-generator-annprocess</artifactId>
|
||||
<version>${jmh.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<!--
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
@@ -68,7 +49,12 @@
|
||||
<phase>package</phase>
|
||||
<goals><goal>shade</goal></goals>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>dev.relism.bench.Main</mainClass>
|
||||
</transformer>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||
</transformers>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
@@ -79,18 +65,10 @@
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>dev.relism.bench.Main</mainClass>
|
||||
</transformer>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
-->
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,176 @@
|
||||
package dev.relism.bench;
|
||||
|
||||
import dev.relism.HttpServer;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.http.HttpStatus;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
@Slf4j
|
||||
public class StaticResourceManager {
|
||||
private static final int GZIP_THRESHOLD = 1024;
|
||||
private static final Map<String, ContentType> MIME_TYPES = new HashMap<>();
|
||||
|
||||
static {
|
||||
MIME_TYPES.put("html", ContentType.TEXT_HTML);
|
||||
MIME_TYPES.put("css", ContentType.TEXT_CSS);
|
||||
MIME_TYPES.put("js", ContentType.TEXT_JAVASCRIPT);
|
||||
MIME_TYPES.put("mjs", ContentType.TEXT_JAVASCRIPT);
|
||||
MIME_TYPES.put("png", ContentType.IMAGE_PNG);
|
||||
MIME_TYPES.put("jpg", ContentType.IMAGE_JPEG);
|
||||
MIME_TYPES.put("jpeg", ContentType.IMAGE_JPEG);
|
||||
MIME_TYPES.put("svg", ContentType.IMAGE_SVG);
|
||||
MIME_TYPES.put("json", ContentType.JSON);
|
||||
MIME_TYPES.put("ico", ContentType.BINARY);
|
||||
MIME_TYPES.put("webp", ContentType.BINARY);
|
||||
}
|
||||
|
||||
public record BakedAsset(byte[] raw, byte[] gzipped, ContentType contentType, boolean immutable, String etag) {}
|
||||
|
||||
private final Map<String, BakedAsset> cache = new ConcurrentHashMap<>();
|
||||
private final String mountPath;
|
||||
private BakedAsset indexFallback;
|
||||
|
||||
// Campi per le statistiche del report
|
||||
private long totalRawSize = 0, totalCompressedSize = 0;
|
||||
private int compressedCount = 0;
|
||||
|
||||
public StaticResourceManager(Path rootPath, String mountPath) {
|
||||
this.mountPath = normalizeMountPath(mountPath);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
bakeAll(rootPath);
|
||||
long duration = System.currentTimeMillis() - start;
|
||||
|
||||
// Richiamo del report qui
|
||||
printReport(duration);
|
||||
}
|
||||
|
||||
private String normalizeMountPath(String path) {
|
||||
String p = path.startsWith("/") ? path : "/" + path;
|
||||
return p.endsWith("/") ? p.substring(0, p.length() - 1) : p;
|
||||
}
|
||||
|
||||
private void bakeAll(Path rootPath) {
|
||||
if (!Files.exists(rootPath)) return;
|
||||
try (var stream = Files.walk(rootPath)) {
|
||||
stream.filter(Files::isRegularFile).forEach(file -> {
|
||||
try {
|
||||
String relative = rootPath.relativize(file).toString().replace("\\", "/");
|
||||
byte[] raw = Files.readAllBytes(file);
|
||||
byte[] gzipped = compressIfBeneficial(raw, relative);
|
||||
String etag = "\"" + Integer.toHexString(Arrays.hashCode(raw)) + "-" + raw.length + "\"";
|
||||
boolean isImmutable = relative.matches(".*\\.[a-f0-9]{8,}\\..*");
|
||||
|
||||
BakedAsset asset = new BakedAsset(raw, gzipped, resolveContentType(relative), isImmutable, etag);
|
||||
cache.put(relative, asset);
|
||||
|
||||
// Aggiornamento statistiche
|
||||
totalRawSize += raw.length;
|
||||
totalCompressedSize += (gzipped != null) ? gzipped.length : raw.length;
|
||||
if (gzipped != null) compressedCount++;
|
||||
if ("index.html".equals(relative)) indexFallback = asset;
|
||||
} catch (IOException ignored) {}
|
||||
});
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
// --- IL METODO PRINTREPORT ---
|
||||
private void printReport(long duration) {
|
||||
double saved = totalRawSize > 0 ? (1.0 - (double)totalCompressedSize / totalRawSize) * 100 : 0;
|
||||
long immutableCount = cache.values().stream().filter(BakedAsset::immutable).count();
|
||||
|
||||
System.out.println("\n" + "=".repeat(45));
|
||||
System.out.printf("🚀 FLASH ASSET BAKE COMPLETE [%dms]\n", duration);
|
||||
System.out.println("-".repeat(45));
|
||||
System.out.printf("📦 Total Assets: %d\n", cache.size());
|
||||
System.out.printf("🤐 Gzipped (On-Disk): %d\n", compressedCount);
|
||||
System.out.printf("💾 RAM Usage: %.2f MB\n", (double)totalRawSize / (1024 * 1024));
|
||||
System.out.printf("📉 Bandwidth Saving: %.1f%%\n", saved);
|
||||
System.out.println("-".repeat(45));
|
||||
System.out.println("🛡️ STRATEGIES ENABLED:");
|
||||
System.out.println(" • ETag Validation: [ACTIVE] (304 Not Modified)");
|
||||
System.out.printf(" • Immutable Assets: [%d files] (Cache: 1 year)\n", immutableCount);
|
||||
System.out.println(" • SPA Fallback: [ENABLED] (Route -> index.html)");
|
||||
System.out.println("=".repeat(45) + "\n");
|
||||
}
|
||||
|
||||
public void register(HttpServer server) {
|
||||
server.get(mountPath + "/**", (req, res) -> {
|
||||
String path = req.getRequestLine().getPath().toString();
|
||||
String subPath = path.substring(mountPath.length()).replaceFirst("^/", "");
|
||||
|
||||
BakedAsset asset = subPath.isEmpty() ? indexFallback : cache.get(subPath);
|
||||
|
||||
if (asset == null) {
|
||||
if (!subPath.contains(".") && indexFallback != null) {
|
||||
return serve(res, indexFallback, req);
|
||||
}
|
||||
return res.status(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return serve(res, asset, req);
|
||||
});
|
||||
}
|
||||
|
||||
private Object serve(dev.relism.models.Response res, BakedAsset asset, dev.relism.models.Request req) {
|
||||
// Caching condizionale (ETag)
|
||||
String ifNoneMatch = req.header("If-None-Match");
|
||||
if (asset.etag().equals(ifNoneMatch)) {
|
||||
return res.status(HttpStatus.NOT_MODIFIED);
|
||||
}
|
||||
|
||||
res.type(asset.contentType());
|
||||
res.header("ETag", asset.etag());
|
||||
|
||||
// Cache-Control Strategy
|
||||
if (asset.contentType() == ContentType.TEXT_HTML) {
|
||||
res.header("Cache-Control", "no-cache, must-revalidate");
|
||||
} else if (asset.immutable()) {
|
||||
res.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
} else {
|
||||
res.header("Cache-Control", "public, max-age=3600");
|
||||
}
|
||||
|
||||
if (asset.gzipped() != null && acceptsGzip(req)) {
|
||||
res.header("Content-Encoding", "gzip");
|
||||
res.header("Vary", "Accept-Encoding");
|
||||
return res.body(asset.gzipped());
|
||||
}
|
||||
return res.body(asset.raw());
|
||||
}
|
||||
|
||||
private boolean acceptsGzip(dev.relism.models.Request req) {
|
||||
String enc = req.header("Accept-Encoding");
|
||||
return enc != null && enc.contains("gzip");
|
||||
}
|
||||
|
||||
private byte[] compressIfBeneficial(byte[] data, String name) {
|
||||
if (data.length < GZIP_THRESHOLD || isAlreadyCompressed(name)) return null;
|
||||
try (var baos = new ByteArrayOutputStream(); var gzip = new GZIPOutputStream(baos)) {
|
||||
gzip.write(data);
|
||||
gzip.finish();
|
||||
byte[] compressed = baos.toByteArray();
|
||||
return compressed.length < (data.length * 0.9) ? compressed : null;
|
||||
} catch (IOException e) { return null; }
|
||||
}
|
||||
|
||||
private boolean isAlreadyCompressed(String n) {
|
||||
n = n.toLowerCase();
|
||||
return n.endsWith(".png") || n.endsWith(".jpg") || n.endsWith(".jpeg") || n.endsWith(".ico") || n.endsWith(".webp");
|
||||
}
|
||||
|
||||
private ContentType resolveContentType(String filename) {
|
||||
int dot = filename.lastIndexOf('.');
|
||||
String ext = (dot > 0) ? filename.substring(dot + 1).toLowerCase() : "";
|
||||
return MIME_TYPES.getOrDefault(ext, ContentType.BINARY);
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,10 @@ public class HttpServer {
|
||||
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@@ -122,30 +124,17 @@ public class HttpServer {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a route on the internal router. The handler return value drives the response:
|
||||
* return a {@link dev.relism.models.Response} to replace it entirely, any other non-null
|
||||
* value to set it as the body, or {@code null} to leave the response object unchanged.
|
||||
*/
|
||||
public HttpServer get(String path, SimpleHandler.FunctionalHandler h) {
|
||||
globalRouter.get(path, h);
|
||||
return this;
|
||||
}
|
||||
public HttpServer get(String path, SimpleHandler.FunctionalHandler h) { globalRouter.get(path, h); return this; }
|
||||
public HttpServer post(String path, SimpleHandler.FunctionalHandler h) { globalRouter.post(path, h); return this; }
|
||||
public HttpServer put(String path, SimpleHandler.FunctionalHandler h) { globalRouter.put(path, h); return this; }
|
||||
public HttpServer delete(String path, SimpleHandler.FunctionalHandler h) { globalRouter.delete(path, h); return this; }
|
||||
public HttpServer patch(String path, SimpleHandler.FunctionalHandler h) { globalRouter.patch(path, h); return this; }
|
||||
public HttpServer options(String path, SimpleHandler.FunctionalHandler h) { globalRouter.options(path, h); return this; }
|
||||
public HttpServer head(String path, SimpleHandler.FunctionalHandler h) { globalRouter.head(path, h); return this; }
|
||||
public HttpServer trace(String path, SimpleHandler.FunctionalHandler h) { globalRouter.trace(path, h); return this; }
|
||||
public HttpServer connect(String path, SimpleHandler.FunctionalHandler h) { globalRouter.connect(path, h); return this; }
|
||||
public HttpServer purge(String path, SimpleHandler.FunctionalHandler h) { globalRouter.purge(path, h); return this; }
|
||||
|
||||
public HttpServer post(String path, SimpleHandler.FunctionalHandler h) {
|
||||
globalRouter.post(path, h);
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpServer put(String path, SimpleHandler.FunctionalHandler h) {
|
||||
globalRouter.put(path, h);
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpServer delete(String path, SimpleHandler.FunctionalHandler h) {
|
||||
globalRouter.delete(path, h);
|
||||
return this;
|
||||
}
|
||||
|
||||
private void process(Socket socket) {
|
||||
activeSockets.add(socket);
|
||||
@@ -161,7 +150,7 @@ public class HttpServer {
|
||||
|
||||
boolean keepAlive = isKeepAlive(request);
|
||||
|
||||
Response response = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
Response response = new Response(200, ContentType.TEXT_PLAIN);
|
||||
RequestHandler handler = globalRouter.route(request);
|
||||
|
||||
try {
|
||||
@@ -178,29 +167,19 @@ public class HttpServer {
|
||||
response.setBody(result);
|
||||
}
|
||||
|
||||
request.getBody();
|
||||
|
||||
out.write(HTTP_1_1);
|
||||
writeStatusPhrase(out, response.getStatusCode());
|
||||
out.write(CRLF);
|
||||
out.write(CONTENT_TYPE);
|
||||
out.write(response.getContentType());
|
||||
out.write(CRLF);
|
||||
out.write(CONTENT_LENGTH);
|
||||
writeInt(out, response.getBody() != null ? response.getBody().length : 0);
|
||||
out.write(CRLF);
|
||||
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
out.write(CRLF);
|
||||
if (response.getBody() != null)
|
||||
out.write(response.getBody());
|
||||
out.flush();
|
||||
writeResponse(out, response, keepAlive);
|
||||
request.drain();
|
||||
|
||||
if (!keepAlive)
|
||||
break;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if (!stopped)
|
||||
log.error("I/O error handling request", e);
|
||||
if (!stopped) {
|
||||
if (e instanceof java.net.SocketException)
|
||||
log.debug("Connection closed: {}", e.getMessage());
|
||||
else
|
||||
log.error("I/O error handling request", e);
|
||||
}
|
||||
} finally {
|
||||
activeSockets.remove(socket);
|
||||
}
|
||||
@@ -215,31 +194,87 @@ public class HttpServer {
|
||||
|| request.headerEquals("Connection", "keep-alive");
|
||||
}
|
||||
|
||||
private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException {
|
||||
byte[] phrase = HttpStatus.bytesForCode(statusCode);
|
||||
if (phrase != null) {
|
||||
out.write(phrase);
|
||||
/**
|
||||
* Writes a complete HTTP response. The fixed-body path (the common case for simple handlers
|
||||
* like /plaintext) is kept inline; streaming and chunked bodies are delegated to
|
||||
* {@link #writeStreamingBody} so the JIT can optimise this method aggressively.
|
||||
*/
|
||||
private static void writeResponse(OutputStream out, Response response, boolean keepAlive) throws IOException {
|
||||
out.write(HTTP_1_1);
|
||||
byte[] statusBytes = response.getStatusBytes();
|
||||
if (statusBytes != null) out.write(statusBytes);
|
||||
else writeStatusPhrase(out, response.getStatusCode());
|
||||
out.write(CRLF);
|
||||
out.write(CONTENT_TYPE);
|
||||
out.write(response.getContentType());
|
||||
out.write(CRLF);
|
||||
response.writeHeaders(out);
|
||||
|
||||
if (response.isStreaming()) {
|
||||
writeStreamingBody(out, response, keepAlive);
|
||||
} else {
|
||||
writeInt(out, statusCode);
|
||||
out.write(UNKNOWN_STATUS_SUFFIX);
|
||||
byte[] body = response.getBody();
|
||||
out.write(CONTENT_LENGTH);
|
||||
writeLong(out, body != null ? body.length : 0);
|
||||
out.write(CRLF);
|
||||
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
out.write(CRLF);
|
||||
if (body != null) out.write(body);
|
||||
}
|
||||
out.flush();
|
||||
}
|
||||
|
||||
/** Streaming write path — extracted from {@link #writeResponse} to keep the hot method small. */
|
||||
private static void writeStreamingBody(OutputStream out, Response response, boolean keepAlive) throws IOException {
|
||||
if (!response.isChunked()) {
|
||||
out.write(CONTENT_LENGTH);
|
||||
writeLong(out, response.getStreamLength());
|
||||
out.write(CRLF);
|
||||
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
out.write(CRLF);
|
||||
response.getStream().transferTo(out);
|
||||
} else {
|
||||
out.write(TRANSFER_CHUNKED);
|
||||
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
out.write(CRLF);
|
||||
writeChunked(out, response.getStream());
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeInt(OutputStream out, int value) throws IOException {
|
||||
if (value == 0) {
|
||||
out.write(DIGITS[0]);
|
||||
return;
|
||||
private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException {
|
||||
byte[] phrase = HttpStatus.bytesForCode(statusCode);
|
||||
if (phrase != null) out.write(phrase);
|
||||
else { writeLong(out, statusCode); out.write(UNKNOWN_STATUS_SUFFIX); }
|
||||
}
|
||||
|
||||
private static void writeLong(OutputStream out, long value) throws IOException {
|
||||
if (value == 0) { out.write(DIGITS[0]); return; }
|
||||
if (value < 0) { out.write('-'); value = -value; }
|
||||
long divisor = 1;
|
||||
while (value / divisor >= 10) divisor *= 10;
|
||||
while (divisor > 0) { out.write(DIGITS[(int) ((value / divisor) % 10)]); divisor /= 10; }
|
||||
}
|
||||
|
||||
private static void writeChunked(OutputStream out, InputStream stream) throws IOException {
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = stream.read(buf)) > 0) {
|
||||
writeHex(out, n);
|
||||
out.write(CRLF);
|
||||
out.write(buf, 0, n);
|
||||
out.write(CRLF);
|
||||
}
|
||||
if (value < 0) {
|
||||
out.write('-');
|
||||
value = -value;
|
||||
}
|
||||
int divisor = 1;
|
||||
while (value / divisor >= 10)
|
||||
divisor *= 10;
|
||||
while (divisor > 0) {
|
||||
out.write(DIGITS[(value / divisor) % 10]);
|
||||
divisor /= 10;
|
||||
out.write(FINAL_CHUNK);
|
||||
}
|
||||
|
||||
private static void writeHex(OutputStream out, int value) throws IOException {
|
||||
int shift = 28;
|
||||
boolean leading = true;
|
||||
while (shift >= 0) {
|
||||
int digit = (value >>> shift) & 0xF;
|
||||
if (digit != 0 || !leading) { leading = false; out.write(digit < 10 ? '0' + digit : 'a' + digit - 10); }
|
||||
shift -= 4;
|
||||
}
|
||||
if (leading) out.write('0');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,7 @@ public class HttpServerConfiguration {
|
||||
private int port;
|
||||
private String host;
|
||||
@Builder.Default
|
||||
private int acceptorThreads = 1;
|
||||
@Builder.Default
|
||||
private int maxHeaderBufferSize = 64 * 1024;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,141 @@
|
||||
package dev.relism;
|
||||
|
||||
import dev.relism.api.multipart.Multipart;
|
||||
import dev.relism.api.multipart.Part;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.*;
|
||||
import dev.relism.routing.Route;
|
||||
import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl;
|
||||
import dev.relism.http.HttpStatus;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class Main {
|
||||
|
||||
private static final int PORT = 8080;
|
||||
|
||||
/*
|
||||
@Route(method = "GET", path = "/profile")
|
||||
public static class ProfileHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return "Class based handler: User profile info under /api/profile";
|
||||
}
|
||||
}
|
||||
*/
|
||||
private static final int PORT = 8080;
|
||||
private static final Path UPLOAD_DIR = Path.of(System.getProperty("java.io.tmpdir"), "flash-uploads");
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
HttpServerConfiguration config = HttpServerConfiguration.builder()
|
||||
.port(PORT)
|
||||
.host("localhost")
|
||||
.build();
|
||||
Files.createDirectories(UPLOAD_DIR);
|
||||
|
||||
HttpServer server = new HttpServer(config);
|
||||
HttpServer server = new HttpServer(
|
||||
HttpServerConfiguration.builder().port(PORT).build());
|
||||
|
||||
FastPathRouterImpl apiRouter = new FastPathRouterImpl();
|
||||
server.mount("/api", apiRouter);
|
||||
|
||||
// apiRouter.register(new ProfileHandler());
|
||||
|
||||
server.get("/headers", (req, res) -> {
|
||||
throw new RuntimeException(req.getQueryParam("test"));
|
||||
server.get("/plaintext", (req, res) -> {
|
||||
res.type(ContentType.TEXT_PLAIN);
|
||||
return "Hello, World!";
|
||||
});
|
||||
|
||||
// ── File serving ──────────────────────────────────────────────────────
|
||||
|
||||
server.get("/files/{name}", (req, res) -> {
|
||||
Path file = UPLOAD_DIR.resolve(req.param("name"));
|
||||
if (!Files.exists(file))
|
||||
return res.status(404).body("not found");
|
||||
|
||||
server.start().thenRun(() -> log.info("Server started on port: " + PORT));
|
||||
return res.type(ContentType.BINARY)
|
||||
.stream(Files.newInputStream(file), Files.size(file))
|
||||
.header("Content-Disposition", "attachment; filename=\"" + file.getFileName() + "\"");
|
||||
});
|
||||
|
||||
// ── Chunked / dynamic content ─────────────────────────────────────────
|
||||
|
||||
// Virtual thread produces rows into a pipe; socket thread drains in 8 KB chunks.
|
||||
// No Content-Length needed — Transfer-Encoding: chunked.
|
||||
server.get("/export/users.csv", (req, res) -> {
|
||||
PipedOutputStream sink = new PipedOutputStream();
|
||||
PipedInputStream source = new PipedInputStream(sink, 64 * 1024);
|
||||
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try (PrintWriter w = new PrintWriter(new BufferedWriter(new OutputStreamWriter(sink)))) {
|
||||
w.println("id,name,email");
|
||||
for (int i = 1; i <= 100_000; i++)
|
||||
w.printf("%d,User %d,user%d@example.com%n", i, i, i);
|
||||
}
|
||||
});
|
||||
|
||||
return res.type(ContentType.TEXT_CSV)
|
||||
.chunked(source)
|
||||
.header("Content-Disposition", "attachment; filename=\"users.csv\"");
|
||||
});
|
||||
|
||||
// ── Multipart upload ──────────────────────────────────────────────────
|
||||
|
||||
// Text fields are buffered eagerly; file is streamed zero-copy to disk.
|
||||
server.post("/upload/avatar", (req, res) -> {
|
||||
Multipart mp = Multipart.of(req);
|
||||
String userId = mp.field("userId");
|
||||
Part avatar = mp.file("avatar");
|
||||
|
||||
if (userId == null || avatar == null)
|
||||
return res.status(400).body("userId and avatar fields are required");
|
||||
|
||||
Path dest = UPLOAD_DIR.resolve(userId + "_" + avatar.filename());
|
||||
Files.copy(avatar.stream(), dest, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
return res.body("saved %s for user %s".formatted(avatar.filename(), userId));
|
||||
});
|
||||
|
||||
// parts() forces a full scan and materialises all file bodies — accepted cost.
|
||||
server.post("/upload/bulk", (req, res) -> {
|
||||
List<Part> files = Multipart.of(req).parts("files");
|
||||
if (files.isEmpty())
|
||||
return res.status(HttpStatus.BAD_REQUEST).body("no files uploaded");
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Part f : files) {
|
||||
byte[] data = f.materialize();
|
||||
Files.write(UPLOAD_DIR.resolve(f.filename()), data);
|
||||
sb.append("%s %d bytes %s%n".formatted(f.filename(), data.length, f.contentType()));
|
||||
}
|
||||
return res.body(sb.toString());
|
||||
});
|
||||
|
||||
// Echo: file bytes flow socket-in → pipe → chunked-out.
|
||||
// The pipe decouples reading (request socket) from writing (response socket) so they
|
||||
// proceed concurrently on separate virtual threads, preventing the TCP deadlock that
|
||||
// would occur if both happened sequentially on the same thread for large files.
|
||||
server.post("/upload/echo", (req, res) -> {
|
||||
Part file = Multipart.of(req).file("file");
|
||||
if (file == null)
|
||||
return res.status(400).body("missing file field");
|
||||
|
||||
PipedOutputStream sink = new PipedOutputStream();
|
||||
PipedInputStream source = new PipedInputStream(sink, 64 * 1024);
|
||||
|
||||
InputStream fileStream = file.stream();
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try { fileStream.transferTo(sink); } catch (IOException ignored) {}
|
||||
finally { try { sink.close(); } catch (IOException ignored) {} }
|
||||
});
|
||||
|
||||
String ct = file.contentType();
|
||||
return res.type(ct != null ? ct : "application/octet-stream")
|
||||
.chunked(source)
|
||||
.header("Content-Disposition", "attachment; filename=\"" + file.filename() + "\"");
|
||||
});
|
||||
|
||||
// Mixed form: text fields parsed eagerly, image streamed back with Content-Length.
|
||||
server.post("/upload/resize-preview", (req, res) -> {
|
||||
Multipart mp = Multipart.of(req);
|
||||
String width = mp.field("width");
|
||||
String height = mp.field("height");
|
||||
Part image = mp.file("image");
|
||||
|
||||
if (image == null)
|
||||
return res.status(400).body("image field required");
|
||||
|
||||
// materialize() accepted here — preview endpoint, size bounded
|
||||
byte[] data = image.materialize();
|
||||
log.info("resize request {}x{} for {} ({} bytes)", width, height, image.filename(), data.length);
|
||||
|
||||
return res.type(ContentType.BINARY)
|
||||
.stream(new ByteArrayInputStream(data), data.length)
|
||||
.header("X-Original-Name", image.filename())
|
||||
.header("X-Requested-Size", width + "x" + height);
|
||||
});
|
||||
|
||||
server.start().thenRun(() -> log.info("Server started on :{}", PORT));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dev.relism;
|
||||
|
||||
import dev.relism.models.HeaderMap;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.HeaderMap;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestLine;
|
||||
import dev.relism.routing.routers.fastpathrouter.FastPathViews;
|
||||
@@ -13,7 +13,7 @@ import java.io.InputStream;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* One instance per connection — the buffer is allocated once and reused across keep-alive
|
||||
* One instance per connection, the buffer is allocated once and reused across keep-alive
|
||||
* requests. Grows on demand (doubling, up to {@code maxHeaderBufferSize}). Zero String
|
||||
* allocations during parsing; paths, headers and protocol are exposed as {@link dev.relism.fpr.core.ByteView} slices.
|
||||
*/
|
||||
@@ -22,7 +22,10 @@ public class RequestParser {
|
||||
private static final int INITIAL_BUFFER_SIZE = 8192;
|
||||
|
||||
private final int maxHeaderBufferSize;
|
||||
private final HeaderMap headerMap = new HeaderMap();
|
||||
private byte[] buffer;
|
||||
private int bufBase = 0; // absolute start of valid data in buffer
|
||||
private int bufLen = 0; // number of valid bytes from bufBase
|
||||
|
||||
public RequestParser() { this(64 * 1024); }
|
||||
public RequestParser(int maxHeaderBufferSize) {
|
||||
@@ -31,30 +34,42 @@ public class RequestParser {
|
||||
}
|
||||
|
||||
public Request parse(InputStream in) throws IOException {
|
||||
int totalRead = 0;
|
||||
int headerEndIdx = -1;
|
||||
while (true) {
|
||||
if (totalRead == buffer.length) {
|
||||
if (buffer.length >= maxHeaderBufferSize)
|
||||
// Take ownership of any leftover bytes from the previous request, then reset so
|
||||
// early-returns leave the fields in a clean state.
|
||||
int base = bufBase;
|
||||
int totalRead = bufLen;
|
||||
bufBase = 0;
|
||||
bufLen = 0;
|
||||
|
||||
int headerEndIdx = totalRead > 0 ? findEndOfHeader(buffer, base, base + totalRead) : -1;
|
||||
while (headerEndIdx == -1) {
|
||||
if (base + totalRead == buffer.length) {
|
||||
if (base > 0) {
|
||||
// Compact: slide valid data to position 0 — rare path (~every N requests
|
||||
// where N = bufferSize / avgRequestSize rather than every request).
|
||||
System.arraycopy(buffer, base, buffer, 0, totalRead);
|
||||
base = 0;
|
||||
} else if (buffer.length >= maxHeaderBufferSize) {
|
||||
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
|
||||
buffer = Arrays.copyOf(buffer, Math.min(buffer.length * 2, maxHeaderBufferSize));
|
||||
} else {
|
||||
buffer = Arrays.copyOf(buffer, Math.min(buffer.length * 2, maxHeaderBufferSize));
|
||||
}
|
||||
}
|
||||
int n = in.read(buffer, totalRead, buffer.length - totalRead);
|
||||
int n = in.read(buffer, base + totalRead, buffer.length - base - totalRead);
|
||||
if (n <= 0) break;
|
||||
int prevTotal = totalRead;
|
||||
totalRead += n;
|
||||
headerEndIdx = findEndOfHeader(buffer, Math.max(0, prevTotal - 3), totalRead);
|
||||
if (headerEndIdx != -1) break;
|
||||
headerEndIdx = findEndOfHeader(buffer, base + Math.max(0, prevTotal - 3), base + totalRead);
|
||||
}
|
||||
if (totalRead <= 0) return null;
|
||||
if (headerEndIdx == -1) {
|
||||
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
|
||||
}
|
||||
|
||||
int methodEnd = find(buffer, 0, headerEndIdx, (byte) ' ');
|
||||
int methodEnd = find(buffer, base, headerEndIdx, (byte) ' ');
|
||||
if (methodEnd == -1) throw new IOException("Invalid request line (method)");
|
||||
|
||||
HttpMethod method = HttpMethod.fromBytes(buffer, 0, methodEnd);
|
||||
HttpMethod method = HttpMethod.fromBytes(buffer, base, methodEnd - base);
|
||||
if (method == null) throw new IOException("Unsupported HTTP method");
|
||||
|
||||
int pathStart = methodEnd + 1;
|
||||
@@ -74,9 +89,10 @@ public class RequestParser {
|
||||
|
||||
FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
|
||||
|
||||
HeaderMap headerMap = new HeaderMap(buffer);
|
||||
int current = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
|
||||
int contentLength = 0;
|
||||
int sectionStart = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
|
||||
int current = sectionStart;
|
||||
long contentLength = 0;
|
||||
boolean isChunked = false;
|
||||
|
||||
while (current < headerEndIdx) {
|
||||
int lineEnd = find(buffer, current, headerEndIdx + 1, (byte) '\r');
|
||||
@@ -87,20 +103,39 @@ public class RequestParser {
|
||||
int valueStart = colon + 1;
|
||||
while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++;
|
||||
|
||||
headerMap.add(current, colon - current, valueStart, lineEnd - valueStart);
|
||||
|
||||
if (equalsIgnoreCase(buffer, current, colon, "content-length")) {
|
||||
contentLength = parseInt(buffer, valueStart, lineEnd);
|
||||
contentLength = parseLong(buffer, valueStart, lineEnd);
|
||||
} else if (equalsIgnoreCase(buffer, current, colon, "transfer-encoding")) {
|
||||
isChunked = equalsIgnoreCase(buffer, valueStart, lineEnd, "chunked");
|
||||
}
|
||||
}
|
||||
current = lineEnd + 2;
|
||||
}
|
||||
|
||||
headerMap.reset(buffer, sectionStart, headerEndIdx);
|
||||
|
||||
int bodyStart = headerEndIdx + 4;
|
||||
int preBufLen = totalRead - bodyStart;
|
||||
return Request.forParsed(
|
||||
new RequestLine(method, pathView, queryView, protocolView, headerMap),
|
||||
in, contentLength, buffer, bodyStart, preBufLen);
|
||||
int preBufLen = (base + totalRead) - bodyStart;
|
||||
|
||||
// Any bytes read beyond this request's body belong to the next request.
|
||||
// Store their absolute position in the buffer — no copy needed; the next parse()
|
||||
// call will read directly from bufBase without touching the data.
|
||||
if (!isChunked && contentLength == 0 && preBufLen > 0) {
|
||||
bufBase = bodyStart;
|
||||
bufLen = preBufLen;
|
||||
preBufLen = 0;
|
||||
} else if (!isChunked && contentLength > 0 && preBufLen > contentLength) {
|
||||
bufBase = bodyStart + (int) contentLength;
|
||||
bufLen = preBufLen - (int) contentLength;
|
||||
preBufLen = (int) contentLength;
|
||||
}
|
||||
|
||||
RequestLine requestLine = new RequestLine(method, pathView, queryView, protocolView, headerMap);
|
||||
|
||||
if (isChunked) {
|
||||
return Request.forParsed(requestLine, new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0);
|
||||
}
|
||||
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen);
|
||||
}
|
||||
|
||||
private static int findEndOfHeader(byte[] buf, int from, int len) {
|
||||
@@ -130,8 +165,8 @@ public class RequestParser {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int parseInt(byte[] buf, int start, int end) {
|
||||
int value = 0;
|
||||
private static long parseLong(byte[] buf, int start, int end) {
|
||||
long value = 0;
|
||||
for (int i = start; i < end; i++) {
|
||||
byte c = buf[i];
|
||||
if (c >= '0' && c <= '9') value = value * 10 + (c - '0');
|
||||
|
||||
@@ -6,7 +6,7 @@ import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Pre-compiled byte representations of common HTTP {@code Content-Type} values.
|
||||
* {@link #getBytes()} returns the pre-computed array directly — never allocates.
|
||||
* {@link #getBytes()} returns the pre-computed array directly, never allocates.
|
||||
*/
|
||||
@Getter
|
||||
public enum ContentType {
|
||||
|
||||
@@ -58,7 +58,7 @@ public enum HttpStatus {
|
||||
}
|
||||
}
|
||||
|
||||
private final int code;
|
||||
private final int code;
|
||||
private final byte[] bytes;
|
||||
|
||||
HttpStatus(int code, String reason) {
|
||||
@@ -66,7 +66,14 @@ public enum HttpStatus {
|
||||
this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** * Returns pre-compiled status bytes for the given code.
|
||||
/** Numeric status code (e.g. {@code 200}). */
|
||||
public int code() { return code; }
|
||||
|
||||
/** Pre-encoded {@code "200 OK"} bytes — zero allocation on the write path. */
|
||||
public byte[] bytes() { return bytes; }
|
||||
|
||||
/**
|
||||
* Returns pre-compiled status bytes for the given code.
|
||||
* Access is O(1) and generates zero garbage.
|
||||
*/
|
||||
public static byte[] bytesForCode(int code) {
|
||||
|
||||
@@ -1,65 +1,78 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Zero-allocation header storage backed by raw byte offsets into the shared request buffer.
|
||||
* Offsets are recorded at parse time; Strings are allocated only on {@link #getFirst} / {@link #getAll}.
|
||||
* Lazy header access backed by a raw byte slice of the request buffer.
|
||||
* No allocations at parse time; Strings are created only when {@link #first} / {@link #all} is called.
|
||||
* One instance per connection; reused across keep-alive requests via {@link #reset}.
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
public class HeaderMap {
|
||||
private static final int MAX_HEADERS = 32;
|
||||
private byte[] buffer;
|
||||
private int sectionStart;
|
||||
private int sectionEnd;
|
||||
|
||||
private final byte[] buffer;
|
||||
private final int[] keys = new int[MAX_HEADERS * 2]; // interleaved [start, len] pairs
|
||||
private final int[] values = new int[MAX_HEADERS * 2]; // interleaved [start, len] pairs
|
||||
private int count = 0;
|
||||
|
||||
public HeaderMap(byte[] buffer) {
|
||||
this.buffer = buffer;
|
||||
/** Resets this map to the header section {@code buffer[sectionStart, sectionEnd)}. */
|
||||
public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
|
||||
this.buffer = buffer;
|
||||
this.sectionStart = sectionStart;
|
||||
this.sectionEnd = sectionEnd;
|
||||
}
|
||||
|
||||
public void add(int keyStart, int keyLen, int valStart, int valLen) {
|
||||
if (count >= MAX_HEADERS) throw new IllegalStateException("Too many headers: limit is " + MAX_HEADERS);
|
||||
keys[count * 2] = keyStart;
|
||||
keys[count * 2 + 1] = keyLen;
|
||||
values[count * 2] = valStart;
|
||||
values[count * 2 + 1] = valLen;
|
||||
count++;
|
||||
/** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */
|
||||
public String first(String name) {
|
||||
long r = findFirst(name);
|
||||
if (r < 0) return null;
|
||||
int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
|
||||
return new String(buffer, s, l, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public String getFirst(String name) {
|
||||
int idx = indexOf(name);
|
||||
if (idx < 0) return null;
|
||||
return new String(buffer, values[idx * 2], values[idx * 2 + 1], StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public List<String> getAll(String name) {
|
||||
/** Returns all values of header {@code name} in declaration order, or an empty list. */
|
||||
public List<String> all(String name) {
|
||||
if (buffer == null) return List.of();
|
||||
List<String> result = null;
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (keyMatches(i, name)) {
|
||||
int i = sectionStart;
|
||||
while (i < sectionEnd) {
|
||||
int lineEnd = findCR(i);
|
||||
int colon = findColon(i, lineEnd);
|
||||
if (colon != -1 && keyMatches(i, colon - i, name)) {
|
||||
int vs = skipSpaces(colon + 1, lineEnd);
|
||||
if (result == null) result = new ArrayList<>();
|
||||
result.add(new String(buffer, values[i * 2], values[i * 2 + 1], StandardCharsets.UTF_8));
|
||||
result.add(new String(buffer, vs, lineEnd - vs, StandardCharsets.UTF_8));
|
||||
}
|
||||
i = lineEnd + 2;
|
||||
}
|
||||
return result != null ? result : List.of();
|
||||
}
|
||||
|
||||
public List<String> getAll() {
|
||||
/** Returns all header values in declaration order. */
|
||||
public List<String> all() {
|
||||
if (buffer == null) return List.of();
|
||||
List<String> result = new ArrayList<>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
result.add(new String(buffer, values[i * 2], values[i * 2 + 1], StandardCharsets.UTF_8));
|
||||
int i = sectionStart;
|
||||
while (i < sectionEnd) {
|
||||
int lineEnd = findCR(i);
|
||||
int colon = findColon(i, lineEnd);
|
||||
if (colon != -1) {
|
||||
int vs = skipSpaces(colon + 1, lineEnd);
|
||||
result.add(new String(buffer, vs, lineEnd - vs, StandardCharsets.UTF_8));
|
||||
}
|
||||
i = lineEnd + 2;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public boolean headerValueEqualsIgnoreCase(String name, String value) {
|
||||
int idx = indexOf(name);
|
||||
if (idx < 0) return false;
|
||||
int vs = values[idx * 2], vl = values[idx * 2 + 1];
|
||||
/** Case-insensitive comparison of the first value of {@code name} against {@code value}. */
|
||||
public boolean valueEqualsIgnoreCase(String name, String value) {
|
||||
long r = findFirst(name);
|
||||
if (r < 0) return false;
|
||||
int vs = (int) (r >> 32), vl = (int) (r & 0xFFFFFFFFL);
|
||||
if (vl != value.length()) return false;
|
||||
for (int i = 0; i < vl; i++) {
|
||||
byte b = buffer[vs + i];
|
||||
@@ -71,31 +84,54 @@ public class HeaderMap {
|
||||
return true;
|
||||
}
|
||||
|
||||
public ByteView getView(String name) {
|
||||
int idx = indexOf(name);
|
||||
if (idx < 0) return null;
|
||||
final int start = values[idx * 2];
|
||||
final int len = values[idx * 2 + 1];
|
||||
/** Returns a zero-copy {@link ByteView} over the first value of {@code name}, or {@code null}. */
|
||||
public ByteView view(String name) {
|
||||
long r = findFirst(name);
|
||||
if (r < 0) return null;
|
||||
final int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
|
||||
return new ByteView() {
|
||||
public int length() { return len; }
|
||||
public byte byteAt(int i) { return buffer[start + i]; }
|
||||
public int length() { return l; }
|
||||
public byte byteAt(int i) { return buffer[s + i]; }
|
||||
};
|
||||
}
|
||||
|
||||
private int indexOf(String name) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (keyMatches(i, name)) return i;
|
||||
/** Returns {@code (valStart << 32) | valLen}, or {@code -1} if not found. */
|
||||
private long findFirst(String name) {
|
||||
if (buffer == null) return -1L;
|
||||
int i = sectionStart;
|
||||
while (i < sectionEnd) {
|
||||
int lineEnd = findCR(i);
|
||||
int colon = findColon(i, lineEnd);
|
||||
if (colon != -1 && keyMatches(i, colon - i, name)) {
|
||||
int vs = skipSpaces(colon + 1, lineEnd);
|
||||
return ((long) vs << 32) | (lineEnd - vs);
|
||||
}
|
||||
i = lineEnd + 2;
|
||||
}
|
||||
return -1L;
|
||||
}
|
||||
|
||||
private int findCR(int from) {
|
||||
for (int i = from; i < sectionEnd; i++) if (buffer[i] == '\r') return i;
|
||||
return sectionEnd;
|
||||
}
|
||||
|
||||
private int findColon(int from, int to) {
|
||||
for (int i = from; i < to; i++) if (buffer[i] == ':') return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private boolean keyMatches(int i, String name) {
|
||||
int ks = keys[i * 2], kl = keys[i * 2 + 1];
|
||||
if (kl != name.length()) return false;
|
||||
for (int j = 0; j < kl; j++) {
|
||||
byte b = buffer[ks + j];
|
||||
private int skipSpaces(int from, int end) {
|
||||
while (from < end && buffer[from] == ' ') from++;
|
||||
return from;
|
||||
}
|
||||
|
||||
private boolean keyMatches(int start, int len, String name) {
|
||||
if (len != name.length()) return false;
|
||||
for (int i = 0; i < len; i++) {
|
||||
byte b = buffer[start + i];
|
||||
if (b >= 'A' && b <= 'Z') b += 32;
|
||||
char c = name.charAt(j);
|
||||
char c = name.charAt(i);
|
||||
if (c >= 'A' && c <= 'Z') c += 32;
|
||||
if (b != (byte) c) return false;
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
|
||||
/**
|
||||
* Deferred body reader. On the first {@link #get()} call, pre-buffered bytes from the header
|
||||
* read-ahead are used first, then the remainder is pulled from the socket stream. Result is cached.
|
||||
*/
|
||||
final class LazyBody {
|
||||
private static final byte[] EMPTY = new byte[0];
|
||||
|
||||
private final InputStream stream;
|
||||
private final int contentLength;
|
||||
private final byte[] preBuf;
|
||||
private final int preBufOff;
|
||||
private final int preBufLen;
|
||||
private byte[] resolved;
|
||||
|
||||
LazyBody(InputStream stream, int contentLength, byte[] preBuf, int preBufOff, int preBufLen) {
|
||||
this.stream = stream;
|
||||
this.contentLength = contentLength;
|
||||
this.preBuf = preBuf;
|
||||
this.preBufOff = preBufOff;
|
||||
this.preBufLen = preBufLen;
|
||||
}
|
||||
|
||||
static LazyBody of(byte[] bytes) {
|
||||
LazyBody lb = new LazyBody(null, bytes.length, null, 0, 0);
|
||||
lb.resolved = bytes;
|
||||
return lb;
|
||||
}
|
||||
|
||||
static LazyBody empty() {
|
||||
LazyBody lb = new LazyBody(null, 0, null, 0, 0);
|
||||
lb.resolved = EMPTY;
|
||||
return lb;
|
||||
}
|
||||
|
||||
byte[] get() {
|
||||
if (resolved != null) return resolved;
|
||||
byte[] buf = new byte[contentLength];
|
||||
int copied = Math.min(preBufLen, contentLength);
|
||||
if (copied > 0) System.arraycopy(preBuf, preBufOff, buf, 0, copied);
|
||||
if (copied < contentLength) {
|
||||
try {
|
||||
stream.readNBytes(buf, copied, contentLength - copied);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
return resolved = buf;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* Lazy query parameter access ({@code ?key=value&...}). Backed by a zero-copy {@link ByteView}
|
||||
* over the raw query string bytes — no parsing at construction, values decoded on demand.
|
||||
* over the raw query string bytes, no parsing at construction, values decoded on demand.
|
||||
*/
|
||||
public class QueryParams {
|
||||
public static final QueryParams EMPTY = new QueryParams(null);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
@@ -9,58 +10,139 @@ import lombok.Value;
|
||||
import lombok.experimental.NonFinal;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Immutable view of an incoming HTTP/1.1 request. Constructed by {@link dev.relism.RequestParser}
|
||||
* and passed directly to route handlers — never modified after creation (path/query params are
|
||||
* injected once by the router before the handler runs).
|
||||
*
|
||||
* <pre>{@code
|
||||
* server.get("/users/{id}", (req, res) -> {
|
||||
* String id = req.param("id"); // /users/42 → "42"
|
||||
* String page = req.query("page"); // ?page=2 → "2"
|
||||
* String accept = req.header("Accept"); // first Accept value
|
||||
* byte[] body = req.body().bytes(); // materialise body
|
||||
* InputStream in = req.body().stream(); // zero-copy stream
|
||||
* });
|
||||
* }</pre>
|
||||
*/
|
||||
@Value
|
||||
@ToString
|
||||
public class Request {
|
||||
RequestLine requestLine;
|
||||
|
||||
@Getter(lombok.AccessLevel.NONE)
|
||||
@EqualsAndHashCode.Exclude
|
||||
@ToString.Exclude
|
||||
LazyBody lazyBody;
|
||||
RequestBody body;
|
||||
|
||||
@NonFinal @Setter @Getter PathParams pathParams;
|
||||
@NonFinal @Setter @Getter QueryParams queryParams;
|
||||
/** Internal: the parsed request line (method, path, query, protocol, headers). */
|
||||
RequestLine requestLine;
|
||||
|
||||
private Request(RequestLine requestLine, LazyBody lazyBody) {
|
||||
@NonFinal @Setter PathParams pathParams;
|
||||
@NonFinal @Setter QueryParams queryParams;
|
||||
|
||||
private Request(RequestLine requestLine, RequestBody body) {
|
||||
this.requestLine = requestLine;
|
||||
this.lazyBody = lazyBody;
|
||||
this.body = body;
|
||||
this.pathParams = null;
|
||||
this.queryParams = null;
|
||||
}
|
||||
|
||||
public Request(RequestLine requestLine, byte[] body) {
|
||||
this(requestLine, LazyBody.of(body));
|
||||
this(requestLine, RequestBody.of(body));
|
||||
}
|
||||
|
||||
public static Request forParsed(RequestLine requestLine, InputStream stream,
|
||||
int contentLength, byte[] headerBuf,
|
||||
long contentLength, byte[] headerBuf,
|
||||
int bodyStart, int preBufLen) {
|
||||
LazyBody lazy = contentLength > 0
|
||||
? new LazyBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
|
||||
: LazyBody.empty();
|
||||
return new Request(requestLine, lazy);
|
||||
RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
|
||||
: contentLength == 0 ? RequestBody.empty()
|
||||
: /* chunked */ new RequestBody(stream, -1L, null, 0, 0);
|
||||
return new Request(requestLine, rb);
|
||||
}
|
||||
|
||||
public byte[] getBody() { return lazyBody.get(); }
|
||||
// ── Request line ──────────────────────────────────────────────────────────
|
||||
|
||||
public String getHeader(String name) { return requestLine.getHeaders().getFirst(name); }
|
||||
public List<String> getHeaders(String name) { return requestLine.getHeaders().getAll(name); }
|
||||
public List<String> getHeaders() { return requestLine.getHeaders().getAll(); }
|
||||
public boolean headerEquals(String name, String value) { return requestLine.getHeaders().headerValueEqualsIgnoreCase(name, value); }
|
||||
/** HTTP method ({@code GET}, {@code POST}, …). */
|
||||
public HttpMethod method() { return requestLine.getMethod(); }
|
||||
|
||||
public String getPathParam(String name) {
|
||||
return pathParams != null ? pathParams.get(name) : null;
|
||||
/**
|
||||
* Request path decoded as UTF-8. Includes a leading slash; never includes the query string.
|
||||
* Example: a request for {@code /users/42?page=1} returns {@code "/users/42"}.
|
||||
*/
|
||||
public String path() {
|
||||
ByteView v = requestLine.getPath();
|
||||
byte[] buf = new byte[v.length()];
|
||||
for (int i = 0; i < v.length(); i++) buf[i] = v.byteAt(i);
|
||||
return new String(buf, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public String getQueryParam(String name) {
|
||||
return resolveQueryParams().get(name);
|
||||
}
|
||||
// ── Headers ───────────────────────────────────────────────────────────────
|
||||
|
||||
public List<String> getQueryParams(String name) {
|
||||
return resolveQueryParams().getAll(name);
|
||||
/**
|
||||
* Returns the first value of header {@code name}, or {@code null} if absent.
|
||||
* Lookup is case-insensitive ({@code "content-type"} and {@code "Content-Type"} are equivalent).
|
||||
*/
|
||||
public String header(String name) { return requestLine.getHeaders().first(name); }
|
||||
|
||||
/**
|
||||
* Returns all values of header {@code name} in declaration order.
|
||||
* Useful for headers that appear multiple times (e.g. {@code Accept}, {@code Cookie}).
|
||||
* Lookup is case-insensitive. Returns an empty list if the header is absent.
|
||||
*/
|
||||
public List<String> headers(String name) { return requestLine.getHeaders().all(name); }
|
||||
|
||||
/**
|
||||
* Returns all header values in declaration order, one entry per header line.
|
||||
* Useful for debugging; for targeted access prefer {@link #header(String)}.
|
||||
*/
|
||||
public List<String> headers() { return requestLine.getHeaders().all(); }
|
||||
|
||||
// ── Path parameters ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the path parameter named {@code name}, or {@code null}.
|
||||
* Parameters are declared in the route pattern (e.g. {@code /users/{id}}) and
|
||||
* injected by the router before the handler runs. Returns {@code null} if this
|
||||
* route has no such parameter or the route is not parametric.
|
||||
*/
|
||||
public String param(String name) { return pathParams != null ? pathParams.get(name) : null; }
|
||||
|
||||
// ── Query parameters ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the first query parameter named {@code name}, or {@code null}.
|
||||
* The query string is parsed lazily on the first call and cached for the request lifetime.
|
||||
* For {@code ?a=1&a=2}, returns {@code "1"}.
|
||||
*/
|
||||
public String query(String name) { return resolveQueryParams().get(name); }
|
||||
|
||||
/**
|
||||
* Returns all query parameters named {@code name} in declaration order.
|
||||
* For {@code ?tag=a&tag=b}, returns {@code ["a", "b"]}.
|
||||
* Returns an empty list if the parameter is absent.
|
||||
*/
|
||||
public List<String> queries(String name) { return resolveQueryParams().getAll(name); }
|
||||
|
||||
// ── Body ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the request body accessor. Use {@link RequestBody#bytes()} to materialise
|
||||
* the full body or {@link RequestBody#stream()} for zero-copy streaming access.
|
||||
* The two modes are mutually exclusive per request.
|
||||
*/
|
||||
public RequestBody body() { return body; }
|
||||
|
||||
/** Discards unread body bytes; called by the server after each request on keep-alive connections. */
|
||||
public void drain() { body.drain(); }
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Internal: case-insensitive header value comparison used by the server keep-alive logic. */
|
||||
public boolean headerEquals(String name, String value) {
|
||||
return requestLine.getHeaders().valueEqualsIgnoreCase(name, value);
|
||||
}
|
||||
|
||||
private QueryParams resolveQueryParams() {
|
||||
|
||||
@@ -13,5 +13,5 @@ public abstract class RequestHandler {
|
||||
* return a {@link Response} to replace the whole response, any other non-null value
|
||||
* to set it as the body, or {@code null} to leave the response as-is.
|
||||
*/
|
||||
public abstract Object handle(Request request, Response response);
|
||||
public abstract Object handle(Request request, Response response) throws Exception;
|
||||
}
|
||||
@@ -1,45 +1,142 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.http.HttpStatus;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* HTTP response. All mutating methods return {@code this} for fluent chaining.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // fixed body
|
||||
* return new Response(200, "ok", ContentType.TEXT_PLAIN);
|
||||
*
|
||||
* // known-length stream → Content-Length header
|
||||
* return new Response(200, ContentType.BINARY).stream(Files.newInputStream(p), Files.size(p));
|
||||
*
|
||||
* // unknown-length stream → Transfer-Encoding: chunked
|
||||
* return new Response(200, ContentType.TEXT_PLAIN).chunked(source);
|
||||
* }</pre>
|
||||
*/
|
||||
@Getter
|
||||
@ToString
|
||||
public class Response {
|
||||
@Setter private int statusCode;
|
||||
private byte[] body;
|
||||
private byte[] contentType;
|
||||
private byte[] statusBytes; // pre-encoded "200 OK"; null when set via status(int)
|
||||
private byte[] body;
|
||||
@ToString.Exclude
|
||||
private InputStream stream;
|
||||
private long streamLength; // meaningful only when isStreaming() && !chunked
|
||||
private boolean chunked;
|
||||
private byte[] contentType;
|
||||
@Getter(lombok.AccessLevel.NONE)
|
||||
private List<byte[]> headers; // pre-encoded "Name: Value\r\n" entries
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constructors
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public Response(int statusCode, ContentType contentType) {
|
||||
this.statusCode = statusCode;
|
||||
this.contentType = contentType.getBytes();
|
||||
}
|
||||
|
||||
public Response(int statusCode, byte[] body, ContentType contentType) {
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
this.contentType = contentType.getBytes();
|
||||
this(statusCode, contentType);
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
public Response(int statusCode, String text, ContentType contentType) {
|
||||
this.statusCode = statusCode;
|
||||
this.body = text.getBytes(StandardCharsets.UTF_8);
|
||||
this.contentType = contentType.getBytes();
|
||||
this(statusCode, text.getBytes(StandardCharsets.UTF_8), contentType);
|
||||
}
|
||||
|
||||
public void setContentType(ContentType contentType) {
|
||||
this.contentType = contentType.getBytes();
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
// Fluent mutators
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public void setContentType(String contentType) {
|
||||
this.contentType = contentType.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
/** Sets the status code. The phrase is looked up from {@link HttpStatus} on the write path. */
|
||||
public Response status(int code) { this.statusCode = code; this.statusBytes = null; return this; }
|
||||
|
||||
public Response setBody(Object body) {
|
||||
if (body instanceof byte[] bytes) {
|
||||
this.body = bytes;
|
||||
} else if (body != null) {
|
||||
this.body = body.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
/** Sets the status from an {@link HttpStatus} constant. The pre-encoded bytes are used
|
||||
* directly on the write path — zero lookup, zero allocation. */
|
||||
public Response status(HttpStatus status) { this.statusCode = status.code(); this.statusBytes = status.bytes(); return this; }
|
||||
public Response type(ContentType ct) { this.contentType = ct.getBytes(); return this; }
|
||||
public Response type(String ct) { this.contentType = ct.getBytes(StandardCharsets.UTF_8); return this; }
|
||||
|
||||
public Response body(byte[] bytes) {
|
||||
this.body = bytes;
|
||||
this.stream = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Response body(String text) {
|
||||
return body(text.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** Streaming response with known length; written with {@code Content-Length}. */
|
||||
public Response stream(InputStream is, long length) {
|
||||
this.stream = is;
|
||||
this.streamLength = length;
|
||||
this.chunked = false;
|
||||
this.body = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Streaming response with unknown length; written with {@code Transfer-Encoding: chunked}. */
|
||||
public Response chunked(InputStream is) {
|
||||
this.stream = is;
|
||||
this.chunked = true;
|
||||
this.body = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Adds a response header. Encoded once at call time; zero-alloc on the write path. */
|
||||
public Response header(String name, String value) {
|
||||
if (headers == null) headers = new ArrayList<>();
|
||||
headers.add((name + ": " + value + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
return this;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State queries
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public boolean isStreaming() { return stream != null; }
|
||||
|
||||
/**
|
||||
* Pre-encoded status bytes (e.g. {@code "200 OK"}), or {@code null} if the status was set
|
||||
* via {@link #status(int)} — in which case {@link HttpStatus#bytesForCode} is used as fallback.
|
||||
*/
|
||||
public byte[] getStatusBytes() { return statusBytes; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal setters used by HttpServer for handler return values
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Sets the body from an arbitrary handler return value. */
|
||||
public Response setBody(Object body) {
|
||||
if (body instanceof byte[] bytes) this.body = bytes;
|
||||
else if (body != null) this.body = body.toString().getBytes(StandardCharsets.UTF_8);
|
||||
return this;
|
||||
}
|
||||
|
||||
public void setContentType(ContentType ct) { this.contentType = ct.getBytes(); }
|
||||
|
||||
/** Returns custom headers, or an empty list if none were added. */
|
||||
public List<byte[]> getHeaders() { return headers != null ? headers : List.of(); }
|
||||
|
||||
/** Writes pre-encoded custom headers directly to {@code out}. Zero-alloc when no headers are set. */
|
||||
public void writeHeaders(OutputStream out) throws IOException {
|
||||
if (headers == null) return;
|
||||
for (int i = 0, n = headers.size(); i < n; i++) out.write(headers.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public class SimpleHandler extends RequestHandler {
|
||||
private final FunctionalHandler delegate;
|
||||
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
public Object handle(Request request, Response response) throws Exception {
|
||||
return delegate.handle(request, response);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,6 @@ public class SimpleHandler extends RequestHandler {
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FunctionalHandler {
|
||||
Object handle(Request request, Response response);
|
||||
Object handle(Request request, Response response) throws Exception;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import lombok.Getter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Base router. Each router has a namespace prefix (default {@code "/"}); routes added via
|
||||
* {@link #get}, {@link #post}, etc. are relative to it. Error handlers are scoped to this router.
|
||||
* Base router. Each router has a namespace prefix (default {@code "/"});
|
||||
* Error handlers are scoped to this router.
|
||||
* The namespace is set automatically by {@link GlobalRouter#mount}.
|
||||
*/
|
||||
public abstract class AbstractRouter {
|
||||
@@ -57,21 +57,20 @@ public abstract class AbstractRouter {
|
||||
* Registers a route relative to this router's namespace. Return a {@link Response} to replace
|
||||
* it entirely, any other non-null value to set it as the body, or {@code null} to leave it unchanged.
|
||||
*/
|
||||
public AbstractRouter get(String path, SimpleHandler.FunctionalHandler handler) {
|
||||
return addRoute(HttpMethod.GET, PathUtils.sanitize(path), new SimpleHandler(handler));
|
||||
public AbstractRouter register(HttpMethod method, String path, SimpleHandler.FunctionalHandler handler) {
|
||||
return addRoute(method, PathUtils.sanitize(path), new SimpleHandler(handler));
|
||||
}
|
||||
|
||||
public AbstractRouter post(String path, SimpleHandler.FunctionalHandler handler) {
|
||||
return addRoute(HttpMethod.POST, PathUtils.sanitize(path), new SimpleHandler(handler));
|
||||
}
|
||||
|
||||
public AbstractRouter put(String path, SimpleHandler.FunctionalHandler handler) {
|
||||
return addRoute(HttpMethod.PUT, PathUtils.sanitize(path), new SimpleHandler(handler));
|
||||
}
|
||||
|
||||
public AbstractRouter delete(String path, SimpleHandler.FunctionalHandler handler) {
|
||||
return addRoute(HttpMethod.DELETE, PathUtils.sanitize(path), new SimpleHandler(handler));
|
||||
}
|
||||
public AbstractRouter get(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.GET, path, h); }
|
||||
public AbstractRouter post(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.POST, path, h); }
|
||||
public AbstractRouter put(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.PUT, path, h); }
|
||||
public AbstractRouter delete(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.DELETE, path, h); }
|
||||
public AbstractRouter patch(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.PATCH, path, h); }
|
||||
public AbstractRouter options(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.OPTIONS, path, h); }
|
||||
public AbstractRouter head(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.HEAD, path, h); }
|
||||
public AbstractRouter trace(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.TRACE, path, h); }
|
||||
public AbstractRouter connect(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.CONNECT, path, h); }
|
||||
public AbstractRouter purge(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.PURGE, path, h); }
|
||||
|
||||
/** Registers a class-based handler; the class must be annotated with {@link Route @Route}. */
|
||||
public AbstractRouter register(RequestHandler handler) {
|
||||
|
||||
@@ -39,7 +39,7 @@ public final class FastPathViews {
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable composite view: method bytes + path. Reused via ThreadLocal — call reset() before use. */
|
||||
/** Mutable composite view: method bytes + path. Reused via ThreadLocal, call reset() before use. */
|
||||
public static final class MethodPathByteView implements ByteView {
|
||||
private byte[] method;
|
||||
private ByteView path;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>404 — Flash</title>
|
||||
<title>404</title>
|
||||
<style>
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box }
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>500 — Flash</title>
|
||||
<title>500</title>
|
||||
<style>
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box }
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class HttpServerConcurrencyTest {
|
||||
server = new HttpServer(config);
|
||||
server.get("/ping", (req, res) -> "pong");
|
||||
server.post("/echo", (req, res) -> {
|
||||
byte[] body = req.getBody();
|
||||
byte[] body = req.body().bytes();
|
||||
return new Response(200, body, ContentType.TEXT_PLAIN);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
@@ -39,14 +40,29 @@ class HttpServerTest {
|
||||
server.get("/api/ping", (req, res) -> "pong");
|
||||
|
||||
server.post("/api/echo", (req, res) -> {
|
||||
byte[] body = req.getBody();
|
||||
return new Response(201, body, ContentType.TEXT_PLAIN); // echo body & change status
|
||||
byte[] body = req.body().bytes();
|
||||
return res.status(201).body(body); // echo body & change status
|
||||
});
|
||||
|
||||
server.get("/api/crash", (req, res) -> {
|
||||
throw new RuntimeException("Simulated Crash");
|
||||
});
|
||||
|
||||
byte[] streamData = "streaming response body".getBytes(StandardCharsets.UTF_8);
|
||||
server.get("/api/stream", (req, res) ->
|
||||
res.stream(new ByteArrayInputStream(streamData), streamData.length));
|
||||
|
||||
server.get("/api/chunked-out", (req, res) ->
|
||||
res.chunked(new ByteArrayInputStream(streamData)));
|
||||
|
||||
server.get("/api/custom-header", (req, res) ->
|
||||
res.body("ok").header("X-Flash", "works"));
|
||||
|
||||
server.post("/api/chunked-in", (req, res) -> {
|
||||
byte[] body = req.body().bytes();
|
||||
return res.body(body);
|
||||
});
|
||||
|
||||
server.start().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@@ -57,27 +73,35 @@ class HttpServerTest {
|
||||
}
|
||||
}
|
||||
|
||||
// --- raw socket helper ---
|
||||
// --- helpers ---
|
||||
|
||||
private static final int SOCKET_TIMEOUT_MS = 5000;
|
||||
|
||||
private String sendRawRequest(String rawHttp) throws Exception {
|
||||
try (Socket socket = new Socket("127.0.0.1", port);
|
||||
OutputStream out = socket.getOutputStream();
|
||||
InputStream in = socket.getInputStream()) {
|
||||
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
out.write(rawHttp.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
return readOneResponse(in);
|
||||
}
|
||||
}
|
||||
|
||||
// Read until \r\n\r\n to get the full header block
|
||||
ByteArrayOutputStream headerBuf = new ByteArrayOutputStream();
|
||||
int b, prev3 = -1, prev2 = -1, prev1 = -1;
|
||||
while ((b = in.read()) != -1) {
|
||||
headerBuf.write(b);
|
||||
if (prev3 == '\r' && prev2 == '\n' && prev1 == '\r' && b == '\n') break;
|
||||
prev3 = prev2; prev2 = prev1; prev1 = b;
|
||||
}
|
||||
String headers = headerBuf.toString(StandardCharsets.UTF_8);
|
||||
private String readOneResponse(InputStream in) throws Exception {
|
||||
ByteArrayOutputStream headerBuf = new ByteArrayOutputStream();
|
||||
int b, prev3 = -1, prev2 = -1, prev1 = -1;
|
||||
while ((b = in.read()) != -1) {
|
||||
headerBuf.write(b);
|
||||
if (prev3 == '\r' && prev2 == '\n' && prev1 == '\r' && b == '\n') break;
|
||||
prev3 = prev2; prev2 = prev1; prev1 = b;
|
||||
}
|
||||
String headers = headerBuf.toString(StandardCharsets.UTF_8);
|
||||
|
||||
// Parse Content-Length
|
||||
String body;
|
||||
if (headers.toLowerCase().contains("transfer-encoding: chunked")) {
|
||||
body = readChunkedBody(in);
|
||||
} else {
|
||||
int contentLength = 0;
|
||||
for (String line : headers.split("\r\n")) {
|
||||
if (line.toLowerCase().startsWith("content-length:")) {
|
||||
@@ -85,11 +109,25 @@ class HttpServerTest {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Read exactly Content-Length bytes for the body
|
||||
byte[] body = in.readNBytes(contentLength);
|
||||
return headers + new String(body, StandardCharsets.UTF_8);
|
||||
body = new String(in.readNBytes(contentLength), StandardCharsets.UTF_8);
|
||||
}
|
||||
return headers + body;
|
||||
}
|
||||
|
||||
private String readChunkedBody(InputStream in) throws Exception {
|
||||
ByteArrayOutputStream result = new ByteArrayOutputStream();
|
||||
while (true) {
|
||||
StringBuilder sizeLine = new StringBuilder();
|
||||
int b;
|
||||
while ((b = in.read()) != -1 && b != '\n') {
|
||||
if (b != '\r') sizeLine.append((char) b);
|
||||
}
|
||||
int size = Integer.parseInt(sizeLine.toString().trim(), 16);
|
||||
if (size == 0) break;
|
||||
result.write(in.readNBytes(size));
|
||||
in.read(); in.read(); // \r\n after chunk data
|
||||
}
|
||||
return result.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
// --- E2E Tests ---
|
||||
@@ -160,4 +198,78 @@ class HttpServerTest {
|
||||
assertTrue(res.startsWith("HTTP/1.1 404 Not Found"));
|
||||
assertTrue(res.contains("No route matched this request."));
|
||||
}
|
||||
|
||||
// --- streaming response ---
|
||||
|
||||
@Test
|
||||
void testStreamingResponse_writesContentLengthAndBody() throws Exception {
|
||||
String req = "GET /api/stream HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||
String res = sendRawRequest(req);
|
||||
|
||||
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
||||
assertTrue(res.contains("Content-Length: 23")); // "streaming response body"
|
||||
assertTrue(res.endsWith("streaming response body"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testChunkedResponse_writesTransferEncodingChunked() throws Exception {
|
||||
String req = "GET /api/chunked-out HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||
String res = sendRawRequest(req);
|
||||
|
||||
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
||||
assertTrue(res.contains("Transfer-Encoding: chunked"));
|
||||
assertTrue(res.endsWith("streaming response body"));
|
||||
}
|
||||
|
||||
// --- custom response headers ---
|
||||
|
||||
@Test
|
||||
void testCustomHeader_appearsInResponse() throws Exception {
|
||||
String req = "GET /api/custom-header HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||
String res = sendRawRequest(req);
|
||||
|
||||
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
||||
assertTrue(res.contains("X-Flash: works\r\n"));
|
||||
}
|
||||
|
||||
// --- chunked request body ---
|
||||
|
||||
@Test
|
||||
void testChunkedRequestBody_decodedAndEchoed() throws Exception {
|
||||
String req = "POST /api/chunked-in HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Transfer-Encoding: chunked\r\n" +
|
||||
"\r\n" +
|
||||
"5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n";
|
||||
|
||||
String res = sendRawRequest(req);
|
||||
|
||||
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
||||
assertTrue(res.endsWith("hello world"));
|
||||
}
|
||||
|
||||
// --- keep-alive ---
|
||||
|
||||
@Test
|
||||
void testKeepAlive_twoRequestsOnSameConnection() throws Exception {
|
||||
String req = "GET /api/ping HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||
|
||||
try (Socket socket = new Socket("127.0.0.1", port);
|
||||
OutputStream out = socket.getOutputStream();
|
||||
InputStream in = socket.getInputStream()) {
|
||||
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
out.write(req.getBytes(StandardCharsets.UTF_8));
|
||||
out.write(req.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
|
||||
String first = readOneResponse(in);
|
||||
String second = readOneResponse(in);
|
||||
|
||||
assertTrue(first.contains("pong"));
|
||||
assertTrue(second.contains("pong"));
|
||||
assertTrue(first.contains("Connection: keep-alive"));
|
||||
assertTrue(second.contains("Connection: keep-alive"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ class RequestParserTest {
|
||||
@Test
|
||||
void queryParam_resolvedFromPath() throws IOException {
|
||||
Request r = parse(req("GET /search?q=flash&page=2 HTTP/1.1", "Host: localhost"));
|
||||
assertEquals("flash", r.getQueryParam("q"));
|
||||
assertEquals("2", r.getQueryParam("page"));
|
||||
assertEquals("flash", r.query("q"));
|
||||
assertEquals("2", r.query("page"));
|
||||
}
|
||||
|
||||
// --- headers ---
|
||||
@@ -53,15 +53,15 @@ class RequestParserTest {
|
||||
@Test
|
||||
void headers_parsed() throws IOException {
|
||||
Request r = parse(req("GET / HTTP/1.1", "Host: example.com", "Accept: application/json"));
|
||||
assertEquals("example.com", r.getHeader("Host"));
|
||||
assertEquals("application/json", r.getHeader("Accept"));
|
||||
assertEquals("example.com", r.header("Host"));
|
||||
assertEquals("application/json", r.header("Accept"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void headers_caseInsensitive() throws IOException {
|
||||
Request r = parse(req("GET / HTTP/1.1", "Content-Type: text/plain"));
|
||||
assertEquals("text/plain", r.getHeader("content-type"));
|
||||
assertEquals("text/plain", r.getHeader("CONTENT-TYPE"));
|
||||
assertEquals("text/plain", r.header("content-type"));
|
||||
assertEquals("text/plain", r.header("CONTENT-TYPE"));
|
||||
}
|
||||
|
||||
// --- body ---
|
||||
@@ -72,13 +72,13 @@ class RequestParserTest {
|
||||
String raw = "POST / HTTP/1.1\r\nContent-Length: " + body.length() + "\r\n\r\n" + body;
|
||||
Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
assertNotNull(r);
|
||||
assertEquals(body, new String(r.getBody(), StandardCharsets.UTF_8));
|
||||
assertEquals(body, new String(r.body().bytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void body_emptyWhenNoContentLength() throws IOException {
|
||||
Request r = parse(req("GET / HTTP/1.1", "Host: localhost"));
|
||||
assertEquals(0, r.getBody().length);
|
||||
assertTrue(r.body().isEmpty());
|
||||
}
|
||||
|
||||
// --- edge cases / robustness ---
|
||||
@@ -102,19 +102,45 @@ class RequestParserTest {
|
||||
|
||||
@Test
|
||||
void requestLine_noProtocol_throwsIOException() {
|
||||
// No space after path — parser cannot find protocol boundary
|
||||
// No space after path, parser cannot find protocol boundary
|
||||
assertThrows(IOException.class, () -> parse(req("GET /noproto")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void headers_exceedingMaxBufferSize_throwsIOException() {
|
||||
// Feed more bytes than the configured cap with no \r\n\r\n — must throw
|
||||
// Feed more bytes than the configured cap with no \r\n\r\n : must throw
|
||||
int cap = 16 * 1024;
|
||||
byte[] giant = new byte[cap + 1];
|
||||
Arrays.fill(giant, (byte) 'A');
|
||||
assertThrows(IOException.class, () -> new RequestParser(cap).parse(new ByteArrayInputStream(giant)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void chunkedTransferEncoding_bodyReadable() throws IOException {
|
||||
String raw = "POST / HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Transfer-Encoding: chunked\r\n" +
|
||||
"\r\n" +
|
||||
"5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n";
|
||||
Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
assertNotNull(r);
|
||||
assertEquals(-1L, r.body().contentLength()); // -1 = chunked
|
||||
assertArrayEquals("hello world".getBytes(StandardCharsets.UTF_8), r.body().bytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentLength_parsedAsLong() throws IOException {
|
||||
// 5 GB — too large to materialize, but contentLength must be a long
|
||||
String raw = "POST / HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Content-Length: 5000000000\r\n" +
|
||||
"\r\n";
|
||||
Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
assertNotNull(r);
|
||||
assertEquals(5_000_000_000L, r.body().contentLength());
|
||||
assertThrows(IllegalStateException.class, r.body()::bytes);
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentLength_largerThanBody_readsPartial() throws IOException {
|
||||
// Content-Length claims 50 but stream ends after 5 bytes
|
||||
@@ -122,7 +148,7 @@ class RequestParserTest {
|
||||
String raw = "POST / HTTP/1.1\r\nContent-Length: 50\r\n\r\n" + body;
|
||||
Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
assertNotNull(r);
|
||||
assertEquals(50, r.getBody().length);
|
||||
assertEquals(body, new String(r.getBody(), 0, body.length(), StandardCharsets.UTF_8));
|
||||
assertEquals(50, r.body().bytes().length);
|
||||
assertEquals(body, new String(r.body().bytes(), 0, body.length(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,73 +12,63 @@ class HeaderMapTest {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static HeaderMap parse(String raw, String... headers) {
|
||||
private static HeaderMap parse(String... headers) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String h : headers) sb.append(h).append("\r\n");
|
||||
byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||
HeaderMap map = new HeaderMap(buffer);
|
||||
|
||||
int current = 0;
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
int colon = sb.indexOf(":", current);
|
||||
int lineEnd = sb.indexOf("\r\n", current);
|
||||
int valueStart = colon + 1;
|
||||
while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++;
|
||||
|
||||
map.add(current, colon - current, valueStart, lineEnd - valueStart);
|
||||
current = lineEnd + 2;
|
||||
}
|
||||
HeaderMap map = new HeaderMap();
|
||||
map.reset(buffer, 0, buffer.length);
|
||||
return map;
|
||||
}
|
||||
|
||||
// --- getFirst ---
|
||||
// --- first ---
|
||||
|
||||
@Test
|
||||
void getFirst_existingHeader() {
|
||||
HeaderMap map = parse("", "Host: localhost", "Accept: text/plain");
|
||||
assertEquals("localhost", map.getFirst("Host"));
|
||||
assertEquals("text/plain", map.getFirst("Accept"));
|
||||
void first_existingHeader() {
|
||||
HeaderMap map = parse("Host: localhost", "Accept: text/plain");
|
||||
assertEquals("localhost", map.first("Host"));
|
||||
assertEquals("text/plain", map.first("Accept"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFirst_caseInsensitive() {
|
||||
HeaderMap map = parse("", "ConteNT-tYPe: application/json");
|
||||
assertEquals("application/json", map.getFirst("content-type"));
|
||||
assertEquals("application/json", map.getFirst("CONTENT-TYPE"));
|
||||
void first_caseInsensitive() {
|
||||
HeaderMap map = parse("ConteNT-tYPe: application/json");
|
||||
assertEquals("application/json", map.first("content-type"));
|
||||
assertEquals("application/json", map.first("CONTENT-TYPE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFirst_missingHeader_returnsNull() {
|
||||
HeaderMap map = parse("", "Host: localhost");
|
||||
assertNull(map.getFirst("Accept"));
|
||||
void first_missingHeader_returnsNull() {
|
||||
HeaderMap map = parse("Host: localhost");
|
||||
assertNull(map.first("Accept"));
|
||||
}
|
||||
|
||||
// --- getAll ---
|
||||
// --- all ---
|
||||
|
||||
@Test
|
||||
void getAll_multipleValuesByName() {
|
||||
HeaderMap map = parse("", "Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2");
|
||||
assertEquals(List.of("a=1", "b=2"), map.getAll("Cookie"));
|
||||
void all_multipleValuesByName() {
|
||||
HeaderMap map = parse("Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2");
|
||||
assertEquals(List.of("a=1", "b=2"), map.all("Cookie"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAll_missingHeader_returnsEmptyList() {
|
||||
HeaderMap map = parse("", "Host: localhost");
|
||||
assertTrue(map.getAll("Cookie").isEmpty());
|
||||
void all_missingHeader_returnsEmptyList() {
|
||||
HeaderMap map = parse("Host: localhost");
|
||||
assertTrue(map.all("Cookie").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAll_returnsAllHeaders() {
|
||||
HeaderMap map = parse("", "A: 1", "B: 2");
|
||||
assertEquals(List.of("1", "2"), map.getAll());
|
||||
void all_returnsAllHeaders() {
|
||||
HeaderMap map = parse("A: 1", "B: 2");
|
||||
assertEquals(List.of("1", "2"), map.all());
|
||||
}
|
||||
|
||||
// --- getView ---
|
||||
// --- view ---
|
||||
|
||||
@Test
|
||||
void getView_returnsZeroCopyView() {
|
||||
HeaderMap map = parse("", "Host: localhost");
|
||||
ByteView view = map.getView("Host");
|
||||
void view_returnsZeroCopyView() {
|
||||
HeaderMap map = parse("Host: localhost");
|
||||
ByteView view = map.view("Host");
|
||||
assertNotNull(view);
|
||||
assertEquals(9, view.length());
|
||||
assertEquals('l', view.byteAt(0));
|
||||
@@ -86,20 +76,18 @@ class HeaderMapTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void getView_missingHeader_returnsNull() {
|
||||
HeaderMap map = parse("", "Host: localhost");
|
||||
assertNull(map.getView("Accept"));
|
||||
void view_missingHeader_returnsNull() {
|
||||
HeaderMap map = parse("Host: localhost");
|
||||
assertNull(map.view("Accept"));
|
||||
}
|
||||
|
||||
// --- limit ---
|
||||
// --- empty ---
|
||||
|
||||
@Test
|
||||
void maxHeadersLimit() {
|
||||
byte[] buffer = new byte[100];
|
||||
HeaderMap map = new HeaderMap(buffer);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
map.add(0, 1, 1, 1);
|
||||
}
|
||||
assertThrows(IllegalStateException.class, () -> map.add(0, 1, 1, 1));
|
||||
void emptyMap_returnsNullAndEmptyList() {
|
||||
HeaderMap map = new HeaderMap();
|
||||
assertNull(map.first("Host"));
|
||||
assertTrue(map.all("Host").isEmpty());
|
||||
assertTrue(map.all().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class RequestLineTest {
|
||||
ByteView path = viewOf("/api");
|
||||
ByteView query = viewOf("q=1");
|
||||
ByteView proto = viewOf("HTTP/1.1");
|
||||
HeaderMap headers = new HeaderMap(new byte[0]);
|
||||
HeaderMap headers = new HeaderMap();
|
||||
|
||||
RequestLine rl = new RequestLine(HttpMethod.GET, path, query, proto, headers);
|
||||
|
||||
|
||||
@@ -25,64 +25,65 @@ class RequestTest {
|
||||
// --- creation ---
|
||||
|
||||
@Test
|
||||
void request_creationAndGetters() {
|
||||
HeaderMap headers = new HeaderMap(new byte[0]);
|
||||
void request_creationAndAccessors() {
|
||||
HeaderMap headers = new HeaderMap();
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/path"), viewOf("q=1"), viewOf("HTTP/1.1"), headers);
|
||||
byte[] body = "body".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
Request r = new Request(line, body);
|
||||
|
||||
assertEquals(line, r.getRequestLine());
|
||||
assertArrayEquals(body, r.getBody());
|
||||
assertEquals(HttpMethod.GET, r.method());
|
||||
assertEquals("/path", r.path());
|
||||
assertArrayEquals(body, r.body().bytes());
|
||||
assertNotNull(r.toString());
|
||||
}
|
||||
|
||||
// --- delegates ---
|
||||
// --- headers ---
|
||||
|
||||
@Test
|
||||
void headers_delegatesToRequestLine() {
|
||||
void header_delegatesToRequestLine() {
|
||||
byte[] buffer = "Host: localhost\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
HeaderMap headers = new HeaderMap(buffer);
|
||||
headers.add(0, 4, 6, 9);
|
||||
HeaderMap headers = new HeaderMap();
|
||||
headers.reset(buffer, 0, buffer.length);
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), headers);
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertEquals("localhost", r.getHeader("Host"));
|
||||
assertEquals(List.of("localhost"), r.getHeaders("Host"));
|
||||
assertEquals(List.of("localhost"), r.getHeaders());
|
||||
assertEquals("localhost", r.header("Host"));
|
||||
assertEquals(List.of("localhost"), r.headers("Host"));
|
||||
assertEquals(List.of("localhost"), r.headers());
|
||||
}
|
||||
|
||||
// --- pathParams ---
|
||||
// --- path params ---
|
||||
|
||||
@Test
|
||||
void pathParam_lazyGet() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap(new byte[0]));
|
||||
void param_lazyGet() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap());
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertNull(r.getPathParam("id"));
|
||||
assertNull(r.param("id"));
|
||||
|
||||
r.setPathParams(new PathParams(viewOf("/123"), new String[]{"id"}, new int[]{1}, new int[]{3}));
|
||||
assertEquals("123", r.getPathParam("id"));
|
||||
assertEquals("123", r.param("id"));
|
||||
}
|
||||
|
||||
// --- queryParams ---
|
||||
// --- query params ---
|
||||
|
||||
@Test
|
||||
void queryParam_lazyGet_fromQueryString() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), viewOf("a=1&b=2&b=3"), viewOf("HTTP/1.1"), new HeaderMap(new byte[0]));
|
||||
void query_lazyGet_fromQueryString() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), viewOf("a=1&b=2&b=3"), viewOf("HTTP/1.1"), new HeaderMap());
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertEquals("1", r.getQueryParam("a"));
|
||||
assertEquals("2", r.getQueryParam("b")); // First value
|
||||
assertEquals(List.of("2", "3"), r.getQueryParams("b"));
|
||||
assertEquals("1", r.query("a"));
|
||||
assertEquals("2", r.query("b"));
|
||||
assertEquals(List.of("2", "3"), r.queries("b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryParam_lazyGet_nullQueryString() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap(new byte[0]));
|
||||
void query_lazyGet_nullQueryString() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap());
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertNull(r.getQueryParam("a"));
|
||||
assertTrue(r.getQueryParams("a").isEmpty());
|
||||
assertNull(r.query("a"));
|
||||
assertTrue(r.queries("a").isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ package dev.relism.models;
|
||||
import dev.relism.http.ContentType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ResponseTest {
|
||||
|
||||
// --- creation ---
|
||||
// --- construction ---
|
||||
|
||||
@Test
|
||||
void constructor_withByteArray() {
|
||||
@@ -31,34 +33,34 @@ class ResponseTest {
|
||||
assertArrayEquals(ContentType.TEXT_PLAIN.getBytes(), r.getContentType());
|
||||
}
|
||||
|
||||
// --- setters ---
|
||||
// --- fluent mutators ---
|
||||
|
||||
@Test
|
||||
void setContentType_byEnum() {
|
||||
void type_byEnum() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
r.setContentType(ContentType.JSON);
|
||||
r.type(ContentType.JSON);
|
||||
assertArrayEquals(ContentType.JSON.getBytes(), r.getContentType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setContentType_byString() {
|
||||
void type_byString() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
r.setContentType("application/custom");
|
||||
r.type("application/custom");
|
||||
assertArrayEquals("application/custom".getBytes(StandardCharsets.UTF_8), r.getContentType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setBody_withByteArray() {
|
||||
void body_withByteArray() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
byte[] newBody = "new".getBytes(StandardCharsets.UTF_8);
|
||||
r.setBody(newBody);
|
||||
r.body(newBody);
|
||||
assertArrayEquals(newBody, r.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setBody_withObjectConvertedToString() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
r.setBody(12345); // auto boxes to Integer, toString called
|
||||
r.setBody(12345);
|
||||
assertArrayEquals("12345".getBytes(StandardCharsets.UTF_8), r.getBody());
|
||||
}
|
||||
|
||||
@@ -68,4 +70,55 @@ class ResponseTest {
|
||||
r.setStatusCode(201);
|
||||
assertEquals(201, r.getStatusCode());
|
||||
}
|
||||
|
||||
// --- streaming ---
|
||||
|
||||
@Test
|
||||
void stream_knownLength() {
|
||||
InputStream src = new ByteArrayInputStream("body".getBytes());
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN).stream(src, 4L);
|
||||
|
||||
assertTrue(r.isStreaming());
|
||||
assertFalse(r.isChunked());
|
||||
assertEquals(4L, r.getStreamLength());
|
||||
assertSame(src, r.getStream());
|
||||
assertNull(r.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void chunked_setsChunkedFlag() {
|
||||
InputStream src = new ByteArrayInputStream("x".getBytes());
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN).chunked(src);
|
||||
|
||||
assertTrue(r.isStreaming());
|
||||
assertTrue(r.isChunked());
|
||||
assertSame(src, r.getStream());
|
||||
assertNull(r.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isStreaming_falseForFixedBody() {
|
||||
assertFalse(new Response(200, new byte[0], ContentType.TEXT_PLAIN).isStreaming());
|
||||
}
|
||||
|
||||
// --- header ---
|
||||
|
||||
@Test
|
||||
void header_encodedCorrectly() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
r.header("X-Foo", "bar");
|
||||
assertArrayEquals("X-Foo: bar\r\n".getBytes(StandardCharsets.UTF_8), r.getHeaders().get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void header_chaining_returnsSelf() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
assertSame(r, r.header("A", "1").header("B", "2"));
|
||||
assertEquals(2, r.getHeaders().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getHeaders_emptyWhenNoneAdded() {
|
||||
assertTrue(new Response(200, new byte[0], ContentType.TEXT_PLAIN).getHeaders().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ class GlobalRouterTest {
|
||||
RequestLine line = new RequestLine(
|
||||
HttpMethod.GET, pathView, null,
|
||||
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
|
||||
new HeaderMap(new byte[0])
|
||||
new HeaderMap()
|
||||
);
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
|
||||
+5
-5
@@ -23,7 +23,7 @@ class FastPathRouterImplTest {
|
||||
RequestLine line = new RequestLine(
|
||||
method, pathView, null,
|
||||
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
|
||||
new HeaderMap(new byte[0])
|
||||
new HeaderMap()
|
||||
);
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ class FastPathRouterImplTest {
|
||||
// --- route ---
|
||||
|
||||
@Test
|
||||
void route_lazyCompilationAndMatch() {
|
||||
void route_lazyCompilationAndMatch() throws Exception {
|
||||
FastPathRouterImpl router = new FastPathRouterImpl();
|
||||
|
||||
router.get("/a", (req, res) -> "A");
|
||||
@@ -57,7 +57,7 @@ class FastPathRouterImplTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void route_extractsPathParams() {
|
||||
void route_extractsPathParams() throws Exception {
|
||||
FastPathRouterImpl router = new FastPathRouterImpl();
|
||||
|
||||
router.get("/users/{id}/items/{itemId}", (req, res) -> "Extract");
|
||||
@@ -70,7 +70,7 @@ class FastPathRouterImplTest {
|
||||
|
||||
// Verify path params were injected
|
||||
assertNotNull(request.getPathParams());
|
||||
assertEquals("123", request.getPathParam("id"));
|
||||
assertEquals("456", request.getPathParam("itemId"));
|
||||
assertEquals("123", request.param("id"));
|
||||
assertEquals("456", request.param("itemId"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class ErrorPagesTest {
|
||||
byte[] protoBytes = protocol.getBytes(StandardCharsets.UTF_8);
|
||||
FastPathViews.RequestByteView protoView = new FastPathViews.RequestByteView(protoBytes, 0, protoBytes.length);
|
||||
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, pathView, null, protoView, new HeaderMap(new byte[0]));
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, pathView, null, protoView, new HeaderMap());
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user