org.n52.io.request.IoParameters.java Source code

Java tutorial

Introduction

Here is the source code for org.n52.io.request.IoParameters.java

Source

/*
 * Copyright (C) 2013-2016 52North Initiative for Geospatial Open Source
 * Software GmbH
 *
 * This program is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 as published
 * by the Free Software Foundation.
 *
 * If the program is linked with libraries which are licensed under one of
 * the following licenses, the combination of the program with the linked
 * library is not considered a "derivative work" of the program:
 *
 *     - Apache License, version 2.0
 *     - Apache Software License, version 1.0
 *     - GNU Lesser General Public License, version 3
 *     - Mozilla Public License, versions 1.0, 1.1 and 2.0
 *     - Common Development and Distribution License (CDDL), version 1.0
 *
 * Therefore the distribution of the program linked with libraries licensed
 * under the aforementioned licenses, is permitted by the copyright holders
 * if the distribution is compliant with both the GNU General Public License
 * version 2 and the aforementioned licenses.
 *
 * 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 General Public License
 * for more details.
 */
package org.n52.io.request;

import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.TypeFactory;
import com.vividsolutions.jts.geom.Point;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import org.joda.time.DateTime;
import org.joda.time.Instant;
import org.n52.io.IntervalWithTimeZone;
import org.n52.io.IoParseException;
import org.n52.io.crs.BoundingBox;
import org.n52.io.crs.CRSUtils;
import static org.n52.io.crs.CRSUtils.DEFAULT_CRS;
import static org.n52.io.crs.CRSUtils.createEpsgForcedXYAxisOrder;
import static org.n52.io.crs.CRSUtils.createEpsgStrictAxisOrder;
import org.n52.io.geojson.old.GeojsonPoint;
import org.n52.io.measurement.img.ChartDimension;
import org.n52.io.response.BBox;
import org.n52.io.style.LineStyle;
import org.n52.io.style.Style;
import org.opengis.referencing.FactoryException;
import org.opengis.referencing.operation.TransformException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

public class IoParameters implements Parameters {

    private final static Logger LOGGER = LoggerFactory.getLogger(IoParameters.class);

    private final static String DEFAULT_CONFIG_FILE = "config-general.json";

    private static final ObjectMapper om = new ObjectMapper(); // TODO use global object mapper

    private final MultiValueMap<String, JsonNode> query;

    private static InputStream getDefaultConfigFile() {
        try {
            Path path = Paths.get(IoParameters.class.getResource("/").toURI());
            File config = path.resolve(DEFAULT_CONFIG_FILE).toFile();
            return config.exists() ? new FileInputStream(config)
                    : IoParameters.class.getClassLoader().getResourceAsStream("/" + DEFAULT_CONFIG_FILE);
        } catch (URISyntaxException | IOException e) {
            LOGGER.debug("Could not find default config under '{}'", DEFAULT_CONFIG_FILE, e);
            return null;
        }
    }

    protected IoParameters(IoParameters parameters) {
        this((File) null);
        if (parameters != null) {
            query.putAll(parameters.query);
        }
    }

    protected IoParameters(MultiValueMap<String, JsonNode> queryParameters) {
        this(queryParameters, (File) null);
    }

    protected IoParameters(MultiValueMap<String, JsonNode> queryParameters, File defaults) {
        this(defaults);
        if (queryParameters != null) {
            query.putAll(queryParameters);
        }
    }

    protected IoParameters(Map<String, JsonNode> queryParameters) {
        this(queryParameters, (File) null);
    }

    protected IoParameters(Map<String, JsonNode> queryParameters, File defaults) {
        this(defaults);
        query.setAll(queryParameters);
    }

    private IoParameters(File defaultConfig) {
        query = new LinkedMultiValueMap<>();
        query.setAll(readDefaultConfig(defaultConfig));
    }

    private Map<String, JsonNode> readDefaultConfig(File config) {
        try (InputStream stream = config == null ? getDefaultConfigFile() : new FileInputStream(config)) {
            return om.readValue(stream, TypeFactory.defaultInstance().constructMapLikeType(HashMap.class,
                    String.class, JsonNode.class));
        } catch (IOException e) {
            LOGGER.info("Could not load '{}'. Using empty config.", DEFAULT_CONFIG_FILE, e);
            return new HashMap<>();
        }
    }

