001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import com.soklet.internal.spring.LinkedCaseInsensitiveMap;
020import org.jspecify.annotations.NonNull;
021import org.jspecify.annotations.Nullable;
022
023import javax.annotation.concurrent.ThreadSafe;
024import java.time.Instant;
025import java.util.Collections;
026import java.util.LinkedHashSet;
027import java.util.Map;
028import java.util.Optional;
029import java.util.Set;
030
031import static java.lang.String.format;
032import static java.util.Objects.requireNonNull;
033
034/**
035 * Utility methods for evaluating HTTP conditional requests against a selected dynamic representation.
036 * <p>
037 * This helper supports cache validation and optimistic concurrency for application responses without forcing resource
038 * methods into {@link MarshaledResponse}. Applications still own representation validators: choose the current
039 * {@link EntityTag}, {@code Last-Modified} instant, cache headers, and normal success response.
040 * <p>
041 * When a request precondition requires an immediate response, {@link #responseFor(Request, EntityTag, Instant)}
042 * returns a bodyless {@link Response} with status {@code 304 Not Modified} or {@code 412 Precondition Failed}.
043 * Otherwise it returns {@link Optional#empty()} and the application should build its normal response.
044 * <p>
045 * Malformed entity-tag preconditions fail closed when they protect writes: malformed {@code If-Match} returns
046 * {@code 412 Precondition Failed}, and malformed {@code If-None-Match} does the same for non-{@code GET}/{@code HEAD}
047 * requests. Malformed {@code If-None-Match} on {@code GET} and {@code HEAD} is treated as a cache miss.
048 *
049 * @author <a href="https://www.revetkn.com">Mark Allen</a>
050 */
051@ThreadSafe
052public final class ConditionalRequests {
053        @NonNull
054        private static final Set<@NonNull String> CONTROLLED_EXTRA_HEADER_NAMES;
055
056        static {
057                CONTROLLED_EXTRA_HEADER_NAMES = Set.of(
058                                "content-length",
059                                "content-type",
060                                "etag",
061                                "last-modified",
062                                "transfer-encoding"
063                );
064        }
065
066        private ConditionalRequests() {
067                // Non-instantiable
068        }
069
070        /**
071         * Evaluates conditional request headers against the supplied validators.
072         *
073         * @param request      the request whose conditional headers should be evaluated
074         * @param entityTag    the current representation's entity tag, or {@code null} if unavailable
075         * @param lastModified the current representation's last-modified instant, or {@code null} if unavailable
076         * @return a short-circuit response, or {@link Optional#empty()} when the application should continue normally
077         */
078        @NonNull
079        public static Optional<Response> responseFor(@NonNull Request request,
080                                                                                                                                                                                 @Nullable EntityTag entityTag,
081                                                                                                                                                                                 @Nullable Instant lastModified) {
082                return responseFor(request, entityTag, lastModified, null);
083        }
084
085        /**
086         * Evaluates conditional request headers against the supplied validators.
087         * <p>
088         * {@code extraHeaders} are included on short-circuit {@code 304} and {@code 412} responses. They are intended for
089         * response metadata such as {@code Cache-Control} or {@code Vary}; validator and body-framing headers are rejected
090         * because they are controlled by this helper.
091         *
092         * @param request      the request whose conditional headers should be evaluated
093         * @param entityTag    the current representation's entity tag, or {@code null} if unavailable
094         * @param lastModified the current representation's last-modified instant, or {@code null} if unavailable
095         * @param extraHeaders endpoint-specific metadata headers to include on short-circuit responses
096         * @return a short-circuit response, or {@link Optional#empty()} when the application should continue normally
097         */
098        @NonNull
099        public static Optional<Response> responseFor(@NonNull Request request,
100                                                                                                                                                                                 @Nullable EntityTag entityTag,
101                                                                                                                                                                                 @Nullable Instant lastModified,
102                                                                                                                                                                                 @Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> extraHeaders) {
103                requireNonNull(request);
104                Map<String, Set<String>> copiedExtraHeaders = copyExtraHeaders(extraHeaders);
105                Instant truncatedLastModified = lastModified == null ? null : ConditionalRequestEvaluator.truncateToSeconds(lastModified);
106                ConditionalRequestEvaluator.PreconditionOutcome preconditionOutcome = ConditionalRequestEvaluator.evaluate(
107                                ConditionalRequestEvaluator.RequestContext.fromRequest(request),
108                                entityTag,
109                                truncatedLastModified,
110                                true
111                );
112
113                return switch (preconditionOutcome) {
114                        case CONTINUE -> Optional.empty();
115                        case NOT_MODIFIED -> Optional.of(bodylessResponse(304, entityTag, truncatedLastModified, copiedExtraHeaders));
116                        case PRECONDITION_FAILED -> Optional.of(bodylessResponse(412, entityTag, truncatedLastModified, copiedExtraHeaders));
117                };
118        }
119
120        /**
121         * Builds validator headers for the supplied representation validators.
122         *
123         * @param entityTag    the current representation's entity tag, or {@code null} if unavailable
124         * @param lastModified the current representation's last-modified instant, or {@code null} if unavailable
125         * @return immutable {@code ETag} and {@code Last-Modified} headers for the supplied validators
126         */
127        @NonNull
128        public static Map<@NonNull String, @NonNull Set<@NonNull String>> validatorHeaders(@Nullable EntityTag entityTag,
129                                                                                                                                                                                                                                                                                                                                                 @Nullable Instant lastModified) {
130                return validatorHeadersFor(entityTag, lastModified == null ? null : ConditionalRequestEvaluator.truncateToSeconds(lastModified));
131        }
132
133        /**
134         * Builds validator headers plus endpoint-specific metadata headers.
135         * <p>
136         * {@code extraHeaders} are intended for response metadata such as {@code Cache-Control} or {@code Vary}; validator
137         * and body-framing headers are rejected because they are controlled by this helper.
138         *
139         * @param entityTag    the current representation's entity tag, or {@code null} if unavailable
140         * @param lastModified the current representation's last-modified instant, or {@code null} if unavailable
141         * @param extraHeaders endpoint-specific metadata headers to include with the validators
142         * @return immutable combined headers
143         */
144        @NonNull
145        public static Map<@NonNull String, @NonNull Set<@NonNull String>> validatorHeaders(@Nullable EntityTag entityTag,
146                                                                                                                                                                                                                                                                                                                                                 @Nullable Instant lastModified,
147                                                                                                                                                                                                                                                                                                                                                 @Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> extraHeaders) {
148                return responseHeaders(
149                                entityTag,
150                                lastModified == null ? null : ConditionalRequestEvaluator.truncateToSeconds(lastModified),
151                                copyExtraHeaders(extraHeaders)
152                );
153        }
154
155        @NonNull
156        private static Response bodylessResponse(@NonNull Integer statusCode,
157                                                                                                                                                                         @Nullable EntityTag entityTag,
158                                                                                                                                                                         @Nullable Instant lastModified,
159                                                                                                                                                                         @NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> extraHeaders) {
160                requireNonNull(statusCode);
161                requireNonNull(extraHeaders);
162
163                return Response.withStatusCode(statusCode)
164                                .headers(responseHeaders(entityTag, lastModified, extraHeaders))
165                                .build();
166        }
167
168        @NonNull
169        private static Map<@NonNull String, @NonNull Set<@NonNull String>> responseHeaders(@Nullable EntityTag entityTag,
170                                                                                                                                                                                                                                                                                                                                                 @Nullable Instant lastModified,
171                                                                                                                                                                                                                                                                                                                                                 @NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> extraHeaders) {
172                requireNonNull(extraHeaders);
173                Map<String, Set<String>> headers = new LinkedCaseInsensitiveMap<>();
174                headers.putAll(validatorHeadersFor(entityTag, lastModified));
175                headers.putAll(extraHeaders);
176                return Collections.unmodifiableMap(headers);
177        }
178
179        @NonNull
180        private static Map<@NonNull String, @NonNull Set<@NonNull String>> validatorHeadersFor(@Nullable EntityTag entityTag,
181                                                                                                                                                                                                                                                                                                                                                                @Nullable Instant truncatedLastModified) {
182                Map<String, Set<String>> headers = new LinkedCaseInsensitiveMap<>();
183
184                if (entityTag != null)
185                        headers.put("ETag", Set.of(entityTag.toHeaderValue()));
186
187                if (truncatedLastModified != null)
188                        headers.put("Last-Modified", Set.of(HttpDate.toHeaderValue(truncatedLastModified)));
189
190                return Collections.unmodifiableMap(headers);
191        }
192
193        @NonNull
194        private static Map<@NonNull String, @NonNull Set<@NonNull String>> copyExtraHeaders(
195                        @Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> extraHeaders) {
196                if (extraHeaders == null || extraHeaders.isEmpty())
197                        return Map.of();
198
199                Map<String, Set<String>> copiedHeaders = new LinkedCaseInsensitiveMap<>();
200
201                for (Map.Entry<String, Set<String>> entry : extraHeaders.entrySet()) {
202                        String headerName = requireNonNull(entry.getKey());
203                        rejectControlledExtraHeader(headerName);
204
205                        Set<String> copiedHeaderValues = new LinkedHashSet<>(requireNonNull(entry.getValue()));
206                        copiedHeaderValues.forEach(value -> {
207                                requireNonNull(value, format("Header '%s' includes a null value.", headerName));
208                                Utilities.validateHeaderNameAndValue(headerName, value);
209                        });
210                        copiedHeaders.put(headerName, Collections.unmodifiableSet(copiedHeaderValues));
211                }
212
213                return Collections.unmodifiableMap(copiedHeaders);
214        }
215
216        private static void rejectControlledExtraHeader(@NonNull String headerName) {
217                requireNonNull(headerName);
218                String normalizedHeaderName = headerName.toLowerCase(java.util.Locale.US);
219
220                if (CONTROLLED_EXTRA_HEADER_NAMES.contains(normalizedHeaderName))
221                        throw new IllegalArgumentException(format("Header '%s' is controlled by conditional request responses; supply validators through the dedicated arguments.", headerName));
222        }
223}