diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/Cookie.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/Cookie.java
index 8a3736a1b..8721b7d53 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/Cookie.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/Cookie.java
@@ -35,6 +35,7 @@ public interface Cookie {
*
* @return 表示 Cookie 版本号的 {@code int}。
*/
+ @Deprecated
int version();
/**
@@ -43,6 +44,7 @@ public interface Cookie {
*
* @return 表示 Cookie 注释的 {@link String}。
*/
+ @Deprecated
String comment();
/**
@@ -79,12 +81,20 @@ public interface Cookie {
/**
* 判断 Cookie 是否仅允许在服务端获取。
- *
该属性并不是 Cookie 的标准,但是被浏览器支持。
+ * 其 HttpOnly 属性的格式为 {@code ;HttpOnly ...},如果存在则表示仅服务端可访问。
*
- * @return 如果 Cookie 仅允许在服务端获取,返回 {@code true},否则,返回 {@code false}。
+ * @return 如果 Cookie 仅允许在服务端访问,则返回 {@code true},否则返回 {@code false}。
*/
boolean httpOnly();
+ /**
+ * 获取 Cookie 的 SameSite 属性。
+ * 其 SameSite 属性的格式为 {@code ;SameSite=VALUE ...},表示跨站请求策略。
+ *
+ * @return SameSite 值,如 {@code "Strict"}、{@code "Lax"}、{@code "None"}。
+ */
+ String sameSite();
+
/**
* {@link Cookie} 的构建器。
*/
@@ -107,18 +117,30 @@ interface Builder {
/**
* 向当前构建器中设置 Cookie 的版本。
+ *
+ * 此属性源自 RFC 2965,
+ * 但已在 RFC 6265
+ * 中移出标准定义。现代浏览器会忽略该属性。
+ *
*
* @param version 表示待设置的 Cookie 版本的 {@code int}。
* @return 表示当前构建器的 {@link Builder}。
*/
+ @Deprecated
Builder version(int version);
/**
* 向当前构建器中设置 Cookie 的注释。
+ *
+ * 此属性源自 RFC 2965,
+ * 但已在 RFC 6265
+ * 中移出标准定义。现代浏览器会忽略该属性。
+ *
*
* @param comment 表示待设置的 Cookie 注释的 {@link String}。
* @return 表示当前构建器的 {@link Builder}。
*/
+ @Deprecated
Builder comment(String comment);
/**
@@ -161,6 +183,19 @@ interface Builder {
*/
Builder httpOnly(boolean httpOnly);
+ /**
+ * 向当前构建器中设置 Cookie 限制跨站请求时发送行为安全级别。
+ *
+ * 该属性定义于
+ * RFC 6265bis 草案第 4.1.2.7 节,用于控制跨站请求时是否发送 Cookie。
+ * 尽管该规范尚处于草案阶段,但已被主流浏览器(如 Chrome、Firefox、Safari、Edge)广泛支持。
+ *
+ *
+ * @param sameSite SameSite 值,如 "Strict", "Lax", "None"。
+ * @return 表示当前构建器的 {@link Builder}。
+ */
+ Builder sameSite(String sameSite);
+
/**
* 构建对象。
*
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/support/DefaultHttpClassicClientRequest.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/support/DefaultHttpClassicClientRequest.java
index db2062f17..242a834ac 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/support/DefaultHttpClassicClientRequest.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/support/DefaultHttpClassicClientRequest.java
@@ -155,7 +155,7 @@ protected void commit() {
if (this.isCommitted()) {
return;
}
- this.headers().set(COOKIE, this.cookies().toString());
+ this.headers().set(COOKIE, this.cookies().toRequestHeaderValue());
super.commit();
}
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/ConfigurableCookieCollection.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/ConfigurableCookieCollection.java
index 87cb3012a..0fcf37f14 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/ConfigurableCookieCollection.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/ConfigurableCookieCollection.java
@@ -31,15 +31,4 @@ public interface ConfigurableCookieCollection extends CookieCollection {
static ConfigurableCookieCollection create() {
return new DefaultCookieCollection();
}
-
- /**
- * 根据指定的消息头创建一个可读可写的 Cookie 集合。
- *
- * @param headerValue 表示指定消息头的 {@link HeaderValue}。
- * @return 表示创建出来的可读可写的 Cookie 集合的 {@link ConfigurableCookieCollection}。
- * @throws IllegalArgumentException 当 {@code headerValue} 为 {@code null} 时。
- */
- static ConfigurableCookieCollection create(HeaderValue headerValue) {
- return new DefaultCookieCollection(headerValue);
- }
}
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/CookieCollection.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/CookieCollection.java
index 5f6eae616..23b07c51b 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/CookieCollection.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/CookieCollection.java
@@ -17,9 +17,10 @@
* @author 季聿阶
* @since 2022-07-06
*/
-public interface CookieCollection extends HeaderValue {
+public interface CookieCollection {
/**
* 获取指定名字的 {@link Cookie}。
+ * 如果存在多个同名 Cookie,返回第一个匹配的 Cookie。
*
* @param name 表示 Cookie 名字的 {@link Optional}{@code <}{@link String}{@code >}。
* @return 表示指定名字的 {@link Cookie}。
@@ -27,7 +28,15 @@ public interface CookieCollection extends HeaderValue {
Optional get(String name);
/**
- * 获取所有的 {@link Cookie}。
+ * 根据名字查找所有匹配的 {@link Cookie}。
+ *
+ * @param name 表示 Cookie 名字的 {@link String}。
+ * @return 返回所有匹配名字的 {@link Cookie} 列表。
+ */
+ List all(String name);
+
+ /**
+ * 获取集合中所有的 {@link Cookie}。
*
* @return 表示所有 {@link Cookie} 列表的 {@link List}{@code <}{@link Cookie}{@code >}。
*/
@@ -39,4 +48,20 @@ public interface CookieCollection extends HeaderValue {
* @return 表示所有 {@link Cookie} 的数量的 {@code int}。
*/
int size();
+
+ /**
+ * 将集合转换为 HTTP 请求头中 Cookie 形式的字符串。
+ * 格式为 {@code name1=value1; name2=value2; ...}。
+ *
+ * @return 表示请求头的字符串。
+ */
+ String toRequestHeaderValue();
+
+ /**
+ * 将集合转换为 HTTP 响应头形式的字符串列表。
+ * 每个 Cookie 对应一个 {@code Set-Cookie: ...} 头。
+ *
+ * @return 表示响应头列表的 {@link List}{@code <}{@link String}{@code >}。
+ */
+ List toResponseHeadersValues();
}
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/server/support/DefaultHttpClassicServerResponse.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/server/support/DefaultHttpClassicServerResponse.java
index 48687b102..ef57de702 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/server/support/DefaultHttpClassicServerResponse.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/server/support/DefaultHttpClassicServerResponse.java
@@ -10,7 +10,7 @@
import static modelengine.fit.http.protocol.MessageHeaderNames.CONNECTION;
import static modelengine.fit.http.protocol.MessageHeaderNames.CONTENT_DISPOSITION;
import static modelengine.fit.http.protocol.MessageHeaderNames.CONTENT_LENGTH;
-import static modelengine.fit.http.protocol.MessageHeaderNames.COOKIE;
+import static modelengine.fit.http.protocol.MessageHeaderNames.SET_COOKIE;
import static modelengine.fit.http.protocol.MessageHeaderNames.TRANSFER_ENCODING;
import static modelengine.fit.http.protocol.MessageHeaderValues.CHUNKED;
import static modelengine.fit.http.protocol.MessageHeaderValues.KEEP_ALIVE;
@@ -255,7 +255,7 @@ protected void commit() {
if (this.isCommitted()) {
return;
}
- this.headers().set(COOKIE, this.cookies().toString());
+ this.headers().set(SET_COOKIE, this.cookies().toResponseHeadersValues());
if (this.entity != null) {
this.setContentTypeByEntity(this.headers(), this.entity);
if (this.entity instanceof FileEntity) {
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicRequest.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicRequest.java
index a7d0c0fc4..ccfc79ba3 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicRequest.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicRequest.java
@@ -6,6 +6,7 @@
package modelengine.fit.http.support;
+import static modelengine.fit.http.protocol.MessageHeaderNames.COOKIE;
import static modelengine.fit.http.protocol.MessageHeaderNames.HOST;
import static modelengine.fitframework.inspection.Validation.notNull;
@@ -16,6 +17,7 @@
import modelengine.fit.http.protocol.MessageHeaders;
import modelengine.fit.http.protocol.QueryCollection;
import modelengine.fit.http.protocol.RequestLine;
+import modelengine.fit.http.util.HttpUtils;
/**
* 表示 {@link HttpClassicRequest} 的抽象实现类。
@@ -24,6 +26,8 @@
* @since 2022-11-23
*/
public abstract class AbstractHttpClassicRequest extends AbstractHttpMessage implements HttpClassicRequest {
+ private static final String COOKIE_DELIMITER = ";";
+
private final RequestLine startLine;
private final MessageHeaders headers;
@@ -38,6 +42,8 @@ public AbstractHttpClassicRequest(HttpResource httpResource, RequestLine startLi
super(httpResource, startLine, headers);
this.startLine = notNull(startLine, "The request line cannot be null.");
this.headers = notNull(headers, "The message headers cannot be null.");
+ String actualCookie = String.join(COOKIE_DELIMITER, this.headers.all(COOKIE));
+ HttpUtils.parseCookies(actualCookie).forEach(this.cookies()::add);
}
@Override
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicResponse.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicResponse.java
index 75c29ec95..7f6794a78 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicResponse.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicResponse.java
@@ -6,6 +6,7 @@
package modelengine.fit.http.support;
+import static modelengine.fit.http.protocol.MessageHeaderNames.SET_COOKIE;
import static modelengine.fitframework.inspection.Validation.notNull;
import modelengine.fit.http.HttpClassicResponse;
@@ -13,6 +14,9 @@
import modelengine.fit.http.protocol.MessageHeaders;
import modelengine.fit.http.protocol.RequestLine;
import modelengine.fit.http.protocol.StatusLine;
+import modelengine.fit.http.util.HttpUtils;
+
+import java.util.List;
/**
* {@link HttpClassicResponse} 的默认实现。
@@ -33,6 +37,9 @@ public abstract class AbstractHttpClassicResponse extends AbstractHttpMessage im
public AbstractHttpClassicResponse(HttpResource httpResource, StatusLine startLine, MessageHeaders headers) {
super(httpResource, startLine, headers);
this.startLine = notNull(startLine, "The status line cannot be null.");
+ notNull(headers, "The headers cannot be null.");
+ List actualCookies = headers.all(SET_COOKIE);
+ actualCookies.stream().map(HttpUtils::parseSetCookie).forEach(this.cookies()::add);
}
@Override
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 631ddd488..9ab1d362b 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
@@ -8,7 +8,6 @@
import static modelengine.fit.http.protocol.MessageHeaderNames.CONTENT_LENGTH;
import static modelengine.fit.http.protocol.MessageHeaderNames.CONTENT_TYPE;
-import static modelengine.fit.http.protocol.MessageHeaderNames.COOKIE;
import static modelengine.fit.http.protocol.MessageHeaderNames.TRANSFER_ENCODING;
import static modelengine.fit.http.protocol.MessageHeaderValues.CHUNKED;
import static modelengine.fitframework.inspection.Validation.notNull;
@@ -47,8 +46,6 @@
* @since 2022-08-03
*/
public abstract class AbstractHttpMessage implements HttpMessage {
- private static final String COOKIE_DELIMITER = ";";
-
private final ParameterCollection parameters =
ParameterCollection.create().set(DefaultContentType.CHARSET, StandardCharsets.UTF_8.name());
private final HttpResource httpResource;
@@ -70,8 +67,7 @@ protected AbstractHttpMessage(HttpResource httpResource, StartLine startLine, Me
this.httpResource = notNull(httpResource, "The http resource cannot be null.");
this.startLine = notNull(startLine, "The start line cannot be null.");
this.headers = notNull(headers, "The message headers cannot be null.");
- String actualCookie = String.join(COOKIE_DELIMITER, this.headers.all(COOKIE));
- this.cookies = ConfigurableCookieCollection.create(HttpUtils.parseHeaderValue(actualCookie));
+ this.cookies = ConfigurableCookieCollection.create();
}
@Override
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/DefaultCookieCollection.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/DefaultCookieCollection.java
index 1df0f9fd6..dee4b65da 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/DefaultCookieCollection.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/DefaultCookieCollection.java
@@ -6,18 +6,22 @@
package modelengine.fit.http.support;
-import static modelengine.fitframework.inspection.Validation.notNull;
+import static modelengine.fit.http.util.HttpUtils.COOKIES_FORMAT_SEPARATOR;
+import static modelengine.fit.http.util.HttpUtils.COOKIE_PAIR_SEPARATOR;
import modelengine.fit.http.Cookie;
import modelengine.fit.http.header.ConfigurableCookieCollection;
import modelengine.fit.http.header.CookieCollection;
-import modelengine.fit.http.header.HeaderValue;
-import modelengine.fit.http.header.support.DefaultHeaderValue;
-import modelengine.fit.http.header.support.DefaultParameterCollection;
+import modelengine.fit.http.util.HttpUtils;
+import modelengine.fitframework.util.CollectionUtils;
import modelengine.fitframework.util.StringUtils;
+import java.util.ArrayList;
import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -27,49 +31,56 @@
* @author 季聿阶
* @since 2022-07-06
*/
-public class DefaultCookieCollection extends DefaultHeaderValue implements ConfigurableCookieCollection {
- /**
- * 初始化 {@link DefaultCookieCollection} 的新实例。
- */
- public DefaultCookieCollection() {
- super(StringUtils.EMPTY, new DefaultParameterCollection());
- }
+public class DefaultCookieCollection implements ConfigurableCookieCollection {
+ private final Map> store = new LinkedHashMap<>();
- /**
- * 使用指定的消息头初始化 {@link DefaultCookieCollection} 的新实例。
- *
- * @param headerValue 表示消息头的 {@link HeaderValue}。
- * @throws IllegalArgumentException 当 {@code headerValue} 为 {@code null} 时。
- */
- public DefaultCookieCollection(HeaderValue headerValue) {
- super(notNull(headerValue, "The header value cannot be null.").value(), headerValue.parameters());
+ @Override
+ public Optional get(String name) {
+ List cookies = this.store.get(name);
+ if (CollectionUtils.isEmpty(cookies)) {
+ return Optional.empty();
+ }
+ return Optional.of(cookies.get(0));
}
@Override
- public Optional get(String name) {
- return this.parameters().get(name).map(value -> Cookie.builder().name(name).value(value).build());
+ public List all(String name) {
+ return this.store.getOrDefault(name, Collections.emptyList());
}
@Override
public List all() {
- return Collections.unmodifiableList(this.parameters()
- .keys()
- .stream()
- .map(this::get)
- .filter(Optional::isPresent)
- .map(Optional::get)
- .collect(Collectors.toList()));
+ return this.store.values().stream().flatMap(List::stream).collect(Collectors.toList());
}
@Override
public int size() {
- return this.parameters().size();
+ return this.store.values().stream().mapToInt(List::size).sum();
}
@Override
public void add(Cookie cookie) {
- if (cookie != null) {
- this.parameters().set(cookie.name(), cookie.value());
+ if (cookie == null || StringUtils.isBlank(cookie.name())) {
+ return;
+ }
+ if (HttpUtils.isInvalidCookiePair(cookie.name(), cookie.value())) {
+ throw new IllegalArgumentException("Invalid cookie: name or value is not allowed.");
}
+ this.store.computeIfAbsent(cookie.name(), k -> new ArrayList<>());
+ List list = this.store.get(cookie.name());
+ list.removeIf(c -> Objects.equals(c.path(), cookie.path()) && Objects.equals(c.domain(), cookie.domain()));
+ list.add(cookie);
+ }
+
+ @Override
+ public String toRequestHeaderValue() {
+ return all().stream()
+ .map(c -> c.name() + COOKIE_PAIR_SEPARATOR + c.value())
+ .collect(Collectors.joining(COOKIES_FORMAT_SEPARATOR));
+ }
+
+ @Override
+ public List toResponseHeadersValues() {
+ return all().stream().map(HttpUtils::formatSetCookie).collect(Collectors.toList());
}
}
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/util/HttpUtils.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/util/HttpUtils.java
index 43fd215d3..4aa4df42e 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/util/HttpUtils.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/util/HttpUtils.java
@@ -6,8 +6,16 @@
package modelengine.fit.http.util;
+import static modelengine.fit.http.protocol.CookieAttributeNames.DOMAIN;
+import static modelengine.fit.http.protocol.CookieAttributeNames.EXPIRES;
+import static modelengine.fit.http.protocol.CookieAttributeNames.HTTP_ONLY;
+import static modelengine.fit.http.protocol.CookieAttributeNames.MAX_AGE;
+import static modelengine.fit.http.protocol.CookieAttributeNames.PATH;
+import static modelengine.fit.http.protocol.CookieAttributeNames.SAME_SITE;
+import static modelengine.fit.http.protocol.CookieAttributeNames.SECURE;
import static modelengine.fitframework.inspection.Validation.notNull;
+import modelengine.fit.http.Cookie;
import modelengine.fit.http.header.HeaderValue;
import modelengine.fit.http.header.ParameterCollection;
import modelengine.fit.http.header.support.DefaultHeaderValue;
@@ -21,9 +29,17 @@
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
+import java.time.Duration;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Locale;
+import java.util.regex.Pattern;
/**
* Http 协议相关的工具类。
@@ -33,8 +49,204 @@
* @since 2022-07-22
*/
public class HttpUtils {
-
private static final char STRING_VALUE_SURROUNDED = '\"';
+ private static final String COOKIES_PARSE_SEPARATOR = ";";
+ public static final String COOKIES_FORMAT_SEPARATOR = COOKIES_PARSE_SEPARATOR + " ";
+ public static final String COOKIE_PAIR_SEPARATOR = "=";
+
+ private static final Pattern TOKEN_PATTERN = Pattern.compile("^[!#$%&'*+\\-.^_`|~0-9a-zA-Z]+$");
+
+ private static final String PATH_KEY = PATH.toLowerCase(Locale.ROOT);
+ private static final String DOMAIN_KEY = DOMAIN.toLowerCase(Locale.ROOT);
+ private static final String MAX_AGE_KEY = MAX_AGE.toLowerCase(Locale.ROOT);
+ private static final String EXPIRES_KEY = EXPIRES.toLowerCase(Locale.ROOT);
+ private static final String SECURE_KEY = SECURE.toLowerCase(Locale.ROOT);
+ private static final String HTTP_ONLY_KEY = HTTP_ONLY.toLowerCase(Locale.ROOT);
+ private static final String SAME_SITE_KEY = SAME_SITE.toLowerCase(Locale.ROOT);
+
+ /**
+ * 将给定的 {@link Cookie} 对象格式化为符合 HTTP 协议的 {@code Set-Cookie} 头部字符串。
+ * 生成结果遵循 RFC 6265 规范,如果 cookie 对象为空,则返回空字符串
+ *
+ * @param cookie 表示待格式化的 {@link Cookie} 对象。
+ * @return 表示生成的 {@code Set-Cookie} 头部字符串的 {@link String}。
+ */
+ public static String formatSetCookie(Cookie cookie) {
+ if (cookie == null || StringUtils.isBlank(cookie.name())) {
+ return StringUtils.EMPTY;
+ }
+
+ StringBuilder sb = new StringBuilder().append(cookie.name())
+ .append(COOKIE_PAIR_SEPARATOR)
+ .append(cookie.value() != null ? cookie.value() : StringUtils.EMPTY);
+
+ if (StringUtils.isNotBlank(cookie.path())) {
+ sb.append(COOKIES_FORMAT_SEPARATOR).append(PATH).append(COOKIE_PAIR_SEPARATOR).append(cookie.path());
+ }
+ if (StringUtils.isNotBlank(cookie.domain())) {
+ sb.append(COOKIES_FORMAT_SEPARATOR).append(DOMAIN).append(COOKIE_PAIR_SEPARATOR).append(cookie.domain());
+ }
+ if (cookie.maxAge() >= 0) {
+ sb.append(COOKIES_FORMAT_SEPARATOR).append(MAX_AGE).append(COOKIE_PAIR_SEPARATOR).append(cookie.maxAge());
+ }
+ if (cookie.secure()) {
+ sb.append(COOKIES_FORMAT_SEPARATOR).append(SECURE);
+ }
+ if (cookie.httpOnly()) {
+ sb.append(COOKIES_FORMAT_SEPARATOR).append(HTTP_ONLY);
+ }
+ if (StringUtils.isNotBlank(cookie.sameSite())) {
+ sb.append(COOKIES_FORMAT_SEPARATOR)
+ .append(SAME_SITE)
+ .append(COOKIE_PAIR_SEPARATOR)
+ .append(cookie.sameSite());
+ }
+ return sb.toString();
+ }
+
+ /**
+ * 从消息头 Set-Cookie 的字符串值中解析 Cookie 的值以及属性。
+ * 若包含 Expires 属性,则会自动换算为 Max-Age。
+ *
+ * @param rawCookie 表示待解析的 Set-Cookie 字符串值的 {@link String}。
+ * @return 表示解析后的 {@link Cookie}。
+ */
+ public static Cookie parseSetCookie(String rawCookie) {
+ if (StringUtils.isBlank(rawCookie)) {
+ return Cookie.builder().build();
+ }
+
+ var parts = rawCookie.split(COOKIES_PARSE_SEPARATOR);
+ var builder = parseCookieNameValue(parts[0]);
+ if (builder == null) {
+ return Cookie.builder().build();
+ }
+
+ parseCookieAttributes(parts, builder);
+ return builder.build();
+ }
+
+ private static int safeParseInt(String val) {
+ try {
+ return Integer.parseInt(val);
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ private static int convertExpiresToMaxAge(String expiresString) {
+ if (StringUtils.isBlank(expiresString)) {
+ return -1;
+ }
+ try {
+ ZonedDateTime expires =
+ ZonedDateTime.parse(expiresString, DateTimeFormatter.RFC_1123_DATE_TIME.withLocale(Locale.US));
+ long seconds = Duration.between(ZonedDateTime.now(ZoneOffset.UTC), expires).getSeconds();
+ if (seconds <= 0) {
+ return 0;
+ }
+ if (seconds > Integer.MAX_VALUE) {
+ return Integer.MAX_VALUE;
+ }
+ return (int) seconds;
+ } catch (DateTimeParseException e) {
+ return -1;
+ }
+ }
+
+ /**
+ * 从 Cookie 头部字符串解析多个 Cookie。
+ * 示例:{@code "a=1; b=2; c=3"} → List[Cookie(a=1), Cookie(b=2), Cookie(c=3)]
+ *
+ * @param rawCookie 表示原始 Cookie 头的字符串的 {@link String}(例如 "a=1; b=2; c=3")。
+ * @return 表示解析得到的 Cookie 列表的 {@link List}{@code <}{@link Cookie}{@code >}。
+ */
+ public static List parseCookies(String rawCookie) {
+ if (StringUtils.isBlank(rawCookie)) {
+ return Collections.emptyList();
+ }
+
+ List cookies = new ArrayList<>();
+ for (String part : rawCookie.split(COOKIES_PARSE_SEPARATOR)) {
+ Cookie.Builder builder = parseCookieNameValue(part.trim());
+ if (builder != null) {
+ cookies.add(builder.build());
+ }
+ }
+ return cookies;
+ }
+
+ private static Cookie.Builder parseCookieNameValue(String part) {
+ String trimmed = part.trim();
+ if (trimmed.isEmpty()) {
+ return null;
+ }
+
+ int eqIndex = trimmed.indexOf(COOKIE_PAIR_SEPARATOR);
+ if (eqIndex <= 0) {
+ return null;
+ }
+
+ String name = trimmed.substring(0, eqIndex).trim();
+ String value = trimmed.substring(eqIndex + 1).trim();
+ if (isValueSurrounded(value)) {
+ value = value.substring(1, value.length() - 1);
+ }
+
+ if (isInvalidCookiePair(name, value)) {
+ return null;
+ }
+ return Cookie.builder().name(name).value(value);
+ }
+
+ private static void parseCookieAttributes(String[] parts, Cookie.Builder builder) {
+ for (int i = 1; i < parts.length; i++) {
+ var part = parts[i].trim();
+ if (part.isEmpty()) {
+ continue;
+ }
+
+ var kv = part.split(COOKIE_PAIR_SEPARATOR, 2);
+ var key = kv[0].trim().toLowerCase(Locale.ROOT);
+ var val = kv.length > 1 ? kv[1].trim() : StringUtils.EMPTY;
+
+ if (PATH_KEY.equals(key)) {
+ builder.path(val);
+ } else if (DOMAIN_KEY.equals(key)) {
+ builder.domain(val);
+ } else if (MAX_AGE_KEY.equals(key)) {
+ builder.maxAge(safeParseInt(val));
+ } else if (EXPIRES_KEY.equals(key)) {
+ builder.maxAge(convertExpiresToMaxAge(val));
+ } else if (SECURE_KEY.equals(key)) {
+ builder.secure(true);
+ } else if (HTTP_ONLY_KEY.equals(key)) {
+ builder.httpOnly(true);
+ } else if (SAME_SITE_KEY.equals(key)) {
+ builder.sameSite(val);
+ }
+ }
+ }
+
+ /**
+ * 验证给定的 Cookie 名称和值是否合法。
+ *
+ * @param name 表示 Cookie 的名称 {@link String}。
+ * @param value 表示 Cookie 的值 {@link String},允许为空但不允许为 {@code null},可带双引号。
+ * @return 如果 name 和 value 都合法返回 {@code true},否则返回 {@code false}。
+ */
+ public static boolean isInvalidCookiePair(String name, String value) {
+ if (StringUtils.isEmpty(name) || !TOKEN_PATTERN.matcher(name).matches()) {
+ return true;
+ }
+ if (value == null) {
+ return true;
+ }
+ if (isValueSurrounded(value)) {
+ value = value.substring(1, value.length() - 1);
+ }
+ return !value.isEmpty() && !TOKEN_PATTERN.matcher(value).matches();
+ }
/**
* 从消息头的字符串值中解析消息头的值。
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/header/ConfigurableCookieCollectionTest.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/header/ConfigurableCookieCollectionTest.java
index 945cf51ea..c5219fe5c 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/header/ConfigurableCookieCollectionTest.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/header/ConfigurableCookieCollectionTest.java
@@ -24,24 +24,12 @@
*/
@DisplayName("测试 ConfigurableCookieCollection 类")
class ConfigurableCookieCollectionTest {
- @Test
- @DisplayName("当构建 Cookie 集合的参数为 null 时,抛出异常")
- void givenNullThenThrowException() {
- assertThatThrownBy(this::buildNullCookieCollection).isInstanceOf(IllegalArgumentException.class);
- }
-
- private void buildNullCookieCollection() {
- ConfigurableCookieCollection.create(null);
- }
-
@Test
@DisplayName("返回所有的 Cookie")
void shouldReturnAllCookie() {
final Cookie cookie = Cookie.builder()
.name("idea")
.value("00ae-u98i")
- .version(1)
- .comment("")
.domain("localhost")
.maxAge(10)
.path("/")
@@ -53,4 +41,83 @@ void shouldReturnAllCookie() {
final List cookies = cookieCollection.all();
assertThat(cookies).hasSize(1);
}
+
+ @Test
+ @DisplayName("添加非法 Cookie 应抛异常")
+ void shouldThrowExceptionForInvalidCookie() {
+ ConfigurableCookieCollection collection = ConfigurableCookieCollection.create();
+
+ Cookie invalidNameCookie = Cookie.builder().name("inva;lid").value("123").build();
+ assertThatThrownBy(() -> collection.add(invalidNameCookie)).isInstanceOf(IllegalArgumentException.class);
+
+ Cookie invalidValueCookie = Cookie.builder().name("validName").value("v@lue;").build();
+ assertThatThrownBy(() -> collection.add(invalidValueCookie)).isInstanceOf(IllegalArgumentException.class);
+
+ Cookie nullValueCookie = Cookie.builder().name("someName").value(null).build();
+ assertThatThrownBy(() -> collection.add(nullValueCookie)).isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ @DisplayName("允许空字符串 value")
+ void shouldHandleEmptyAndNullValue() {
+ ConfigurableCookieCollection collection = ConfigurableCookieCollection.create();
+
+ // 空字符串 value 是允许的
+ Cookie emptyValueCookie = Cookie.builder().name("token").value("").build();
+ collection.add(emptyValueCookie);
+ assertThat(collection.get("token")).isPresent().get().extracting(Cookie::value).isEqualTo("");
+ }
+
+ @Test
+ @DisplayName("同名 Cookie 不同路径可共存")
+ void shouldAllowMultipleCookiesWithDifferentPath() {
+ ConfigurableCookieCollection collection = ConfigurableCookieCollection.create();
+ collection.add(Cookie.builder().name("user").value("A").path("/a").build());
+ collection.add(Cookie.builder().name("user").value("B").path("/b").build());
+
+ List sameNameCookies = collection.all("user");
+ assertThat(sameNameCookies).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("同名同 path/domain 的 Cookie 应被替换")
+ void shouldReplaceCookieWithSamePathAndDomain() {
+ ConfigurableCookieCollection collection = ConfigurableCookieCollection.create();
+
+ Cookie c1 = Cookie.builder().name("id").value("1").path("/").domain("a.com").build();
+ Cookie c2 = Cookie.builder().name("id").value("2").path("/").domain("a.com").build();
+
+ collection.add(c1);
+ collection.add(c2);
+
+ List cookies = collection.all("id");
+ assertThat(cookies).hasSize(1);
+ assertThat(cookies.get(0).value()).isEqualTo("2");
+ }
+
+ @Test
+ @DisplayName("toRequestHeader 生成单行请求头")
+ void shouldGenerateRequestHeader() {
+ ConfigurableCookieCollection collection = ConfigurableCookieCollection.create();
+ collection.add(Cookie.builder().name("a").value("1").build());
+ collection.add(Cookie.builder().name("b").value("2").build());
+
+ String header = collection.toRequestHeaderValue();
+ assertThat(header).isEqualTo("a=1; b=2");
+ }
+
+ @Test
+ @DisplayName("toResponseHeaders 生成多个 Set-Cookie 响应头")
+ void shouldGenerateMultipleResponseHeaders() {
+ ConfigurableCookieCollection collection = ConfigurableCookieCollection.create();
+ collection.add(Cookie.builder().name("token").value("xyz").secure(true).httpOnly(true).build());
+ collection.add(Cookie.builder().name("lang").value("zh-CN").sameSite("Lax").build());
+
+ List headers = collection.toResponseHeadersValues();
+
+ assertThat(headers).hasSize(2);
+ assertThat(headers.get(0)).contains("token=xyz");
+ assertThat(headers.get(1)).contains("lang=zh-CN");
+ assertThat(headers.get(1)).contains("SameSite=Lax");
+ }
}
diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/util/HttpUtilsTest.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/util/HttpUtilsTest.java
index f0ae44a91..3641ac7fe 100644
--- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/util/HttpUtilsTest.java
+++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/util/HttpUtilsTest.java
@@ -9,6 +9,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
+import modelengine.fit.http.Cookie;
import modelengine.fit.http.header.HeaderValue;
import modelengine.fitframework.util.StringUtils;
@@ -17,6 +18,10 @@
import java.net.MalformedURLException;
import java.net.URL;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
/**
* 为 {@link HttpUtils} 提供单元测试。
@@ -26,6 +31,114 @@
*/
@DisplayName("测试 HttpUtils 工具类")
public class HttpUtilsTest {
+ @Test
+ @DisplayName("格式化完整 Set-Cookie,返回正确格式化字符串")
+ void givenFullCookie_thenIncludeAllAttributes() {
+ Cookie cookie = Cookie.builder()
+ .name("token")
+ .value("abc")
+ .path("/api")
+ .domain("example.com")
+ .maxAge(3600)
+ .secure(true)
+ .httpOnly(true)
+ .sameSite("Lax")
+ .build();
+
+ String result = HttpUtils.formatSetCookie(cookie);
+ assertThat(result).contains("token=abc")
+ .contains("Path=/api")
+ .contains("Domain=example.com")
+ .contains("Max-Age=3600")
+ .contains("Secure")
+ .contains("HttpOnly")
+ .contains("SameSite=Lax");
+ }
+
+ @Test
+ @DisplayName("格式化空的 Set-Cookie,返回空字符串")
+ void givenNullCookie_thenReturnEmptyString() {
+ assertThat(HttpUtils.formatSetCookie(null)).isEmpty();
+ }
+
+ @Test
+ @DisplayName("解析合法的 Set-Cookie 值,返回正确的 Cookie 对象")
+ void givenValidSetCookieStringThenParseSuccessfully() {
+ String rawCookie = "ID=ab12xy; Path=/; Domain=example.com; Max-Age=3600; Secure; SameSite=Strict";
+ Cookie cookie = HttpUtils.parseSetCookie(rawCookie);
+
+ assertThat(cookie.name()).isEqualTo("ID");
+ assertThat(cookie.value()).isEqualTo("ab12xy");
+ assertThat(cookie.path()).isEqualTo("/");
+ assertThat(cookie.domain()).isEqualTo("example.com");
+ assertThat(cookie.maxAge()).isEqualTo(3600);
+ assertThat(cookie.secure()).isTrue();
+ assertThat(cookie.httpOnly()).isFalse();
+ assertThat(cookie.sameSite()).isEqualTo("Strict");
+ }
+
+ @Test
+ @DisplayName("给定空的 Set-Cookie 值,返回空的 Cookie 对象")
+ void givenEmptySetCookieThenReturnEmptyCookie() {
+ Cookie cookie = HttpUtils.parseSetCookie("");
+ assertThat(cookie.name()).isNull();
+ assertThat(cookie.value()).isNull();
+ }
+
+ @Test
+ @DisplayName("解析异常或不完整的 Cookie 值时,应忽略非法项并不抛异常")
+ void givenMalformedCookiesThenHandleGracefully() {
+ String rawCookie1 = "a=\"incomplete; b=2";
+ List cookies1 = HttpUtils.parseCookies(rawCookie1);
+ assertThat(cookies1).extracting(Cookie::name).contains("b");
+ assertThat(cookies1).extracting(Cookie::name).doesNotContain("a");
+
+ String rawCookie2 = "x=1;; ; y=2;";
+ List cookies2 = HttpUtils.parseCookies(rawCookie2);
+ assertThat(cookies2).hasSize(2);
+ assertThat(cookies2.get(0).name()).isEqualTo("x");
+ assertThat(cookies2.get(1).name()).isEqualTo("y");
+
+ String rawCookie4 = ";;;";
+ List cookies4 = HttpUtils.parseCookies(rawCookie4);
+ assertThat(cookies4).isEmpty();
+ }
+
+ @Test
+ @DisplayName("解析带 Expires 属性的 Set-Cookie,自动换算为 Max-Age")
+ void givenExpiresAttributeThenConvertToMaxAge() {
+ ZonedDateTime expiresTime = ZonedDateTime.now(ZoneOffset.UTC).plusHours(1);
+ String expiresStr = expiresTime.format(DateTimeFormatter.RFC_1123_DATE_TIME);
+ String rawCookie = "ID=xyz; Expires=" + expiresStr;
+
+ Cookie cookie = HttpUtils.parseSetCookie(rawCookie);
+ assertThat(cookie.maxAge()).isBetween(3500, 3700);
+ }
+
+ @Test
+ @DisplayName("解析多个 Cookie 头部值,返回正确的 Cookie 列表")
+ void givenMultipleCookiesThenReturnCookieList() {
+ String rawCookie = "a=1; b=2; c=3";
+ List cookies = HttpUtils.parseCookies(rawCookie);
+
+ assertThat(cookies).hasSize(3);
+ assertThat(cookies.get(0).name()).isEqualTo("a");
+ assertThat(cookies.get(0).value()).isEqualTo("1");
+ assertThat(cookies.get(1).name()).isEqualTo("b");
+ assertThat(cookies.get(2).value()).isEqualTo("3");
+ }
+
+ @Test
+ @DisplayName("解析非法格式的 Cookie 值,自动跳过无效项")
+ void givenInvalidCookieStringThenSkipInvalidPairs() {
+ String rawCookie = "a=1; invalid; b=2";
+ List cookies = HttpUtils.parseCookies(rawCookie);
+
+ assertThat(cookies).hasSize(2);
+ assertThat(cookies.get(0).name()).isEqualTo("a");
+ assertThat(cookies.get(1).name()).isEqualTo("b");
+ }
+
@Test
@DisplayName("给定空的值,解析消息头的值返回为空")
void givenEmptyValueThenReturnHeaderValueISEmpty() {
diff --git a/framework/fit/java/fit-builtin/services/fit-http-protocol/definition/src/main/java/modelengine/fit/http/protocol/CookieAttributeNames.java b/framework/fit/java/fit-builtin/services/fit-http-protocol/definition/src/main/java/modelengine/fit/http/protocol/CookieAttributeNames.java
new file mode 100644
index 000000000..634bdca49
--- /dev/null
+++ b/framework/fit/java/fit-builtin/services/fit-http-protocol/definition/src/main/java/modelengine/fit/http/protocol/CookieAttributeNames.java
@@ -0,0 +1,37 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved.
+ * This file is a part of the ModelEngine Project.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+package modelengine.fit.http.protocol;
+
+/**
+ * RFC 6265 等规范中定义的 Set-Cookie 属性名称常量。
+ * RFC 6265 列出了很多属性名称的定义来源。
+ *
+ * @author 徐吴昊
+ * @since 2025-09-24
+ */
+public class CookieAttributeNames {
+ /** @see RFC 6265 */
+ public static final String EXPIRES = "Expires";
+
+ /** @see RFC 6265 */
+ public static final String MAX_AGE = "Max-Age";
+
+ /** @see RFC 6265 */
+ public static final String DOMAIN = "Domain";
+
+ /** @see RFC 6265 */
+ public static final String PATH = "Path";
+
+ /** @see RFC 6265 */
+ public static final String SECURE = "Secure";
+
+ /** @see RFC 6265 */
+ public static final String HTTP_ONLY = "HttpOnly";
+
+ /** @see RFC 6265bis */
+ public static final String SAME_SITE = "SameSite";
+}