diff --git a/spring-boot-core-starter/build.gradle b/spring-boot-core-starter/build.gradle index ed89cc77..4e2c02a0 100644 --- a/spring-boot-core-starter/build.gradle +++ b/spring-boot-core-starter/build.gradle @@ -11,5 +11,5 @@ dependencies { testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' } - testImplementation 'org.spockframework:spock-spring' + testImplementation 'org.spockframework:spock-spring:2.4-M1-groovy-4.0' } \ No newline at end of file diff --git a/spring-boot-springdoc-starter/build.gradle b/spring-boot-springdoc-starter/build.gradle index cc1102b1..c49a73b5 100644 --- a/spring-boot-springdoc-starter/build.gradle +++ b/spring-boot-springdoc-starter/build.gradle @@ -1,8 +1,9 @@ dependencies { compileOnly 'org.springframework.boot:spring-boot-starter' compileOnly 'org.springdoc:springdoc-openapi-webmvc-core:1.7.0' - implementation project(":unit-api-jackson") implementation project(":unit-api-core") + implementation project(":unit-api-jackson") + implementation project(":unit-api-validation") implementation project(":spring-boot-core-starter") testImplementation 'org.springdoc:springdoc-openapi-webmvc-core:1.7.0' diff --git a/spring-boot-springdoc-starter/src/main/java/com/raynigon/unit/api/springdoc/UnitApiPropertyCustomizer.java b/spring-boot-springdoc-starter/src/main/java/com/raynigon/unit/api/springdoc/UnitApiPropertyCustomizer.java index 9ddfef52..198d3d32 100644 --- a/spring-boot-springdoc-starter/src/main/java/com/raynigon/unit/api/springdoc/UnitApiPropertyCustomizer.java +++ b/spring-boot-springdoc-starter/src/main/java/com/raynigon/unit/api/springdoc/UnitApiPropertyCustomizer.java @@ -5,18 +5,23 @@ import com.raynigon.unit.api.core.service.UnitsApiService; import com.raynigon.unit.api.jackson.annotation.JsonUnit; import com.raynigon.unit.api.jackson.annotation.JsonUnitHelper; +import com.raynigon.unit.api.validation.annotation.UnitMax; +import com.raynigon.unit.api.validation.annotation.UnitMin; +import com.raynigon.unit.api.validation.validator.AbstractUnitValidator; import io.swagger.v3.core.converter.AnnotatedType; import io.swagger.v3.oas.models.media.NumberSchema; import io.swagger.v3.oas.models.media.Schema; +import java.lang.annotation.Annotation; import java.lang.reflect.Type; -import java.util.HashMap; -import java.util.Map; +import java.util.*; +import java.util.stream.Collectors; import java.util.stream.Stream; import javax.measure.Quantity; import javax.measure.Unit; import io.swagger.v3.oas.models.media.StringSchema; +import lombok.val; import org.springdoc.core.customizers.PropertyCustomizer; public class UnitApiPropertyCustomizer implements PropertyCustomizer { @@ -26,6 +31,7 @@ public class UnitApiPropertyCustomizer implements PropertyCustomizer { public Schema customize(Schema property, AnnotatedType type) { if (isApplicable(type.getType())) { Unit unit = resolveUnit(type); + Set constraints = resolveConstraints(type); QuantityShape shape = resolveShape(type); switch (shape) { case OBJECT: @@ -42,19 +48,13 @@ public Schema customize(Schema property, AnnotatedType type) { property.setType("string"); property.setProperties(null); } - property.setDescription( - buildDescription(type.getPropertyName(), unit, property.getDescription())); + String description = buildDescription(type, property, unit, constraints); + property.setDescription(description); property.setExample("1" + (unit.getSymbol() != null ? " " + unit.getSymbol() : "")); } return property; } - private QuantityShape resolveShape(AnnotatedType type) { - JsonUnit jsonUnit = resolveJsonUnit(type); - if (jsonUnit == null) return QuantityShape.NUMBER; - return JsonUnitHelper.getShape(jsonUnit); - } - @SuppressWarnings({"rawtypes", "unchecked"}) private Unit resolveUnit(AnnotatedType type) { @@ -79,12 +79,26 @@ private JsonUnit resolveJsonUnit(AnnotatedType type) { return jsonUnit; } + private QuantityShape resolveShape(AnnotatedType type) { + JsonUnit jsonUnit = resolveJsonUnit(type); + if (jsonUnit == null) return QuantityShape.NUMBER; + return JsonUnitHelper.getShape(jsonUnit); + } + + private Set resolveConstraints(AnnotatedType type) { + return Arrays.stream(type.getCtxAnnotations()) + .filter(it -> UnitMin.class.isAssignableFrom(it.annotationType()) || UnitMax.class.isAssignableFrom(it.annotationType())) + .collect(Collectors.toSet()); + } + private boolean isApplicable(Type type) { return (type instanceof SimpleType) && Quantity.class.isAssignableFrom(((SimpleType) type).getRawClass()); } - private String buildDescription(String name, Unit unit, String description) { + private String buildDescription(AnnotatedType type, Schema property, Unit unit, Set constraints) { + String name = type.getPropertyName(); + String description = property.getDescription(); String result = ""; if (unit != null) { String unitName = unit.getName(); @@ -96,8 +110,31 @@ private String buildDescription(String name, Unit unit, String description) { result += " (" + unitSymbol + ")"; } } + if (constraints != null) { + if (constraints.size() == 2) { + // Case 1: UnitMin and UnitMax are present in the constraints set + UnitMin min = getAnnotation(constraints, UnitMin.class); + Unit minUnit = AbstractUnitValidator.createUnit(min.unit()); + UnitMax max = getAnnotation(constraints, UnitMax.class); + Unit maxUnit = AbstractUnitValidator.createUnit(max.unit()); + + result += " and must be between " + min.value() + " " + minUnit.getSymbol() + " and " + max.value() + " " + maxUnit.getSymbol(); + } else if (constraints.stream().anyMatch(it -> UnitMin.class.isAssignableFrom(it.annotationType()))) { + // Case 2: UnitMin is present in the constraints set + UnitMin min = getAnnotation(constraints, UnitMin.class); + Unit minUnit = AbstractUnitValidator.createUnit(min.unit()); + + result += " and must be greater than " + min.value() + " " + minUnit.getSymbol(); + } else if (constraints.stream().anyMatch(it -> UnitMax.class.isAssignableFrom(it.annotationType()))) { + // Case 2: UnitMax is present in the constraints set + UnitMax max = getAnnotation(constraints, UnitMax.class); + Unit maxUnit = AbstractUnitValidator.createUnit(max.unit()); + + result += " and must be less than " + max.value() + " " + maxUnit.getSymbol(); + } + } if (description != null) { - if (!result.equals("")) { + if (!result.isEmpty()) { result += "\n"; } result += description; @@ -105,6 +142,14 @@ private String buildDescription(String name, Unit unit, String description) { return result; } + @SuppressWarnings("unchecked") + private T getAnnotation(Collection events, Class clazz) { + return (T) events.stream() + .filter(it -> clazz.isAssignableFrom(it.annotationType())) + .findFirst() + .orElse(null); + } + private Map> buildQuantityObjectProperties() { Map> properties = new HashMap<>(); NumberSchema value = new NumberSchema(); diff --git a/spring-boot-springdoc-starter/src/test/groovy/com/raynigon/unit/api/springdoc/UnitApiPropertyCustomizerSpec.groovy b/spring-boot-springdoc-starter/src/test/groovy/com/raynigon/unit/api/springdoc/UnitApiPropertyCustomizerSpec.groovy index 018a73fd..a19f443b 100644 --- a/spring-boot-springdoc-starter/src/test/groovy/com/raynigon/unit/api/springdoc/UnitApiPropertyCustomizerSpec.groovy +++ b/spring-boot-springdoc-starter/src/test/groovy/com/raynigon/unit/api/springdoc/UnitApiPropertyCustomizerSpec.groovy @@ -4,8 +4,11 @@ import com.fasterxml.jackson.databind.JavaType import com.fasterxml.jackson.databind.type.SimpleType import com.fasterxml.jackson.databind.type.TypeBindings import com.raynigon.unit.api.core.annotation.QuantityShape +import com.raynigon.unit.api.core.units.si.power.Watt import com.raynigon.unit.api.core.units.si.speed.KilometrePerHour import com.raynigon.unit.api.jackson.annotation.JsonUnit +import com.raynigon.unit.api.validation.annotation.UnitMax +import com.raynigon.unit.api.validation.annotation.UnitMin import io.swagger.v3.core.converter.AnnotatedType import io.swagger.v3.oas.models.media.NumberSchema import io.swagger.v3.oas.models.media.Schema @@ -67,6 +70,7 @@ class UnitApiPropertyCustomizerSpec extends Specification { annotatedType.ctxAnnotations([ jsonUnit ] as Annotation[]) + jsonUnit.annotationType() >> JsonUnit.class jsonUnit.value() >> JsonUnit.NoneUnit.class jsonUnit.shape() >> QuantityShape.STRING @@ -102,6 +106,7 @@ class UnitApiPropertyCustomizerSpec extends Specification { annotatedType.ctxAnnotations([ jsonUnit ] as Annotation[]) + jsonUnit.annotationType() >> JsonUnit.class jsonUnit.value() >> JsonUnit.NoneUnit.class jsonUnit.shape() >> QuantityShape.OBJECT @@ -140,6 +145,7 @@ class UnitApiPropertyCustomizerSpec extends Specification { annotatedType.ctxAnnotations([ jsonUnit ] as Annotation[]) + jsonUnit.annotationType() >> JsonUnit.class jsonUnit.value() >> JsonUnit.NoneUnit.class jsonUnit.shape() >> QuantityShape.STRING @@ -159,4 +165,158 @@ class UnitApiPropertyCustomizerSpec extends Specification { "" | "speed is given in Kilometre per Hour (km/h)\n" "test" | "speed is given in Kilometre per Hour (km/h)\ntest" } + + def 'convert quantity with annotation and min'() { + + given: + def customizer = new UnitApiPropertyCustomizer() + def property = new Schema() + property.name = "speed" + + and: + def annotatedType = new AnnotatedType() + annotatedType.propertyName = "speed" + annotatedType.type = new SimpleType( + Quantity.class, + TypeBindings.create( + Quantity.class, + [new SimpleType(Speed.class)] as JavaType[], + ), + null, + null + ) + and: + JsonUnit jsonUnit = Mock(JsonUnit) + jsonUnit.annotationType() >> JsonUnit.class + jsonUnit.value() >> JsonUnit.NoneUnit.class + jsonUnit.shape() >> QuantityShape.STRING + + and: + UnitMin unitMin = Mock(UnitMin) + unitMin.annotationType() >> UnitMin.class + unitMin.value() >> 0.0 + unitMin.unit() >> Watt.class + + and: + annotatedType.ctxAnnotations([ + jsonUnit, + unitMin + ] as Annotation[]) + + + when: + def result = customizer.customize(property, annotatedType) + + then: + 2 * jsonUnit.unit() >> KilometrePerHour.class + + and: + result.type == "string" + result.description == "speed is given in Kilometre per Hour (km/h) and must be greater than 0.0 W" + } + + def 'convert quantity with annotation and max'() { + + given: + def customizer = new UnitApiPropertyCustomizer() + def property = new Schema() + property.name = "speed" + + and: + def annotatedType = new AnnotatedType() + annotatedType.propertyName = "speed" + annotatedType.type = new SimpleType( + Quantity.class, + TypeBindings.create( + Quantity.class, + [new SimpleType(Speed.class)] as JavaType[], + ), + null, + null + ) + and: + JsonUnit jsonUnit = Mock(JsonUnit) + jsonUnit.annotationType() >> JsonUnit.class + jsonUnit.value() >> JsonUnit.NoneUnit.class + jsonUnit.shape() >> QuantityShape.STRING + + and: + UnitMax unitMax = Mock(UnitMax) + unitMax.annotationType() >> unitMax.class + unitMax.value() >> 10.0 + unitMax.unit() >> Watt.class + + and: + annotatedType.ctxAnnotations([ + jsonUnit, + unitMax + ] as Annotation[]) + + + when: + def result = customizer.customize(property, annotatedType) + + then: + 2 * jsonUnit.unit() >> KilometrePerHour.class + + and: + result.type == "string" + result.description == "speed is given in Kilometre per Hour (km/h) and must be less than 10.0 W" + } + + def 'convert quantity with annotation and min + max'() { + + given: + def customizer = new UnitApiPropertyCustomizer() + def property = new Schema() + property.name = "speed" + + and: + def annotatedType = new AnnotatedType() + annotatedType.propertyName = "speed" + annotatedType.type = new SimpleType( + Quantity.class, + TypeBindings.create( + Quantity.class, + [new SimpleType(Speed.class)] as JavaType[], + ), + null, + null + ) + and: + JsonUnit jsonUnit = Mock(JsonUnit) + jsonUnit.annotationType() >> JsonUnit.class + jsonUnit.value() >> JsonUnit.NoneUnit.class + jsonUnit.shape() >> QuantityShape.STRING + + and: + UnitMin unitMin = Mock(UnitMin) + unitMin.annotationType() >> UnitMin.class + unitMin.value() >> 0.0 + unitMin.unit() >> Watt.class + + and: + UnitMax unitMax = Mock(UnitMax) + unitMax.annotationType() >> UnitMax.class + unitMax.value() >> 10.0 + unitMax.unit() >> Watt.class + + and: + annotatedType.ctxAnnotations([ + jsonUnit, + unitMin, + unitMax + ] as Annotation[]) + + + when: + def result = customizer.customize(property, annotatedType) + + then: + 2 * jsonUnit.unit() >> KilometrePerHour.class + + and: + result.type == "string" + result.description == "speed is given in Kilometre per Hour (km/h) and must be between 0.0 W and 10.0 W" + } } diff --git a/unit-api-validation/src/main/java/com/raynigon/unit/api/validation/validator/AbstractUnitValidator.java b/unit-api-validation/src/main/java/com/raynigon/unit/api/validation/validator/AbstractUnitValidator.java index a4e7f579..a5151787 100644 --- a/unit-api-validation/src/main/java/com/raynigon/unit/api/validation/validator/AbstractUnitValidator.java +++ b/unit-api-validation/src/main/java/com/raynigon/unit/api/validation/validator/AbstractUnitValidator.java @@ -10,7 +10,7 @@ import java.lang.reflect.InvocationTargetException; import java.util.function.BiPredicate; -abstract class AbstractUnitValidator +abstract public class AbstractUnitValidator implements ConstraintValidator> { private Unit unit = null; @@ -35,7 +35,7 @@ protected boolean check(ConstraintValidatorContext context, Quantity quantity protected abstract double getValue(A constraintAnnotation); - private static Unit createUnit(Class> unitType) { + public static Unit createUnit(Class> unitType) { try { Constructor> ctor = unitType.getConstructor(); return ctor.newInstance();