    /**
     * @return the value of {@value #OFFSET} parameter. If not present, the
     * default {@value #DEFAULT_OFFSET} is returned.
     * @throws IoParseException if parameter could not be parsed.
     */
    public int getOffset() {
        return getAsInteger(OFFSET, DEFAULT_OFFSET);
    }

    /**
     * @return the value of {@value #LIMIT} parameter. If not present, the
     * default {@value #DEFAULT_LIMIT} is returned.
     * @throws IoParseException if parameter could not be parsed.
     */
    public int getLimit() {
        return getAsInteger(LIMIT, DEFAULT_LIMIT);
    }

    /**
     * @return the chart dimensions. If {@value #WIDTH} and {@value #HEIGHT}
     * parameters are missing the defaults are used:
     * <code>width=</code>{@value #DEFAULT_WIDTH}, <code>height=</code>
     * {@value #DEFAULT_HEIGHT}
     * @throws IoParseException if parsing parameter fails.
     */
    public ChartDimension getChartDimension() {
        return new ChartDimension(getWidth(), getHeight());
    }

    /**
     * @return the requested chart width in pixels or the default
     * {@value #DEFAULT_WIDTH}.
     * @throws IoParseException if parsing parameter fails.
     */
    private int getWidth() {
        return getAsInteger(WIDTH, DEFAULT_WIDTH);
    }

    /**
     * Returns the requested chart height in pixels.
     *
     * @return the requested chart height in pixels or the default
     * {@value #DEFAULT_HEIGHT}.
     * @throws IoParseException if parsing parameter fails.
     */
    private int getHeight() {
        return getAsInteger(HEIGHT, DEFAULT_HEIGHT);
    }

    /**
     * Indicates if rendered chart shall be returned as Base64 encoded string.
     *
     * @return the value of parameter {@value #BASE_64} or the default
     * {@value #DEFAULT_BASE_64}.
     * @throws IoParseException if parsing parameter fails.
     */
    public boolean isBase64() {
        return getAsBoolean(BASE_64, DEFAULT_BASE_64);
    }

    /**
     * @return <code>true</code> if timeseries chart shall include a background
     * grid.
     * @throws IoParseException if parsing parameter fails.
     */
    public boolean isGrid() {
        return getAsBoolean(GRID, DEFAULT_GRID);
    }

    /**
     * @return <code>true</code> if timeseries data shall be generalized.
     * @throws IoParseException if parsing parameter fails.
     */
    public boolean isGeneralize() throws IoParseException {
        return getAsBoolean(GENERALIZE, DEFAULT_GENERALIZE);
    }

    /**
     * @return <code>true</code> if a legend shall be included when rendering a
     * chart, <code>false</code> otherwise.
     * @throws IoParseException if parsing parameter fails.
     */
    public boolean isLegend() {
        return getAsBoolean(LEGEND, DEFAULT_LEGEND);
    }

    /**
     * @return the value of {@value #LOCALE} parameter. If not present, the
     * default {@value #DEFAULT_LOCALE} is returned.
     */
    public String getLocale() {
        return getAsString(LOCALE, DEFAULT_LOCALE);
    }

    /**
     * @return the value of {@value #STYLE} parameter. If not present, the
     * default styles are returned.
     * @throws IoParseException if parsing style parameter failed.
     */
    public StyleProperties getStyle() {
        return containsParameter(STYLE) ? parseStyleProperties(getAsString(STYLE))
                : StyleProperties.createDefaults();
    }

    /**
     * Creates a generic {@link StyleProperties} instance which can be used to
     * create more concrete {@link Style}s. For example use
     * {@link LineStyle#createLineStyle(StyleProperties)} which gives you a
     * style view which can be used for lines.
     *
     * @param style the JSON style parameter to parse.
     * @return a parsed {@link StyleProperties} instance.
     * @throws IoParseException if parsing parameter fails.
     */
    private StyleProperties parseStyleProperties(String style) {
        try {
            return style == null ? StyleProperties.createDefaults()
                    : new ObjectMapper().readValue(style, StyleProperties.class);
        } catch (JsonMappingException e) {
            throw new IoParseException("Could not read style properties: " + style, e);
        } catch (JsonParseException e) {
            throw new IoParseException("Could not parse style properties: " + style, e);
        } catch (IOException e) {
            throw new IllegalArgumentException("An error occured during request handling.", e);
        }

    }

