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