SchemaResolver.java

package com.bonitasoft.processbuilder.validation;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.bonitasoft.processbuilder.constants.SchemaConstants;
import com.bonitasoft.processbuilder.records.LoadedSchema;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.github.fge.jsonschema.core.report.LogLevel;
import com.github.fge.jsonschema.core.report.ProcessingMessage;
import com.github.fge.jsonschema.core.report.ProcessingReport;
import com.github.fge.jsonschema.main.JsonSchema;
import com.github.fge.jsonschema.main.JsonSchemaFactory;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.media.Schema;
import io.swagger.v3.parser.OpenAPIV3Parser;
import io.swagger.v3.parser.core.models.ParseOptions;
import io.swagger.v3.parser.core.models.SwaggerParseResult;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.StreamSupport;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.Map.Entry;

/**
 * Utility class responsible for loading, resolving, and validating JSON data against 
 * OpenAPI (Swagger) schema definitions.
 * <p>
 * This class handles file parsing, recursive reference resolution, schema extraction,
 * and detailed logging of validation reports. It is non-instantiable.
 * </p>
 * @author Bonitasoft
 * @since 1.0
 */
public final class SchemaResolver {

    private static final Logger LOGGER = LoggerFactory.getLogger(SchemaResolver.class);

    /**
     * ObjectMapper for JSON serialization and deserialization, configured to ignore null fields.
     */
    private static final ObjectMapper JSON_MAPPER = new ObjectMapper()
            .setSerializationInclusion(JsonInclude.Include.NON_NULL);

    /**
     * Factory instance used to create the final JsonSchema validator (from fge/json-schema-validator).
     */
    private static final JsonSchemaFactory SCHEMA_FACTORY = JsonSchemaFactory.byDefault();

    /**
     * Private constructor to prevent instantiation of this utility class.
     * All methods in this class are static and should be called directly on the class itself.
     * @throws UnsupportedOperationException always, to enforce the utility pattern.
     */
    private SchemaResolver() {
        throw new UnsupportedOperationException("This is a "+this.getClass().getSimpleName()+" class and cannot be instantiated.");
    }


