RestServiceRequest.java

package com.bonitasoft.processbuilder.records;

import com.bonitasoft.processbuilder.enums.RestContentType;
import com.bonitasoft.processbuilder.enums.RestHttpMethod;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

/**
 * Represents a REST service request configuration.
 * <p>
 * This record contains all the information needed to execute a REST API call,
 * including the URL, HTTP method, headers, body, authentication, and timeout settings.
 * </p>
 *
 * @param url             The full URL to call (required)
 * @param method          The HTTP method (GET, POST, PUT, etc.)
 * @param headers         Additional headers to include in the request
 * @param queryParams     Query parameters to append to the URL
 * @param body            The request body (for POST, PUT, PATCH)
 * @param contentType     The content type of the request body
 * @param auth            Authentication configuration
 * @param timeoutMs       Connection and read timeout in milliseconds
 * @param followRedirects Whether to follow HTTP redirects
 * @param verifySsl       Whether to verify SSL certificates
 * @author Bonitasoft
 * @since 1.0
 */
@JsonIgnoreProperties(ignoreUnknown = true)
public record RestServiceRequest(
        String url,
        RestHttpMethod method,
        Map<String, String> headers,
        Map<String, String> queryParams,
        String body,
        RestContentType contentType,
        RestAuthConfig auth,
        int timeoutMs,
        boolean followRedirects,
        boolean verifySsl
) {

    /**
     * Default timeout in milliseconds (30 seconds).
     */
    public static final int DEFAULT_TIMEOUT_MS = 30000;

    /**
     * Compact constructor with validation and defaults.
     */
    public RestServiceRequest {
        if (url == null || url.isBlank()) {
            throw new IllegalArgumentException("URL cannot be null or blank");
        }
        url = url.trim();
        method = method != null ? method : RestHttpMethod.GET;
        headers = headers != null ? Map.copyOf(headers) : Collections.emptyMap();
        queryParams = queryParams != null ? Map.copyOf(queryParams) : Collections.emptyMap();
        contentType = contentType != null ? contentType : RestContentType.JSON;
        auth = auth != null ? auth : RestAuthConfig.none();
        timeoutMs = timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
    }

    // ========================================================================
    // Builder Pattern
    // ========================================================================

    /**
     * Creates a new builder for RestServiceRequest.
     *
     * @param url The URL to call
     * @return A new builder instance
     */
    public static Builder builder(String url) {
        return new Builder(url);
    }

    /**
     * Builder class for constructing RestServiceRequest instances.
     */
    public static class Builder {
        private final String url;
        private RestHttpMethod method = RestHttpMethod.GET;
        private Map<String, String> headers = new HashMap<>();
        private Map<String, String> queryParams = new HashMap<>();
        private String body;
        private RestContentType contentType = RestContentType.JSON;
        private RestAuthConfig auth = RestAuthConfig.none();
        private int timeoutMs = DEFAULT_TIMEOUT_MS;
        private boolean followRedirects = true;
        private boolean verifySsl = true;

        private Builder(String url) {
            this.url = url;
        }

        public Builder method(RestHttpMethod method) {
            this.method = method;
            return this;
        }

        public Builder get() {
            return method(RestHttpMethod.GET);
        }

        public Builder post() {
            return method(RestHttpMethod.POST);
        }

        public Builder put() {
            return method(RestHttpMethod.PUT);
        }

        public Builder patch() {
            return method(RestHttpMethod.PATCH);
        }

        public Builder delete() {
            return method(RestHttpMethod.DELETE);
        }

        public Builder header(String name, String value) {
            this.headers.put(name, value);
            return this;
        }

        public Builder headers(Map<String, String> headers) {
            this.headers.putAll(headers);
            return this;
        }

        public Builder queryParam(String name, String value) {
            this.queryParams.put(name, value);
            return this;
        }

        public Builder queryParams(Map<String, String> params) {
            this.queryParams.putAll(params);
            return this;
        }

        public Builder body(String body) {
            this.body = body;
            return this;
        }

        public Builder jsonBody(Object obj) {
            try {
                this.body = new ObjectMapper().writeValueAsString(obj);
                this.contentType = RestContentType.JSON;
            } catch (Exception e) {
                throw new IllegalArgumentException("Failed to serialize object to JSON", e);
            }
            return this;
        }

        public Builder contentType(RestContentType contentType) {
            this.contentType = contentType;
            return this;
        }

        public Builder auth(RestAuthConfig auth) {
            this.auth = auth;
            return this;
        }

        public Builder basicAuth(String username, String password) {
            this.auth = RestAuthConfig.basic(username, password);
            return this;
        }

        public Builder bearerAuth(String token) {
            this.auth = RestAuthConfig.bearer(token);
            return this;
        }

        public Builder apiKeyAuth(String keyName, String keyValue) {
            this.auth = RestAuthConfig.apiKey(keyName, keyValue,
                    com.bonitasoft.processbuilder.enums.RestApiKeyLocation.HEADER);
            return this;
        }

        public Builder timeout(int timeoutMs) {
            this.timeoutMs = timeoutMs;
            return this;
        }

        public Builder followRedirects(boolean follow) {
            this.followRedirects = follow;
            return this;
        }

        public Builder verifySsl(boolean verify) {
            this.verifySsl = verify;
            return this;
        }

        public RestServiceRequest build() {
            return new RestServiceRequest(
                    url, method, headers, queryParams, body,
                    contentType, auth, timeoutMs, followRedirects, verifySsl
            );
        }
    }

    // ========================================================================
    // Factory Methods
    // ========================================================================

    /**
     * Creates a simple GET request.
     *
     * @param url The URL to call
     * @return A GET request
     */
    public static RestServiceRequest get(String url) {
        return builder(url).get().build();
    }

    /**
     * Creates a POST request with JSON body.
     *
     * @param url  The URL to call
     * @param body The JSON body string
     * @return A POST request
     */
    public static RestServiceRequest postJson(String url, String body) {
        return builder(url).post().body(body).contentType(RestContentType.JSON).build();
    }

    /**
     * Parses a RestServiceRequest from JSON.
     *
     * @param requestNode The JSON node containing the request configuration
     * @param logger      Optional logger for warnings
     * @return The parsed RestServiceRequest
     */
    public static RestServiceRequest fromJson(JsonNode requestNode, Logger logger) {
        if (requestNode == null || requestNode.isNull()) {
            throw new IllegalArgumentException("Request JSON node cannot be null");
        }

        String url = getRequiredText(requestNode, "url");

        Builder builder = builder(url);

        // Parse method
        String methodStr = getTextValue(requestNode, "method", "GET");
        RestHttpMethod.fromKey(methodStr).ifPresent(builder::method);

        // Parse headers
        JsonNode headersNode = requestNode.get("headers");
        if (headersNode != null && headersNode.isObject()) {
            headersNode.fields().forEachRemaining(entry ->
                    builder.header(entry.getKey(), entry.getValue().asText()));
        }

        // Parse query params
        JsonNode paramsNode = requestNode.get("queryParams");
        if (paramsNode != null && paramsNode.isObject()) {
            paramsNode.fields().forEachRemaining(entry ->
                    builder.queryParam(entry.getKey(), entry.getValue().asText()));
        }

        // Parse body
        JsonNode bodyNode = requestNode.get("body");
        if (bodyNode != null && !bodyNode.isNull()) {
            if (bodyNode.isObject() || bodyNode.isArray()) {
                builder.body(bodyNode.toString());
            } else {
                builder.body(bodyNode.asText());
            }
        }

        // Parse content type
        String contentTypeStr = getTextValue(requestNode, "contentType", "application/json");
        RestContentType.fromMimeType(contentTypeStr).ifPresent(builder::contentType);

        // Parse auth
        JsonNode authNode = requestNode.get("auth");
        if (authNode != null && !authNode.isNull()) {
            builder.auth(RestAuthConfig.fromJson(authNode, logger));
        }

        // Parse timeout
        JsonNode timeoutNode = requestNode.get("timeoutMs");
        if (timeoutNode != null && timeoutNode.isNumber()) {
            builder.timeout(timeoutNode.asInt(DEFAULT_TIMEOUT_MS));
        }

        // Parse follow redirects
        JsonNode redirectsNode = requestNode.get("followRedirects");
        if (redirectsNode != null && redirectsNode.isBoolean()) {
            builder.followRedirects(redirectsNode.asBoolean(true));
        }

        // Parse verify SSL
        JsonNode sslNode = requestNode.get("verifySsl");
        if (sslNode != null && sslNode.isBoolean()) {
            builder.verifySsl(sslNode.asBoolean(true));
        }

        return builder.build();
    }

    private static String getRequiredText(JsonNode node, String field) {
        JsonNode fieldNode = node.get(field);
        if (fieldNode == null || fieldNode.isNull() || fieldNode.asText().isBlank()) {
            throw new IllegalArgumentException("Required field '" + field + "' is missing or blank");
        }
        return fieldNode.asText().trim();
    }

    private static String getTextValue(JsonNode node, String field, String defaultValue) {
        JsonNode fieldNode = node.get(field);
        if (fieldNode == null || fieldNode.isNull()) {
            return defaultValue;
        }
        String value = fieldNode.asText();
        return (value == null || value.isBlank()) ? defaultValue : value.trim();
    }

    // ========================================================================
    // Utility Methods
    // ========================================================================

    /**
     * Builds the full URL including query parameters.
     *
     * @return The full URL with query parameters
     */
    public String buildFullUrl() {
        if (queryParams.isEmpty() && auth.getAuthQueryParams().isEmpty()) {
            return url;
        }

        StringBuilder fullUrl = new StringBuilder(url);
        boolean hasQueryString = url.contains("?");

        // Add auth query params first
        for (Map.Entry<String, String> param : auth.getAuthQueryParams().entrySet()) {
            fullUrl.append(hasQueryString ? "&" : "?");
            fullUrl.append(urlEncode(param.getKey())).append("=").append(urlEncode(param.getValue()));
            hasQueryString = true;
        }

        // Add regular query params
        for (Map.Entry<String, String> param : queryParams.entrySet()) {
            fullUrl.append(hasQueryString ? "&" : "?");
            fullUrl.append(urlEncode(param.getKey())).append("=").append(urlEncode(param.getValue()));
            hasQueryString = true;
        }

        return fullUrl.toString();
    }

    /**
     * Builds all headers including auth headers and content type.
     *
     * @return Combined headers map
     */
    public Map<String, String> buildAllHeaders() {
        Map<String, String> allHeaders = new HashMap<>();

        // Add content type if there's a body
        if (body != null && !body.isEmpty()) {
            allHeaders.put("Content-Type", contentType.getMimeType());
        }

        // Add auth headers
        allHeaders.putAll(auth.getAuthHeaders());

        // Add custom headers (can override defaults)
        allHeaders.putAll(headers);

        return allHeaders;
    }

    /**
     * Checks if this request has a body.
     *
     * @return true if the request has a non-empty body
     */
    public boolean hasBody() {
        return body != null && !body.isEmpty();
    }

    private static String urlEncode(String value) {
        try {
            return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
        } catch (Exception e) {
            return value;
        }
    }
}