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. Username: '{_options.Username}', Password: '{(!string.IsNullOrEmpty(_options.Password) ? "***SET***" : "***NULL***")}'");
return false;
}
if (string.IsNullOrEmpty(_options.ApiKey) || string.IsNullOrEmpty(_options.AuthToken))
{
Console.WriteLine($"Salesforce authentication requires ApiKey (ClientId) and AuthToken (ClientSecret) in options. ApiKey: '{_options.ApiKey}', AuthToken: '{(!string.IsNullOrEmpty(_options.AuthToken) ? "***SET***" : "***NULL***")}'");
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();
}
}
}
}