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