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.Collections.emptyMap;
019import static java.util.Optional.ofNullable;
020import static java.util.concurrent.CompletableFuture.completedFuture;
021import static java.util.stream.Collectors.joining;
022import static java.util.stream.Collectors.toList;
023import static java.util.stream.Collectors.toSet;
024
025import java.io.ByteArrayInputStream;
026import java.io.ByteArrayOutputStream;
027import java.io.IOException;
028import java.io.InputStream;
029import java.io.StringReader;
030import java.nio.charset.StandardCharsets;
031import java.security.Principal;
032import java.util.Collection;
033import java.util.Collections;
034import java.util.List;
035import java.util.Map;
036import java.util.concurrent.CompletableFuture;
037import java.util.concurrent.CompletionStage;
038import java.util.concurrent.ExecutionException;
039import java.util.stream.Stream;
040
041import javax.annotation.PostConstruct;
042import javax.enterprise.context.ApplicationScoped;
043import javax.inject.Inject;
044import javax.json.Json;
045import javax.json.JsonObject;
046import javax.json.bind.Jsonb;
047import javax.servlet.ServletContext;
048import javax.servlet.ServletException;
049import javax.servlet.http.HttpServletRequest;
050import javax.ws.rs.HttpMethod;
051import javax.ws.rs.WebApplicationException;
052import javax.ws.rs.core.Context;
053import javax.ws.rs.core.Response;
054import javax.ws.rs.core.UriInfo;
055
056import org.apache.cxf.Bus;
057import org.apache.cxf.transport.http.DestinationRegistry;
058import org.apache.cxf.transport.servlet.ServletController;
059import org.apache.cxf.transport.servlet.servicelist.ServiceListGeneratorServlet;
060import org.talend.sdk.component.server.api.BulkReadResource;
061import org.talend.sdk.component.server.front.cxf.CxfExtractor;
062import org.talend.sdk.component.server.front.memory.InMemoryRequest;
063import org.talend.sdk.component.server.front.memory.InMemoryResponse;
064import org.talend.sdk.component.server.front.memory.MemoryInputStream;
065import org.talend.sdk.component.server.front.memory.SimpleServletConfig;
066import org.talend.sdk.component.server.front.model.BulkRequests;
067import org.talend.sdk.component.server.front.model.BulkResponses;
068import org.talend.sdk.component.server.front.model.ErrorDictionary;
069import org.talend.sdk.component.server.front.model.error.ErrorPayload;
070import org.talend.sdk.component.server.service.qualifier.ComponentServer;
071
072import lombok.extern.slf4j.Slf4j;
073
074@Slf4j
075@ApplicationScoped
076public class BulkReadResourceImpl implements BulkReadResource {
077
078    private static final CompletableFuture[] EMPTY_PROMISES = new CompletableFuture[0];
079
080    @Inject
081    private CxfExtractor cxf;
082
083    @Inject
084    private Bus bus;
085
086    @Inject
087    @Context
088    private ServletContext servletContext;
089
090    @Inject
091    @Context
092    private HttpServletRequest httpServletRequest;
093
094    @Inject
095    @Context
096    private UriInfo uriInfo;
097
098    @Inject
099    @Context
100    private HttpServletRequest request;
101
102    @Inject
103    @ComponentServer
104    private Jsonb defaultMapper;
105
106    private ServletController controller;
107
108    private final String appPrefix = "/api/v1";
109
110    private final Collection<String> blacklisted =
111            Stream.of(appPrefix + "/component/icon/", appPrefix + "/component/dependency/").collect(toSet());
112
113    private final BulkResponses.Result forbiddenInBulkModeResponse =
114            new BulkResponses.Result(Response.Status.FORBIDDEN.getStatusCode(), emptyMap(),
115                    Json.createReader(new StringReader(
116                            "{\"code\":\"UNAUTHORIZED\",\"description\":\"Forbidden endpoint in bulk mode.\"}"))
117                            .readObject());
118
119    private final BulkResponses.Result forbiddenResponse =
120            new BulkResponses.Result(Response.Status.FORBIDDEN.getStatusCode(), emptyMap(),
121                    Json.createReader(new StringReader(
122                            "{\"code\":\"UNAUTHORIZED\",\"description\":\"Secured endpoint, ensure to pass the right token.\"}"))
123                            .readObject());
124
125    private final BulkResponses.Result invalidResponse =
126            new BulkResponses.Result(Response.Status.BAD_REQUEST.getStatusCode(), emptyMap(),
127                    Json.createReader(
128                            new StringReader("{\"code\":\"UNEXPECTED\",\"description\":\"unknownEndpoint.\"}"))
129                            .readObject());
130
131    @PostConstruct
132    private void init() {
133        final DestinationRegistry registry = cxf.getRegistry();
134        controller = new ServletController(registry,
135                new SimpleServletConfig(servletContext, "Talend Component Kit Bulk Transport"),
136                new ServiceListGeneratorServlet(registry, bus));
137    }
138
139    @Override
140    public CompletionStage<BulkResponses> bulk(final BulkRequests requests) {
141        final Collection<CompletableFuture<BulkResponses.Result>> responses =
142                ofNullable(requests.getRequests()).map(Collection::stream).orElseGet(Stream::empty).map(request -> {
143                    if (isBlacklisted(request)) {
144                        return completedFuture(forbiddenInBulkModeResponse);
145                    }
146                    if (request.getPath() == null || !request.getPath().startsWith(appPrefix)
147                            || request.getPath().contains("?")) {
148                        return completedFuture(invalidResponse);
149                    }
150                    return doExecute(request, uriInfo);
151                }).collect(toList());
152        return CompletableFuture
153                .allOf(responses.toArray(EMPTY_PROMISES))
154                .handle((ignored, error) -> new BulkResponses(responses.stream().map(it -> {
155                    try {
156                        return it.get();
157                    } catch (final InterruptedException e) {
158                        Thread.currentThread().interrupt();
159                        throw new IllegalStateException(e);
160                    } catch (final ExecutionException e) {
161                        throw new WebApplicationException(Response
162                                .serverError()
163                                .entity(new ErrorPayload(ErrorDictionary.UNEXPECTED, e.getMessage()))
164                                .build());
165                    }
166                }).collect(toList())));
167    }
168
169    private boolean isBlacklisted(final BulkRequests.Request request) {
170        return blacklisted.stream().anyMatch(it -> request.getPath() == null || request.getPath().startsWith(it));
171    }
172
173    private CompletableFuture<BulkResponses.Result> doExecute(final BulkRequests.Request inputRequest,
174            final UriInfo info) {
175        final Map<String, List<String>> headers =
176                ofNullable(inputRequest.getHeaders()).orElseGet(Collections::emptyMap);
177        final String path = ofNullable(inputRequest.getPath()).map(it -> it.substring(appPrefix.length())).orElse("/");
178
179        // theoretically we should encode these params but should be ok this way for now - due to the param we can
180        // accept
181        final String queryString = ofNullable(inputRequest.getQueryParameters())
182                .map(Map::entrySet)
183                .map(Collection::stream)
184                .orElseGet(Stream::empty)
185                .flatMap(it -> ofNullable(it.getValue())
186                        .map(Collection::stream)
187                        .orElseGet(Stream::empty)
188                        .map(value -> it.getKey() + '=' + value))
189                .collect(joining("&"));
190
191        final int port = info.getBaseUri().getPort();
192        final Principal userPrincipal = request.getUserPrincipal(); // this is ap proxy so ready it early
193        final InMemoryRequest request = new InMemoryRequest(ofNullable(inputRequest.getVerb()).orElse(HttpMethod.GET),
194                headers, path, appPrefix + path, appPrefix, queryString, port < 0 ? 8080 : port, servletContext,
195                new MemoryInputStream(ofNullable(inputRequest.getPayload())
196                        .map(it -> it.getBytes(StandardCharsets.UTF_8))
197                        .map(ByteArrayInputStream::new)
198                        .map(InputStream.class::cast)
199                        .orElse(null)),
200                () -> userPrincipal, controller);
201        final BulkResponses.Result result = new BulkResponses.Result();
202        final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
203        final CompletableFuture<BulkResponses.Result> promise = new CompletableFuture<>();
204        final InMemoryResponse response = new InMemoryResponse(() -> true, () -> {
205            try {
206                JsonObject jsonObject = Json.createReader(new StringReader(outputStream.toString())).readObject();
207                result.setResponse(jsonObject);
208            } catch (Exception e) {
209                throw new RuntimeException(e);
210            }
211            promise.complete(result);
212        }, bytes -> {
213            try {
214                outputStream.write(bytes);
215            } catch (final IOException e) {
216                throw new IllegalStateException(e);
217            }
218        }, (status, responseHeaders) -> {
219            result.setStatus(status);
220            result.setHeaders(headers);
221            return "";
222        });
223        request.setResponse(response);
224        try {
225            controller.invoke(request, response);
226        } catch (final ServletException e) {
227            result.setStatus(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode());
228            result.setResponse(Json.createReader(new StringReader(defaultMapper
229                    .toJson(new ErrorPayload(ErrorDictionary.UNEXPECTED, e.getMessage()))))
230                    .readObject());
231            promise.complete(result);
232            throw new IllegalStateException(e);
233        }
234        return promise;
235    }
236}