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 org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.NotThreadSafe;
023import javax.annotation.concurrent.ThreadSafe;
024import java.util.ArrayList;
025import java.util.List;
026import java.util.Objects;
027import java.util.Optional;
028import java.util.stream.Collectors;
029
030import static java.lang.String.format;
031import static java.util.Objects.requireNonNull;
032
033/**
034 * Encapsulates the results of a request that would normally be handled by your {@link HttpServer} (both logical response and bytes to be sent over the wire), used for integration testing via {@link Simulator#performHttpRequest(Request)}.
035 * <p>
036 * Instances can be acquired via the {@link #withMarshaledResponse(MarshaledResponse)} builder factory method.
037 * A convenience instance factory is also available via {@link #fromMarshaledResponse(MarshaledResponse)}.
038 * <p>
039 * The Server-Sent Event equivalent of this type is {@link SseRequestResult}, which is used for integration testing via {@link Simulator#performSseRequest(Request)}.
040 * <p>
041 * See <a href="https://www.soklet.com/docs/testing#integration-testing">https://www.soklet.com/docs/testing#integration-testing</a> for detailed documentation.
042 *
043 * @author <a href="https://www.revetkn.com">Mark Allen</a>
044 */
045@ThreadSafe
046public final class HttpRequestResult {
047        @NonNull
048        private final MarshaledResponse marshaledResponse;
049        @Nullable
050        private final Response response;
051        @Nullable
052        private final CorsPreflightResponse corsPreflightResponse;
053        @Nullable
054        private final ResourceMethod resourceMethod;
055        @Nullable
056        private final SseHandshakeResult sseHandshakeResult;
057        @NonNull
058        private final List<@NonNull McpObject> mcpStreamMessages;
059        @NonNull
060        private final Boolean mcpStreamClosedAfterReplay;
061
062        /**
063         * Acquires a builder for {@link HttpRequestResult} instances.
064         *
065         * @param marshaledResponse the bytes that will ultimately be written over the wire
066         * @return the builder
067         */
068        @NonNull
069        public static Builder withMarshaledResponse(@NonNull MarshaledResponse marshaledResponse) {
070                requireNonNull(marshaledResponse);
071                return new Builder(marshaledResponse);
072        }
073
074        /**
075         * Creates a {@link HttpRequestResult} from a marshaled response without additional customization.
076         *
077         * @param marshaledResponse the bytes that will ultimately be written over the wire
078         * @return a {@link HttpRequestResult} instance
079         */
080        @NonNull
081        public static HttpRequestResult fromMarshaledResponse(@NonNull MarshaledResponse marshaledResponse) {
082                return withMarshaledResponse(marshaledResponse).build();
083        }
084
085        /**
086         * Vends a mutable copier seeded with this instance's data, suitable for building new instances.
087         *
088         * @return a copier for this instance
089         */
090        @NonNull
091        public Copier copy() {
092                return new Copier(this);
093        }
094
095        protected HttpRequestResult(@NonNull Builder builder) {
096                requireNonNull(builder);
097
098                this.marshaledResponse = builder.marshaledResponse;
099                this.response = builder.response;
100                this.corsPreflightResponse = builder.corsPreflightResponse;
101                this.resourceMethod = builder.resourceMethod;
102                this.sseHandshakeResult = builder.sseHandshakeResult;
103                this.mcpStreamMessages = List.copyOf(builder.mcpStreamMessages);
104                this.mcpStreamClosedAfterReplay = builder.mcpStreamClosedAfterReplay;
105        }
106
107        @Override
108        public String toString() {
109                List<String> components = new ArrayList<>(5);
110
111                components.add(format("marshaledResponse=%s", getMarshaledResponse()));
112
113                Response response = getResponse().orElse(null);
114
115                if (response != null)
116                        components.add(format("response=%s", response));
117
118                CorsPreflightResponse corsPreflightResponse = getCorsPreflightResponse().orElse(null);
119
120                if (corsPreflightResponse != null)
121                        components.add(format("corsPreflightResponse=%s", corsPreflightResponse));
122
123                ResourceMethod resourceMethod = getResourceMethod().orElse(null);
124
125                if (resourceMethod != null)
126                        components.add(format("resourceMethod=%s", resourceMethod));
127
128                // Hide this for now because handshake info is package-private and we don't want it to leak out
129
130                // SseHandshakeResult sseHandshakeResult = getSseHandshakeResult().orElse(null);
131
132                // if (sseHandshakeResult != null)
133                //      components.add(format("sseHandshakeResult=%s", sseHandshakeResult));
134
135                return format("%s{%s}", getClass().getSimpleName(), components.stream().collect(Collectors.joining(", ")));
136        }
137
138        @Override
139        public boolean equals(@Nullable Object object) {
140                if (this == object)
141                        return true;
142
143                if (!(object instanceof HttpRequestResult requestResult))
144                        return false;
145
146                return Objects.equals(getMarshaledResponse(), requestResult.getMarshaledResponse())
147                                && Objects.equals(getResponse(), requestResult.getResponse())
148                                && Objects.equals(getCorsPreflightResponse(), requestResult.getCorsPreflightResponse())
149                                && Objects.equals(getResourceMethod(), requestResult.getResourceMethod())
150                                && Objects.equals(getSseHandshakeResult(), requestResult.getSseHandshakeResult())
151                                && Objects.equals(getMcpStreamMessages(), requestResult.getMcpStreamMessages())
152                                && Objects.equals(isMcpStreamClosedAfterReplay(), requestResult.isMcpStreamClosedAfterReplay());
153        }
154
155        @Override
156        public int hashCode() {
157                return Objects.hash(getMarshaledResponse(), getResponse(), getCorsPreflightResponse(), getResourceMethod(), getSseHandshakeResult(),
158                                getMcpStreamMessages(), isMcpStreamClosedAfterReplay());
159        }
160
161        /**
162         * The final representation of the response to be written over the wire.
163         *
164         * @return the response to be written over the wire
165         */
166        @NonNull
167        public MarshaledResponse getMarshaledResponse() {
168                return this.marshaledResponse;
169        }
170
171        /**
172         * The logical response, determined by the return value of the <em>Resource Method</em> (if available).
173         *
174         * @return the logical response
175         */
176        @NonNull
177        public Optional<Response> getResponse() {
178                return Optional.ofNullable(this.response);
179        }
180
181        /**
182         * The CORS preflight logical response, if applicable for the request.
183         *
184         * @return the CORS preflight logical response
185         */
186        @NonNull
187        public Optional<CorsPreflightResponse> getCorsPreflightResponse() {
188                return Optional.ofNullable(this.corsPreflightResponse);
189        }
190
191        /**
192         * The <em>Resource Method</em> that handled the request, if available.
193         *
194         * @return the <em>Resource Method</em> that handled the request
195         */
196        @NonNull
197        public Optional<ResourceMethod> getResourceMethod() {
198                return Optional.ofNullable(this.resourceMethod);
199        }
200
201
202        /**
203         * The SSE handshake result, if available.
204         *
205         * @return the SSE handshake result
206         */
207        @NonNull
208        Optional<SseHandshakeResult> getSseHandshakeResult() {
209                return Optional.ofNullable(this.sseHandshakeResult);
210        }
211
212        @NonNull
213        List<@NonNull McpObject> getMcpStreamMessages() {
214                return this.mcpStreamMessages;
215        }
216
217        @NonNull
218        Boolean isMcpStreamClosedAfterReplay() {
219                return this.mcpStreamClosedAfterReplay;
220        }
221
222        /**
223         * Builder used to construct instances of {@link HttpRequestResult} via {@link HttpRequestResult#withMarshaledResponse(MarshaledResponse)}.
224         * <p>
225         * This class is intended for use by a single thread.
226         *
227         * @author <a href="https://www.revetkn.com">Mark Allen</a>
228         */
229        @NotThreadSafe
230        public static final class Builder {
231                @NonNull
232                private MarshaledResponse marshaledResponse;
233                @Nullable
234                private Response response;
235                @Nullable
236                private CorsPreflightResponse corsPreflightResponse;
237                @Nullable
238                private ResourceMethod resourceMethod;
239                @Nullable
240                private SseHandshakeResult sseHandshakeResult;
241                @NonNull
242                private List<@NonNull McpObject> mcpStreamMessages;
243                @NonNull
244                private Boolean mcpStreamClosedAfterReplay;
245
246                protected Builder(@NonNull MarshaledResponse marshaledResponse) {
247                        requireNonNull(marshaledResponse);
248                        this.marshaledResponse = marshaledResponse;
249                        this.mcpStreamMessages = List.of();
250                        this.mcpStreamClosedAfterReplay = false;
251                }
252
253                @NonNull
254                public Builder marshaledResponse(@NonNull MarshaledResponse marshaledResponse) {
255                        requireNonNull(marshaledResponse);
256                        this.marshaledResponse = marshaledResponse;
257                        return this;
258                }
259
260                @NonNull
261                public Builder response(@Nullable Response response) {
262                        this.response = response;
263                        return this;
264                }
265
266                @NonNull
267                public Builder corsPreflightResponse(@Nullable CorsPreflightResponse corsPreflightResponse) {
268                        this.corsPreflightResponse = corsPreflightResponse;
269                        return this;
270                }
271
272                @NonNull
273                public Builder resourceMethod(@Nullable ResourceMethod resourceMethod) {
274                        this.resourceMethod = resourceMethod;
275                        return this;
276                }
277
278                @NonNull
279                Builder sseHandshakeResult(@Nullable SseHandshakeResult sseHandshakeResult) {
280                        this.sseHandshakeResult = sseHandshakeResult;
281                        return this;
282                }
283
284                @NonNull
285                Builder mcpStreamMessages(@Nullable List<@NonNull McpObject> mcpStreamMessages) {
286                        this.mcpStreamMessages = mcpStreamMessages == null ? List.of() : List.copyOf(mcpStreamMessages);
287                        return this;
288                }
289
290                @NonNull
291                Builder mcpStreamClosedAfterReplay(@NonNull Boolean mcpStreamClosedAfterReplay) {
292                        requireNonNull(mcpStreamClosedAfterReplay);
293                        this.mcpStreamClosedAfterReplay = mcpStreamClosedAfterReplay;
294                        return this;
295                }
296
297                @NonNull
298                public HttpRequestResult build() {
299                        return new HttpRequestResult(this);
300                }
301        }
302
303        /**
304         * Builder used to copy instances of {@link HttpRequestResult} via {@link HttpRequestResult#copy()}.
305         * <p>
306         * This class is intended for use by a single thread.
307         *
308         * @author <a href="https://www.revetkn.com">Mark Allen</a>
309         */
310        @NotThreadSafe
311        public static final class Copier {
312                @NonNull
313                private final Builder builder;
314
315                Copier(@NonNull HttpRequestResult requestResult) {
316                        requireNonNull(requestResult);
317
318                        this.builder = new Builder(requestResult.getMarshaledResponse())
319                                        .response(requestResult.getResponse().orElse(null))
320                                        .corsPreflightResponse(requestResult.getCorsPreflightResponse().orElse(null))
321                                        .resourceMethod(requestResult.getResourceMethod().orElse(null))
322                                        .sseHandshakeResult(requestResult.getSseHandshakeResult().orElse(null))
323                                        .mcpStreamMessages(requestResult.getMcpStreamMessages())
324                                        .mcpStreamClosedAfterReplay(requestResult.isMcpStreamClosedAfterReplay());
325                }
326
327                @NonNull
328                public Copier marshaledResponse(@NonNull MarshaledResponse marshaledResponse) {
329                        requireNonNull(marshaledResponse);
330                        this.builder.marshaledResponse(marshaledResponse);
331                        return this;
332                }
333
334                @NonNull
335                public Copier response(@Nullable Response response) {
336                        this.builder.response(response);
337                        return this;
338                }
339
340                @NonNull
341                public Copier corsPreflightResponse(@Nullable CorsPreflightResponse corsPreflightResponse) {
342                        this.builder.corsPreflightResponse(corsPreflightResponse);
343                        return this;
344                }
345
346                @NonNull
347                public Copier resourceMethod(@Nullable ResourceMethod resourceMethod) {
348                        this.builder.resourceMethod(resourceMethod);
349                        return this;
350                }
351
352                @NonNull
353                Copier sseHandshakeResult(@Nullable SseHandshakeResult sseHandshakeResult) {
354                        this.builder.sseHandshakeResult(sseHandshakeResult);
355                        return this;
356                }
357
358                @NonNull
359                Copier mcpStreamMessages(@Nullable List<@NonNull McpObject> mcpStreamMessages) {
360                        this.builder.mcpStreamMessages(mcpStreamMessages);
361                        return this;
362                }
363
364                @NonNull
365                Copier mcpStreamClosedAfterReplay(@NonNull Boolean mcpStreamClosedAfterReplay) {
366                        this.builder.mcpStreamClosedAfterReplay(mcpStreamClosedAfterReplay);
367                        return this;
368                }
369
370                @NonNull
371                public HttpRequestResult finish() {
372                        return this.builder.build();
373                }
374        }
375}