    /**
     * Loads the OpenAPI document from a resource, resolves dependencies, and prepares the 
     * {@link JsonSchema} validator for a specific target schema.
     *
     * @param resourcePath The path to the OpenAPI resource (e.g., "schemas/openapi.yaml").
     * @param targetSchemaName The name of the schema to extract from the components section (e.g., "Category").
     * @param jsonInput The raw JSON input string (stored in LoadedSchema).
     * @return A {@link LoadedSchema} record containing the validator, titles map, and input.
     * @throws RuntimeException If reading, parsing, schema resolution, or serialization fails.
     */
    public static LoadedSchema getValidatorSchema(String resourcePath, String targetSchemaName, String jsonInput) {

        OpenAPI openAPI;
        SwaggerParseResult result;
        
        // 1. Configure and Parse OpenAPI (First try-catch block for reading/parsing errors)
        try {
            ParseOptions options = new ParseOptions();
            options.setResolve(true);
            options.setResolveFully(true);

            // Using new OpenAPIV3Parser() inside the method for test isolation (can be mocked statically)
            OpenAPIV3Parser parser = new OpenAPIV3Parser();
            result = parser.readLocation(resourcePath, null, options); 
            openAPI = result.getOpenAPI();

        } catch (Exception e) {
            if (e.getCause() instanceof IOException) {
                LOGGER.error("FILE_IO_ERROR: Failed to load OpenAPI resource: {}", resourcePath, e);
                throw new RuntimeException("File I/O error during schema loading.", e);
            }
            
            LOGGER.error("PARSING_FATAL: Critical error during OpenAPI parsing.", e);
            throw new RuntimeException("OpenAPI parsing failed unexpectedly.", e);
        }

        // 2. Validate Parsed Object State (Checks that must be OUTSIDE the generic processing catch)
        
        if (openAPI == null) {
            // If the resolved object is null, log parsing messages for debugging.
            if (result.getMessages() != null && !result.getMessages().isEmpty()) {
                 LOGGER.error("PARSING_ERROR: Swagger Parser reported messages during loading: {}", result.getMessages());
            }
            // THIS EXCEPTION IS THROWN NOW OUTSIDE THE GENERIC CATCH BLOCK
            throw new RuntimeException("OpenAPI file failed to parse or could not be found.");
        }
        
        if (openAPI.getComponents() == null || openAPI.getComponents().getSchemas() == null) {
            // THIS EXCEPTION IS THROWN NOW OUTSIDE THE GENERIC CATCH BLOCK
            throw new RuntimeException("OpenAPI document loaded, but schema components are missing.");
        }


        // 3. Process Schema and Create Validator (Second try-catch block for internal processing errors)
        try {
            // Get Target Schema
            Schema<?> targetSchema = openAPI.getComponents().getSchemas().get(targetSchemaName);

            if (targetSchema == null) {
                // This is a specific validation error, but as it's inside this try-block, 
                // it will be caught by the generic catch below (which is what the failing test confirms).
                throw new RuntimeException("Target schema '" + targetSchemaName + "' not found in OpenAPI components.");
            }
            

            // Create Dynamic Title Map
            Map<String, String> componentTitles = createComponentTitleMap(targetSchema);

            // Serialize, Clean, and Parse to JsonNode for fge Validator
            String schemaJson = JSON_MAPPER.writeValueAsString(targetSchema);
            JsonNode schemaJsonNode = parseJson(schemaJson);

            // Clean $schema and create fge Validator
            if (schemaJsonNode.isObject()) {
                ((ObjectNode) schemaJsonNode).remove("$schema");
            }
            JsonSchema validator = SCHEMA_FACTORY.getJsonSchema(schemaJsonNode);        
            
            return new LoadedSchema(validator, componentTitles, targetSchemaName, jsonInput);

        } catch (Exception e) {
            // EXCEPTION MANAGEMENT: Catches all processing errors (JsonNode parsing, schema not found, serialization etc.)
            LOGGER.error("PROCESSING_FATAL: Critical error in schema resolution for target {}.", targetSchemaName, e);
            // This re-wraps the 'Target schema not found' error, matching your current passing test logic.
            throw new RuntimeException("Schema processing failed.", e);
        }
    }

    /**
     * Creates a map associating the allOf pointer (/allOf/N) with the original component's title 
     * for enhanced error reporting.
     *
     * @param targetSchema The schema being validated, containing the 'allOf' structure.
     * @return A map where the key is the JSON pointer (e.g., "/allOf/0") and the value is the component name/title.
     */
    private static Map<String, String> createComponentTitleMap(Schema<?> targetSchema) {
    
        if (targetSchema.getAllOf() == null || targetSchema.getAllOf().isEmpty()) {
            return new HashMap<>();
        }
        
        List<Schema> allOfSchemas = targetSchema.getAllOf();

        return IntStream.range(0, allOfSchemas.size())
                .mapToObj(index -> {
                    Schema<?> refSchema = allOfSchemas.get(index);
                    String refString = refSchema.get$ref();
                    String title = refSchema.getTitle(); 

                    // Logic to determine the final, user-friendly title
                    String finalTitle = title != null ? title : 
                                        (refString != null && refString.startsWith(SchemaConstants.SCHEMA_COMPONENTS_PREFIX)) 
                                        ? refString.substring(SchemaConstants.SCHEMA_COMPONENTS_PREFIX.length()) 
                                        : "Inline Schema Component";
                    
                    return Map.entry("/allOf/" + index, finalTitle);
                })
                .collect(Collectors.toMap(Entry::getKey, Entry::getValue));
    }


    /**
     * Parses a JSON string into a Jackson JsonNode.
     * @param json The JSON string to parse.
     * @return The resulting {@link JsonNode}.
     * @throws RuntimeException if the JSON string is malformed.
     */
    public static JsonNode parseJson(String json) {
        try {
            return JSON_MAPPER.readTree(json);
        } catch (Exception e) {
            LOGGER.error("PARSE_ERROR: Error parsing JSON string: {}", e.getMessage());
            throw new RuntimeException("Error parsing JSON string.", e);
        }
    }


