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}