    public String getFormat() {
        return getAsString(FORMAT, DEFAULT_FORMAT);
    }

    public boolean isSetRawFormat() {
        return containsParameter(RAW_FORMAT);
    }

    public String getRawFormat() {
        if (isSetRawFormat()) {
            final JsonNode value = query.getFirst(RAW_FORMAT);
            return value != null ? value.asText() : null;
        }
        return null;
    }

    /**
     * @return the value of {@value #TIMESPAN} parameter. If not present, the
     * default timespan is returned.
     * @throws IoParseException if timespan could not be parsed.
     */
    public IntervalWithTimeZone getTimespan() {
        return containsParameter(TIMESPAN) ? validateTimespan(getAsString(TIMESPAN)) : createDefaultTimespan();
    }

    private IntervalWithTimeZone createDefaultTimespan() {
        DateTime now = new DateTime();
        DateTime lastWeek = now.minusWeeks(1);
        String interval = lastWeek.toString().concat("/").concat(now.toString());
        return new IntervalWithTimeZone(interval);
    }

    private IntervalWithTimeZone validateTimespan(String timespan) {
        return new IntervalWithTimeZone(timespan);
    }

    public Instant getResultTime() {
        if (!containsParameter(RESULTTIME)) {
            return null;
        }
        return validateTimestamp(getAsString(RESULTTIME));
    }

    private Instant validateTimestamp(String timestamp) {
        try {
            return Instant.parse(timestamp);
        } catch (Exception e) {
            String message = "Could not parse result time parameter." + timestamp;
            throw new IoParseException(message, e);
        }
    }

    /**
     * @return the category filter
     * @deprecated use {@link #getCategories()}
     */
    @Deprecated
    public String getCategory() {
        return getAsString(CATEGORY);
    }

    /**
     * @return the service filter
     * @deprecated use {@link #getServices()}
     */
    @Deprecated
    public String getService() {
        return getAsString(SERVICE);
    }

    /**
     * @return the offering filter
     * @deprecated use {@link #getOfferings()}
     */
    @Deprecated
    public String getOffering() {
        return getAsString(OFFERING);
    }

    /**
     * @return the feature filter
     * @deprecated use {@link #getFeatures()}
     */
    @Deprecated
    public String getFeature() {
        return getAsString(FEATURE);
    }

    /**
     * @return the procedure filter
     * @deprecated use {@link #getProcedures()}
     */
    @Deprecated
    public String getProcedure() {
        return getAsString(PROCEDURE);
    }

    /**
     * @return the phenomenon filter
     * @deprecated use {@link #getPhenomena()}
     */
    @Deprecated
    public String getPhenomenon() {
        return getAsString(PHENOMENON);
    }

    @Deprecated
    public String getStation() {
        return getAsString(STATION);
    }

    public Set<String> getCategories() {
        return getValuesOf(CATEGORIES);
    }

    public Set<String> getServices() {
        return getValuesOf(SERVICES);
    }

    public Set<String> getOfferings() {
        return getValuesOf(OFFERINGS);
    }

    public Set<String> getFeatures() {
        return getValuesOf(FEATURES);
    }

    public Set<String> getProcedures() {
        return getValuesOf(PROCEDURES);
    }

    public Set<String> getPhenomena() {
        return getValuesOf(PHENOMENA);
    }

    public Set<String> getPlatforms() {
        return getValuesOf(PLATFORMS);
    }

    public Set<String> getSeries() {
        return getValuesOf(SERIES);
    }

    public Set<String> getDatasets() {
        return getValuesOf(DATASETS);
    }

    public Set<String> getFields() {
        return getValuesOf(FILTER_FIELDS);
    }

    public Set<String> getPlatformTypes() {
        return getValuesOf(FILTER_PLATFORM_TYPES);
    }

    public Set<String> getPlatformGeometryTypes() {
        return getValuesOf(FILTER_PLATFORM_GEOMETRIES);
    }

    public Set<String> getObservedGeometryTypes() {
        return getValuesOf(FILTER_OBSERVED_GEOMETRIES);
    }

    public Set<String> getDatasetTypes() {
        return getValuesOf(FILTER_DATASET_TYPES);
    }

    public Set<String> getSearchTerms() {
        return getValuesOf(SEARCH_TERM);
    }

    public Set<String> getGeometryTypes() {
        return getValuesOf(GEOMETRY_TYPES);
    }

    Set<String> getValuesOf(String parameterName) {
        return containsParameter(parameterName) ? new HashSet<>(csvToLowerCasedSet(getAsString(parameterName)))
                : Collections.<String>emptySet();
    }

    private Set<String> csvToLowerCasedSet(String csv) {
        String[] values = csv.split(",");
        for (int i = 0; i < values.length; i++) {
            values[i] = values[i].toLowerCase();
        }
        return new HashSet<>(Arrays.asList(values));
    }

    public FilterResolver getFilterResolver() {
        return new FilterResolver(this);
    }

    /**
     * Creates a {@link BoundingBox} instance from given spatial request
     * parameters. The resulting bounding box is the merged extent of all
     * spatial filters given. For example if {@value #NEAR} and {@value #BBOX}
     * exist, the returned bounding box includes both extents.
     *
     * @return a spatial filter created from given spatial parameters.
     * @throws IoParseException if parsing parameters fails, or if a requested
     * {@value #CRS} object could not be created.
     */
    public BoundingBox getSpatialFilter() {
        if (!containsParameter(NEAR) && !containsParameter(BBOX)) {
            return null;
        }

        BBox bboxBounds = createBbox();
        BoundingBox bounds = parseBoundsFromVicinity();
        return mergeBounds(bounds, bboxBounds);
    }

    private BoundingBox mergeBounds(BoundingBox bounds, BBox bboxBounds) {
        if (bboxBounds == null) {
            // nothing to merge
            return bounds;
        }
        CRSUtils crsUtils = createEpsgForcedXYAxisOrder();
        Point lowerLeft = crsUtils.convertToPointFrom(bboxBounds.getLl());
        Point upperRight = crsUtils.convertToPointFrom(bboxBounds.getUr());
        if (bounds == null) {
            bounds = new BoundingBox(lowerLeft, upperRight, DEFAULT_CRS);
            LOGGER.debug("Parsed bbox bounds: {}", bounds.toString());
        } else {
            extendBy(lowerLeft, bounds);
            extendBy(upperRight, bounds);
            LOGGER.debug("Merged bounds: {}", bounds.toString());
        }
        return bounds;
    }

    /**
     * Extends the bounding box with the given point. If point is contained by
     * this instance nothing is changed.
     *
     * @param point the point in CRS:84 which shall extend the bounding box.
     */
    private void extendBy(Point point, BoundingBox bbox) {
        if (bbox.contains(point)) {
            return;
        }
        double llX = Math.min(point.getX(), bbox.getLowerLeft().getX());
        double llY = Math.max(point.getX(), bbox.getUpperRight().getX());
        double urX = Math.min(point.getY(), bbox.getLowerLeft().getY());
        double urY = Math.max(point.getY(), bbox.getUpperRight().getY());

        CRSUtils crsUtils = createEpsgForcedXYAxisOrder();
        bbox.setLl(crsUtils.createPoint(llX, llY, bbox.getSrs()));
        bbox.setUr(crsUtils.createPoint(urX, urY, bbox.getSrs()));
    }

    /**
     * @return a {@link BBox} instance or <code>null</code> if no {@link #BBOX}
     * parameter is present.
     * @throws IoParseException if parsing parameter fails.
     * @throws IoParseException if a requested {@value #CRS} object could not be
     * created
     */
    private BBox createBbox() {
        if (!containsParameter(BBOX)) {
            return null;
        }
        String bboxValue = getAsString(BBOX);
        BBox bbox = parseJson(bboxValue, BBox.class);
        bbox.setLl(convertToCrs84(bbox.getLl()));
        bbox.setUr(convertToCrs84(bbox.getUr()));
        return bbox;
    }

    private BoundingBox parseBoundsFromVicinity() {
        if (!containsParameter(NEAR)) {
            return null;
        }
        String vicinityValue = getAsString(NEAR);
        Vicinity vicinity = parseJson(vicinityValue, Vicinity.class);
        if (containsParameter(CRS)) {
            vicinity.setCenter(convertToCrs84(vicinity.getCenter()));
        }
        BoundingBox bounds = vicinity.calculateBounds();
        LOGGER.debug("Parsed vicinity bounds: {}", bounds.toString());
        return bounds;
    }

    /**
     * @param jsonString the JSON string to parse.
     * @param clazz the type to serialize given JSON string to.
     * @return a mapped instance parsed from JSON.
     * @throws IoParseException if JSON is invalid or does not map to given
     * type.
     */
    private <T> T parseJson(String jsonString, Class<T> clazz) {
        try {
            ObjectMapper mapper = new ObjectMapper();
            return mapper.readValue(jsonString, clazz);
        } catch (JsonParseException e) {
            throw new IoParseException("The given parameter is invalid JSON." + jsonString, e);
        } catch (JsonMappingException e) {
            throw new IoParseException("The given parameter could not been read: " + jsonString, e);
        } catch (IOException e) {
            throw new RuntimeException("Could not handle input to parse.", e);
        }
    }

    private GeojsonPoint convertToCrs84(GeojsonPoint point) {
        return isForceXY() // is strict XY axis order?!
                ? transformToInnerCrs(point, createEpsgForcedXYAxisOrder())
                : transformToInnerCrs(point, createEpsgStrictAxisOrder());
    }

    /**
     * @param point a GeoJSON point to be transformed to internally used CRS:84.
     * @param crsUtils a reference helper.
     * @return a transformed GeoJSON instance.
     * @throws IoParseException if point could not be transformed, or if
     * requested CRS object could not be created.
     */
    private GeojsonPoint transformToInnerCrs(GeojsonPoint point, CRSUtils crsUtils) {
        try {
            Point toTransformed = crsUtils.convertToPointFrom(point, getCrs());
            Point crs84Point = (Point) crsUtils.transformOuterToInner(toTransformed, getCrs());
            return crsUtils.convertToGeojsonFrom(crs84Point);
        } catch (TransformException e) {
            throw new IoParseException("Could not transform to internally used CRS:84.", e);
        } catch (FactoryException e) {
            throw new IoParseException("Check if 'crs' parameter is a valid EPSG CRS. Was: '" + getCrs() + "'.", e);
        }
    }

    /**
     * @return the requested reference context, or the default
     * ({@value CRSUtils#DEFAULT_CRS}) which will be interpreted as lon/lat
     * ordered axes).
     */
    public String getCrs() {
        return getAsString(CRS, DEFAULT_CRS);
    }

    public boolean isForceXY() {
        return getAsBoolean(FORCE_XY, DEFAULT_FORCE_XY);
    }

    public boolean isMatchDomainIds() {
        return getAsBoolean(MATCH_DOMAIN_IDS, DEFAULT_MATCH_DOMAIN_IDS);
    }

    /**
     * @return the value of {@value #EXPANDED} parameter.
     * @throws IoParseException if parameter could not be parsed.
     */
    public boolean isExpanded() {
        return getAsBoolean(EXPANDED, DEFAULT_EXPANDED);
    }

    public boolean isForceLatestValueRequests() {
        return getAsBoolean(FORCE_LATEST_VALUE, DEFAULT_FORCE_LATEST_VALUE);
    }

    public boolean isStatusIntervalsRequests() {
        return getAsBoolean(STATUS_INTERVALS, DEFAULT_STATUS_INTERVALS);
    }

    public boolean isRenderingHintsRequests() {
        return getAsBoolean(RENDERING_HINTS, DEFAULT_RENDERING_HINTS);
    }

    public String getHrefBase() {
        return getAsString(Parameters.HREF_BASE);
    }

    public boolean isShowTimeIntervals() {
        return getAsBoolean(SHOW_TIME_INTERVALS, DEFAULT_SHOW_TIME_INTERVALS);
    }

    public boolean containsParameter(String parameter) {
        return query.containsKey(parameter.toLowerCase());
    }

    public String getOther(String parameter) {
        return getAsString(parameter);
    }

    public String getAsString(String parameter, String defaultValue) {
        return containsParameter(parameter) ? getAsString(parameter) : defaultValue;
    }

    public String getAsString(String parameter) {
        return containsParameter(parameter) ? asCsv(query.get(parameter.toLowerCase())) : null;
    }

    private String asCsv(List<JsonNode> list) {
        StringBuilder sb = new StringBuilder();
        for (JsonNode jsonNode : list) {
            if (sb.length() != 0) {
                sb.append(",");
            }
            sb.append(jsonNode.asText());
        }
        return sb.toString();
    }

    public int getAsInteger(String parameter, int defaultValue) {
        return containsParameter(parameter) ? getAsInteger(parameter) : defaultValue;
    }

    /**
     * @param parameter
     *        the parameter to parse to an <code>int</code> value.
     * @return an integer value.
     * @throws IoParseException
     *         if parsing to <code>int</code> fails.
     */
    public int getAsInteger(String parameter) {
        try {
            String value = getAsString(parameter);
            Integer.parseInt(value);
            return query.getFirst(parameter.toLowerCase()).asInt();
        } catch (NumberFormatException e) {
            throw new IoParseException("Parameter '" + parameter + "' has to be an integer!", e);
        }
    }

    public boolean getAsBoolean(String parameter, boolean defaultValue) {
        return containsParameter(parameter) ? getAsBoolean(parameter) : defaultValue;
    }

    /**
     * @param parameter the parameter to parse to <code>boolean</code>.
     * @return <code>true</code> or <code>false</code> as <code>boolean</code>.
     * @throws IoParseException if parsing to <code>boolean</code> fails.
     */
    public boolean getAsBoolean(String parameter) {
        try {
            String value = getAsString(parameter);
            Boolean.parseBoolean(value);
            return query.getFirst(parameter.toLowerCase()).asBoolean();
        } catch (NumberFormatException e) {
            throw new IoParseException("Parameter '" + parameter + "' has to be 'false' or 'true'!", e);
        }
    }

    public RequestSimpleParameterSet toSimpleParameterSet() {
        RequestSimpleParameterSet parameterSet = new RequestSimpleParameterSet();
        addValuesToParameterSet(parameterSet);
        return parameterSet;
    }

    public RequestStyledParameterSet toRequestStyledParameterSet() {
        RequestStyledParameterSet parameterSet = new RequestStyledParameterSet();
        addValuesToParameterSet(parameterSet);
        return parameterSet;
    }

    private RequestParameterSet addValuesToParameterSet(RequestParameterSet parameterSet) {
        // TODO check value object
        // TODO keep multi value map
        for (Entry<String, List<JsonNode>> entry : query.entrySet()) {
            List<JsonNode> values = entry.getValue();
            String lowercasedKey = entry.getKey().toLowerCase();
            if (values.size() == 1) {
                parameterSet.addParameter(lowercasedKey, values.get(0));
            } else {
                parameterSet.addParameter(lowercasedKey, getJsonNodeFrom(values));
            }
        }
        return parameterSet;
    }

    public static JsonNode getJsonNodeFrom(Object object) {
        if (object == null) {
            return null;
        }
        try {
            return om.readTree(om.writeValueAsString(object));
        } catch (IOException e) {
            LOGGER.error("Could not parse parameter", e);
            return null;
        }
    }

    public IoParameters removeAllOf(String key) {
        MultiValueMap<String, JsonNode> newValues = new LinkedMultiValueMap<>(query);
        newValues.remove(key.toLowerCase());
        return new IoParameters(newValues);
    }

    public IoParameters extendWith(String key, String... values) {
        MultiValueMap<String, String> newValues = new LinkedMultiValueMap<>();
        newValues.put(key.toLowerCase(), Arrays.asList(values));

        MultiValueMap<String, JsonNode> mergedValues = new LinkedMultiValueMap<>(query);
        mergedValues.putAll(convertValuesToJsonNodes(newValues));
        return new IoParameters(mergedValues);
    }

