RestUtils.java

/*
 * Licensed to The Apereo Foundation under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 *
 * The Apereo Foundation licenses this file to you under the Educational
 * Community License, Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License. You may obtain a copy of the License
 * at:
 *
 *   http://opensource.org/licenses/ecl2.txt
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
 * License for the specific language governing permissions and limitations under
 * the License.
 *
 */

package org.opencastproject.index.service.util;

import static com.entwinemedia.fn.data.json.Jsons.arr;
import static com.entwinemedia.fn.data.json.Jsons.f;
import static com.entwinemedia.fn.data.json.Jsons.obj;
import static com.entwinemedia.fn.data.json.Jsons.v;
import static java.lang.String.format;

import org.opencastproject.util.DateTimeSupport;
import org.opencastproject.util.data.Tuple;
import org.opencastproject.util.requests.SortCriterion;

import com.entwinemedia.fn.Fx;
import com.entwinemedia.fn.data.json.Field;
import com.entwinemedia.fn.data.json.JValue;
import com.entwinemedia.fn.data.json.SimpleSerializer;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.StringTokenizer;

import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.StreamingOutput;

/**
 * Utils method for the Rest Endpoint implementation
 */
public final class RestUtils {
  private static final Logger logger = LoggerFactory.getLogger(RestUtils.class);

  private static final SimpleSerializer serializer = new SimpleSerializer();

  private RestUtils() {
  }

  /**
   * Create an OK (200) response with the given JSON as body
   *
   * @param json
   *          the JSON string to add to the response body.
   * @return an OK response
   */
  public static Response okJson(JValue json) {
    return Response.ok(stream(serializer.fn.toJson(json)), MediaType.APPLICATION_JSON_TYPE).build();
  }

  /**
   * Create an CONFLICT (409) response with the given JSON as body
   *
   * @param json
   *          the JSON string to add to the response body.
   * @return an OK response
   */
  public static Response conflictJson(JValue json) {
    return Response.status(Status.CONFLICT).entity(stream(serializer.fn.toJson(json)))
            .type(MediaType.APPLICATION_JSON_TYPE).build();
  }

  /**
   * Create a NOT FOUND (404) response with the given messages and arguments
   *
   * @param msg
   * @param args
   * @return a NOT FOUND response
   */
  public static Response notFound(String msg, Object... args) {
    return Response.status(Status.NOT_FOUND).entity(format(msg, args)).type(MediaType.TEXT_PLAIN_TYPE).build();
  }

  /**
   * Create a NOT FOUND (404) response with the given JSON as body
   *
   * @param json
   *          the JSON string to add to the response body.
   * @return a NOT FOUND response
   */
  public static Response notFoundJson(JValue json) {
    return Response.status(Status.NOT_FOUND).entity(stream(serializer.fn.toJson(json)))
        .type(MediaType.APPLICATION_JSON_TYPE).build();
  }

  /**
   * Create an INTERNAL SERVER ERROR (500) response with the given JSON as body
   *
   * @param json
   *          the JSON string to add to the response body.
   * @return an INTERNAL SERVER ERROR response
   */
  public static Response serverErrorJson(JValue json) {
    return Response.status(Status.INTERNAL_SERVER_ERROR).entity(stream(serializer.fn.toJson(json)))
        .type(MediaType.APPLICATION_JSON_TYPE).build();
  }

  /**
   * Return the given list of value with the standard format for JSON list value with offset, limit and total
   * information. See also
   * {@link org.opencastproject.index.service.util.RestUtils#okJsonList(List, Optional, Optional, long)}.
   *
   * @param jsonList
   *          The list of value to return
   * @param offset
   *          The result offset
   * @param limit
   *          The maximal list size
   * @param total
   *          The amount of available items in the system
   * @return a {@link Response} with an JSON object formatted like above as body.
   * @throws IllegalArgumentException
   *           if the value list is null
   */
  public static Response okJsonList(List<JValue> jsonList, int offset, int limit, long total) {
    return okJsonList(jsonList, Optional.of(offset), Optional.of(limit), total);
  }

  /**
   * Return the given list of value with the standard format for JSON list value with offset, limit and total
   * information. The JSON object in the response body has the following format:
   *
   * <pre>
   * {
   *  results: [
   *    // array containing all the object from the given list
   *  ],
   *  count: 12, // The number of item returned (size of the given list)
   *  offset: 2, // The result offset (given parameter)
   *  limit: 12, // The maximal size of the list (given parameter)
   *  total: 123 // The total number of items available in the system (given parameter)
   * }
   * </pre>
   *
   * Limit and offset are optional.
   *
   * @param jsonList
   *          The list of value to return
   * @param optOffset
   *          The result offset (optional)
   * @param optLimit
   *          The maximal list size (optional)
   * @param total
   *          The amount of available items in the system
   * @return a {@link Response} with an JSON object formatted like above as body.
   * @throws IllegalArgumentException
   *           if the value list is null
   */
  public static Response okJsonList(List<JValue> jsonList, Optional<Integer> optOffset, Optional<Integer> optLimit,
          long total) {
    if (jsonList == null)
      throw new IllegalArgumentException("The list of value must not be null.");

    ArrayList<Field> fields = new ArrayList<>();
    fields.add(f("results", arr(jsonList)));
    fields.add(f("count", v(jsonList.size())));
    fields.add(f("total", v(total)));

    if (optOffset.isPresent()) {
      fields.add(f("offset", v(optOffset.get())));
    }
    if (optLimit.isPresent()) {
      fields.add(f("limit", v(optLimit.get())));
    }

    return okJson(obj(fields));
  }

