Check those annotations with style!
KlumCast is validator for annotation placement for Groovy based schemas. It allows to conveniently validate AST driving annotations before the actual transformation is performed and thus helps keep the transformation code clean.
KlumCast is for you if you are writing a Groovy AST transformation that is driven by annotations.
It is also relevant for extenders of an existing framework, for example a Layer3 approach with KlumDSL can hugely benefit from the usage of KlumCast.
Lets consider a an annotation that creates convenience methods to fill add entries to collection fields:
class MyClass {
@AutoAdd
List<String> names
}
Based on this setup, the following code would be generated:
void addName(String name) {
if (names == null) names = []
names.add(name)
}
void addNames(Collection<String> names) {
if (this.names == null) this.names = []
this.names.addAll(names)
}
void addNames(String... names) {
if (this.names == null) this.names = []
this.names.addAll(names)
}
A Groovy AST-Transformation generating the above code would be easy to implement. However, before actually creating the methods, it must be assured, that the field to be transformed is actually a collection. Otherwise, compilation errors or worse, runtime errors would occur.
Making this check is easy to implement, but it would clutter the transformation code as well as the javadoc of the annotation. KlumCast allows to move this check to a separate class, which is then called by the AST-Transformation. It also includes a couple of preconfigured validations that can be used out of the box.
Note that annotation, checks and transformation can be written in Java as well as in Groovy, only the actual model schema - and of course the model itself - needs to be written in Groovy.
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@KlumCastValidated
@NeedsType(Collection)
@interface AutoAdd {}
This is a lot cleaner and easier to understand, as well as less effort in implementing the transformation.
Since KlumCast derived from Klum-AST, it is a fitting example to explain the key concepts of KlumCast.
KlumDSL ist a framework to define statically typed and checked static models, i.e. glorified Pojos, by using a short, elegant DSL language, something like:
Home.Create.With {
entry("main")
entry("back")
baseStations {
hue(ip: "100.1.2.3", tokenEnv: "HUE_TOKEN")
homematic("100.1.2.3", authEnv: "HOMEMATIC_AUTH")
}
livingRoom {
light("ceiling", hueId: "1")
light("table", hueId: "2")
light("floor", hueId: "3")
rollershutter("window", type: HmIP.BROLL, hmId: "abcdef")
}
}
This model code returns an instance of our example Home, which can be consumed and used by Java and Groovy code using convenient getters or GPath notations:
myHome.livingRoom.lights.ceiling
While this barely scratches the surface of KlumAST (see Klum-AST for more details), it is sufficient to explain the key concepts of KlumCast. The most important information to take is that KlumAST makes heavy use of annotations to generate type safe methods to create the model. The schema (i.e. the defining classes, think XML and XML-Schema) of the above model could be something like:
@DSL
class Home {
Map<String, BaseStation> baseStations
Map<String, Room> rooms
@Field(members = "entry")
Map<String, Entry> entries
}
@DSL(stripSuffix = "Base")
abstract class BaseStation {
@Key String key
@Owner Home
}
@DSL
class HueBase extends BaseStation {
String ip
String tokenEnv
}
The Annotations in the above example all have to follow additional placement rules that need to be checked by the AST-Transformation consuming them:
@Field.members
is only valid on Collections and Maps@Key
and@Owner
annotations are only valid on classes annotated with@DSL
@Key
is only valid on String fields@Owner
is only valid on fields or on single argument methods@DSL.stripSuffix
is only valid on non-final classes
KlumCast ist split into two modules: klum-cast-annotations and klum-cast-compile. The annotations module contains the preconfigured validations as well as the base class for custom validations. The compile module contains the AST-Transformation that is applied to the schema.
The annotations module must be present at runtime (since the validation transformation needs access to the compiled classes of the annotations). However, the compile module only needs to be present during the compilation of the schema, but not during the compilation of the model itself, i.e. it should be a compileOnly dependency in Gradle or an optional dependency in Maven. Since the compile module contains a global AST-Transformation, it would have a slight impact on the compilation time of the model, so it should be avoided to have it present during the compilation of the model.
If a project is split into the usual three modules (annotations, ast and runtime), the klumcast-annotations module should be a regular dependency of the annotations module (compile
for Maven, api
or implementation
for Gradle), while the klumcast-compile module should be a compileOnly dependency of the ast module (optional
for Maven, compileOnly
for Gradle). See KlumAST for an example.
In order to use KlumCast on an annotation, the validation to check needs to be annotated with @KlumCastValidated
as well as the actual validation annotations as in the example above. The @KlumCastValidated
annotation is only used to mark the annotation as validation target and is not used by the AST-Transformation itself. It allows the validation transformation to easily detect the annotations to be processed.
@Target([ElementType.FIELD, ElementType.METHOD])
@Retention(RetentionPolicy.RUNTIME)
@KlumCastValidated
@NumberOfParameters(1)
@ClassNeedsAnnotation(DSL)
public @interface Field {
String[] members() default {};
}
Note that the validations to be annotated can be implemented in Java or Groovy, but the place where the annotation is used needs to be Groovy, since the AST transformation is only applied to Groovy code.
KlumCast also supports validation annotations placed on members of the validated annotation, which makes syntax more concise.
For example, the @Validate
annotation of KlumAST can be placed on classes as well as on fields and methods. Depending on the placement, only specific members of the annotation are valid:
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@KlumCastValidated
@NumberOfParameters(0)
@Documented
public @interface Validate {
@NotOn({ ElementType.METHOD , ElementType.TYPE })
Class<? extends Closure> value() default GroovyTruth.class;
@NotOn({ ElementType.METHOD , ElementType.TYPE })
String message() default "";
}
KlumCast includes a couple of validations that can be used out of the box:
Checks if the class the annotated element is part of is annotated with the given annotation.
Checks that the annotated methods has exactly the given number of parameters. Note that if the annotated element is no method, the validation is ignored.
If the validated annotation is placed on a method, the method must be static. Has no effect if the annotation is placed on any other element.
Designates that the annotated members are mutually exclusive, i.e. only one of them can be set at the same time.
Checks that the annotated method has the given return type. Note that if the annotated element is no method, the validation is ignored.
Forces the parameters of an annotated method to be of the given type. Note that if the annotated element is no method, the validation is ignored.
The annotation must only be used once per class.
The AlsoNeeds annotation is used to specify that a certain annotation member should be used together with one or more specific annotation members. It can only be used on annotation members.
The NotTogetherWith annotation is used to specify that a certain annotation member should not be used together with one or more specific annotation members. It can only be used on annotation members.
The NotOn annotation is used to specify that a certain annotation member should not be used on a specific element type. It can only be used on annotation members.
The OnlyOn annotation is used to specify that a certain annotation member should only be used on a specific element type. It can only be used on annotation members.
Validation annotations can themselves be aggregations of multiple annotations. This is useful if a combination of validations is used multiple times. Or to give an annotation a domain specific name.
Note that Target and Retention annotations are omitted in the examples below for brevity.
@KlumCastValidated
@ClassNeedsAnnotation(DSL)
@interface NeedsDslClass {}
@KlumCastValidated
@NumberOfParameters(1)
@NeedsReturnType(Void)
@interface SetterLike {}
Custom validation can be declared using the @KlumCastValidator
annotation. This annotation points to the class implementing the validation. The class must extend KlumCastCheck
and have the actual validator annotation as type parameter. This allows for easy parametrizing of the validator.
So usually, a custom validation consist of two elements, the control annotation (which is eventually placed on the target annotation to mark it as validated) and a validator class. The control annotation needs to be annotated with @KlumCastValidator
and the validator class needs to extend KlumCastCheck
.
The control annotation needs to be of Runtime retention and target only Annotations. It is annotated with @KlumCastValidator
which points to the classname or type of the validator class. The annotation should have a clear name and contain further members to parametrize the validator.
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
@KlumCastValidator("my.NameMustMatchCheck")
@interface NameMustMatch {
String value()
}
The validator class extends KlumCastCheck
and have the annotation as Type-Parameter. The actual check is usually implemented by the doCheck
method, which has access to the following information:
- the control annotation (as annotation object,
NameMustMatch
in the example above (if the KlumCastValidatior annotation is placed directly on the annotation to be validated, this can be null) - the
KlumCastValidator
annotation (which can have an additional String array to further parametrize the validator), in the example above this would be in instance of@KlumCastValidator("my.NameMustMatch")
- the annotation target, i.e. the annotated element itself as an
AnnotatedNode
instance - the annotation to validate, i.e. the annotation that is annotated with the control annotation, as an
AnnotationNode
instance - if the control annotation is placed on a member of the annotation to validate, that member's name as a String
The last to elements are Groovy-Compiler AST-Nodes.
The doCheck
should perform necessary validations and eventually either return or throw an exception. If an exception is thrown, it is converted to a compilation error.
class NameMustMatchCheck extends KlumCastCheck<NameMustMatch> {
@Override
protected void doCheck(AnnotationNode annotationToCheck, AnnotatedNode target) {
if (!target.getText().startsWith(controlAnnotation.value())) {
throw new IllegalStateException("Target element must start with ${controlAnnotation.value()}")
}
}
}
Filters can be set to determine that a check is only valid in certain condition. This can be done in three ways:
By overriding the isValidFor(AnnotatedNode target)
method, custom checks can be implemented directly in the check implementation.
The KlumCastValidator Annotation has a member validFor
of type ElementType[]
. Only if the annotated target is one of the listed types here, the Check is executed.
By annotation a member of an annotation with @Filter
, that member becomes a filter for its annotation chain. Filter members can either be
- An
ElementType[]
, in which case the filter behaves exactly asKlumCastValidator.validFor
- A Class object containing a subclass of
Filter.Function
which acts as a custom filter - A String containing the fully qualified Class-name of the filter implementation
Note that in order for a check to be executed, all checks of the annotation chain must match.
For convenience, the validator class can be implemented as inner class to the annotation itself. In that case, the alternative syntax using type
instead of value
is useful.
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
@KlumCastValidator(type = NeedsSomething.Check.class)
@interface NeedsSomething
String value()
class Check extends KlumCastCheck<NeedsSomething> {
@Override
protected void doCheck(AnnotationNode annotationToCheck, AnnotatedNode target) {
if (target.getText().equalsIgnoreCase("foo")) {
throw new IllegalStateException("must not be placed on Foos")
}
}
}
}
Note that annotations and KlumCastValidator annotations can be freely mixed, i.e. a control annotation can have multiple KlumCastValidator annotations as well as multiple control annotations (which themselves can have multiple KlumCastValidator annotations or even more control annotations). Just remember that annotations neither having
KlumCastValidated
nor KlumCastValidator
are ignored by the AST-Transformation.