001/** 002 * Copyright (C) 2006-2024 Talend Inc. - www.talend.com 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.talend.sdk.component.tools.validator; 017 018import static java.util.function.Function.identity; 019import static java.util.stream.Collectors.toMap; 020 021import java.lang.reflect.Field; 022import java.lang.reflect.Method; 023import java.lang.reflect.Modifier; 024import java.lang.reflect.Parameter; 025import java.lang.reflect.ParameterizedType; 026import java.lang.reflect.Type; 027import java.util.Arrays; 028import java.util.List; 029import java.util.Map; 030import java.util.Objects; 031import java.util.Set; 032import java.util.stream.Collectors; 033import java.util.stream.Stream; 034 035import org.apache.xbean.finder.AnnotationFinder; 036import org.talend.sdk.component.api.configuration.Option; 037import org.talend.sdk.component.api.configuration.action.Proposable; 038import org.talend.sdk.component.api.configuration.action.Updatable; 039import org.talend.sdk.component.api.configuration.type.DataSet; 040import org.talend.sdk.component.api.configuration.type.DataStore; 041import org.talend.sdk.component.api.record.Schema; 042import org.talend.sdk.component.api.service.ActionType; 043import org.talend.sdk.component.api.service.Service; 044import org.talend.sdk.component.api.service.completion.DynamicValues; 045import org.talend.sdk.component.api.service.dependency.DynamicDependencies; 046import org.talend.sdk.component.api.service.discovery.DiscoverDataset; 047import org.talend.sdk.component.api.service.healthcheck.HealthCheck; 048import org.talend.sdk.component.api.service.schema.DiscoverSchema; 049import org.talend.sdk.component.api.service.schema.DiscoverSchemaExtended; 050import org.talend.sdk.component.api.service.update.Update; 051import org.talend.sdk.component.tools.validator.Validators.ValidatorHelper; 052 053public class ActionValidator implements Validator { 054 055 private final Validators.ValidatorHelper helper; 056 057 public ActionValidator(final ValidatorHelper helper) { 058 this.helper = helper; 059 } 060 061 @Override 062 public Stream<String> validate(final AnnotationFinder finder, final List<Class<?>> components) { 063 // returned types 064 final Stream<String> actionType = this.checkActionType(finder); 065 066 // parameters for @DynamicValues 067 final Stream<String> actionWithoutParameter = finder 068 .findAnnotatedMethods(DynamicValues.class) 069 .stream() 070 .filter(m -> countParameters(m) != 0) 071 .map(m -> m + " should have no parameter") 072 .sorted(); 073 074 // parameters for @HealthCheck 075 final Stream<String> health = finder 076 .findAnnotatedMethods(HealthCheck.class) 077 .stream() 078 .filter(m -> countParameters(m) != 1 || !m.getParameterTypes()[0].isAnnotationPresent(DataStore.class)) 079 .map(m -> m + " should have its first parameter being a datastore (marked with @DataStore)") 080 .sorted(); 081 082 // Discover dataset 083 final Stream<String> datasetDiscover = finder 084 .findAnnotatedMethods(DiscoverDataset.class) 085 .stream() 086 .filter(m -> countParameters(m) != 1 || !m.getParameterTypes()[0].isAnnotationPresent(DataStore.class)) 087 .map(m -> m + " should have a datastore as first parameter (marked with @DataStore)") 088 .sorted(); 089 090 // parameters for @DiscoverSchema 091 final Stream<String> discover = finder 092 .findAnnotatedMethods(DiscoverSchema.class) 093 .stream() 094 .filter(m -> countParameters(m) != 1 || !m.getParameterTypes()[0].isAnnotationPresent(DataSet.class)) 095 .map(m -> m + " should have its first parameter being a dataset (marked with @DataSet)") 096 .sorted(); 097 098 // parameters for @DiscoverSchemaExtended 099 final Stream<String> discoverProcessor = findDiscoverSchemaExtendedErrors(finder); 100 101 // parameters for @DynamicDependencies 102 final Stream<String> dynamicDependencyErrors = findDynamicDependenciesErrors(finder); 103 104 // returned type for @Update, for now limit it on objects and not primitives 105 final Stream<String> updatesErrors = this.findUpdatesErrors(finder); 106 107 final Stream<String> enumProposable = finder 108 .findAnnotatedFields(Proposable.class) 109 .stream() 110 .filter(f -> f.getType().isEnum()) 111 .map(f -> f.toString() + " must not define @Proposable since it is an enum") 112 .sorted(); 113 114 final Set<String> proposables = finder 115 .findAnnotatedFields(Proposable.class) 116 .stream() 117 .map(f -> f.getAnnotation(Proposable.class).value()) 118 .collect(Collectors.toSet()); 119 final Set<String> dynamicValues = finder 120 .findAnnotatedMethods(DynamicValues.class) 121 .stream() 122 .map(f -> f.getAnnotation(DynamicValues.class).value()) 123 .collect(Collectors.toSet()); 124 proposables.removeAll(dynamicValues); 125 126 final Stream<String> proposableWithoutDynamic = proposables 127 .stream() 128 .map(p -> "No @DynamicValues(\"" + p + "\"), add a service with this method: " + "@DynamicValues(\"" + p 129 + "\") Values proposals();") 130 .sorted(); 131 132 return Stream 133 .of(actionType, // 134 actionWithoutParameter, // 135 health, // 136 datasetDiscover, // 137 discover, // 138 discoverProcessor, // 139 dynamicDependencyErrors, // 140 updatesErrors, // 141 enumProposable, // 142 proposableWithoutDynamic) // 143 .reduce(Stream::concat) 144 .orElseGet(Stream::empty); 145 146 } 147 148 private Stream<String> checkActionType(final AnnotationFinder finder) { 149 return Validators.getActionsStream().flatMap(action -> { 150 final Class<?> returnedType = action.getAnnotation(ActionType.class).expectedReturnedType(); 151 final List<Method> annotatedMethods = finder.findAnnotatedMethods(action); 152 return Stream 153 .concat(annotatedMethods 154 .stream() 155 .filter(m -> !returnedType.isAssignableFrom(m.getReturnType())) 156 .map(m -> m + " doesn't return a " + returnedType + ", please fix it"), 157 annotatedMethods 158 .stream() 159 .filter(m -> !m.getDeclaringClass().isAnnotationPresent(Service.class) 160 && !Modifier.isAbstract(m.getDeclaringClass().getModifiers())) 161 .map(m -> m + " is not declared into a service class")); 162 }).sorted(); 163 } 164 165 /** 166 * Checks method signature for @DiscoverSchemaExtended annotation. 167 * Valid signatures are: 168 * <ul> 169 * <li>public Schema guessMethodName(final Schema incomingSchema, final @Option("configuration") procConf, final 170 * String branch)</li> 171 * <li>public Schema guessMethodName(final Schema incomingSchema, final @Option("configuration") procConf)</li> 172 * <li>public Schema guessMethodName(final @Option("configuration") procConf, final String branch)</li> 173 * <li>public Schema guessMethodName(final @Option("configuration") procConf)</li> 174 * </ul> 175 * 176 * @param finder 177 * @return Errors on @DiscoverSchemaExtended method 178 */ 179 private Stream<String> findDiscoverSchemaExtendedErrors(final AnnotationFinder finder) { 180 181 final Stream<String> optionParameter = finder 182 .findAnnotatedMethods(DiscoverSchemaExtended.class) 183 .stream() 184 .filter(m -> !hasOption(m)) 185 .map(m -> m + " should have a parameter being an option (marked with @Option)") 186 .sorted(); 187 188 final Stream<String> returnType = finder 189 .findAnnotatedMethods(DiscoverSchemaExtended.class) 190 .stream() 191 .filter(m -> !hasCorrectReturnType(m)) 192 .map(m -> m + " should return a Schema assignable") 193 .sorted(); 194 195 final Stream<String> incomingSchema = finder 196 .findAnnotatedMethods(DiscoverSchemaExtended.class) 197 .stream() 198 .filter(m -> hasTypeParameter(m, Schema.class)) 199 .filter(m -> !hasSchemaCorrectNaming(m)) 200 .map(m -> m + " should have its Schema `incomingSchema' parameter named `incomingSchema'") 201 .sorted(); 202 203 final Stream<String> branch = finder 204 .findAnnotatedMethods(DiscoverSchemaExtended.class) 205 .stream() 206 .filter(m -> hasTypeParameter(m, String.class)) 207 .filter(m -> !hasBranchCorrectNaming(m)) 208 .map(m -> m + " should have its String `branch' parameter named `branch'") 209 .sorted(); 210 211 return Stream.of(returnType, optionParameter, incomingSchema, branch) 212 .reduce(Stream::concat) 213 .orElseGet(Stream::empty); 214 } 215 216 /** 217 * Checks method signature for @DynamicDependencies annotation. 218 * Valid signatures are: 219 * <ul> 220 * <li>public List<String> getDependencies(@Option("configuration") final TheDataset dataset)</li> 221 * </ul> 222 * 223 * @param finder 224 * @return Errors on @DynamicDependencies method 225 */ 226 private Stream<String> findDynamicDependenciesErrors(final AnnotationFinder finder) { 227 228 final Stream<String> optionParameter = finder 229 .findAnnotatedMethods(DynamicDependencies.class) 230 .stream() 231 .filter(m -> !hasOption(m) || !hasDatasetParameter(m)) 232 .map(m -> m + " should have a Dataset parameter marked with @Option") 233 .sorted(); 234 235 final Stream<String> returnType = finder 236 .findAnnotatedMethods(DynamicDependencies.class) 237 .stream() 238 .filter(m -> !hasStringInList(m)) 239 .map(m -> m + " should return List<String>") 240 .sorted(); 241 242 return Stream.of(returnType, optionParameter) 243 .reduce(Stream::concat) 244 .orElseGet(Stream::empty); 245 } 246 247 private Stream<String> findUpdatesErrors(final AnnotationFinder finder) { 248 final Map<String, Method> updates = finder 249 .findAnnotatedMethods(Update.class) 250 .stream() 251 .collect(toMap(m -> m.getAnnotation(Update.class).value(), identity())); 252 final Stream<String> updateAction = updates 253 .values() 254 .stream() 255 .filter(m -> isPrimitiveLike(m.getReturnType())) 256 .map(m -> m + " should return an object") 257 .sorted(); 258 259 final List<Field> updatableFields = finder.findAnnotatedFields(Updatable.class); 260 final Stream<String> directChild = updatableFields 261 .stream() 262 .filter(f -> f.getAnnotation(Updatable.class).after().contains(".") /* no '..' or '.' */) 263 .map(f -> "@Updatable.after should only reference direct child primitive fields") 264 .sorted(); 265 266 final Stream<String> noPrimitive = updatableFields 267 .stream() 268 .filter(f -> isPrimitiveLike(f.getType())) 269 .map(f -> "@Updatable should not be used on primitives: " + f) 270 .sorted(); 271 272 final Stream<String> serviceType = updatableFields.stream().map(f -> { 273 final Method service = updates.get(f.getAnnotation(Updatable.class).value()); 274 if (service == null) { 275 return null; // another error will mention it 276 } 277 if (f.getType().isAssignableFrom(service.getReturnType())) { 278 return null; // no error 279 } 280 return "@Updatable field '" + f + "' does not match returned type of '" + service + "'"; 281 }).filter(Objects::nonNull).sorted(); 282 283 final Stream<String> noFieldUpdatable = updatableFields 284 .stream() 285 .filter(f -> updates.get(f.getAnnotation(Updatable.class).value()) == null) 286 .map(f -> "No @Update service found for field " + f + ", did you intend to use @Updatable?") 287 .sorted(); 288 return Stream 289 .of(updateAction, directChild, noPrimitive, serviceType, noFieldUpdatable) 290 .reduce(Stream::concat) 291 .orElseGet(Stream::empty); 292 } 293 294 private int countParameters(final Method m) { 295 return countParameters(m.getParameters()); 296 } 297 298 private int countParameters(final Parameter[] params) { 299 return (int) Stream.of(params).filter(p -> !this.helper.isService(p)).count(); 300 } 301 302 private boolean isPrimitiveLike(final Class<?> type) { 303 return type.isPrimitive() || type == String.class; 304 } 305 306 private boolean hasOption(final Method method) { 307 return Arrays.stream(method.getParameters()) 308 .filter(p -> p.isAnnotationPresent(Option.class)) 309 .count() == 1; 310 } 311 312 private boolean hasTypeParameter(final Method method, final Class<?> clazz) { 313 return Arrays.stream(method.getParameters()) 314 .filter(p -> clazz.isAssignableFrom(p.getType())) 315 .count() == 1; 316 } 317 318 private boolean hasSchemaCorrectNaming(final Method method) { 319 return Arrays.stream(method.getParameters()) 320 .filter(p -> Schema.class.isAssignableFrom(p.getType())) 321 .filter(p -> "incomingSchema".equals(p.getName())) 322 .count() == 1; 323 } 324 325 private boolean hasDatasetParameter(final Method method) { 326 return Arrays.stream(method.getParameters()) 327 .filter(p -> p.getType().isAnnotationPresent(DataSet.class)) 328 .count() == 1; 329 } 330 331 private boolean hasBranchCorrectNaming(final Method method) { 332 return Arrays.stream(method.getParameters()) 333 .filter(p -> String.class.isAssignableFrom(p.getType())) 334 .filter(p -> "branch".equals(p.getName())) 335 .count() == 1; 336 } 337 338 private boolean hasCorrectReturnType(final Method method) { 339 return Schema.class.isAssignableFrom(method.getReturnType()); 340 } 341 342 private boolean hasStringInList(final Method method) { 343 if (List.class.isAssignableFrom(method.getReturnType()) 344 && method.getGenericReturnType() instanceof ParameterizedType) { 345 Type[] actualTypeArguments = ((ParameterizedType) method.getGenericReturnType()).getActualTypeArguments(); 346 if (actualTypeArguments.length > 0) { 347 return "java.lang.String".equals(actualTypeArguments[0].getTypeName()); 348 } 349 } 350 return false; 351 } 352}