All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.servicetalk.http.netty.HeaderUtils Maven / Gradle / Ivy

The newest version!
/*
 * Copyright © 2018-2021 Apple Inc. and the ServiceTalk project authors
 *
 * Licensed 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.
 */
package io.servicetalk.http.netty;

import io.servicetalk.buffer.api.Buffer;
import io.servicetalk.buffer.api.CharSequences;
import io.servicetalk.concurrent.api.Publisher;
import io.servicetalk.concurrent.api.ScanMapper;
import io.servicetalk.concurrent.api.ScanMapper.MappedTerminal;
import io.servicetalk.http.api.EmptyHttpHeaders;
import io.servicetalk.http.api.HttpHeaderNames;
import io.servicetalk.http.api.HttpHeaders;
import io.servicetalk.http.api.HttpMetaData;
import io.servicetalk.http.api.HttpProtocolVersion;
import io.servicetalk.http.api.HttpRequestMetaData;
import io.servicetalk.http.api.HttpRequestMethod;
import io.servicetalk.http.api.StreamingHttpRequest;
import io.servicetalk.http.api.StreamingHttpResponse;

import io.netty.util.AsciiString;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.function.Predicate;
import javax.annotation.Nullable;

import static io.servicetalk.buffer.api.CharSequences.parseLong;
import static io.servicetalk.concurrent.api.Publisher.empty;
import static io.servicetalk.concurrent.api.Publisher.from;
import static io.servicetalk.concurrent.api.Publisher.fromIterable;
import static io.servicetalk.http.api.HeaderUtils.isTransferEncodingChunked;
import static io.servicetalk.http.api.HttpApiConversions.isPayloadEmpty;
import static io.servicetalk.http.api.HttpApiConversions.isSafeToAggregate;
import static io.servicetalk.http.api.HttpApiConversions.mayHaveTrailers;
import static io.servicetalk.http.api.HttpHeaderNames.CONTENT_LENGTH;
import static io.servicetalk.http.api.HttpHeaderNames.EXPECT;
import static io.servicetalk.http.api.HttpHeaderNames.TRANSFER_ENCODING;
import static io.servicetalk.http.api.HttpHeaderValues.CHUNKED;
import static io.servicetalk.http.api.HttpHeaderValues.CONTINUE;
import static io.servicetalk.http.api.HttpHeaderValues.ZERO;
import static io.servicetalk.http.api.HttpProtocolVersion.HTTP_1_1;
import static io.servicetalk.http.api.HttpRequestMethod.CONNECT;
import static io.servicetalk.http.api.HttpRequestMethod.HEAD;
import static io.servicetalk.http.api.HttpRequestMethod.PATCH;
import static io.servicetalk.http.api.HttpRequestMethod.POST;
import static io.servicetalk.http.api.HttpRequestMethod.PUT;
import static io.servicetalk.http.api.HttpRequestMethod.TRACE;
import static io.servicetalk.http.api.HttpResponseStatus.NO_CONTENT;
import static io.servicetalk.http.api.HttpResponseStatus.StatusClass.INFORMATIONAL_1XX;
import static io.servicetalk.http.api.HttpResponseStatus.StatusClass.SUCCESSFUL_2XX;

final class HeaderUtils {

    // Predicates that validate when `expect: 100-continue` feature has to be handled.
    static final Predicate REQ_EXPECT_CONTINUE = reqMetaData -> {
        // Versions prior HTTP/1.1 do not support Expect-Continue
        return reqMetaData.version().compareTo(HTTP_1_1) >= 0 &&
                reqMetaData.headers().containsIgnoreCase(EXPECT, CONTINUE);
    };

    static final Predicate OBJ_EXPECT_CONTINUE = msg -> {
        if (!(msg instanceof HttpRequestMetaData)) {
            return false;
        }
        return REQ_EXPECT_CONTINUE.test((HttpRequestMetaData) msg);
    };

    private HeaderUtils() {
        // no instances
    }

    static int indexOf(CharSequence sequence, char c, int fromIndex) {
        return sequence instanceof AsciiString ? ((AsciiString) sequence).indexOf(c, fromIndex) :
                CharSequences.indexOf(sequence, c, fromIndex);
    }

    static void removeTransferEncodingChunked(final HttpHeaders headers) {
        final Iterator itr = headers.valuesIterator(TRANSFER_ENCODING);
        while (itr.hasNext()) {
            if (io.netty.handler.codec.http.HttpHeaderValues.CHUNKED.contentEqualsIgnoreCase(itr.next())) {
                itr.remove();
            }
        }
    }

    static boolean canAddRequestContentLength(final StreamingHttpRequest request) {
        return canAddContentLength(request) && clientMaySendPayloadBodyFor(request.method());
    }

