/** * Copyright (c) Codice Foundation * <p/> * This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser * General Public License as published by the Free Software Foundation, either version 3 of the * License, or any later version. * <p/> * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. A copy of the GNU Lesser General Public License * is distributed along with this program and can be found at * <http://www.gnu.org/licenses/lgpl.html>. */ package org.codice.ddf.itests.common.restito; import static java.lang.Thread.sleep; import static com.xebialabs.restito.semantics.Action.composite; import static com.xebialabs.restito.semantics.Action.contentType; import static com.xebialabs.restito.semantics.Action.custom; import static com.xebialabs.restito.semantics.Action.header; import java.io.IOException; import java.time.Duration; import java.util.Collections; import java.util.Map; import org.apache.commons.lang.StringUtils; import org.glassfish.grizzly.http.server.Response; import org.glassfish.grizzly.http.util.HttpStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.xebialabs.restito.semantics.Action; import com.xebialabs.restito.semantics.Function; /** * Encompasses the response for a product retrieval for a Restito stub server. * Allows for the simple setup of specific responses, slow sending, and simulated * network failure retry testing. */ public class ChunkedContent { protected static final Logger LOGGER = LoggerFactory.getLogger(ChunkedContent.class); public static class ChunkedContentBuilder { private String message; private Duration delayBetweenChunks = Duration.ofMillis(0); private int numberOfFailures = 0; private HeaderCapture headerCapture = null; /** * Set message. * * @param message Message to be sent in the response. */ public ChunkedContentBuilder(String message) { this.message = message; } /** * Set delay between sending each character of the message in milliseconds. * * @param delay Time to wait between sending each character of the message. * @return Builder object */ public ChunkedContentBuilder delayBetweenChunks(Duration delay) { this.delayBetweenChunks = delay; return this; } /** * Number of times to fail (simulate network disconnect) after sending * the first character. Once this number is reached, the message will * send successfully. * * @param numberOfFailures Number of times to fail (simulate network disconnect) after sending * the first character. Once this number is reached, the message will * send successfully. * @return Builder object */ public ChunkedContentBuilder fail(int numberOfFailures) { this.numberOfFailures = numberOfFailures; return this; } /** * Setting this will make the Response handle range headers properly. If this isn't set, then * the response will ignore range requests and return the entire message every time. * * @param headerCapture HeaderCapture object that contains the request's headers. * @return */ public ChunkedContentBuilder allowPartialContent(HeaderCapture headerCapture) { this.headerCapture = headerCapture; return this; } /** * Builds Action. * * @return Action constructed from builder object. */ public Action build() { return createChunkedContent(message, delayBetweenChunks, numberOfFailures, headerCapture); } } private static Action getChunkedResponseHeaders() { return composite(contentType("text/plain"), header("Transfer-Encoding", "chunked"), header("content-type", "text/plain")); } private static Action getRangeSupportHeaders() { return header("Accept-Ranges", "bytes"); } /** * Returns a composite action that holds the headers required for a plain text response as well * as the response function itself. Unless the headers included are not wanted, this is the * preferred way to set the Restito response actions. * <p> * Note that additional headers may also be needed for specific endpoint functionality. This * method returns a composite action so that additional headers can be set alongside this action. * * @param responseMessage Message to be sent in response. * @param delayBetweenChunks Time to wait between sending each character of the message. * @param numberOfFailures Number of times to fail (simulate network disconnect) after sending * the first character. Once this number is reached, the message will * send successfully. * @param headerCapture Object that can be called to return the request's headers. * @return composite action that holds the headers required for a plain text response as well * as the response function itself. */ private static Action createChunkedContent(String responseMessage, Duration delayBetweenChunks, int numberOfFailures, HeaderCapture headerCapture) { Action response = composite(getChunkedResponseHeaders(), custom(new ChunkedContentFunction(responseMessage, delayBetweenChunks, numberOfFailures, headerCapture))); // adds the Accept-Ranges header for range-header support if (headerCapture != null) { response = composite(getRangeSupportHeaders(), response); } return response; } /** * Private inner class representing the response function */ private static class ChunkedContentFunction implements Function<Response, Response> { private char[] responseMessage; private long messageDelayMs; private int numberOfFailures; private int numberOfRetries; private HeaderCapture headerCapture; /** * Implementation of the Function interface's apply method. This class can also be used as a * Function<Response, Response> directly in the Restito response if custom actions are needed. * * @param response Response object correlating to the incoming request. Used to write data * back to the requesting client. * @return Response New state of the response object correlating to the incoming request. */ @Override public Response apply(Response response) { return respond(response); } /** * Constructor for a response that has a delay and a number of planned failures. * Supports range headers. * * @param responseMessage Message to be sent in response. * @param messageDelay Time to wait between sending each character of the message. * @param numberOfFailures Number of times to fail (simulate network disconnect) after sending * the first character. Once this number is reached, the message will * send successfully. * @param headerCapture HeaderCapture object that contains the request's headers. */ private ChunkedContentFunction(String responseMessage, Duration messageDelay, int numberOfFailures, HeaderCapture headerCapture) { this.responseMessage = responseMessage.toCharArray(); this.messageDelayMs = messageDelay.toMillis(); this.numberOfFailures = numberOfFailures; this.headerCapture = headerCapture; } private Response respond(Response response) { Map<String, String> requestHeaders = Collections.emptyMap(); if (headerCapture != null) { requestHeaders = headerCapture.getHeaders(); LOGGER.debug("ChunkedContentResponse: extracted request headers [{}]", requestHeaders); } // if range header is present, return 206 - Partial Content status and set Content-Range header if byte Offset is specified ByteRange byteRange; if (StringUtils.isNotBlank(requestHeaders.get("range"))) { byteRange = new ByteRange(requestHeaders.get("range"), responseMessage.length); response.setStatus(HttpStatus.PARTIAL_CONTENT_206); response.setHeader("Content-Range", byteRange.contentRangeValue()); LOGGER.debug("ChunkedContentResponse: Response range header set to [Content-Range: {}]", byteRange.contentRangeValue()); } else { response.setStatus(HttpStatus.OK_200); byteRange = new ByteRange(0, responseMessage.length - 1); LOGGER.debug("ChunkedContentResponse: set response status to 200"); } return send(response, byteRange); } /** * Sends message to the client one character at a time. Appropriate headers must be set on * the Response object before calling this method. * * @param response Response object containing an output stream to the client * @param byteRange Object containing the range of bytes to send to the client * @return */ private Response send(Response response, ByteRange byteRange) { // send each character, respecting the range header for (int i = byteRange.start; i <= byteRange.end; i++) { try { LOGGER.debug("ChunkedContentResponse: Sending character [{}]", responseMessage[i]); response.getNIOWriter() .write(responseMessage[i]); response.flush(); sleep(messageDelayMs); // fail download by ungracefully closing the output buffer to simulate connection lost if (numberOfRetries < numberOfFailures) { response.getOutputBuffer() .recycle(); numberOfRetries++; return response; } } catch (IOException | InterruptedException e) { LOGGER.error("Error", e); break; } } response.finish(); return response; } /** * Holds byte offset data specified by the request's range header and any related parsing * methods. */ private class ByteRange { public final int start; public final int end; ByteRange(int start, int end) { this.start = start; this.end = end; } /** * Tokenizes the starting and ending bytes values from a range header. The stub server only * advertising supporting byte offsets specifically, so this function assumes that byte * ranges are provided and does not evaluate the unit of measurement * * @param rangeHeaderValue Value of the range header sent in the request * @param totalSizeOfProductInBytes Total size of the message to be returned. This is * not the partial content size, but the FULL size of * the product. */ ByteRange(String rangeHeaderValue, int totalSizeOfProductInBytes) { // extract bytes String startToken = StringUtils.substringBetween(rangeHeaderValue, "=", "-"); String endToken = StringUtils.substringAfter(rangeHeaderValue, "-"); // range offsets can be blank to indicate "until beginning" or "until end" of data. try { if (StringUtils.isBlank(startToken)) { start = 0; } else { start = Integer.parseInt(startToken); } if (StringUtils.isBlank(endToken)) { end = totalSizeOfProductInBytes - 1; } else { end = Integer.parseInt(endToken); } }catch (NumberFormatException e){ LOGGER.error("Incoming request's range header is improperly formatted: [range={}]", rangeHeaderValue, e); throw e; } } String contentRangeValue() { return "bytes " + start + "-" + end + "/" + responseMessage.length; } } } }