enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles

This commit is contained in:
Relism
2026-03-19 12:44:33 +01:00
parent 94d029a631
commit 96afbf665d
30 changed files with 1250 additions and 521 deletions
+116 -53
View File
@@ -4,24 +4,37 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <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$/.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/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/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/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/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/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/LazyBody.java" beforeDir="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/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/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/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/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/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/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/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> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -35,7 +48,7 @@
</persistenceIdMap> </persistenceIdMap>
</component> </component>
<component name="EmbeddingIndexingInfo"> <component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="88" /> <option name="cachedIndexableFilesCount" value="86" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" /> <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component> </component>
<component name="FileTemplateManagerImpl"> <component name="FileTemplateManagerImpl">
@@ -73,44 +86,48 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"Application.ExternalBenchmark.executor": "Run", &quot;Application.ExternalBenchmark (1).executor&quot;: &quot;Run&quot;,
"Application.Main.executor": "Run", &quot;Application.ExternalBenchmark.executor&quot;: &quot;Run&quot;,
"Application.dev.relism.bench.Main.executor": "Run", &quot;Application.Main.executor&quot;: &quot;Run&quot;,
"JUnit.RequestParserTest.executor": "Run", &quot;Application.dev.relism.bench.Main.executor&quot;: &quot;Run&quot;,
"JUnit.RequestParserTest.headers_caseInsensitive.executor": "Debug", &quot;JUnit.RequestParserTest.executor&quot;: &quot;Run&quot;,
"Maven.FlashPractice [test].executor": "Run", &quot;JUnit.RequestParserTest.headers_caseInsensitive.executor&quot;: &quot;Debug&quot;,
"Maven.flash [compile].executor": "Run", &quot;Maven.FlashPractice [test].executor&quot;: &quot;Run&quot;,
"Maven.flash [install].executor": "Run", &quot;Maven.flash [compile].executor&quot;: &quot;Run&quot;,
"Maven.flash [test].executor": "Run", &quot;Maven.flash [install].executor&quot;: &quot;Run&quot;,
"Maven.flash-bench [clean].executor": "Run", &quot;Maven.flash [test].executor&quot;: &quot;Run&quot;,
"Maven.flash-bench [install].executor": "Run", &quot;Maven.flash-bench [clean].executor&quot;: &quot;Run&quot;,
"Maven.flash-bench [validate].executor": "Run", &quot;Maven.flash-bench [install].executor&quot;: &quot;Run&quot;,
"ModuleVcsDetector.initialDetectionPerformed": "true", &quot;Maven.flash-bench [package].executor&quot;: &quot;Run&quot;,
"RunOnceActivity.MCP Project settings loaded": "true", &quot;Maven.flash-bench [validate].executor&quot;: &quot;Run&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;Maven.flash-parent [install].executor&quot;: &quot;Run&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;Maven.flash-parent [package].executor&quot;: &quot;Run&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
"SHARE_PROJECT_CONFIGURATION_FILES": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"git-widget-placeholder": "master", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"ignore.virus.scanning.warn.message": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"kotlin-language-version-configured": "true", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"last_opened_file_path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash", &quot;SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
"node.js.detected.package.eslint": "true", &quot;git-widget-placeholder&quot;: &quot;master&quot;,
"node.js.detected.package.tslint": "true", &quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;kotlin-language-version-configured&quot;: &quot;true&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;last_opened_file_path&quot;: &quot;C:/Users/elorc/Documents/Coding/Java/practice/Flash&quot;,
"nodejs_package_manager_path": "npm", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"onboarding.tips.debug.path": "C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice/flash-bench/src/main/java/dev/relism/Main.java", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"project.structure.last.edited": "Modules", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"project.structure.proportion": "0.15", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"project.structure.side.proportion": "0.1150748", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"settings.editor.selected.configurable": "ml.llm.LLMThirdPartyAiProvidersConfigurable", &quot;onboarding.tips.debug.path&quot;: &quot;C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice/flash-bench/src/main/java/dev/relism/Main.java&quot;,
"vue.rearranger.settings.migration": "true" &quot;project.structure.last.edited&quot;: &quot;Modules&quot;,
&quot;project.structure.proportion&quot;: &quot;0.15&quot;,
&quot;project.structure.side.proportion&quot;: &quot;0.1150748&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;ml.llm.LLMThirdPartyAiProvidersConfigurable&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
} }
}]]></component> }</component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
<recent name="C:\Users\elorc\Documents\Coding\Java\practice\Flash" /> <recent name="C:\Users\elorc\Documents\Coding\Java\practice\Flash" />
@@ -119,10 +136,10 @@
<recent name="dev.relism" /> <recent name="dev.relism" />
</key> </key>
</component> </component>
<component name="RunManager" selected="Application.ExternalBenchmark"> <component name="RunManager" selected="Application.dev.relism.bench.Main">
<configuration name="ExternalBenchmark" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true"> <configuration name="ExternalBenchmark (1)" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
<option name="MAIN_CLASS_NAME" value="dev.relism.ExternalBenchmark" /> <option name="MAIN_CLASS_NAME" value="dev.relism.ExternalBenchmark" />
<module name="flash-bench" /> <module name="flash" />
<extension name="coverage"> <extension name="coverage">
<pattern> <pattern>
<option name="PATTERN" value="dev.relism.*" /> <option name="PATTERN" value="dev.relism.*" />
@@ -133,12 +150,38 @@
<option name="Make" enabled="true" /> <option name="Make" enabled="true" />
</method> </method>
</configuration> </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"> <configuration name="Main" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
<option name="MAIN_CLASS_NAME" value="dev.relism.Main" /> <option name="MAIN_CLASS_NAME" value="dev.relism.Main" />
<module name="flash" /> <module name="flash" />
<extension name="coverage"> <extension name="coverage">
<pattern> <pattern>
<option name="PATTERN" value="dev.relism.*" /> <option name="PATTERN" value="dev.relism.bench.*" />
<option name="ENABLED" value="true" /> <option name="ENABLED" value="true" />
</pattern> </pattern>
</extension> </extension>
@@ -151,7 +194,7 @@
<module name="flash-bench" /> <module name="flash-bench" />
<extension name="coverage"> <extension name="coverage">
<pattern> <pattern>
<option name="PATTERN" value="dev.relism.*" /> <option name="PATTERN" value="dev.relism.bench.*" />
<option name="ENABLED" value="true" /> <option name="ENABLED" value="true" />
</pattern> </pattern>
</extension> </extension>
@@ -161,9 +204,10 @@
</configuration> </configuration>
<recent_temporary> <recent_temporary>
<list> <list>
<item itemvalue="Application.ExternalBenchmark" />
<item itemvalue="Application.dev.relism.bench.Main" /> <item itemvalue="Application.dev.relism.bench.Main" />
<item itemvalue="Application.Main" /> <item itemvalue="Application.Main" />
<item itemvalue="Application.ExternalBenchmark (1)" />
<item itemvalue="Application.ExternalBenchmark" />
</list> </list>
</recent_temporary> </recent_temporary>
</component> </component>
@@ -193,7 +237,17 @@
<workItem from="1773536904928" duration="395000" /> <workItem from="1773536904928" duration="395000" />
<workItem from="1773537302622" duration="150000" /> <workItem from="1773537302622" duration="150000" />
<workItem from="1773537489622" duration="5048000" /> <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>
<task id="LOCAL-00001" summary="Initial"> <task id="LOCAL-00001" summary="Initial">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -219,7 +273,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1773581516834</updated> <updated>1773581516834</updated>
</task> </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 /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -240,7 +302,8 @@
<MESSAGE value="Initial" /> <MESSAGE value="Initial" />
<MESSAGE value="pre-module refactor" /> <MESSAGE value="pre-module refactor" />
<MESSAGE value="refactored, pre-buffer reuse" /> <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>
<component name="XSLT-Support.FileAssociations.UIState"> <component name="XSLT-Support.FileAssociations.UIState">
<expand /> <expand />
+15 -37
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" <project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 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"> 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> </parent>
<artifactId>flash-bench</artifactId> <artifactId>flash-bench</artifactId>
<packaging>jar</packaging>
<properties> <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> </properties>
<dependencies> <dependencies>
@@ -26,39 +26,20 @@
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId> <artifactId>slf4j-simple</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
<scope>provided</scope>
</dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.asynchttpclient</groupId>
<artifactId>async-http-client</artifactId>
<version>${ahc.version}</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
<finalName>flash-bench</finalName>
<plugins> <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> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId> <artifactId>maven-shade-plugin</artifactId>
@@ -68,7 +49,12 @@
<phase>package</phase> <phase>package</phase>
<goals><goal>shade</goal></goals> <goals><goal>shade</goal></goals>
<configuration> <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> <filters>
<filter> <filter>
<artifact>*:*</artifact> <artifact>*:*</artifact>
@@ -79,18 +65,10 @@
</excludes> </excludes>
</filter> </filter>
</filters> </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> </configuration>
</execution> </execution>
</executions> </executions>
</plugin> </plugin>
-->
</plugins> </plugins>
</build> </build>
</project> </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);
}
}
+95 -60
View File
@@ -41,6 +41,8 @@ public class HttpServer {
private static final byte[] CONTENT_LENGTH = "Content-Length: ".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[] 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); private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
@@ -122,30 +124,17 @@ public class HttpServer {
return this; return this;
} }
/** public HttpServer get(String path, SimpleHandler.FunctionalHandler h) { globalRouter.get(path, h); return this; }
* Registers a route on the internal router. The handler return value drives the response: public HttpServer post(String path, SimpleHandler.FunctionalHandler h) { globalRouter.post(path, h); return this; }
* return a {@link dev.relism.models.Response} to replace it entirely, any other non-null public HttpServer put(String path, SimpleHandler.FunctionalHandler h) { globalRouter.put(path, h); return this; }
* value to set it as the body, or {@code null} to leave the response object unchanged. 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 get(String path, SimpleHandler.FunctionalHandler h) { public HttpServer options(String path, SimpleHandler.FunctionalHandler h) { globalRouter.options(path, h); return this; }
globalRouter.get(path, h); public HttpServer head(String path, SimpleHandler.FunctionalHandler h) { globalRouter.head(path, h); return this; }
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) { private void process(Socket socket) {
activeSockets.add(socket); activeSockets.add(socket);
@@ -161,7 +150,7 @@ public class HttpServer {
boolean keepAlive = isKeepAlive(request); 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); RequestHandler handler = globalRouter.route(request);
try { try {
@@ -178,29 +167,19 @@ public class HttpServer {
response.setBody(result); response.setBody(result);
} }
request.getBody(); writeResponse(out, response, keepAlive);
request.drain();
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();
if (!keepAlive) if (!keepAlive)
break; break;
} }
} catch (IOException e) { } catch (IOException e) {
if (!stopped) if (!stopped) {
if (e instanceof java.net.SocketException)
log.debug("Connection closed: {}", e.getMessage());
else
log.error("I/O error handling request", e); log.error("I/O error handling request", e);
}
} finally { } finally {
activeSockets.remove(socket); activeSockets.remove(socket);
} }
@@ -215,31 +194,87 @@ public class HttpServer {
|| request.headerEquals("Connection", "keep-alive"); || request.headerEquals("Connection", "keep-alive");
} }
private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException { /**
byte[] phrase = HttpStatus.bytesForCode(statusCode); * Writes a complete HTTP response. The fixed-body path (the common case for simple handlers
if (phrase != null) { * like /plaintext) is kept inline; streaming and chunked bodies are delegated to
out.write(phrase); * {@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 { } else {
writeInt(out, statusCode); byte[] body = response.getBody();
out.write(UNKNOWN_STATUS_SUFFIX); 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 { private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException {
if (value == 0) { byte[] phrase = HttpStatus.bytesForCode(statusCode);
out.write(DIGITS[0]); if (phrase != null) out.write(phrase);
return; else { writeLong(out, statusCode); out.write(UNKNOWN_STATUS_SUFFIX); }
} }
if (value < 0) {
out.write('-'); private static void writeLong(OutputStream out, long value) throws IOException {
value = -value; 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; }
} }
int divisor = 1;
while (value / divisor >= 10) private static void writeChunked(OutputStream out, InputStream stream) throws IOException {
divisor *= 10; byte[] buf = new byte[8192];
while (divisor > 0) { int n;
out.write(DIGITS[(value / divisor) % 10]); while ((n = stream.read(buf)) > 0) {
divisor /= 10; writeHex(out, n);
out.write(CRLF);
out.write(buf, 0, n);
out.write(CRLF);
} }
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 int port;
private String host; private String host;
@Builder.Default @Builder.Default
private int acceptorThreads = 1;
@Builder.Default
private int maxHeaderBufferSize = 64 * 1024; private int maxHeaderBufferSize = 64 * 1024;
} }
+121 -27
View File
@@ -1,47 +1,141 @@
package dev.relism; package dev.relism;
import dev.relism.api.multipart.Multipart;
import dev.relism.api.multipart.Part;
import dev.relism.http.ContentType; import dev.relism.http.ContentType;
import dev.relism.models.*; import dev.relism.http.HttpStatus;
import dev.relism.routing.Route;
import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.io.IOException; import java.io.*;
import java.nio.file.*;
import java.util.List;
@Slf4j @Slf4j
public class Main { public class Main {
private static final int PORT = 8080; private static final int PORT = 8080;
private static final Path UPLOAD_DIR = Path.of(System.getProperty("java.io.tmpdir"), "flash-uploads");
/*
@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";
}
}
*/
public static void main(String[] args) throws IOException { public static void main(String[] args) throws IOException {
HttpServerConfiguration config = HttpServerConfiguration.builder() Files.createDirectories(UPLOAD_DIR);
.port(PORT)
.host("localhost")
.build();
HttpServer server = new HttpServer(config); HttpServer server = new HttpServer(
HttpServerConfiguration.builder().port(PORT).build());
FastPathRouterImpl apiRouter = new FastPathRouterImpl(); server.get("/plaintext", (req, res) -> {
server.mount("/api", apiRouter); res.type(ContentType.TEXT_PLAIN);
return "Hello, World!";
// apiRouter.register(new ProfileHandler());
server.get("/headers", (req, res) -> {
throw new RuntimeException(req.getQueryParam("test"));
}); });
// ── 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; package dev.relism;
import dev.relism.models.HeaderMap;
import dev.relism.http.HttpMethod; import dev.relism.http.HttpMethod;
import dev.relism.models.HeaderMap;
import dev.relism.models.Request; import dev.relism.models.Request;
import dev.relism.models.RequestLine; import dev.relism.models.RequestLine;
import dev.relism.routing.routers.fastpathrouter.FastPathViews; import dev.relism.routing.routers.fastpathrouter.FastPathViews;
@@ -13,7 +13,7 @@ import java.io.InputStream;
import java.util.Arrays; 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 * 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. * 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 static final int INITIAL_BUFFER_SIZE = 8192;
private final int maxHeaderBufferSize; private final int maxHeaderBufferSize;
private final HeaderMap headerMap = new HeaderMap();
private byte[] buffer; 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() { this(64 * 1024); }
public RequestParser(int maxHeaderBufferSize) { public RequestParser(int maxHeaderBufferSize) {
@@ -31,30 +34,42 @@ public class RequestParser {
} }
public Request parse(InputStream in) throws IOException { public Request parse(InputStream in) throws IOException {
int totalRead = 0; // Take ownership of any leftover bytes from the previous request, then reset so
int headerEndIdx = -1; // early-returns leave the fields in a clean state.
while (true) { int base = bufBase;
if (totalRead == buffer.length) { int totalRead = bufLen;
if (buffer.length >= maxHeaderBufferSize) 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"); throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
} else {
buffer = Arrays.copyOf(buffer, Math.min(buffer.length * 2, maxHeaderBufferSize)); 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; if (n <= 0) break;
int prevTotal = totalRead; int prevTotal = totalRead;
totalRead += n; totalRead += n;
headerEndIdx = findEndOfHeader(buffer, Math.max(0, prevTotal - 3), totalRead); headerEndIdx = findEndOfHeader(buffer, base + Math.max(0, prevTotal - 3), base + totalRead);
if (headerEndIdx != -1) break;
} }
if (totalRead <= 0) return null; if (totalRead <= 0) return null;
if (headerEndIdx == -1) { if (headerEndIdx == -1) {
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes"); 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)"); 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"); if (method == null) throw new IOException("Unsupported HTTP method");
int pathStart = methodEnd + 1; int pathStart = methodEnd + 1;
@@ -74,9 +89,10 @@ public class RequestParser {
FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart); FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
HeaderMap headerMap = new HeaderMap(buffer); int sectionStart = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
int current = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1; int current = sectionStart;
int contentLength = 0; long contentLength = 0;
boolean isChunked = false;
while (current < headerEndIdx) { while (current < headerEndIdx) {
int lineEnd = find(buffer, current, headerEndIdx + 1, (byte) '\r'); int lineEnd = find(buffer, current, headerEndIdx + 1, (byte) '\r');
@@ -87,20 +103,39 @@ public class RequestParser {
int valueStart = colon + 1; int valueStart = colon + 1;
while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++; while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++;
headerMap.add(current, colon - current, valueStart, lineEnd - valueStart);
if (equalsIgnoreCase(buffer, current, colon, "content-length")) { 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; current = lineEnd + 2;
} }
headerMap.reset(buffer, sectionStart, headerEndIdx);
int bodyStart = headerEndIdx + 4; int bodyStart = headerEndIdx + 4;
int preBufLen = totalRead - bodyStart; int preBufLen = (base + totalRead) - bodyStart;
return Request.forParsed(
new RequestLine(method, pathView, queryView, protocolView, headerMap), // Any bytes read beyond this request's body belong to the next request.
in, contentLength, buffer, bodyStart, preBufLen); // 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) { private static int findEndOfHeader(byte[] buf, int from, int len) {
@@ -130,8 +165,8 @@ public class RequestParser {
return true; return true;
} }
private static int parseInt(byte[] buf, int start, int end) { private static long parseLong(byte[] buf, int start, int end) {
int value = 0; long value = 0;
for (int i = start; i < end; i++) { for (int i = start; i < end; i++) {
byte c = buf[i]; byte c = buf[i];
if (c >= '0' && c <= '9') value = value * 10 + (c - '0'); 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. * 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 @Getter
public enum ContentType { public enum ContentType {
@@ -66,7 +66,14 @@ public enum HttpStatus {
this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8); 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. * Access is O(1) and generates zero garbage.
*/ */
public static byte[] bytesForCode(int code) { public static byte[] bytesForCode(int code) {
@@ -1,65 +1,78 @@
package dev.relism.models; package dev.relism.models;
import dev.relism.fpr.core.ByteView; import dev.relism.fpr.core.ByteView;
import lombok.NoArgsConstructor;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/** /**
* Zero-allocation header storage backed by raw byte offsets into the shared request buffer. * Lazy header access backed by a raw byte slice of the request buffer.
* Offsets are recorded at parse time; Strings are allocated only on {@link #getFirst} / {@link #getAll}. * 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 { public class HeaderMap {
private static final int MAX_HEADERS = 32; private byte[] buffer;
private int sectionStart;
private int sectionEnd;
private final byte[] buffer; /** Resets this map to the header section {@code buffer[sectionStart, sectionEnd)}. */
private final int[] keys = new int[MAX_HEADERS * 2]; // interleaved [start, len] pairs public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
private final int[] values = new int[MAX_HEADERS * 2]; // interleaved [start, len] pairs
private int count = 0;
public HeaderMap(byte[] buffer) {
this.buffer = buffer; this.buffer = buffer;
this.sectionStart = sectionStart;
this.sectionEnd = sectionEnd;
} }
public void add(int keyStart, int keyLen, int valStart, int valLen) { /** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */
if (count >= MAX_HEADERS) throw new IllegalStateException("Too many headers: limit is " + MAX_HEADERS); public String first(String name) {
keys[count * 2] = keyStart; long r = findFirst(name);
keys[count * 2 + 1] = keyLen; if (r < 0) return null;
values[count * 2] = valStart; int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
values[count * 2 + 1] = valLen; return new String(buffer, s, l, StandardCharsets.UTF_8);
count++;
} }
public String getFirst(String name) { /** Returns all values of header {@code name} in declaration order, or an empty list. */
int idx = indexOf(name); public List<String> all(String name) {
if (idx < 0) return null; if (buffer == null) return List.of();
return new String(buffer, values[idx * 2], values[idx * 2 + 1], StandardCharsets.UTF_8);
}
public List<String> getAll(String name) {
List<String> result = null; List<String> result = null;
for (int i = 0; i < count; i++) { int i = sectionStart;
if (keyMatches(i, name)) { 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<>(); 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(); 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<>(); List<String> result = new ArrayList<>();
for (int i = 0; i < count; i++) { int i = sectionStart;
result.add(new String(buffer, values[i * 2], values[i * 2 + 1], StandardCharsets.UTF_8)); 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; return result;
} }
public boolean headerValueEqualsIgnoreCase(String name, String value) { /** Case-insensitive comparison of the first value of {@code name} against {@code value}. */
int idx = indexOf(name); public boolean valueEqualsIgnoreCase(String name, String value) {
if (idx < 0) return false; long r = findFirst(name);
int vs = values[idx * 2], vl = values[idx * 2 + 1]; if (r < 0) return false;
int vs = (int) (r >> 32), vl = (int) (r & 0xFFFFFFFFL);
if (vl != value.length()) return false; if (vl != value.length()) return false;
for (int i = 0; i < vl; i++) { for (int i = 0; i < vl; i++) {
byte b = buffer[vs + i]; byte b = buffer[vs + i];
@@ -71,31 +84,54 @@ public class HeaderMap {
return true; return true;
} }
public ByteView getView(String name) { /** Returns a zero-copy {@link ByteView} over the first value of {@code name}, or {@code null}. */
int idx = indexOf(name); public ByteView view(String name) {
if (idx < 0) return null; long r = findFirst(name);
final int start = values[idx * 2]; if (r < 0) return null;
final int len = values[idx * 2 + 1]; final int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
return new ByteView() { return new ByteView() {
public int length() { return len; } public int length() { return l; }
public byte byteAt(int i) { return buffer[start + i]; } public byte byteAt(int i) { return buffer[s + i]; }
}; };
} }
private int indexOf(String name) { /** Returns {@code (valStart << 32) | valLen}, or {@code -1} if not found. */
for (int i = 0; i < count; i++) { private long findFirst(String name) {
if (keyMatches(i, name)) return i; 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; return -1;
} }
private boolean keyMatches(int i, String name) { private int skipSpaces(int from, int end) {
int ks = keys[i * 2], kl = keys[i * 2 + 1]; while (from < end && buffer[from] == ' ') from++;
if (kl != name.length()) return false; return from;
for (int j = 0; j < kl; j++) { }
byte b = buffer[ks + j];
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; 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 (c >= 'A' && c <= 'Z') c += 32;
if (b != (byte) c) return false; 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} * 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 class QueryParams {
public static final QueryParams EMPTY = new QueryParams(null); public static final QueryParams EMPTY = new QueryParams(null);
@@ -1,6 +1,7 @@
package dev.relism.models; package dev.relism.models;
import dev.relism.fpr.core.ByteView; import dev.relism.fpr.core.ByteView;
import dev.relism.http.HttpMethod;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@@ -9,58 +10,139 @@ import lombok.Value;
import lombok.experimental.NonFinal; import lombok.experimental.NonFinal;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List; 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 @Value
@ToString @ToString
public class Request { public class Request {
RequestLine requestLine;
@Getter(lombok.AccessLevel.NONE) @Getter(lombok.AccessLevel.NONE)
@EqualsAndHashCode.Exclude @EqualsAndHashCode.Exclude
@ToString.Exclude @ToString.Exclude
LazyBody lazyBody; RequestBody body;
@NonFinal @Setter @Getter PathParams pathParams; /** Internal: the parsed request line (method, path, query, protocol, headers). */
@NonFinal @Setter @Getter QueryParams queryParams; 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.requestLine = requestLine;
this.lazyBody = lazyBody; this.body = body;
this.pathParams = null; this.pathParams = null;
this.queryParams = null; this.queryParams = null;
} }
public Request(RequestLine requestLine, byte[] body) { public Request(RequestLine requestLine, byte[] body) {
this(requestLine, LazyBody.of(body)); this(requestLine, RequestBody.of(body));
} }
public static Request forParsed(RequestLine requestLine, InputStream stream, public static Request forParsed(RequestLine requestLine, InputStream stream,
int contentLength, byte[] headerBuf, long contentLength, byte[] headerBuf,
int bodyStart, int preBufLen) { int bodyStart, int preBufLen) {
LazyBody lazy = contentLength > 0 RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
? new LazyBody(stream, contentLength, headerBuf, bodyStart, preBufLen) : contentLength == 0 ? RequestBody.empty()
: LazyBody.empty(); : /* chunked */ new RequestBody(stream, -1L, null, 0, 0);
return new Request(requestLine, lazy); return new Request(requestLine, rb);
} }
public byte[] getBody() { return lazyBody.get(); } // ── Request line ──────────────────────────────────────────────────────────
public String getHeader(String name) { return requestLine.getHeaders().getFirst(name); } /** HTTP method ({@code GET}, {@code POST}, …). */
public List<String> getHeaders(String name) { return requestLine.getHeaders().getAll(name); } public HttpMethod method() { return requestLine.getMethod(); }
public List<String> getHeaders() { return requestLine.getHeaders().getAll(); }
public boolean headerEquals(String name, String value) { return requestLine.getHeaders().headerValueEqualsIgnoreCase(name, value); }
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) { // ── Headers ───────────────────────────────────────────────────────────────
return resolveQueryParams().get(name);
}
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() { 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 * 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. * 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; package dev.relism.models;
import dev.relism.http.ContentType; import dev.relism.http.ContentType;
import dev.relism.http.HttpStatus;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import lombok.ToString; import lombok.ToString;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets; 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 @Getter
@ToString @ToString
public class Response { public class Response {
@Setter private int statusCode; @Setter private int statusCode;
private byte[] statusBytes; // pre-encoded "200 OK"; null when set via status(int)
private byte[] body; private byte[] body;
@ToString.Exclude
private InputStream stream;
private long streamLength; // meaningful only when isStreaming() && !chunked
private boolean chunked;
private byte[] contentType; 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) { public Response(int statusCode, byte[] body, ContentType contentType) {
this.statusCode = statusCode; this(statusCode, contentType);
this.body = body; this.body = body;
this.contentType = contentType.getBytes();
} }
public Response(int statusCode, String text, ContentType contentType) { public Response(int statusCode, String text, ContentType contentType) {
this.statusCode = statusCode; this(statusCode, text.getBytes(StandardCharsets.UTF_8), contentType);
this.body = text.getBytes(StandardCharsets.UTF_8);
this.contentType = contentType.getBytes();
} }
public void setContentType(ContentType contentType) { // -------------------------------------------------------------------------
this.contentType = contentType.getBytes(); // Fluent mutators
} // -------------------------------------------------------------------------
public void setContentType(String contentType) { /** Sets the status code. The phrase is looked up from {@link HttpStatus} on the write path. */
this.contentType = contentType.getBytes(StandardCharsets.UTF_8); public Response status(int code) { this.statusCode = code; this.statusBytes = null; return this; }
}
public Response setBody(Object body) { /** Sets the status from an {@link HttpStatus} constant. The pre-encoded bytes are used
if (body instanceof byte[] bytes) { * 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.body = bytes;
} else if (body != null) { this.stream = null;
this.body = body.toString().getBytes(StandardCharsets.UTF_8);
}
return this; 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; private final FunctionalHandler delegate;
@Override @Override
public Object handle(Request request, Response response) { public Object handle(Request request, Response response) throws Exception {
return delegate.handle(request, response); return delegate.handle(request, response);
} }
@@ -21,6 +21,6 @@ public class SimpleHandler extends RequestHandler {
*/ */
@FunctionalInterface @FunctionalInterface
public interface FunctionalHandler { 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; import java.nio.charset.StandardCharsets;
/** /**
* Base router. Each router has a namespace prefix (default {@code "/"}); routes added via * Base router. Each router has a namespace prefix (default {@code "/"});
* {@link #get}, {@link #post}, etc. are relative to it. Error handlers are scoped to this router. * Error handlers are scoped to this router.
* The namespace is set automatically by {@link GlobalRouter#mount}. * The namespace is set automatically by {@link GlobalRouter#mount}.
*/ */
public abstract class AbstractRouter { 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 * 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. * 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) { public AbstractRouter register(HttpMethod method, String path, SimpleHandler.FunctionalHandler handler) {
return addRoute(HttpMethod.GET, PathUtils.sanitize(path), new SimpleHandler(handler)); return addRoute(method, PathUtils.sanitize(path), new SimpleHandler(handler));
} }
public AbstractRouter post(String path, SimpleHandler.FunctionalHandler handler) { public AbstractRouter get(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.GET, path, h); }
return addRoute(HttpMethod.POST, PathUtils.sanitize(path), new SimpleHandler(handler)); 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 put(String path, SimpleHandler.FunctionalHandler handler) { public AbstractRouter patch(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.PATCH, path, h); }
return addRoute(HttpMethod.PUT, PathUtils.sanitize(path), new SimpleHandler(handler)); 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 delete(String path, SimpleHandler.FunctionalHandler handler) { public AbstractRouter connect(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.CONNECT, path, h); }
return addRoute(HttpMethod.DELETE, PathUtils.sanitize(path), new SimpleHandler(handler)); 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}. */ /** Registers a class-based handler; the class must be annotated with {@link Route @Route}. */
public AbstractRouter register(RequestHandler handler) { 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 { public static final class MethodPathByteView implements ByteView {
private byte[] method; private byte[] method;
private ByteView path; private ByteView path;
@@ -4,7 +4,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>404 — Flash</title> <title>404</title>
<style> <style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box }
@@ -4,7 +4,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>500 — Flash</title> <title>500</title>
<style> <style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box }
@@ -40,7 +40,7 @@ class HttpServerConcurrencyTest {
server = new HttpServer(config); server = new HttpServer(config);
server.get("/ping", (req, res) -> "pong"); server.get("/ping", (req, res) -> "pong");
server.post("/echo", (req, res) -> { server.post("/echo", (req, res) -> {
byte[] body = req.getBody(); byte[] body = req.body().bytes();
return new Response(200, body, ContentType.TEXT_PLAIN); 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.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
@@ -39,14 +40,29 @@ class HttpServerTest {
server.get("/api/ping", (req, res) -> "pong"); server.get("/api/ping", (req, res) -> "pong");
server.post("/api/echo", (req, res) -> { server.post("/api/echo", (req, res) -> {
byte[] body = req.getBody(); byte[] body = req.body().bytes();
return new Response(201, body, ContentType.TEXT_PLAIN); // echo body & change status return res.status(201).body(body); // echo body & change status
}); });
server.get("/api/crash", (req, res) -> { server.get("/api/crash", (req, res) -> {
throw new RuntimeException("Simulated Crash"); 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); server.start().get(5, TimeUnit.SECONDS);
} }
@@ -57,17 +73,22 @@ class HttpServerTest {
} }
} }
// --- raw socket helper --- // --- helpers ---
private static final int SOCKET_TIMEOUT_MS = 5000;
private String sendRawRequest(String rawHttp) throws Exception { private String sendRawRequest(String rawHttp) throws Exception {
try (Socket socket = new Socket("127.0.0.1", port); try (Socket socket = new Socket("127.0.0.1", port);
OutputStream out = socket.getOutputStream(); OutputStream out = socket.getOutputStream();
InputStream in = socket.getInputStream()) { InputStream in = socket.getInputStream()) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
out.write(rawHttp.getBytes(StandardCharsets.UTF_8)); out.write(rawHttp.getBytes(StandardCharsets.UTF_8));
out.flush(); out.flush();
return readOneResponse(in);
}
}
// Read until \r\n\r\n to get the full header block private String readOneResponse(InputStream in) throws Exception {
ByteArrayOutputStream headerBuf = new ByteArrayOutputStream(); ByteArrayOutputStream headerBuf = new ByteArrayOutputStream();
int b, prev3 = -1, prev2 = -1, prev1 = -1; int b, prev3 = -1, prev2 = -1, prev1 = -1;
while ((b = in.read()) != -1) { while ((b = in.read()) != -1) {
@@ -77,7 +98,10 @@ class HttpServerTest {
} }
String headers = headerBuf.toString(StandardCharsets.UTF_8); 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; int contentLength = 0;
for (String line : headers.split("\r\n")) { for (String line : headers.split("\r\n")) {
if (line.toLowerCase().startsWith("content-length:")) { if (line.toLowerCase().startsWith("content-length:")) {
@@ -85,11 +109,25 @@ class HttpServerTest {
break; break;
} }
} }
body = new String(in.readNBytes(contentLength), StandardCharsets.UTF_8);
// Read exactly Content-Length bytes for the body
byte[] body = in.readNBytes(contentLength);
return headers + new String(body, 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 --- // --- E2E Tests ---
@@ -160,4 +198,78 @@ class HttpServerTest {
assertTrue(res.startsWith("HTTP/1.1 404 Not Found")); assertTrue(res.startsWith("HTTP/1.1 404 Not Found"));
assertTrue(res.contains("No route matched this request.")); 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 @Test
void queryParam_resolvedFromPath() throws IOException { void queryParam_resolvedFromPath() throws IOException {
Request r = parse(req("GET /search?q=flash&page=2 HTTP/1.1", "Host: localhost")); Request r = parse(req("GET /search?q=flash&page=2 HTTP/1.1", "Host: localhost"));
assertEquals("flash", r.getQueryParam("q")); assertEquals("flash", r.query("q"));
assertEquals("2", r.getQueryParam("page")); assertEquals("2", r.query("page"));
} }
// --- headers --- // --- headers ---
@@ -53,15 +53,15 @@ class RequestParserTest {
@Test @Test
void headers_parsed() throws IOException { void headers_parsed() throws IOException {
Request r = parse(req("GET / HTTP/1.1", "Host: example.com", "Accept: application/json")); Request r = parse(req("GET / HTTP/1.1", "Host: example.com", "Accept: application/json"));
assertEquals("example.com", r.getHeader("Host")); assertEquals("example.com", r.header("Host"));
assertEquals("application/json", r.getHeader("Accept")); assertEquals("application/json", r.header("Accept"));
} }
@Test @Test
void headers_caseInsensitive() throws IOException { void headers_caseInsensitive() throws IOException {
Request r = parse(req("GET / HTTP/1.1", "Content-Type: text/plain")); Request r = parse(req("GET / HTTP/1.1", "Content-Type: text/plain"));
assertEquals("text/plain", r.getHeader("content-type")); assertEquals("text/plain", r.header("content-type"));
assertEquals("text/plain", r.getHeader("CONTENT-TYPE")); assertEquals("text/plain", r.header("CONTENT-TYPE"));
} }
// --- body --- // --- body ---
@@ -72,13 +72,13 @@ class RequestParserTest {
String raw = "POST / HTTP/1.1\r\nContent-Length: " + body.length() + "\r\n\r\n" + body; 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))); Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
assertNotNull(r); assertNotNull(r);
assertEquals(body, new String(r.getBody(), StandardCharsets.UTF_8)); assertEquals(body, new String(r.body().bytes(), StandardCharsets.UTF_8));
} }
@Test @Test
void body_emptyWhenNoContentLength() throws IOException { void body_emptyWhenNoContentLength() throws IOException {
Request r = parse(req("GET / HTTP/1.1", "Host: localhost")); Request r = parse(req("GET / HTTP/1.1", "Host: localhost"));
assertEquals(0, r.getBody().length); assertTrue(r.body().isEmpty());
} }
// --- edge cases / robustness --- // --- edge cases / robustness ---
@@ -102,19 +102,45 @@ class RequestParserTest {
@Test @Test
void requestLine_noProtocol_throwsIOException() { 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"))); assertThrows(IOException.class, () -> parse(req("GET /noproto")));
} }
@Test @Test
void headers_exceedingMaxBufferSize_throwsIOException() { 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; int cap = 16 * 1024;
byte[] giant = new byte[cap + 1]; byte[] giant = new byte[cap + 1];
Arrays.fill(giant, (byte) 'A'); Arrays.fill(giant, (byte) 'A');
assertThrows(IOException.class, () -> new RequestParser(cap).parse(new ByteArrayInputStream(giant))); 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 @Test
void contentLength_largerThanBody_readsPartial() throws IOException { void contentLength_largerThanBody_readsPartial() throws IOException {
// Content-Length claims 50 but stream ends after 5 bytes // 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; 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))); Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8)));
assertNotNull(r); assertNotNull(r);
assertEquals(50, r.getBody().length); assertEquals(50, r.body().bytes().length);
assertEquals(body, new String(r.getBody(), 0, body.length(), StandardCharsets.UTF_8)); assertEquals(body, new String(r.body().bytes(), 0, body.length(), StandardCharsets.UTF_8));
} }
} }
@@ -12,73 +12,63 @@ class HeaderMapTest {
// --- helpers --- // --- helpers ---
private static HeaderMap parse(String raw, String... headers) { private static HeaderMap parse(String... headers) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (String h : headers) sb.append(h).append("\r\n"); for (String h : headers) sb.append(h).append("\r\n");
byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8); byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8);
HeaderMap map = new HeaderMap(buffer); HeaderMap map = new HeaderMap();
map.reset(buffer, 0, buffer.length);
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;
}
return map; return map;
} }
// --- getFirst --- // --- first ---
@Test @Test
void getFirst_existingHeader() { void first_existingHeader() {
HeaderMap map = parse("", "Host: localhost", "Accept: text/plain"); HeaderMap map = parse("Host: localhost", "Accept: text/plain");
assertEquals("localhost", map.getFirst("Host")); assertEquals("localhost", map.first("Host"));
assertEquals("text/plain", map.getFirst("Accept")); assertEquals("text/plain", map.first("Accept"));
} }
@Test @Test
void getFirst_caseInsensitive() { void first_caseInsensitive() {
HeaderMap map = parse("", "ConteNT-tYPe: application/json"); HeaderMap map = parse("ConteNT-tYPe: application/json");
assertEquals("application/json", map.getFirst("content-type")); assertEquals("application/json", map.first("content-type"));
assertEquals("application/json", map.getFirst("CONTENT-TYPE")); assertEquals("application/json", map.first("CONTENT-TYPE"));
} }
@Test @Test
void getFirst_missingHeader_returnsNull() { void first_missingHeader_returnsNull() {
HeaderMap map = parse("", "Host: localhost"); HeaderMap map = parse("Host: localhost");
assertNull(map.getFirst("Accept")); assertNull(map.first("Accept"));
} }
// --- getAll --- // --- all ---
@Test @Test
void getAll_multipleValuesByName() { void all_multipleValuesByName() {
HeaderMap map = parse("", "Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2"); HeaderMap map = parse("Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2");
assertEquals(List.of("a=1", "b=2"), map.getAll("Cookie")); assertEquals(List.of("a=1", "b=2"), map.all("Cookie"));
} }
@Test @Test
void getAll_missingHeader_returnsEmptyList() { void all_missingHeader_returnsEmptyList() {
HeaderMap map = parse("", "Host: localhost"); HeaderMap map = parse("Host: localhost");
assertTrue(map.getAll("Cookie").isEmpty()); assertTrue(map.all("Cookie").isEmpty());
} }
@Test @Test
void getAll_returnsAllHeaders() { void all_returnsAllHeaders() {
HeaderMap map = parse("", "A: 1", "B: 2"); HeaderMap map = parse("A: 1", "B: 2");
assertEquals(List.of("1", "2"), map.getAll()); assertEquals(List.of("1", "2"), map.all());
} }
// --- getView --- // --- view ---
@Test @Test
void getView_returnsZeroCopyView() { void view_returnsZeroCopyView() {
HeaderMap map = parse("", "Host: localhost"); HeaderMap map = parse("Host: localhost");
ByteView view = map.getView("Host"); ByteView view = map.view("Host");
assertNotNull(view); assertNotNull(view);
assertEquals(9, view.length()); assertEquals(9, view.length());
assertEquals('l', view.byteAt(0)); assertEquals('l', view.byteAt(0));
@@ -86,20 +76,18 @@ class HeaderMapTest {
} }
@Test @Test
void getView_missingHeader_returnsNull() { void view_missingHeader_returnsNull() {
HeaderMap map = parse("", "Host: localhost"); HeaderMap map = parse("Host: localhost");
assertNull(map.getView("Accept")); assertNull(map.view("Accept"));
} }
// --- limit --- // --- empty ---
@Test @Test
void maxHeadersLimit() { void emptyMap_returnsNullAndEmptyList() {
byte[] buffer = new byte[100]; HeaderMap map = new HeaderMap();
HeaderMap map = new HeaderMap(buffer); assertNull(map.first("Host"));
for (int i = 0; i < 32; i++) { assertTrue(map.all("Host").isEmpty());
map.add(0, 1, 1, 1); assertTrue(map.all().isEmpty());
}
assertThrows(IllegalStateException.class, () -> map.add(0, 1, 1, 1));
} }
} }
@@ -28,7 +28,7 @@ class RequestLineTest {
ByteView path = viewOf("/api"); ByteView path = viewOf("/api");
ByteView query = viewOf("q=1"); ByteView query = viewOf("q=1");
ByteView proto = viewOf("HTTP/1.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); RequestLine rl = new RequestLine(HttpMethod.GET, path, query, proto, headers);
@@ -25,64 +25,65 @@ class RequestTest {
// --- creation --- // --- creation ---
@Test @Test
void request_creationAndGetters() { void request_creationAndAccessors() {
HeaderMap headers = new HeaderMap(new byte[0]); HeaderMap headers = new HeaderMap();
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/path"), viewOf("q=1"), viewOf("HTTP/1.1"), headers); RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/path"), viewOf("q=1"), viewOf("HTTP/1.1"), headers);
byte[] body = "body".getBytes(StandardCharsets.UTF_8); byte[] body = "body".getBytes(StandardCharsets.UTF_8);
Request r = new Request(line, body); Request r = new Request(line, body);
assertEquals(line, r.getRequestLine()); assertEquals(HttpMethod.GET, r.method());
assertArrayEquals(body, r.getBody()); assertEquals("/path", r.path());
assertArrayEquals(body, r.body().bytes());
assertNotNull(r.toString()); assertNotNull(r.toString());
} }
// --- delegates --- // --- headers ---
@Test @Test
void headers_delegatesToRequestLine() { void header_delegatesToRequestLine() {
byte[] buffer = "Host: localhost\r\n".getBytes(StandardCharsets.UTF_8); byte[] buffer = "Host: localhost\r\n".getBytes(StandardCharsets.UTF_8);
HeaderMap headers = new HeaderMap(buffer); HeaderMap headers = new HeaderMap();
headers.add(0, 4, 6, 9); headers.reset(buffer, 0, buffer.length);
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), headers); RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), headers);
Request r = new Request(line, new byte[0]); Request r = new Request(line, new byte[0]);
assertEquals("localhost", r.getHeader("Host")); assertEquals("localhost", r.header("Host"));
assertEquals(List.of("localhost"), r.getHeaders("Host")); assertEquals(List.of("localhost"), r.headers("Host"));
assertEquals(List.of("localhost"), r.getHeaders()); assertEquals(List.of("localhost"), r.headers());
} }
// --- pathParams --- // --- path params ---
@Test @Test
void pathParam_lazyGet() { void param_lazyGet() {
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap(new byte[0])); RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap());
Request r = new Request(line, new byte[0]); 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})); 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 @Test
void queryParam_lazyGet_fromQueryString() { void query_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])); 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]); Request r = new Request(line, new byte[0]);
assertEquals("1", r.getQueryParam("a")); assertEquals("1", r.query("a"));
assertEquals("2", r.getQueryParam("b")); // First value assertEquals("2", r.query("b"));
assertEquals(List.of("2", "3"), r.getQueryParams("b")); assertEquals(List.of("2", "3"), r.queries("b"));
} }
@Test @Test
void queryParam_lazyGet_nullQueryString() { void query_lazyGet_nullQueryString() {
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap(new byte[0])); RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap());
Request r = new Request(line, new byte[0]); Request r = new Request(line, new byte[0]);
assertNull(r.getQueryParam("a")); assertNull(r.query("a"));
assertTrue(r.getQueryParams("a").isEmpty()); assertTrue(r.queries("a").isEmpty());
} }
} }
@@ -3,13 +3,15 @@ package dev.relism.models;
import dev.relism.http.ContentType; import dev.relism.http.ContentType;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
class ResponseTest { class ResponseTest {
// --- creation --- // --- construction ---
@Test @Test
void constructor_withByteArray() { void constructor_withByteArray() {
@@ -31,34 +33,34 @@ class ResponseTest {
assertArrayEquals(ContentType.TEXT_PLAIN.getBytes(), r.getContentType()); assertArrayEquals(ContentType.TEXT_PLAIN.getBytes(), r.getContentType());
} }
// --- setters --- // --- fluent mutators ---
@Test @Test
void setContentType_byEnum() { void type_byEnum() {
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); 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()); assertArrayEquals(ContentType.JSON.getBytes(), r.getContentType());
} }
@Test @Test
void setContentType_byString() { void type_byString() {
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); 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()); assertArrayEquals("application/custom".getBytes(StandardCharsets.UTF_8), r.getContentType());
} }
@Test @Test
void setBody_withByteArray() { void body_withByteArray() {
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
byte[] newBody = "new".getBytes(StandardCharsets.UTF_8); byte[] newBody = "new".getBytes(StandardCharsets.UTF_8);
r.setBody(newBody); r.body(newBody);
assertArrayEquals(newBody, r.getBody()); assertArrayEquals(newBody, r.getBody());
} }
@Test @Test
void setBody_withObjectConvertedToString() { void setBody_withObjectConvertedToString() {
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); 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()); assertArrayEquals("12345".getBytes(StandardCharsets.UTF_8), r.getBody());
} }
@@ -68,4 +70,55 @@ class ResponseTest {
r.setStatusCode(201); r.setStatusCode(201);
assertEquals(201, r.getStatusCode()); 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( RequestLine line = new RequestLine(
HttpMethod.GET, pathView, null, HttpMethod.GET, pathView, null,
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8), 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]); return new Request(line, new byte[0]);
} }
@@ -23,7 +23,7 @@ class FastPathRouterImplTest {
RequestLine line = new RequestLine( RequestLine line = new RequestLine(
method, pathView, null, method, pathView, null,
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8), 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]); return new Request(line, new byte[0]);
} }
@@ -31,7 +31,7 @@ class FastPathRouterImplTest {
// --- route --- // --- route ---
@Test @Test
void route_lazyCompilationAndMatch() { void route_lazyCompilationAndMatch() throws Exception {
FastPathRouterImpl router = new FastPathRouterImpl(); FastPathRouterImpl router = new FastPathRouterImpl();
router.get("/a", (req, res) -> "A"); router.get("/a", (req, res) -> "A");
@@ -57,7 +57,7 @@ class FastPathRouterImplTest {
} }
@Test @Test
void route_extractsPathParams() { void route_extractsPathParams() throws Exception {
FastPathRouterImpl router = new FastPathRouterImpl(); FastPathRouterImpl router = new FastPathRouterImpl();
router.get("/users/{id}/items/{itemId}", (req, res) -> "Extract"); router.get("/users/{id}/items/{itemId}", (req, res) -> "Extract");
@@ -70,7 +70,7 @@ class FastPathRouterImplTest {
// Verify path params were injected // Verify path params were injected
assertNotNull(request.getPathParams()); assertNotNull(request.getPathParams());
assertEquals("123", request.getPathParam("id")); assertEquals("123", request.param("id"));
assertEquals("456", request.getPathParam("itemId")); assertEquals("456", request.param("itemId"));
} }
} }
@@ -24,7 +24,7 @@ class ErrorPagesTest {
byte[] protoBytes = protocol.getBytes(StandardCharsets.UTF_8); byte[] protoBytes = protocol.getBytes(StandardCharsets.UTF_8);
FastPathViews.RequestByteView protoView = new FastPathViews.RequestByteView(protoBytes, 0, protoBytes.length); 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]); return new Request(line, new byte[0]);
} }