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.server.front;
017
018import static java.util.Arrays.asList;
019import static java.util.Optional.ofNullable;
020import static java.util.function.Function.identity;
021import static java.util.stream.Collectors.toList;
022import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE;
023
024import java.util.Collection;
025import java.util.HashMap;
026import java.util.HashSet;
027import java.util.Locale;
028import java.util.Map;
029import java.util.concurrent.CompletableFuture;
030import java.util.concurrent.CompletionStage;
031import java.util.concurrent.ExecutionException;
032import java.util.function.Predicate;
033import java.util.stream.Stream;
034
035import javax.cache.annotation.CacheDefaults;
036import javax.cache.annotation.CacheResult;
037import javax.enterprise.context.ApplicationScoped;
038import javax.inject.Inject;
039import javax.ws.rs.WebApplicationException;
040import javax.ws.rs.core.Context;
041import javax.ws.rs.core.HttpHeaders;
042import javax.ws.rs.core.Response;
043
044import org.talend.sdk.component.api.exception.ComponentException;
045import org.talend.sdk.component.runtime.manager.ComponentManager;
046import org.talend.sdk.component.runtime.manager.ContainerComponentRegistry;
047import org.talend.sdk.component.runtime.manager.ServiceMeta;
048import org.talend.sdk.component.server.api.ActionResource;
049import org.talend.sdk.component.server.dao.ComponentActionDao;
050import org.talend.sdk.component.server.extension.api.action.Action;
051import org.talend.sdk.component.server.front.model.ActionItem;
052import org.talend.sdk.component.server.front.model.ActionList;
053import org.talend.sdk.component.server.front.model.ErrorDictionary;
054import org.talend.sdk.component.server.front.model.error.ErrorPayload;
055import org.talend.sdk.component.server.front.security.SecurityUtils;
056import org.talend.sdk.component.server.service.ExtensionComponentMetadataManager;
057import org.talend.sdk.component.server.service.LocaleMapper;
058import org.talend.sdk.component.server.service.PropertiesService;
059import org.talend.sdk.component.server.service.httpurlconnection.IgnoreNetAuthenticator;
060import org.talend.sdk.component.server.service.jcache.FrontCacheKeyGenerator;
061import org.talend.sdk.component.server.service.jcache.FrontCacheResolver;
062
063import lombok.extern.slf4j.Slf4j;
064
065@Slf4j
066@ApplicationScoped
067@IgnoreNetAuthenticator
068@CacheDefaults(cacheResolverFactory = FrontCacheResolver.class, cacheKeyGenerator = FrontCacheKeyGenerator.class)
069public class ActionResourceImpl implements ActionResource {
070
071    @Inject
072    private ComponentManager manager;
073
074    @Inject
075    private ComponentActionDao actionDao;
076
077    @Inject
078    private PropertiesService propertiesService;
079
080    @Inject
081    private LocaleMapper localeMapper;
082
083    @Inject
084    private ExtensionComponentMetadataManager virtualActions;
085
086    @Inject
087    @Context
088    private HttpHeaders headers;
089
090    @Inject
091    private SecurityUtils secUtils;
092
093    @Override
094    public CompletionStage<Response> execute(final String family, final String type, final String action,
095            final String lang, final Map<String, String> params) {
096        return virtualActions
097                .getAction(family, type, action)
098                .map(it -> it.getHandler().apply(params, lang).exceptionally(this::onError))
099                .orElseGet(() -> doExecuteLocalAction(family, type, action, lang, params));
100    }
101
102    @Override
103    @CacheResult
104    public ActionList getIndex(final String[] types, final String[] families, final String language) {
105        final Predicate<String> typeMatcher = new Predicate<String>() {
106
107            private final Collection<String> accepted = new HashSet<>(asList(types));
108
109            @Override
110            public boolean test(final String type) {
111                return accepted.isEmpty() || accepted.contains(type);
112            }
113        };
114        final Predicate<String> componentMatcher = new Predicate<String>() {
115
116            private final Collection<String> accepted = new HashSet<>(asList(families));
117
118            @Override
119            public boolean test(final String family) {
120                return accepted.isEmpty() || accepted.contains(family);
121            }
122        };
123        final Locale locale = localeMapper.mapLocale(language);
124        return new ActionList(Stream
125                .concat(findDeployedActions(typeMatcher, componentMatcher, locale),
126                        findVirtualActions(typeMatcher, componentMatcher, locale))
127                .collect(toList()));
128    }
129
130    private CompletableFuture<Response> doExecuteLocalAction(final String family, final String type,
131            final String action, final String lang, final Map<String, String> params) {
132        return CompletableFuture.supplyAsync(() -> {
133            if (action == null) {
134                throw new WebApplicationException(Response
135                        .status(Response.Status.BAD_REQUEST)
136                        .entity(new ErrorPayload(ErrorDictionary.ACTION_MISSING, "Action can't be null"))
137                        .build());
138            }
139            if (type == null) {
140                throw new WebApplicationException(Response
141                        .status(Response.Status.BAD_REQUEST)
142                        .entity(new ErrorPayload(ErrorDictionary.TYPE_MISSING, "Type can't be null"))
143                        .build());
144            }
145            if (family == null) {
146                throw new WebApplicationException(Response
147                        .status(Response.Status.BAD_REQUEST)
148                        .entity(new ErrorPayload(ErrorDictionary.FAMILY_MISSING, "Family can't be null"))
149                        .build());
150            }
151            final ServiceMeta.ActionMeta actionMeta = actionDao.findBy(family, type, action);
152            if (actionMeta == null) {
153                throw new WebApplicationException(Response
154                        .status(Response.Status.NOT_FOUND)
155                        .entity(new ErrorPayload(ErrorDictionary.ACTION_MISSING, "No action with id '" + action + "'"))
156                        .build());
157            }
158            try {
159                final Map<String, String> runtimeParams = ofNullable(params).map(HashMap::new).orElseGet(HashMap::new);
160                runtimeParams.put("$lang", localeMapper.mapLocale(lang).getLanguage());
161                String tenant;
162                try {
163                    tenant = headers.getHeaderString("x-talend-tenant-id");
164                } catch (Exception e) {
165                    log.debug("[doExecuteLocalAction] context not applicable: {}", e.getMessage());
166                    tenant = null;
167                }
168                final Map<String, String> deciphered = secUtils.decrypt(actionMeta.getParameters()
169                        .get(), runtimeParams, tenant);
170                final Object result = actionMeta.getInvoker().apply(deciphered);
171                return Response.ok(result).type(APPLICATION_JSON_TYPE).build();
172            } catch (final RuntimeException re) {
173                return onError(re);
174            }
175            // synchronous, if needed we can move to async with timeout later but currently we don't want.
176            // check org.talend.sdk.component.server.service.ComponentManagerService.readCurrentLocale if you change it
177        }, Runnable::run).exceptionally(e -> {
178            final Throwable cause;
179            if (ExecutionException.class.isInstance(e.getCause())) {
180                cause = e.getCause().getCause();
181            } else {
182                cause = e.getCause();
183            }
184            if (WebApplicationException.class.isInstance(cause)) {
185                final WebApplicationException wae = WebApplicationException.class.cast(cause);
186                final Response response = wae.getResponse();
187                String message = "";
188                if (ErrorPayload.class.isInstance(wae.getResponse().getEntity())) {
189                    throw wae; // already logged and setup broken so just rethrow
190                } else {
191                    try {
192                        message = response.readEntity(String.class);
193                    } catch (final Exception ignored) {
194                        // no-op
195                    }
196                    if (message.isEmpty()) {
197                        message = cause.getMessage();
198                    }
199                    throw new WebApplicationException(message,
200                            Response
201                                    .status(response.getStatus())
202                                    .entity(new ErrorPayload(ErrorDictionary.UNEXPECTED, message))
203                                    .build());
204                }
205            }
206            throw new WebApplicationException(Response
207                    .status(500)
208                    .entity(new ErrorPayload(ErrorDictionary.UNEXPECTED, cause.getMessage()))
209                    .build());
210        });
211    }
212
213    private Response onError(final Throwable re) {
214        log.warn(re.getMessage(), re);
215        if (WebApplicationException.class.isInstance(re.getCause())) {
216            return WebApplicationException.class.cast(re.getCause()).getResponse();
217        }
218
219        if (ComponentException.class.isInstance(re)) {
220            final ComponentException ce = (ComponentException) re;
221            throw new WebApplicationException(Response
222                    .status(ce.getErrorOrigin() == ComponentException.ErrorOrigin.USER ? 400
223                            : ce.getErrorOrigin() == ComponentException.ErrorOrigin.BACKEND ? 456 : 520,
224                            "Unexpected callback error")
225                    .entity(new ErrorPayload(ErrorDictionary.ACTION_ERROR,
226                            "Action execution failed with: " + ofNullable(re.getMessage())
227                                    .orElseGet(() -> NullPointerException.class.isInstance(re) ? "unexpected null"
228                                            : "no error message")))
229                    .build());
230        }
231
232        throw new WebApplicationException(Response
233                .status(520, "Unexpected callback error")
234                .entity(new ErrorPayload(ErrorDictionary.ACTION_ERROR,
235                        "Action execution failed with: " + ofNullable(re.getMessage())
236                                .orElseGet(() -> NullPointerException.class.isInstance(re) ? "unexpected null"
237                                        : "no error message")))
238                .build());
239    }
240
241    private Stream<ActionItem> findVirtualActions(final Predicate<String> typeMatcher,
242            final Predicate<String> componentMatcher, final Locale locale) {
243        return virtualActions
244                .getActions()
245                .stream()
246                .filter(act -> typeMatcher.test(act.getReference().getType())
247                        && componentMatcher.test(act.getReference().getFamily()))
248                .map(Action::getReference)
249                .map(it -> new ActionItem(it.getFamily(), it.getType(), it.getName(), it.getProperties()));
250    }
251
252    private Stream<ActionItem> findDeployedActions(final Predicate<String> typeMatcher,
253            final Predicate<String> componentMatcher, final Locale locale) {
254        return manager
255                .find(c -> c
256                        .get(ContainerComponentRegistry.class)
257                        .getServices()
258                        .stream()
259                        .map(s -> s.getActions().stream())
260                        .flatMap(identity())
261                        .filter(act -> typeMatcher.test(act.getType()) && componentMatcher.test(act.getFamily()))
262                        .map(s -> new ActionItem(s.getFamily(), s.getType(), s.getAction(),
263                                propertiesService
264                                        .buildProperties(s.getParameters().get(), c.getLoader(), locale, null)
265                                        .collect(toList()))));
266    }
267}