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.LinkedHashSet;
026import java.util.Map;
027import java.util.Optional;
028import java.util.Set;
029import java.util.function.Consumer;
030
031import static java.lang.String.format;
032import static java.util.Objects.requireNonNull;
033
034/**
035 * A finalized representation of a {@link Response}, suitable for sending to clients over the wire.
036 * <p>
037 * Your application's {@link ResponseMarshaler} is responsible for taking the {@link Response} returned by a <em>Resource Method</em> as input
038 * and converting its {@link Response#getBody()} to a {@code byte[]}.
039 * <p>
040 * For example, if a {@link Response} were to specify a body of {@code List.of("one", "two")}, a {@link ResponseMarshaler} might
041 * 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"]}.
042 * <p>
043 * Full documentation is available at <a href="https://www.soklet.com/docs/response-writing">https://www.soklet.com/docs/response-writing</a>.
044 *
045 * @author <a href="https://www.revetkn.com">Mark Allen</a>
046 */
047@ThreadSafe
048public class MarshaledResponse {
049        @Nonnull
050        private final Integer statusCode;
051        @Nonnull
052        private final Map<String, Set<String>> headers;
053        @Nonnull
054        private final Set<ResponseCookie> cookies;
055        @Nullable
056        private final byte[] body;
057
058        /**
059         * Acquires a builder for {@link MarshaledResponse} instances.
060         *
061         * @param statusCode the HTTP status code for this response
062         * @return the builder
063         */
064        @Nonnull
065        public static Builder withStatusCode(@Nonnull Integer statusCode) {
066                requireNonNull(statusCode);
067                return new Builder(statusCode);
068        }
069
070        /**
071         * Vends a mutable copier seeded with this instance's data, suitable for building new instances.
072         *
073         * @return a copier for this instance
074         */
075        @Nonnull
076        public Copier copy() {
077                return new Copier(this);
078        }
079
080        protected MarshaledResponse(@Nonnull Builder builder) {
081                requireNonNull(builder);
082
083                this.statusCode = builder.statusCode;
084                this.headers = builder.headers == null ? Map.of() : new LinkedCaseInsensitiveMap<>(builder.headers);
085                this.cookies = builder.cookies == null ? Set.of() : new LinkedHashSet<>(builder.cookies);
086                this.body = builder.body;
087        }
088
089        @Override
090        public String toString() {
091                return format("%s{statusCode=%s, headers=%s, cookies=%s, body=%s}", getClass().getSimpleName(),
092                                getStatusCode(), getHeaders(), getCookies(),
093                                format("%d bytes", getBody().isPresent() ? getBody().get().length : 0));
094        }
095
096        /**
097         * The HTTP status code for this response.
098         *
099         * @return the status code
100         */
101        @Nonnull
102        public Integer getStatusCode() {
103                return this.statusCode;
104        }
105
106        /**
107         * The HTTP headers to write for this response.
108         *
109         * @return the headers to write
110         */
111        @Nonnull
112        public Map<String, Set<String>> getHeaders() {
113                return this.headers;
114        }
115
116        /**
117         * The HTTP cookies to write for this response.
118         *
119         * @return the cookies to write
120         */
121        @Nonnull
122        public Set<ResponseCookie> getCookies() {
123                return this.cookies;
124        }
125
126        /**
127         * The HTTP response body to write, if available.
128         *
129         * @return the response body to write, or {@link Optional#empty()}) if no body should be written
130         */
131        @Nonnull
132        public Optional<byte[]> getBody() {
133                return Optional.ofNullable(this.body);
134        }
135
136        /**
137         * Builder used to construct instances of {@link MarshaledResponse} via {@link MarshaledResponse#withStatusCode(Integer)}.
138         * <p>
139         * This class is intended for use by a single thread.
140         *
141         * @author <a href="https://www.revetkn.com">Mark Allen</a>
142         */
143        @NotThreadSafe
144        public static class Builder {
145                @Nonnull
146                private Integer statusCode;
147                @Nullable
148                private Set<ResponseCookie> cookies;
149                @Nullable
150                private Map<String, Set<String>> headers;
151                @Nullable
152                private byte[] body;
153
154                protected Builder(@Nonnull Integer statusCode) {
155                        requireNonNull(statusCode);
156                        this.statusCode = statusCode;
157                }
158
159                @Nonnull
160                public Builder statusCode(@Nonnull Integer statusCode) {
161                        requireNonNull(statusCode);
162                        this.statusCode = statusCode;
163                        return this;
164                }
165
166                @Nonnull
167                public Builder cookies(@Nullable Set<ResponseCookie> cookies) {
168                        this.cookies = cookies;
169                        return this;
170                }
171
172                @Nonnull
173                public Builder headers(@Nullable Map<String, Set<String>> headers) {
174                        this.headers = headers;
175                        return this;
176                }
177
178                @Nonnull
179                public Builder body(@Nullable byte[] body) {
180                        this.body = body;
181                        return this;
182                }
183
184                @Nonnull
185                public MarshaledResponse build() {
186                        return new MarshaledResponse(this);
187                }
188        }
189
190        /**
191         * Builder used to copy instances of {@link MarshaledResponse} via {@link MarshaledResponse#copy()}.
192         * <p>
193         * This class is intended for use by a single thread.
194         *
195         * @author <a href="https://www.revetkn.com">Mark Allen</a>
196         */
197        @NotThreadSafe
198        public static class Copier {
199                @Nonnull
200                private final Builder builder;
201
202                Copier(@Nonnull MarshaledResponse marshaledResponse) {
203                        requireNonNull(marshaledResponse);
204
205                        this.builder = new Builder(marshaledResponse.getStatusCode())
206                                        .headers(new LinkedCaseInsensitiveMap<>(marshaledResponse.getHeaders()))
207                                        .cookies(new LinkedHashSet<>(marshaledResponse.getCookies()))
208                                        .body(marshaledResponse.getBody().orElse(null));
209                }
210
211                @Nonnull
212                public Copier statusCode(@Nonnull Integer statusCode) {
213                        requireNonNull(statusCode);
214                        this.builder.statusCode(statusCode);
215                        return this;
216                }
217
218                @Nonnull
219                public Copier headers(@Nonnull Map<String, Set<String>> headers) {
220                        this.builder.headers(headers);
221                        return this;
222                }
223
224                // Convenience method for mutation
225                @Nonnull
226                public Copier headers(@Nonnull Consumer<Map<String, Set<String>>> headersConsumer) {
227                        requireNonNull(headersConsumer);
228
229                        if (this.builder.headers == null)
230                                this.builder.headers(new LinkedCaseInsensitiveMap<>());
231
232                        headersConsumer.accept(this.builder.headers);
233                        return this;
234                }
235
236                @Nonnull
237                public Copier cookies(@Nullable Set<ResponseCookie> cookies) {
238                        this.builder.cookies(cookies);
239                        return this;
240                }
241
242                // Convenience method for mutation
243                @Nonnull
244                public Copier cookies(@Nonnull Consumer<Set<ResponseCookie>> cookiesConsumer) {
245                        requireNonNull(cookiesConsumer);
246
247                        if (this.builder.cookies == null)
248                                this.builder.cookies(new LinkedHashSet<>());
249
250                        cookiesConsumer.accept(this.builder.cookies);
251                        return this;
252                }
253
254                @Nonnull
255                public Copier body(@Nullable byte[] body) {
256                        this.builder.body(body);
257                        return this;
258                }
259
260                @Nonnull
261                public MarshaledResponse finish() {
262                        return this.builder.build();
263                }
264        }
265}