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.NotThreadSafe; 024import javax.annotation.concurrent.ThreadSafe; 025import java.util.Collections; 026import java.util.LinkedHashSet; 027import java.util.Map; 028import java.util.Map.Entry; 029import java.util.Objects; 030import java.util.Optional; 031import java.util.Set; 032import java.util.function.Consumer; 033 034import static java.lang.String.format; 035import static java.util.Objects.requireNonNull; 036 037/** 038 * Represents a logical HTTP response returned by a <em>Resource Method</em>. 039 * <p> 040 * Your application's {@link ResponseMarshaler} is responsible for taking the {@link Response} returned by a <em>Resource Method</em> as input 041 * and creating a finalized binary representation ({@link MarshaledResponse}), suitable for sending to clients over the wire. 042 * <p> 043 * Instances can be acquired via these builder factory methods: 044 * <ul> 045 * <li>{@link #withStatusCode(Integer)} (builder primed with status code)</li> 046 * <li>{@link #withRedirect(RedirectType, String)} (builder primed with redirect info)</li> 047 * </ul> 048 * Convenience instance factories are also available via {@link #fromStatusCode(Integer)} and {@link #fromRedirect(RedirectType, String)}. 049 * <p> 050 * For performance, header collections are shallow-copied and not defensively deep-copied. Treat returned collections as immutable. 051 * <p> 052 * Full documentation is available at <a href="https://www.soklet.com/docs/response-writing">https://www.soklet.com/docs/response-writing</a>. 053 * 054 * @author <a href="https://www.revetkn.com">Mark Allen</a> 055 */ 056@ThreadSafe 057public final class Response { 058 @NonNull 059 private final Integer statusCode; 060 @NonNull 061 private final Set<@NonNull ResponseCookie> cookies; 062 @NonNull 063 private final Map<@NonNull String, @NonNull Set<@NonNull String>> headers; 064 @Nullable 065 private final Object body; 066 067 /** 068 * Acquires a builder for {@link Response} instances. 069 * 070 * @param statusCode the HTTP status code for this request ({@code 200, 201, etc.}) 071 * @return the builder 072 */ 073 @NonNull 074 public static Builder withStatusCode(@NonNull Integer statusCode) { 075 requireNonNull(statusCode); 076 return new Builder(statusCode); 077 } 078 079 /** 080 * Creates a {@link Response} with the given status code and no additional customization. 081 * 082 * @param statusCode the HTTP status code for this request ({@code 200, 201, etc.}) 083 * @return a {@link Response} instance 084 */ 085 @NonNull 086 public static Response fromStatusCode(@NonNull Integer statusCode) { 087 return withStatusCode(statusCode).build(); 088 } 089 090 /** 091 * Acquires a builder for {@link Response} instances that are intended to redirect the client. 092 * 093 * @param redirectType the kind of redirect to perform, for example {@link RedirectType#HTTP_307_TEMPORARY_REDIRECT} 094 * @param location the URL to redirect to 095 * @return the builder 096 */ 097 @NonNull 098 public static Builder withRedirect(@NonNull RedirectType redirectType, 099 @NonNull String location) { 100 requireNonNull(redirectType); 101 requireNonNull(location); 102 return new Builder(redirectType, location); 103 } 104 105 /** 106 * Creates a redirect {@link Response} with no additional customization. 107 * 108 * @param redirectType the kind of redirect to perform, for example {@link RedirectType#HTTP_307_TEMPORARY_REDIRECT} 109 * @param location the URL to redirect to 110 * @return a {@link Response} instance 111 */ 112 @NonNull 113 public static Response fromRedirect(@NonNull RedirectType redirectType, 114 @NonNull String location) { 115 return withRedirect(redirectType, location).build(); 116 } 117 118 private Response(@NonNull Builder builder) { 119 requireNonNull(builder); 120 121 Map<String, Set<String>> headers = builder.headers == null 122 ? new LinkedCaseInsensitiveMap<>() 123 : new LinkedCaseInsensitiveMap<>(builder.headers); 124 125 if (builder.location != null && !headers.containsKey("Location")) 126 headers.put("Location", Set.of(builder.location)); 127 128 // Verify headers are legal 129 for (Entry<String, Set<String>> entry : headers.entrySet()) { 130 String headerName = entry.getKey(); 131 Set<String> headerValues = entry.getValue(); 132 133 for (String headerValue : headerValues) 134 Utilities.validateHeaderNameAndValue(headerName, headerValue); 135 } 136 137 Set<ResponseCookie> cookies = builder.cookies == null 138 ? Collections.emptySet() 139 : new LinkedHashSet<>(builder.cookies); 140 141 this.statusCode = builder.statusCode; 142 this.cookies = Collections.unmodifiableSet(cookies); 143 this.headers = Collections.unmodifiableMap(headers); 144 this.body = builder.body; 145 } 146 147 @Override 148 public String toString() { 149 return format("%s{statusCode=%s, cookies=%s, headers=%s, body=%s}", 150 getClass().getSimpleName(), getStatusCode(), getCookies(), getHeaders(), getBody()); 151 } 152 153 @Override 154 public boolean equals(@Nullable Object object) { 155 if (this == object) 156 return true; 157 158 if (!(object instanceof Response response)) 159 return false; 160 161 return Objects.equals(getStatusCode(), response.getStatusCode()) 162 && Objects.equals(getCookies(), response.getCookies()) 163 && Objects.equals(getHeaders(), response.getHeaders()) 164 && Objects.equals(getBody(), response.getBody()); 165 } 166 167 @Override 168 public int hashCode() { 169 return Objects.hash(getStatusCode(), getCookies(), getHeaders(), getBody()); 170 } 171 172 /** 173 * Vends a mutable copier seeded with this instance's data, suitable for building new instances. 174 * 175 * @return a copier for this instance 176 */ 177 @NonNull 178 public Copier copy() { 179 return new Copier(this); 180 } 181 182 /** 183 * The HTTP status code to be written to the client for this response. 184 * <p> 185 * See {@link StatusCode} for an enumeration of all HTTP status codes. 186 * 187 * @return the HTTP status code to write to the response 188 */ 189 @NonNull 190 public Integer getStatusCode() { 191 return this.statusCode; 192 } 193 194 /** 195 * The cookies to be written to the client for this response. 196 * <p> 197 * It is possible to send multiple {@code ResponseCookie} values with the same name to the client. 198 * <p> 199 * <em>Note that {@code ResponseCookie} values, like all response headers, have case-insensitive names per the HTTP spec.</em> 200 * 201 * @return the cookies to write to the response 202 */ 203 @NonNull 204 public Set<@NonNull ResponseCookie> getCookies() { 205 return this.cookies; 206 } 207 208 /** 209 * The headers to be written to the client for this response. 210 * <p> 211 * The keys are the header names and the values are header values. Soklet writes one header line per value. 212 * If order matters, provide either a {@link java.util.SortedSet} or {@link java.util.LinkedHashSet} to preserve 213 * the desired ordering; otherwise values are naturally sorted for consistency. 214 * <p> 215 * <em>Note that response headers have case-insensitive names per the HTTP spec.</em> 216 * 217 * @return the headers to write to the response 218 */ 219 @NonNull 220 public Map<@NonNull String, @NonNull Set<@NonNull String>> getHeaders() { 221 return this.headers; 222 } 223 224 /** 225 * The "logical" body content to be written to the response, if present. 226 * <p> 227 * It is the responsibility of the {@link ResponseMarshaler} to take this object and convert it into bytes to send over the wire. 228 * 229 * @return the object representing the response body, or {@link Optional#empty()} if no response body should be written 230 */ 231 @NonNull 232 public Optional<Object> getBody() { 233 return Optional.ofNullable(this.body); 234 } 235 236 /** 237 * Builder used to construct instances of {@link Response} via {@link Response#withStatusCode(Integer)} 238 * or {@link Response#withRedirect(RedirectType, String)}. 239 * <p> 240 * This class is intended for use by a single thread. 241 * 242 * @author <a href="https://www.revetkn.com">Mark Allen</a> 243 */ 244 @NotThreadSafe 245 public static final class Builder { 246 @NonNull 247 private Integer statusCode; 248 @Nullable 249 private String location; 250 @Nullable 251 private Set<@NonNull ResponseCookie> cookies; 252 @Nullable 253 private Map<@NonNull String, @NonNull Set<@NonNull String>> headers; 254 @Nullable 255 private Object body; 256 257 protected Builder(@NonNull Integer statusCode) { 258 requireNonNull(statusCode); 259 260 this.statusCode = statusCode; 261 this.location = null; 262 } 263 264 protected Builder(@NonNull RedirectType redirectType, 265 @NonNull String location) { 266 requireNonNull(redirectType); 267 requireNonNull(location); 268 269 this.statusCode = redirectType.getStatusCode().getStatusCode(); 270 this.location = location; 271 } 272 273 @NonNull 274 public Builder statusCode(@NonNull Integer statusCode) { 275 requireNonNull(statusCode); 276 277 this.statusCode = statusCode; 278 this.location = null; 279 return this; 280 } 281 282 @NonNull 283 public Builder redirect(@NonNull RedirectType redirectType, 284 @NonNull String location) { 285 requireNonNull(redirectType); 286 requireNonNull(location); 287 288 this.statusCode = redirectType.getStatusCode().getStatusCode(); 289 this.location = location; 290 return this; 291 } 292 293 @NonNull 294 public Builder cookies(@Nullable Set<@NonNull ResponseCookie> cookies) { 295 this.cookies = cookies; 296 return this; 297 } 298 299 @NonNull 300 public Builder headers(@Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> headers) { 301 this.headers = headers; 302 return this; 303 } 304 305 @NonNull 306 public Builder body(@Nullable Object body) { 307 this.body = body; 308 return this; 309 } 310 311 @NonNull 312 public Response build() { 313 return new Response(this); 314 } 315 } 316 317 /** 318 * Builder used to copy instances of {@link Response} via {@link Response#copy()}. 319 * <p> 320 * This class is intended for use by a single thread. 321 * 322 * @author <a href="https://www.revetkn.com">Mark Allen</a> 323 */ 324 @NotThreadSafe 325 public static final class Copier { 326 @NonNull 327 private final Builder builder; 328 329 Copier(@NonNull Response response) { 330 requireNonNull(response); 331 332 this.builder = new Builder(response.getStatusCode()) 333 .headers(new LinkedCaseInsensitiveMap<>(response.getHeaders())) 334 .cookies(new LinkedHashSet<>(response.getCookies())) 335 .body(response.getBody().orElse(null)); 336 } 337 338 @NonNull 339 public Copier statusCode(@NonNull Integer statusCode) { 340 requireNonNull(statusCode); 341 this.builder.statusCode(statusCode); 342 return this; 343 } 344 345 @NonNull 346 public Copier headers(@Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> headers) { 347 this.builder.headers(headers); 348 return this; 349 } 350 351 // Convenience method for mutation 352 @NonNull 353 public Copier headers(@NonNull Consumer<Map<@NonNull String, @NonNull Set<@NonNull String>>> headersConsumer) { 354 requireNonNull(headersConsumer); 355 356 if (this.builder.headers == null) 357 this.builder.headers(new LinkedCaseInsensitiveMap<>()); 358 359 headersConsumer.accept(this.builder.headers); 360 return this; 361 } 362 363 @NonNull 364 public Copier cookies(@Nullable Set<@NonNull ResponseCookie> cookies) { 365 this.builder.cookies(cookies); 366 return this; 367 } 368 369 // Convenience method for mutation 370 @NonNull 371 public Copier cookies(@NonNull Consumer<Set<@NonNull ResponseCookie>> cookiesConsumer) { 372 requireNonNull(cookiesConsumer); 373 374 if (this.builder.cookies == null) 375 this.builder.cookies(new LinkedHashSet<>()); 376 377 cookiesConsumer.accept(this.builder.cookies); 378 return this; 379 } 380 381 @NonNull 382 public Copier body(@Nullable Object body) { 383 this.builder.body(body); 384 return this; 385 } 386 387 @NonNull 388 public Response finish() { 389 return this.builder.build(); 390 } 391 } 392}