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; 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: {System.Text.Json.JsonSerializer.Serialize(entityData)}"); var response = await _httpClient.PostAsJsonAsync(createUri, entityData, 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 = System.Text.Json.JsonSerializer.Deserialize>(responseContent); // 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); } // --- 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>(); } } }