  /**
   * Create a streaming response entity. Pass it as an entity parameter to one of the response builder methods like
   * {@link org.opencastproject.util.RestUtil.R#ok(Object)}.
   */
  public static StreamingOutput stream(final Fx<OutputStream> out) {
    return s -> {
      try (BufferedOutputStream bs = new BufferedOutputStream(s)) {
        out.apply(bs);
      }
    };
  }

  /**
   * Parse a sort query parameter to a set of {@link SortCriterion}. The parameter has to be of the following form:
   * {@code <field name>:ASC|DESC}
   *
   * @param sort
   *          the parameter string to parse (will be checked if blank)
   * @return a set of sort criterion, never {@code null}
   */
  public static ArrayList<SortCriterion> parseSortQueryParameter(String sort) throws WebApplicationException {
    ArrayList<SortCriterion> sortOrders = new ArrayList<>();

    if (StringUtils.isNotBlank(sort)) {
      StringTokenizer tokenizer = new StringTokenizer(sort, ",");
      while (tokenizer.hasMoreTokens()) {
        try {
          sortOrders.add(SortCriterion.parse(tokenizer.nextToken()));
        } catch (IllegalArgumentException e) {
          throw new WebApplicationException(Status.BAD_REQUEST);
        }
      }
    }

    return sortOrders;
  }

  /**
   * Parse the UTC format date range string to two Date objects to represent a range of dates.
   * <p>
   * Sample UTC date range format string:<br>
   * i.e. yyyy-MM-ddTHH:mm:ssZ/yyyy-MM-ddTHH:mm:ssZ e.g. 2014-09-27T16:25Z/2014-09-27T17:55Z
   * </p>
   *
   * @param fromToDateRange
   *          The string that represents the UTC formed date range.
   * @return A Tuple with the two Dates
   * @throws IllegalArgumentException
   *           Thrown if the input string is malformed
   */
  public static Tuple<Date, Date> getFromAndToDateRange(String fromToDateRange) {
    String[] dates = fromToDateRange.split("/");
    if (dates.length != 2) {
      logger.warn("The date range '{}' is malformed", fromToDateRange);
      throw new IllegalArgumentException("The date range string is malformed");
    }

    Date fromDate = null;
    try {
      fromDate = new Date(DateTimeSupport.fromUTC(dates[0]));
    } catch (Exception e) {
      logger.warn("Unable to parse from date parameter '{}'", dates[0]);
      throw new IllegalArgumentException("Unable to parse from date parameter");
    }

    Date toDate = null;
    try {
      toDate = new Date(DateTimeSupport.fromUTC(dates[1]));
    } catch (Exception e) {
      logger.warn("Unable to parse to date parameter '{}'", dates[1]);
      throw new IllegalArgumentException("Unable to parse to date parameter");
    }

    return new Tuple<Date, Date>(fromDate, toDate);
  }

  /**
   * Parse the filter to a {@link Map}
   *
   * @param filter
   *          the filters
   * @return the map of filter name and values
   */
  public static Map<String, String> parseFilter(String filter) {
    Map<String, String> filters = new HashMap<>();
    if (StringUtils.isNotBlank(filter)) {
      for (String f : filter.split(",")) {
        String[] filterTuple = f.split(":");
        if (filterTuple.length < 2) {
          logger.debug("No value for filter '{}' in filters list: {}", filterTuple[0], filter);
          continue;
        }
        // use substring because dates also contain : so there might be more than two parts
        filters.put(filterTuple[0].trim(), URLDecoder.decode(f.substring(filterTuple[0].length() + 1).trim(),
            StandardCharsets.UTF_8));
      }
    }
    return filters;
  }

  public static String getJsonString(JValue json) throws WebApplicationException, IOException {
    OutputStream output = new OutputStream() {
      private StringBuilder string = new StringBuilder();

      @Override
      public void write(int b) throws IOException {
        this.string.append((char) b);
      }

      @Override
      public String toString() {
        return this.string.toString();
      }
    };

    stream(serializer.fn.toJson(json)).write(output);

    return output.toString();
  }

  /**
   * Get a {@link String} value from a {@link JValue} ignoring errors.
   *
   * @param json
   *          The {@link JValue} to convert to a {@link String}
   * @return The {@link String} representation of the {@link JValue} or an empty string if there was an error.
   */
  public static String getJsonStringSilent(JValue json) {
    try {
      return getJsonString(json);
    } catch (Exception e) {
      return "";
    }
  }
}