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}