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}