ConnectorExecutionEngine.java

package com.bonitasoft.processbuilder.execution;

import com.bonitasoft.processbuilder.enums.RestHttpMethod;
import com.bonitasoft.processbuilder.records.RestAuthConfig;
import com.bonitasoft.processbuilder.records.RestServiceRequest;
import com.bonitasoft.processbuilder.records.RestServiceResponse;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Facade for executing REST connectors.
 * <p>
 * This is the single entry point used by both consumers:
 * </p>
 * <ul>
 *   <li><b>REST API Extension</b> (ExecuteRestService controller)</li>
 *   <li><b>Custom Bonita Connector</b> (RestExecutionConnector in RestAPIConnector process)</li>
 * </ul>
 * <p>
 * The engine parses the PBConfiguration JSON, resolves the method template,
 * substitutes parameters, normalizes + decrypts authentication, and executes
 * the HTTP call via {@link HttpExecutor}.
 * </p>
 */
public final class ConnectorExecutionEngine {

    private static final Logger LOGGER = LoggerFactory.getLogger(ConnectorExecutionEngine.class);
    private static final ObjectMapper MAPPER = new ObjectMapper();

    private final HttpExecutor httpExecutor;

    public ConnectorExecutionEngine() {
        this.httpExecutor = new HttpExecutor();
    }

    public ConnectorExecutionEngine(HttpExecutor httpExecutor) {
        this.httpExecutor = httpExecutor;
    }

    /**
     * Executes a REST connector request.
     *
     * @param request The connector request containing configJson, methodName, params, etc.
     * @return The connector response with success/error, statusCode, responseBody, etc.
     */
    public ConnectorResponse execute(ConnectorRequest request) {
        long startTime = System.currentTimeMillis();

        try {
            LOGGER.info("ConnectorExecutionEngine: executing actionType={}, methodName={}",
                    request.actionType(), request.methodName());

            // 1. Parse the PBConfiguration JSON
            JsonNode configJson = MAPPER.readTree(request.configJson());

            // 2. Detect structure type and build RestServiceRequest
            boolean isNewStructure = configJson.has("baseUrl") && configJson.has("methods");
            boolean isLegacyStructure = configJson.has("url");

            RestServiceRequest.Builder builder;

            if (isNewStructure) {
                builder = buildFromNewStructure(configJson, request);
            } else if (isLegacyStructure) {
                builder = buildFromLegacyStructure(configJson, request);
            } else {
                return ConnectorResponse.error(
                        "Invalid configuration: missing 'baseUrl'+'methods' or 'url'",
                        elapsed(startTime), null);
            }

            // 3. Apply runtime overrides from ConnectorRequest
            applyOverrides(builder, request);

            RestServiceRequest restRequest = builder.build();

            // 4. Execute HTTP call
            RestServiceResponse restResponse = httpExecutor.execute(restRequest);

            // 5. Map to ConnectorResponse
            long executionTime = elapsed(startTime);

            if (restResponse.isSuccessful()) {
                return ConnectorResponse.success(
                        restResponse.statusCode(),
                        restResponse.body(),
                        restResponse.headers(),
                        executionTime,
                        restResponse.url());
            } else {
                String errorMsg = restResponse.errorMessage() != null
                        ? restResponse.errorMessage()
                        : "HTTP " + restResponse.statusCode();
                return ConnectorResponse.error(
                        restResponse.statusCode(),
                        restResponse.body(),
                        errorMsg,
                        executionTime,
                        restResponse.url());
            }

        } catch (Exception e) {
            LOGGER.error("ConnectorExecutionEngine failed: {}", e.getMessage(), e);
            return ConnectorResponse.error(e.getMessage(), elapsed(startTime), null);
        }
    }

    // ========================================================================
    // NEW structure: baseUrl + methods[]
    // ========================================================================

    private RestServiceRequest.Builder buildFromNewStructure(JsonNode configJson, ConnectorRequest request)
            throws Exception {

        String baseUrl = configJson.get("baseUrl").asText();

        // Substitute {{param}} in baseUrl
        Map<String, String> allParams = new HashMap<>(request.params());
        baseUrl = TemplateSubstitution.substitute(baseUrl, allParams);

        // Find method by methodName
        JsonNode methodsArray = configJson.get("methods");
        if (methodsArray == null || !methodsArray.isArray()) {
            throw new IllegalArgumentException("Missing or invalid 'methods' array in configuration");
        }

        String methodName = request.methodName();
        if (methodName.isEmpty()) {
            throw new IllegalArgumentException("methodName is required. Available: " + getMethodNames(methodsArray));
        }

        JsonNode methodConfig = null;
        for (JsonNode method : methodsArray) {
            if (method.has("name") && methodName.equals(method.get("name").asText())) {
                methodConfig = method;
                break;
            }
        }

        if (methodConfig == null) {
            throw new IllegalArgumentException(
                    "Method '" + methodName + "' not found. Available: " + getMethodNames(methodsArray));
        }

        // Extract HTTP method and path
        String httpMethod = methodConfig.has("httpMethod") ? methodConfig.get("httpMethod").asText() : "GET";
        String path = methodConfig.has("path") ? methodConfig.get("path").asText() : "";

        // Substitute {{param}} in path
        path = TemplateSubstitution.substitute(path, allParams);

        // Build final URL
        String finalUrl = TemplateSubstitution.buildFinalUrl(baseUrl, path);
        LOGGER.debug("Built final URL: {}", finalUrl);

        RestServiceRequest.Builder builder = RestServiceRequest.builder(finalUrl);

        // Set HTTP method
        RestHttpMethod.fromKey(httpMethod).ifPresent(builder::method);

        // Apply base configuration (auth, headers, timeout, SSL)
        applyBaseConfig(builder, configJson, allParams);

        // Apply method-specific query parameters
        if (methodConfig.has("queryParams") && methodConfig.get("queryParams").isObject()) {
            methodConfig.get("queryParams").fields().forEachRemaining(entry -> {
                String value = TemplateSubstitution.substitute(entry.getValue().asText(), allParams);
                builder.queryParam(entry.getKey(), value);
            });
        }

        // Apply method-specific headers
        if (methodConfig.has("headers") && methodConfig.get("headers").isObject()) {
            methodConfig.get("headers").fields().forEachRemaining(entry -> {
                String value = TemplateSubstitution.substitute(entry.getValue().asText(), allParams);
                builder.header(entry.getKey(), value);
            });
        }

        // Apply body template
        if (methodConfig.has("bodyTemplate") && !methodConfig.get("bodyTemplate").asText().isEmpty()) {
            String body = TemplateSubstitution.substitute(methodConfig.get("bodyTemplate").asText(), allParams);
            builder.body(body);
        }

        return builder;
    }

