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 -> 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}