    /**
     * Performs JSON validation against the loaded schema, logging the outcome and detailed errors.
     *
     * @param loadedSchema The record containing the validator, titles map, and input JSON.
     * @return {@code true} if validation is successful, {@code false} otherwise.
     */
	public static boolean isJsonValid(LoadedSchema loadedSchema) {

        LOGGER.info("INFO: Starting validation for target: {}", loadedSchema.targetSchemaName());
       
        try {
            // 1. Parse JSON Input
            JsonNode jsonInputNode = parseJson(loadedSchema.jsonInput());

            JsonSchema jsonSchemaValidator = loadedSchema.validator();

            // 2. Perform Validation
            ProcessingReport jsonReport = jsonSchemaValidator.validate(jsonInputNode);

            // 3. Check Result and Log Errors
            if (!jsonReport.isSuccess()) {
                
                LOGGER.warn("VALIDATION_FAILED: Failed for {} payload. Reporting detailed errors...", loadedSchema.targetSchemaName());
                
                // Log all specific, translated errors 
                printRelevantValidationErrors(jsonReport, loadedSchema.titles());
                
                return false;
            }

            // SUCCESS
            LOGGER.info("SUCCESS: Validation successful for {} payload.", loadedSchema.targetSchemaName());
            return true;

        } catch (Exception e) {
            // Catches critical errors during validation (e.g., malformed schema definition itself or JSON parsing failure)
            LOGGER.error("FATAL_ERROR: Schema processing failed during validation for {}.", loadedSchema.targetSchemaName(), e);
            return false;
        }
    }


    /**
     * Logs the relevant validation errors (ERROR/FATAL) by descending into the 'allOf' structure.
     *
     * @param report The processing report containing validation errors.
     * @param componentTitles Map of internal pointer names to user-friendly component names.
     */
    public static void printRelevantValidationErrors(ProcessingReport report, Map<String, String> componentTitles) {
        
        Spliterator<ProcessingMessage> spliterator = report.spliterator();
        
        StreamSupport.stream(spliterator, false)
            .filter(error -> error.getLogLevel().ordinal() >= LogLevel.ERROR.ordinal())
            
            .forEach(error -> {
                
                JsonNode errorJson = error.asJson();
                String keyword = errorJson.has("keyword") ? errorJson.get("keyword").asText() : "N/A";
                
                // Logic to descend into the nested 'allOf' error structure
                if ("allOf".equals(keyword) && errorJson.has("reports")) {
                    
                    JsonNode reportsNode = errorJson.get("reports");
                    
                    Spliterator<Map.Entry<String, JsonNode>> mapSpliterator = 
                        Spliterators.spliteratorUnknownSize(reportsNode.fields(), Spliterator.ORDERED);
                    
                    StreamSupport.stream(mapSpliterator, false) 
                        .forEach(entry -> {
                            JsonNode reportNode = entry.getValue(); 
                            
                            if (reportNode.isArray()) {
                                
                                reportNode.spliterator().forEachRemaining(subError -> { 
                                    
                                    if (subError.has("keyword") && "required".equals(subError.get("keyword").asText())) {
                                        
                                        String failedPointer = subError.get("schema").get("pointer").asText();
                                        String missingProps = subError.get("missing").toString();
                                        String level = subError.get("level").toString();
                                        String message = subError.get("message").asText();
                                        
                                        String translatedName = componentTitles.getOrDefault(
                                            failedPointer, 
                                            "Unknown Component (" + failedPointer + ")"
                                        );
                                        
                                        LOGGER.error(
                                            "VALIDATION FAILED: Required property missing. Level: {} | Component: {} | Missing: {} | Details: {}", 
                                            level, translatedName, 
                                            missingProps.replaceAll("\"", ""), 
                                            message
                                        );
                                    }
                                });
                            }
                        });
                } else {
                    // Log other high-level errors
                    LOGGER.error(
                        "GENERIC VALIDATION ERROR: Level={} | Message={} | Keyword={}", 
                        error.getLogLevel().toString(), 
                        error.getMessage(), 
                        keyword
                    );
                }
            });
    }
}