using DataConnection.REST.Configuration; using DataConnection.REST.Interfaces; using DataConnection.REST.Models; using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; namespace DataConnection.REST.Implementations { /// /// Client specific for Salesforce REST API. /// Handles OAuth2 authentication and metadata discovery. /// public class SalesforceServiceClient : BaseRestServiceClient, IRestMetadataDiscovery { private string? _accessToken; private string? _instanceUrl; private DateTime _tokenExpiry; /// /// Configurazione JSON per garantire la compatibilità con Salesforce API /// Utilizza sempre la cultura invariante per i numeri per evitare problemi con virgole/punti decimali /// private static readonly JsonSerializerOptions SalesforceJsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, NumberHandling = JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; public SalesforceServiceClient(HttpClient httpClient, RestServiceOptions options) : base(httpClient, options) { } /// /// Authenticates with Salesforce using Username/Password OAuth2 flow. /// /// Connected App Consumer Key /// Connected App Consumer Secret /// Salesforce username /// Salesforce password + security token /// Cancellation token /// True if authentication is successful public async Task AuthenticateAsync(string clientId, string clientSecret, string username, string password, CancellationToken cancellationToken = default) { try { var tokenEndpoint = "/services/oauth2/token"; var tokenRequest = new List> { new("grant_type", "password"), new("client_id", clientId), new("client_secret", clientSecret), new("username", username), new("password", password) }; var formContent = new FormUrlEncodedContent(tokenRequest); Console.WriteLine($"--- Salesforce Authentication Attempt ---"); Console.WriteLine($"Target URL: {_httpClient.BaseAddress}{tokenEndpoint}"); Console.WriteLine($"Username: {username}"); Console.WriteLine($"--- End Salesforce Authentication Attempt ---"); var response = await _httpClient.PostAsync(tokenEndpoint, formContent, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); Console.WriteLine($"Salesforce Authentication failed: {response.StatusCode}"); Console.WriteLine($"Error details: {errorContent}"); return false; } var tokenResponse = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (tokenResponse != null && !string.IsNullOrEmpty(tokenResponse.AccessToken)) { _accessToken = tokenResponse.AccessToken; _instanceUrl = tokenResponse.InstanceUrl; _tokenExpiry = DateTime.UtcNow.AddSeconds(3600); // Default 1 hour, Salesforce doesn't always return expires_in // Don't change BaseAddress - we'll use absolute URLs for API calls // Store the instance URL for building complete URLs later // Add Authorization header for future requests _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); Console.WriteLine($"Salesforce Authentication successful. Token expires around: {_tokenExpiry.ToLocalTime()}"); Console.WriteLine($"Instance URL: {_instanceUrl}"); return true; } Console.WriteLine("Salesforce Authentication response could not be parsed."); return false; } catch (HttpRequestException ex) { Console.WriteLine($"HTTP Request Error during Salesforce Authentication: {ex.Message}"); if (ex.InnerException != null) { Console.WriteLine($"Inner Exception: {ex.InnerException.Message}"); } return false; } catch (JsonException ex) { Console.WriteLine($"JSON Parsing Error during Salesforce Authentication: {ex.Message}"); return false; } catch (Exception ex) { Console.WriteLine($"Error during Salesforce Authentication: {ex.Message}"); return false; } } /// /// Authenticates with Salesforce using the credentials from options. /// /// Cancellation token /// True if authentication is successful public override async Task AuthenticateAsync(CancellationToken cancellationToken = default) { // For Salesforce, we need ClientId, ClientSecret, Username, and Password // These should be provided in the options if (string.IsNullOrEmpty(_options.Username) || string.IsNullOrEmpty(_options.Password)) { Console.WriteLine("Salesforce authentication requires username and password in options"); return false; } if (string.IsNullOrEmpty(_options.ApiKey) || string.IsNullOrEmpty(_options.AuthToken)) { Console.WriteLine("Salesforce authentication requires ApiKey (ClientId) and AuthToken (ClientSecret) in options"); return false; } // Use the actual credentials from options var clientId = _options.ApiKey; // ClientId should be in ApiKey field var clientSecret = _options.AuthToken; // ClientSecret should be in AuthToken field Console.WriteLine($"Using Salesforce credentials - ClientId: {clientId}, Username: {_options.Username}"); return await AuthenticateAsync(clientId, clientSecret, _options.Username, _options.Password, cancellationToken); } /// /// Checks if the current access token is active. /// public bool IsAuthenticated() { return !string.IsNullOrEmpty(_accessToken) && DateTime.UtcNow < _tokenExpiry; } /// /// Discovers SObjects (entities) and their fields from Salesforce. /// /// Cancellation token /// A list of discovered entity information public async Task> DiscoverEntitiesAsync(CancellationToken cancellationToken = default) { if (!IsAuthenticated()) { Console.WriteLine("Error: Not authenticated to Salesforce. Cannot discover metadata."); return new List(); } var entities = new List(); try { // First, get list of all SObjects var sobjectsEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/"; var response = await _httpClient.GetAsync(sobjectsEndpoint, cancellationToken); response.EnsureSuccessStatusCode(); var sobjectsResponse = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (sobjectsResponse?.SObjects != null) { // For demo purposes, limit to first 20 objects to avoid too many API calls var limitedSObjects = sobjectsResponse.SObjects.ToList(); // Process SObjects in parallel for better performance var semaphore = new SemaphoreSlim(20, 20); // Limit concurrent requests to 5 var tasks = limitedSObjects.Where(sobject => !string.IsNullOrEmpty(sobject.Name)) .Select(async sobject => { await semaphore.WaitAsync(cancellationToken); try { // Get detailed field information for each SObject var describeEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/{sobject.Name}/describe/"; var describeResponse = await _httpClient.GetAsync(describeEndpoint, cancellationToken); if (describeResponse.IsSuccessStatusCode) { var describeResult = await describeResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (describeResult?.Fields != null) { var entityInfo = new RestEntityInfo { Name = sobject.Name }; foreach (var field in describeResult.Fields) { if (string.IsNullOrEmpty(field.Name)) continue; var propInfo = new RestPropertyInfo { Name = field.Name, Type = field.Type ?? "string", IsKey = field.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) }; entityInfo.Properties.Add(propInfo); } return entityInfo; } } return null; } catch (Exception ex) { Console.WriteLine($"Error describing SObject {sobject.Name}: {ex.Message}"); return null; } finally { semaphore.Release(); } }); var results = await Task.WhenAll(tasks); entities.AddRange(results.Where(result => result != null)!); } } catch (HttpRequestException ex) { Console.WriteLine($"HTTP Request Error during Salesforce metadata discovery: {ex.Message}"); } catch (JsonException ex) { Console.WriteLine($"JSON Parsing Error during Salesforce metadata discovery: {ex.Message}"); } catch (Exception ex) { Console.WriteLine($"Error during Salesforce metadata discovery: {ex.Message}"); } return entities; } /// /// Discovers a list of available SObjects from Salesforce without detailed field information. /// /// Cancellation token /// A list of discovered SObject summaries public async Task> DiscoverEntitySummariesAsync(CancellationToken cancellationToken = default) { if (!IsAuthenticated()) { Console.WriteLine("Error: Not authenticated to Salesforce. Cannot discover metadata."); return new List(); } var entities = new List(); try { // Get list of all SObjects var sobjectsEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/"; var response = await _httpClient.GetAsync(sobjectsEndpoint, cancellationToken); response.EnsureSuccessStatusCode(); var sobjectsResponse = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (sobjectsResponse?.SObjects != null) { foreach (var sobject in sobjectsResponse.SObjects) { if (string.IsNullOrEmpty(sobject.Name)) continue; var entitySummary = new RestEntitySummary { Name = sobject.Name, Label = sobject.Label ?? sobject.Name, IsCustom = sobject.Custom, EntityType = "SObject" }; entities.Add(entitySummary); } } } catch (HttpRequestException ex) { Console.WriteLine($"HTTP Request Error during Salesforce metadata discovery: {ex.Message}"); } catch (JsonException ex) { Console.WriteLine($"JSON Parsing Error during Salesforce metadata discovery: {ex.Message}"); } catch (Exception ex) { Console.WriteLine($"Error during Salesforce metadata discovery: {ex.Message}"); } return entities.OrderBy(e => e.Name).ToList(); } /// /// Discovers detailed information for a specific SObject including all its fields. /// /// The name of the SObject to get details for /// Cancellation token /// Detailed SObject information or null if not found public async Task DiscoverEntityDetailsAsync(string entityName, CancellationToken cancellationToken = default) { if (!IsAuthenticated()) { Console.WriteLine("Error: Not authenticated to Salesforce. Cannot discover entity details."); return null; } try { // Get detailed field information for the specific SObject var describeEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/{entityName}/describe/"; var describeResponse = await _httpClient.GetAsync(describeEndpoint, cancellationToken); if (!describeResponse.IsSuccessStatusCode) { Console.WriteLine($"Failed to get details for SObject {entityName}: {describeResponse.StatusCode}"); return null; } var describeResult = await describeResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (describeResult?.Fields != null) { var entityInfo = new RestEntityInfo { Name = entityName }; foreach (var field in describeResult.Fields) { if (string.IsNullOrEmpty(field.Name)) continue; var propInfo = new RestPropertyInfo { Name = field.Name, Type = field.Type ?? "string", IsKey = field.Name.Equals("Id", StringComparison.OrdinalIgnoreCase), IsRequired = !field.Nillable, MaxLength = field.Length > 0 ? field.Length : null }; entityInfo.Properties.Add(propInfo); } return entityInfo; } } catch (HttpRequestException ex) { Console.WriteLine($"HTTP Request Error during Salesforce entity details discovery: {ex.Message}"); } catch (JsonException ex) { Console.WriteLine($"JSON Parsing Error during Salesforce entity details discovery: {ex.Message}"); } catch (Exception ex) { Console.WriteLine($"Error during Salesforce entity details discovery: {ex.Message}"); } return null; } /// /// Creates a new SObject in Salesforce. /// /// The name of the SObject to create (e.g., "Account", "Contact"). /// The data for the new SObject as key-value pairs. /// Cancellation token. /// The created entity data or null if creation failed. public override async Task?> CreateEntityAsync(string entityName, Dictionary entityData, CancellationToken cancellationToken = default) { if (!IsAuthenticated()) { Console.WriteLine("Error: Not authenticated to Salesforce. Cannot create entity."); return null; } // Salesforce REST API endpoint for creating SObjects var createUri = $"{_instanceUrl}/services/data/v60.0/sobjects/{entityName}/"; try { Console.WriteLine($"--- Salesforce Entity Creation Attempt ---"); Console.WriteLine($"SObject: {entityName}"); Console.WriteLine($"Target URL: {createUri}"); Console.WriteLine($"Data: {JsonSerializer.Serialize(entityData, SalesforceJsonOptions)}"); // Normalizza i valori numerici per evitare problemi con virgole decimali var normalizedData = NormalizeNumericValues(entityData); // Usa StringContent con configurazione JSON specifica per Salesforce var jsonContent = new StringContent( JsonSerializer.Serialize(normalizedData, SalesforceJsonOptions), System.Text.Encoding.UTF8, "application/json" ); var response = await _httpClient.PostAsync(createUri, jsonContent, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); Console.WriteLine($"Salesforce Entity Creation failed: {response.StatusCode}"); Console.WriteLine($"Error details: {errorContent}"); Console.WriteLine($"--- End Salesforce Entity Creation Attempt (Failed) ---"); return null; } var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); Console.WriteLine($"Salesforce Entity Creation successful"); Console.WriteLine($"Response: {responseContent}"); Console.WriteLine($"--- End Salesforce Entity Creation Attempt (Success) ---"); if (string.IsNullOrEmpty(responseContent)) return entityData; // Return original data if no response content // Salesforce returns creation result with Id and success status var creationResult = JsonSerializer.Deserialize>(responseContent, SalesforceJsonOptions); // Merge the original data with the creation result (which includes the new Id) if (creationResult != null) { var result = new Dictionary(entityData); foreach (var kvp in creationResult) { result[kvp.Key] = kvp.Value; } return result; } return creationResult; } catch (HttpRequestException ex) { Console.WriteLine($"HTTP Request Error during Salesforce entity creation: {ex.Message}"); if (ex.InnerException != null) { Console.WriteLine($"Inner Exception: {ex.InnerException.Message}"); } Console.WriteLine($"--- End Salesforce Entity Creation Attempt (Exception) ---"); return null; } catch (JsonException ex) { Console.WriteLine($"JSON Parsing Error during Salesforce entity creation: {ex.Message}"); Console.WriteLine($"--- End Salesforce Entity Creation Attempt (JsonException) ---"); return null; } catch (Exception ex) { Console.WriteLine($"Error during Salesforce entity creation: {ex.Message}"); Console.WriteLine($"--- End Salesforce Entity Creation Attempt (Exception) ---"); return null; } } public override async Task?> UpsertEntityAsync(string entityName, Dictionary entityData, CancellationToken cancellationToken = default) { // Per Salesforce, implementiamo upsert provando prima la creazione // Se fallisce con un errore di duplicato, potremmo implementare logic di aggiornamento // Per ora, semplicemente tentiamo la creazione try { Console.WriteLine($"--- Starting Salesforce Entity Upsert: {entityName} ---"); Console.WriteLine($"Entity Data: {string.Join(", ", entityData.Select(kvp => $"{kvp.Key}={kvp.Value}"))}"); // Prima tenta la creazione var result = await CreateEntityAsync(entityName, entityData, cancellationToken); if (result != null) { Console.WriteLine($"Upsert completed successfully via CREATE for {entityName}"); return result; } // Se la creazione fallisce, potresti implementare qui la logica di aggiornamento // Per ora, restituiamo null Console.WriteLine($"Upsert failed for {entityName}"); return null; } catch (Exception ex) { Console.WriteLine($"Error during Salesforce entity upsert: {ex.Message}"); return null; } } /// /// Finds entities by their key fields in Salesforce. /// /// The name of the SObject to search (e.g., "Account", "Contact"). /// The key fields and their values to match. /// Cancellation token. /// A list of matching entities or an empty list if none found. public override async Task>> FindEntitiesByKeysAsync(string entityName, Dictionary keyFields, CancellationToken cancellationToken = default) { try { Console.WriteLine($"--- Starting Salesforce Entity Search: {entityName} ---"); Console.WriteLine($"Key Fields: {string.Join(", ", keyFields.Select(kvp => $"{kvp.Key}={kvp.Value}"))}"); if (!await EnsureAuthenticatedAsync(cancellationToken)) { Console.WriteLine("Authentication failed for entity search"); return new List>(); } // Costruisci la query SOQL var whereConditions = keyFields.Select(kvp => { var value = kvp.Value?.ToString() ?? ""; // Se il valore è una stringa, aggiungi le virgolette if (kvp.Value is string) { value = $"'{value.Replace("'", "\\'")}'"; // Escape delle virgolette } return $"{kvp.Key} = {value}"; }); var query = $"SELECT Id FROM {entityName} WHERE {string.Join(" AND ", whereConditions)}"; Console.WriteLine($"SOQL Query: {query}"); var encodedQuery = Uri.EscapeDataString(query); var queryEndpoint = $"/services/data/v59.0/query/?q={encodedQuery}"; var response = await GetAsync(queryEndpoint, cancellationToken); if (response?.Records != null) { var results = response.Records.Select(record => record as Dictionary ?? new Dictionary() ).ToList(); Console.WriteLine($"Found {results.Count} entities matching the key fields"); return results; } Console.WriteLine("No entities found matching the key fields"); return new List>(); } catch (Exception ex) { Console.WriteLine($"Error during Salesforce entity search: {ex.Message}"); return new List>(); } } /// /// Deletes an entity in Salesforce by its ID. /// /// The name of the SObject (e.g., "Account", "Contact"). /// The ID of the entity to delete. /// Cancellation token. /// True if deletion was successful, false otherwise. public override async Task DeleteEntityAsync(string entityName, string entityId, CancellationToken cancellationToken = default) { try { Console.WriteLine($"--- Starting Salesforce Entity Delete: {entityName}/{entityId} ---"); if (!await EnsureAuthenticatedAsync(cancellationToken)) { Console.WriteLine("Authentication failed for entity deletion"); return false; } var deleteEndpoint = $"/services/data/v59.0/sobjects/{entityName}/{entityId}"; // Salesforce usa DELETE HTTP method per eliminare record var request = new HttpRequestMessage(HttpMethod.Delete, $"{_instanceUrl}{deleteEndpoint}"); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); var response = await _httpClient.SendAsync(request, cancellationToken); if (response.IsSuccessStatusCode) { Console.WriteLine($"Entity {entityName}/{entityId} deleted successfully"); return true; } else { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); Console.WriteLine($"Failed to delete entity {entityName}/{entityId}. Status: {response.StatusCode}, Error: {errorContent}"); return false; } } catch (Exception ex) { Console.WriteLine($"Error during Salesforce entity deletion: {ex.Message}"); return false; } } /// /// Updates an existing entity in Salesforce by its ID. /// /// The name of the SObject (e.g., "Account", "Contact"). /// The ID of the entity to update. /// The data to update as key-value pairs. /// Cancellation token. /// The updated entity data or null if update failed. public override async Task?> UpdateEntityAsync(string entityName, string entityId, Dictionary entityData, CancellationToken cancellationToken = default) { try { Console.WriteLine($"--- Starting Salesforce Entity Update: {entityName}/{entityId} ---"); if (!await EnsureAuthenticatedAsync(cancellationToken)) { Console.WriteLine("Authentication failed for entity update"); return null; } var updateEndpoint = $"/services/data/v59.0/sobjects/{entityName}/{entityId}"; // Salesforce usa PATCH HTTP method per aggiornare record var request = new HttpRequestMessage(HttpMethod.Patch, $"{_instanceUrl}{updateEndpoint}"); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); request.Content = JsonContent.Create(entityData); var response = await _httpClient.SendAsync(request, cancellationToken); if (response.IsSuccessStatusCode) { Console.WriteLine($"Entity {entityName}/{entityId} updated successfully"); // Ritorna i dati aggiornati includendo l'ID var updatedData = new Dictionary(entityData) { ["Id"] = entityId }; return updatedData; } else { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); Console.WriteLine($"Failed to update entity {entityName}/{entityId}. Status: {response.StatusCode}, Error: {errorContent}"); return null; } } catch (Exception ex) { Console.WriteLine($"Error during Salesforce entity update: {ex.Message}"); return null; } } /// /// Ensures the client is authenticated, attempting to authenticate if not already authenticated. /// /// Cancellation token. /// True if authentication is successful, false otherwise. private async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken = default) { if (IsAuthenticated()) { return true; } Console.WriteLine("Client not authenticated, attempting to authenticate..."); return await AuthenticateAsync(cancellationToken); } /// /// Normalizza i valori numerici in un dictionary per garantire compatibilità con Salesforce API /// Converte valori decimali con virgola in formato con punto decimale /// private static Dictionary NormalizeNumericValues(Dictionary data) { var normalizedData = new Dictionary(); bool hasNormalized = false; foreach (var kvp in data) { var value = kvp.Value; if (value != null) { // Se è una stringa che rappresenta un numero decimale con virgola, convertila if (value is string stringValue && IsNumericWithComma(stringValue)) { if (decimal.TryParse(stringValue, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.CurrentCulture, out decimal decimalValue)) { // Converte in double usando cultura invariante per garantire punto decimale var normalizedValue = double.Parse(decimalValue.ToString(System.Globalization.CultureInfo.InvariantCulture)); normalizedData[kvp.Key] = normalizedValue; Console.WriteLine($"NUMERIC NORMALIZATION: {kvp.Key}: '{stringValue}' → {normalizedValue}"); hasNormalized = true; } else { normalizedData[kvp.Key] = value; } } // Se è già un decimal, convertilo in double con cultura invariante else if (value is decimal dec) { var normalizedValue = double.Parse(dec.ToString(System.Globalization.CultureInfo.InvariantCulture)); normalizedData[kvp.Key] = normalizedValue; Console.WriteLine($"DECIMAL NORMALIZATION: {kvp.Key}: {dec} → {normalizedValue}"); hasNormalized = true; } else { normalizedData[kvp.Key] = value; } } else { normalizedData[kvp.Key] = value!; } } if (hasNormalized) { Console.WriteLine($"NORMALIZATION SUMMARY: Processed {data.Count} fields, normalized {normalizedData.Count(kvp => kvp.Value is double)} numeric values"); } return normalizedData; } /// /// Verifica se una stringa rappresenta un numero con virgola decimale /// private static bool IsNumericWithComma(string value) { if (string.IsNullOrEmpty(value)) return false; // Pattern per numeri con virgola: opzionale segno, cifre, virgola, cifre return System.Text.RegularExpressions.Regex.IsMatch(value.Trim(), @"^[+-]?\d+,\d+$"); } // --- Nested classes for deserializing Salesforce responses --- private class SalesforceTokenResponse { [JsonPropertyName("access_token")] public string AccessToken { get; set; } = string.Empty; [JsonPropertyName("instance_url")] public string InstanceUrl { get; set; } = string.Empty; [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; [JsonPropertyName("token_type")] public string TokenType { get; set; } = string.Empty; [JsonPropertyName("issued_at")] public string IssuedAt { get; set; } = string.Empty; [JsonPropertyName("signature")] public string Signature { get; set; } = string.Empty; } private class SalesforceSObjectsResponse { [JsonPropertyName("encoding")] public string Encoding { get; set; } = string.Empty; [JsonPropertyName("maxBatchSize")] public int MaxBatchSize { get; set; } [JsonPropertyName("sobjects")] public List SObjects { get; set; } = new List(); } private class SalesforceSObject { [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; [JsonPropertyName("label")] public string Label { get; set; } = string.Empty; [JsonPropertyName("custom")] public bool Custom { get; set; } [JsonPropertyName("keyPrefix")] public string KeyPrefix { get; set; } = string.Empty; } private class SalesforceDescribeResponse { [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; [JsonPropertyName("label")] public string Label { get; set; } = string.Empty; [JsonPropertyName("fields")] public List Fields { get; set; } = new List(); } /// /// Finds entities by required fields to detect duplicates. /// /// The name of the entity to search. /// The required fields and their values to search for. /// Cancellation token. /// A list of matching entities. public override async Task>> FindEntitiesByRequiredFieldsAsync(string entityName, Dictionary requiredFields, CancellationToken cancellationToken = default) { try { Console.WriteLine($"--- Searching for duplicates in {entityName} by required fields ---"); if (!await EnsureAuthenticatedAsync(cancellationToken)) { Console.WriteLine("Authentication failed for required fields search"); return new List>(); } if (!requiredFields.Any()) { Console.WriteLine("No required fields provided for duplicate search"); return new List>(); } // Build WHERE clause with required fields var whereConditions = new List(); foreach (var field in requiredFields) { if (field.Value != null) { var value = field.Value.ToString(); // Escape single quotes in string values if (field.Value is string stringValue) { value = stringValue.Replace("'", "\\'"); whereConditions.Add($"{field.Key} = '{value}'"); } else { whereConditions.Add($"{field.Key} = {value}"); } } } if (!whereConditions.Any()) { Console.WriteLine("No valid field values provided for duplicate search"); return new List>(); } var whereClause = string.Join(" AND ", whereConditions); var query = $"SELECT Id, {string.Join(", ", requiredFields.Keys)} FROM {entityName} WHERE {whereClause}"; Console.WriteLine($"Executing duplicate search query: {query}"); var queryEndpoint = $"/services/data/v59.0/query?q={Uri.EscapeDataString(query)}"; var response = await GetAsync($"{_instanceUrl}{queryEndpoint}", cancellationToken); if (response?.Records != null) { Console.WriteLine($"Found {response.Records.Count} potential duplicates for required fields: {string.Join(", ", requiredFields.Select(kv => $"{kv.Key}={kv.Value}"))}"); return response.Records; } Console.WriteLine("No duplicates found"); return new List>(); } catch (Exception ex) { Console.WriteLine($"Error during required fields search: {ex.Message}"); return new List>(); } } private class SalesforceField { [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; [JsonPropertyName("label")] public string Label { get; set; } = string.Empty; [JsonPropertyName("length")] public int Length { get; set; } [JsonPropertyName("nillable")] public bool Nillable { get; set; } [JsonPropertyName("unique")] public bool Unique { get; set; } } private class SalesforceQueryResponse { [JsonPropertyName("records")] public List> Records { get; set; } = new List>(); } private class SalesforceCompositeRequest { [JsonPropertyName("compositeRequest")] public List CompositeRequest { get; set; } = new List(); } private class SalesforceCompositeSubRequest { [JsonPropertyName("method")] public string Method { get; set; } = string.Empty; [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; [JsonPropertyName("referenceId")] public string ReferenceId { get; set; } = string.Empty; [JsonPropertyName("body")] public object Body { get; set; } = new object(); } private class SalesforceCompositeResponse { [JsonPropertyName("compositeResponse")] public List CompositeResponse { get; set; } = new List(); } private class SalesforceCompositeSubResponse { [JsonPropertyName("referenceId")] public string ReferenceId { get; set; } = string.Empty; [JsonPropertyName("httpStatusCode")] public int HttpStatusCode { get; set; } [JsonPropertyName("body")] public object Body { get; set; } = new object(); } public class CompositeOperationResult { public string ReferenceId { get; set; } = string.Empty; public string? EntityId { get; set; } public int HttpStatusCode { get; set; } public bool Success { get; set; } public string ErrorMessage { get; set; } = string.Empty; public Dictionary? CreatedData { get; set; } public Dictionary? UpdatedData { get; set; } } /// /// Executes multiple create operations using Salesforce Composite API with automatic batching /// /// The name of the SObject to create /// List of entity data to create /// Cancellation token /// List of results for each create operation public async Task> BatchCreateEntitiesAsync(string entityName, List> entityDataList, CancellationToken cancellationToken = default) { if (!IsAuthenticated()) { Console.WriteLine("Error: Not authenticated to Salesforce. Cannot perform batch create."); return new List(); } if (!entityDataList.Any()) { return new List(); } // Salesforce limit: max 25 operations per composite request const int maxBatchSize = 25; // Split into batches of 25 var batches = new List<(List> batch, int startIndex, int batchNumber)>(); for (int i = 0; i < entityDataList.Count; i += maxBatchSize) { var batch = entityDataList.Skip(i).Take(maxBatchSize).ToList(); var batchNumber = (i / maxBatchSize) + 1; batches.Add((batch, i, batchNumber)); } var totalBatches = batches.Count; Console.WriteLine($"--- Starting parallel processing of {totalBatches} batch(es) with {entityDataList.Count} total records ---"); // Execute all batches in parallel var batchTasks = batches.Select(async b => { Console.WriteLine($"--- Processing Batch {b.batchNumber}/{totalBatches}: {b.batch.Count} records (parallel) ---"); return await ExecuteCreateBatchAsync(entityName, b.batch, b.startIndex, cancellationToken); }); var batchResults = await Task.WhenAll(batchTasks); // Aggregate all results var allResults = new List(); foreach (var result in batchResults) { allResults.AddRange(result); } Console.WriteLine($"All batches completed: {allResults.Count(r => r.Success)} success, {allResults.Count(r => !r.Success)} failed"); return allResults; } private async Task> ExecuteCreateBatchAsync(string entityName, List> batch, int startIndex, CancellationToken cancellationToken) { try { // Salesforce Composite API endpoint var compositeUri = $"{_instanceUrl}/services/data/v60.0/composite/"; // Build composite request var compositeRequest = new SalesforceCompositeRequest(); for (int i = 0; i < batch.Count; i++) { // Normalizza i valori numerici per evitare problemi con virgole decimali var normalizedData = NormalizeNumericValues(batch[i]); var subrequest = new SalesforceCompositeSubRequest { Method = "POST", Url = $"/services/data/v60.0/sobjects/{entityName}/", ReferenceId = $"create_{startIndex + i}", Body = normalizedData }; compositeRequest.CompositeRequest.Add(subrequest); } // Usa StringContent con configurazione JSON specifica per Salesforce var jsonContent = new StringContent( JsonSerializer.Serialize(compositeRequest, SalesforceJsonOptions), System.Text.Encoding.UTF8, "application/json" ); var response = await _httpClient.PostAsync(compositeUri, jsonContent, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); Console.WriteLine($"Salesforce Batch Create failed: {response.StatusCode}"); Console.WriteLine($"Error details: {errorContent}"); // Return error results for all operations in this batch return batch.Select((_, index) => new CompositeOperationResult { ReferenceId = $"create_{startIndex + index}", Success = false, ErrorMessage = $"Batch operation failed: {response.StatusCode} - {errorContent}" }).ToList(); } var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); var compositeResponse = JsonSerializer.Deserialize(responseContent, SalesforceJsonOptions); var results = new List(); if (compositeResponse?.CompositeResponse != null) { foreach (var subResponse in compositeResponse.CompositeResponse) { var result = new CompositeOperationResult { ReferenceId = subResponse.ReferenceId, HttpStatusCode = subResponse.HttpStatusCode, Success = subResponse.HttpStatusCode >= 200 && subResponse.HttpStatusCode < 300 }; if (result.Success && subResponse.Body != null) { if (subResponse.Body is JsonElement bodyElement) { var bodyDict = JsonSerializer.Deserialize>(bodyElement.GetRawText(), SalesforceJsonOptions); result.CreatedData = bodyDict; // Extract the created ID if (bodyDict?.ContainsKey("id") == true) { result.EntityId = bodyDict["id"]?.ToString(); } } } else { result.ErrorMessage = subResponse.Body?.ToString() ?? "Unknown error"; } results.Add(result); } } return results; } catch (Exception ex) { Console.WriteLine($"Error during Salesforce batch create: {ex.Message}"); // Return error results for all operations in this batch return batch.Select((_, index) => new CompositeOperationResult { ReferenceId = $"create_{startIndex + index}", Success = false, ErrorMessage = ex.Message }).ToList(); } } /// /// Executes multiple update operations using Salesforce Composite API with automatic batching /// /// The name of the SObject to update /// Dictionary where key is entityId and value is the data to update /// Cancellation token /// List of results for each update operation public async Task> BatchUpdateEntitiesAsync(string entityName, Dictionary> updateData, CancellationToken cancellationToken = default) { if (!IsAuthenticated()) { Console.WriteLine("Error: Not authenticated to Salesforce. Cannot perform batch update."); return new List(); } if (!updateData.Any()) { return new List(); } // Salesforce limit: max 25 operations per composite request const int maxBatchSize = 25; var updateList = updateData.ToList(); // Split into batches of 25 var batches = new List<(Dictionary> batch, int startIndex, int batchNumber)>(); for (int i = 0; i < updateList.Count; i += maxBatchSize) { var batch = updateList.Skip(i).Take(maxBatchSize).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); var batchNumber = (i / maxBatchSize) + 1; batches.Add((batch, i, batchNumber)); } var totalBatches = batches.Count; Console.WriteLine($"--- Starting parallel processing of {totalBatches} update batch(es) with {updateList.Count} total records ---"); // Execute all batches in parallel var batchTasks = batches.Select(async b => { Console.WriteLine($"--- Processing Update Batch {b.batchNumber}/{totalBatches}: {b.batch.Count} records (parallel) ---"); return await ExecuteUpdateBatchAsync(entityName, b.batch, b.startIndex, cancellationToken); }); var batchResults = await Task.WhenAll(batchTasks); // Aggregate all results var allResults = new List(); foreach (var result in batchResults) { allResults.AddRange(result); } Console.WriteLine($"All update batches completed: {allResults.Count(r => r.Success)} success, {allResults.Count(r => !r.Success)} failed"); return allResults; } private async Task> ExecuteUpdateBatchAsync(string entityName, Dictionary> batch, int startIndex, CancellationToken cancellationToken) { try { // Salesforce Composite API endpoint var compositeUri = $"{_instanceUrl}/services/data/v60.0/composite/"; // Build composite request var compositeRequest = new SalesforceCompositeRequest(); int index = 0; foreach (var kvp in batch) { var entityId = kvp.Key; var entityData = kvp.Value; // Normalizza i valori numerici per evitare problemi con virgole decimali var normalizedData = NormalizeNumericValues(entityData); var subrequest = new SalesforceCompositeSubRequest { Method = "PATCH", Url = $"/services/data/v60.0/sobjects/{entityName}/{entityId}", ReferenceId = $"update_{startIndex + index}", Body = normalizedData }; compositeRequest.CompositeRequest.Add(subrequest); index++; } // Usa StringContent con configurazione JSON specifica per Salesforce var jsonContent = new StringContent( JsonSerializer.Serialize(compositeRequest, SalesforceJsonOptions), System.Text.Encoding.UTF8, "application/json" ); var response = await _httpClient.PostAsync(compositeUri, jsonContent, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); Console.WriteLine($"Salesforce Batch Update failed: {response.StatusCode}"); Console.WriteLine($"Error details: {errorContent}"); // Return error results for all operations in this batch return batch.Select((kvp, idx) => new CompositeOperationResult { ReferenceId = $"update_{startIndex + idx}", EntityId = kvp.Key, Success = false, ErrorMessage = $"Batch operation failed: {response.StatusCode} - {errorContent}" }).ToList(); } var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); var compositeResponse = JsonSerializer.Deserialize(responseContent, SalesforceJsonOptions); var results = new List(); if (compositeResponse?.CompositeResponse != null) { int resultIndex = 0; foreach (var subResponse in compositeResponse.CompositeResponse) { var originalEntityId = batch.ElementAt(resultIndex).Key; var result = new CompositeOperationResult { ReferenceId = subResponse.ReferenceId, EntityId = originalEntityId, HttpStatusCode = subResponse.HttpStatusCode, Success = subResponse.HttpStatusCode >= 200 && subResponse.HttpStatusCode < 300 }; if (result.Success) { // For successful updates, create updated data with the ID var originalData = batch.ElementAt(resultIndex).Value; result.UpdatedData = new Dictionary(originalData) { ["Id"] = originalEntityId }; } else { result.ErrorMessage = subResponse.Body?.ToString() ?? "Unknown error"; } results.Add(result); resultIndex++; } } return results; } catch (Exception ex) { Console.WriteLine($"Error during Salesforce batch update: {ex.Message}"); // Return error results for all operations in this batch return batch.Select((kvp, idx) => new CompositeOperationResult { ReferenceId = $"update_{startIndex + idx}", EntityId = kvp.Key, Success = false, ErrorMessage = ex.Message }).ToList(); } } } }