package org.yamcs.web.rest;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import org.yamcs.TimeInterval;
import org.yamcs.api.MediaType;
import org.yamcs.security.AuthenticationToken;
import org.yamcs.security.Privilege;
import org.yamcs.utils.TimeEncoding;
import org.yamcs.web.BadRequestException;
import org.yamcs.web.HttpException;
import org.yamcs.web.rest.Router.RouteMatch;
import com.fasterxml.jackson.core.JsonEncoding;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonGenerator;
import com.google.protobuf.MessageLite;
import io.netty.buffer.ByteBufInputStream;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpUtil;
import io.netty.handler.codec.http.QueryStringDecoder;
import io.netty.handler.ssl.SslHandler;
import io.netty.util.AsciiString;
import io.protostuff.JsonIOUtil;
import io.protostuff.Schema;
/**
* Encapsulates everything to do with one Rest Request. Object is gc-ed, when request ends.
*/
public class RestRequest {
public enum Option {
NO_LINK;
}
private ChannelHandlerContext channelHandlerContext;
private FullHttpRequest httpRequest;
private QueryStringDecoder qsDecoder;
private AuthenticationToken token;
private RouteMatch routeMatch;
private static JsonFactory jsonFactory = new JsonFactory();
CompletableFuture<Void> cf = new CompletableFuture<>();
static AtomicInteger counter = new AtomicInteger();
final int requestId;
long txSize = 0;
public RestRequest(ChannelHandlerContext channelHandlerContext, FullHttpRequest httpRequest, QueryStringDecoder qsDecoder, AuthenticationToken token) {
this.channelHandlerContext = channelHandlerContext;
this.httpRequest = httpRequest;
this.token = token;
this.qsDecoder = qsDecoder;
this.requestId = counter.incrementAndGet();
}
void setRouteMatch(RouteMatch routeMatch) {
this.routeMatch = routeMatch;
}
public boolean hasRouteParam(String name) {
try {
return routeMatch.regexMatch.group(name) != null;
} catch (IllegalArgumentException e) {
// Could likely be improved, we need this catch in case of multiple @Route annotations
// for the same method. Because then above call could throw an error if the requested
// group is not present in one of the patterns
return false;
}
}
public String getRouteParam(String name) {
return routeMatch.getRouteParam(name);
}
/**
*
* @return unique across running yamcs server rest request id used to aid in tracking the request executin in the log file
*
*/
public int getRequestId() {
return requestId;
}
public long getLongRouteParam(String name) throws BadRequestException {
String routeParam = routeMatch.regexMatch.group(name);
try {
return Long.parseLong(routeParam);
} catch (NumberFormatException e) {
throw new BadRequestException("Path segment ':" + name + "' is not a valid integer value");
}
}
public int getIntegerRouteParam(String name) throws BadRequestException {
String routeParam = routeMatch.regexMatch.group(name);
try {
return Integer.parseInt(routeParam);
} catch (NumberFormatException e) {
throw new BadRequestException("Path segment ':" + name + "' is not a valid integer value");
}
}
public long getDateRouteParam(String name) throws BadRequestException {
String routeParam = routeMatch.regexMatch.group(name);
try {
return Long.parseLong(routeParam);
} catch (NumberFormatException e) {
try {
return TimeEncoding.parse(routeParam);
} catch (IllegalArgumentException e2) {
throw new BadRequestException("Path segment ':" + name + "' is not a valid ISO 8601 date string");
}
}
}
public String getFullPathWithoutQueryString() {
return qsDecoder.path();
}
public boolean hasHeader(String name) {
return httpRequest.headers().contains(name);
}
public String getHeader(AsciiString name) {
return httpRequest.headers().get(name);
}
/**
* Experimental feature to gather common parameters. Should also be made to include the pretty-flag
*/
public Set<Option> getOptions() {
Set<Option> options = new HashSet<>(2);
if (getQueryParameterAsBoolean("nolink", false)) {
options.add(Option.NO_LINK);
}
return options;
}
/**
* Matches the content type on either the Accept header or a 'format' query param.
* Should probably better be integrated with the deriveTargetContentType setting.
*/
public boolean asksFor(MediaType mediaType) {
if (hasQueryParameter("format")) {
switch (getQueryParameter("format").toLowerCase()) {
case "json":
return MediaType.JSON.equals(mediaType);
case "csv":
return MediaType.CSV.equals(mediaType);
case "proto":
return MediaType.PROTOBUF.equals(mediaType);
case "raw":
case "binary":
return MediaType.OCTET_STREAM.equals(mediaType);
default:
return mediaType.is(getQueryParameter("format"));
}
} else {
return getHttpRequest().headers().contains(HttpHeaderNames.ACCEPT)
&& mediaType.is(getHttpRequest().headers().get(HttpHeaderNames.ACCEPT));
}
}
/**
* Returns the username of the authenticated user. Or {@link Privilege#getDefaultUser()} if the user
* is not authenticated.
*/
public String getUsername() {
return Privilege.getInstance().getUsername(token);
}
public AuthenticationToken getAuthToken() {
return token;
}
public boolean hasQueryParameter(String name) {
return qsDecoder.parameters().containsKey(name);
}
public Map<String, List<String>> getQueryParameters() {
return qsDecoder.parameters();
}
public List<String> getQueryParameterList(String name) {
return qsDecoder.parameters().get(name);
}
public List<String> getQueryParameterList(String name, List<String> defaultList) {
if (hasQueryParameter(name)) {
return getQueryParameterList(name);
} else {
return defaultList;
}
}
public String getQueryParameter(String name) {
List<String> param = qsDecoder.parameters().get(name);
if (param==null || param.isEmpty()) return null;
return param.get(0);
}
public String getQueryParameter(String name, String defaultValue) throws BadRequestException {
if (hasQueryParameter(name)) {
return getQueryParameter(name);
} else {
return defaultValue;
}
}
public int getQueryParameterAsInt(String name) throws BadRequestException {
String param = getQueryParameter(name);
try {
return Integer.parseInt(param);
} catch (NumberFormatException e) {
throw new BadRequestException("Query parameter '" + name + "' does not have a valid integer value");
}
}
public int getQueryParameterAsInt(String name, int defaultValue) throws BadRequestException {
if (hasQueryParameter(name)) {
return getQueryParameterAsInt(name);
} else {
return defaultValue;
}
}
public long getQueryParameterAsLong(String name) throws BadRequestException {
String param = getQueryParameter(name);
try {
return Long.parseLong(param);
} catch (NumberFormatException e) {
throw new BadRequestException("Query parameter '" + name + "' does not have a valid integer value");
}
}
public long getQueryParameterAsLong(String name, long defaultValue) throws BadRequestException {
if (hasQueryParameter(name)) {
return getQueryParameterAsLong(name);
} else {
return defaultValue;
}
}
public long getQueryParameterAsDate(String name) throws BadRequestException {
String param = getQueryParameter(name);
try {
return Long.parseLong(param);
} catch (NumberFormatException e) {
return TimeEncoding.parse(param);
}
}
public long getQueryParameterAsDate(String name, long defaultValue) throws BadRequestException {
if (hasQueryParameter(name)) {
return getQueryParameterAsDate(name);
} else {
return defaultValue;
}
}
public boolean getQueryParameterAsBoolean(String name) {
List<String> paramList = getQueryParameterList(name);
String param = paramList.get(0);
return (param == null || "".equals(param) || "true".equalsIgnoreCase(param)
|| "yes".equalsIgnoreCase(param));
}
public boolean getQueryParameterAsBoolean(String name, boolean defaultValue) {
if (hasQueryParameter(name)) {
return getQueryParameterAsBoolean(name);
} else {
return defaultValue;
}
}
public boolean isSSL() {
return channelHandlerContext.pipeline().get(SslHandler.class) != null;
}
public ChannelHandlerContext getChannelHandlerContext() {
return channelHandlerContext;
}
public HttpRequest getHttpRequest() {
return httpRequest;
}
/**
* Returns a new Json Generator that will have pretty-printing enabled if the original request specified this.
*/
public JsonGenerator createJsonGenerator(OutputStream out) throws IOException {
JsonGenerator generator = jsonFactory.createGenerator(out, JsonEncoding.UTF8);
if (qsDecoder.parameters().containsKey("pretty")) {
if (hasQueryParameter("pretty") && getQueryParameterAsBoolean("pretty")) {
generator.useDefaultPrettyPrinter();
}
} else {
// Pretty by default
generator.useDefaultPrettyPrinter();
}
return generator;
}
public boolean hasBody() {
return HttpUtil.getContentLength(httpRequest) > 0;
}
/**
* Deserializes the incoming message extracted from the body. This does not
* care about what the HTTP method is. Any required checks should be done
* elsewhere.
* <p>
* This method is only able to read JSON or Protobuf, the two auto-supported
* serialization mechanisms. If a certain operation needs to read anything
* else, it should check for that itself, and then use
* {@link #bodyAsInputStream()}.
*/
public <T extends MessageLite.Builder> T bodyAsMessage(Schema<T> sourceSchema) throws BadRequestException {
MediaType sourceContentType = deriveSourceContentType();
InputStream cin = bodyAsInputStream();
T msg = sourceSchema.newMessage();
// Allow for empty body, otherwise user has to specify '{}'
if (HttpUtil.getContentLength(httpRequest) > 0) {
try {
if (MediaType.PROTOBUF.equals(sourceContentType)) {
msg.mergeFrom(cin);
} else {
JsonIOUtil.mergeFrom(cin, msg, sourceSchema, false);
}
} catch(IOException|NullPointerException e) {
throw new BadRequestException(e);
} finally {
// GPB's mergeFrom does not close the stream, not sure about JsonIOUtil
try { cin.close(); } catch (IOException e) {}
}
}
return msg;
}
public InputStream bodyAsInputStream() {
return new ByteBufInputStream(httpRequest.content());
}
/**
* @return see {@link MediaType#getContentType(HttpRequest)}
*/
public MediaType deriveSourceContentType() {
return MediaType.getContentType(httpRequest);
}
/**
* Derives an applicable content type for the output. This tries to match
* JSON or BINARY media types with the ACCEPT header, else it will revert to
* the (derived) source content type.
*
* @return the content type that will be used for the response message
*/
public MediaType deriveTargetContentType() {
return deriveTargetContentType(httpRequest);
}
public static MediaType deriveTargetContentType(HttpRequest httpRequest) {
MediaType mt = MediaType.JSON;
if (httpRequest.headers().contains(HttpHeaderNames.ACCEPT)) {
String acceptedContentType = httpRequest.headers().get(HttpHeaderNames.ACCEPT);
mt = MediaType.from(acceptedContentType);
} else if (httpRequest.headers().contains(HttpHeaderNames.CONTENT_TYPE)) {
String declaredContentType = httpRequest.headers().get(HttpHeaderNames.CONTENT_TYPE);
mt = MediaType.from(declaredContentType);
}
//we only support one of these two for the output, so just force JSON by default
if(mt!=MediaType.JSON && mt!=MediaType.PROTOBUF) {
mt = MediaType.JSON;
}
return mt;
}
public String getBaseURL() {
String scheme = isSSL() ? "https://" : "http://";
String host = getHeader(HttpHeaderNames.HOST);
return (host != null) ? scheme + host : "";
}
/**
*
* When the request is finished, the CompleteableFuture has to be used to signal the end.
*
*
* @return future to be used to signal the end of processing the request
*
*/
public CompletableFuture<Void> getCompletableFuture() {
return cf;
}
/**
* Get the number of bytes transferred as the result of the REST call.
* It should not include the http headers.
* Note that the number might be increased before the data is sent so it will be wrong if there was an error sending data.
*
*
* @return number of bytes transferred as part of the request
*/
public long getTransferredSize() {
return txSize;
}
/**
* add numBytes to the transferred size
*
* @param numBytes
*/
public void addTransferredSize(long numBytes) {
txSize+=numBytes;
}
/**
* Returns true if the request specifies descending by use of the query string paramter 'order=desc'
*/
public boolean asksDescending(boolean descendByDefault) throws HttpException {
if (hasQueryParameter("order")) {
switch (getQueryParameter("order").toLowerCase()) {
case "asc":
case "ascending":
return false;
case "desc":
case "descending":
return true;
default:
throw new BadRequestException("Unsupported value for order parameter. Expected 'asc' or 'desc'");
}
} else {
return descendByDefault;
}
}
/**
* Interprets the provided string as either an instant, or an ISO 8601
* string and returns it as an instant of type long
*/
public static long parseTime(String datetime) {
try {
return Long.parseLong(datetime);
} catch (NumberFormatException e) {
return TimeEncoding.parse(datetime);
}
}
public IntervalResult scanForInterval() throws HttpException {
return new IntervalResult(this);
}
public String getApiURL() {
return getBaseURL() + "/api";
}
public static class IntervalResult {
private final long start;
private final long stop;
IntervalResult(RestRequest req) throws BadRequestException {
start = req.getQueryParameterAsDate("start", TimeEncoding.INVALID_INSTANT);
stop = req.getQueryParameterAsDate("stop", TimeEncoding.INVALID_INSTANT);
}
public boolean hasInterval() {
return start != TimeEncoding.INVALID_INSTANT || stop != TimeEncoding.INVALID_INSTANT;
}
public boolean hasStart() {
return start != TimeEncoding.INVALID_INSTANT;
}
public boolean hasStop() {
return stop != TimeEncoding.INVALID_INSTANT;
}
public long getStart() {
return start;
}
public long getStop() {
return stop;
}
public TimeInterval asTimeInterval() {
TimeInterval intv = new TimeInterval();
if (hasStart()) intv.setStart(start);
if (hasStop()) intv.setStop(stop);
return intv;
}
public String asSqlCondition(String col) {
StringBuilder buf = new StringBuilder();
if (start != TimeEncoding.INVALID_INSTANT) {
buf.append(col).append(" >= ").append(start);
if (stop != TimeEncoding.INVALID_INSTANT) {
buf.append(" and ").append(col).append(" < ").append(stop);
}
} else {
buf.append(col).append(" < ").append(stop);
}
return buf.toString();
}
}
}