package io.github.ibuildthecloud.gdapi.validation; import static io.github.ibuildthecloud.gdapi.validation.ValidationErrorCodes.*; import io.github.ibuildthecloud.gdapi.context.ApiContext; import io.github.ibuildthecloud.gdapi.exception.ClientVisibleException; import io.github.ibuildthecloud.gdapi.factory.SchemaFactory; import io.github.ibuildthecloud.gdapi.id.IdFormatter; import io.github.ibuildthecloud.gdapi.model.Action; import io.github.ibuildthecloud.gdapi.model.Field; import io.github.ibuildthecloud.gdapi.model.FieldType; import io.github.ibuildthecloud.gdapi.model.Resource; import io.github.ibuildthecloud.gdapi.model.Schema; import io.github.ibuildthecloud.gdapi.model.Schema.Method; import io.github.ibuildthecloud.gdapi.model.impl.ValidationErrorImpl; import io.github.ibuildthecloud.gdapi.request.ApiRequest; import io.github.ibuildthecloud.gdapi.request.handler.AbstractResponseGenerator; import io.github.ibuildthecloud.gdapi.util.DateUtils; import io.github.ibuildthecloud.gdapi.util.RequestUtils; import io.github.ibuildthecloud.gdapi.util.ResponseCodes; import io.github.ibuildthecloud.gdapi.util.TypeUtils; import java.io.IOException; import java.io.InputStream; import java.text.ParseException; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import javax.annotation.PostConstruct; import org.apache.commons.beanutils.ConvertUtils; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class ValidationHandler extends AbstractResponseGenerator { private static final Logger log = LoggerFactory.getLogger(ValidationHandler.class); ReferenceValidator referenceValidator; Set<String> supportedMethods; @Override public void generate(ApiRequest request) throws IOException { ValidationContext context = new ValidationContext(); context.schemaFactory = request.getSchemaFactory(); context.idFormatter = ApiContext.getContext().getIdFormatter(); context.schema = context.schemaFactory.getSchema(request.getType()); validateId(request, context); validateType(request, context); validateAction(request, context); validateMethod(request, context); validateField(request, context); } protected void validateAction(ApiRequest request, ValidationContext context) { String action = request.getAction(); if (action == null || !Method.POST.isMethod(request.getMethod())) { return; } Map<String, Action> actions = request.getId() == null ? context.schema.getCollectionActions() : context.schema.getResourceActions(); if (actions == null || !actions.containsKey(action)) { error(INVALID_ACTION, Resource.ACTION); } if (referenceValidator != null && request.getId() != null) { Resource resource = referenceValidator.getResourceId(request.getType(), request.getId()); if (resource == null) { error(ResponseCodes.NOT_FOUND); } if (!resource.getActions().containsKey(action)) { error(ACTION_NOT_AVAILABLE, Resource.ACTION); } } String input = actions.get(action).getInput(); if (input != null) { Schema inputSchema = context.schemaFactory.getSchema(input); if (inputSchema == null) { log.error("Failed to find input schema [{}] for action [{}] on type [{}]", input, action, request.getType()); error(ResponseCodes.NOT_FOUND); } else { context.actionSchema = inputSchema; } } } protected void validateType(ApiRequest request, ValidationContext context) { if (request.getType() != null && context.schema == null) { error(ResponseCodes.NOT_FOUND); } } protected void validateField(ApiRequest request, ValidationContext context) { if (RequestUtils.isReadMethod(request.getMethod())) { validateReadField(request, context); } else { validateWriteField(request, context); } } protected void validateReadField(ApiRequest request, ValidationContext context) { request.setRequestObject(new HashMap<String, Object>()); } protected void validateWriteField(ApiRequest request, ValidationContext context) { if (Method.PUT.isMethod(request.getMethod())) { validateOperationField(context.schema, request, false, context); } else if (Method.POST.isMethod(request.getMethod())) { if (request.getAction() == null) { validateOperationField(context.schema, request, true, context); } else { validateOperationField(context.actionSchema, request, true, context); } } } protected void validateOperationField(Schema schema, ApiRequest request, boolean create, ValidationContext context) { Map<String, Object> input = RequestUtils.toMap(request.getRequestObject()); Object obj = validateRawOperationField(schema, request.getType(), input, create, context, request.getId()); if (obj != null) { request.setRequestObject(obj); } } protected Object validateRawOperationField(Schema schema, String type, Map<String, Object> input, boolean create, ValidationContext context, String id) { if (schema == null) { return null; } Map<String, Object> sanitized = new LinkedHashMap<String, Object>(); Map<String, Field> fields = schema.getResourceFields(); for (Map.Entry<String, Object> entry : input.entrySet()) { String fieldName = entry.getKey(); Object value = entry.getValue(); if (!create && TypeUtils.ID_FIELD.equals(fieldName)) { /* For right now, just never let anyone update "id" */ continue; } Field field = fields.get(fieldName); if (field == null || !isOperation(field, create)) { continue; } boolean wasNull = value == null && (field.isNullable() || !field.hasDefault()); value = convert(fieldName, field, value, context); if (value != null || wasNull) { if (value instanceof List) { for (Object individualValue : (List<?>)value) { if (individualValue == null) { error(NOT_NULLABLE, fieldName); } checkFieldCriteria(type, fieldName, field, individualValue, id); } } else { checkFieldCriteria(type, fieldName, field, value, id); } sanitized.put(fieldName, value); } } for (Map.Entry<String, Field> entry : fields.entrySet()) { String fieldName = entry.getKey(); Field field = entry.getValue(); if (create && !sanitized.containsKey(fieldName) && field.hasDefault()) { sanitized.put(fieldName, field.getDefault()); } if (create && isOperation(field, create) && field.isRequired()) { if (!sanitized.containsKey(fieldName)) { error(MISSING_REQUIRED, fieldName); } if (field.getTypeEnum() == FieldType.ARRAY) { List<Object> list = convertArray(fieldName, null, null, sanitized.get(fieldName), context); if (list != null && list.size() == 0) { error(MISSING_REQUIRED, fieldName); } } } } return sanitized; } protected boolean isOperation(Field field, boolean create) { return (create && field.isCreate()) || (!create && field.isUpdate()); } protected Object convert(String fieldName, Field field, Object value, ValidationContext context) { return convert(fieldName, field, field.getTypeEnum(), field.getSubTypeEnums(), field.getSubTypes(), value, null, context); } protected Object convert(String fieldName, Field field, FieldType type, List<FieldType> subTypes, List<String> subTypeNames, Object value, String lastSubTypeName, ValidationContext context) { if (value == null) { return value; } switch (type) { case MAP: @SuppressWarnings("unchecked") Map<String, Object> map = (Map<String, Object>)checkType(fieldName, value, Map.class); return convertMap(fieldName, subTypes, subTypeNames, map, context); case ARRAY: if (subTypes.size() == 0) { return error(INVALID_FORMAT, fieldName); } return convertArray(fieldName, subTypes, subTypeNames, value, context); case BLOB: return checkType(fieldName, value, InputStream.class); case JSON: return value; case DATE: case BOOLEAN: case ENUM: case FLOAT: case INT: case PASSWORD: case STRING: return convertGenericType(fieldName, value, type); case REFERENCE: if (subTypeNames.size() == 0) { return error(INVALID_FORMAT, fieldName); } return convertReference(subTypeNames.get(0), fieldName, value, context); case NONE: case TYPE: if (field != null) { lastSubTypeName = field.getType(); } Map<String, Object> mapValue = RequestUtils.toMap(value); Schema schema = context.schemaFactory.getSchema(lastSubTypeName); if (schema != null) { ValidationContext validationContext = new ValidationContext(); validationContext.idFormatter = context.idFormatter; validationContext.schema = schema; validationContext.schemaFactory = context.schemaFactory; return validateRawOperationField(schema, lastSubTypeName, mapValue, true, validationContext, null); } default: throw new IllegalStateException("Do not know how to convert type [" + type + "]"); } } protected Object convertReference(String type, String fieldName, Object value, ValidationContext context) { String id = context.idFormatter.parseId(value.toString()); if (id == null) { error(INVALID_REFERENCE, fieldName); } if (referenceValidator != null) { Object referenced = referenceValidator.getById(type, id); if (referenced == null) { error(INVALID_REFERENCE, fieldName); } } try { /* Attempt to convert to long */ return new Long(id); } catch (NumberFormatException nfe) { return id; } } public static Object convertGenericType(String fieldName, Object value, FieldType type) { if (FieldType.DATE == type) { return convertDate(fieldName, value); } if (type.getClasses().length == 0) return error(INVALID_FORMAT, fieldName); Class<?> clz = type.getClasses()[0]; value = ConvertUtils.convert(value, clz); if (value == null || !clz.isAssignableFrom(value.getClass())) { return error(INVALID_FORMAT, fieldName); } return value; } protected Object checkType(String fieldName, Object value, Class<?> type) { if (type.isAssignableFrom(value.getClass())) { return value; } return error(INVALID_FORMAT, fieldName); } protected Map<String, Object> convertMap(String fieldName, List<FieldType> subTypes, List<String> subTypesNames, Map<String, Object> value, ValidationContext context) { Map<String, Object> result = new LinkedHashMap<String, Object>(); if (subTypes == null) { result.putAll(value); return result; } FieldType type = subTypes.get(0); for (Map.Entry<String, Object> entry : value.entrySet()) { Object item = convert(fieldName, null, type, subTypes.subList(1, subTypes.size()), subTypesNames.subList(1, subTypesNames.size()), entry.getValue(), subTypesNames.get(0), context); result.put(entry.getKey(), item); } return result; } protected List<Object> convertArray(String fieldName, List<FieldType> subTypes, List<String> subTypesNames, Object value, ValidationContext context) { List<Object> result = new ArrayList<Object>(); List<?> items = null; if (value instanceof Object[]) { items = Arrays.asList(value); } else if (value instanceof List) { items = (List<?>)value; } else { items = Arrays.asList(value); } if (subTypes == null) { result.addAll(items); return result; } FieldType type = subTypes.get(0); for (Object item : items) { item = convert(fieldName, null, type, subTypes.subList(1, subTypes.size()), subTypesNames.subList(1, subTypesNames.size()), item, subTypesNames.get(0), context); result.add(item); } return result; } public static Object convertDate(String fieldName, Object value) { if (value instanceof Date) { return value; } if (value instanceof Number) { return new Date(((Number) value).longValue()); } try { if (StringUtils.isBlank(value.toString())) { return null; } return DateUtils.parse(value.toString()); } catch (ParseException e) { return error(INVALID_DATE_FORMAT, fieldName); } } protected void checkFieldCriteria(String type, String fieldName, Field field, Object inputValue, String id) { Object value = inputValue; Number numVal = null; String stringValue = null; Long minLength = field.getMinLength(); Long maxLength = field.getMaxLength(); Long min = field.getMin(); Long max = field.getMax(); List<String> options = field.getOptions(); String validChars = field.getValidChars(); String invalidChars = field.getInvalidChars(); if (value == null && field.getDefault() != null) { value = field.getDefault(); } if (value instanceof Number) { numVal = (Number)value; } if (value != null) { stringValue = value.toString(); } if (value == null && !field.isNullable()) { error(NOT_NULLABLE, fieldName); } if (value != null && field.isUnique() && referenceValidator != null) { if (referenceValidator.getByField(type, fieldName, value, id) != null) { error(NOT_UNIQUE, fieldName); } } if (numVal != null) { if (min != null && numVal.longValue() < min.longValue()) { error(MIN_LIMIT_EXCEEDED, fieldName); } if (max != null && numVal.longValue() > max.longValue()) { error(MAX_LIMIT_EXCEEDED, fieldName); } } if (stringValue != null) { if (minLength != null && stringValue.length() < minLength.longValue()) { error(MIN_LENGTH_EXCEEDED, fieldName); } if (maxLength != null && stringValue.length() > maxLength.longValue()) { error(MAX_LENGTH_EXCEEDED, fieldName); } } if (options != null && options.size() > 0) { if (stringValue != null || !field.isNullable()) { if (!options.contains(stringValue)) { error(INVALID_OPTION, fieldName); } } } if (validChars != null && stringValue != null) { if (!stringValue.matches("^[" + validChars + "]*$")) { error(INVALID_CHARACTERS, fieldName); } } if (invalidChars != null && stringValue != null) { if (stringValue.matches("^[" + invalidChars + "]*$")) { error(INVALID_CHARACTERS, fieldName); } } } protected void validateId(ApiRequest request, ValidationContext context) { String id = request.getId(); if (id == null) { return; } // TODO should add some property on whether the ID should be formatted if (context.schemaFactory.typeStringMatches(Schema.class, request.getType())) { return; } String formattedId = context.idFormatter.parseId(id); if (formattedId == null) { error(ResponseCodes.NOT_FOUND); } else { request.setId(formattedId); } } protected void validateMethod(ApiRequest request, ValidationContext context) { String method = request.getMethod(); if (request.getAction() != null && Method.POST.isMethod(method)) { return; } if (method == null || !supportedMethods.contains(method)) { error(ResponseCodes.METHOD_NOT_ALLOWED); } String type = request.getType(); String id = request.getId(); if (type == null || context.schema == null) { return; } List<String> allowed = id == null ? context.schema.getCollectionMethods() : context.schema.getResourceMethods(); if (!allowed.contains(method)) { error(ResponseCodes.METHOD_NOT_ALLOWED); } } protected static Object error(String code, String fieldName) { ValidationErrorImpl error = new ValidationErrorImpl(code, fieldName); throw new ClientVisibleException(error); } protected Object error(int code) { throw new ClientVisibleException(code); } @PostConstruct public void init() { if (supportedMethods == null) { supportedMethods = new HashSet<String>(); for (Method m : Method.values()) { supportedMethods.add(m.toString()); } } } public Set<String> getSupportedMethods() { return supportedMethods; } public void setSupportedMethods(Set<String> supportedMethods) { this.supportedMethods = supportedMethods; } protected static final class ValidationContext { SchemaFactory schemaFactory; Schema schema; Schema actionSchema; IdFormatter idFormatter; } public ReferenceValidator getReferenceValidator() { return referenceValidator; } public void setReferenceValidator(ReferenceValidator referenceValidator) { this.referenceValidator = referenceValidator; } }