    static boolean canAddResponseContentLength(final StreamingHttpResponse response,
                                               final HttpRequestMethod requestMethod) {
        return canAddContentLength(response) && serverMaySendPayloadBodyFor(response.status().code(), requestMethod);
    }

    static boolean clientMaySendPayloadBodyFor(final HttpRequestMethod requestMethod) {
        // A client MUST NOT send a message body in a TRACE request.
        // https://tools.ietf.org/html/rfc7231#section-4.3.8
        return !TRACE.equals(requestMethod);
    }

    static boolean serverMaySendPayloadBodyFor(final int statusCode, final HttpRequestMethod requestMethod) {
        // (for HEAD) the server MUST NOT send a message body in the response.
        // https://tools.ietf.org/html/rfc7231#section-4.3.2
        return !HEAD.equals(requestMethod) && !isEmptyResponseStatus(statusCode)
                && !isEmptyConnectResponse(requestMethod, statusCode);
    }

    private static boolean canAddContentLength(final HttpMetaData metadata) {
        return (isPayloadEmpty(metadata) || isSafeToAggregate(metadata)) &&
                (metadata.version().major() > 1 || !mayHaveTrailers(metadata)) &&
                !hasContentHeaders(metadata.headers());
    }

    private static boolean alwaysAppendTrailers(final HttpProtocolVersion protocolVersion) {
        // Always include trailers for h2 because trailers are allowed even if content-length is present, so we use
        // trailers as a token to know the stream is done (even if they are empty).
        return protocolVersion.major() > 1;
    }

    static boolean shouldAppendTrailers(final HttpProtocolVersion protocolVersion, final HttpMetaData metaData) {
        return alwaysAppendTrailers(protocolVersion) ||
                (chunkedSupported(protocolVersion) && (mayHaveTrailers(metaData) ||
                        !metaData.headers().contains(CONTENT_LENGTH)));
    }

    static Publisher setRequestContentLength(final HttpProtocolVersion protocolVersion,
                                                     final StreamingHttpRequest request) {
        return setContentLength(request, request.messageBody(),
                shouldAddZeroContentLength(request.method()) ? HeaderUtils::updateContentLength :
                        HeaderUtils::updateRequestContentLengthNonZero, protocolVersion, /* propagateCancel */ false);
    }

    static Publisher setResponseContentLength(final HttpProtocolVersion protocolVersion,
                                                      final StreamingHttpResponse response) {
        return setContentLength(response, response.messageBody(), HeaderUtils::updateContentLength, protocolVersion,
                /* propagateCancel */ true);
    }

    private static void updateRequestContentLengthNonZero(final int contentLength, final HttpHeaders headers) {
        if (contentLength > 0) {
            headers.set(CONTENT_LENGTH, Integer.toString(contentLength));
        }
    }

    private static void updateContentLength(final int contentLength, final HttpHeaders headers) {
        assert contentLength >= 0;
        headers.set(CONTENT_LENGTH, contentLength == 0 ? ZERO : Integer.toString(contentLength));
    }

    static boolean shouldAddZeroContentLength(final HttpRequestMethod requestMethod) {
        // A user agent SHOULD NOT send a Content-Length header field when the request message does not contain a
        // payload body and the method semantics do not anticipate such a body.
        // https://tools.ietf.org/html/rfc7230#section-3.3.2
        return POST.equals(requestMethod) || PUT.equals(requestMethod) || PATCH.equals(requestMethod);
    }

    static boolean responseMayHaveContent(final int statusCode,
                                          final HttpRequestMethod requestMethod) {
        return !isEmptyResponseStatus(statusCode) && !isEmptyConnectResponse(requestMethod, statusCode);
    }

    static ScanMapper appendTrailersMapper() {
        return new ScanMapper() {
            private boolean sawHeaders;

            @Nullable
            @Override
            public Object mapOnNext(@Nullable final Object next) {
                if (next instanceof HttpHeaders) {
                    sawHeaders = true;
                }
                return next;
            }

            @Nullable
            @Override
            public MappedTerminal mapOnError(final Throwable cause) throws Throwable {
                throw cause;
            }

            @Nullable
            @Override
            public MappedTerminal mapOnComplete() {
                return sawHeaders ? null : EmptyHeadersComplete.INSTANCE;
            }
        };
    }

    private static final class EmptyHeadersComplete implements MappedTerminal {
        private static final MappedTerminal INSTANCE = new EmptyHeadersComplete();

        private EmptyHeadersComplete() {
        }

        @Override
        public HttpHeaders onNext() {
            return EmptyHttpHeaders.INSTANCE;
        }

        @Override
        public boolean onNextValid() {
            return true;
        }

        @Nullable
        @Override
        public Throwable terminal() {
            return null;
        }
    }

    static boolean emptyMessageBody(final HttpMetaData metadata, final Publisher messageBody) {
        return messageBody == empty() || emptyMessageBody(metadata);
    }

