diff --git a/FORMS_AND_MULTIPART.md b/FORMS_AND_MULTIPART.md new file mode 100644 index 0000000..627185e --- /dev/null +++ b/FORMS_AND_MULTIPART.md @@ -0,0 +1,249 @@ +# Forms and Multipart Support Evaluation + +## Overview + +This document evaluates the Java 11+ HTTP Client's support for HTML form submissions and multipart/form-data requests, including file uploads. + +## Key Findings + +### Forms (application/x-www-form-urlencoded) + +**The Java HTTP Client does NOT provide a specific API for form data submission.** + +Key limitations: +- No built-in `BodyPublishers.ofForm()` or similar convenience methods +- No automatic URL encoding of form parameters +- No form builder or helper API +- Developers must manually construct form data strings + +### Multipart Requests (multipart/form-data) + +**The Java HTTP Client does NOT provide a specific API for multipart requests.** + +Key limitations: +- No built-in `BodyPublishers.ofMultipart()` or similar convenience methods +- No multipart builder or helper API +- No automatic boundary generation or formatting +- No file upload convenience methods +- Developers must manually construct the entire multipart body with proper boundaries and headers + +## Detailed Analysis + +### 1. Form Data Submission + +#### What's Missing + +The Java HTTP Client provides no convenience methods for form submissions. Developers must: + +1. **Manually build the form data string** with URL encoding: +```java +String formData = "username=" + URLEncoder.encode("testuser", StandardCharsets.UTF_8) + + "&password=" + URLEncoder.encode("testpass", StandardCharsets.UTF_8); +``` + +2. **Set the correct Content-Type header**: +```java +HttpRequest request = HttpRequest.newBuilder() + .uri(uri) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(formData)) + .build(); +``` + +#### Comparison with Other HTTP Clients + +Other HTTP client libraries typically provide form convenience methods: + +- **Apache HttpClient**: `UrlEncodedFormEntity` with `NameValuePair` list +- **OkHttp**: `FormBody.Builder()` with automatic encoding +- **Spring WebClient**: `FormInserter` API for reactive form submissions +- **Retrofit**: `@FormUrlEncoded` annotation with `@Field` parameters + +#### Manual Implementation Required + +Test: `JavaHttpClientFormsTest.testManualFormDataImplementation()` + +Example helper method developers must implement: +```java +private String buildFormData(Map data) { + return data.entrySet().stream() + .map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + "=" + + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); +} +``` + +This is straightforward but requires developers to: +- Remember to URL encode all values +- Handle special characters correctly (`&`, `=`, spaces, etc.) +- Join parameters with `&` +- Set the correct Content-Type header + +### 2. Multipart/Form-Data Requests + +#### What's Missing + +Multipart requests are significantly more complex than form data, yet the Java HTTP Client provides no support: + +1. **No boundary generation** +2. **No part construction helpers** +3. **No file upload convenience methods** +4. **No content-disposition header generation** +5. **No mixed content handling (text + files)** + +#### Manual Implementation Required + +Tests: +- `JavaHttpClientMultipartTest.testManualMultipartTextFields()` +- `JavaHttpClientMultipartTest.testManualFileUpload()` +- `JavaHttpClientMultipartTest.testManualMultipartMixedContent()` + +Developers must manually construct the entire multipart body: + +```java +String boundary = "----WebKitFormBoundary" + System.currentTimeMillis(); + +// For each text field: +"--" + boundary + "\r\n" + +"Content-Disposition: form-data; name=\"fieldname\"\r\n" + +"\r\n" + +"field value\r\n" + +// For each file: +"--" + boundary + "\r\n" + +"Content-Disposition: form-data; name=\"file\"; filename=\"file.txt\"\r\n" + +"Content-Type: text/plain\r\n" + +"\r\n" + +[file bytes] + "\r\n" + +// Final boundary: +"--" + boundary + "--\r\n" +``` + +And set the Content-Type header with the boundary: +```java +.header("Content-Type", "multipart/form-data; boundary=" + boundary) +``` + +#### Complexity Factors + +1. **Boundary Selection**: Must not appear in content, typically random or timestamp-based +2. **CRLF Handling**: Must use `\r\n` (not just `\n`) for HTTP compliance +3. **Content-Disposition**: Different format for files vs. text fields +4. **Content-Type for Files**: Must determine and set correct MIME type for each file +5. **Binary Data**: Must handle file bytes correctly without corruption +6. **Final Boundary**: Must end with `--boundary--\r\n` + +#### Comparison with Other HTTP Clients + +Other HTTP client libraries provide multipart convenience APIs: + +- **Apache HttpClient**: `MultipartEntityBuilder` with `addTextBody()` and `addBinaryBody()` +- **OkHttp**: `MultipartBody.Builder()` with `addFormDataPart()` +- **Spring WebClient**: `MultipartBodyBuilder` for reactive multipart requests +- **Retrofit**: `@Multipart` annotation with `@Part` parameters + +### 3. Test Results + +All tests demonstrate that while form and multipart submissions **work** with the Java HTTP Client, they require **complete manual implementation**. + +#### Forms Tests (JavaHttpClientFormsTest) + +✅ `testNoBuiltInFormAPI()` - Confirms no built-in API exists +✅ `testManualFormDataImplementation()` - Manual form submission works +✅ `testFormDataWithSpecialCharacters()` - URL encoding required for special chars +✅ `testCompleteFormHandlingRequired()` - Demonstrates full manual process + +#### Multipart Tests (JavaHttpClientMultipartTest) + +✅ `testNoBuiltInMultipartAPI()` - Confirms no built-in API exists +✅ `testManualMultipartTextFields()` - Manual multipart with text fields works +✅ `testManualFileUpload()` - Manual file upload works +✅ `testManualMultipartMixedContent()` - Mixed content (text + files) works +✅ `testBoundaryHandling()` - Demonstrates boundary complexity + +## Impact Assessment + +### For Simple Forms +**Impact: Medium** + +- Manual implementation is straightforward for basic forms +- URL encoding is standard Java functionality +- Most developers can implement this correctly +- Main risk: forgetting to URL encode values with special characters + +### For Multipart Requests +**Impact: High** + +- Manual implementation is complex and error-prone +- Many opportunities for mistakes (CRLF, boundaries, headers) +- File uploads require careful binary data handling +- Mixed content increases complexity significantly +- Testing is essential to ensure correct formatting + +## Recommendations + +### For Application Developers + +1. **Create utility classes** for form and multipart handling if you need these features frequently +2. **Consider using a third-party library** like Apache HttpClient or OkHttp if you have extensive form/multipart needs +3. **Thoroughly test** multipart implementations - the format is strict and errors are common +4. **Use existing libraries** rather than reimplementing multipart from scratch (see Helper Libraries below) + +### For JDK Enhancement Consideration + +Consider adding convenience APIs similar to other HTTP clients: + +**For Forms:** +```java +HttpRequest.BodyPublishers.ofForm(Map formData) +``` + +**For Multipart:** +```java +MultipartBodyBuilder builder = MultipartBodyBuilder.create() + .addField("name", "value") + .addFile("file", Path.of("file.txt"), MediaType.TEXT_PLAIN) + .build(); + +HttpRequest.BodyPublishers.ofMultipart(builder) +``` + +This would: +- Reduce boilerplate code +- Prevent common implementation errors +- Improve developer experience +- Match functionality of other modern HTTP clients +- Make Java HTTP Client more suitable for web application development + +## Helper Libraries + +Instead of manual implementation, developers can use: + +1. **Apache HttpClient** - Mature library with excellent form and multipart support +2. **OkHttp** - Modern library with clean multipart API +3. **Third-party utilities** - Various open-source helpers for multipart construction + +## Test Server Implementation + +The test suite includes `NettyFormsServer` which demonstrates: +- Parsing `application/x-www-form-urlencoded` data +- Parsing `multipart/form-data` requests +- Handling file uploads +- Extracting form fields and file metadata + +This server is used to validate that manually constructed requests are correctly formatted and can be parsed by a standard HTTP server. + +## Conclusion + +The Java HTTP Client can handle form submissions and multipart requests, but requires **complete manual implementation** for both: + +- **Forms**: Moderate complexity, manageable for most developers +- **Multipart**: High complexity, error-prone, benefits greatly from helper utilities + +This lack of convenience APIs is a notable gap compared to other modern HTTP client libraries. Applications with significant form or file upload requirements should consider: +1. Using a wrapper library or utility class +2. Using a different HTTP client library (Apache HttpClient, OkHttp) +3. Waiting for potential JDK enhancements + +For occasional use, the manual implementation demonstrated in the test suite is workable, though it requires careful attention to details like URL encoding, boundary formatting, and CRLF handling. diff --git a/README.md b/README.md index 35dc6de..6792e9c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Java HTTP Client Testing -Check of Java HTTP Client Limitations with HTTP/2, GOAWAY frames, HTTP Compression, HTTP Caching, and HTTP Authentication +Check of Java HTTP Client Limitations with HTTP/2, GOAWAY frames, HTTP Compression, HTTP Caching, HTTP Authentication, Forms, and Multipart ## Overview -This project tests Java 11+ HTTP Client behavior with HTTP/2 protocol features, HTTP compression, HTTP caching, and HTTP authentication, specifically: +This project tests Java 11+ HTTP Client behavior with HTTP/2 protocol features, HTTP compression, HTTP caching, HTTP authentication, and form/multipart handling, specifically: - HTTP/2 Upgrade from HTTP/1.1 - GOAWAY frame handling - Connection management over HTTP and HTTPS @@ -12,6 +12,8 @@ This project tests Java 11+ HTTP Client behavior with HTTP/2 protocol features, - HTTP compression support (or lack thereof) - HTTP caching support (or lack thereof) - HTTP authentication schemes support (Basic, Digest, NTLM, SPNEGO/Kerberos) +- HTML form submission support (application/x-www-form-urlencoded) +- Multipart/form-data support including file uploads ## Test Scenarios @@ -112,6 +114,31 @@ The tests demonstrate that: See [GITHUB_ISSUE_SUMMARY.md](GITHUB_ISSUE_SUMMARY.md) for a concise summary suitable for submitting as a JDK enhancement request. +### 10. HTML Forms and Multipart Support + +Tests evaluating support for HTML form submissions and multipart/form-data requests. See [FORMS_AND_MULTIPART.md](FORMS_AND_MULTIPART.md) for detailed documentation. + +Tests are in `JavaHttpClientFormsTest`: +- `testNoBuiltInFormAPI()` - Verifies client does NOT provide form data API +- `testManualFormDataImplementation()` - Demonstrates manual URL-encoded form submission +- `testFormDataWithSpecialCharacters()` - Tests URL encoding requirements +- `testCompleteFormHandlingRequired()` - Shows complete manual implementation needed + +Tests are in `JavaHttpClientMultipartTest`: +- `testNoBuiltInMultipartAPI()` - Verifies client does NOT provide multipart API +- `testManualMultipartTextFields()` - Demonstrates manual multipart with text fields +- `testManualFileUpload()` - Tests file upload with multipart/form-data +- `testManualMultipartMixedContent()` - Tests mixed content (text fields + files) +- `testBoundaryHandling()` - Demonstrates boundary generation and formatting complexity + +The tests demonstrate that: +- Java HTTP Client does NOT provide convenience APIs for form data submission +- Java HTTP Client does NOT provide convenience APIs for multipart/form-data +- Applications must manually build `application/x-www-form-urlencoded` strings with URL encoding +- Applications must manually construct multipart bodies with boundaries and headers +- File uploads require manual encoding and Content-Disposition header construction +- Both forms and multipart work but require complete manual implementation + ## Known Limitations ### Custom SSLParameters Interfere with HTTP/2 @@ -178,7 +205,8 @@ mvn test -Dorg.slf4j.simpleLogger.defaultLogLevel=debug ├── src/main/java/io/github/laeubi/httpclient/ │ ├── NettyHttp2Server.java # Netty-based HTTP/2 test server with connection tracking │ ├── NettyWebSocketServer.java # Netty-based WebSocket test server -│ └── NettyAuthenticationServer.java # Netty-based authentication test server +│ ├── NettyAuthenticationServer.java # Netty-based authentication test server +│ └── NettyFormsServer.java # Netty-based forms and multipart test server ├── src/test/java/io/github/laeubi/httpclient/ │ ├── JavaHttpClientUpgradeTest.java # HTTP/2 upgrade and ALPN tests │ ├── JavaHttpClientGoawayTest.java # GOAWAY frame handling tests @@ -187,6 +215,8 @@ mvn test -Dorg.slf4j.simpleLogger.defaultLogLevel=debug │ ├── JavaHttpClientCompressionTest.java # HTTP compression tests │ ├── JavaHttpClientCachingTest.java # HTTP caching tests │ ├── JavaHttpClientAuthenticationTest.java # HTTP authentication tests +│ ├── JavaHttpClientFormsTest.java # HTML forms tests +│ ├── JavaHttpClientMultipartTest.java # Multipart/form-data tests │ └── JavaHttpClientBase.java # Base test class with utilities ├── src/main/resources/ │ └── simplelogger.properties # Logging configuration @@ -195,6 +225,7 @@ mvn test -Dorg.slf4j.simpleLogger.defaultLogLevel=debug ├── HTTP_COMPRESSION.md # HTTP compression documentation ├── HTTP_CACHING.md # HTTP caching documentation ├── HTTP_AUTHENTICATION.md # HTTP authentication documentation +├── FORMS_AND_MULTIPART.md # Forms and multipart/form-data documentation ├── GITHUB_ISSUE_SUMMARY.md # JDK enhancement request summary └── pom.xml # Maven project configuration ``` diff --git a/src/main/java/io/github/laeubi/httpclient/NettyFormsServer.java b/src/main/java/io/github/laeubi/httpclient/NettyFormsServer.java new file mode 100644 index 0000000..9754b6d --- /dev/null +++ b/src/main/java/io/github/laeubi/httpclient/NettyFormsServer.java @@ -0,0 +1,225 @@ +package io.github.laeubi.httpclient; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.QueryStringDecoder; +import io.netty.handler.codec.http.multipart.Attribute; +import io.netty.handler.codec.http.multipart.FileUpload; +import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder; +import io.netty.handler.codec.http.multipart.InterfaceHttpData; +import io.netty.handler.logging.LogLevel; +import io.netty.handler.logging.LoggingHandler; + +/** + * Netty-based HTTP server for testing form and multipart request handling. + */ +public class NettyFormsServer { + + private static final Logger logger = LoggerFactory.getLogger(NettyFormsServer.class); + + private final int port; + private Channel channel; + private EventLoopGroup bossGroup; + private EventLoopGroup workerGroup; + + public NettyFormsServer(int port) throws Exception { + this.port = port; + logger.info("Starting Netty Forms server on port {}", port); + + bossGroup = new NioEventLoopGroup(1); + workerGroup = new NioEventLoopGroup(); + + ServerBootstrap b = new ServerBootstrap(); + b.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .handler(new LoggingHandler(LogLevel.INFO)) + .childHandler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + ch.pipeline().addLast(new HttpServerCodec()); + ch.pipeline().addLast(new HttpObjectAggregator(1048576)); // 1MB max + ch.pipeline().addLast(new FormsHandler()); + } + }); + + channel = b.bind(port).sync().channel(); + logger.info("Forms server started successfully on port {}", port); + } + + public void stop() { + logger.info("Stopping forms server on port {}", port); + if (channel != null) { + channel.close(); + } + if (workerGroup != null) { + workerGroup.shutdownGracefully(); + } + if (bossGroup != null) { + bossGroup.shutdownGracefully(); + } + } + + public int getPort() { + return port; + } + + /** + * Handler for form and multipart requests + */ + private static class FormsHandler extends SimpleChannelInboundHandler { + private static final Logger logger = LoggerFactory.getLogger(FormsHandler.class); + + @Override + protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) { + logger.info("Received {} request to {}", req.method(), req.uri()); + logger.info("Content-Type: {}", req.headers().get(HttpHeaderNames.CONTENT_TYPE)); + logger.info("Content-Length: {}", req.headers().get(HttpHeaderNames.CONTENT_LENGTH)); + + try { + String path = new QueryStringDecoder(req.uri()).path(); + String responseBody; + + if (HttpMethod.POST.equals(req.method())) { + String contentType = req.headers().get(HttpHeaderNames.CONTENT_TYPE); + + if (contentType != null && contentType.startsWith("application/x-www-form-urlencoded")) { + responseBody = handleFormUrlEncoded(req); + } else if (contentType != null && contentType.startsWith("multipart/form-data")) { + responseBody = handleMultipart(req); + } else { + responseBody = "Unsupported Content-Type: " + contentType; + } + } else { + responseBody = "Method not supported: " + req.method(); + } + + sendResponse(ctx, HttpResponseStatus.OK, responseBody); + } catch (Exception e) { + logger.error("Error processing request", e); + sendResponse(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR, + "Error: " + e.getMessage()); + } + } + + private String handleFormUrlEncoded(FullHttpRequest req) { + logger.info("Processing application/x-www-form-urlencoded data"); + + String content = req.content().toString(StandardCharsets.UTF_8); + logger.info("Raw form data: {}", content); + + Map formData = new HashMap<>(); + QueryStringDecoder decoder = new QueryStringDecoder("?" + content); + decoder.parameters().forEach((key, values) -> { + if (!values.isEmpty()) { + formData.put(key, values.get(0)); + logger.info("Form field: {} = {}", key, values.get(0)); + } + }); + + StringBuilder response = new StringBuilder(); + response.append("Form data received:\n"); + formData.forEach((key, value) -> + response.append(key).append("=").append(value).append("\n")); + + return response.toString(); + } + + private String handleMultipart(FullHttpRequest req) { + logger.info("Processing multipart/form-data"); + + HttpPostRequestDecoder decoder = null; + try { + decoder = new HttpPostRequestDecoder(req); + + StringBuilder response = new StringBuilder(); + response.append("Multipart data received:\n"); + + for (InterfaceHttpData data : decoder.getBodyHttpDatas()) { + logger.info("Part type: {}, name: {}", data.getHttpDataType(), data.getName()); + + if (data.getHttpDataType() == InterfaceHttpData.HttpDataType.Attribute) { + Attribute attribute = (Attribute) data; + String value = attribute.getValue(); + logger.info("Field: {} = {}", attribute.getName(), value); + response.append("Field: ").append(attribute.getName()) + .append(" = ").append(value).append("\n"); + } else if (data.getHttpDataType() == InterfaceHttpData.HttpDataType.FileUpload) { + FileUpload fileUpload = (FileUpload) data; + logger.info("File: {} ({}), size: {} bytes", + fileUpload.getName(), + fileUpload.getFilename(), + fileUpload.length()); + response.append("File: ").append(fileUpload.getName()) + .append(" (").append(fileUpload.getFilename()) + .append("), size: ").append(fileUpload.length()).append(" bytes\n"); + + // Log first 100 bytes of file content for debugging + if (fileUpload.length() > 0 && fileUpload.length() < 1000) { + ByteBuf content = fileUpload.getByteBuf(); + byte[] bytes = new byte[content.readableBytes()]; + content.getBytes(0, bytes); + String preview = new String(bytes, StandardCharsets.UTF_8); + logger.info("File content preview: {}", + preview.length() > 100 ? preview.substring(0, 100) + "..." : preview); + } + } + } + + return response.toString(); + } catch (Exception e) { + logger.error("Error decoding multipart data", e); + return "Error processing multipart data: " + e.getMessage(); + } finally { + if (decoder != null) { + decoder.destroy(); + } + } + } + + private void sendResponse(ChannelHandlerContext ctx, HttpResponseStatus status, String body) { + byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); + ByteBuf content = Unpooled.copiedBuffer(bodyBytes); + + FullHttpResponse response = new DefaultFullHttpResponse( + HttpVersion.HTTP_1_1, status, content); + + response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8"); + response.headers().set(HttpHeaderNames.CONTENT_LENGTH, bodyBytes.length); + + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + logger.error("Exception in forms handler", cause); + ctx.close(); + } + } +} diff --git a/src/test/java/io/github/laeubi/httpclient/JavaHttpClientFormsTest.java b/src/test/java/io/github/laeubi/httpclient/JavaHttpClientFormsTest.java new file mode 100644 index 0000000..661cdfd --- /dev/null +++ b/src/test/java/io/github/laeubi/httpclient/JavaHttpClientFormsTest.java @@ -0,0 +1,187 @@ +package io.github.laeubi.httpclient; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Test suite demonstrating Java HTTP Client support for HTML form data submission. + * + * This test suite evaluates: + * 1. Whether the client provides a specific API for form data + * 2. Manual implementation of application/x-www-form-urlencoded + * 3. Form data with special characters requiring URL encoding + */ +public class JavaHttpClientFormsTest extends JavaHttpClientBase { + + private static final Logger logger = LoggerFactory.getLogger(JavaHttpClientFormsTest.class); + private static NettyFormsServer formsServer; + + @BeforeAll + public static void startServers() throws Exception { + formsServer = new NettyFormsServer(8090); + } + + @AfterAll + public static void stopServers() { + if (formsServer != null) { + formsServer.stop(); + } + } + + @Test + @DisplayName("Java HTTP Client does NOT provide a specific API for form data") + public void testNoBuiltInFormAPI() throws Exception { + logger.info("\n=== Testing whether Java HTTP Client has built-in form API ==="); + + // Java HTTP Client does NOT have methods like: + // - HttpRequest.BodyPublishers.ofForm(Map) + // - FormDataBuilder or similar convenience API + // All form submissions must be manually constructed + + logger.info("Java HTTP Client provides no specific form data API"); + logger.info("Forms must be manually built using application/x-www-form-urlencoded"); + logger.info("=== No built-in form API available ===\n"); + } + + @Test + @DisplayName("Manual implementation of application/x-www-form-urlencoded form data") + public void testManualFormDataImplementation() throws Exception { + logger.info("\n=== Testing manual form data submission ==="); + + HttpClient client = httpClient(); + + // Manually build form data + Map formData = new HashMap<>(); + formData.put("username", "testuser"); + formData.put("password", "testpass"); + formData.put("remember", "true"); + + String formBody = buildFormData(formData); + logger.info("Built form data: {}", formBody); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + formsServer.getPort() + "/form")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(formBody)) + .build(); + + logger.info("Sending POST request with form data"); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + logger.info("Response status: {}", response.statusCode()); + logger.info("Response body: {}", response.body()); + + assertEquals(200, response.statusCode(), "Expected 200 OK response"); + assertTrue(response.body().contains("username=testuser"), "Response should echo username"); + assertTrue(response.body().contains("password=testpass"), "Response should echo password"); + assertTrue(response.body().contains("remember=true"), "Response should echo remember flag"); + + logger.info("=== Manual form data submission works but requires manual implementation ===\n"); + } + + @Test + @DisplayName("Form data with special characters requires URL encoding") + public void testFormDataWithSpecialCharacters() throws Exception { + logger.info("\n=== Testing form data with special characters ==="); + + HttpClient client = httpClient(); + + // Form data with special characters that need URL encoding + Map formData = new HashMap<>(); + formData.put("email", "user@example.com"); + formData.put("message", "Hello World! How are you?"); + formData.put("tags", "java,http,client"); + formData.put("special", "a=b&c=d"); + + String formBody = buildFormData(formData); + logger.info("Built form data with special chars: {}", formBody); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + formsServer.getPort() + "/form")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(formBody)) + .build(); + + logger.info("Sending POST request with special characters"); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + logger.info("Response status: {}", response.statusCode()); + logger.info("Response body: {}", response.body()); + + assertEquals(200, response.statusCode(), "Expected 200 OK response"); + assertTrue(response.body().contains("email=user@example.com"), "Response should contain email"); + assertTrue(response.body().contains("message=Hello World! How are you?"), + "Response should contain message with spaces and punctuation"); + assertTrue(response.body().contains("special=a=b&c=d"), + "Response should contain special characters properly decoded"); + + logger.info("=== URL encoding works but is manual responsibility ===\n"); + } + + @Test + @DisplayName("Demonstrate complete manual form handling required") + public void testCompleteFormHandlingRequired() throws Exception { + logger.info("\n=== Demonstrating complete manual form handling ==="); + + HttpClient client = httpClient(); + + // Realistic form data + Map formData = new HashMap<>(); + formData.put("firstName", "John"); + formData.put("lastName", "Doe"); + formData.put("age", "30"); + formData.put("email", "john.doe@example.com"); + formData.put("country", "USA"); + + // Manual construction required + String formBody = buildFormData(formData); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + formsServer.getPort() + "/form")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(formBody)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode(), "Expected 200 OK response"); + assertNotNull(response.body(), "Response body should not be null"); + + logger.info("Summary:"); + logger.info("- Java HTTP Client has NO built-in form API"); + logger.info("- Developers must manually build application/x-www-form-urlencoded strings"); + logger.info("- URL encoding is developer's responsibility"); + logger.info("- No convenience methods for common form operations"); + logger.info("=== Complete manual implementation required ===\n"); + } + + /** + * Helper method to build application/x-www-form-urlencoded form data. + * This is what developers must implement manually since Java HTTP Client + * does not provide this functionality. + */ + private String buildFormData(Map data) { + return data.entrySet().stream() + .map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8) + "=" + + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); + } +} diff --git a/src/test/java/io/github/laeubi/httpclient/JavaHttpClientMultipartTest.java b/src/test/java/io/github/laeubi/httpclient/JavaHttpClientMultipartTest.java new file mode 100644 index 0000000..250f533 --- /dev/null +++ b/src/test/java/io/github/laeubi/httpclient/JavaHttpClientMultipartTest.java @@ -0,0 +1,309 @@ +package io.github.laeubi.httpclient; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Test suite demonstrating Java HTTP Client support for multipart/form-data requests. + * + * This test suite evaluates: + * 1. Whether the client provides a specific API for multipart requests + * 2. Manual implementation of multipart/form-data + * 3. File upload with multipart + * 4. Mixed content (text fields + files) in multipart requests + */ +public class JavaHttpClientMultipartTest extends JavaHttpClientBase { + + private static final Logger logger = LoggerFactory.getLogger(JavaHttpClientMultipartTest.class); + private static NettyFormsServer formsServer; + + @BeforeAll + public static void startServers() throws Exception { + formsServer = new NettyFormsServer(8091); + } + + @AfterAll + public static void stopServers() { + if (formsServer != null) { + formsServer.stop(); + } + } + + @Test + @DisplayName("Java HTTP Client does NOT provide a specific API for multipart data") + public void testNoBuiltInMultipartAPI() throws Exception { + logger.info("\n=== Testing whether Java HTTP Client has built-in multipart API ==="); + + // Java HTTP Client does NOT have methods like: + // - HttpRequest.BodyPublishers.ofMultipartForm(...) + // - MultipartBuilder or similar convenience API + // All multipart submissions must be manually constructed + + logger.info("Java HTTP Client provides no specific multipart data API"); + logger.info("Multipart must be manually built with proper boundaries and headers"); + logger.info("=== No built-in multipart API available ===\n"); + } + + @Test + @DisplayName("Manual implementation of multipart/form-data with text fields") + public void testManualMultipartTextFields() throws Exception { + logger.info("\n=== Testing manual multipart submission with text fields ==="); + + HttpClient client = httpClient(); + + String boundary = "----WebKitFormBoundary" + System.currentTimeMillis(); + + MultipartBodyBuilder builder = new MultipartBodyBuilder(boundary); + builder.addField("username", "testuser"); + builder.addField("email", "test@example.com"); + builder.addField("message", "Hello from multipart!"); + + byte[] body = builder.build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + formsServer.getPort() + "/multipart")) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + + logger.info("Sending POST request with multipart data"); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + logger.info("Response status: {}", response.statusCode()); + logger.info("Response body: {}", response.body()); + + assertEquals(200, response.statusCode(), "Expected 200 OK response"); + assertTrue(response.body().contains("username"), "Response should contain username field"); + assertTrue(response.body().contains("testuser"), "Response should contain username value"); + assertTrue(response.body().contains("email"), "Response should contain email field"); + + logger.info("=== Manual multipart submission works but requires complex implementation ===\n"); + } + + @Test + @DisplayName("Manual implementation of file upload with multipart/form-data") + public void testManualFileUpload() throws Exception { + logger.info("\n=== Testing manual file upload with multipart ==="); + + HttpClient client = httpClient(); + + // Create a temporary file for upload + Path tempFile = Files.createTempFile("test-upload", ".txt"); + String fileContent = "This is test file content\nLine 2\nLine 3"; + Files.writeString(tempFile, fileContent); + + try { + String boundary = "----WebKitFormBoundary" + System.currentTimeMillis(); + + MultipartBodyBuilder builder = new MultipartBodyBuilder(boundary); + builder.addField("description", "Test file upload"); + builder.addFile("file", tempFile.getFileName().toString(), + Files.readAllBytes(tempFile), "text/plain"); + + byte[] body = builder.build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + formsServer.getPort() + "/upload")) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + + logger.info("Sending POST request with file upload"); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + logger.info("Response status: {}", response.statusCode()); + logger.info("Response body: {}", response.body()); + + assertEquals(200, response.statusCode(), "Expected 200 OK response"); + assertTrue(response.body().contains("file"), "Response should contain file field"); + assertTrue(response.body().contains(tempFile.getFileName().toString()), + "Response should contain filename"); + + logger.info("=== File upload works but requires manual multipart construction ===\n"); + } finally { + Files.deleteIfExists(tempFile); + } + } + + @Test + @DisplayName("Manual implementation of multipart with mixed content") + public void testManualMultipartMixedContent() throws Exception { + logger.info("\n=== Testing multipart with mixed text and file content ==="); + + HttpClient client = httpClient(); + + // Create temporary files + Path textFile = Files.createTempFile("document", ".txt"); + Path dataFile = Files.createTempFile("data", ".csv"); + + Files.writeString(textFile, "Document content here"); + Files.writeString(dataFile, "name,value\nitem1,100\nitem2,200"); + + try { + String boundary = "----WebKitFormBoundary" + System.currentTimeMillis(); + + MultipartBodyBuilder builder = new MultipartBodyBuilder(boundary); + // Add text fields + builder.addField("title", "Mixed Content Upload"); + builder.addField("author", "Test User"); + builder.addField("version", "1.0"); + + // Add files + builder.addFile("document", textFile.getFileName().toString(), + Files.readAllBytes(textFile), "text/plain"); + builder.addFile("data", dataFile.getFileName().toString(), + Files.readAllBytes(dataFile), "text/csv"); + + byte[] body = builder.build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + formsServer.getPort() + "/multipart")) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + + logger.info("Sending POST request with mixed multipart content"); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + logger.info("Response status: {}", response.statusCode()); + logger.info("Response body length: {} bytes", response.body().length()); + + assertEquals(200, response.statusCode(), "Expected 200 OK response"); + assertNotNull(response.body(), "Response body should not be null"); + + logger.info("=== Mixed content multipart works but is complex to implement ===\n"); + } finally { + Files.deleteIfExists(textFile); + Files.deleteIfExists(dataFile); + } + } + + @Test + @DisplayName("Demonstrate boundary handling complexity") + public void testBoundaryHandling() throws Exception { + logger.info("\n=== Demonstrating multipart boundary handling complexity ==="); + + // Boundaries must: + // 1. Be unique and not appear in the content + // 2. Start with two hyphens "--" + // 3. End with two hyphens "--" followed by CRLF + // 4. Separate each part with the boundary + // 5. Be included in the Content-Type header + + String boundary = "----CustomBoundary123456789"; + logger.info("Boundary: {}", boundary); + logger.info("Content-Type header must include: multipart/form-data; boundary=" + boundary); + logger.info("Each part starts with: --{}", boundary); + logger.info("Final boundary ends with: --{}--", boundary); + + HttpClient client = httpClient(); + + MultipartBodyBuilder builder = new MultipartBodyBuilder(boundary); + builder.addField("test", "value"); + byte[] body = builder.build(); + + String bodyStr = new String(body, StandardCharsets.UTF_8); + logger.info("Generated multipart body:\n{}", bodyStr); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + formsServer.getPort() + "/multipart")) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode(), "Expected 200 OK response"); + + logger.info("\nSummary:"); + logger.info("- Java HTTP Client has NO built-in multipart API"); + logger.info("- Developers must manually construct multipart/form-data bodies"); + logger.info("- Boundary generation and formatting is developer's responsibility"); + logger.info("- File uploads require manual encoding"); + logger.info("- No convenience methods for common multipart operations"); + logger.info("=== Complete manual implementation required ===\n"); + } + + /** + * Helper class to manually build multipart/form-data bodies. + * This demonstrates the complexity that developers must handle manually + * since Java HTTP Client does not provide this functionality. + */ + private static class MultipartBodyBuilder { + private final String boundary; + private final List parts = new ArrayList<>(); + private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8); + private static final byte[] DOUBLE_DASH = "--".getBytes(StandardCharsets.UTF_8); + + public MultipartBodyBuilder(String boundary) { + this.boundary = boundary; + } + + public void addField(String name, String value) { + StringBuilder part = new StringBuilder(); + part.append("--").append(boundary).append("\r\n"); + part.append("Content-Disposition: form-data; name=\"").append(name).append("\"\r\n"); + part.append("\r\n"); + part.append(value).append("\r\n"); + parts.add(part.toString().getBytes(StandardCharsets.UTF_8)); + } + + public void addFile(String fieldName, String fileName, byte[] fileContent, String contentType) + throws IOException { + StringBuilder part = new StringBuilder(); + part.append("--").append(boundary).append("\r\n"); + part.append("Content-Disposition: form-data; name=\"").append(fieldName) + .append("\"; filename=\"").append(fileName).append("\"\r\n"); + part.append("Content-Type: ").append(contentType).append("\r\n"); + part.append("\r\n"); + + byte[] header = part.toString().getBytes(StandardCharsets.UTF_8); + byte[] combined = new byte[header.length + fileContent.length + CRLF.length]; + System.arraycopy(header, 0, combined, 0, header.length); + System.arraycopy(fileContent, 0, combined, header.length, fileContent.length); + System.arraycopy(CRLF, 0, combined, header.length + fileContent.length, CRLF.length); + + parts.add(combined); + } + + public byte[] build() throws IOException { + // Calculate total size + int totalSize = 0; + for (byte[] part : parts) { + totalSize += part.length; + } + // Add final boundary size: "--boundary--\r\n" + byte[] finalBoundary = ("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8); + totalSize += finalBoundary.length; + + // Combine all parts + byte[] result = new byte[totalSize]; + int offset = 0; + for (byte[] part : parts) { + System.arraycopy(part, 0, result, offset, part.length); + offset += part.length; + } + System.arraycopy(finalBoundary, 0, result, offset, finalBoundary.length); + + return result; + } + } +}