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}