001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.mock;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.io.*;
022import java.nio.charset.*;
023import java.util.*;
024import java.util.concurrent.*;
025
026import org.apache.juneau.rest.client.*;
027
028/**
029 * A programmable in-process {@link HttpTransport} for unit testing.
030 *
031 * <p>
032 * Register one or more route handlers, or a catch-all handler, and the transport will dispatch
033 * each {@link TransportRequest} to the appropriate handler without any real network activity.
034 *
035 * <h5 class='section'>Example:</h5>
036 * <p class='bjava'>
037 *    <jv>transport</jv> = MockHttpTransport.<jsm>builder</jsm>()
038 *       .on(<js>"GET"</js>, <js>"/hello"</js>, req -&gt; TransportResponse.<jsm>builder</jsm>()
039 *          .statusCode(200)
040 *          .reasonPhrase(<js>"OK"</js>)
041 *          .header(<js>"Content-Type"</js>, <js>"text/plain"</js>)
042 *          .body(new ByteArrayInputStream(<js>"Hello, World!"</js>.getBytes(StandardCharsets.UTF_8)))
043 *          .build())
044 *       .build();
045 *
046 *    RestClient <jv>client</jv> = RestClient.<jsm>builder</jsm>()
047 *       .transport(<jv>transport</jv>)
048 *       .build();
049 * </p>
050 *
051 * <h5 class='section'>See Also:</h5><ul>
052 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/NextGenRestClient">Next-Generation REST Client</a>
053 * </ul>
054 *
055 * @since 9.2.1
056 */
057@SuppressWarnings({
058   "resource" // execute()/of()/build() return Closeable types; caller must close TransportResponse / MockHttpTransport
059})
060public final class MockHttpTransport implements HttpTransport {
061
062   /**
063    * A handler that maps an incoming {@link TransportRequest} to a {@link TransportResponse}.
064    *
065    * @since 9.2.1
066    */
067   @FunctionalInterface
068   public interface RequestHandler {
069      /**
070       * Handles the request.
071       *
072       * @param request The incoming request. Never {@code null}.
073       * @return The response to return. Never {@code null}.
074       * @throws TransportException If the handler wants to simulate a network error.
075       */
076      TransportResponse handle(TransportRequest request) throws TransportException;
077   }
078
079   private final List<Route> routes;
080   private final RequestHandler fallback;
081   private final List<TransportRequest> recordedRequests;
082
083   private MockHttpTransport(Builder builder) {
084      this.routes = List.copyOf(builder.routes);
085      this.fallback = builder.fallback;
086      this.recordedRequests = builder.recordRequests ? new CopyOnWriteArrayList<>() : null;
087   }
088
089   /** Returns a new {@link Builder}. */
090   public static Builder builder() {
091      return new Builder();
092   }
093
094   /**
095    * Creates a simple mock transport that always returns the given status code and body string.
096    *
097    * @param statusCode The HTTP status code to return.
098    * @param body The body string to return (UTF-8 encoded). May be <jk>null</jk>.
099    * @return A new instance. Never <jk>null</jk>.
100    */
101   public static MockHttpTransport of(int statusCode, String body) {
102      return builder()
103         .fallback((RequestHandler) req -> TransportResponse.builder()
104            .statusCode(statusCode)
105            .body(body != null ? new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)) : null)
106            .build())
107         .build();
108   }
109
110   @Override /* HttpTransport */
111   public TransportResponse execute(TransportRequest request) throws TransportException {
112      if (request.isSsrfGuardActive())
113         return PolicyEnforcedRedirects.execute(request, this::dispatchWithTimeout);
114      return dispatchWithTimeout(request);
115   }
116
117   // Simulates TransportRequest.getTimeout(): when set, the matched handler runs on a daemon thread and the call fails
118   // with a TransportException (cause: TimeoutException) if the handler has not returned within the timeout.  With no
119   // timeout the handler runs inline on the calling thread, exactly as before.
120   private TransportResponse dispatchWithTimeout(TransportRequest request) throws TransportException {
121      var timeout = request.getTimeout();
122      if (timeout == null)
123         return dispatch(request);
124      var task = new FutureTask<TransportResponse>(() -> dispatch(request));
125      var thread = new Thread(task, "juneau-mock-transport");
126      thread.setDaemon(true);
127      thread.start();
128      try {
129         return task.get(Math.max(1, timeout.toMillis()), TimeUnit.MILLISECONDS);
130      } catch (TimeoutException e) {
131         task.cancel(true);
132         throw new TransportException("HTTP request timed out", e);
133      } catch (InterruptedException e) {
134         task.cancel(true);
135         Thread.currentThread().interrupt();
136         throw new TransportException("HTTP request interrupted", e);
137      } catch (ExecutionException e) {
138         var cause = e.getCause();
139         if (cause instanceof TransportException te)
140            throw te;
141         if (cause instanceof RuntimeException re)
142            throw re;
143         throw new TransportException("HTTP transport error: " + cause.getMessage(), cause);
144      }
145   }
146
147   @Override /* HttpTransport */
148   public boolean supportsUrlPolicy() {
149      // There is no real DNS resolution or socket connect to pin-on-connect: routing is an in-process dispatch
150      // to registered handlers, so the "cannot honor the connect-time contract" rationale for failing closed
151      // does not apply here. The deny-private lexical pre-check (RemoteUrlPolicy.requireAllowedUrl) still runs
152      // upstream in the @Remote proxy engine before any request reaches this transport.
153      return true;
154   }
155
156   private TransportResponse dispatch(TransportRequest request) throws TransportException {
157      if (recordedRequests != null)
158         recordedRequests.add(request);
159
160      var path = request.getUri().getPath();
161      var method = request.getMethod();
162
163      for (var route : routes) {
164         if (route.matches(method, path))
165            return route.handler().handle(request);
166      }
167
168      if (fallback != null)
169         return fallback.handle(request);
170
171      return TransportResponse.builder()
172         .statusCode(404)
173         .reasonPhrase("Not Found")
174         .body(new ByteArrayInputStream(("No mock route matched: " + method + " " + path).getBytes(StandardCharsets.UTF_8)))
175         .build();
176   }
177
178   /**
179    * Returns an unmodifiable view of all requests received since creation (only available when recording is enabled).
180    *
181    * @return The recorded requests. Empty if recording is disabled.
182    */
183   public List<TransportRequest> getRecordedRequests() {
184      return recordedRequests != null ? u(recordedRequests) : List.of();
185   }
186
187   /**
188    * Clears the list of recorded requests.
189    */
190   public void clearRecordedRequests() {
191      if (recordedRequests != null)
192         recordedRequests.clear();
193   }
194
195   // -----------------------------------------------------------------------------------------------------------------
196   // Inner types
197   // -----------------------------------------------------------------------------------------------------------------
198
199   private record Route(String method, String path, RequestHandler handler) {
200      boolean matches(String m, String p) {
201         return (method == null || eqic(method, m)) && (path == null || path.equals(p));
202      }
203   }
204
205   /**
206    * Fluent builder for {@link MockHttpTransport}.
207    *
208    * @since 9.2.1
209    */
210   public static final class Builder {
211
212      final List<Route> routes = l();
213      RequestHandler fallback;
214      boolean recordRequests;
215
216      private Builder() {}
217
218      /**
219       * Registers a handler for the given HTTP method and path.
220       *
221       * @param method The HTTP method (e.g. {@code "GET"}). {@code null} matches any method.
222       * @param path The exact request path (e.g. {@code "/api/users"}). {@code null} matches any path.
223       * @param handler The handler. Must not be <jk>null</jk>.
224       * @return This object.
225       */
226      public Builder on(String method, String path, RequestHandler handler) {
227         reqnn("handler", handler);
228         routes.add(new Route(method, path, handler));
229         return this;
230      }
231
232      /**
233       * Sets a fallback handler used when no route matches.
234       *
235       * <p>
236       * Without a fallback, unmatched requests receive a {@code 404} response.
237       *
238       * @param value
239       *    The fallback handler.
240       *    <br>Can be <jk>null</jk> — a <jk>null</jk> fallback is the intentional default (unmatched requests get a {@code 404}).
241       * @return This object.
242       */
243      public Builder fallback(RequestHandler value) {
244         fallback = value;
245         return this;
246      }
247
248      /**
249       * Enables recording of all received requests so they can be retrieved via
250       * {@link MockHttpTransport#getRecordedRequests()}.
251       *
252       * @return This object.
253       */
254      public Builder recordRequests() {
255         recordRequests = true;
256         return this;
257      }
258
259      /**
260       * Builds and returns the {@link MockHttpTransport}.
261       *
262       * @return A new instance. Never <jk>null</jk>.
263       */
264      public MockHttpTransport build() {
265         return new MockHttpTransport(this);
266      }
267   }
268}