    static boolean emptyMessageBody(final HttpMetaData metadata) {
        return isPayloadEmpty(metadata) && !mayHaveTrailers(metadata);
    }

    static Publisher flatEmptyMessage(final HttpProtocolVersion protocolVersion,
                                              final HttpMetaData metadata,
                                              final Publisher messageBody,
                                              final boolean propagateCancel) {
        assert emptyMessageBody(metadata, messageBody);
        // HTTP/2 and above can write meta-data as a single frame with endStream=true flag. To check the version, use
        // HttpProtocolVersion from ConnectionInfo because HttpMetaData may have different version.
        final Publisher flatMessage =
                protocolVersion.major() > 1 || !shouldAppendTrailers(protocolVersion, metadata) ? from(metadata) :
                        from(metadata, EmptyHttpHeaders.INSTANCE);
        if (messageBody == empty()) {
            return flatMessage;
        }
        // Subscribe to the messageBody publisher to trigger any applied transformations, but ignore its content because
        // the PayloadInfo indicated it's effectively empty and does not contain trailers.
        return propagateCancel ?
                flatMessage.concatPropagateCancel(messageBody.ignoreElements()) :
                flatMessage.concat(messageBody.ignoreElements());
    }

    private static final class ContentLengthList extends ArrayList {
        private static final long serialVersionUID = -6593491776432933503L;
        int contentLength;

        ContentLengthList(int contentLength, int arraySize) {
            super(arraySize);
            this.contentLength = contentLength;
        }

        @Override
        public int hashCode() {
            return 31 * contentLength + super.hashCode();
        }

        @Override
        public boolean equals(Object o) {
            return o instanceof ContentLengthList && ((ContentLengthList) o).contentLength == contentLength &&
                    super.equals(o);
        }
    }

    private static Publisher setContentLength(final HttpMetaData metadata,
                                                      final Publisher messageBody,
                                                      final BiIntConsumer contentLengthUpdater,
                                                      final HttpProtocolVersion protocolVersion,
                                                      final boolean propagateCancel) {
        if (emptyMessageBody(metadata, messageBody)) {
            contentLengthUpdater.apply(0, metadata.headers());
            return flatEmptyMessage(protocolVersion, metadata, messageBody, propagateCancel);
        }
        return messageBody.collect(() -> null, (reduction, item) -> {
            if (reduction == null) {
                // avoid allocating a list if the Publisher emits only a single Buffer
                return item;
            }
            final ContentLengthList items;
            if (reduction instanceof ContentLengthList) {
                @SuppressWarnings("unchecked")
                ContentLengthList itemsUnchecked = (ContentLengthList) reduction;
                items = itemsUnchecked;
            } else {
                // this method is called if the payload has been aggregated, we expect .
                items = new ContentLengthList<>(
                        reduction instanceof Buffer ? ((Buffer) reduction).readableBytes() : 0, 2);
                items.add(reduction);
            }
            if (item instanceof Buffer) {
                items.contentLength += ((Buffer) item).readableBytes();
            }

            items.add(item);
            return items;
        }).flatMapPublisher(reduction -> {
            int contentLength = 0;
            final Publisher flatRequest;
            // We will insert content-length header but haven't yet because we need to compute the value. So no need
            // to pass headers to determine if trailers should be appended.
            final boolean appendTrailers = alwaysAppendTrailers(protocolVersion);
            if (reduction == null) {
                flatRequest = appendTrailers ? from(metadata, EmptyHttpHeaders.INSTANCE) : from(metadata);
            } else if (reduction instanceof Buffer) {
                final Buffer buffer = (Buffer) reduction;
                contentLength = buffer.readableBytes();
                if (contentLength == 0) {
                    flatRequest = appendTrailers ? from(metadata, EmptyHttpHeaders.INSTANCE) : from(metadata);
                } else {
                    flatRequest = appendTrailers ? from(metadata, buffer, EmptyHttpHeaders.INSTANCE) :
                            from(metadata, buffer);
                }
            } else if (reduction instanceof ContentLengthList) {
                @SuppressWarnings("unchecked")
                final ContentLengthList items = (ContentLengthList) reduction;
                contentLength = items.contentLength;
                if (appendTrailers && !(items.get(items.size() - 1) instanceof HttpHeaders)) {
                    items.add(EmptyHttpHeaders.INSTANCE);
                }
                flatRequest = Publisher.from(metadata).concat(fromIterable(items));
            } else if (reduction instanceof HttpHeaders) {
                flatRequest = from(metadata, reduction);
            } else {
                throw new IllegalArgumentException("unsupported payload chunk type: " + reduction);
            }
            contentLengthUpdater.apply(contentLength, metadata.headers());
            return flatRequest;
        });
    }

