From 1c039ff6552933d8ff5bf85e26e7d7e2b3f2e66a Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Fri, 22 Aug 2025 19:48:42 +0200 Subject: [PATCH] =?UTF-8?q?HTTP/2:=20implement=20RFC=209218=20core=20piece?= =?UTF-8?q?s.=20Advertise=20SETTINGS=5FNO=5FRFC7540=5FPRIORITIES=3D1=20and?= =?UTF-8?q?=20honor=20peer=E2=80=99s=20setting=20when=20emitting=20PRIORIT?= =?UTF-8?q?Y=5FUPDATE.=20Add=20parsing=20for=20PRIORITY=5FUPDATE=20(empty?= =?UTF-8?q?=20PFV=20allowed)=20and=20expose=20onPriorityUpdateFrame=20hook?= =?UTF-8?q?.=20Track=20serverSettingsSeen/remoteNoH2Priorities;=20reject?= =?UTF-8?q?=20invalid=20SETTINGS=20and=20gate=20priority=20updates.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apache/hc/core5/http2/config/H2Param.java | 24 +- .../hc/core5/http2/frame/FrameType.java | 28 +- .../hc/core5/http2/impl/H2Processors.java | 2 + .../impl/nio/AbstractH2StreamMultiplexer.java | 122 ++++- .../impl/nio/ClientH2StreamMultiplexer.java | 16 +- .../http2/protocol/H2RequestPriority.java | 119 +++++ .../examples/ClassicH2PriorityExample.java | 169 +++++++ .../hc/core5/http2/frame/FrameTypeTest.java | 47 ++ .../nio/TestAbstractH2StreamMultiplexer.java | 423 +++++++++++++++++- .../http2/protocol/H2RequestPriorityTest.java | 174 +++++++ .../org/apache/hc/core5/http/HttpHeaders.java | 5 + .../http/priority/PriorityFormatter.java | 62 +++ .../hc/core5/http/priority/PriorityMerge.java | 51 +++ .../core5/http/priority/PriorityParams.java | 63 +++ .../http/priority/PriorityParamsParser.java | 150 +++++++ .../core5/http/priority/PriorityParser.java | 172 +++++++ .../hc/core5/http/priority/PriorityValue.java | 100 +++++ .../PriorityParserAndFormatterTest.java | 175 ++++++++ .../http/priority/TestPriorityMerge.java | 115 +++++ .../priority/TestPriorityParamsParser.java | 66 +++ .../http/priority/TestPriorityValue.java | 136 ++++++ 21 files changed, 2189 insertions(+), 30 deletions(-) create mode 100644 httpcore5-h2/src/main/java/org/apache/hc/core5/http2/protocol/H2RequestPriority.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/examples/ClassicH2PriorityExample.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/frame/FrameTypeTest.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/protocol/H2RequestPriorityTest.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityFormatter.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityMerge.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityParams.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityParamsParser.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityParser.java create mode 100644 httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityValue.java create mode 100644 httpcore5/src/test/java/org/apache/hc/core5/http/priority/PriorityParserAndFormatterTest.java create mode 100644 httpcore5/src/test/java/org/apache/hc/core5/http/priority/TestPriorityMerge.java create mode 100644 httpcore5/src/test/java/org/apache/hc/core5/http/priority/TestPriorityParamsParser.java create mode 100644 httpcore5/src/test/java/org/apache/hc/core5/http/priority/TestPriorityValue.java diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/config/H2Param.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/config/H2Param.java index 7d4455e22f..5062af7353 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/config/H2Param.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/config/H2Param.java @@ -38,7 +38,8 @@ public enum H2Param { MAX_CONCURRENT_STREAMS(0x3), INITIAL_WINDOW_SIZE(0x4), MAX_FRAME_SIZE(0x5), - MAX_HEADER_LIST_SIZE(0x6); + MAX_HEADER_LIST_SIZE(0x6), + SETTINGS_NO_RFC7540_PRIORITIES (0x9); int code; @@ -50,25 +51,32 @@ public int getCode() { return code; } - private static final H2Param[] LOOKUP_TABLE = new H2Param[6]; + private static final H2Param[] LOOKUP_TABLE; static { - for (final H2Param param: H2Param.values()) { - LOOKUP_TABLE[param.code - 1] = param; + int max = 0; + for (final H2Param p : H2Param.values()) { + if (p.code > max) { + max = p.code; + } + } + LOOKUP_TABLE = new H2Param[max + 1]; + for (final H2Param p : H2Param.values()) { + LOOKUP_TABLE[p.code] = p; } } public static H2Param valueOf(final int code) { - if (code < 1 || code > LOOKUP_TABLE.length) { + if (code < 0 || code >= LOOKUP_TABLE.length) { return null; } - return LOOKUP_TABLE[code - 1]; + return LOOKUP_TABLE[code]; } public static String toString(final int code) { - if (code < 1 || code > LOOKUP_TABLE.length) { + if (code < 0 || code >= LOOKUP_TABLE.length || LOOKUP_TABLE[code] == null) { return Integer.toString(code); } - return LOOKUP_TABLE[code - 1].name(); + return LOOKUP_TABLE[code].name(); } } diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameType.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameType.java index 483af68a6c..fb6c6c1a66 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameType.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameType.java @@ -42,9 +42,10 @@ public enum FrameType { PING(0x06), GOAWAY(0x07), WINDOW_UPDATE(0x08), - CONTINUATION(0x09); + CONTINUATION(0x09), + PRIORITY_UPDATE(0x10); // 16 - int value; + final int value; FrameType(final int value) { this.value = value; @@ -54,10 +55,17 @@ public int getValue() { return value; } - private static final FrameType[] LOOKUP_TABLE = new FrameType[10]; + private static final FrameType[] LOOKUP_TABLE; static { - for (final FrameType frameType: FrameType.values()) { - LOOKUP_TABLE[frameType.value] = frameType; + int max = -1; + for (final FrameType t : FrameType.values()) { + if (t.value > max) { + max = t.value; + } + } + LOOKUP_TABLE = new FrameType[max + 1]; + for (final FrameType t : FrameType.values()) { + LOOKUP_TABLE[t.value] = t; } } @@ -65,14 +73,20 @@ public static FrameType valueOf(final int value) { if (value < 0 || value >= LOOKUP_TABLE.length) { return null; } - return LOOKUP_TABLE[value]; + return LOOKUP_TABLE[value]; // may be null for gaps (e.g., 0x0A..0x0F) } public static String toString(final int value) { if (value < 0 || value >= LOOKUP_TABLE.length) { return Integer.toString(value); } - return LOOKUP_TABLE[value].name(); + final FrameType t = LOOKUP_TABLE[value]; + return t != null ? t.name() : Integer.toString(value); } + /** Convenience: compare this enum to a raw frame type byte. */ + public boolean same(final int rawType) { + return this.value == rawType; + } } + diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/H2Processors.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/H2Processors.java index b6cf55270d..7c84d379da 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/H2Processors.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/H2Processors.java @@ -38,6 +38,7 @@ import org.apache.hc.core5.http2.protocol.H2RequestConformance; import org.apache.hc.core5.http2.protocol.H2RequestConnControl; import org.apache.hc.core5.http2.protocol.H2RequestContent; +import org.apache.hc.core5.http2.protocol.H2RequestPriority; import org.apache.hc.core5.http2.protocol.H2RequestTargetHost; import org.apache.hc.core5.http2.protocol.H2RequestValidateHost; import org.apache.hc.core5.http2.protocol.H2ResponseConformance; @@ -86,6 +87,7 @@ public static HttpProcessorBuilder customClient(final String agentInfo) { H2RequestTargetHost.INSTANCE, H2RequestContent.INSTANCE, H2RequestConnControl.INSTANCE, + H2RequestPriority.INSTANCE, new RequestUserAgent(!TextUtils.isBlank(agentInfo) ? agentInfo : VersionInfo.getSoftwareInfo(SOFTWARE, "org.apache.hc.core5", HttpProcessors.class)), RequestExpectContinue.INSTANCE); diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java index 6553c034af..8541a5b05b 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java @@ -32,6 +32,7 @@ import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; import java.nio.charset.CharacterCodingException; +import java.nio.charset.StandardCharsets; import java.util.Deque; import java.util.Iterator; import java.util.List; @@ -137,6 +138,10 @@ enum SettingsHandshake { READY, TRANSMITTED, ACKED } private EndpointDetails endpointDetails; private boolean goAwayReceived; + // RFC 9218 gating state + private volatile boolean serverSettingsSeen; + private volatile boolean remoteNoH2Priorities; + AbstractH2StreamMultiplexer( final ProtocolIOSession ioSession, final FrameFactory frameFactory, @@ -173,6 +178,9 @@ enum SettingsHandshake { READY, TRANSMITTED, ACKED } this.lowMark = H2Config.INIT.getInitialWindowSize() / 2; this.streamListener = streamListener; + + this.serverSettingsSeen = false; + this.remoteNoH2Priorities = false; } @Override @@ -188,7 +196,6 @@ public String getId() { abstract H2StreamHandler createRemotelyInitiatedStream( H2StreamChannel channel, - HttpProcessor httpProcessor, BasicHttpConnectionMetrics connMetrics, HandlerFactory pushHandlerFactory) throws IOException; @@ -261,6 +268,11 @@ private void commitFrame(final RawFrame frame) throws IOException { } } + protected final void writeExtensionFrame(final int type, final int flags, final int streamId, final ByteBuffer payload) + throws IOException { + commitFrame(new RawFrame(type, flags, streamId, payload)); + } + private void commitHeaders( final int streamId, final List headers, final boolean endStream) throws IOException { if (streamListener != null) { @@ -418,8 +430,9 @@ public final void onConnect() throws HttpException, IOException { new H2Setting(H2Param.MAX_CONCURRENT_STREAMS, localConfig.getMaxConcurrentStreams()), new H2Setting(H2Param.INITIAL_WINDOW_SIZE, localConfig.getInitialWindowSize()), new H2Setting(H2Param.MAX_FRAME_SIZE, localConfig.getMaxFrameSize()), - new H2Setting(H2Param.MAX_HEADER_LIST_SIZE, localConfig.getMaxHeaderListSize())); - + new H2Setting(H2Param.MAX_HEADER_LIST_SIZE, localConfig.getMaxHeaderListSize()), + // RFC 9218 MUST: advertise intent to ignore RFC 7540 priorities in the first SETTINGS + new H2Setting(H2Param.SETTINGS_NO_RFC7540_PRIORITIES, 1)); commitFrame(settingsFrame); localSettingState = SettingsHandshake.TRANSMITTED; maximizeWindow(0, connInputWindow); @@ -547,10 +560,10 @@ public final void onTimeout(final Timeout timeout) throws HttpException, IOExcep final RawFrame goAway; if (localSettingState != SettingsHandshake.ACKED) { goAway = frameFactory.createGoAway(processedRemoteStreamId, H2Error.SETTINGS_TIMEOUT, - "Setting timeout (" + timeout + ")"); + "Setting timeout (" + timeout + ")"); } else { goAway = frameFactory.createGoAway(processedRemoteStreamId, H2Error.NO_ERROR, - "Timeout due to inactivity (" + timeout + ")"); + "Timeout due to inactivity (" + timeout + ")"); } commitFrame(goAway); for (final Iterator> it = streamMap.entrySet().iterator(); it.hasNext(); ) { @@ -918,10 +931,10 @@ private void consumeFrame(final RawFrame frame) throws HttpException, IOExceptio if ((payload.remaining() % 6) != 0) { throw new H2ConnectionException(H2Error.FRAME_SIZE_ERROR, "Invalid SETTINGS payload"); } - consumeSettingsFrame(payload); - remoteSettingState = SettingsHandshake.TRANSMITTED; + consumeSettingsFrame(payload); // inside: set remoteNoH2Priorities if NO_RFC7540=1 seen } - // Send ACK + serverSettingsSeen = true; + remoteSettingState = SettingsHandshake.TRANSMITTED; final RawFrame response = frameFactory.createSettingsAck(); commitFrame(response); remoteSettingState = SettingsHandshake.ACKED; @@ -1021,9 +1034,21 @@ private void consumeFrame(final RawFrame frame) throws HttpException, IOExceptio } ioSession.setEvent(SelectionKey.OP_WRITE); break; + case PRIORITY_UPDATE: { + onPriorityUpdateFrame(frame); + } + break; + default: + break; } } + protected void onPriorityUpdateFrame(final RawFrame frame) throws H2ConnectionException { + parsePriorityUpdatePayload(frame); // allows empty PFV + // At this layer we don't need to parse the dictionary; unknown/ext params are fine. + // Apply 'u.priorityFieldValue' to your local scheduler as needed; no errors for empty. + } + private void consumeDataFrame(final RawFrame frame, final H2Stream stream) throws HttpException, IOException { if (stream.isRemoteClosed()) { throw new H2StreamResetException(H2Error.STREAM_CLOSED, "Stream already closed"); @@ -1093,7 +1118,6 @@ private void consumeHeaderFrame(final RawFrame frame, final H2Stream stream) thr } final ByteBuffer payload = frame.getPayloadContent(); if (frame.isFlagSet(FrameFlag.PRIORITY)) { - // Priority not supported payload.getInt(); payload.get(); } @@ -1193,9 +1217,21 @@ private void consumeSettingsFrame(final ByteBuffer payload) throws IOException { throw new H2ConnectionException(H2Error.PROTOCOL_ERROR, ex.getMessage()); } break; + case SETTINGS_NO_RFC7540_PRIORITIES: + if (value != 0 && value != 1) { + throw new H2ConnectionException(H2Error.PROTOCOL_ERROR, "Invalid value for SETTINGS_NO_RFC7540_PRIORITIES"); + } + if (serverSettingsSeen && remoteNoH2Priorities != (value == 1)) { + throw new H2ConnectionException(H2Error.PROTOCOL_ERROR, "SETTINGS_NO_RFC7540_PRIORITIES changed"); + } + remoteNoH2Priorities = value == 1; + break; } } } + if (!serverSettingsSeen) { + serverSettingsSeen = true; + } applyRemoteSettings(configBuilder.build()); } @@ -1336,6 +1372,27 @@ void appendState(final StringBuilder buf) { .append(", processedRemoteStreamId=").append(processedRemoteStreamId); } + private boolean isPriorityUpdateAllowed() { + // pre-SETTINGS: allowed; post-SETTINGS: only if peer said "1" + return !serverSettingsSeen || remoteNoH2Priorities; + } + + private void sendPriorityUpdateInternal(final int prioritizedStreamId, + final String priorityFieldValue) throws IOException { + if (priorityFieldValue == null) { + return; + } + if (!isPriorityUpdateAllowed()) { + return; // suppressed per RFC 9218 §2.1.1 + } + final byte[] ascii = priorityFieldValue.getBytes(StandardCharsets.US_ASCII); + final ByteBuffer payload = ByteBuffer.allocate(4 + ascii.length); + payload.putInt(prioritizedStreamId & 0x7FFFFFFF); + payload.put(ascii); + payload.flip(); + writeExtensionFrame(FrameType.PRIORITY_UPDATE.getValue(), 0, 0, payload); + } + private static class Continuation { final int streamId; @@ -1418,6 +1475,12 @@ public void submit(final List
headers, final boolean endStream) throws I if (localEndStream) { return; } + for (final Header h : headers) { + if ("priority".equalsIgnoreCase(h.getName())) { + sendPriorityUpdateInternal(id, h.getValue()); + break; + } + } idle = false; commitHeaders(id, headers, endStream); if (endStream) { @@ -1746,4 +1809,43 @@ public String toString() { } -} + /** + * RFC 9218 parsing helper for PRIORITY_UPDATE. + * - Validates streamId == 0 (HTTP/2 control stream) + * - Enforces payload length >= 4 (31-bit stream ID); allows exactly 4 (empty Priority Field Value) + * - Returns ASCII Priority Field Value as-is (unknown/ext parameters preserved). + * + * Base class does NOT call this by default; a server-side subclass can call it from + * {@link #onPriorityUpdateFrame(RawFrame)} to accept client PRIORITY_UPDATE frames. + */ + protected final PrioritizedUpdate parsePriorityUpdatePayload(final RawFrame frame) throws H2ConnectionException { + if (frame.getStreamId() != 0) { + throw new H2ConnectionException(H2Error.PROTOCOL_ERROR, "PRIORITY_UPDATE must use stream 0"); + } + final ByteBuffer payload = frame.getPayloadContent(); + if (payload == null || payload.remaining() < 4) { + throw new H2ConnectionException(H2Error.FRAME_SIZE_ERROR, "Invalid PRIORITY_UPDATE payload"); + } + final int prioritizedId = payload.getInt() & 0x7FFF_FFFF; + + // Allow empty Priority Field Value (payload length exactly 4) => "all defaults" + final String fieldValue; + if (payload.hasRemaining()) { + // Preserve as-is (unknown/ext params are ignored by us but kept intact) + fieldValue = StandardCharsets.US_ASCII.decode(payload.slice()).toString(); + } else { + fieldValue = ""; + } + return new PrioritizedUpdate(prioritizedId, fieldValue); + } + + protected static final class PrioritizedUpdate { + public final int prioritizedStreamId; + public final String priorityFieldValue; // may be empty => "all defaults" + PrioritizedUpdate(final int prioritizedStreamId, final String priorityFieldValue) { + this.prioritizedStreamId = prioritizedStreamId; + this.priorityFieldValue = priorityFieldValue != null ? priorityFieldValue : ""; + } + } + +} \ No newline at end of file diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamMultiplexer.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamMultiplexer.java index b7850090b9..e91166d2d4 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamMultiplexer.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamMultiplexer.java @@ -43,6 +43,7 @@ import org.apache.hc.core5.http2.config.H2Config; import org.apache.hc.core5.http2.frame.DefaultFrameFactory; import org.apache.hc.core5.http2.frame.FrameFactory; +import org.apache.hc.core5.http2.frame.RawFrame; import org.apache.hc.core5.http2.frame.StreamIdGenerator; import org.apache.hc.core5.reactor.ProtocolIOSession; @@ -51,6 +52,8 @@ * client side HTTP/2 messaging protocol with full support for * multiplexed message transmission. * + * Enforces RFC 9218 §7.1: clients MUST treat inbound PRIORITY_UPDATE as a connection error. + * * @since 5.0 */ @Internal @@ -94,6 +97,7 @@ void acceptHeaderFrame() throws H2ConnectionException { @Override void acceptPushFrame() throws H2ConnectionException { + // Allowed; server may send push streams if enabled. } @Override @@ -135,6 +139,17 @@ H2StreamHandler createRemotelyInitiatedStream( context); } + /** + * RFC 9218 §7.1: clients MUST treat inbound PRIORITY_UPDATE as a connection error. + * @since 5.4 + */ + @Override + protected void onPriorityUpdateFrame(final RawFrame frame) throws H2ConnectionException { + throw new H2ConnectionException( + H2Error.PROTOCOL_ERROR, + "Inbound PRIORITY_UPDATE is not permitted on client connections"); + } + @Override public String toString() { final StringBuilder buf = new StringBuilder(); @@ -145,4 +160,3 @@ public String toString() { } } - diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/protocol/H2RequestPriority.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/protocol/H2RequestPriority.java new file mode 100644 index 0000000000..4a5dd4b9de --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/protocol/H2RequestPriority.java @@ -0,0 +1,119 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.protocol; + +import java.io.IOException; + +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.HttpVersion; +import org.apache.hc.core5.http.ProtocolVersion; +import org.apache.hc.core5.http.priority.PriorityFormatter; +import org.apache.hc.core5.http.priority.PriorityValue; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.util.Args; + +/** + * Emits RFC 9218 {@code Priority} request header for HTTP/2+. + *

+ * The priority value is taken from the request context attribute + * {@link #ATTR_HTTP2_PRIORITY_VALUE}. If the formatted value equals + * RFC defaults (u=3, i=false) the header is omitted. + *

+ * If {@code overwrite} is {@code false} (default), an existing {@code Priority} + * header set by the caller is preserved. + * + * @since 5.4 + */ +@Contract(threading = ThreadingBehavior.IMMUTABLE) +public final class H2RequestPriority implements HttpRequestInterceptor { + + /** + * Context attribute to carry a {@link PriorityValue}. + */ + public static final String ATTR_HTTP2_PRIORITY_VALUE = "http2.priority.value"; + + /** + * Singleton with {@code overwrite=false}. + */ + public static final H2RequestPriority INSTANCE = new H2RequestPriority(false); + + private final boolean overwrite; + + public H2RequestPriority() { + this(false); + } + + public H2RequestPriority(final boolean overwrite) { + this.overwrite = overwrite; + } + + @Override + public void process(final HttpRequest request, final EntityDetails entity, + final HttpContext context) throws HttpException, IOException { + + Args.notNull(request, "HTTP request"); + Args.notNull(context, "HTTP context"); + + final ProtocolVersion ver = HttpCoreContext.cast(context).getProtocolVersion(); + if (ver == null || ver.compareToVersion(HttpVersion.HTTP_2) < 0) { + return; // only for HTTP/2+ + } + + final Header existing = request.getFirstHeader(HttpHeaders.PRIORITY); + if (existing != null && !overwrite) { + return; // respect caller-set header + } + + final PriorityValue pv = HttpCoreContext.cast(context) + .getAttribute(ATTR_HTTP2_PRIORITY_VALUE, PriorityValue.class); + if (pv == null) { + return; + } + + final String value = PriorityFormatter.format(pv); + if (value == null) { + // defaults (u=3, i=false) -> omit header + if (overwrite && existing != null) { + request.removeHeaders(HttpHeaders.PRIORITY); + } + return; + } + + if (overwrite && existing != null) { + request.removeHeaders(HttpHeaders.PRIORITY); + } + request.addHeader(HttpHeaders.PRIORITY, value); + } +} diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/examples/ClassicH2PriorityExample.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/examples/ClassicH2PriorityExample.java new file mode 100644 index 0000000000..dc1e1ba92e --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/examples/ClassicH2PriorityExample.java @@ -0,0 +1,169 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.examples; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.Future; + +import org.apache.hc.core5.annotation.Experimental; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpConnection; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester; +import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; +import org.apache.hc.core5.http.nio.AsyncClientEndpoint; +import org.apache.hc.core5.http.nio.support.classic.ClassicToAsyncRequestProducer; +import org.apache.hc.core5.http.nio.support.classic.ClassicToAsyncResponseConsumer; +import org.apache.hc.core5.http.priority.PriorityValue; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.http2.HttpVersionPolicy; +import org.apache.hc.core5.http2.config.H2Config; +import org.apache.hc.core5.http2.frame.RawFrame; +import org.apache.hc.core5.http2.impl.H2Processors; +import org.apache.hc.core5.http2.impl.nio.H2StreamListener; +import org.apache.hc.core5.http2.protocol.H2RequestPriority; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.util.Timeout; + +/** + * Example: HTTP/2 request that sets RFC 9218 Priority via context and emits the "Priority" header. + *

+ * Requires H2Processors to include H2RequestPriority (client chain) and an HTTP/2 connection. + */ +@Experimental +public class ClassicH2PriorityExample { + + public static void main(final String[] args) throws Exception { + + // Force HTTP/2 and disable push for a cleaner demo + final H2Config h2Config = H2Config.custom() + .setPushEnabled(false) + .build(); + + // Ensure the client processor chain has H2RequestPriority inside (see H2Processors.customClient) + final HttpAsyncRequester requester = org.apache.hc.core5.http2.impl.nio.bootstrap.H2RequesterBootstrap.bootstrap() + .setH2Config(h2Config) + .setVersionPolicy(HttpVersionPolicy.FORCE_HTTP_2) + .setHttpProcessor(H2Processors.client()) // includes H2RequestPriority + .setStreamListener(new H2StreamListener() { + @Override + public void onHeaderInput(final HttpConnection connection, final int streamId, final List headers) { + for (final Header h : headers) { + System.out.println(connection.getRemoteAddress() + " (" + streamId + ") << " + h); + } + } + + @Override + public void onHeaderOutput(final HttpConnection connection, final int streamId, final List headers) { + for (final Header h : headers) { + System.out.println(connection.getRemoteAddress() + " (" + streamId + ") >> " + h); + } + } + + @Override + public void onFrameInput(final HttpConnection c, final int id, final RawFrame f) { + } + + @Override + public void onFrameOutput(final HttpConnection c, final int id, final RawFrame f) { + } + + @Override + public void onInputFlowControl(final HttpConnection c, final int id, final int d, final int s) { + } + + @Override + public void onOutputFlowControl(final HttpConnection c, final int id, final int d, final int s) { + } + }) + .create(); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("HTTP requester shutting down"); + requester.close(CloseMode.GRACEFUL); + })); + requester.start(); + + final HttpHost target = new HttpHost("nghttp2.org"); + final Future future = requester.connect(target, Timeout.ofSeconds(30)); + final AsyncClientEndpoint clientEndpoint = future.get(); + + // ---- Request 1: Explicit non-default priority -> header MUST be emitted + executeWithPriority(clientEndpoint, target, "/httpbin/headers", PriorityValue.of(0, true)); + + // ---- Request 2: RFC defaults -> header MUST be omitted by the interceptor + executeWithPriority(clientEndpoint, target, "/httpbin/user-agent", PriorityValue.defaults()); + + System.out.println("Shutting down I/O reactor"); + requester.initiateShutdown(); + } + + private static void executeWithPriority( + final AsyncClientEndpoint endpoint, + final HttpHost target, + final String path, + final PriorityValue priorityValue) throws Exception { + + final ClassicHttpRequest request = ClassicRequestBuilder.get() + .setHttpHost(target) + .setPath(path) + .build(); + + // Place the PriorityValue into the context so H2RequestPriority can format the header + final HttpCoreContext ctx = HttpCoreContext.create(); + ctx.setAttribute(H2RequestPriority.ATTR_HTTP2_PRIORITY_VALUE, priorityValue); + + final ClassicToAsyncRequestProducer requestProducer = new ClassicToAsyncRequestProducer(request, Timeout.ofMinutes(1)); + final ClassicToAsyncResponseConsumer responseConsumer = new ClassicToAsyncResponseConsumer(Timeout.ofMinutes(1)); + + endpoint.execute(requestProducer, responseConsumer, ctx, null); + + requestProducer.blockWaiting().execute(); + try (ClassicHttpResponse response = responseConsumer.blockWaiting()) { + System.out.println(path + " -> " + response.getCode()); + final HttpEntity entity = response.getEntity(); + if (entity != null) { + final ContentType ct = ContentType.parse(entity.getContentType()); + final Charset cs = ContentType.getCharset(ct, StandardCharsets.UTF_8); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(entity.getContent(), cs))) { + String line; + while ((line = reader.readLine()) != null) { + System.out.println(line); + } + } + } + } + } +} diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/frame/FrameTypeTest.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/frame/FrameTypeTest.java new file mode 100644 index 0000000000..45b78d8604 --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/frame/FrameTypeTest.java @@ -0,0 +1,47 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.frame; + + + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class FrameTypeTest { + + @Test + void lookup_knows_priority_update_and_doesnt_throw() { + assertEquals(FrameType.PRIORITY_UPDATE, FrameType.valueOf(0x10)); + assertTrue(FrameType.PRIORITY_UPDATE.same(0x10)); + assertNull(FrameType.valueOf(0x0F)); // still unknown -> null + assertEquals("PRIORITY_UPDATE", FrameType.toString(16)); // name() only for known + } + +} \ No newline at end of file diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java index 2b3041dc37..140b5110c8 100644 --- a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java @@ -29,13 +29,17 @@ import java.io.IOException; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.locks.Lock; +import java.util.stream.IntStream; import org.apache.hc.core5.function.Supplier; import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.config.CharCodingConfig; import org.apache.hc.core5.http.impl.BasicHttpConnectionMetrics; import org.apache.hc.core5.http.impl.CharCodingSupport; @@ -56,6 +60,7 @@ import org.apache.hc.core5.http2.frame.RawFrame; import org.apache.hc.core5.http2.frame.StreamIdGenerator; import org.apache.hc.core5.http2.hpack.HPackEncoder; +import org.apache.hc.core5.reactor.Command; import org.apache.hc.core5.reactor.ProtocolIOSession; import org.apache.hc.core5.util.ByteArrayBuffer; import org.junit.jupiter.api.Assertions; @@ -88,9 +93,21 @@ class TestAbstractH2StreamMultiplexer { ArgumentCaptor exceptionCaptor; @BeforeEach - void prepareMocks() { + void prepareMocks() throws IOException { MockitoAnnotations.openMocks(this); Mockito.when(protocolIOSession.getLock()).thenReturn(lock); + + // For Core versions where IOSession has only write(ByteBuffer) + Mockito.when(protocolIOSession.write(ArgumentMatchers.any(ByteBuffer.class))) + .thenAnswer(inv -> { + final ByteBuffer b = inv.getArgument(0, ByteBuffer.class); + final int n = b.remaining(); + b.position(b.limit()); + return n; + }); + + Mockito.doNothing().when(protocolIOSession).setEvent(ArgumentMatchers.anyInt()); + Mockito.doNothing().when(protocolIOSession).clearEvent(ArgumentMatchers.anyInt()); } static class H2StreamMultiplexerImpl extends AbstractH2StreamMultiplexer { @@ -139,6 +156,10 @@ H2StreamHandler createLocallyInitiatedStream( final BasicHttpConnectionMetrics connMetrics) throws IOException { return null; } + + void emitPriorityUpdateForTest(final ByteBuffer payload) throws IOException { + writeExtensionFrame(FrameType.PRIORITY_UPDATE.getValue(), 0, 0, payload); + } } @Test @@ -537,7 +558,6 @@ void testContinuationAfterEndOfStream() throws Exception { Assertions.assertEquals(H2Error.PROTOCOL_ERROR, H2Error.getByCode(exception.getCode())); } - @Test void testInputHeaderContinuationFramesNoLimit() throws Exception { final H2Config h2Config = H2Config.custom() @@ -642,8 +662,403 @@ void testInputHeaderContinuationFramesMaxLimit() throws Exception { outBuffer.write(continuationFrame3, writableChannel); Assertions.assertThrows(H2ConnectionException.class, () -> - streamMultiplexer.onInput(ByteBuffer.wrap(writableChannel.toByteArray()))); + streamMultiplexer.onInput(ByteBuffer.wrap(writableChannel.toByteArray()))); } -} + @Test + void testPriorityUpdateInputIsRejected() throws Exception { + final AbstractH2StreamMultiplexer streamMultiplexer = new H2StreamMultiplexerImpl( + protocolIOSession, + FRAME_FACTORY, + StreamIdGenerator.ODD, + httpProcessor, + CharCodingConfig.DEFAULT, + H2Config.custom() + .setMaxFrameSize(FrameConsts.MIN_FRAME_SIZE) + .build(), + h2StreamListener, + () -> streamHandler); + + final WritableByteChannelMock writableChannel = new WritableByteChannelMock(1024); + final FrameOutputBuffer outbuffer = new FrameOutputBuffer(16 * 1024); + + final byte[] ascii = "u=3,i".getBytes(StandardCharsets.US_ASCII); + final ByteBuffer payload = ByteBuffer.allocate(4 + ascii.length); + payload.putInt(1); + payload.put(ascii); + payload.flip(); + + final RawFrame priUpd = new RawFrame(FrameType.PRIORITY_UPDATE.getValue(), 0, 0, payload); + outbuffer.write(priUpd, writableChannel); + final byte[] bytes = writableChannel.toByteArray(); + + // New behavior: tolerate/accept PRIORITY_UPDATE input (no exception) + Assertions.assertDoesNotThrow(() -> + streamMultiplexer.onInput(ByteBuffer.wrap(bytes))); + + // And we did observe the incoming frame at the listener + Mockito.verify(h2StreamListener).onFrameInput( + ArgumentMatchers.same(streamMultiplexer), + ArgumentMatchers.eq(0), + ArgumentMatchers.any()); + } + + + @Test + void testWriteExtensionPriorityUpdateIsFlushed() throws Exception { + final H2Config h2Config = H2Config.custom().build(); + + final H2StreamMultiplexerImpl streamMultiplexer = new H2StreamMultiplexerImpl( + protocolIOSession, + FRAME_FACTORY, + StreamIdGenerator.ODD, + httpProcessor, + CharCodingConfig.DEFAULT, + h2Config, + h2StreamListener, + () -> streamHandler); + + final byte[] ascii = "u=3,i".getBytes(StandardCharsets.US_ASCII); + final ByteBuffer payload = ByteBuffer.allocate(4 + ascii.length); + payload.putInt(1); + payload.put(ascii); + payload.flip(); + + streamMultiplexer.emitPriorityUpdateForTest(payload); + streamMultiplexer.onOutput(); + + Mockito.verify(protocolIOSession, Mockito.atLeastOnce()) + .write(ArgumentMatchers.any(ByteBuffer.class)); + } + + + static final class PriorityHeaderSender implements H2StreamHandler { + + private final H2StreamChannel channel; + private final List

headers; + private final boolean endStream; + private boolean sent; + + PriorityHeaderSender(final H2StreamChannel channel, final List
headers, final boolean endStream) { + this.channel = channel; + this.headers = headers; + this.endStream = endStream; + this.sent = false; + } + + @Override + public HandlerFactory getPushHandlerFactory() { + return null; + } + + @Override + public boolean isOutputReady() { + return !sent; + } + + @Override + public void produceOutput() throws IOException, HttpException { + if (!sent) { + channel.submit(headers, endStream); + sent = true; + } + } + + @Override + public void consumePromise(final List
headers) { } + + @Override + public void consumeHeader(final List
headers, final boolean endStream) { } + + @Override + public void updateInputCapacity() { } + + @Override + public void consumeData(final ByteBuffer src, final boolean endStream) { } + + @Override + public void handle(final org.apache.hc.core5.http.HttpException ex, final boolean endStream) throws org.apache.hc.core5.http.HttpException, IOException { + throw ex; + } + @Override + public void failed(final Exception cause) { } + + @Override + public void releaseResources() { } + } + + // A multiplexer variant that creates a locally-initiated stream with our PriorityHeaderSender. + static class H2StreamMultiplexerLocal extends AbstractH2StreamMultiplexer { + + private final List
headersToSend; + private final boolean endStream; + + H2StreamMultiplexerLocal( + final ProtocolIOSession ioSession, + final FrameFactory frameFactory, + final StreamIdGenerator idGenerator, + final HttpProcessor httpProcessor, + final CharCodingConfig charCodingConfig, + final H2Config h2Config, + final H2StreamListener streamListener, + final List
headersToSend, + final boolean endStream) { + super(ioSession, frameFactory, idGenerator, httpProcessor, charCodingConfig, h2Config, streamListener); + this.headersToSend = headersToSend; + this.endStream = endStream; + } + + @Override + void acceptHeaderFrame() throws H2ConnectionException { } + + @Override + void acceptPushRequest() throws H2ConnectionException { } + + @Override + void acceptPushFrame() throws H2ConnectionException { } + + @Override + H2StreamHandler createRemotelyInitiatedStream( + final H2StreamChannel channel, + final HttpProcessor httpProcessor, + final BasicHttpConnectionMetrics connMetrics, + final HandlerFactory pushHandlerFactory) { + return null; + } + + @Override + H2StreamHandler createLocallyInitiatedStream( + final ExecutableCommand command, + final H2StreamChannel channel, + final HttpProcessor httpProcessor, + final BasicHttpConnectionMetrics connMetrics) { + return new PriorityHeaderSender(channel, headersToSend, endStream); + } + } + + private static final class FrameStub { + final int type; + final int streamId; + FrameStub(final int type, final int streamId) { + this.type = type; + this.streamId = streamId; + } + } + + private static List parseFrames(final byte[] all) { + final List out = new ArrayList<>(); + int p = 0; + while (p + 9 <= all.length) { + final int len = ((all[p] & 0xff) << 16) | ((all[p + 1] & 0xff) << 8) | (all[p + 2] & 0xff); + final int type = all[p + 3] & 0xff; + // flags = all[p + 4] & 0xff; + final int sid = ((all[p + 5] & 0x7f) << 24) | ((all[p + 6] & 0xff) << 16) | ((all[p + 7] & 0xff) << 8) | (all[p + 8] & 0xff); + p += 9; + if (p + len > all.length) break; + out.add(new FrameStub(type, sid)); + p += len; + } + return out; + } + + @Test + void testSubmitWithPriorityHeaderEmitsPriorityUpdateBeforeHeaders() throws Exception { + // Capture writes + final List writes = new ArrayList<>(); + Mockito.when(protocolIOSession.write(ArgumentMatchers.any(ByteBuffer.class))) + .thenAnswer(inv -> { + final ByteBuffer b = inv.getArgument(0, ByteBuffer.class); + final byte[] copy = new byte[b.remaining()]; + b.get(copy); + writes.add(copy); + return copy.length; + }); + Mockito.doNothing().when(protocolIOSession).setEvent(ArgumentMatchers.anyInt()); + Mockito.doNothing().when(protocolIOSession).clearEvent(ArgumentMatchers.anyInt()); + + // Headers including PRIORITY (RFC 9218) + final List
reqHeaders = Arrays.asList( + new BasicHeader(":method", "GET"), + new BasicHeader(":scheme", "https"), + new BasicHeader(":path", "/"), + new BasicHeader(":authority", "example.test"), + new BasicHeader(HttpHeaders.PRIORITY, "u=3,i") + ); + + final H2Config h2Config = H2Config.custom().build(); + + final H2StreamMultiplexerLocal mux = new H2StreamMultiplexerLocal( + protocolIOSession, + FRAME_FACTORY, + StreamIdGenerator.ODD, // locally initiated streams get odd IDs -> 1 + httpProcessor, + CharCodingConfig.DEFAULT, + h2Config, + h2StreamListener, + reqHeaders, + true); + + // Start connection (sends our SETTINGS etc.) + mux.onConnect(); + writes.clear(); // ignore noise from onConnect + + // Make remote send SETTINGS so remoteSettingState -> ACKED AND allow PRIORITY_UPDATE (NO_RFC7540=1) + final WritableByteChannelMock writable = new WritableByteChannelMock(256); + final FrameOutputBuffer fob = new FrameOutputBuffer(16 * 1024); + final ByteBuffer pl = ByteBuffer.allocate(6); + pl.putShort((short) 0x0009); // SETTINGS_NO_RFC7540_PRIORITIES + pl.putInt(1); // value = 1 (peer opts-in) + pl.flip(); + final RawFrame incomingSettings = new RawFrame(FrameType.SETTINGS.getValue(), 0, 0, pl); + fob.write(incomingSettings, writable); + mux.onInput(ByteBuffer.wrap(writable.toByteArray())); + writes.clear(); // ignore the ACK we just sent + + // Queue a dummy command so a locally-initiated stream gets created + final ExecutableCommand exec = Mockito.mock(ExecutableCommand.class, Mockito.withSettings().extraInterfaces(Command.class)); + Mockito.when(protocolIOSession.poll()).thenReturn(exec, (Command) null); + + // Drive output (this should trigger PriorityHeaderSender -> channel.submit) + mux.onOutput(); + + // Combine captured writes + int total = 0; + for (final byte[] a : writes) total += a.length; + final byte[] all = new byte[total]; + int pos = 0; + for (final byte[] a : writes) { + System.arraycopy(a, 0, all, pos, a.length); + pos += a.length; + } + + // Parse frames and assert ordering: PRIORITY_UPDATE (type 0x10, sid 0) BEFORE HEADERS (0x01, sid 1) + final List frames = parseFrames(all); + + // Find the first PRIORITY_UPDATE and the first HEADERS for stream 1 + int idxPriUpd = -1; + int idxHeaders = -1; + for (int i = 0; i < frames.size(); i++) { + final FrameStub f = frames.get(i); + if (idxPriUpd < 0 && f.type == FrameType.PRIORITY_UPDATE.getValue() && f.streamId == 0) { + idxPriUpd = i; + } + if (idxHeaders < 0 && f.type == FrameType.HEADERS.getValue() && f.streamId == 1) { + idxHeaders = i; + } + } + + Assertions.assertTrue(idxPriUpd >= 0, "PRIORITY_UPDATE not emitted"); + Assertions.assertTrue(idxHeaders >= 0, "HEADERS not emitted"); + Assertions.assertTrue(idxPriUpd < idxHeaders, "PRIORITY_UPDATE must be sent before HEADERS"); + } + + + @Test + void testPriorityUpdateSuppressedAfterSettingsWithoutNoH2() throws Exception { + final List writes = new ArrayList<>(); + Mockito.when(protocolIOSession.write(ArgumentMatchers.any(ByteBuffer.class))) + .thenAnswer(inv -> { final ByteBuffer b = inv.getArgument(0, ByteBuffer.class); + final byte[] c = new byte[b.remaining()]; b.get(c); writes.add(c); return c.length; }); + Mockito.doNothing().when(protocolIOSession).setEvent(ArgumentMatchers.anyInt()); + Mockito.doNothing().when(protocolIOSession).clearEvent(ArgumentMatchers.anyInt()); + + final List
reqHeaders = Arrays.asList( + new BasicHeader(":method","GET"), + new BasicHeader(":scheme","https"), + new BasicHeader(":path","/"), + new BasicHeader(":authority","example.test"), + new BasicHeader(HttpHeaders.PRIORITY, "u=3") // would normally trigger PRIORITY_UPDATE + ); + + final H2StreamMultiplexerLocal mux = new H2StreamMultiplexerLocal( + protocolIOSession, FRAME_FACTORY, StreamIdGenerator.ODD, + httpProcessor, CharCodingConfig.DEFAULT, H2Config.custom().build(), + h2StreamListener, reqHeaders, true); + + mux.onConnect(); + writes.clear(); + + // Inject server SETTINGS WITHOUT 0x9 + final WritableByteChannelMock w = new WritableByteChannelMock(256); + final FrameOutputBuffer fob = new FrameOutputBuffer(16 * 1024); + final RawFrame serverSettings = new RawFrame(FrameType.SETTINGS.getValue(), 0, 0, ByteBuffer.allocate(0)); + fob.write(serverSettings, w); + mux.onInput(ByteBuffer.wrap(w.toByteArray())); + writes.clear(); // drop our ACK + + // Queue a request (locally-initiated) + final ExecutableCommand exec = Mockito.mock(ExecutableCommand.class, Mockito.withSettings().extraInterfaces(Command.class)); + Mockito.when(protocolIOSession.poll()).thenReturn(exec, (Command) null); + + mux.onOutput(); + + // Stitch writes and parse + final int total = writes.stream().mapToInt(a -> a.length).sum(); + final byte[] all = new byte[total]; int p = 0; + for (final byte[] a : writes) { System.arraycopy(a, 0, all, p, a.length); p += a.length; } + + final List frames = parseFrames(all); + // Assert: no PRIORITY_UPDATE frames (type 0x10) + Assertions.assertTrue(frames.stream().noneMatch(f -> f.type == FrameType.PRIORITY_UPDATE.getValue()), + "PRIORITY_UPDATE must be suppressed after first SETTINGS without NO_RFC7540"); + } + + + @Test + void testPriorityUpdateContinuesAfterSettingsWithNoH2Equals1() throws Exception { + final List writes = new ArrayList<>(); + Mockito.when(protocolIOSession.write(ArgumentMatchers.any(ByteBuffer.class))) + .thenAnswer(inv -> { final ByteBuffer b = inv.getArgument(0, ByteBuffer.class); + final byte[] c = new byte[b.remaining()]; b.get(c); writes.add(c); return c.length; }); + Mockito.doNothing().when(protocolIOSession).setEvent(ArgumentMatchers.anyInt()); + Mockito.doNothing().when(protocolIOSession).clearEvent(ArgumentMatchers.anyInt()); + + final List
reqHeaders = Arrays.asList( + new BasicHeader(":method","GET"), + new BasicHeader(":scheme","https"), + new BasicHeader(":path","/"), + new BasicHeader(":authority","example.test"), + new BasicHeader(HttpHeaders.PRIORITY, "u=0,i") // triggers PRIORITY_UPDATE + ); + + final H2StreamMultiplexerLocal mux = new H2StreamMultiplexerLocal( + protocolIOSession, FRAME_FACTORY, StreamIdGenerator.ODD, + httpProcessor, CharCodingConfig.DEFAULT, H2Config.custom().build(), + h2StreamListener, reqHeaders, true); + + mux.onConnect(); + writes.clear(); + + // Inject server SETTINGS with 0x9 = 1 + final WritableByteChannelMock w = new WritableByteChannelMock(256); + final FrameOutputBuffer fob = new FrameOutputBuffer(16 * 1024); + final ByteBuffer pl = ByteBuffer.allocate(6); + pl.putShort((short) 0x0009); // SETTINGS_NO_RFC7540_PRIORITIES + pl.putInt(1); // value = 1 + pl.flip(); + final RawFrame serverSettings = new RawFrame(FrameType.SETTINGS.getValue(), 0, 0, pl); + fob.write(serverSettings, w); + mux.onInput(ByteBuffer.wrap(w.toByteArray())); + writes.clear(); // drop our ACK + + // Queue a locally-initiated stream + final ExecutableCommand exec = Mockito.mock(ExecutableCommand.class, Mockito.withSettings().extraInterfaces(Command.class)); + Mockito.when(protocolIOSession.poll()).thenReturn(exec, (Command) null); + + mux.onOutput(); + + final int total = writes.stream().mapToInt(a -> a.length).sum(); + final byte[] all = new byte[total]; int p = 0; + for (final byte[] a : writes) { System.arraycopy(a, 0, all, p, a.length); p += a.length; } + + final List frames = parseFrames(all); + + final int idxPriUpd = IntStream.range(0, frames.size()) + .filter(i -> frames.get(i).type == FrameType.PRIORITY_UPDATE.getValue() && frames.get(i).streamId == 0) + .findFirst().orElse(-1); + Assertions.assertTrue(idxPriUpd >= 0, "PRIORITY_UPDATE should still be emitted when NO_RFC7540=1"); + } + + +} diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/protocol/H2RequestPriorityTest.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/protocol/H2RequestPriorityTest.java new file mode 100644 index 0000000000..fda7ead5bf --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/protocol/H2RequestPriorityTest.java @@ -0,0 +1,174 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.protocol; + +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.HttpVersion; +import org.apache.hc.core5.http.message.BasicHttpRequest; +import org.apache.hc.core5.http.priority.PriorityValue; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class H2RequestPriorityTest { + + private HttpCoreContext h2ctx; + + @BeforeEach + void setUp() { + h2ctx = HttpCoreContext.create(); + h2ctx.setProtocolVersion(HttpVersion.HTTP_2); + } + + @Test + void testH2RequestPriority_noopOnHttp11() throws Exception { + final HttpCoreContext ctx11 = HttpCoreContext.create(); + ctx11.setProtocolVersion(HttpVersion.HTTP_1_1); + + final BasicHttpRequest request = new BasicHttpRequest("GET", new HttpHost("host"), "/"); + ctx11.setAttribute(H2RequestPriority.ATTR_HTTP2_PRIORITY_VALUE, PriorityValue.of(0, true)); + + final H2RequestPriority interceptor = H2RequestPriority.INSTANCE; + interceptor.process(request, null, ctx11); + + Assertions.assertNull(request.getFirstHeader(HttpHeaders.PRIORITY), + "No Priority header should be added for HTTP/1.1"); + } + + @Test + void adds_u_only_when_nonDefault_urgency() throws Exception { + final BasicHttpRequest req = new BasicHttpRequest("GET", new HttpHost("h"), "/"); + h2ctx.setAttribute(H2RequestPriority.ATTR_HTTP2_PRIORITY_VALUE, PriorityValue.of(5, false)); + + H2RequestPriority.INSTANCE.process(req, null, h2ctx); + + Assertions.assertNotNull(req.getFirstHeader(HttpHeaders.PRIORITY)); + Assertions.assertEquals("u=5", req.getFirstHeader(HttpHeaders.PRIORITY).getValue()); + Assertions.assertEquals(1, req.getHeaders(HttpHeaders.PRIORITY).length); + } + + @Test + void adds_i_only_when_incremental_true() throws Exception { + final BasicHttpRequest req = new BasicHttpRequest("GET", new HttpHost("h"), "/"); + h2ctx.setAttribute(H2RequestPriority.ATTR_HTTP2_PRIORITY_VALUE, PriorityValue.of(3, true)); + + H2RequestPriority.INSTANCE.process(req, null, h2ctx); + + Assertions.assertEquals("i", req.getFirstHeader(HttpHeaders.PRIORITY).getValue()); + } + + @Test + void adds_both_with_expected_format_and_order() throws Exception { + final BasicHttpRequest req = new BasicHttpRequest("GET", new HttpHost("h"), "/"); + h2ctx.setAttribute(H2RequestPriority.ATTR_HTTP2_PRIORITY_VALUE, PriorityValue.of(1, true)); + + H2RequestPriority.INSTANCE.process(req, null, h2ctx); + + Assertions.assertEquals("u=1, i", req.getFirstHeader(HttpHeaders.PRIORITY).getValue()); + } + + @Test + void omits_header_when_defaults() throws Exception { + final BasicHttpRequest req = new BasicHttpRequest("GET", new HttpHost("h"), "/"); + h2ctx.setAttribute(H2RequestPriority.ATTR_HTTP2_PRIORITY_VALUE, PriorityValue.defaults()); + + H2RequestPriority.INSTANCE.process(req, null, h2ctx); + + Assertions.assertNull(req.getFirstHeader(HttpHeaders.PRIORITY)); + } + + @Test + void preserves_existing_when_overwrite_false() throws Exception { + final BasicHttpRequest req = new BasicHttpRequest("GET", new HttpHost("h"), "/"); + req.addHeader(HttpHeaders.PRIORITY, "u=0"); + h2ctx.setAttribute(H2RequestPriority.ATTR_HTTP2_PRIORITY_VALUE, PriorityValue.of(5, true)); + + H2RequestPriority.INSTANCE.process(req, null, h2ctx); + + Assertions.assertEquals("u=0", req.getFirstHeader(HttpHeaders.PRIORITY).getValue(), + "Existing header must be preserved when overwrite=false"); + } + + @Test + void overwrites_existing_when_overwrite_true() throws Exception { + final BasicHttpRequest req = new BasicHttpRequest("GET", new HttpHost("h"), "/"); + req.addHeader(HttpHeaders.PRIORITY, "u=7"); + h2ctx.setAttribute(H2RequestPriority.ATTR_HTTP2_PRIORITY_VALUE, PriorityValue.of(0, true)); + + new H2RequestPriority(true).process(req, null, h2ctx); + + Assertions.assertEquals("u=0, i", req.getFirstHeader(HttpHeaders.PRIORITY).getValue()); + Assertions.assertEquals(1, req.getHeaders(HttpHeaders.PRIORITY).length); + } + + @Test + void removes_existing_when_overwrite_true_and_defaults() throws Exception { + final BasicHttpRequest req = new BasicHttpRequest("GET", new HttpHost("h"), "/"); + req.addHeader(HttpHeaders.PRIORITY, "u=7"); + h2ctx.setAttribute(H2RequestPriority.ATTR_HTTP2_PRIORITY_VALUE, PriorityValue.defaults()); + + new H2RequestPriority(true).process(req, null, h2ctx); + + Assertions.assertNull(req.getFirstHeader(HttpHeaders.PRIORITY), + "Defaults format to null; overwrite=true should remove any existing header"); + } + + @Test + void noop_when_no_context_value() throws Exception { + final BasicHttpRequest req = new BasicHttpRequest("GET", new HttpHost("h"), "/"); + + H2RequestPriority.INSTANCE.process(req, null, h2ctx); + + Assertions.assertNull(req.getFirstHeader(HttpHeaders.PRIORITY)); + } + + @Test + void respects_case_insensitive_existing_header_name() throws Exception { + final BasicHttpRequest req = new BasicHttpRequest("GET", new HttpHost("h"), "/"); + req.addHeader("priority", "u=6"); // lower-case, should still be found + h2ctx.setAttribute(H2RequestPriority.ATTR_HTTP2_PRIORITY_VALUE, PriorityValue.of(0, true)); + + H2RequestPriority.INSTANCE.process(req, null, h2ctx); + + Assertions.assertEquals("u=6", req.getFirstHeader(HttpHeaders.PRIORITY).getValue()); + } + + @Test + void dedups_multiple_existing_headers_on_overwrite_true() throws Exception { + final BasicHttpRequest req = new BasicHttpRequest("GET", new HttpHost("h"), "/"); + req.addHeader(HttpHeaders.PRIORITY, "u=7"); + req.addHeader(HttpHeaders.PRIORITY, "i"); + h2ctx.setAttribute(H2RequestPriority.ATTR_HTTP2_PRIORITY_VALUE, PriorityValue.of(2, false)); + + new H2RequestPriority(true).process(req, null, h2ctx); + + Assertions.assertEquals(1, req.getHeaders(HttpHeaders.PRIORITY).length); + Assertions.assertEquals("u=2", req.getFirstHeader(HttpHeaders.PRIORITY).getValue()); + } +} \ No newline at end of file diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/HttpHeaders.java b/httpcore5/src/main/java/org/apache/hc/core5/http/HttpHeaders.java index c8a966a7c9..6c4b13b2a4 100644 --- a/httpcore5/src/main/java/org/apache/hc/core5/http/HttpHeaders.java +++ b/httpcore5/src/main/java/org/apache/hc/core5/http/HttpHeaders.java @@ -204,4 +204,9 @@ private HttpHeaders() { public static final String WWW_AUTHENTICATE = "WWW-Authenticate"; + /** + * The HTTP {@code Priority} header field name. + */ + public static final String PRIORITY = "Priority"; + } diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityFormatter.java b/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityFormatter.java new file mode 100644 index 0000000000..0975f0ab5d --- /dev/null +++ b/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityFormatter.java @@ -0,0 +1,62 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http.priority; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Formats PriorityValue as RFC 9218 Structured Fields Dictionary. + * Only emits non-defaults: u when != 3, i when true. + * Returns null when both are defaults (callers should omit the header then). + */ +@Internal +public final class PriorityFormatter { + + private PriorityFormatter() { + } + + public static String format(final PriorityValue value) { + if (value == null) { + return null; + } + final List parts = new ArrayList<>(2); + if (value.getUrgency() != PriorityValue.DEFAULT_URGENCY) { + parts.add("u=" + value.getUrgency()); + } + if (value.isIncremental()) { + // In SF Dictionary, boolean true can be represented by key without value (per RFC 8941). + parts.add("i"); + } + if (parts.isEmpty()) { + return null; // omit header when all defaults + } + return String.join(", ", parts); + } +} diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityMerge.java b/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityMerge.java new file mode 100644 index 0000000000..765ff38037 --- /dev/null +++ b/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityMerge.java @@ -0,0 +1,51 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http.priority; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Non-normative merge helper per RFC 9218 §8 example. + * Policy: if server provides a member, prefer it; otherwise keep client's. + */ +@Internal +public final class PriorityMerge { + private PriorityMerge() { + } + + public static PriorityValue merge(final PriorityValue clientRequest, final PriorityParams serverResponse) { + final int u = serverResponse != null && serverResponse.getUrgency() != null + ? serverResponse.getUrgency() + : (clientRequest != null ? clientRequest.getUrgency() : PriorityValue.DEFAULT_URGENCY); + + final boolean i = serverResponse != null && serverResponse.getIncremental() != null + ? serverResponse.getIncremental() + : (clientRequest != null ? clientRequest.isIncremental() : PriorityValue.DEFAULT_INCREMENTAL); + + return PriorityValue.of(u, i); + } +} diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityParams.java b/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityParams.java new file mode 100644 index 0000000000..58197329e9 --- /dev/null +++ b/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityParams.java @@ -0,0 +1,63 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http.priority; + +import org.apache.hc.core5.annotation.Internal; + +/** + * Presence-aware view for RFC 9218 Priority on responses. + */ +@Internal +public final class PriorityParams { + private final Integer urgency; // null if 'u' absent + private final Boolean incremental; // null if 'i' absent + + public PriorityParams(final Integer urgency, final Boolean incremental) { + if (urgency != null && (urgency < 0 || urgency > 7)) { + throw new IllegalArgumentException("urgency out of range [0..7]: " + urgency); + } + this.urgency = urgency; + this.incremental = incremental; + } + + public Integer getUrgency() { + return urgency; + } + + public Boolean getIncremental() { + return incremental; + } + + /** + * Convert to concrete value by applying RFC defaults (u=3, i=false) for missing members. + */ + public PriorityValue toValueWithDefaults() { + final int u = urgency != null ? urgency : PriorityValue.DEFAULT_URGENCY; + final boolean i = incremental != null ? incremental : PriorityValue.DEFAULT_INCREMENTAL; + return PriorityValue.of(u, i); + } +} diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityParamsParser.java b/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityParamsParser.java new file mode 100644 index 0000000000..32e223edc5 --- /dev/null +++ b/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityParamsParser.java @@ -0,0 +1,150 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http.priority; + +import java.util.Locale; + +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.util.CharArrayBuffer; +import org.apache.hc.core5.util.Tokenizer; + +/** + * Parser for the HTTP {@code Priority} header (RFC 9218). + *

+ * Recognizes the subset used here: + *

    + *
  • {@code u} — urgency, integer in the range {@code 0..7}
  • + *
  • {@code i} — incremental, boolean ({@code 1}/{@code 0} or {@code ?1}/{@code ?0}); + * a bare {@code i} (no value) is treated as {@code true}
  • + *
+ * The parser is tolerant: keys are case-insensitive, unknown members and malformed values are ignored, + * comma-separated items and semicolon-separated parameters are supported, and absence is reported + * as {@code null} fields in {@link PriorityParams}. This class is stateless and thread-safe. + *

Internal: not part of the public API and subject to change without notice. + */ +@Internal +public final class PriorityParamsParser { + private static final Tokenizer TK = Tokenizer.INSTANCE; + private static final Tokenizer.Delimiter KEY_DELIMS = Tokenizer.delimiters('=', ',', ';'); + private static final Tokenizer.Delimiter VALUE_DELIMS = Tokenizer.delimiters(',', ';'); + + private PriorityParamsParser() { + } + + public static PriorityParams parse(final String headerValue) { + Integer urgency = null; // null means “u absent” + Boolean incremental = null; // null means “i absent” + + if (headerValue == null || headerValue.isEmpty()) { + return new PriorityParams(null, null); + } + + final CharArrayBuffer buf = new CharArrayBuffer(headerValue.length()); + buf.append(headerValue); + final Tokenizer.Cursor c = new Tokenizer.Cursor(0, buf.length()); + + while (!c.atEnd()) { + TK.skipWhiteSpace(buf, c); + if (c.atEnd()) break; + + final String rawKey = TK.parseToken(buf, c, KEY_DELIMS); + if (rawKey == null || rawKey.isEmpty()) { + skipToNextItem(buf, c); + continue; + } + final String key = rawKey.toLowerCase(Locale.ROOT); + TK.skipWhiteSpace(buf, c); + final char ch = c.atEnd() ? 0 : buf.charAt(c.getPos()); + + if (ch == '=') { + c.updatePos(c.getPos() + 1); + TK.skipWhiteSpace(buf, c); + + if ("u".equals(key)) { + final String numTok = TK.parseToken(buf, c, VALUE_DELIMS); + try { + final int u = Integer.parseInt(numTok); + if (u >= 0 && u <= 7) urgency = u; + } catch (final Exception ignore) { /* absent on error */ } + } else if ("i".equals(key)) { + final char b = c.atEnd() ? 0 : buf.charAt(c.getPos()); + if (b == '?') { + c.updatePos(c.getPos() + 1); + final char v = c.atEnd() ? 0 : buf.charAt(c.getPos()); + if (v == '1') { + incremental = Boolean.TRUE; + c.updatePos(c.getPos() + 1); + } else if (v == '0') { + incremental = Boolean.FALSE; + c.updatePos(c.getPos() + 1); + } + } else { + final String tok = TK.parseToken(buf, c, VALUE_DELIMS); + if ("1".equals(tok)) incremental = Boolean.TRUE; + else if ("0".equals(tok)) incremental = Boolean.FALSE; + } + } else { + TK.parseToken(buf, c, VALUE_DELIMS); // ignore unknown member with value + } + skipParamsThenNextItem(buf, c); + } else { + if ("i".equals(key)) incremental = Boolean.TRUE; // bare true + skipParamsThenNextItem(buf, c); + } + } + return new PriorityParams(urgency, incremental); + } + + private static void skipToNextItem(final CharSequence buf, final Tokenizer.Cursor c) { + while (!c.atEnd()) { + final char ch = buf.charAt(c.getPos()); + c.updatePos(c.getPos() + 1); + if (ch == ',') break; + } + } + + private static void skipParamsThenNextItem(final CharSequence buf, final Tokenizer.Cursor c) { + while (!c.atEnd()) { + final int pos = c.getPos(); + final char ch = buf.charAt(pos); + if (ch == ';') { + c.updatePos(pos + 1); + TK.parseToken(buf, c, VALUE_DELIMS); + continue; + } + break; + } + while (!c.atEnd()) { + final char ch = buf.charAt(c.getPos()); + if (ch == ',') { + c.updatePos(c.getPos() + 1); + break; + } + c.updatePos(c.getPos() + 1); + } + } +} diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityParser.java b/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityParser.java new file mode 100644 index 0000000000..e81f5b8f87 --- /dev/null +++ b/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityParser.java @@ -0,0 +1,172 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http.priority; + +import java.util.Locale; + +import org.apache.hc.core5.util.CharArrayBuffer; +import org.apache.hc.core5.util.Tokenizer; + +public final class PriorityParser { + + private static final Tokenizer TK = Tokenizer.INSTANCE; + + // Non-deprecated delimiter predicates + private static final Tokenizer.Delimiter KEY_DELIMS = Tokenizer.delimiters('=', ',', ';'); + private static final Tokenizer.Delimiter VALUE_DELIMS = Tokenizer.delimiters(',', ';'); + + private PriorityParser() { + } + + public static PriorityValue parse(final String headerValue) { + int urgency = PriorityValue.DEFAULT_URGENCY; + boolean incremental = PriorityValue.DEFAULT_INCREMENTAL; + + if (headerValue == null || headerValue.isEmpty()) { + return PriorityValue.of(urgency, incremental); + } + + final CharArrayBuffer buf = new CharArrayBuffer(headerValue.length()); + buf.append(headerValue); + final Tokenizer.Cursor cursor = new Tokenizer.Cursor(0, buf.length()); + + while (!cursor.atEnd()) { + TK.skipWhiteSpace(buf, cursor); + if (cursor.atEnd()) { + break; + } + + final String rawKey = TK.parseToken(buf, cursor, KEY_DELIMS); + if (rawKey == null || rawKey.isEmpty()) { + skipToNextItem(buf, cursor); + continue; + } + final String key = rawKey.toLowerCase(Locale.ROOT); + + TK.skipWhiteSpace(buf, cursor); + final char ch = currentChar(buf, cursor); + + if (ch == '=') { + cursor.updatePos(cursor.getPos() + 1); // consume '=' + TK.skipWhiteSpace(buf, cursor); + + if ("u".equals(key)) { + final String numTok = TK.parseToken(buf, cursor, VALUE_DELIMS); + final Integer u = safeParseInt(numTok); + if (u != null && u >= 0 && u <= 7) { + urgency = u; + } + } else if ("i".equals(key)) { + // Accept RFC 8941 booleans '?1'/'?0' and tolerant '1'/'0' + final char b = currentChar(buf, cursor); + if (b == '?') { + cursor.updatePos(cursor.getPos() + 1); + final char v = currentChar(buf, cursor); + if (v == '1') { + incremental = true; + cursor.updatePos(cursor.getPos() + 1); + } else if (v == '0') { + incremental = false; + cursor.updatePos(cursor.getPos() + 1); + } + } else { + final String tok = TK.parseToken(buf, cursor, VALUE_DELIMS); + if ("1".equals(tok)) { + incremental = true; + } else if ("0".equals(tok)) { + incremental = false; + } + } + } else { + // Unknown member -> parse & ignore its value token + TK.parseToken(buf, cursor, VALUE_DELIMS); + } + + skipParamsThenNextItem(buf, cursor); + + } else { + // Bare member: only "i" counts (true) + if ("i".equals(key)) { + incremental = true; + } + skipParamsThenNextItem(buf, cursor); + } + } + + return PriorityValue.of(urgency, incremental); + } + + // ---- helpers (no deprecated APIs) ---- + + private static char currentChar(final CharSequence buf, final Tokenizer.Cursor c) { + return c.atEnd() ? 0 : buf.charAt(c.getPos()); + } + + private static Integer safeParseInt(final String s) { + if (s == null) { + return null; + } + try { + return Integer.parseInt(s); + } catch (final NumberFormatException ignore) { + return null; + } + } + + private static void skipToNextItem(final CharSequence buf, final Tokenizer.Cursor c) { + while (!c.atEnd()) { + final char ch = buf.charAt(c.getPos()); + c.updatePos(c.getPos() + 1); + if (ch == ',') { + break; + } + } + } + + // Skip any SF parameters (';param[=value]...'), then advance to the next item (after a single ',') if present. + private static void skipParamsThenNextItem(final CharSequence buf, final Tokenizer.Cursor c) { + while (!c.atEnd()) { + final int pos = c.getPos(); + final char ch = buf.charAt(pos); + if (ch == ';') { + c.updatePos(pos + 1); + // consume parameter token (up to ',' or ';') + TK.parseToken(buf, c, VALUE_DELIMS); + continue; + } + break; + } + while (!c.atEnd()) { + final char ch = buf.charAt(c.getPos()); + if (ch == ',') { + c.updatePos(c.getPos() + 1); // consume comma + break; + } + c.updatePos(c.getPos() + 1); + } + } +} diff --git a/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityValue.java b/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityValue.java new file mode 100644 index 0000000000..aa8168e3ae --- /dev/null +++ b/httpcore5/src/main/java/org/apache/hc/core5/http/priority/PriorityValue.java @@ -0,0 +1,100 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http.priority; + +import java.util.Objects; + +/** + * RFC 9218 Priority field value. + * u: [0..7], default 3 (smaller is higher priority) + * i: boolean, default false + *

+ * Unknown params MUST be ignored (RFC 9218 §4). + */ + +public final class PriorityValue { + + public static final int DEFAULT_URGENCY = 3; + public static final boolean DEFAULT_INCREMENTAL = false; + + private final int urgency; + private final boolean incremental; + + public PriorityValue(final int urgency, final boolean incremental) { + if (urgency < 0 || urgency > 7) { + throw new IllegalArgumentException("urgency out of range [0..7]: " + urgency); + } + this.urgency = urgency; + this.incremental = incremental; + } + + public static PriorityValue of(final int urgency, final boolean incremental) { + return new PriorityValue(urgency, incremental); + } + + public static PriorityValue defaults() { + return new PriorityValue(DEFAULT_URGENCY, DEFAULT_INCREMENTAL); + } + + public int getUrgency() { + return urgency; + } + + public boolean isIncremental() { + return incremental; + } + + public PriorityValue withUrgency(final int newUrgency) { + return new PriorityValue(newUrgency, this.incremental); + } + + public PriorityValue withIncremental(final boolean newIncremental) { + return new PriorityValue(this.urgency, newIncremental); + } + + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof PriorityValue)) { + return false; + } + final PriorityValue other = (PriorityValue) obj; + return urgency == other.urgency && incremental == other.incremental; + } + + @Override + public int hashCode() { + return Objects.hash(urgency, incremental); + } + + @Override + public String toString() { + return "PriorityValue{u=" + urgency + ", i=" + incremental + '}'; + } +} diff --git a/httpcore5/src/test/java/org/apache/hc/core5/http/priority/PriorityParserAndFormatterTest.java b/httpcore5/src/test/java/org/apache/hc/core5/http/priority/PriorityParserAndFormatterTest.java new file mode 100644 index 0000000000..c03e21068e --- /dev/null +++ b/httpcore5/src/test/java/org/apache/hc/core5/http/priority/PriorityParserAndFormatterTest.java @@ -0,0 +1,175 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http.priority; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Tests for RFC 9218 Priority header parsing/formatting. + * Covers RFC examples and core behaviors (defaults, ignoring unknowns, etc). + * + * @since 5.6 + */ +public final class PriorityParserAndFormatterTest { + + // ---- RFC 9218 examples -------------------------------------------------- + + @Test + void rfc_example_css_urgency0() { + // From RFC 9218 §4.1: priority = u=0 + final PriorityValue v = PriorityParser.parse("u=0"); + assertEquals(0, v.getUrgency()); + assertFalse(v.isIncremental()); + + // Formatter emits only non-defaults + assertEquals("u=0", PriorityFormatter.format(v)); + } + + @Test + void rfc_example_jpeg_u5_incremental() { + // From RFC 9218 §4.2: priority = u=5, i + final PriorityValue v = PriorityParser.parse("u=5, i"); + assertEquals(5, v.getUrgency()); + assertTrue(v.isIncremental()); + + // Formatter keeps canonical "u=5, i" + assertEquals("u=5, i", PriorityFormatter.format(v)); + } + + // ---- Defaults & omission ------------------------------------------------- + + @Test + void defaults_when_empty_or_null() { + final PriorityValue v1 = PriorityParser.parse(""); + assertEquals(3, v1.getUrgency()); + assertFalse(v1.isIncremental()); + + final PriorityValue v2 = PriorityParser.parse(null); + assertEquals(3, v2.getUrgency()); + assertFalse(v2.isIncremental()); + + // Formatter omits header if all defaults + assertNull(PriorityFormatter.format(PriorityValue.defaults())); + } + + // ---- Boolean variants per RFC 8941 -------------------------------------- + + @Test + void boolean_variants_for_incremental() { + assertTrue(PriorityParser.parse("i").isIncremental()); // bare true + assertTrue(PriorityParser.parse("i=?1").isIncremental()); // structured boolean true + assertFalse(PriorityParser.parse("i=?0").isIncremental()); // structured boolean false + assertTrue(PriorityParser.parse("i=1").isIncremental()); // tolerant numeric '1' + assertFalse(PriorityParser.parse("i=0").isIncremental()); // tolerant numeric '0' + } + + // ---- Unknown & invalid handling ----------------------------------------- + + @Test + void unknown_members_are_ignored() { + final PriorityValue v = PriorityParser.parse("foo=bar, u=2, i, baz=?1"); + assertEquals(2, v.getUrgency()); + assertTrue(v.isIncremental()); + } + + @Test + void urgency_out_of_range_is_ignored() { + final PriorityValue v1 = PriorityParser.parse("u=9"); // >7 + assertEquals(3, v1.getUrgency()); + + final PriorityValue v2 = PriorityParser.parse("u=-1"); // <0 + assertEquals(3, v2.getUrgency()); + } + + @Test + void malformed_members_are_ignored() { + final PriorityValue v = PriorityParser.parse("u=abc, i=banana, i=?x"); + assertEquals(3, v.getUrgency()); // default + assertFalse(v.isIncremental()); // default + } + + // ---- Whitespace, params, case-insensitivity ----------------------------- + + @Test + void handles_ows_and_parameters_and_case() { + // Ignore structured-field parameters after members, and key is case-insensitive + final PriorityValue v = PriorityParser.parse(" U = 1 ;p=v , I ;x "); + assertEquals(1, v.getUrgency()); + assertTrue(v.isIncremental()); + + // Formatter canonicalizes output (no params, normalized layout) + assertEquals("u=1, i", PriorityFormatter.format(v)); + } + + // ---- Minimal formatting rules ------------------------------------------- + + @Test + void formatter_emits_only_non_defaults_in_canonical_order() { + assertEquals("u=1", PriorityFormatter.format(PriorityValue.of(1, false))); + assertEquals("i", PriorityFormatter.format(PriorityValue.of(3, true))); + assertEquals("u=2, i", PriorityFormatter.format(PriorityValue.of(2, true))); + } + + @Test + void formatter_returns_null_for_defaults() { + assertNull(PriorityFormatter.format(PriorityValue.of(3, false))); + } + + // ---- Round-trips --------------------------------------------------------- + + @Test + void round_trip_common_values() { + roundTrip("u=0"); + roundTrip("u=0, i"); + roundTrip("u=5, i"); + roundTrip("i"); + roundTrip("u=7"); + } + + private static void roundTrip(final String s) { + final PriorityValue v = PriorityParser.parse(s); + final String out = PriorityFormatter.format(v); + // If v equals defaults, formatter returns null; otherwise we expect the canonical equivalent + if (v.getUrgency() == 3 && !v.isIncremental()) { + assertNull(out); + } else { + // Canonical ordering is u first (if non-default), then i if true + if (v.getUrgency() != 3 && v.isIncremental()) { + assertEquals("u=" + v.getUrgency() + ", i", out); + } else if (v.getUrgency() != 3) { + assertEquals("u=" + v.getUrgency(), out); + } else { + assertEquals("i", out); + } + } + } +} diff --git a/httpcore5/src/test/java/org/apache/hc/core5/http/priority/TestPriorityMerge.java b/httpcore5/src/test/java/org/apache/hc/core5/http/priority/TestPriorityMerge.java new file mode 100644 index 0000000000..f926aa6f11 --- /dev/null +++ b/httpcore5/src/test/java/org/apache/hc/core5/http/priority/TestPriorityMerge.java @@ -0,0 +1,115 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http.priority; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Tests for non-normative PriorityMerge helper. + * + * @since 5.6 + */ +public final class TestPriorityMerge { + + @Test + void rfc_example_server_overrides_urgency_preserves_incremental() { + // Client: u=5, i=true + final PriorityValue clientReq = PriorityValue.of(5, true); + // Server response: u=1 (i omitted => server does not change it) + final PriorityParams serverResp = PriorityParamsParser.parse("u=1"); + + final PriorityValue merged = PriorityMerge.merge(clientReq, serverResp); + assertEquals(1, merged.getUrgency()); + assertTrue(merged.isIncremental()); + } + + @Test + void response_absent_keeps_client_values() { + final PriorityValue clientReq = PriorityValue.of(2, false); + final PriorityValue merged = PriorityMerge.merge(clientReq, null); + + assertEquals(2, merged.getUrgency()); + assertFalse(merged.isIncremental()); + } + + @Test + void client_absent_uses_response_or_defaults() { + // Only server response provided + final PriorityParams resp1 = PriorityParamsParser.parse("u=0, i"); + final PriorityValue merged1 = PriorityMerge.merge(null, resp1); + assertEquals(0, merged1.getUrgency()); + assertTrue(merged1.isIncremental()); + + // Neither side provided => defaults + final PriorityValue merged2 = PriorityMerge.merge(null, null); + assertEquals(PriorityValue.DEFAULT_URGENCY, merged2.getUrgency()); + assertEquals(PriorityValue.DEFAULT_INCREMENTAL, merged2.isIncremental()); + } + + @Test + void server_sets_only_incremental_true_keeps_client_urgency() { + final PriorityValue clientReq = PriorityValue.of(4, false); + final PriorityParams serverResp = PriorityParamsParser.parse("i"); + + final PriorityValue merged = PriorityMerge.merge(clientReq, serverResp); + assertEquals(4, merged.getUrgency()); + assertTrue(merged.isIncremental()); + } + + @Test + void server_sets_only_urgency_keeps_client_incremental() { + final PriorityValue clientReq = PriorityValue.of(6, true); + final PriorityParams serverResp = PriorityParamsParser.parse("u=1"); + + final PriorityValue merged = PriorityMerge.merge(clientReq, serverResp); + assertEquals(1, merged.getUrgency()); + assertTrue(merged.isIncremental()); + } + + @Test + void out_of_range_server_urgency_is_ignored_but_other_members_apply() { + final PriorityValue clientReq = PriorityValue.of(3, false); + // u=9 is invalid -> ignored; i applies + final PriorityParams serverResp = PriorityParamsParser.parse("u=9, i"); + + final PriorityValue merged = PriorityMerge.merge(clientReq, serverResp); + assertEquals(3, merged.getUrgency()); // unchanged + assertTrue(merged.isIncremental()); // from server + } + + @Test + void null_safety_with_valid_inputs() { + // Server provides only u; client null -> defaults for i + final PriorityValue merged = PriorityMerge.merge(null, new PriorityParams(2, null)); + assertEquals(2, merged.getUrgency()); + assertEquals(PriorityValue.DEFAULT_INCREMENTAL, merged.isIncremental()); + } +} diff --git a/httpcore5/src/test/java/org/apache/hc/core5/http/priority/TestPriorityParamsParser.java b/httpcore5/src/test/java/org/apache/hc/core5/http/priority/TestPriorityParamsParser.java new file mode 100644 index 0000000000..8ece9692f1 --- /dev/null +++ b/httpcore5/src/test/java/org/apache/hc/core5/http/priority/TestPriorityParamsParser.java @@ -0,0 +1,66 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http.priority; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +final class TestPriorityParamsParser { + + @Test + void captures_presence_correctly() { + final PriorityParams p1 = PriorityParamsParser.parse("u=1"); + assertEquals(1, p1.getUrgency()); + assertNull(p1.getIncremental()); + + final PriorityParams p2 = PriorityParamsParser.parse("i"); + assertNull(p2.getUrgency()); + assertEquals(Boolean.TRUE, p2.getIncremental()); + + final PriorityParams p3 = PriorityParamsParser.parse("u=5, i"); + assertEquals(5, p3.getUrgency()); + assertEquals(Boolean.TRUE, p3.getIncremental()); + } + + @Test + void ignores_unknown_and_out_of_range() { + final PriorityParams p = PriorityParamsParser.parse("foo=bar, u=9, i=?0"); + assertNull(p.getUrgency()); // out-of-range ignored => absent + assertEquals(Boolean.FALSE, p.getIncremental()); + } + + @Test + void toValueWithDefaults_applies_defaults() { + final PriorityParams p = PriorityParamsParser.parse("i"); + final PriorityValue v = p.toValueWithDefaults(); + assertEquals(3, v.getUrgency()); + assertTrue(v.isIncremental()); + } +} diff --git a/httpcore5/src/test/java/org/apache/hc/core5/http/priority/TestPriorityValue.java b/httpcore5/src/test/java/org/apache/hc/core5/http/priority/TestPriorityValue.java new file mode 100644 index 0000000000..edd8ad8d79 --- /dev/null +++ b/httpcore5/src/test/java/org/apache/hc/core5/http/priority/TestPriorityValue.java @@ -0,0 +1,136 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http.priority; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Tests for PriorityValue (RFC 9218). + * + * @since 5.6 + */ +public final class TestPriorityValue { + + @Test + void defaultsAreAsSpecified() { + final PriorityValue v = PriorityValue.defaults(); + assertEquals(3, v.getUrgency()); + assertFalse(v.isIncremental()); + } + + @Test + void factoryOfBuildsEquivalentInstances() { + final PriorityValue v1 = new PriorityValue(0, true); + final PriorityValue v2 = PriorityValue.of(0, true); + assertEquals(v1, v2); + assertEquals(v1.hashCode(), v2.hashCode()); + } + + @Test + void acceptsFullValidRangeZeroToSeven() { + for (int u = 0; u <= 7; u++) { + final PriorityValue v = PriorityValue.of(u, false); + assertEquals(u, v.getUrgency()); + assertFalse(v.isIncremental()); + } + } + + @Test + void rejectsUrgencyBelowZero() { + final IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> new PriorityValue(-1, false)); + assertTrue(ex.getMessage().toLowerCase().contains("range")); + } + + @Test + void rejectsUrgencyAboveSeven() { + final IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> PriorityValue.of(8, true)); + assertTrue(ex.getMessage().toLowerCase().contains("range")); + } + + @Test + void withersReturnNewInstancesAndDoNotMutate() { + final PriorityValue base = PriorityValue.of(3, false); + final PriorityValue u0 = base.withUrgency(0); + final PriorityValue inc = base.withIncremental(true); + + // base remains unchanged + assertEquals(3, base.getUrgency()); + assertFalse(base.isIncremental()); + + // new values applied + assertEquals(0, u0.getUrgency()); + assertFalse(u0.isIncremental()); + + assertEquals(3, inc.getUrgency()); + assertTrue(inc.isIncremental()); + + // withers create distinct instances when changing a field + assertNotSame(base, u0); + assertNotSame(base, inc); + } + + @Test + void withUrgencyValidatesRange() { + final PriorityValue base = PriorityValue.defaults(); + assertThrows(IllegalArgumentException.class, () -> base.withUrgency(-1)); + assertThrows(IllegalArgumentException.class, () -> base.withUrgency(9)); + } + + @Test + void equalityAndHashAreBasedOnFields() { + final PriorityValue a = PriorityValue.of(2, true); + final PriorityValue b = PriorityValue.of(2, true); + final PriorityValue c = PriorityValue.of(2, false); + final PriorityValue d = PriorityValue.of(3, true); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + + assertNotEquals(a, c); + assertNotEquals(a, d); + assertNotEquals(c, d); + } + + @Test + void toStringIncludesFields() { + final PriorityValue v = PriorityValue.of(1, true); + final String s = v.toString(); + assertNotNull(s); + assertTrue(s.contains("u=1")); + assertTrue(s.contains("i=true")); + } +}