Skip to content

Commit f07c6e0

Browse files
CodeCasterXclaude
andauthored
[fit] Add multipart/form-data serialization support for HTTP client (#315) (#331)
## Summary - Implement multipart/form-data request serialization for HTTP client - Support both text fields and file uploads in multipart requests - Auto-generate boundary with format: FitFormBoundary-{uuid} - Maintain backward compatibility with existing deserialization ## Changes 1. Add Entity.resolvedParameters() method for Content-Type parameters 2. Implement MultiPartEntitySerializer.serializeEntity() method 3. Auto-generate and inject boundary parameter in DefaultPartitionedEntity 4. Merge entity parameters with base parameters in AbstractHttpMessage 5. Add comprehensive test cases for multipart serialization ## Test Results - All 237 tests passed - New tests cover: empty entities, text fields, file fields, mixed fields Fixes #315 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <[email protected]>
1 parent c7f623a commit f07c6e0

File tree

6 files changed

+297
-10
lines changed

6 files changed

+297
-10
lines changed

framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/Entity.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import java.io.Closeable;
1515
import java.io.InputStream;
1616
import java.nio.charset.Charset;
17+
import java.util.Map;
1718

1819
/**
1920
* 表示消息体内的数据。
@@ -37,6 +38,15 @@ public interface Entity extends Closeable {
3738
@Nonnull
3839
MimeType resolvedMimeType();
3940

41+
/**
42+
* 获取实体的 Content-Type 额外参数。
43+
* <p>例如,对于 multipart/form-data,需要返回包含 boundary 参数的 Map。</p>
44+
*
45+
* @return 表示实体的 Content-Type 额外参数的 {@link Map}{@code <}{@link String}{@code , }{@link String}{@code >}。
46+
*/
47+
@Nonnull
48+
Map<String, String> resolvedParameters();
49+
4050
/**
4151
* 通过指定的字节数组,按照 {@link java.nio.charset.StandardCharsets#UTF_8} 创建文本消息体数据。
4252
*

framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/serializer/MultiPartEntitySerializer.java

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import modelengine.fit.http.entity.FileEntity;
1919
import modelengine.fit.http.entity.NamedEntity;
2020
import modelengine.fit.http.entity.PartitionedEntity;
21+
import modelengine.fit.http.entity.TextEntity;
2122
import modelengine.fit.http.entity.support.DefaultNamedEntity;
2223
import modelengine.fit.http.entity.support.DefaultPartitionedEntity;
2324
import modelengine.fit.http.entity.support.DefaultTextEntity;
@@ -82,7 +83,111 @@ public class MultiPartEntitySerializer implements EntitySerializer<PartitionedEn
8283

8384
@Override
8485
public void serializeEntity(@Nonnull PartitionedEntity entity, Charset charset, OutputStream out) {
85-
throw new EntityWriteException("Unsupported to serialize entity of Content-Type 'multipart/*'.");
86+
String boundary = this.getBoundary(entity);
87+
try {
88+
for (NamedEntity namedEntity : entity.entities()) {
89+
this.writeBoundary(out, boundary, charset, false);
90+
this.writeHeaders(out, namedEntity, charset);
91+
this.writeEntityContent(out, namedEntity, charset);
92+
}
93+
this.writeBoundary(out, boundary, charset, true);
94+
} catch (IOException e) {
95+
throw new EntityWriteException("Failed to serialize entity of Content-Type 'multipart/*'.", e);
96+
}
97+
}
98+
99+
/**
100+
* 获取 boundary 分隔符。
101+
*
102+
* @param entity 表示分块的消息体数据的 {@link PartitionedEntity}。
103+
* @return 表示 boundary 分隔符的 {@link String}。
104+
*/
105+
private String getBoundary(PartitionedEntity entity) {
106+
String boundary = entity.belongTo()
107+
.contentType()
108+
.flatMap(ContentType::boundary)
109+
.orElseThrow(() -> new EntityWriteException("The boundary is not present in Content-Type."));
110+
return BOUNDARY_SURROUND + boundary;
111+
}
112+
113+
/**
114+
* 写入分隔符。
115+
*
116+
* @param out 表示输出流的 {@link OutputStream}。
117+
* @param boundary 表示 boundary 分隔符的 {@link String}。
118+
* @param charset 表示字符集的 {@link Charset}。
119+
* @param isEnd 表示是否是终止分隔符的 {@code boolean}。
120+
* @throws IOException 当发生 I/O 异常时。
121+
*/
122+
private void writeBoundary(OutputStream out, String boundary, Charset charset, boolean isEnd) throws IOException {
123+
out.write(BOUNDARY_SURROUND.getBytes(charset));
124+
out.write(boundary.getBytes(charset));
125+
if (isEnd) {
126+
out.write(BOUNDARY_SURROUND.getBytes(charset));
127+
}
128+
out.write(CR);
129+
out.write(LF);
130+
}
131+
132+
/**
133+
* 写入消息头。
134+
*
135+
* @param out 表示输出流的 {@link OutputStream}。
136+
* @param namedEntity 表示带名字的消息体数据的 {@link NamedEntity}。
137+
* @param charset 表示字符集的 {@link Charset}。
138+
* @throws IOException 当发生 I/O 异常时。
139+
*/
140+
private void writeHeaders(OutputStream out, NamedEntity namedEntity, Charset charset) throws IOException {
141+
// Write Content-Disposition header
142+
StringBuilder disposition = new StringBuilder("Content-Disposition: form-data");
143+
if (!StringUtils.isEmpty(namedEntity.name())) {
144+
disposition.append("; name=\"").append(namedEntity.name()).append("\"");
145+
}
146+
if (namedEntity.isFile()) {
147+
FileEntity fileEntity = namedEntity.asFile();
148+
disposition.append("; filename=\"").append(fileEntity.filename()).append("\"");
149+
}
150+
out.write(disposition.toString().getBytes(charset));
151+
out.write(CR);
152+
out.write(LF);
153+
154+
// Write Content-Type header if it's a file
155+
if (namedEntity.isFile()) {
156+
Entity innerEntity = namedEntity.entity();
157+
String contentType = "Content-Type: " + innerEntity.resolvedMimeType().value();
158+
out.write(contentType.getBytes(charset));
159+
out.write(CR);
160+
out.write(LF);
161+
}
162+
163+
// Write empty line
164+
out.write(CR);
165+
out.write(LF);
166+
}
167+
168+
/**
169+
* 写入实体内容。
170+
*
171+
* @param out 表示输出流的 {@link OutputStream}。
172+
* @param namedEntity 表示带名字的消息体数据的 {@link NamedEntity}。
173+
* @param charset 表示字符集的 {@link Charset}。
174+
* @throws IOException 当发生 I/O 异常时。
175+
*/
176+
private void writeEntityContent(OutputStream out, NamedEntity namedEntity, Charset charset) throws IOException {
177+
Entity innerEntity = namedEntity.entity();
178+
if (namedEntity.isText()) {
179+
TextEntity textEntity = cast(innerEntity);
180+
out.write(textEntity.content().getBytes(charset));
181+
} else if (namedEntity.isFile()) {
182+
FileEntity fileEntity = cast(innerEntity);
183+
byte[] buffer = new byte[8192];
184+
int bytesRead;
185+
while ((bytesRead = fileEntity.read(buffer)) != -1) {
186+
out.write(buffer, 0, bytesRead);
187+
}
188+
}
189+
out.write(CR);
190+
out.write(LF);
86191
}
87192

88193
@Override

framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/support/AbstractEntity.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import modelengine.fit.http.entity.Entity;
1313

1414
import java.io.IOException;
15+
import java.util.Collections;
16+
import java.util.Map;
1517

1618
/**
1719
* 表示 {@link Entity} 的抽象实现。
@@ -38,6 +40,11 @@ public HttpMessage belongTo() {
3840
return this.httpMessage;
3941
}
4042

43+
@Override
44+
public Map<String, String> resolvedParameters() {
45+
return Collections.emptyMap();
46+
}
47+
4148
@Override
4249
public void close() throws IOException {}
4350
}

framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/entity/support/DefaultPartitionedEntity.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
import modelengine.fit.http.entity.PartitionedEntity;
1414
import modelengine.fit.http.protocol.MimeType;
1515
import modelengine.fitframework.inspection.Nonnull;
16+
import modelengine.fitframework.util.UuidUtils;
1617

1718
import java.io.IOException;
1819
import java.util.Collections;
1920
import java.util.List;
21+
import java.util.Map;
2022

2123
/**
2224
* 表示 {@link PartitionedEntity} 的默认实现。
@@ -25,7 +27,9 @@
2527
* @since 2022-10-12
2628
*/
2729
public class DefaultPartitionedEntity extends AbstractEntity implements PartitionedEntity {
30+
private static final String BOUNDARY_PREFIX = "FitFormBoundary";
2831
private final List<NamedEntity> namedEntities;
32+
private final String boundary;
2933

3034
/**
3135
* 创建分块的消息体数据对象。
@@ -36,6 +40,19 @@ public class DefaultPartitionedEntity extends AbstractEntity implements Partitio
3640
public DefaultPartitionedEntity(HttpMessage httpMessage, List<NamedEntity> namedEntities) {
3741
super(httpMessage);
3842
this.namedEntities = getIfNull(namedEntities, Collections::emptyList);
43+
this.boundary = this.generateBoundary();
44+
}
45+
46+
/**
47+
* 生成随机的 boundary 分隔符。
48+
* <p>格式:FitFormBoundary-{32位随机十六进制字符}</p>
49+
* <p>示例:FitFormBoundary-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p</p>
50+
* <p>注意:实际写入消息体时会自动添加 {@code --} 前缀。</p>
51+
*
52+
* @return 表示生成的 boundary 分隔符的 {@link String}。
53+
*/
54+
private String generateBoundary() {
55+
return BOUNDARY_PREFIX + "-" + UuidUtils.randomUuidString().replace("-", "");
3956
}
4057

4158
@Override
@@ -49,6 +66,12 @@ public MimeType resolvedMimeType() {
4966
return MimeType.MULTIPART_FORM_DATA;
5067
}
5168

69+
@Nonnull
70+
@Override
71+
public Map<String, String> resolvedParameters() {
72+
return Map.of("boundary", this.boundary);
73+
}
74+
5275
@Override
5376
public void close() throws IOException {
5477
super.close();

framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpMessage.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,13 @@ protected void setContentTypeByEntity(ConfigurableMessageHeaders headers, Entity
118118
if (isPresent) {
119119
return;
120120
}
121+
ParameterCollection mergedParameters = ParameterCollection.create();
122+
for (String key : this.parameters.keys()) {
123+
this.parameters.get(key).ifPresent(value -> mergedParameters.set(key, value));
124+
}
125+
entity.resolvedParameters().forEach(mergedParameters::set);
121126
ContentType contentType =
122-
HeaderValue.create(entity.resolvedMimeType().value(), this.parameters).toContentType();
127+
HeaderValue.create(entity.resolvedMimeType().value(), mergedParameters).toContentType();
123128
notNull(contentType,
124129
() -> new UnsupportedOperationException(StringUtils.format(
125130
"Not supported entity type. " + "[entityType={0}]", entity.getClass().getName())));

0 commit comments

Comments
 (0)