diff --git a/src/main/java/org/javawebstack/abstractdata/AbstractPath.java b/src/main/java/org/javawebstack/abstractdata/AbstractPath.java new file mode 100644 index 0000000..e64640e --- /dev/null +++ b/src/main/java/org/javawebstack/abstractdata/AbstractPath.java @@ -0,0 +1,97 @@ +package org.javawebstack.abstractdata; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class AbstractPath { + + public static final AbstractPath ROOT = new AbstractPath(null, null); + + private final AbstractPath parent; + private final String name; + + public AbstractPath(String name) { + this(ROOT, name); + if(name == null || name.isEmpty()) + throw new IllegalArgumentException("Name can not be null or empty"); + } + + private AbstractPath(AbstractPath parent, String name) { + this.parent = parent; + this.name = name; + } + + public String getName() { + return name; + } + + public AbstractPath getParent() { + return parent; + } + + public AbstractPath subPath(String name) { + return new AbstractPath(this, name); + } + + public AbstractPath clone() { + return new AbstractPath( + this.parent != null ? this.parent.clone() : null, + name + ); + } + + public AbstractPath concat(AbstractPath path) { + AbstractPath cloned = clone(); + for(String part : path.getParts()) + cloned = cloned.subPath(part); + return cloned; + } + + public List getParts() { + List parts = parent != null ? parent.getParts() : new ArrayList<>(); + if(name != null) + parts.add(name); + return parts; + } + + public String toString() { + return String.join(".", getParts()); + } + + public static AbstractPath parse(String s) { + s = s.trim(); + if(s.isEmpty()) + return ROOT; + String[] spl = s.split("\\."); + AbstractPath path = new AbstractPath(spl[0]); + for(int i=1; i> customValidations = new ArrayList<>(); + + public AbstractArraySchema itemSchema(AbstractSchema schema) { + this.itemSchema = schema; + return this; + } + + public AbstractArraySchema min(int min) { + this.min = min; + return this; + } + + public AbstractArraySchema max(int max) { + this.max = max; + return this; + } + + public AbstractArraySchema allowNull() { + this.allowNull = true; + return this; + } + + public AbstractArraySchema customValidation(CustomValidation validation) { + customValidations.add(validation); + return this; + } + + public AbstractSchema getItemSchema() { + return itemSchema; + } + + public Integer getMin() { + return min; + } + + public Integer getMax() { + return max; + } + + public List> getCustomValidations() { + return customValidations; + } + + public List validate(AbstractPath path, AbstractElement value) { + List errors = new ArrayList<>(); + if(value.getType() != AbstractElement.Type.ARRAY) { + errors.add(new SchemaValidationError(path, "invalid_type").meta("expected", "array").meta("actual", value.getType().name().toLowerCase(Locale.ROOT))); + return errors; + } + AbstractArray array = value.array(); + if(min != null && array.size() < min) { + errors.add(new SchemaValidationError(path, "not_enough_items").meta("min", String.valueOf(min)).meta("actual", String.valueOf(array.size()))); + } + if(max != null && array.size() > max) { + errors.add(new SchemaValidationError(path, "too_many_items").meta("max", String.valueOf(max)).meta("actual", String.valueOf(array.size()))); + } + if(itemSchema != null) { + for(int i=0; i validation : customValidations) { + errors.addAll(validation.validate(path, array)); + } + return errors; + } + +} diff --git a/src/main/java/org/javawebstack/abstractdata/schema/AbstractBooleanSchema.java b/src/main/java/org/javawebstack/abstractdata/schema/AbstractBooleanSchema.java new file mode 100644 index 0000000..958e1a9 --- /dev/null +++ b/src/main/java/org/javawebstack/abstractdata/schema/AbstractBooleanSchema.java @@ -0,0 +1,49 @@ +package org.javawebstack.abstractdata.schema; + +import org.javawebstack.abstractdata.AbstractElement; +import org.javawebstack.abstractdata.AbstractPath; +import org.javawebstack.abstractdata.AbstractPrimitive; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public class AbstractBooleanSchema implements AbstractSchema { + + private Boolean staticValue; + private final List> customValidations = new ArrayList<>(); + + public AbstractBooleanSchema staticValue(boolean value) { + this.staticValue = value; + return this; + } + + public AbstractBooleanSchema customValidation(CustomValidation validation) { + customValidations.add(validation); + return this; + } + + public Boolean getStaticValue() { + return staticValue; + } + + public List> getCustomValidations() { + return customValidations; + } + + public List validate(AbstractPath path, AbstractElement value) { + List errors = new ArrayList<>(); + if(value.getType() != AbstractElement.Type.BOOLEAN) { + errors.add(new SchemaValidationError(path, "invalid_type").meta("expected", "boolean").meta("actual", value.getType().name().toLowerCase(Locale.ROOT))); + return errors; + } + if(staticValue != null && staticValue != value.bool()) { + errors.add(new SchemaValidationError(path, "invalid_static_value").meta("expected", staticValue.toString()).meta("actual", value.bool().toString())); + } + for(CustomValidation validation : customValidations) { + errors.addAll(validation.validate(path, value.primitive())); + } + return errors; + } + +} diff --git a/src/main/java/org/javawebstack/abstractdata/schema/AbstractNumberSchema.java b/src/main/java/org/javawebstack/abstractdata/schema/AbstractNumberSchema.java new file mode 100644 index 0000000..8561c6c --- /dev/null +++ b/src/main/java/org/javawebstack/abstractdata/schema/AbstractNumberSchema.java @@ -0,0 +1,85 @@ +package org.javawebstack.abstractdata.schema; + +import org.javawebstack.abstractdata.AbstractElement; +import org.javawebstack.abstractdata.AbstractPath; +import org.javawebstack.abstractdata.AbstractPrimitive; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public class AbstractNumberSchema implements AbstractSchema { + + private boolean integerOnly = false; + private Number min; + private Number max; + private final List> customValidations = new ArrayList<>(); + + public AbstractNumberSchema min(Number min) { + this.min = min; + return this; + } + + public AbstractNumberSchema max(Number max) { + this.max = max; + return this; + } + + public AbstractNumberSchema integerOnly() { + this.integerOnly = true; + return this; + } + + public AbstractNumberSchema customValidation(CustomValidation validation) { + customValidations.add(validation); + return this; + } + + public Number getMin() { + return min; + } + + public Number getMax() { + return max; + } + + public boolean isIntegerOnly() { + return integerOnly; + } + + public List> getCustomValidations() { + return customValidations; + } + + public List validate(AbstractPath path, AbstractElement value) { + List errors = new ArrayList<>(); + if(value.getType() != AbstractElement.Type.NUMBER) { + errors.add(new SchemaValidationError(path, "invalid_type").meta("expected", integerOnly ? "integer" : "number").meta("actual", value.getType().name().toLowerCase(Locale.ROOT))); + return errors; + } + Number n = value.number(); + BigDecimal dN = (n instanceof Float || n instanceof Double) ? BigDecimal.valueOf(n.doubleValue()) : BigDecimal.valueOf(n.longValue()); + if(integerOnly && (n instanceof Float || n instanceof Double)) { + errors.add(new SchemaValidationError(path, "invalid_type").meta("expected", "integer").meta("actual", "number")); + return errors; + } + if(min != null) { + BigDecimal dMin = (min instanceof Float || min instanceof Double) ? BigDecimal.valueOf(min.doubleValue()) : BigDecimal.valueOf(min.longValue()); + if(dN.compareTo(dMin) < 0) { + errors.add(new SchemaValidationError(path, "number_smaller_than_min").meta("min", dMin.toPlainString()).meta("actual", dN.toPlainString())); + } + } + if(max != null) { + BigDecimal dMax = (max instanceof Float || min instanceof Double) ? BigDecimal.valueOf(max.doubleValue()) : BigDecimal.valueOf(max.longValue()); + if(dN.compareTo(dMax) > 0) { + errors.add(new SchemaValidationError(path, "number_larger_than_max").meta("max", dMax.toPlainString()).meta("actual", dN.toPlainString())); + } + } + for(CustomValidation validation : customValidations) { + errors.addAll(validation.validate(path, value.primitive())); + } + return errors; + } + +} diff --git a/src/main/java/org/javawebstack/abstractdata/schema/AbstractObjectSchema.java b/src/main/java/org/javawebstack/abstractdata/schema/AbstractObjectSchema.java new file mode 100644 index 0000000..f3c4eaf --- /dev/null +++ b/src/main/java/org/javawebstack/abstractdata/schema/AbstractObjectSchema.java @@ -0,0 +1,79 @@ +package org.javawebstack.abstractdata.schema; + +import org.javawebstack.abstractdata.AbstractElement; +import org.javawebstack.abstractdata.AbstractObject; +import org.javawebstack.abstractdata.AbstractPath; + +import java.util.*; + +public class AbstractObjectSchema implements AbstractSchema { + + private final Map properties = new HashMap<>(); + private final Set requiredProperties = new HashSet<>(); + private final List> customValidations = new ArrayList<>(); + private boolean allowAdditionalProperties = false; + private AbstractSchema additionalPropertySchema; + + public AbstractObjectSchema requiredProperty(String name, AbstractSchema schema) { + properties.put(name, schema); + requiredProperties.add(name); + return this; + } + + public AbstractObjectSchema optionalProperty(String name, AbstractSchema schema) { + properties.put(name, schema); + requiredProperties.remove(name); + return this; + } + + public AbstractObjectSchema customValidation(CustomValidation validation) { + customValidations.add(validation); + return this; + } + + public AbstractObjectSchema additionalProperties() { + return additionalProperties(null); + } + + public AbstractObjectSchema additionalProperties(AbstractSchema schema) { + allowAdditionalProperties = true; + additionalPropertySchema = schema; + return this; + } + + public List validate(AbstractPath path, AbstractElement value) { + List errors = new ArrayList<>(); + if(value.getType() != AbstractElement.Type.OBJECT) { + errors.add(new SchemaValidationError(path, "invalid_type").meta("expected", "object").meta("actual", value.getType().name().toLowerCase(Locale.ROOT))); + return errors; + } + AbstractObject object = value.object(); + for(String prop : requiredProperties) { + if(!object.has(prop) || object.get(prop).isNull()) { + errors.add(new SchemaValidationError(path.subPath(prop), "missing_required_property")); + } + } + for(String prop : object.keys()) { + AbstractElement propValue = object.get(prop); + AbstractPath propPath = path.subPath(prop); + if(properties.containsKey(prop)) { + if(propValue.isNull()) + continue; + errors.addAll(properties.get(prop).validate(propPath, propValue)); + } else { + if(allowAdditionalProperties) { + if(additionalPropertySchema != null) { + errors.addAll(additionalPropertySchema.validate(propPath, propValue)); + } + } else { + errors.add(new SchemaValidationError(propPath, "unexpected_property")); + } + } + } + for(CustomValidation validation : customValidations) { + errors.addAll(validation.validate(path, object)); + } + return errors; + } + +} diff --git a/src/main/java/org/javawebstack/abstractdata/schema/AbstractSchema.java b/src/main/java/org/javawebstack/abstractdata/schema/AbstractSchema.java new file mode 100644 index 0000000..8b06d54 --- /dev/null +++ b/src/main/java/org/javawebstack/abstractdata/schema/AbstractSchema.java @@ -0,0 +1,72 @@ +package org.javawebstack.abstractdata.schema; + +import org.javawebstack.abstractdata.AbstractElement; +import org.javawebstack.abstractdata.AbstractPath; + +import java.util.List; + +public interface AbstractSchema { + + default List validate(AbstractElement value) { + return validate(AbstractPath.ROOT, value); + } + + List validate(AbstractPath path, AbstractElement value); + + static AbstractArraySchema array(AbstractSchema itemSchema) { + return array().itemSchema(itemSchema); + } + + static AbstractArraySchema array() { + return new AbstractArraySchema(); + } + + static AbstractObjectSchema object() { + return new AbstractObjectSchema(); + } + + static AbstractStringSchema staticString(String s) { + return string().staticValue(s); + } + + static AbstractStringSchema enumString(Class> enumType) { + return string().enumValues(enumType); + } + + static AbstractStringSchema enumString(String... values) { + return string().enumValues(values); + } + + static AbstractStringSchema string() { + return new AbstractStringSchema(); + } + + static AbstractNumberSchema integer(int min, int max) { + return integer().min(min).max(max); + } + + static AbstractNumberSchema integer() { + return new AbstractNumberSchema().integerOnly(); + } + + static AbstractNumberSchema number() { + return new AbstractNumberSchema(); + } + + static AbstractNumberSchema number(double min, double max) { + return number().min(min).max(max); + } + + static AbstractBooleanSchema staticBool(boolean v) { + return bool().staticValue(v); + } + + static AbstractBooleanSchema bool() { + return new AbstractBooleanSchema(); + } + + static OneOfSchema oneOf(AbstractSchema... schemas) { + return new OneOfSchema(schemas); + } + +} diff --git a/src/main/java/org/javawebstack/abstractdata/schema/AbstractStringSchema.java b/src/main/java/org/javawebstack/abstractdata/schema/AbstractStringSchema.java new file mode 100644 index 0000000..10f6f46 --- /dev/null +++ b/src/main/java/org/javawebstack/abstractdata/schema/AbstractStringSchema.java @@ -0,0 +1,107 @@ +package org.javawebstack.abstractdata.schema; + +import org.javawebstack.abstractdata.AbstractElement; +import org.javawebstack.abstractdata.AbstractPath; +import org.javawebstack.abstractdata.AbstractPrimitive; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class AbstractStringSchema implements AbstractSchema { + + private Integer minLength; + private Integer maxLength; + private String staticValue; + private String regex; + private Pattern regexPattern; + private Set enumValues; + private final List> customValidations = new ArrayList<>(); + + public AbstractStringSchema staticValue(String value) { + this.staticValue = value; + return this; + } + + public AbstractStringSchema enumValues(Class> enumType) { + Set values = new HashSet<>(); + for(Enum v : enumType.getEnumConstants()) { + values.add(v.name()); + } + return enumValues(values); + } + + public AbstractStringSchema enumValues(String... values) { + return enumValues(new HashSet<>(Arrays.asList(values))); + } + + public AbstractStringSchema enumValues(Set values) { + this.enumValues = values; + return this; + } + + public AbstractStringSchema minLength(int min) { + this.minLength = min; + return this; + } + + public AbstractStringSchema maxLength(int max) { + this.maxLength = max; + return this; + } + + public AbstractStringSchema regex(String regex) { + this.regex = regex; + this.regexPattern = Pattern.compile(regex); + return this; + } + + public AbstractStringSchema customValidation(CustomValidation validation) { + customValidations.add(validation); + return this; + } + + public String getRegex() { + return regex; + } + + public String getStaticValue() { + return staticValue; + } + + public List> getCustomValidations() { + return customValidations; + } + + public List validate(AbstractPath path, AbstractElement value) { + List errors = new ArrayList<>(); + if(value.getType() != AbstractElement.Type.STRING) { + errors.add(new SchemaValidationError(path, "invalid_type").meta("expected", "string").meta("actual", value.getType().name().toLowerCase(Locale.ROOT))); + return errors; + } + String s = value.string(); + if(staticValue != null && !staticValue.equals(s)) { + errors.add(new SchemaValidationError(path, "invalid_static_value").meta("expected", staticValue).meta("actual", s)); + } + if(enumValues != null && !enumValues.contains(s)) { + errors.add(new SchemaValidationError(path, "invalid_enum_value").meta("expected", String.join(", ", enumValues)).meta("actual", s)); + } + if(minLength != null && s.length() < minLength) { + errors.add(new SchemaValidationError(path, "value_too_short").meta("min", minLength.toString()).meta("actual", String.valueOf(s.length()))); + } + if(maxLength != null && s.length() > maxLength) { + errors.add(new SchemaValidationError(path, "value_too_long").meta("max", maxLength.toString()).meta("actual", String.valueOf(s.length()))); + } + if(regexPattern != null) { + Matcher matcher = regexPattern.matcher(s); + if(!matcher.matches()) { + errors.add(new SchemaValidationError(path, "invalid_pattern").meta("pattern", regex).meta("actual", s)); + } + } + for(CustomValidation validation : customValidations) { + errors.addAll(validation.validate(path, value.primitive())); + } + return errors; + } + +} diff --git a/src/main/java/org/javawebstack/abstractdata/schema/CustomValidation.java b/src/main/java/org/javawebstack/abstractdata/schema/CustomValidation.java new file mode 100644 index 0000000..1001f0d --- /dev/null +++ b/src/main/java/org/javawebstack/abstractdata/schema/CustomValidation.java @@ -0,0 +1,12 @@ +package org.javawebstack.abstractdata.schema; + +import org.javawebstack.abstractdata.AbstractElement; +import org.javawebstack.abstractdata.AbstractPath; + +import java.util.List; + +public interface CustomValidation { + + List validate(AbstractPath path, T value); + +} diff --git a/src/main/java/org/javawebstack/abstractdata/schema/OneOfSchema.java b/src/main/java/org/javawebstack/abstractdata/schema/OneOfSchema.java new file mode 100644 index 0000000..4960632 --- /dev/null +++ b/src/main/java/org/javawebstack/abstractdata/schema/OneOfSchema.java @@ -0,0 +1,35 @@ +package org.javawebstack.abstractdata.schema; + +import org.javawebstack.abstractdata.AbstractElement; +import org.javawebstack.abstractdata.AbstractPath; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class OneOfSchema implements AbstractSchema { + + private final List schemas = new ArrayList<>(); + + public OneOfSchema(AbstractSchema... schemas) { + if(schemas.length == 0) + throw new IllegalArgumentException("At least one schema is required"); + this.schemas.addAll(Arrays.asList(schemas)); + } + + public List validate(AbstractPath path, AbstractElement value) { + List> schemaErrors = new ArrayList<>(); + for(AbstractSchema schema : schemas) { + List errors = schema.validate(path, value); + if(errors.isEmpty()) + return errors; + schemaErrors.add(errors); + } + for(List errors : schemaErrors) { + if(!(errors.size() == 1 && errors.get(0).getError().equals("invalid_type"))) + return errors; + } + return schemaErrors.get(0); + } + +} diff --git a/src/main/java/org/javawebstack/abstractdata/schema/SchemaValidationError.java b/src/main/java/org/javawebstack/abstractdata/schema/SchemaValidationError.java new file mode 100644 index 0000000..f516f39 --- /dev/null +++ b/src/main/java/org/javawebstack/abstractdata/schema/SchemaValidationError.java @@ -0,0 +1,80 @@ +package org.javawebstack.abstractdata.schema; + +import org.javawebstack.abstractdata.AbstractPath; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class SchemaValidationError { + + private static final Map BUILTIN_DESCRIPTIONS = new HashMap() {{ + put("invalid_type", "Expected value of type {expected}, got {actual}"); + put("not_enough_items", "At least {min} item(s) required, got {actual}"); + put("too_many_items", "Not more than {max} item(s) allowed, got {actual}"); + put("null_not_allowed", "Value null is not allowed"); + put("invalid_static_value", "Static value does not match, expected '{expected}' but got '{actual}'"); + put("number_smaller_than_min", "The value '{actual}' is smaller than {min}"); + put("number_larger_than_max", "The value '{actual}' is larger than {max}"); + put("missing_required_property", "The property is required but missing"); + put("unexpected_property", "Unexpected property, additional properties are not allowed"); + put("value_too_short", "The length of the value ({actual}) is shorter than the minimum of {min}"); + put("value_too_long", "The length of the value ({actual}) is longer than the maximum of {max}"); + put("invalid_pattern", "The value '{actual}' does not match the pattern '{pattern}'"); + }}; + + private AbstractPath path; + private String error; + private Map errorMeta = new HashMap<>(); + + public SchemaValidationError(AbstractPath path, String error) { + this.path = path; + this.error = error; + } + + public SchemaValidationError meta(String key, String value) { + errorMeta.put(key, value); + return this; + } + + public AbstractPath getPath() { + return path; + } + + public String getError() { + return error; + } + + public Map getErrorMeta() { + return errorMeta; + } + + public String getErrorDescription() { + return getErrorDescription(new HashMap<>()); + } + + public String getErrorDescription(Map customDescriptions) { + String message; + if(customDescriptions.containsKey(error)) { + message = customDescriptions.get(error); + } else if(BUILTIN_DESCRIPTIONS.containsKey(error)) { + message = BUILTIN_DESCRIPTIONS.get(error); + } else { + return error; + } + for(String key : errorMeta.keySet()) { + message = message.replace("{" + key + "}", errorMeta.get(key)); + } + return message; + } + + public static Map> groupErrors(List errors) { + Map> errorMap = new HashMap<>(); + for(SchemaValidationError e : errors) { + errorMap.computeIfAbsent(e.getPath(), k -> new ArrayList<>()).add(e); + } + return errorMap; + } + +}