From eb463bd31c76ef535007e47ba021f575ba3f62f2 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Wed, 1 Oct 2025 10:50:38 +0200 Subject: [PATCH] HTTP/2: RFC 9218 Priority support Add PRIORITY_UPDATE (0x10) and SETTINGS_NO_RFC7540_PRIORITIES (0x9). Client emits before HEADERS on opt-in; server accepts and applies. Wire into multiplexer + Priority header utils + tests. --- .../apache/hc/core5/http2/config/H2Param.java | 26 +- .../hc/core5/http2/frame/FrameFactory.java | 5 + .../hc/core5/http2/frame/FrameType.java | 29 +- .../impl/nio/AbstractH2StreamMultiplexer.java | 96 ++++++- .../impl/nio/ClientH2StreamMultiplexer.java | 3 +- .../http2/priority/PriorityFormatter.java | 63 +++++ .../core5/http2/priority/PriorityMerge.java | 51 ++++ .../core5/http2/priority/PriorityParams.java | 63 +++++ .../http2/priority/PriorityParamsParser.java | 152 ++++++++++ .../core5/http2/priority/PriorityParser.java | 178 ++++++++++++ .../core5/http2/priority/PriorityValue.java | 92 ++++++ .../http2/protocol/H2RequestPriority.java | 121 ++++++++ .../examples/ClassicH2PriorityExample.java | 169 +++++++++++ .../nio/TestAbstractH2StreamMultiplexer.java | 262 ++++++++++++++++++ .../http2/priority/TestPriorityMerge.java | 110 ++++++++ .../priority/TestPriorityParamsParser.java | 66 +++++ .../TestPriorityParserAndFormatter.java | 170 ++++++++++++ .../http2/priority/TestPriorityValue.java | 131 +++++++++ .../http2/protocol/H2RequestPriorityTest.java | 174 ++++++++++++ .../org/apache/hc/core5/http/HttpHeaders.java | 5 + 20 files changed, 1943 insertions(+), 23 deletions(-) create mode 100644 httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityFormatter.java create mode 100644 httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityMerge.java create mode 100644 httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityParams.java create mode 100644 httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityParamsParser.java create mode 100644 httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityParser.java create mode 100644 httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityValue.java 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/priority/TestPriorityMerge.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityParamsParser.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityParserAndFormatter.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityValue.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/protocol/H2RequestPriorityTest.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..59fef9efe0 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(); } -} +} \ No newline at end of file diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameFactory.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameFactory.java index 0b4a8f7233..0efe74e732 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameFactory.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/frame/FrameFactory.java @@ -111,4 +111,9 @@ public RawFrame createWindowUpdate(final int streamId, final int increment) { return new RawFrame(FrameType.WINDOW_UPDATE.getValue(), 0, streamId, payload); } + public RawFrame createPriorityUpdate(final ByteBuffer payload) { + // type 0x10, flags 0, streamId 0 (connection control stream) + return new RawFrame(FrameType.PRIORITY_UPDATE.getValue(), 0, 0, payload); + } + } 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..2253e24ca6 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,19 @@ 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; + } +} \ No newline at end of file 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 f4f205628d..40e98f0a46 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 @@ -31,10 +31,13 @@ import java.nio.BufferOverflowException; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; +import java.nio.charset.StandardCharsets; import java.util.Deque; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicInteger; @@ -66,6 +69,7 @@ import org.apache.hc.core5.http.nio.command.ShutdownCommand; import org.apache.hc.core5.http.protocol.HttpContext; import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http2.H2ConnectionException; import org.apache.hc.core5.http2.H2Error; import org.apache.hc.core5.http2.H2StreamResetException; @@ -83,6 +87,9 @@ import org.apache.hc.core5.http2.nio.AsyncPingHandler; import org.apache.hc.core5.http2.nio.command.PingCommand; import org.apache.hc.core5.http2.nio.command.PushResponseCommand; +import org.apache.hc.core5.http2.priority.PriorityParamsParser; +import org.apache.hc.core5.http2.priority.PriorityValue; +import org.apache.hc.core5.http2.priority.PriorityFormatter; import org.apache.hc.core5.io.CloseMode; import org.apache.hc.core5.reactor.Command; import org.apache.hc.core5.reactor.ProtocolIOSession; @@ -94,7 +101,7 @@ abstract class AbstractH2StreamMultiplexer implements Identifiable, HttpConnection { - private static final long CONNECTION_WINDOW_LOW_MARK = 10 * 1024 * 1024; // 10 MiB + private static final long CONNECTION_WINDOW_LOW_MARK = 10 * 1024 * 1024; enum ConnectionHandshake { READY, ACTIVE, GRACEFUL_SHUTDOWN, SHUTDOWN } enum SettingsHandshake { READY, TRANSMITTED, ACKED } @@ -133,6 +140,9 @@ enum SettingsHandshake { READY, TRANSMITTED, ACKED } private EndpointDetails endpointDetails; private boolean goAwayReceived; + private final Map priorities = new ConcurrentHashMap<>(); + private volatile boolean peerNoRfc7540Priorities; + AbstractH2StreamMultiplexer( final ProtocolIOSession ioSession, final FrameFactory frameFactory, @@ -892,7 +902,6 @@ private void consumeFrame(final RawFrame frame) throws HttpException, IOExceptio consumeSettingsFrame(payload); remoteSettingState = SettingsHandshake.TRANSMITTED; } - // Send ACK final RawFrame response = frameFactory.createSettingsAck(); commitFrame(response); remoteSettingState = SettingsHandshake.ACKED; @@ -900,7 +909,6 @@ private void consumeFrame(final RawFrame frame) throws HttpException, IOExceptio } break; case PRIORITY: - // Stream priority not supported break; case PUSH_PROMISE: { acceptPushFrame(); @@ -985,6 +993,29 @@ private void consumeFrame(final RawFrame frame) throws HttpException, IOExceptio } ioSession.setEvent(SelectionKey.OP_WRITE); break; + case PRIORITY_UPDATE: { + if (streamId != 0) { + throw new H2ConnectionException(H2Error.PROTOCOL_ERROR, "PRIORITY_UPDATE must be on stream 0"); + } + final ByteBuffer payload = frame.getPayload(); + if (payload == null || payload.remaining() < 4) { + throw new H2ConnectionException(H2Error.FRAME_SIZE_ERROR, "Invalid PRIORITY_UPDATE payload"); + } + final int prioritizedId = payload.getInt() & 0x7fffffff; + final int len = payload.remaining(); + final String field; + if (len > 0) { + final byte[] b = new byte[len]; + payload.get(b); + field = new String(b, StandardCharsets.US_ASCII); + } else { + field = ""; + } + final PriorityValue pv = PriorityParamsParser.parse(field).toValueWithDefaults(); + priorities.put(prioritizedId, pv); + requestSessionOutput(); + } + break; } } @@ -1049,7 +1080,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(); } @@ -1058,6 +1088,7 @@ private void consumeHeaderFrame(final RawFrame frame, final H2Stream stream) thr if (streamListener != null) { streamListener.onHeaderInput(this, streamId, headers); } + recordPriorityFromHeaders(streamId, headers); stream.consumeHeader(headers, frame.isFlagSet(FrameFlag.END_STREAM)); } else { continuation.copyPayload(payload); @@ -1076,6 +1107,7 @@ private void consumeContinuationFrame(final RawFrame frame, final H2Stream strea if (streamListener != null) { streamListener.onHeaderInput(this, streamId, headers); } + recordPriorityFromHeaders(streamId, headers); if (continuation.type == FrameType.PUSH_PROMISE.getValue()) { stream.consumePromise(headers); } else { @@ -1132,6 +1164,9 @@ private void consumeSettingsFrame(final ByteBuffer payload) throws IOException { throw new H2ConnectionException(H2Error.PROTOCOL_ERROR, ex.getMessage()); } break; + case SETTINGS_NO_RFC7540_PRIORITIES: + peerNoRfc7540Priorities = value == 1; + break; } } } @@ -1324,6 +1359,38 @@ H2Stream createStream(final H2StreamChannel channel, final H2StreamHandler strea return streams.createActive(channel, streamHandler); } + public final void sendPriorityUpdate(final int prioritizedStreamId, final PriorityValue value) throws IOException { + if (value == null) { + return; + } + final String field = PriorityFormatter.format(value); + if (field == null) { + return; + } + final byte[] ascii = field.getBytes(StandardCharsets.US_ASCII); + final ByteArrayBuffer buf = new ByteArrayBuffer(4 + ascii.length); + buf.append((byte) (prioritizedStreamId >> 24)); + buf.append((byte) (prioritizedStreamId >> 16)); + buf.append((byte) (prioritizedStreamId >> 8)); + buf.append((byte) prioritizedStreamId); + buf.append(ascii, 0, ascii.length); + final RawFrame frame = frameFactory.createPriorityUpdate(ByteBuffer.wrap(buf.array(), 0, buf.length())); + commitFrame(frame); + } + + private void recordPriorityFromHeaders(final int streamId, final List headers) { + if (headers == null || headers.isEmpty()) { + return; + } + for (final Header h : headers) { + if (HttpHeaders.PRIORITY.equalsIgnoreCase(h.getName())) { + final PriorityValue pv = PriorityParamsParser.parse(h.getValue()).toValueWithDefaults(); + priorities.put(streamId, pv); + break; + } + } + } + class H2StreamChannelImpl implements H2StreamChannel { private final int id; @@ -1371,6 +1438,25 @@ public void submit(final List
headers, final boolean endStream) throws I return; } ensureNotClosed(); + if (peerNoRfc7540Priorities && streams.isSameSide(id)) { + for (final Header h : headers) { + if (HttpHeaders.PRIORITY.equalsIgnoreCase(h.getName())) { + final byte[] ascii = h.getValue() != null + ? h.getValue().getBytes(StandardCharsets.US_ASCII) + : new byte[0]; + final ByteArrayBuffer b = new ByteArrayBuffer(4 + ascii.length); + b.append((byte) (id >> 24)); + b.append((byte) (id >> 16)); + b.append((byte) (id >> 8)); + b.append((byte) id); + b.append(ascii, 0, ascii.length); + final ByteBuffer pl = ByteBuffer.wrap(b.array(), 0, b.length()); + final RawFrame priUpd = new RawFrame(FrameType.PRIORITY_UPDATE.getValue(), 0, 0, pl); + commitFrameInternal(priUpd); + break; + } + } + } commitHeaders(id, headers, endStream); if (endStream) { localClosed = true; @@ -1508,4 +1594,4 @@ public String toString() { } -} +} \ 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 24ecd70df8..ab8ecc48a0 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 @@ -103,7 +103,8 @@ H2Setting[] generateSettings(final H2Config localConfig) { 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()), + new H2Setting(H2Param.SETTINGS_NO_RFC7540_PRIORITIES, 1) }; } diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityFormatter.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityFormatter.java new file mode 100644 index 0000000000..635b0dead3 --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityFormatter.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.http2.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); + } +} \ No newline at end of file diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityMerge.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityMerge.java new file mode 100644 index 0000000000..ff4abc4049 --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/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.http2.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-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityParams.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityParams.java new file mode 100644 index 0000000000..79bcdd4d61 --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/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.http2.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-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityParamsParser.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityParamsParser.java new file mode 100644 index 0000000000..ae3393fb46 --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityParamsParser.java @@ -0,0 +1,152 @@ +/* + * ==================================================================== + * 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.priority; + +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.message.MessageSupport; +import org.apache.hc.core5.http.message.ParserCursor; +import org.apache.hc.core5.util.Tokenizer; + +@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 CharSequence src, final ParserCursor c) { + Integer u = null; + Boolean i = null; + while (!c.atEnd()) { + TK.skipWhiteSpace(src, c); + if (c.atEnd()) { + break; + } + final String k = TK.parseToken(src, c, KEY_DELIMS); + if (k == null || k.isEmpty()) { + skipToNextItem(src, c); + continue; + } + TK.skipWhiteSpace(src, c); + final char ch = c.atEnd() ? 0 : src.charAt(c.getPos()); + if (ch == '=') { + c.updatePos(c.getPos() + 1); + TK.skipWhiteSpace(src, c); + if ("u".equalsIgnoreCase(k)) { + final String t = TK.parseToken(src, c, VALUE_DELIMS); + try { + final int v = Integer.parseInt(t); + if (v >= 0 && v <= 7) { + u = v; + } + } catch (final Exception ignore) { + } + } else if ("i".equalsIgnoreCase(k)) { + final char b = c.atEnd() ? 0 : src.charAt(c.getPos()); + if (b == '?') { + c.updatePos(c.getPos() + 1); + final char v = c.atEnd() ? 0 : src.charAt(c.getPos()); + if (v == '1') { + i = Boolean.TRUE; + c.updatePos(c.getPos() + 1); + } else if (v == '0') { + i = Boolean.FALSE; + c.updatePos(c.getPos() + 1); + } + } else { + final String t = TK.parseToken(src, c, VALUE_DELIMS); + if ("1".equals(t)) { + i = Boolean.TRUE; + } else if ("0".equals(t)) { + i = Boolean.FALSE; + } + } + } else { + TK.parseToken(src, c, VALUE_DELIMS); + } + skipParamsThenNextItem(src, c); + } else { + if ("i".equalsIgnoreCase(k)) { + i = Boolean.TRUE; + } + skipParamsThenNextItem(src, c); + } + } + return new PriorityParams(u, i); + } + + public static PriorityParams parse(final String value) { + if (value == null || value.isEmpty()) { + return new PriorityParams(null, null); + } + final ParserCursor c = new ParserCursor(0, value.length()); + return parse(value, c); + } + + public static PriorityParams parse(final Header header) { + if (header == null) { + return new PriorityParams(null, null); + } + final PriorityParams[] box = new PriorityParams[1]; + MessageSupport.parseHeader(header, (seq, cur) -> box[0] = parse(seq, cur)); + return box[0] != null ? box[0] : new PriorityParams(null, null); + } + + private static void skipToNextItem(final CharSequence buf, final ParserCursor 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 ParserCursor 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-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityParser.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityParser.java new file mode 100644 index 0000000000..3849a65c73 --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityParser.java @@ -0,0 +1,178 @@ +/* + * ==================================================================== + * 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.priority; + +import java.util.Locale; + +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.message.MessageSupport; +import org.apache.hc.core5.http.message.ParserCursor; +import org.apache.hc.core5.util.Tokenizer; + +@Internal +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 Header header) { + if (header == null) { + return PriorityValue.defaults(); + } + final PriorityValue[] out = new PriorityValue[1]; + MessageSupport.parseHeader(header, (seq, cur) -> out[0] = parse(seq, cur)); + return out[0] != null ? out[0] : PriorityValue.defaults(); + } + + public static PriorityValue parse(final String headerValue) { + if (headerValue == null || headerValue.isEmpty()) { + return PriorityValue.defaults(); + } + final ParserCursor c = new ParserCursor(0, headerValue.length()); + return parse(headerValue, c); + } + + public static PriorityValue parse(final CharSequence src, final ParserCursor cursor) { + int urgency = PriorityValue.DEFAULT_URGENCY; + boolean incremental = PriorityValue.DEFAULT_INCREMENTAL; + + while (!cursor.atEnd()) { + TK.skipWhiteSpace(src, cursor); + if (cursor.atEnd()) { + break; + } + + final String rawKey = TK.parseToken(src, cursor, KEY_DELIMS); + if (rawKey == null || rawKey.isEmpty()) { + skipToNextItem(src, cursor); + continue; + } + final String key = rawKey.toLowerCase(Locale.ROOT); + + TK.skipWhiteSpace(src, cursor); + final char ch = currentChar(src, cursor); + + if (ch == '=') { + cursor.updatePos(cursor.getPos() + 1); + TK.skipWhiteSpace(src, cursor); + + if ("u".equals(key)) { + final String numTok = TK.parseToken(src, cursor, VALUE_DELIMS); + final Integer u = safeParseInt(numTok); + if (u != null && u >= 0 && u <= 7) { + urgency = u; + } + } else if ("i".equals(key)) { + final char b = currentChar(src, cursor); + if (b == '?') { + cursor.updatePos(cursor.getPos() + 1); + final char v = currentChar(src, 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(src, cursor, VALUE_DELIMS); + if ("1".equals(tok)) { + incremental = true; + } else if ("0".equals(tok)) { + incremental = false; + } + } + } else { + TK.parseToken(src, cursor, VALUE_DELIMS); // ignore unknown member + } + skipParamsThenNextItem(src, cursor); + + } else { + if ("i".equals(key)) { + incremental = true; // bare true + } + skipParamsThenNextItem(src, cursor); + } + } + return PriorityValue.of(urgency, incremental); + } + + 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-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityValue.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityValue.java new file mode 100644 index 0000000000..379d7966c3 --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/priority/PriorityValue.java @@ -0,0 +1,92 @@ +/* + * ==================================================================== + * 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.priority; + +import java.util.Objects; + +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 + '}'; + } +} \ No newline at end of file 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..f0d0367c1a --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/protocol/H2RequestPriority.java @@ -0,0 +1,121 @@ +/* + * ==================================================================== + * 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.Experimental; +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.protocol.HttpContext; +import org.apache.hc.core5.http.protocol.HttpCoreContext; +import org.apache.hc.core5.http2.priority.PriorityFormatter; +import org.apache.hc.core5.http2.priority.PriorityValue; +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 + */ +@Experimental +@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..c235db0a18 --- /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.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.priority.PriorityValue; +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); + } + } + } + } + } +} \ 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 4025ae28ef..0089a12e51 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 @@ -27,14 +27,19 @@ package org.apache.hc.core5.http2.impl.nio; +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.CharCodingSupport; import org.apache.hc.core5.http.message.BasicHeader; @@ -772,5 +777,262 @@ void testStreamRemoteResetNoErrorRemoteAlreadyClosed() throws Exception { Mockito.verify(streamHandler, Mockito.never()).failed(ArgumentMatchers.any()); } + @Test + void testPriorityUpdateInputAccepted() throws Exception { + final AbstractH2StreamMultiplexer mux = new H2StreamMultiplexerImpl( + protocolIOSession, + FRAME_FACTORY, + StreamIdGenerator.ODD, + httpProcessor, + CharCodingConfig.DEFAULT, + H2Config.custom().setMaxFrameSize(FrameConsts.MIN_FRAME_SIZE).build(), + h2StreamListener, + () -> streamHandler); + + final WritableByteChannelMock writable = new WritableByteChannelMock(1024); + final FrameOutputBuffer fob = 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); // prioritized stream id = 1 + payload.put(ascii); + payload.flip(); + + final RawFrame priUpd = new RawFrame(FrameType.PRIORITY_UPDATE.getValue(), 0, 0, payload); + fob.write(priUpd, writable); + final byte[] bytes = writable.toByteArray(); + + // Should NOT throw; server must accept PRIORITY_UPDATE from client + Assertions.assertDoesNotThrow(() -> mux.onInput(ByteBuffer.wrap(bytes))); + + // Listener sees the incoming frame + Mockito.verify(h2StreamListener).onFrameInput( + ArgumentMatchers.same(mux), + ArgumentMatchers.eq(0), + ArgumentMatchers.any()); + } + + // Helper: minimal stream handler that sends our headers once + 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() { } + } + + // Small struct + parser to decode the frames we capture from writes + 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; + 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; + } + + // 2) Client emits PRIORITY_UPDATE BEFORE HEADERS when Priority header present + @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()); + + final H2Config h2Config = H2Config.custom().build(); + final H2StreamMultiplexerImpl mux = new H2StreamMultiplexerImpl( + protocolIOSession, FRAME_FACTORY, StreamIdGenerator.ODD, + httpProcessor, CharCodingConfig.DEFAULT, h2Config, h2StreamListener, () -> streamHandler); + + // Start connection (sends our SETTINGS) + mux.onConnect(); + writes.clear(); // ignore noise from onConnect + + // Pretend server SETTINGS includes NO_RFC7540=1 (opts in to new scheme) + 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); + 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(); // drop the ACK we sent + + // Create a locally-initiated stream and a handler that will submit headers with Priority + final H2StreamChannel ch = mux.createChannel(1); + 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") + ); + mux.createStream(ch, new PriorityHeaderSender(ch, reqHeaders, true)); + + // Drive output so the handler submits + mux.onOutput(); + + // Stitch captured bytes and parse frames + final int total = writes.stream().mapToInt(a -> a.length).sum(); + 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; } + final List frames = parseFrames(all); + + // Find first PRIORITY_UPDATE (type 0x10, sid 0) and first HEADERS for stream 1 + int idxPriUpd = -1, 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 precede HEADERS"); + } + + // 3) Optional policy: suppress emission when peer’s first SETTINGS omits 0x9 + @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 H2StreamMultiplexerImpl mux = new H2StreamMultiplexerImpl( + protocolIOSession, FRAME_FACTORY, StreamIdGenerator.ODD, + httpProcessor, CharCodingConfig.DEFAULT, H2Config.custom().build(), + h2StreamListener, () -> streamHandler); + + mux.onConnect(); + writes.clear(); + + // Server SETTINGS without 0x9 + final WritableByteChannelMock w = new WritableByteChannelMock(256); + final FrameOutputBuffer fob = new FrameOutputBuffer(16 * 1024); + fob.write(new RawFrame(FrameType.SETTINGS.getValue(), 0, 0, ByteBuffer.allocate(0)), w); + mux.onInput(ByteBuffer.wrap(w.toByteArray())); + writes.clear(); // drop our ACK + + // Create local stream that will send Priority header + final H2StreamChannel ch = mux.createChannel(1); + 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") + ); + mux.createStream(ch, new PriorityHeaderSender(ch, reqHeaders, true)); + + 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); + Assertions.assertTrue(frames.stream().noneMatch(f -> f.type == FrameType.PRIORITY_UPDATE.getValue()), + "PRIORITY_UPDATE must be suppressed when peer didn't send NO_RFC7540 (policy)"); + } + + // 4) Continue emission when peer sends NO_RFC7540=1 + @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 H2StreamMultiplexerImpl mux = new H2StreamMultiplexerImpl( + protocolIOSession, FRAME_FACTORY, StreamIdGenerator.ODD, + httpProcessor, CharCodingConfig.DEFAULT, H2Config.custom().build(), + h2StreamListener, () -> streamHandler); + + mux.onConnect(); + writes.clear(); + + // 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); + pl.putInt(1); + pl.flip(); + fob.write(new RawFrame(FrameType.SETTINGS.getValue(), 0, 0, pl), w); + mux.onInput(ByteBuffer.wrap(w.toByteArray())); + writes.clear(); // drop ACK + + final H2StreamChannel ch = mux.createChannel(1); + 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") + ); + mux.createStream(ch, new PriorityHeaderSender(ch, reqHeaders, true)); + + 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 be emitted when NO_RFC7540=1"); + } + } diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityMerge.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityMerge.java new file mode 100644 index 0000000000..ecf7c7619a --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityMerge.java @@ -0,0 +1,110 @@ +/* + * ==================================================================== + * 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.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; + +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()); + } +} \ No newline at end of file diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityParamsParser.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityParamsParser.java new file mode 100644 index 0000000000..9615ff513e --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/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.http2.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-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityParserAndFormatter.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityParserAndFormatter.java new file mode 100644 index 0000000000..ae70440f01 --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityParserAndFormatter.java @@ -0,0 +1,170 @@ +/* + * ==================================================================== + * 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.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.apache.hc.core5.http.Header; +import org.junit.jupiter.api.Test; + +public final class TestPriorityParserAndFormatter { + + // ---- 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((Header) 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); + } + } + } +} \ No newline at end of file diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityValue.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityValue.java new file mode 100644 index 0000000000..af70767b07 --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/priority/TestPriorityValue.java @@ -0,0 +1,131 @@ +/* + * ==================================================================== + * 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.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; + +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")); + } +} \ No newline at end of file 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..b6d8524dfc --- /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.protocol.HttpCoreContext; +import org.apache.hc.core5.http2.priority.PriorityValue; +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"; + }