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