HttpJsonExecutor.java

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.ai;

import java.net.http.HttpRequest;

/**
 * Shared chat-call orchestration for the four AI provider clients.
 *
 * <p>
 * Each provider's {@code suggestForClass} call follows the same five-step
 * sequence: serialise a provider-specific request payload to JSON, POST it
 * to the provider's endpoint, deserialise the response into a
 * provider-specific record, extract the inner JSON text containing the
 * classification, and parse that text into a normalised
 * {@link AiClassSuggestion}. Only the request shape, the URL, the auth
 * headers, and the response shape vary; the surrounding flow is identical.
 * </p>
 *
 * <p>
 * This executor hosts that surrounding flow. Each provider supplies the
 * variable bits as a {@link HttpRequest} and a typed extractor function.
 * The executor handles serialisation, deserialisation, normalisation, and
 * error wrapping so that the provider records stay focused on what is
 * genuinely unique to them: the JSON schema of the request and response,
 * and the auth conventions of the upstream service.
 * </p>
 *
 * <h2>Thread safety</h2>
 *
 * <p>
 * This class is thread-safe to the extent that the supplied
 * {@link HttpSupport} is. The injected {@code HttpSupport} owns its
 * {@code HttpClient} and {@code ObjectMapper}, both of which are
 * thread-safe.
 * </p>
 *
 * @see AiProviderClient
 * @see HttpSupport
 * @since 1.0.0
 */
public final class HttpJsonExecutor {

    private final HttpSupport httpSupport;

    /**
     * Creates a new executor backed by {@code httpSupport}.
     *
     * @param httpSupport HTTP and JSON support layer; must not be {@code null}
     */
    public HttpJsonExecutor(HttpSupport httpSupport) {
        this.httpSupport = httpSupport;
    }

    /**
     * Returns the HTTP and JSON support layer used by this executor. Exposed
     * so provider clients can construct their request payloads and access
     * the shared {@code ObjectMapper} without duplicating it.
     *
     * @return the backing {@link HttpSupport}
     */
    public HttpSupport httpSupport() {
        return httpSupport;
    }

    /**
     * Executes a fully-prepared chat-completion request against an AI provider
     * and returns the normalised classification result.
     *
     * <p>
     * The pre-built {@code request} carries the provider's URL, auth headers,
     * and serialised request body. The executor sends it, deserialises the
     * response into {@code responseType}, hands the deserialised value to
     * {@code contentExtractor} to pull out the inner JSON text containing
     * the classification, parses that text into an
     * {@link AiClassSuggestion}, and applies
     * {@link AiProviderClient#normalize(AiClassSuggestion)} before returning.
     * </p>
     *
     * <p>
     * Any failure during the HTTP send, JSON parsing, or content extraction
     * is wrapped in an {@link AiSuggestionException} whose message names the
     * provider and the offending class so users can diagnose from the CLI.
     * </p>
     *
     * @param providerName       human-readable provider name, embedded in
     *                           error messages ({@code "Ollama"},
     *                           {@code "Anthropic"}, etc.); must not be
     *                           {@code null}
     * @param fqcn               fully qualified class name being classified,
     *                           embedded in error messages so users can
     *                           diagnose from the CLI; must not be
     *                           {@code null}
     * @param request            fully-prepared HTTP POST request including
     *                           URL, headers, and serialised JSON body;
     *                           must not be {@code null}
     * @param responseType       Jackson {@code Class} used to deserialise
     *                           the response body; must not be {@code null}
     * @param contentExtractor   function extracting the inner JSON text
     *                           from the deserialised response; should
     *                           return {@code null} or blank when the
     *                           provider returned no usable content; must
     *                           not be {@code null}
     * @param <RESP>             provider-specific response DTO type
     * @return normalised classification result; never {@code null}
     * @throws AiSuggestionException if the HTTP call fails, the response
     *                               cannot be parsed, or the extracted
     *                               content is empty or malformed
     */
    public <RESP> AiClassSuggestion execute(String providerName, String fqcn, HttpRequest request,
            Class<RESP> responseType, ContentExtractor<RESP> contentExtractor) throws AiSuggestionException {
        try {
            String responseBody = httpSupport.postJson(request);
            RESP response = httpSupport.objectMapper().readValue(responseBody, responseType);

            String content = contentExtractor.extract(response);

            String json = JsonText.extractFirstJsonObject(content);
            AiClassSuggestion suggestion = httpSupport.objectMapper().readValue(json, AiClassSuggestion.class);
            return AiProviderClient.normalize(suggestion);

        } catch (Exception e) {
            // The original per-provider code wrapped every failure (including an already-typed
            // AiSuggestionException) into a single user-facing "<Provider> suggestion failed for X"
            // message; the catch is intentionally broad to preserve that contract here.
            throw new AiSuggestionException(providerName + " suggestion failed for " + fqcn, e);
        }
    }

    /**
     * Strategy for extracting the inner JSON-classification text from a
     * provider-specific deserialised response.
     *
     * <p>
     * Modelled as a checked-exception functional interface so each provider
     * can throw {@link AiSuggestionException} with a precise diagnostic
     * message — for example "No choices returned by model" or "Anthropic
     * returned no text block" — instead of being forced to return
     * {@code null} and lose that specificity.
     * </p>
     *
     * @param <RESP> provider-specific response DTO type
     */
    @FunctionalInterface
    public interface ContentExtractor<RESP> {

        /**
         * Returns the inner JSON-classification text contained in
         * {@code response}, or throws when the response has no usable
         * content.
         *
         * @param response deserialised provider response; never {@code null}
         * @return non-blank inner JSON text
         * @throws AiSuggestionException if the response carries no usable
         *                               content; the message becomes the
         *                               diagnostic cause that the executor
         *                               wraps into the user-facing error
         */
        String extract(RESP response) throws AiSuggestionException;
    }
}