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 java.util.function.Consumer;
023
024import static java.lang.String.format;
025
026/**
027 * Simulates server behavior of accepting a request and returning a response without touching the network, useful for writing integration tests.
028 * <p>
029 * <a href="https://www.soklet.com/docs/server-sent-events">Server-Sent Event</a> simulation is also supported.
030 * <p>
031 * Instances of {@link Simulator} are made available via {@link com.soklet.Soklet#runSimulator(SokletConfig, Consumer)}.
032 * <p>
033 * Usage example:
034 * <pre>{@code @Test
035 * public void basicIntegrationTest () {
036 *   // Just use your app's existing configuration
037 *   SokletConfig config = obtainMySokletConfig();
038 *
039 *   // With the Simulator, you can issue requests
040 *   // and receive responses just like you would with real servers.
041 *   Soklet.runSimulator(config, (simulator) -> {
042 *     // Construct a request
043 *     Request request = Request.withPath(HttpMethod.GET, "/hello")
044 *       .queryParameters(Map.of("name", Set.of("Mark")))
045 *       .build();
046 *
047 *     // Perform the request and get a handle to the result
048 *     HttpRequestResult result = simulator.performHttpRequest(request);
049 *
050 *     // Verify status code
051 *     Integer expectedCode = 200;
052 *     Integer actualCode = result.getMarshaledResponse().getStatusCode();
053 *     assertEquals(expectedCode, actualCode, "Bad status code");
054 *
055 *     // Now, create a request for an SSE Event Source...
056 *     Request eventSourceRequest = Request.withPath(HttpMethod.GET, "/sse-test")
057 *         .queryParameters(Map.of("signingToken", Set.of("xxx")))
058 *         .build();
059 *
060 *     // ...and perform it and get a handle to the result.
061 *     SseRequestResult eventSourceResult =
062 *       simulator.performSseRequest(eventSourceRequest);
063 *
064 *     // Single-shot latch; we'll wait until a Server-Sent Event comes through
065 *     CountDownLatch eventReceivedLatch = new CountDownLatch(1);
066 *
067 *     // The Simulator provides 3 logical outcomes for SSE connections:
068 *     // * Accepted Handshake (connection stays open)
069 *     // * Rejected Handshake (explicit rejection, connection closed)
070 *     // * Request Failed (implicit rejection, e.g. uncaught exception, connection closed)
071 *     switch (eventSourceResult) {
072 *       // Explicit handshake acceptance
073 *       case HandshakeAccepted handshakeAccepted -> {
074 *         handshakeAccepted.registerEventConsumer((event) -> {
075 *           // Server-Sent Event received: open the latch to end the test
076 *           eventReceivedLatch.countDown();
077 *         });
078 *
079 *         // On a separate thread, broadcast a Server-Sent Event
080 *         new Thread(() -> {
081 *           // ... not shown
082 *         }).start();
083 *       }
084 *
085 *       // Explicit handshake rejection
086 *       case HandshakeRejected handshakeRejected ->
087 *         Assertions.fail("SSE Handshake Rejected: " + handshakeRejected);
088 *
089 *       // Uncaught exception
090 *       case RequestFailed requestFailed ->
091 *         Assertions.fail("SSE Request Failed: " + requestFailed);
092 *     }
093 *
094 *     // Finally, wait a bit for the latch to open
095 *     try {
096 *       eventReceivedLatch.await(5, SECONDS);
097 *     } catch (InterruptedException e) {
098 *       Assertions.fail("Didn't receive a Server-Sent Event in time");
099 *     }
100 *   });
101 * }}</pre>
102 * <p>
103 * Full documentation is available at <a href="https://www.soklet.com/docs/testing">https://www.soklet.com/docs/testing</a>.
104 *
105 * @author <a href="https://www.revetkn.com">Mark Allen</a>
106 */
107public interface Simulator {
108        /**
109         * Given a request that would normally be handled by your standard {@link HttpServer}, process it and return response data (both logical {@link Response}, if present, and the {@link MarshaledResponse} bytes to be sent over the wire) as well as the matching <em>Resource Method</em>, if available.
110         * <p>
111         * To make requests that would normally be handled by your {@link SseServer}, use {@link #performSseRequest(Request)}.
112         *
113         * @param request the standard HTTP request to process
114         * @return the result (logical response, marshaled response, etc.) that corresponds to the request
115         */
116        @NonNull
117        HttpRequestResult performHttpRequest(@NonNull Request request);
118
119        /**
120         * Given a request that would normally be handled by your {@link SseServer} (that is, for a <em>Resource Method</em> decorated with the {@link com.soklet.annotation.SseEventSource} annotation), process it and return response data ({@link com.soklet.SseRequestResult.HandshakeAccepted}, {@link com.soklet.SseRequestResult.HandshakeRejected}, or {@link com.soklet.SseRequestResult.RequestFailed});
121         * <p>
122         * To make requests that would normally be handled by your {@link HttpServer}, use {@link #performHttpRequest(Request)}.
123         *
124         * @param request the Server-Sent Event HTTP request to process
125         * @return the result (handshake outcode, etc.) that corresponds to the request
126         */
127        @NonNull
128        SseRequestResult performSseRequest(@NonNull Request request);
129
130        /**
131         * Given a request that would normally be handled by your MCP server, process it and return the corresponding MCP-oriented simulator result.
132         * <p>
133         * The default implementation fails fast until MCP simulator support is wired in.
134         *
135         * @param request the MCP HTTP request to process
136         * @return the MCP simulator result
137         */
138        @NonNull
139        default McpRequestResult performMcpRequest(@NonNull Request request) {
140                throw new IllegalStateException(format("You must specify an MCP server in your %s to simulate MCP requests",
141                                SokletConfig.class.getSimpleName()));
142        }
143
144        /**
145         * Registers a handler for exceptions thrown by simulated Server-Sent Event consumers.
146         * <p>
147         * This only applies to simulator-mode SSE broadcasts.
148         *
149         * @param onBroadcastError handler for broadcast errors, or {@code null} to clear
150         * @return this simulator
151         */
152        @NonNull
153        default Simulator onBroadcastError(@Nullable Consumer<Throwable> onBroadcastError) {
154                return this;
155        }
156
157        /**
158         * Registers a handler for exceptions thrown by simulated Server-Sent Event unicast consumers.
159         * <p>
160         * This only applies to simulator-mode SSE unicast deliveries (including client initializers).
161         *
162         * @param onUnicastError handler for unicast errors, or {@code null} to clear
163         * @return this simulator
164         */
165        @NonNull
166        default Simulator onUnicastError(@Nullable Consumer<Throwable> onUnicastError) {
167                return this;
168        }
169
170        /**
171         * Registers a handler for exceptions thrown by simulated MCP stream consumers.
172         *
173         * @param onMcpStreamError handler for MCP stream consumer errors, or {@code null} to clear
174         * @return this simulator
175         */
176        @NonNull
177        default Simulator onMcpStreamError(@Nullable Consumer<Throwable> onMcpStreamError) {
178                return this;
179        }
180}