diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/Entity.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/Entity.java
index ccafa675..6bb2ec46 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/Entity.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/Entity.java
@@ -14,6 +14,7 @@
import java.io.Closeable;
import java.io.InputStream;
import java.nio.charset.Charset;
+import java.util.Map;
/**
* 表示消息体内的数据。
@@ -37,6 +38,15 @@ public interface Entity extends Closeable {
@Nonnull
MimeType resolvedMimeType();
+ /**
+ * 获取实体的 Content-Type 额外参数。
+ *
例如,对于 multipart/form-data,需要返回包含 boundary 参数的 Map。
+ *
+ * @return 表示实体的 Content-Type 额外参数的 {@link Map}{@code <}{@link String}{@code , }{@link String}{@code >}。
+ */
+ @Nonnull
+ Map resolvedParameters();
+
/**
* 通过指定的字节数组,按照 {@link java.nio.charset.StandardCharsets#UTF_8} 创建文本消息体数据。
*
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/serializer/MultiPartEntitySerializer.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/serializer/MultiPartEntitySerializer.java
index e24c458d..7cd60080 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/serializer/MultiPartEntitySerializer.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/serializer/MultiPartEntitySerializer.java
@@ -18,6 +18,7 @@
import modelengine.fit.http.entity.FileEntity;
import modelengine.fit.http.entity.NamedEntity;
import modelengine.fit.http.entity.PartitionedEntity;
+import modelengine.fit.http.entity.TextEntity;
import modelengine.fit.http.entity.support.DefaultNamedEntity;
import modelengine.fit.http.entity.support.DefaultPartitionedEntity;
import modelengine.fit.http.entity.support.DefaultTextEntity;
@@ -82,7 +83,111 @@ public class MultiPartEntitySerializer implements EntitySerializer new EntityWriteException("The boundary is not present in Content-Type."));
+ return BOUNDARY_SURROUND + boundary;
+ }
+
+ /**
+ * 写入分隔符。
+ *
+ * @param out 表示输出流的 {@link OutputStream}。
+ * @param boundary 表示 boundary 分隔符的 {@link String}。
+ * @param charset 表示字符集的 {@link Charset}。
+ * @param isEnd 表示是否是终止分隔符的 {@code boolean}。
+ * @throws IOException 当发生 I/O 异常时。
+ */
+ private void writeBoundary(OutputStream out, String boundary, Charset charset, boolean isEnd) throws IOException {
+ out.write(BOUNDARY_SURROUND.getBytes(charset));
+ out.write(boundary.getBytes(charset));
+ if (isEnd) {
+ out.write(BOUNDARY_SURROUND.getBytes(charset));
+ }
+ out.write(CR);
+ out.write(LF);
+ }
+
+ /**
+ * 写入消息头。
+ *
+ * @param out 表示输出流的 {@link OutputStream}。
+ * @param namedEntity 表示带名字的消息体数据的 {@link NamedEntity}。
+ * @param charset 表示字符集的 {@link Charset}。
+ * @throws IOException 当发生 I/O 异常时。
+ */
+ private void writeHeaders(OutputStream out, NamedEntity namedEntity, Charset charset) throws IOException {
+ // Write Content-Disposition header
+ StringBuilder disposition = new StringBuilder("Content-Disposition: form-data");
+ if (!StringUtils.isEmpty(namedEntity.name())) {
+ disposition.append("; name=\"").append(namedEntity.name()).append("\"");
+ }
+ if (namedEntity.isFile()) {
+ FileEntity fileEntity = namedEntity.asFile();
+ disposition.append("; filename=\"").append(fileEntity.filename()).append("\"");
+ }
+ out.write(disposition.toString().getBytes(charset));
+ out.write(CR);
+ out.write(LF);
+
+ // Write Content-Type header if it's a file
+ if (namedEntity.isFile()) {
+ Entity innerEntity = namedEntity.entity();
+ String contentType = "Content-Type: " + innerEntity.resolvedMimeType().value();
+ out.write(contentType.getBytes(charset));
+ out.write(CR);
+ out.write(LF);
+ }
+
+ // Write empty line
+ out.write(CR);
+ out.write(LF);
+ }
+
+ /**
+ * 写入实体内容。
+ *
+ * @param out 表示输出流的 {@link OutputStream}。
+ * @param namedEntity 表示带名字的消息体数据的 {@link NamedEntity}。
+ * @param charset 表示字符集的 {@link Charset}。
+ * @throws IOException 当发生 I/O 异常时。
+ */
+ private void writeEntityContent(OutputStream out, NamedEntity namedEntity, Charset charset) throws IOException {
+ Entity innerEntity = namedEntity.entity();
+ if (namedEntity.isText()) {
+ TextEntity textEntity = cast(innerEntity);
+ out.write(textEntity.content().getBytes(charset));
+ } else if (namedEntity.isFile()) {
+ FileEntity fileEntity = cast(innerEntity);
+ byte[] buffer = new byte[8192];
+ int bytesRead;
+ while ((bytesRead = fileEntity.read(buffer)) != -1) {
+ out.write(buffer, 0, bytesRead);
+ }
+ }
+ out.write(CR);
+ out.write(LF);
}
@Override
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/support/AbstractEntity.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/support/AbstractEntity.java
index fc0d477b..97d1340b 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/support/AbstractEntity.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/support/AbstractEntity.java
@@ -12,6 +12,8 @@
import modelengine.fit.http.entity.Entity;
import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
/**
* 表示 {@link Entity} 的抽象实现。
@@ -38,6 +40,11 @@ public HttpMessage belongTo() {
return this.httpMessage;
}
+ @Override
+ public Map resolvedParameters() {
+ return Collections.emptyMap();
+ }
+
@Override
public void close() throws IOException {}
}
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/support/DefaultPartitionedEntity.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/support/DefaultPartitionedEntity.java
index 7c5fbcab..80362870 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/support/DefaultPartitionedEntity.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/support/DefaultPartitionedEntity.java
@@ -13,10 +13,12 @@
import modelengine.fit.http.entity.PartitionedEntity;
import modelengine.fit.http.protocol.MimeType;
import modelengine.fitframework.inspection.Nonnull;
+import modelengine.fitframework.util.UuidUtils;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
/**
* 表示 {@link PartitionedEntity} 的默认实现。
@@ -25,7 +27,9 @@
* @since 2022-10-12
*/
public class DefaultPartitionedEntity extends AbstractEntity implements PartitionedEntity {
+ private static final String BOUNDARY_PREFIX = "FitFormBoundary";
private final List namedEntities;
+ private final String boundary;
/**
* 创建分块的消息体数据对象。
@@ -36,6 +40,19 @@ public class DefaultPartitionedEntity extends AbstractEntity implements Partitio
public DefaultPartitionedEntity(HttpMessage httpMessage, List namedEntities) {
super(httpMessage);
this.namedEntities = getIfNull(namedEntities, Collections::emptyList);
+ this.boundary = this.generateBoundary();
+ }
+
+ /**
+ * 生成随机的 boundary 分隔符。
+ * 格式:FitFormBoundary-{32位随机十六进制字符}
+ * 示例:FitFormBoundary-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p
+ * 注意:实际写入消息体时会自动添加 {@code --} 前缀。
+ *
+ * @return 表示生成的 boundary 分隔符的 {@link String}。
+ */
+ private String generateBoundary() {
+ return BOUNDARY_PREFIX + "-" + UuidUtils.randomUuidString().replace("-", "");
}
@Override
@@ -49,6 +66,12 @@ public MimeType resolvedMimeType() {
return MimeType.MULTIPART_FORM_DATA;
}
+ @Nonnull
+ @Override
+ public Map resolvedParameters() {
+ return Map.of("boundary", this.boundary);
+ }
+
@Override
public void close() throws IOException {
super.close();
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpMessage.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpMessage.java
index 11651852..631ddd48 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpMessage.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpMessage.java
@@ -118,8 +118,13 @@ protected void setContentTypeByEntity(ConfigurableMessageHeaders headers, Entity
if (isPresent) {
return;
}
+ ParameterCollection mergedParameters = ParameterCollection.create();
+ for (String key : this.parameters.keys()) {
+ this.parameters.get(key).ifPresent(value -> mergedParameters.set(key, value));
+ }
+ entity.resolvedParameters().forEach(mergedParameters::set);
ContentType contentType =
- HeaderValue.create(entity.resolvedMimeType().value(), this.parameters).toContentType();
+ HeaderValue.create(entity.resolvedMimeType().value(), mergedParameters).toContentType();
notNull(contentType,
() -> new UnsupportedOperationException(StringUtils.format(
"Not supported entity type. " + "[entityType={0}]", entity.getClass().getName())));
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/entity/serializer/MultiPartEntitySerializerTest.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/entity/serializer/MultiPartEntitySerializerTest.java
index 9ae31c78..6cffc90e 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/entity/serializer/MultiPartEntitySerializerTest.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/entity/serializer/MultiPartEntitySerializerTest.java
@@ -18,13 +18,17 @@
import modelengine.fit.http.entity.FileEntity;
import modelengine.fit.http.entity.NamedEntity;
import modelengine.fit.http.entity.PartitionedEntity;
+import modelengine.fit.http.entity.TextEntity;
+import modelengine.fit.http.entity.support.DefaultNamedEntity;
import modelengine.fit.http.entity.support.DefaultPartitionedEntity;
+import modelengine.fit.http.entity.support.DefaultTextEntity;
import modelengine.fit.http.header.ContentType;
import modelengine.fit.http.header.HeaderValue;
import modelengine.fit.http.header.ParameterCollection;
import modelengine.fit.http.header.support.DefaultContentType;
import modelengine.fit.http.header.support.DefaultHeaderValue;
import modelengine.fit.http.header.support.DefaultParameterCollection;
+import modelengine.fit.http.util.HttpUtils;
import modelengine.fitframework.util.IoUtils;
import modelengine.fitframework.util.StringUtils;
@@ -62,14 +66,147 @@ void teardown() throws IOException {
}
}
- @Test
- @DisplayName("调用 serializeEntity() 方法,抛出异常")
- void invokeSerializeEntityMethodThenThrowException() {
- List list = new ArrayList<>();
- this.entity = new DefaultPartitionedEntity(this.httpMessage, list);
- EntityWriteException entityWriteException = catchThrowableOfType(EntityWriteException.class,
- () -> this.multiPartEntitySerializer.serializeEntity(this.entity, this.charset));
- assertThat(entityWriteException).hasMessage("Unsupported to serialize entity of Content-Type 'multipart/*'.");
+ @Nested
+ @DisplayName("测试 serializeEntity() 方法")
+ class TestSerialize {
+ private EntitySerializer getSerializer() {
+ return MultiPartEntitySerializerTest.this.multiPartEntitySerializer;
+ }
+
+ @Test
+ @DisplayName("没有设置 Content-Type 时,抛出异常")
+ void givenNoContentTypeThenThrowException() {
+ List list = new ArrayList<>();
+ MultiPartEntitySerializerTest.this.entity = new DefaultPartitionedEntity(
+ MultiPartEntitySerializerTest.this.httpMessage, list);
+ EntityWriteException entityWriteException = catchThrowableOfType(EntityWriteException.class,
+ () -> this.getSerializer().serializeEntity(MultiPartEntitySerializerTest.this.entity,
+ MultiPartEntitySerializerTest.this.charset));
+ assertThat(entityWriteException).hasMessage("The boundary is not present in Content-Type.");
+ }
+
+ @Test
+ @DisplayName("序列化空的分块实体,返回终止分隔符")
+ void givenEmptyEntitiesThenReturnEndBoundary() {
+ List list = new ArrayList<>();
+ MultiPartEntitySerializerTest.this.entity = new DefaultPartitionedEntity(
+ MultiPartEntitySerializerTest.this.httpMessage, list);
+ // Mock Content-Type with boundary
+ when(MultiPartEntitySerializerTest.this.httpMessage.contentType())
+ .thenReturn(Optional.of(new DefaultContentType(
+ HttpUtils.parseHeaderValue("multipart/form-data; boundary=test-boundary"))));
+
+ byte[] result = this.getSerializer()
+ .serializeEntity(MultiPartEntitySerializerTest.this.entity, MultiPartEntitySerializerTest.this.charset);
+ String resultStr = new String(result, MultiPartEntitySerializerTest.this.charset);
+ assertThat(resultStr).isEqualTo("----test-boundary--\r\n");
+ }
+
+ @Test
+ @DisplayName("序列化包含文本字段的分块实体")
+ void givenTextFieldThenSerialize() {
+ List list = new ArrayList<>();
+ TextEntity textEntity = new DefaultTextEntity(MultiPartEntitySerializerTest.this.httpMessage, "test-content");
+ NamedEntity namedEntity = new DefaultNamedEntity(MultiPartEntitySerializerTest.this.httpMessage,
+ "field-name", textEntity);
+ list.add(namedEntity);
+ MultiPartEntitySerializerTest.this.entity = new DefaultPartitionedEntity(
+ MultiPartEntitySerializerTest.this.httpMessage, list);
+
+ when(MultiPartEntitySerializerTest.this.httpMessage.contentType())
+ .thenReturn(Optional.of(new DefaultContentType(
+ HttpUtils.parseHeaderValue("multipart/form-data; boundary=test-boundary"))));
+
+ byte[] result = this.getSerializer()
+ .serializeEntity(MultiPartEntitySerializerTest.this.entity, MultiPartEntitySerializerTest.this.charset);
+ String resultStr = new String(result, MultiPartEntitySerializerTest.this.charset);
+
+ String expected = """
+ ----test-boundary\r
+ Content-Disposition: form-data; name="field-name"\r
+ \r
+ test-content\r
+ ----test-boundary--\r
+ """;
+ assertThat(resultStr).isEqualTo(expected);
+ }
+
+ @Test
+ @DisplayName("序列化包含文件的分块实体")
+ void givenFileFieldThenSerialize() throws IOException {
+ List list = new ArrayList<>();
+ byte[] fileContent = "file content".getBytes(MultiPartEntitySerializerTest.this.charset);
+ FileEntity fileEntity = FileEntity.createInline(MultiPartEntitySerializerTest.this.httpMessage,
+ "test.txt", new java.io.ByteArrayInputStream(fileContent), fileContent.length);
+ NamedEntity namedEntity = new DefaultNamedEntity(MultiPartEntitySerializerTest.this.httpMessage,
+ "file-field", fileEntity);
+ list.add(namedEntity);
+ MultiPartEntitySerializerTest.this.entity = new DefaultPartitionedEntity(
+ MultiPartEntitySerializerTest.this.httpMessage, list);
+
+ when(MultiPartEntitySerializerTest.this.httpMessage.contentType())
+ .thenReturn(Optional.of(new DefaultContentType(
+ HttpUtils.parseHeaderValue("multipart/form-data; boundary=test-boundary"))));
+
+ byte[] result = this.getSerializer()
+ .serializeEntity(MultiPartEntitySerializerTest.this.entity, MultiPartEntitySerializerTest.this.charset);
+ String resultStr = new String(result, MultiPartEntitySerializerTest.this.charset);
+
+ String expected = """
+ ----test-boundary\r
+ Content-Disposition: form-data; name="file-field"; filename="test.txt"\r
+ Content-Type: text/plain\r
+ \r
+ file content\r
+ ----test-boundary--\r
+ """;
+ assertThat(resultStr).isEqualTo(expected);
+ }
+
+ @Test
+ @DisplayName("序列化混合文本和文件的分块实体")
+ void givenMixedFieldsThenSerialize() throws IOException {
+ List list = new ArrayList<>();
+
+ // Add text field
+ TextEntity textEntity = new DefaultTextEntity(MultiPartEntitySerializerTest.this.httpMessage, "text-value");
+ NamedEntity textNamedEntity = new DefaultNamedEntity(MultiPartEntitySerializerTest.this.httpMessage,
+ "text-field", textEntity);
+ list.add(textNamedEntity);
+
+ // Add file field
+ byte[] fileContent = "file data".getBytes(MultiPartEntitySerializerTest.this.charset);
+ FileEntity fileEntity = FileEntity.createInline(MultiPartEntitySerializerTest.this.httpMessage,
+ "document.pdf", new java.io.ByteArrayInputStream(fileContent), fileContent.length);
+ NamedEntity fileNamedEntity = new DefaultNamedEntity(MultiPartEntitySerializerTest.this.httpMessage,
+ "file-field", fileEntity);
+ list.add(fileNamedEntity);
+
+ MultiPartEntitySerializerTest.this.entity = new DefaultPartitionedEntity(
+ MultiPartEntitySerializerTest.this.httpMessage, list);
+
+ when(MultiPartEntitySerializerTest.this.httpMessage.contentType())
+ .thenReturn(Optional.of(new DefaultContentType(
+ HttpUtils.parseHeaderValue("multipart/form-data; boundary=test-boundary"))));
+
+ byte[] result = this.getSerializer()
+ .serializeEntity(MultiPartEntitySerializerTest.this.entity, MultiPartEntitySerializerTest.this.charset);
+ String resultStr = new String(result, MultiPartEntitySerializerTest.this.charset);
+
+ String expected = """
+ ----test-boundary\r
+ Content-Disposition: form-data; name="text-field"\r
+ \r
+ text-value\r
+ ----test-boundary\r
+ Content-Disposition: form-data; name="file-field"; filename="document.pdf"\r
+ Content-Type: application/octet-stream\r
+ \r
+ file data\r
+ ----test-boundary--\r
+ """;
+ assertThat(resultStr).isEqualTo(expected);
+ }
}
@Nested