    static void addResponseTransferEncodingIfNecessary(final StreamingHttpResponse response,
                                                       final HttpRequestMethod requestMethod) {
        if (serverMaySendPayloadBodyFor(response.status().code(), requestMethod) &&
                canAddTransferEncodingChunked(response)) {
            response.headers().add(TRANSFER_ENCODING, CHUNKED);
        }
    }

    static void addRequestTransferEncodingIfNecessary(final StreamingHttpRequest request) {
        if (clientMaySendPayloadBodyFor(request.method()) && canAddTransferEncodingChunked(request)) {
            request.headers().add(TRANSFER_ENCODING, CHUNKED);
        }
    }

    private static boolean canAddTransferEncodingChunked(final HttpMetaData metaData) {
        final HttpHeaders headers = metaData.headers();
        return chunkedSupported(metaData.version()) &&
                (mayHaveTrailers(metaData) || !headers.contains(CONTENT_LENGTH)) &&
                !isTransferEncodingChunked(headers);
    }

    private static boolean chunkedSupported(final HttpProtocolVersion version) {
        return version.major() == 1 && version.minor() > 0;
    }

    static boolean hasContentHeaders(final HttpHeaders headers) {
        return headers.contains(CONTENT_LENGTH) || isTransferEncodingChunked(headers);
    }

    private static boolean isEmptyConnectResponse(final HttpRequestMethod requestMethod, final int statusCode) {
        // A server MUST NOT send any Transfer-Encoding or Content-Length header fields in a 2xx (Successful) response
        // to CONNECT.
        // https://tools.ietf.org/html/rfc7231#section-4.3.6
        return CONNECT.equals(requestMethod) && SUCCESSFUL_2XX.contains(statusCode);
    }

    private static boolean isEmptyResponseStatus(final int statusCode) {
        // A server MUST NOT send a Content-Length header field in any response with a status code of
        // 1xx (Informational) or 204 (No Content).
        // https://tools.ietf.org/html/rfc7230#section-3.3.2
        return INFORMATIONAL_1XX.contains(statusCode) || statusCode == NO_CONTENT.code();
    }

    /**
     * Extracts the {@link HttpHeaderNames#CONTENT_LENGTH content-length} value from the passed values iterator.
     * 

* This utility validates that there is no more than one {@link HttpHeaderNames#CONTENT_LENGTH content-length} * header present and it has a valid number format. * * @param iterator the {@link Iterator} over content-length header values * @return the normalized content-length from the headers or {@code -1} if no content-length header is found * @throws IllegalArgumentException if multiple content-length header values are present */ @SuppressWarnings("PMD.PreserveStackTrace") static long contentLength(final Iterator iterator) { if (!iterator.hasNext()) { return -1; } // Guard against multiple Content-Length headers as stated in // https://tools.ietf.org/html/rfc7230#section-3.3.2: // // If a message is received that has multiple Content-Length header // fields with field-values consisting of the same decimal value, or a // single Content-Length header field with a field value containing a // list of identical decimal values (e.g., "Content-Length: 42, 42"), // indicating that duplicate Content-Length header fields have been // generated or combined by an upstream message processor, then the // recipient MUST either reject the message as invalid or replace the // duplicated field-values with a single valid Content-Length field // containing that decimal value prior to determining the message body // length or forwarding the message. CharSequence firstValue = iterator.next(); if (iterator.hasNext()) { throw multipleCL(firstValue, iterator); } char firstChar = firstValue.charAt(0); if (firstChar < '0' || firstChar > '9') { // allow numbers only in ASCII or ISO-8859-1 encoding // prevent signed content-length values: -digit or +digit throw malformedCL(firstValue); } final long value; try { // optimistically assume the value can be parsed to skip indexOf check value = parseLong(firstValue); } catch (NumberFormatException e) { if (CharSequences.indexOf(firstValue, ',', 0) >= 0) { throw multipleCL(firstValue, null); } throw malformedCL(firstValue); } return value; } private static IllegalArgumentException malformedCL(final CharSequence value) { return new IllegalArgumentException("Malformed 'content-length' value: " + value); } private static IllegalArgumentException multipleCL(final CharSequence firstValue, @Nullable final Iterator iterator) { final CharSequence allClValues; if (iterator == null) { allClValues = firstValue; } else { final StringBuilder sb = new StringBuilder(firstValue.length() + 8).append(firstValue); while (iterator.hasNext()) { sb.append(", ").append(iterator.next()); } allClValues = sb; } return new IllegalArgumentException("Multiple content-length values found: " + allClValues); } /** * A special consumer that takes an {@code int} and a custom argument and returns the result. * * @param The other argument to this function. */ @FunctionalInterface private interface BiIntConsumer { /** * Evaluates this consumer on the given arguments. * * @param i The {@code int} argument. * @param t The {@link T} argument. */ void apply(int i, T t); } }