    // ========================================================================
    // LEGACY structure: url
    // ========================================================================

    private RestServiceRequest.Builder buildFromLegacyStructure(JsonNode configJson, ConnectorRequest request) {
        String url = configJson.get("url").asText();

        RestServiceRequest.Builder builder = RestServiceRequest.builder(url);

        // Apply method
        if (configJson.has("method")) {
            RestHttpMethod.fromKey(configJson.get("method").asText()).ifPresent(builder::method);
        }

        // Apply headers
        if (configJson.has("headers") && configJson.get("headers").isObject()) {
            Map<String, String> headers = new HashMap<>();
            configJson.get("headers").fields().forEachRemaining(entry ->
                    headers.put(entry.getKey(), entry.getValue().asText()));
            builder.headers(headers);
        }

        // Apply query params from config
        if (configJson.has("queryParams") && configJson.get("queryParams").isObject()) {
            Map<String, String> qp = new HashMap<>();
            configJson.get("queryParams").fields().forEachRemaining(entry ->
                    qp.put(entry.getKey(), entry.getValue().asText()));
            builder.queryParams(qp);
        }

        // Apply timeout
        if (configJson.has("timeoutMs")) {
            builder.timeout(configJson.get("timeoutMs").asInt());
        }

        // Apply SSL
        if (configJson.has("verifySsl")) {
            builder.verifySsl(configJson.get("verifySsl").asBoolean(true));
        }

        // Apply redirects
        if (configJson.has("followRedirects")) {
            builder.followRedirects(configJson.get("followRedirects").asBoolean(true));
        }

        // Apply auth with normalize + decrypt pipeline (FIX for legacy bug)
        if (configJson.has("auth") && configJson.get("auth").isObject()) {
            RestAuthConfig authConfig = AuthPipeline.resolve(configJson.get("auth"));
            builder.auth(authConfig);
        }

        return builder;
    }

    // ========================================================================
    // Common helpers
    // ========================================================================

    private void applyBaseConfig(RestServiceRequest.Builder builder, JsonNode configJson, Map<String, String> params) {
        if (configJson.has("timeoutMs")) {
            builder.timeout(configJson.get("timeoutMs").asInt());
        }
        if (configJson.has("verifySsl")) {
            builder.verifySsl(configJson.get("verifySsl").asBoolean(true));
        }
        if (configJson.has("followRedirects")) {
            builder.followRedirects(configJson.get("followRedirects").asBoolean(true));
        }

        // Apply base headers with template substitution
        if (configJson.has("headers") && configJson.get("headers").isObject()) {
            configJson.get("headers").fields().forEachRemaining(entry -> {
                String value = TemplateSubstitution.substitute(entry.getValue().asText(), params);
                builder.header(entry.getKey(), value);
            });
        }

        // Apply auth with full pipeline (normalize + decrypt)
        if (configJson.has("auth") && configJson.get("auth").isObject()) {
            RestAuthConfig authConfig = AuthPipeline.resolve(configJson.get("auth"));
            builder.auth(authConfig);
        }
    }

    private void applyOverrides(RestServiceRequest.Builder builder, ConnectorRequest request) {
        // Override HTTP method
        if (!request.methodOverride().isEmpty()) {
            RestHttpMethod.fromKey(request.methodOverride()).ifPresent(builder::method);
        }

        // Override body
        if (!request.body().isEmpty()) {
            builder.body(request.body());
        }

        // Override timeout
        if (request.timeoutMs() > 0) {
            builder.timeout(request.timeoutMs());
        }

        // Override SSL
        if (request.verifySsl() != null) {
            builder.verifySsl(request.verifySsl());
        }

        // Add extra headers
        if (!request.headers().isEmpty()) {
            request.headers().forEach(builder::header);
        }

        // Add URL query parameters
        if (!request.queryParams().isEmpty()) {
            request.queryParams().forEach(builder::queryParam);
        }
    }

    private String getMethodNames(JsonNode methodsArray) {
        List<String> names = new ArrayList<>();
        for (JsonNode method : methodsArray) {
            if (method.has("name")) {
                names.add(method.get("name").asText());
            }
        }
        return String.join(", ", names);
    }

    private long elapsed(long startTime) {
        return System.currentTimeMillis() - startTime;
    }
}