001/**
002 * Copyright (C) 2006-2025 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) || !hasObjectParameter(m))
232                .map(m -> m + " should have an Object 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 hasObjectParameter(final Method method) {
326        return Arrays.stream(method.getParameters())
327                .filter(p -> !isPrimitiveLike(p.getType()))
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}