package io.github.azagniotov.stubby4j.stubs; import io.github.azagniotov.stubby4j.annotations.CoberturaIgnore; import io.github.azagniotov.stubby4j.cli.ANSITerminal; import io.github.azagniotov.stubby4j.client.StubbyResponse; import io.github.azagniotov.stubby4j.http.StubbyHttpTransport; import io.github.azagniotov.stubby4j.utils.ConsoleUtils; import io.github.azagniotov.stubby4j.utils.FileUtils; import io.github.azagniotov.stubby4j.utils.ObjectUtils; import io.github.azagniotov.stubby4j.yaml.YAMLParser; import javax.servlet.http.HttpServletRequest; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicLong; import static io.github.azagniotov.stubby4j.stubs.StubResponse.notFoundResponse; import static io.github.azagniotov.stubby4j.stubs.StubResponse.redirectResponse; import static io.github.azagniotov.stubby4j.stubs.StubResponse.unauthorizedResponse; import static io.github.azagniotov.stubby4j.utils.CollectionUtils.constructParamMap; import static io.github.azagniotov.stubby4j.utils.ConsoleUtils.logAssertingRequest; import static io.github.azagniotov.stubby4j.utils.HandlerUtils.extractPostRequestBody; import static io.github.azagniotov.stubby4j.utils.ObjectUtils.isNotNull; import static io.github.azagniotov.stubby4j.utils.ReflectionUtils.injectObjectFields; import static io.github.azagniotov.stubby4j.utils.StringUtils.toLower; import static io.github.azagniotov.stubby4j.yaml.ConfigurableYAMLProperty.BODY; import static java.util.Collections.list; public class StubRepository { private final File configFile; private final List<StubHttpLifecycle> stubs; private final Future<List<StubHttpLifecycle>> stubLoadComputation; private final StubbyHttpTransport stubbyHttpTransport; private final ConcurrentHashMap<String, AtomicLong> resourceStats; private final ConcurrentHashMap<String, StubHttpLifecycle> matchedStubsCache; public StubRepository(final File configFile, final Future<List<StubHttpLifecycle>> stubLoadComputation) { this.stubs = new ArrayList<>(); this.configFile = configFile; this.stubLoadComputation = stubLoadComputation; this.stubbyHttpTransport = new StubbyHttpTransport(); this.resourceStats = new ConcurrentHashMap<>(); this.matchedStubsCache = new ConcurrentHashMap<>(); } public StubSearchResult search(final HttpServletRequest incomingRequest) throws IOException { final StubRequest assertionStubRequest = this.toStubRequest(incomingRequest); logAssertingRequest(assertionStubRequest); final StubResponse match = findMatch(new StubHttpLifecycle.Builder().withRequest(assertionStubRequest).build()); return new StubSearchResult(assertionStubRequest, match); } public StubRequest toStubRequest(final HttpServletRequest request) throws IOException { final StubRequest.Builder builder = new StubRequest.Builder(); builder.withUrl(request.getPathInfo()) .withPost(extractPostRequestBody(request, "stubs")) .withMethod(request.getMethod()); final Enumeration<String> headerNamesEnumeration = request.getHeaderNames(); final List<String> headerNames = isNotNull(headerNamesEnumeration) ? list(request.getHeaderNames()) : new LinkedList<>(); for (final String headerName : headerNames) { final String headerValue = request.getHeader(headerName); builder.withHeader(toLower(headerName), headerValue); } return builder.withQuery(constructParamMap(request.getQueryString())).build(); } private StubResponse findMatch(final StubHttpLifecycle incomingRequest) { final Optional<StubHttpLifecycle> matchedStubOptional = matchStub(incomingRequest); if (!matchedStubOptional.isPresent()) { return notFoundResponse(); } final StubHttpLifecycle matchedStub = matchedStubOptional.get(); final String resourceId = matchedStub.getResourceId(); resourceStats.putIfAbsent(resourceId, new AtomicLong(0)); resourceStats.get(resourceId).incrementAndGet(); final StubResponse matchedStubResponse = matchedStub.getResponse(true); if (matchedStub.isAuthorizationRequired() && matchedStub.isIncomingRequestUnauthorized(incomingRequest)) { return unauthorizedResponse(); } if (matchedStubResponse.hasHeaderLocation()) { return redirectResponse(Optional.of(matchedStubResponse)); } if (matchedStubResponse.isRecordingRequired()) { final String recordingSource = String.format("%s%s", matchedStubResponse.getBody(), incomingRequest.getUrl()); try { final StubbyResponse stubbyResponse = stubbyHttpTransport.fetchRecordableHTTPResponse(matchedStub.getRequest(), recordingSource); injectObjectFields(matchedStubResponse, BODY.toString(), stubbyResponse.getContent()); } catch (Exception e) { ANSITerminal.error(String.format("Could not record from %s: %s", recordingSource, e.toString())); } } return matchedStubResponse; } /** * That's the point where the incoming {@link StubHttpLifecycle} that was created from the incoming * raw {@link HttpServletRequest request} is matched to the in-memory stubs. * <p> * First, the local cache holding previously matched stubs is checked to see if there is a match for the incoming * {@link StubHttpLifecycle request} URI. If the incoming {@link StubHttpLifecycle} URI found in the cache, then the * cached match and the incoming {@link StubHttpLifecycle} are compared to each other to determine a complete * equality based on the {@link StubRequest#equals(Object)}. * <p> * If a complete equality with the cached {@link StubHttpLifecycle match} was not achieved, the incoming * {@link StubHttpLifecycle request} is compared to every {@link StubHttpLifecycle element} in the list of loaded * stubs. * <p> * The {@link List<StubHttpLifecycle>#indexOf(Object)} implicitly invokes {@link StubHttpLifecycle#equals(Object)}, * which invokes the {@link StubRequest#equals(Object)}. * * @param incomingStub {@link StubHttpLifecycle} * @return an {@link Optional} describing {@link StubHttpLifecycle} match, or an empty {@link Optional} if there was no match. * @see #toStubRequest(HttpServletRequest) * @see StubHttpLifecycle#equals(Object) * @see StubRequest#equals(Object) * @see StubMatcher#matches(StubRequest, StubRequest) */ private synchronized Optional<StubHttpLifecycle> matchStub(final StubHttpLifecycle incomingStub) { final String incomingRequestUrl = incomingStub.getUrl(); if (matchedStubsCache.containsKey(incomingRequestUrl)) { ANSITerminal.loaded(String.format("Local cache contains potential match for the URL [%s]", incomingRequestUrl)); final StubHttpLifecycle cachedPotentialMatch = matchedStubsCache.get(incomingRequestUrl); // The order(?) in which equality is determined is important here (what object is "equal to" the other one) if (incomingStub.equals(cachedPotentialMatch)) { ANSITerminal.loaded(String.format("Potential match for the URL [%s] was deemed as a full match", incomingRequestUrl)); return Optional.of(cachedPotentialMatch); } ANSITerminal.warn(String.format("Cached match for the URL [%s] failed to match fully, invalidating match cache..", incomingRequestUrl)); matchedStubsCache.remove(incomingRequestUrl); } final long initialStart = System.currentTimeMillis(); for (final StubHttpLifecycle stubbed : stubs) { if (incomingStub.equals(stubbed)) { final long elapsed = System.currentTimeMillis() - initialStart; ANSITerminal.status(String.format("Found a match after %s milliseconds, caching the found match for URL [%s]", elapsed, incomingRequestUrl)); matchedStubsCache.put(incomingRequestUrl, stubbed); return Optional.of(stubbed); } } return Optional.empty(); } public synchronized Optional<StubHttpLifecycle> matchStubByIndex(final int index) { if (!canMatchStubByIndex(index)) { return Optional.empty(); } return Optional.of(stubs.get(index)); } synchronized boolean resetStubsCache(final List<StubHttpLifecycle> newStubs) { this.matchedStubsCache.clear(); this.stubs.clear(); final boolean added = this.stubs.addAll(newStubs); if (added) { this.matchedStubsCache.clear(); updateResourceIDHeaders(); } return added; } public synchronized void refreshStubsFromYAMLConfig(final YAMLParser yamlParser) throws Exception { resetStubsCache(yamlParser.parse(this.configFile.getParent(), configFile)); } public synchronized void refreshStubsByPost(final YAMLParser yamlParser, final String postPayload) throws Exception { resetStubsCache(yamlParser.parse(this.configFile.getParent(), postPayload)); } public synchronized String refreshStubByIndex(final YAMLParser yamlParser, final String putPayload, final int index) throws Exception { final List<StubHttpLifecycle> parsedStubs = yamlParser.parse(this.configFile.getParent(), putPayload); final StubHttpLifecycle newStub = parsedStubs.get(0); updateStubByIndex(index, newStub); return newStub.getUrl(); } // Just a shallow copy that protects collection from modification, the points themselves are not copied public List<StubHttpLifecycle> getStubs() { return new LinkedList<>(stubs); } // Just a shallow copy that protects collection from modification, the points themselves are not copied public ConcurrentHashMap<String, AtomicLong> getResourceStats() { return new ConcurrentHashMap<>(resourceStats); } @CoberturaIgnore public String getResourceStatsAsCsv() { final String csvNoHeader = resourceStats.toString().replaceAll("\\{|\\}", "").replaceAll(", ", FileUtils.BR).replaceAll("=", ","); return String.format("resourceId,hits%s%s", FileUtils.BR, csvNoHeader); } public synchronized String getOnlyStubRequestUrl() { return stubs.get(0).getUrl(); } public File getYAMLConfig() { return configFile; } public synchronized Map<File, Long> getExternalFiles() { final Set<String> escrow = new HashSet<>(); final Map<File, Long> externalFiles = new HashMap<>(); for (final StubHttpLifecycle stub : stubs) { cacheExternalFile(escrow, externalFiles, stub.getRequest().getRawFile()); final List<StubResponse> responses = stub.getResponses(); for (final StubResponse stubbedResponse : responses) { cacheExternalFile(escrow, externalFiles, stubbedResponse.getRawFile()); } } return externalFiles; } private void cacheExternalFile(final Set<String> escrow, final Map<File, Long> externalFiles, final File file) { if (ObjectUtils.isNotNull(file) && !escrow.contains(file.getName())) { escrow.add(file.getName()); externalFiles.put(file, file.lastModified()); } } @CoberturaIgnore public String getYAMLConfigCanonicalPath() { try { return this.configFile.getCanonicalPath(); } catch (IOException e) { return this.configFile.getAbsolutePath(); } } public synchronized String getStubYAML() { final StringBuilder builder = new StringBuilder(); for (final StubHttpLifecycle stub : stubs) { builder.append(stub.getCompleteYAML()).append(FileUtils.BR).append(FileUtils.BR); } return builder.toString(); } public synchronized String getStubYAMLByIndex(final int index) { return stubs.get(index).getCompleteYAML(); } synchronized void updateStubByIndex(final int index, final StubHttpLifecycle newStub) { deleteStubByIndex(index); stubs.add(index, newStub); updateResourceIDHeaders(); } public synchronized boolean canMatchStubByIndex(final int index) { return stubs.size() - 1 >= index; } public synchronized StubHttpLifecycle deleteStubByIndex(final int index) { final StubHttpLifecycle removedStub = stubs.remove(index); updateResourceIDHeaders(); return removedStub; } private void updateResourceIDHeaders() { for (int index = 0; index < stubs.size(); index++) { stubs.get(index).setResourceId(index); } } @CoberturaIgnore public void retrieveLoadedStubs() { try { stubs.addAll(stubLoadComputation.get()); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } } }