org.restheart.hal.metadata.singletons.JsonPathConditionsChecker.java Source code

Java tutorial

Introduction

Here is the source code for org.restheart.hal.metadata.singletons.JsonPathConditionsChecker.java

Source

/*
 * RESTHeart - the Web API for MongoDB
 * Copyright (C) SoftInstigate Srl
 * 
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 * 
 * 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 Affero General Public License for more details.
 * 
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package org.restheart.hal.metadata.singletons;

import com.mongodb.BasicDBList;
import com.mongodb.BasicDBObject;
import com.mongodb.DBObject;
import io.undertow.server.HttpServerExchange;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import org.restheart.handlers.RequestContext;
import org.restheart.utils.JsonUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 *
 * @author Andrea Di Cesare {@literal <andrea@softinstigate.com>}
 *
 * JsonPathConditionsChecker allows to check request content by using conditions
 * based on json path expression
 *
 * JsonPathConditionsChecker does not support dot notation and update operators
 * on bulk requests. For instance PATCH /db/coll/* {$currentDate: { "a.b": true
 * }}
 *
 * the args arguments is an array of condition. a condition is json object as
 * follows: { "path": "PATHEXPR", [ "type": "APPLY]"] ["count": COUNT ]
 * ["regex": "REGEX"] ["nullable": BOOLEAN]}
 *
 * where
 *
 * <br>PATHEXPR the path expression. use the . notation to identify the property
 * <br>COUNT is the number of expected values
 * <br>APPLY can be any BSON type: null, object, array, string, number, boolean
 * * objectid, date,timestamp, maxkey, minkey, symbol, code, objectid
 * <br>REGEX regular expression. note that string values to match come enclosed
 * in quotation marks, i.e. the regex will need to match "the value", included
 * the quotation marks
 *
 * <br>examples for path expressions:
 *
 * <br>root = {a: {b:1, c: {d:2, e:3}}, f:4}
 * <br> $.a -> {b:1, c: {d:2, e:3}}, f:4}
 * <br> $.* -> {a: {b:1, c: {d:2, e:3}}}, {f:4}
 * <br> $.a.b -> 1
 * <br> $.a.c -> {d:2,e:3}
 * <br> $.a.c.d -> 2
 *
 * <br>root = {a: [{b:1}, {c:2,d:3}}, true]}
 *
 * <br> $.a -> [{b:1}, {c:2,d:3}, true]
 * <br> $.a.[*] -> {b:1}, {c:2,d:3}, true
 * <br> $.a.[*].c -> null, 2, null
 *
 *
 * <br>root = {a: [{b:1}, {b:2}, {b:3}]}"
 *
 * <br> $.*.a.[*].b -> [1,2,3]
 *
 * <br>example regex condition that matches email addresses:
 *
 * <br>{"path":"$._id", "regex":
 * "^\"[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}\"$"}
 * <br>with unicode escapes (used by httpie): {"path":"$._id", "regex":
 * "^\\u0022[A-Z0-9._%+-]+@[A-Z0-9.-]+\\u005C\\u005C.[A-Z]{2,6}\\u0022$"}
 *
 */
public class JsonPathConditionsChecker implements Checker {
    static final Logger LOGGER = LoggerFactory.getLogger(JsonPathConditionsChecker.class);

    protected static String avoidEscapedChars(String s) {
        return s.replaceAll("\"", "'").replaceAll("\t", "  ");
    }

    @Override
    public boolean check(HttpServerExchange exchange, RequestContext context, BasicDBObject contentToCheck,
            DBObject args) {
        if (args instanceof BasicDBList) {
            BasicDBList conditions = filterMissingOptionalAndNullNullableConditions((BasicDBList) args,
                    contentToCheck);
            return applyConditions(conditions, contentToCheck, context);
        } else {
            context.addWarning(
                    "checker wrong definition: args property must be an arrary of string property names.");
            return true;
        }
    }

    @Override
    public PHASE getPhase(RequestContext context) {
        if (context.getMethod() == RequestContext.METHOD.PATCH
                || CheckersUtils.doesRequestUsesDotNotation(context.getContent())
                || CheckersUtils.doesRequestUsesUpdateOperators(context.getContent())) {
            return PHASE.AFTER_WRITE;
        } else {
            return PHASE.BEFORE_WRITE;
        }
    }

    @Override
    public boolean doesSupportRequests(RequestContext context) {
        return !(CheckersUtils.isBulkRequest(context) && getPhase(context) == PHASE.AFTER_WRITE);
    }

    protected boolean applyConditions(BasicDBList conditions, DBObject json, final RequestContext context) {
        return conditions.stream().allMatch((Object _condition) -> {
            if (_condition instanceof BasicDBObject) {
                BasicDBObject condition = (BasicDBObject) _condition;
                String path = null;
                Object _path = condition.get("path");
                if (_path != null && _path instanceof String) {
                    path = (String) _path;
                }
                String type = null;
                Object _type = condition.get("type");
                if (_type != null && _type instanceof String) {
                    type = (String) _type;
                }
                Set<Integer> counts = new HashSet<>();
                Object _count = condition.get("count");
                if (_count != null) {
                    if (_count instanceof Integer) {
                        counts.add((Integer) _count);
                    } else if (_count instanceof BasicDBList) {
                        BasicDBList countsArray = (BasicDBList) _count;
                        countsArray.forEach((Object countElement) -> {
                            if (countElement instanceof Integer) {
                                counts.add((Integer) countElement);
                            }
                        });
                    }
                }
                Set<String> mandatoryFields;
                Object _mandatoryFields = condition.get("mandatoryFields");
                if (_mandatoryFields != null) {
                    mandatoryFields = new HashSet<>();
                    if (_mandatoryFields instanceof BasicDBList) {
                        BasicDBList mandatoryFieldsArray = (BasicDBList) _mandatoryFields;
                        mandatoryFieldsArray.forEach((Object element) -> {
                            if (element instanceof String) {
                                mandatoryFields.add((String) element);
                            }
                        });
                    }
                } else {
                    mandatoryFields = null;
                }
                Set<String> optionalFields;
                Object _optionalFields = condition.get("optionalFields");
                if (_optionalFields != null) {
                    optionalFields = new HashSet<>();
                    if (_optionalFields instanceof BasicDBList) {
                        BasicDBList optionalFieldsArray = (BasicDBList) _optionalFields;
                        optionalFieldsArray.forEach((Object element) -> {
                            if (element instanceof String) {
                                optionalFields.add((String) element);
                            }
                        });
                    }
                } else {
                    optionalFields = null;
                }
                String regex = null;

                Object _regex = condition.get("regex");
                if (_regex != null && _regex instanceof String) {
                    regex = (String) _regex;
                }
                Boolean optional = false;
                Object _optional = condition.get("optional");
                if (_optional != null && _optional instanceof Boolean) {
                    optional = (Boolean) _optional;
                }
                Boolean nullable = false;
                Object _nullable = condition.get("nullable");
                if (_nullable != null && _nullable instanceof Boolean) {
                    nullable = (Boolean) _nullable;
                }
                if (counts.isEmpty() && type == null && regex == null) {
                    context.addWarning(
                            "condition does not have any of 'count', 'type' and 'regex' properties, specify at least one: "
                                    + _condition);
                    return true;
                }
                if (path == null) {
                    context.addWarning(
                            "condition in the args list does not have the 'path' property: " + _condition);
                    return true;
                }
                if (type != null && !counts.isEmpty() && regex != null) {
                    return checkCount(json, path, counts, context) && checkType(json, path, type, mandatoryFields,
                            optionalFields, optional, nullable, context)
                            && checkRegex(json, path, regex, optional, nullable, context);
                } else if (type != null && !counts.isEmpty()) {
                    return checkCount(json, path, counts, context) && checkType(json, path, type, mandatoryFields,
                            optionalFields, optional, nullable, context);
                } else if (type != null && regex != null) {
                    return checkType(json, path, type, mandatoryFields, optionalFields, optional, nullable, context)
                            && checkRegex(json, path, regex, optional, nullable, context);
                } else if (!counts.isEmpty() && regex != null) {
                    return checkCount(json, path, counts, context)
                            && checkRegex(json, path, regex, optional, nullable, context);
                } else if (type != null) {
                    return checkType(json, path, type, mandatoryFields, optionalFields, optional, nullable,
                            context);
                } else if (!counts.isEmpty()) {
                    return checkCount(json, path, counts, context);
                } else if (regex != null) {
                    return checkRegex(json, path, regex, optional, nullable, context);
                }
                return true;
            } else {
                context.addWarning("property in the args list is not an object: " + _condition);
                return true;
            }
        });
    }

    /**
     * this filters out the nullable and optional conditions where the path
     * resolves to null
     *
     * @param conditions
     * @param content
     * @return
     */
    protected BasicDBList filterMissingOptionalAndNullNullableConditions(BasicDBList conditions, DBObject content) {
        Set<String> nullPaths = new HashSet<>();
        BasicDBList ret = new BasicDBList();
        conditions.stream().forEach((Object condition) -> {
            if (condition instanceof BasicDBObject) {
                Boolean nullable = false;
                Object _nullable = ((BasicDBObject) condition).get("nullable");
                if (_nullable != null && _nullable instanceof Boolean) {
                    nullable = (Boolean) _nullable;
                }
                Boolean optional = false;
                Object _optional = ((BasicDBObject) condition).get("optional");
                if (_optional != null && _optional instanceof Boolean) {
                    optional = (Boolean) _optional;
                }
                if (nullable) {
                    Object _path = ((BasicDBObject) condition).get("path");
                    if (_path != null && _path instanceof String) {
                        String path = (String) _path;
                        List<Optional<Object>> props;
                        try {
                            props = JsonUtils.getPropsFromPath(content, path);
                            if (props != null && props.stream().allMatch((Optional<Object> prop) -> {
                                return prop != null && !prop.isPresent();
                            })) {
                                LOGGER.debug("ignoring null path {}", path);
                                nullPaths.add(path);
                            }
                        } catch (IllegalArgumentException ex) {
                            nullPaths.add(path);
                        }
                    }
                }
                if (optional) {
                    Object _path = ((BasicDBObject) condition).get("path");
                    if (_path != null && _path instanceof String) {
                        String path = (String) _path;
                        List<Optional<Object>> props;
                        try {
                            props = JsonUtils.getPropsFromPath(content, path);
                            if (props == null || props.stream().allMatch((Optional<Object> prop) -> {
                                return prop == null;
                            })) {
                                nullPaths.add(path);
                            }
                        } catch (IllegalArgumentException ex) {
                            nullPaths.add(path);
                        }
                    }
                }
            }
        });
        conditions.stream().forEach((Object condition) -> {
            if (condition instanceof BasicDBObject) {
                Object _path = ((BasicDBObject) condition).get("path");
                if (_path != null && _path instanceof String) {
                    String path = (String) _path;
                    boolean hasNullParent = nullPaths.stream().anyMatch((String nullPath) -> {
                        return JsonUtils.isAncestorPath(nullPath, path);
                    });
                    if (!hasNullParent) {
                        ret.add(condition);
                    }
                }
            }
        });
        return ret;
    }

    protected boolean checkCount(DBObject json, String path, Set<Integer> expectedCounts, RequestContext context) {
        Integer count;
        try {
            count = JsonUtils.countPropsFromPath(json, path);
        } catch (IllegalArgumentException ex) {
            return false;
        }
        // props is null when path does not exist. count is false
        if (count == null) {
            return false;
        }
        boolean ret = expectedCounts.contains(count);
        LOGGER.debug("checkCount({}, {}) -> {}", path, expectedCounts, ret);
        if (ret == false) {
            context.addWarning("checkCount condition failed: path: " + path + ", expected: " + expectedCounts
                    + ", got: " + count);
        }
        return ret;
    }

    protected boolean checkType(DBObject json, String path, String type, Set<String> mandatoryFields,
            Set<String> optionalFields, boolean optional, boolean nullable, RequestContext context) {
        BasicDBObject _json = (BasicDBObject) json;
        List<Optional<Object>> props;
        boolean ret;
        boolean failedFieldsCheck = false;
        try {
            props = JsonUtils.getPropsFromPath(_json, path);
        } catch (IllegalArgumentException ex) {
            LOGGER.debug("checkType({}, {}, {}, {}) -> {} -> false", path, type, mandatoryFields, optionalFields,
                    ex.getMessage());
            context.addWarning("checkType condition failed: path: " + path + ", expected type: " + type
                    + ", error: " + ex.getMessage());
            return false;
        }
        // props is null when path does not exist.
        if (props == null) {
            ret = optional;
        } else {
            ret = props.stream().allMatch((Optional<Object> prop) -> {
                if (prop == null) {
                    return optional;
                }
                if (prop.isPresent()) {
                    if ("array".equals(type) && prop.get() instanceof DBObject) {
                        // this might be the case of PATCHING an element array using the dot notation
                        // e.g. object.array.2
                        // if so, the array comes as an BasicDBObject with all numberic keys
                        // in any case, it might also be the object { "object": { "array": {"2": xxx }}}
                        return ((DBObject) prop.get()).keySet().stream().allMatch((String k) -> {
                            try {
                                Integer.parseInt(k);
                                return true;
                            } catch (NumberFormatException nfe) {
                                return false;
                            }
                        }) || JsonUtils.checkType(prop, type);
                    } else {
                        return JsonUtils.checkType(prop, type);
                    }
                } else {
                    return nullable;
                }
            });
            // check object fields
            if (ret && "object".equals(type) && (mandatoryFields != null || optionalFields != null)) {
                Set<String> allFields = new HashSet<>();
                if (mandatoryFields != null) {
                    allFields.addAll(mandatoryFields);
                }
                if (optionalFields != null) {
                    allFields.addAll(optionalFields);
                }
                ret = props.stream().allMatch((Optional<Object> prop) -> {
                    if (prop == null) {
                        return optional;
                    }
                    if (prop.isPresent()) {
                        BasicDBObject obj = (BasicDBObject) prop.get();
                        if (mandatoryFields != null) {
                            return obj.keySet().containsAll(mandatoryFields) && allFields.containsAll(obj.keySet());
                        } else {
                            return allFields.containsAll(obj.keySet());
                        }
                    } else {
                        return nullable;
                    }
                });
                if (ret == false) {
                    failedFieldsCheck = true;
                }
            }
        }

        if (ret) {
            LOGGER.trace("checkType({}, {}, {}, {}) -> {} -> {}", path, type, mandatoryFields, optionalFields,
                    getRootPropsString(props), ret);
        } else {
            LOGGER.debug("checkType({}, {}, {}, {}) -> {} -> {}", path, type, mandatoryFields, optionalFields,
                    getRootPropsString(props), ret);

            String errorMessage;
            if (!failedFieldsCheck) {
                errorMessage = "checkType condition failed: path: " + path + ", expected type: " + type + ", got: "
                        + (props == null ? "null" : avoidEscapedChars(getRootPropsString(props)));

            } else {
                errorMessage = "checkType condition failed: path: " + path + ", mandatory fields: "
                        + mandatoryFields + ", optional fields: " + optionalFields + ", got: "
                        + (props == null ? "null" : avoidEscapedChars(getRootPropsString(props)));
            }

            context.addWarning(errorMessage);
        }

        return ret;
    }

    protected boolean checkRegex(DBObject json, String path, String regex, boolean optional, boolean nullable,
            RequestContext context) {
        BasicDBObject _json = (BasicDBObject) json;
        List<Optional<Object>> props;
        try {
            props = JsonUtils.getPropsFromPath(_json, path);
        } catch (IllegalArgumentException ex) {
            LOGGER.debug("checkRegex({}, {}) -> {}", path, regex, ex.getMessage());
            context.addWarning("checkRegex condition failed: path: " + path + ", regex: " + regex + ", got: "
                    + ex.getMessage());
            return false;
        }
        boolean ret;
        // props is null when path does not exist.
        if (props == null) {
            ret = optional;
        } else {
            Pattern p = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
            ret = props.stream().allMatch((Optional<Object> prop) -> {
                if (prop == null) {
                    return optional;
                }
                if (prop.isPresent()) {
                    if (prop.get() instanceof String) {
                        return p.matcher((String) prop.get()).find();
                    } else {
                        return p.matcher(JsonUtils.serialize(prop.get())).find();
                    }
                } else {
                    return nullable;
                }
            });
        }

        if (ret) {
            LOGGER.trace("checkRegex({}, {}) -> {} -> {}", path, regex, getRootPropsString(props), ret);
        } else {
            LOGGER.debug("checkRegex({}, {}) -> {} -> {}", path, regex, getRootPropsString(props), ret);

            String errorMessage = "checkRegex condition failed: path: " + path + ", regex: " + regex + ", got: "
                    + (props == null ? "null" : avoidEscapedChars(getRootPropsString(props)));

            context.addWarning(errorMessage);
        }

        return ret;
    }

    private String getRootPropsString(List<Optional<Object>> props) {
        if (props == null) {
            return null;
        }

        StringBuilder sb = new StringBuilder();

        props.stream().forEach((_prop) -> {
            if (_prop == null) {
                sb.append("<property not existing>");
            } else if (_prop.isPresent()) {
                Object prop = _prop.get();

                if (prop instanceof BasicDBList) {
                    BasicDBList array = (BasicDBList) prop;

                    sb.append("[");

                    array.stream().forEach((item) -> {
                        if (item instanceof BasicDBObject) {
                            sb.append("{obj}");
                        } else if (item instanceof BasicDBList) {
                            sb.append("[array]");
                        } else if (item instanceof String) {
                            sb.append("'");
                            sb.append(item.toString());
                            sb.append("'");
                        } else {
                            sb.append(item.toString());
                        }

                        sb.append(", ");
                    });

                    // remove last comma
                    if (sb.length() > 1) {
                        sb.deleteCharAt(sb.length() - 1);
                        sb.deleteCharAt(sb.length() - 1);
                    }

                    sb.append("]");
                } else if (prop instanceof BasicDBObject) {
                    BasicDBObject obj = (BasicDBObject) prop;

                    sb.append(obj.keySet().toString());
                } else if (prop instanceof String) {
                    sb.append("'");
                    sb.append(prop.toString());
                    sb.append("'");
                } else {
                    sb.append(prop.toString());
                }
            } else {
                sb.append("null");
            }

            sb.append(", ");
        });

        // remove last comma
        if (sb.length() > 1) {
            sb.deleteCharAt(sb.length() - 1);
            sb.deleteCharAt(sb.length() - 1);
        }

        return sb.toString();
    }
}