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.LinkedHashSet;
026import java.util.Map;
027import java.util.Map.Entry;
028import java.util.Optional;
029import java.util.Set;
030import java.util.function.Consumer;
031
032import static java.lang.String.format;
033import static java.util.Objects.requireNonNull;
034
035/**
036 * A finalized representation of a {@link Response}, suitable for sending to clients over the wire.
037 * <p>
038 * Your application's {@link ResponseMarshaler} is responsible for taking the {@link Response} returned by a <em>Resource Method</em> as input
039 * and converting its {@link Response#getBody()} to a {@code byte[]}.
040 * <p>
041 * For example, if a {@link Response} were to specify a body of {@code List.of("one", "two")}, a {@link ResponseMarshaler} might
042 * convert it to the JSON string {@code ["one", "two"]} and provide as output a corresponding {@link MarshaledResponse} with a body of UTF-8 bytes that represent {@code ["one", "two"]}.
043 * <p>
044 * Alternatively, your <em>Resource Method</em> might want to directly serve bytes to clients (e.g. an image or PDF) and skip the {@link ResponseMarshaler} entirely.
045 * To accomplish this, just have your <em>Resource Method</em> return a {@link MarshaledResponse} instance: this tells Soklet "I already know exactly what bytes I want to send; don't go through the normal marshaling process".
046 * <p>
047 * Instances can be acquired via the {@link #withResponse(Response)} or {@link #withStatusCode(Integer)} builder factory methods.
048 * Convenience instance factories are also available via {@link #fromResponse(Response)} and {@link #fromStatusCode(Integer)}.
049 * <p>
050 * Full documentation is available at <a href="https://www.soklet.com/docs/response-writing">https://www.soklet.com/docs/response-writing</a>.
051 *
052 * @author <a href="https://www.revetkn.com">Mark Allen</a>
053 */
054@ThreadSafe
055public final class MarshaledResponse {
056        @NonNull
057        private final Integer statusCode;
058        @NonNull
059        private final Map<@NonNull String, @NonNull Set<@NonNull String>> headers;
060        @NonNull
061        private final Set<@NonNull ResponseCookie> cookies;
062        @Nullable
063        private final byte[] body;
064
065        /**
066         * Acquires a builder for {@link MarshaledResponse} instances.
067         *
068         * @param response the logical response whose values are used to prime this builder
069         * @return the builder
070         */
071        @NonNull
072        public static Builder withResponse(@NonNull Response response) {
073                requireNonNull(response);
074
075                Object rawBody = response.getBody().orElse(null);
076                byte[] body = null;
077
078                if (rawBody != null && rawBody instanceof byte[] byteArrayBody)
079                        body = byteArrayBody;
080
081                return new Builder(response.getStatusCode())
082                                .headers(response.getHeaders())
083                                .cookies(response.getCookies())
084                                .body(body);
085        }
086
087        /**
088         * Creates a {@link MarshaledResponse} from a logical {@link Response} without additional customization.
089         *
090         * @param response the logical response whose values are used to construct this instance
091         * @return a {@link MarshaledResponse} instance
092         */
093        @NonNull
094        public static MarshaledResponse fromResponse(@NonNull Response response) {
095                return withResponse(response).build();
096        }
097
098        /**
099         * Acquires a builder for {@link MarshaledResponse} instances.
100         *
101         * @param statusCode the HTTP status code for this response
102         * @return the builder
103         */
104        @NonNull
105        public static Builder withStatusCode(@NonNull Integer statusCode) {
106                requireNonNull(statusCode);
107                return new Builder(statusCode);
108        }
109
110        /**
111         * Creates a {@link MarshaledResponse} with the given status code and no additional customization.
112         *
113         * @param statusCode the HTTP status code for this response
114         * @return a {@link MarshaledResponse} instance
115         */
116        @NonNull
117        public static MarshaledResponse fromStatusCode(@NonNull Integer statusCode) {
118                return withStatusCode(statusCode).build();
119        }
120
121        /**
122         * Vends a mutable copier seeded with this instance's data, suitable for building new instances.
123         *
124         * @return a copier for this instance
125         */
126        @NonNull
127        public Copier copy() {
128                return new Copier(this);
129        }
130
131        protected MarshaledResponse(@NonNull Builder builder) {
132                requireNonNull(builder);
133
134                this.statusCode = builder.statusCode;
135                this.headers = builder.headers == null ? Map.of() : new LinkedCaseInsensitiveMap<>(builder.headers);
136                this.cookies = builder.cookies == null ? Set.of() : new LinkedHashSet<>(builder.cookies);
137                this.body = builder.body;
138
139                // Verify headers are legal
140                for (Entry<String, Set<String>> entry : this.headers.entrySet()) {
141                        String headerName = entry.getKey();
142                        Set<String> headerValues = entry.getValue();
143
144                        for (String headerValue : headerValues)
145                                Utilities.validateHeaderNameAndValue(headerName, headerValue);
146                }
147        }
148
149        @Override
150        public String toString() {
151                return format("%s{statusCode=%s, headers=%s, cookies=%s, body=%s}", getClass().getSimpleName(),
152                                getStatusCode(), getHeaders(), getCookies(),
153                                format("%d bytes", getBody().isPresent() ? getBody().get().length : 0));
154        }
155
156        /**
157         * The HTTP status code for this response.
158         *
159         * @return the status code
160         */
161        @NonNull
162        public Integer getStatusCode() {
163                return this.statusCode;
164        }
165
166        /**
167         * The HTTP headers to write for this response.
168         * <p>
169         * Soklet writes one header line per value. If order matters, provide either a {@link java.util.SortedSet} or
170         * {@link java.util.LinkedHashSet} to preserve the desired ordering; otherwise values are naturally sorted for consistency.
171         *
172         * @return the headers to write
173         */
174        @NonNull
175        public Map<@NonNull String, @NonNull Set<@NonNull String>> getHeaders() {
176                return this.headers;
177        }
178
179        /**
180         * The HTTP cookies to write for this response.
181         *
182         * @return the cookies to write
183         */
184        @NonNull
185        public Set<@NonNull ResponseCookie> getCookies() {
186                return this.cookies;
187        }
188
189        /**
190         * The HTTP response body to write, if available.
191         *
192         * @return the response body to write, or {@link Optional#empty()}) if no body should be written
193         */
194        @NonNull
195        public Optional<byte[]> getBody() {
196                return Optional.ofNullable(this.body);
197        }
198
199        /**
200         * Builder used to construct instances of {@link MarshaledResponse} via {@link MarshaledResponse#withResponse(Response)} or {@link MarshaledResponse#withStatusCode(Integer)}.
201         * <p>
202         * This class is intended for use by a single thread.
203         *
204         * @author <a href="https://www.revetkn.com">Mark Allen</a>
205         */
206        @NotThreadSafe
207        public static final class Builder {
208                @NonNull
209                private Integer statusCode;
210                @Nullable
211                private Set<@NonNull ResponseCookie> cookies;
212                @Nullable
213                private Map<@NonNull String, @NonNull Set<@NonNull String>> headers;
214                @Nullable
215                private byte[] body;
216
217                protected Builder(@NonNull Integer statusCode) {
218                        requireNonNull(statusCode);
219                        this.statusCode = statusCode;
220                }
221
222                @NonNull
223                public Builder statusCode(@NonNull Integer statusCode) {
224                        requireNonNull(statusCode);
225                        this.statusCode = statusCode;
226                        return this;
227                }
228
229                @NonNull
230                public Builder cookies(@Nullable Set<@NonNull ResponseCookie> cookies) {
231                        this.cookies = cookies;
232                        return this;
233                }
234
235                @NonNull
236                public Builder headers(@Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
237                        this.headers = headers;
238                        return this;
239                }
240
241                @NonNull
242                public Builder body(@Nullable byte[] body) {
243                        this.body = body;
244                        return this;
245                }
246
247                @NonNull
248                public MarshaledResponse build() {
249                        return new MarshaledResponse(this);
250                }
251        }
252
253        /**
254         * Builder used to copy instances of {@link MarshaledResponse} via {@link MarshaledResponse#copy()}.
255         * <p>
256         * This class is intended for use by a single thread.
257         *
258         * @author <a href="https://www.revetkn.com">Mark Allen</a>
259         */
260        @NotThreadSafe
261        public static final class Copier {
262                @NonNull
263                private final Builder builder;
264
265                Copier(@NonNull MarshaledResponse marshaledResponse) {
266                        requireNonNull(marshaledResponse);
267
268                        this.builder = new Builder(marshaledResponse.getStatusCode())
269                                        .headers(new LinkedCaseInsensitiveMap<>(marshaledResponse.getHeaders()))
270                                        .cookies(new LinkedHashSet<>(marshaledResponse.getCookies()))
271                                        .body(marshaledResponse.getBody().orElse(null));
272                }
273
274                @NonNull
275                public Copier statusCode(@NonNull Integer statusCode) {
276                        requireNonNull(statusCode);
277                        this.builder.statusCode(statusCode);
278                        return this;
279                }
280
281                @NonNull
282                public Copier headers(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
283                        this.builder.headers(headers);
284                        return this;
285                }
286
287                // Convenience method for mutation
288                @NonNull
289                public Copier headers(@NonNull Consumer<Map<@NonNull String, @NonNull Set<@NonNull String>>> headersConsumer) {
290                        requireNonNull(headersConsumer);
291
292                        if (this.builder.headers == null)
293                                this.builder.headers(new LinkedCaseInsensitiveMap<>());
294
295                        headersConsumer.accept(this.builder.headers);
296                        return this;
297                }
298
299                @NonNull
300                public Copier cookies(@Nullable Set<@NonNull ResponseCookie> cookies) {
301                        this.builder.cookies(cookies);
302                        return this;
303                }
304
305                // Convenience method for mutation
306                @NonNull
307                public Copier cookies(@NonNull Consumer<Set<@NonNull ResponseCookie>> cookiesConsumer) {
308                        requireNonNull(cookiesConsumer);
309
310                        if (this.builder.cookies == null)
311                                this.builder.cookies(new LinkedHashSet<>());
312
313                        cookiesConsumer.accept(this.builder.cookies);
314                        return this;
315                }
316
317                @NonNull
318                public Copier body(@Nullable byte[] body) {
319                        this.builder.body(body);
320                        return this;
321                }
322
323                @NonNull
324                public MarshaledResponse finish() {
325                        return this.builder.build();
326                }
327        }
328}