    protected static Map<String, JsonNode> convertValuesToJsonNodes(Map<String, String> queryParameters) {
        Map<String, JsonNode> parameters = new HashMap<>();
        for (Entry<String, String> entry : queryParameters.entrySet()) {
            String key = entry.getKey().toLowerCase();
            parameters.put(key, getJsonNodeFrom(entry.getValue()));
        }
        return parameters;
    }

    protected static MultiValueMap<String, JsonNode> convertValuesToJsonNodes(
            MultiValueMap<String, String> queryParameters) {
        MultiValueMap<String, JsonNode> parameters = new LinkedMultiValueMap<>();
        final Set<Entry<String, List<String>>> entrySet = queryParameters.entrySet();
        for (Entry<String, List<String>> entry : entrySet) {
            for (String value : entry.getValue()) {
                final String key = entry.getKey().toLowerCase();
                parameters.add(key, getJsonNodeFrom(value));
            }
        }
        return parameters;
    }

    @Override
    public String toString() {
        return "IoParameters{" + "query=" + query + '}';
    }

    /* ****************************************************************
     *                    FACTORY METHODS
     * ************************************************************** */

    public static IoParameters createDefaults() {
        return createDefaults(null);
    }

    static IoParameters createDefaults(File defaultConfig) {
        return new IoParameters(Collections.<String, JsonNode>emptyMap(), defaultConfig);
    }

    static IoParameters createFromMultiValueMap(MultiValueMap<String, String> query) {
        return createFromMultiValueMap(query, null);
    }

    static IoParameters createFromMultiValueMap(MultiValueMap<String, String> query, File defaultConfig) {
        return new IoParameters(convertValuesToJsonNodes(query), defaultConfig);
    }

    static IoParameters createFromSingleValueMap(Map<String, String> query) {
        return createFromSingleValueMap(query, null);
    }

    static IoParameters createFromSingleValueMap(Map<String, String> query, File defaultConfig) {
        return new IoParameters(convertValuesToJsonNodes(query), defaultConfig);
    }

    /**
     * @param parameters the parameters sent via POST payload.
     * @return a query map for convenient parameter access plus validation.
     */
    public static IoParameters createFromQuery(RequestParameterSet parameters) {
        return createFromQuery(parameters, null);
    }

    public static IoParameters createFromQuery(RequestParameterSet parameters, File defaultConfig) {
        Map<String, JsonNode> queryParameters = new HashMap<>();
        for (String parameter : parameters.availableParameterNames()) {
            JsonNode value = parameters.getParameterValue(parameter);
            queryParameters.put(parameter.toLowerCase(), value);
        }
        return new IoParameters(queryParameters, defaultConfig);
    }

    public static IoParameters ensureBackwardsCompatibility(IoParameters parameters) {
        return isBackwardsCompatibilityRequest(parameters) ? parameters
                .extendWith(Parameters.FILTER_PLATFORM_TYPES, "stationary", "insitu")
                .extendWith(Parameters.FILTER_DATASET_TYPES, "measurement").removeAllOf(Parameters.HREF_BASE)
                : parameters;
    }

    private static boolean isBackwardsCompatibilityRequest(IoParameters parameters) {
        return !(parameters.containsParameter(Parameters.FILTER_PLATFORM_TYPES)
                || parameters.containsParameter(Parameters.FILTER_DATASET_TYPES));
    }

    public boolean isPureStationaryInsituQuery() {
        Set<String> platformTypes = getPlatformTypes();
        Set<String> datasetTypes = getDatasetTypes();
        return isStationaryInsituOnly(platformTypes) && isMeasurementOnly(datasetTypes);
    }

    private boolean isStationaryInsituOnly(Set<String> platformTypes) {
        return platformTypes.size() == 2 && platformTypes.contains("stationary")
                && platformTypes.contains("insitu");
    }

    private boolean isMeasurementOnly(Set<String> datasetTypes) {
        return datasetTypes.size() == 1 && datasetTypes.contains("measurement");
    }

}