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.*; 020import static org.apache.juneau.rest.server.util.RestUtils.*; 021 022import java.io.*; 023import java.util.*; 024import java.util.concurrent.*; 025import java.util.logging.*; 026 027import org.apache.juneau.commons.inject.*; 028import org.apache.juneau.http.header.NoTrace; 029import org.apache.juneau.marshall.json.*; 030import org.apache.juneau.marshall.parser.*; 031import org.apache.juneau.rest.client.*; 032import org.apache.juneau.rest.client.RestRequest; 033import org.apache.juneau.rest.server.*; 034import org.apache.juneau.rest.server.logging.*; 035 036import jakarta.servlet.*; 037 038/** 039 * Next-generation mock REST client that routes requests in-process to a Juneau {@link RestContext}. 040 * 041 * <p> 042 * Use this class to write serverless unit tests for {@link Rest @Rest}-annotated 043 * resource classes. Requests are dispatched directly to the {@link RestContext} without any network activity, 044 * making tests fast and self-contained. 045 * 046 * <h5 class='section'>Example:</h5> 047 * <p class='bjava'> 048 * <ja>@Rest</ja> 049 * <jk>public class</jk> MyResource { 050 * <ja>@RestGet</ja>(<js>"/hello"</js>) 051 * <jk>public</jk> String hello() { <jk>return</jk> <js>"Hello!"</js>; } 052 * } 053 * 054 * <ja>@Test</ja> 055 * <jk>void</jk> test() <jk>throws</jk> Exception { 056 * <jk>try</jk> (<jv>client</jv> = MockRestClient.<jsm>create</jsm>(MyResource.<jk>class</jk>)) { 057 * var <jv>body</jv> = <jv>client</jv>.get(<js>"/hello"</js>).run().asString(); 058 * assertEquals(<js>"\"Hello!\""</js>, <jv>body</jv>); 059 * } 060 * } 061 * </p> 062 * 063 * <h5 class='section'>See Also:</h5><ul> 064 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/NextGenRestClient">Next-Generation REST Client</a> 065 * </ul> 066 * 067 * @since 9.2.1 068 */ 069@SuppressWarnings({ 070 "resource" // RestClient is closed by MockRestClient.close(); transport is owned by the client 071}) 072public final class MockRestClient implements Closeable { 073 074 private static final Map<Class<?>,RestContext> restContextCache = new ConcurrentHashMap<>(); 075 076 private final RestClient client; 077 078 private MockRestClient(RestClient client) { 079 this.client = client; 080 } 081 082 /** 083 * Creates an {@link MockRestClient} backed by the given REST bean or bean class. 084 * 085 * <p> 086 * {@link RestContext} instances are cached per class for performance when a {@link Class} is passed; 087 * a supplied bean instance always gets its own context. 088 * 089 * @param impl 090 * The REST bean or bean class annotated with {@link Rest @Rest}. 091 * If a {@link Class}, it must have a no-arg constructor. 092 * @return A new client. Never <jk>null</jk>. 093 */ 094 public static MockRestClient create(Object impl) { 095 return builder(impl).build(); 096 } 097 098 /** 099 * Returns a new {@link Builder} for the given REST bean or bean class. 100 * 101 * @param impl 102 * The REST bean or bean class annotated with {@link Rest @Rest}. 103 * If a {@link Class}, it must have a no-arg constructor. 104 * @return A new builder. Never <jk>null</jk>. 105 */ 106 public static Builder builder(Object impl) { 107 return new Builder(impl); 108 } 109 110 /** 111 * Creates a GET request to the given path. 112 * 113 * @param path The request path (relative to the resource's servlet path). Must not be <jk>null</jk>. 114 * @return A new {@link RestRequest}. Never <jk>null</jk>. 115 */ 116 public RestRequest get(String path) { return client.get(path); } 117 118 /** 119 * Creates a POST request to the given path. 120 * 121 * @param path The request path. Must not be <jk>null</jk>. 122 * @return A new {@link RestRequest}. Never <jk>null</jk>. 123 */ 124 public RestRequest post(String path) { return client.post(path); } 125 126 /** 127 * Creates a PUT request to the given path. 128 * 129 * @param path The request path. Must not be <jk>null</jk>. 130 * @return A new {@link RestRequest}. Never <jk>null</jk>. 131 */ 132 public RestRequest put(String path) { return client.put(path); } 133 134 /** 135 * Creates a PATCH request to the given path. 136 * 137 * @param path The request path. Must not be <jk>null</jk>. 138 * @return A new {@link RestRequest}. Never <jk>null</jk>. 139 */ 140 public RestRequest patch(String path) { return client.patch(path); } 141 142 /** 143 * Creates a DELETE request to the given path. 144 * 145 * @param path The request path. Must not be <jk>null</jk>. 146 * @return A new {@link RestRequest}. Never <jk>null</jk>. 147 */ 148 public RestRequest delete(String path) { return client.delete(path); } 149 150 /** 151 * Creates a HEAD request to the given path. 152 * 153 * @param path The request path. Must not be <jk>null</jk>. 154 * @return A new {@link RestRequest}. Never <jk>null</jk>. 155 */ 156 public RestRequest head(String path) { return client.head(path); } 157 158 /** 159 * Creates a request with the given HTTP method and path. 160 * 161 * @param method The HTTP method. Must not be <jk>null</jk>. 162 * @param path The request path. Must not be <jk>null</jk>. 163 * @return A new {@link RestRequest}. Never <jk>null</jk>. 164 */ 165 public RestRequest request(String method, String path) { return client.request(method, path); } 166 167 /** 168 * Returns the underlying {@link RestClient}. 169 * 170 * @return The client. Never <jk>null</jk>. 171 */ 172 public RestClient getClient() { return client; } 173 174 @Override /* Closeable */ 175 public void close() throws IOException { 176 client.close(); 177 } 178 179 // ----------------------------------------------------------------------------------------------------------------- 180 // Inner types 181 // ----------------------------------------------------------------------------------------------------------------- 182 183 /** 184 * Fluent builder for {@link MockRestClient}. 185 * 186 * @since 9.2.1 187 */ 188 public static final class Builder implements BeanStoreOverridable<Builder> { 189 190 private final Object impl; 191 private String contextPath; 192 private BeanStore overridingBeanStore; 193 private Parser defaultParser; 194 private boolean noTrace; 195 196 private Builder(Object impl) { 197 this.impl = impl; 198 } 199 200 /** 201 * Sets the servlet context path (e.g. {@code "/myapp"}). 202 * 203 * @param value The context path. May be <jk>null</jk>. 204 * @return This object. 205 */ 206 public Builder contextPath(String value) { 207 contextPath = toValidContextPath(value); 208 return this; 209 } 210 211 /** 212 * Installs a {@link BeanStore} as the {@code overridingParent} of the {@link RestContext}'s 213 * bean store, so test-time overrides (e.g. {@code TestBeanStore}) resolve at tier 1 of the 214 * lookup chain — above the resource's local {@code @Bean} factory entries. 215 * 216 * <p> 217 * Setting this disables the per-resource {@link RestContext} cache for the duration of this 218 * build, so the overlay is not silently shared with a previously-cached instance. 219 * 220 * <h5 class='section'>Example:</h5> 221 * <p class='bjava'> 222 * <jk>var</jk> <jv>overlay</jv> = <jk>new</jk> TestBeanStore().override(MyService.<jk>class</jk>, mockSvc); 223 * <jk>try</jk> (<jv>client</jv> = MockRestClient.<jsm>builder</jsm>(MyResource.<jk>class</jk>) 224 * .overridingBeanStore(<jv>overlay</jv>) 225 * .build()) { 226 * <jv>client</jv>.get(<js>"/widgets/1"</js>).run().assertStatus().is(200); 227 * } 228 * </p> 229 * 230 * @param value The override layer. Can be <jk>null</jk> to clear a previously-set value. 231 * @return This object. 232 * 233 * @since 10.0.0 234 */ 235 @Override 236 public Builder overridingBeanStore(BeanStore value) { 237 overridingBeanStore = value; 238 return this; 239 } 240 241 /** 242 * Sets an explicit default parser used when the response {@code Content-Type} matches no registered parser. 243 * 244 * <p> 245 * Setting this also causes the client to advertise the parser's media types in the {@code Accept} header, 246 * so the server must be able to produce that content type. Omit this (the default) when the target 247 * resource does not have a JSON serializer configured. 248 * 249 * <h5 class='section'>Example:</h5> 250 * <p class='bjava'> 251 * <jk>try</jk> (<jv>client</jv> = MockRestClient.<jsm>builder</jsm>(MyJsonResource.<jk>class</jk>) 252 * .defaultParser(JsonParser.<jsf>DEFAULT</jsf>) 253 * .build()) { 254 * <jk>var</jk> <jv>api</jv> = <jv>client</jv>.getClient().remote(MyApi.<jk>class</jk>); 255 * <jv>api</jv>.someMethod(); 256 * } 257 * </p> 258 * 259 * @param value The parser. May be <jk>null</jk>. 260 * @return This object. 261 */ 262 public Builder defaultParser(Parser value) { 263 defaultParser = value; 264 return this; 265 } 266 267 /** 268 * Sends a <c>No-Trace: true</c> header with every request, so the server leaves the stack trace of a failed 269 * call out of its log record. 270 * 271 * <p> 272 * Opt-in only; no other builder option turns it on. The one-line access record is still logged. To do the 273 * same for a single call, use {@link RestRequest#noTrace()}. 274 * 275 * <h5 class='section'>Example:</h5> 276 * <p class='bjava'> 277 * <jk>try</jk> (<jv>client</jv> = MockRestClient.<jsm>builder</jsm>(MyResource.<jk>class</jk>).noTrace().build()) { 278 * <jv>client</jv>.get(<js>"/missing"</js>).run().assertStatus().is(404); 279 * } 280 * </p> 281 * 282 * @return This object. 283 */ 284 public Builder noTrace() { 285 noTrace = true; 286 return this; 287 } 288 289 /** 290 * Builds and returns the {@link MockRestClient}. 291 * 292 * @return A new instance. Never <jk>null</jk>. 293 */ 294 @SuppressWarnings({ 295 "java:S112" // Re-wrap as RuntimeException - build() wraps arbitrary checked exceptions from reflection (newInstance) and RestContext init/postInit; no specific type fits and the fluent build() method cannot declare checked exceptions. 296 }) 297 public MockRestClient build() { 298 try { 299 var isClass = impl instanceof Class<?>; 300 var overlay = overridingBeanStore; 301 // Only class-based contexts are cached; a caller-supplied instance always gets its own context. 302 var cacheable = isClass && overlay == null; 303 RestContext restContext = cacheable ? restContextCache.get(impl) : null; 304 if (restContext == null) { 305 var o = isClass ? ((Class<?>)impl).getDeclaredConstructor().newInstance() : impl; 306 restContext = new RestContext(new RestContext.Args(o.getClass(), null, null, () -> o, "", bs -> { 307 bs.addBeanType(RestDebugFormatter.class, BasicTestRestDebugFormatter.class); 308 }, overlay, null, RestContext.ContextKind.ROOT)).postInit().postInitChildFirst(); 309 if (cacheable) 310 restContextCache.put((Class<?>)impl, restContext); 311 } 312 313 var servletPath = toValidContextPath(restContext.getFullPath()); 314 var fullContextPath = emptyIfNull(contextPath) + emptyIfNull(servletPath); 315 316 var transport = new JuneauRestTransport(restContext, emptyIfNull(contextPath), servletPath); 317 var clientBuilder = RestClient.builder() 318 .transport(transport) 319 .rootUrl("http://localhost" + fullContextPath) 320 .defaultSerializer(JsonSerializer.DEFAULT); 321 if (defaultParser != null) 322 clientBuilder.defaultParser(defaultParser); 323 if (noTrace) 324 clientBuilder.header(NoTrace.NAME, "true"); 325 var ngClient = clientBuilder.build(); 326 327 return new MockRestClient(ngClient); 328 } catch (Exception e) { 329 throw rex(e, "Could not initialize MockRestClient"); 330 } 331 } 332 333 private static String emptyIfNull(String s) { return s == null ? "" : s; } 334 } 335 336 // ----------------------------------------------------------------------------------------------------------------- 337 // Transport implementation 338 // ----------------------------------------------------------------------------------------------------------------- 339 340 /** 341 * An {@link HttpTransport} that dispatches requests in-process through a Juneau {@link RestContext}. 342 */ 343 private static final class JuneauRestTransport implements HttpTransport { 344 345 private final RestContext restContext; 346 private final Object restObject; 347 private final String contextPath; 348 private final String servletPath; 349 350 JuneauRestTransport(RestContext restContext, String contextPath, String servletPath) { 351 this.restContext = restContext; 352 this.restObject = restContext.getResource(); 353 this.contextPath = contextPath; 354 this.servletPath = servletPath; 355 } 356 357 @Override /* HttpTransport */ 358 public boolean supportsUrlPolicy() { 359 // Requests are dispatched in-process, directly to the RestContext -- there is no real DNS resolution or 360 // socket connect to pin-on-connect, so the "cannot honor the connect-time contract" fail-closed 361 // rationale does not apply. The deny-private lexical pre-check (RemoteUrlPolicy.requireAllowedUrl) 362 // still runs upstream in the @Remote proxy engine before any request reaches this transport, and this 363 // class's own "http://localhost" root (see MockRestClient.Builder.build()) is never re-checked by that 364 // pre-check -- only an explicit absolute @Url/baseUrl() override on the proxy interface is. 365 return true; 366 } 367 368 @Override /* HttpTransport */ 369 public TransportResponse execute(TransportRequest request) throws TransportException { 370 if (request.isSsrfGuardActive()) 371 return PolicyEnforcedRedirects.execute(request, this::dispatch); 372 return dispatch(request); 373 } 374 375 private TransportResponse dispatch(TransportRequest request) throws TransportException { 376 try { 377 var method = request.getMethod(); 378 var uri = request.getUri().toString(); 379 380 var servletReq = MockServletRequest.create(method, uri) 381 .servletPath(servletPath) 382 .contextPath(contextPath); 383 384 for (var h : request.getHeaders()) 385 servletReq.header(h.name(), h.value()); 386 387 var body = request.getBody(); 388 if (body != null) { 389 var baos = new ByteArrayOutputStream(); 390 body.writeTo(baos); 391 servletReq.content(baos.toByteArray()); 392 } 393 394 var servletRes = MockServletResponse.create(); 395 396 var logLevel = servletReq.getLogLevel(); 397 if (logLevel != null) { 398 var target = Logger.getLogger(restObject.getClass().getName()); 399 var prevLevel = target.getLevel(); 400 target.setLevel(logLevel); 401 try { 402 restContext.execute(restObject, servletReq, servletRes); 403 } finally { 404 target.setLevel(prevLevel); 405 } 406 } else { 407 restContext.execute(restObject, servletReq, servletRes); 408 } 409 410 var responseBuilder = TransportResponse.builder() 411 .statusCode(servletRes.getStatus()) 412 .reasonPhrase(servletRes.getMessage()); 413 414 for (var name : servletRes.getHeaderNames()) 415 for (var value : servletRes.getHeaders(name)) 416 responseBuilder.header(name, value); 417 418 var content = servletRes.getContent(); 419 if (content.length > 0) 420 responseBuilder.body(new ByteArrayInputStream(content)); 421 422 return responseBuilder.build(); 423 424 } catch (TransportException e) { 425 throw e; 426 } catch (ServletException | IOException e) { 427 throw new TransportException("In-process dispatch failed: " + e.getMessage(), e); 428 } 429 } 430 } 431}