2 Commits

Author SHA1 Message Date
Alessio b75e57fe31 [Feature] Aggiunto supporto OAuth2 client_credentials per Salesforce
Implementato il flusso OAuth2 grant_type=client_credentials come alternativa
al flusso password gia' esistente per l'autenticazione Salesforce server-to-server.
La modifica e' completamente retrocompatibile (default rimane Password).

## Dettaglio modifiche

### CredentialManager/Models/CredentialModels.cs
- Aggiunto enum SalesforceGrantType con valori Password e ClientCredentials
- Aggiunta proprieta' GrantType (default: Password) su RestApiCredential
- Aggiunta proprieta' GrantType (default: Password) su SalesforceCredential

### DataConnection/REST/Configuration/RestServiceOptions.cs
- Aggiunta proprieta' SalesforceGrantType per passare il tipo di flusso al client

### DataConnection/REST/Implementations/SalesforceServiceClient.cs
- Iniettato ILogger<SalesforceServiceClient> con NullLogger come fallback
- Sostituiti ~165 Console.WriteLine con chiamate ILogger appropriate
  (LogDebug per dettagli, LogInformation per eventi, LogWarning/LogError per problemi)
- Aggiunto AuthenticateWithPasswordAsync: incapsula il flusso grant_type=password
- Aggiunto AuthenticateWithClientCredentialsAsync: implementa grant_type=client_credentials
  (richiede solo ClientId e ClientSecret, nessun utente, URL My Domain obbligatorio)
- Aggiunto SendTokenRequestAsync: helper condiviso per la POST al token endpoint
- Aggiornato AuthenticateAsync() override: instrada al flusso corretto in base a GrantType
- Rimosso modificatore static da NormalizeNumericValues (usava _logger, causava CS0120)

### Data_Coupler/Services/DataConnectionFactory.cs
- Mappatura del campo GrantType dalle opzioni Salesforce a RestServiceOptions
- Passaggio dell'ILogger al costruttore di SalesforceServiceClient

### CredentialManager/Services/CredentialService.cs
- SaveRestApiCredentialAsync (blocco Salesforce): serializza GrantType in AdditionalParameters
- SaveSalesforceCredentialAsync: aggiunto GrantType nel dizionario iniziale
- MapToRestApiCredential: deserializza GrantType da AdditionalParameters con Enum.TryParse
- MapToSalesforceCredential: idem per il tipo SalesforceCredential

### DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs
- TestSalesforceOAuthLogin aggiornato: per ClientCredentials invia solo client_id e
  client_secret (senza username/password/security_token); per Password comportamento invariato

### Data_Coupler/Pages/CredentialManagement.razor
- Aggiunto dropdown 'Tipo di Autenticazione OAuth2' nella sezione Salesforce
- I campi Username, Password e Security Token vengono nascosti quando si seleziona
  il flusso ClientCredentials
- Alert contestuale: warning My Domain URL per ClientCredentials, info per Password
- GrantType propagato correttamente in EditRestApiCredential e TestRestApiConnectionFromModal

### AGENTS.md
- Aggiunta sezione di documentazione per la nuova funzionalita' OAuth2 client_credentials
2026-05-24 23:11:22 +02:00
Alessio Dal Santo 9fab99112b [Fix] Sicurezza e affidabilità storico esecuzioni schedulazioni
- SchedulingHistory.razor / .cs: iniettato IWebHostEnvironment per nascondere
  lo stack trace (con percorsi di file) in produzione; in produzione viene
  mostrato solo il messaggio di errore sanitizzato e un avviso che invita a
  consultare i log dell'applicazione; in sviluppo il dettaglio completo resta
  visibile invariato.

- Scheduling.razor.cs (ExecuteScheduleManually): isolata la notifica JS
  (ShowSuccessMessage / ShowErrorMessage) in un blocco try-catch separato per
  TaskCanceledException / OperationCanceledException. In questo modo una
  disconnessione del browser durante un'esecuzione lunga non sovrascrive più
  il risultato già salvato correttamente come 'success' con uno stato 'failed'
  e lo stack trace di un'eccezione JSInterop. L'evento viene registrato come
  avviso di log senza impatto sul record storico.

- ScheduledJobService.cs: aggiunto commento esplicativo sul motivo per cui
  il dettaglio completo (ex.ToString) è salvato nel DB ma la UI ne mostra
  solo la versione sanitizzata in produzione.
2026-05-08 13:46:56 +02:00
12 changed files with 459 additions and 256 deletions
+38
View File
@@ -13,6 +13,44 @@
- **Backup e Ripristino**: Sistema completo di backup/restore per configurazioni e dati
- **Amministrazione Avanzata**: Interfaccia unificata per gestione sistema e sicurezza
## 🚀 **NUOVE FUNZIONALITÀ - Salesforce OAuth2 Client Credentials Flow (2026)**
### Supporto `grant_type=client_credentials` per autenticazione server-to-server
**Data Aggiornamento**: 2026
#### **Panoramica**
Aggiunto supporto per il flusso OAuth2 `client_credentials` come alternativa al flusso `password` già esistente.
Completamente retrocompatibile: il default rimane `Password`.
#### **Enum `SalesforceGrantType`** (in `CredentialManager/Models/CredentialModels.cs`)
```csharp
public enum SalesforceGrantType
{
Password, // grant_type=password — richiede Username, Password, SecurityToken (+ClientId/ClientSecret)
ClientCredentials // grant_type=client_credentials — server-to-server, nessun utente
}
```
#### **Differenze tra i flussi**
| Aspetto | `password` | `client_credentials` |
|---|---|---|
| Richiede Username/Password | ✅ Sì | ❌ No |
| Richiede SecurityToken | ✅ Sì (se non Connected App) | ❌ No |
| ClientId / ClientSecret | Opzionale | ✅ Obbligatorio |
| Base URL | login/test.salesforce.com | **My Domain URL** (es. `https://myorg.my.salesforce.com`) |
| Utente Salesforce | Necessario | Integration User (assegnato nella Connected App) |
#### **File modificati**
- `CredentialManager/Models/CredentialModels.cs` — enum `SalesforceGrantType`, proprietà `GrantType` su `RestApiCredential` e `SalesforceCredential`
- `DataConnection/REST/Configuration/RestServiceOptions.cs` — proprietà `SalesforceGrantType`
- `DataConnection/REST/Implementations/SalesforceServiceClient.cs``AuthenticateWithPasswordAsync`, `AuthenticateWithClientCredentialsAsync`, `SendTokenRequestAsync`; `ILogger` iniettato; `NormalizeNumericValues` reso non-static
- `Data_Coupler/Services/DataConnectionFactory.cs` — mapping `GrantType`, logger passato al client
- `CredentialManager/Services/CredentialService.cs``GrantType` serializzato/deserializzato in `AdditionalParameters` JSON
- `DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs``TestSalesforceOAuthLogin` instrada per `GrantType`
- `Data_Coupler/Pages/CredentialManagement.razor` — dropdown "Tipo di Autenticazione OAuth2"; Username/Password/SecurityToken nascosti per `ClientCredentials`; warning My Domain URL
---
## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Optimizations (Febbraio 2026)**
### Salesforce Batch Describe via Composite API
@@ -22,6 +22,27 @@ public enum RestServiceType
Salesforce
}
/// <summary>
/// Tipo di flusso OAuth2 per Salesforce
/// </summary>
public enum SalesforceGrantType
{
/// <summary>
/// Flusso Username/Password (grant_type=password).
/// Richiede: ClientId, ClientSecret, Username, Password (+SecurityToken se non IP-trusted).
/// URL di login: https://login.salesforce.com o https://test.salesforce.com.
/// </summary>
Password,
/// <summary>
/// Flusso Client Credentials (grant_type=client_credentials) — server-to-server, senza utente.
/// Richiede: ClientId, ClientSecret.
/// URL obbligatorio: My Domain URL (es. https://myorg.my.salesforce.com).
/// La Connected App deve avere "Enable Client Credentials Flow" attivato e un Integration User assegnato.
/// </summary>
ClientCredentials
}
/// <summary>
/// Tipi di database supportati (allineato con DataConnection.Enums.DatabaseType)
/// </summary>
@@ -106,6 +127,7 @@ public class RestApiCredential
public string? ApiVersion { get; set; } = "59.0";
public bool IsSandbox { get; set; } = false;
public bool UseSoapApi { get; set; } = false;
public SalesforceGrantType GrantType { get; set; } = SalesforceGrantType.Password;
public string? RefreshToken { get; set; }
public string? AccessToken { get; set; }
public DateTime? TokenExpiry { get; set; }
@@ -145,6 +167,8 @@ public class SalesforceCredential
public bool IsSandbox { get; set; } = false; // Se è un ambiente sandbox
public int TimeoutSeconds { get; set; } = 120;
public bool UseSoapApi { get; set; } = false; // Se usare SOAP invece di REST
/// <summary>Tipo di flusso OAuth2 da utilizzare. Default: Password (retrocompatibile).</summary>
public SalesforceGrantType GrantType { get; set; } = SalesforceGrantType.Password;
public string? RefreshToken { get; set; }
public string? AccessToken { get; set; }
public DateTime? TokenExpiry { get; set; }
@@ -233,6 +233,7 @@ public class CredentialService : ICredentialService
additionalParams["ApiVersion"] = credential.ApiVersion;
additionalParams["IsSandbox"] = credential.IsSandbox.ToString();
additionalParams["UseSoapApi"] = credential.UseSoapApi.ToString();
additionalParams["GrantType"] = credential.GrantType.ToString();
if (!string.IsNullOrEmpty(credential.RefreshToken))
additionalParams["RefreshToken"] = credential.RefreshToken;
if (!string.IsNullOrEmpty(credential.AccessToken))
@@ -523,7 +524,8 @@ public class CredentialService : ICredentialService
["SecurityToken"] = credential.SecurityToken,
["ApiVersion"] = credential.ApiVersion,
["IsSandbox"] = credential.IsSandbox.ToString(),
["UseSoapApi"] = credential.UseSoapApi.ToString()
["UseSoapApi"] = credential.UseSoapApi.ToString(),
["GrantType"] = credential.GrantType.ToString()
};
// Aggiungi ClientId e ClientSecret se forniti
@@ -793,6 +795,8 @@ public class CredentialService : ICredentialService
credential.IsSandbox = sandbox;
if (additionalParams.TryGetValue("UseSoapApi", out var useSoap) && bool.TryParse(useSoap, out var soap))
credential.UseSoapApi = soap;
if (additionalParams.TryGetValue("GrantType", out var grantTypeStr) && Enum.TryParse<SalesforceGrantType>(grantTypeStr, out var grantType))
credential.GrantType = grantType;
if (additionalParams.TryGetValue("RefreshToken", out var refreshToken))
credential.RefreshToken = refreshToken;
if (additionalParams.TryGetValue("AccessToken", out var accessToken))
@@ -915,6 +919,8 @@ public class CredentialService : ICredentialService
credential.IsSandbox = sandbox;
if (additionalParams.TryGetValue("UseSoapApi", out var useSoap) && bool.TryParse(useSoap, out var soap))
credential.UseSoapApi = soap;
if (additionalParams.TryGetValue("GrantType", out var grantTypeStr) && Enum.TryParse<SalesforceGrantType>(grantTypeStr, out var grantType))
credential.GrantType = grantType;
if (additionalParams.TryGetValue("RefreshToken", out var refreshToken))
credential.RefreshToken = refreshToken;
if (additionalParams.TryGetValue("AccessToken", out var accessToken))
@@ -863,23 +863,39 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
try
{
var tokenUrl = credential.LoginUrl.TrimEnd('/') + "/services/oauth2/token";
List<KeyValuePair<string, string>> tokenData;
var tokenData = new List<KeyValuePair<string, string>>
if (credential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials)
{
new("grant_type", "password"),
new("username", credential.Username),
new("password", credential.Password + credential.SecurityToken),
new("client_id", credential.ClientId ?? ""),
new("client_secret", credential.ClientSecret ?? "")
};
// Client Credentials flow — server-to-server, no user
tokenData = new List<KeyValuePair<string, string>>
{
new("grant_type", "client_credentials"),
new("client_id", credential.ClientId ?? ""),
new("client_secret", credential.ClientSecret ?? "")
};
}
else
{
// Password flow (default)
tokenData = new List<KeyValuePair<string, string>>
{
new("grant_type", "password"),
new("username", credential.Username),
new("password", credential.Password + credential.SecurityToken),
new("client_id", credential.ClientId ?? ""),
new("client_secret", credential.ClientSecret ?? "")
};
}
var tokenContent = new FormUrlEncodedContent(tokenData);
var response = await httpClient.PostAsync(tokenUrl, tokenContent);
if (response.IsSuccessStatusCode)
{
var responseContent = await response.Content.ReadAsStringAsync();
return (true, $"Connessione Salesforce riuscita!\n\nDettagli:\n- Login URL: {credential.LoginUrl}\n- API Version: {credential.ApiVersion}\n- Sandbox: {credential.IsSandbox}\n- Tipo Auth: OAuth2\n- Timeout: {credential.TimeoutSeconds}s");
var flowLabel = credential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials
? "client_credentials" : "password";
return (true, $"Connessione Salesforce riuscita!\n\nDettagli:\n- Login URL: {credential.LoginUrl}\n- API Version: {credential.ApiVersion}\n- Sandbox: {credential.IsSandbox}\n- Tipo Auth: OAuth2 ({flowLabel})\n- Timeout: {credential.TimeoutSeconds}s");
}
else
{
@@ -41,6 +41,11 @@ namespace DataConnection.REST.Configuration
/// </summary>
public bool IgnoreSslErrors { get; set; } = false;
// Add other relevant configuration properties (e.g., OAuth settings, specific headers)
/// <summary>
/// Salesforce OAuth2 grant type. Default: Password (retrocompatibile).
/// ClientCredentials = server-to-server, senza utente.
/// </summary>
public CredentialManager.Models.SalesforceGrantType SalesforceGrantType { get; set; }
= CredentialManager.Models.SalesforceGrantType.Password;
}
}
File diff suppressed because it is too large Load Diff
@@ -286,6 +286,8 @@ public class ScheduledJobService : BackgroundService
executionHistory.EndTime = DateTime.Now;
executionHistory.Status = "failed";
executionHistory.Message = $"Errore durante l'esecuzione automatica: {ex.Message}";
// Memorizza il dettaglio completo (stack trace) solo per scopi diagnostici;
// la UI in produzione ne mostrerà una versione sanitizzata senza percorsi di file.
executionHistory.ErrorDetails = ex.ToString();
await scheduleService.UpdateExecutionHistoryAsync(executionHistory);
}
+38 -12
View File
@@ -663,11 +663,30 @@ else
} <!-- Campi specifici per Salesforce -->
@if (currentRestApiCredential.ServiceType == RestServiceType.Salesforce)
{
<div class="alert alert-info">
<strong>Opzioni di Autenticazione:</strong><br/>
• <strong>Username/Password + Security Token:</strong> Autenticazione standard<br/>
• <strong>Username/Password + Client ID/Secret:</strong> Autenticazione OAuth<br/>
• Il Security Token è richiesto solo se non si configura una Connected App (Client ID/Secret)
<div class="row mb-3">
<div class="col-md-12">
<label class="form-label fw-semibold">Tipo di Autenticazione OAuth2</label>
<InputSelect class="form-select" @bind-Value="currentRestApiCredential.GrantType">
<option value="Password">Password Flow — Username + Password + Security Token (grant_type=password)</option>
<option value="ClientCredentials">Client Credentials — Server-to-Server, nessun utente (grant_type=client_credentials)</option>
</InputSelect>
@if (currentRestApiCredential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials)
{
<div class="alert alert-warning mt-2 py-2">
<i class="fa fa-exclamation-triangle me-1"></i>
<strong>client_credentials</strong>: il <strong>Base URL</strong> deve essere il <strong>My Domain URL</strong> della tua org
(es. <code>https://myorg.my.salesforce.com</code>), <strong>non</strong> login.salesforce.com.<br/>
Richiede: Connected App con "Enable Client Credentials Flow" attivato e un Integration User assegnato.
</div>
}
else
{
<div class="alert alert-info mt-2 py-2">
<i class="fa fa-info-circle me-1"></i>
<strong>password flow</strong>: Username, Password + Security Token. Client ID/Secret facoltativi (Connected App).
</div>
}
</div>
</div>
<div class="row">
<div class="col-md-6">
@@ -687,11 +706,15 @@ else
<div class="form-text">Esempio: 59.0</div>
</div>
</div>
</div> <div class="mb-3">
<label class="form-label">@GetFieldLabel("SecurityToken", currentRestApiCredential.ServiceType)</label>
<InputText type="password" class="form-control" @bind-Value="currentRestApiCredential.SecurityToken" />
<div class="form-text">Token di sicurezza Salesforce (richiesto solo se non si usa OAuth o Connected App)</div>
</div>
@if (currentRestApiCredential.GrantType != CredentialManager.Models.SalesforceGrantType.ClientCredentials)
{
<div class="mb-3">
<label class="form-label">@GetFieldLabel("SecurityToken", currentRestApiCredential.ServiceType)</label>
<InputText type="password" class="form-control" @bind-Value="currentRestApiCredential.SecurityToken" />
<div class="form-text">Token di sicurezza Salesforce (richiesto solo se non si usa OAuth o Connected App)</div>
</div>
}
<div class="row">
<div class="col-md-6">
@@ -738,8 +761,9 @@ else
}
<!-- Campi comuni per autenticazione username/password -->
@if (currentRestApiCredential.ServiceType != RestServiceType.Generic ||
(string.IsNullOrEmpty(currentRestApiCredential.ApiKey) && string.IsNullOrEmpty(currentRestApiCredential.AuthToken)))
@if ((currentRestApiCredential.ServiceType != RestServiceType.Generic ||
(string.IsNullOrEmpty(currentRestApiCredential.ApiKey) && string.IsNullOrEmpty(currentRestApiCredential.AuthToken))) &&
!(currentRestApiCredential.ServiceType == RestServiceType.Salesforce && currentRestApiCredential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials))
{ <div class="row">
<div class="col-md-6">
<div class="mb-3">
@@ -1312,6 +1336,7 @@ else
ApiVersion = credential.ApiVersion,
IsSandbox = credential.IsSandbox,
UseSoapApi = credential.UseSoapApi,
GrantType = credential.GrantType,
RefreshToken = credential.RefreshToken,
AccessToken = credential.AccessToken,
TokenExpiry = credential.TokenExpiry
@@ -1533,7 +1558,8 @@ else
ClientSecret = currentRestApiCredential.ClientSecret,
ApiVersion = currentRestApiCredential.ApiVersion,
IsSandbox = currentRestApiCredential.IsSandbox,
UseSoapApi = currentRestApiCredential.UseSoapApi
UseSoapApi = currentRestApiCredential.UseSoapApi,
GrantType = currentRestApiCredential.GrantType
}; // Salviamo temporaneamente la credenziale per il test
await CredentialService.SaveRestApiCredentialAsync(tempCredential);
+21 -6
View File
@@ -311,13 +311,20 @@ public partial class Scheduling : ComponentBase
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, status, message, result.RecordsProcessed);
if (result.IsSuccess)
// Notifica l'utente (best-effort: la connessione browser potrebbe essere stata interrotta
// durante un'esecuzione lunga senza che questo invalidi il risultato già salvato).
try
{
await ShowSuccessMessage($"Schedulazione eseguita con successo! {result.RecordsProcessed} record elaborati in {result.Duration.TotalSeconds:F2} secondi.");
if (result.IsSuccess)
await ShowSuccessMessage($"Schedulazione eseguita con successo! {result.RecordsProcessed} record elaborati in {result.Duration.TotalSeconds:F2} secondi.");
else
await ShowErrorMessage($"Errore durante l'esecuzione: {result.ErrorMessage}");
}
else
catch (OperationCanceledException)
{
await ShowErrorMessage($"Errore durante l'esecuzione: {result.ErrorMessage}");
// La connessione Blazor è stata interrotta durante l'esecuzione: il risultato è
// già stato salvato correttamente, la notifica non può essere recapitata.
Logger.LogWarning("Notifica UI non inviata per la schedulazione {ScheduleId}: connessione browser interrotta durante l'esecuzione", scheduleId);
}
await LoadSchedules();
@@ -326,7 +333,7 @@ public partial class Scheduling : ComponentBase
{
Logger.LogError(ex, "Errore nell'esecuzione manuale schedulazione {ScheduleId}", scheduleId);
// Aggiorna lo storico in caso di eccezione
// Aggiorna lo storico in caso di eccezione durante l'esecuzione effettiva
if (executionHistory != null)
{
executionHistory.EndTime = DateTime.Now;
@@ -337,7 +344,15 @@ public partial class Scheduling : ComponentBase
}
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, "failed", $"Errore: {ex.Message}");
await ShowErrorMessage("Errore nell'esecuzione: " + ex.Message);
try
{
await ShowErrorMessage("Errore nell'esecuzione: " + ex.Message);
}
catch (OperationCanceledException)
{
Logger.LogWarning("Notifica UI non inviata per la schedulazione {ScheduleId}: connessione browser non disponibile", scheduleId);
}
}
finally
{
+9 -1
View File
@@ -235,7 +235,15 @@
{
<h6>Dettagli Errori</h6>
<div class="alert alert-danger">
<pre style="white-space: pre-wrap; font-size: 0.85em;">@selectedExecution.ErrorDetails</pre>
@if (IsDevelopment)
{
<pre style="white-space: pre-wrap; font-size: 0.85em;">@selectedExecution.ErrorDetails</pre>
}
else
{
<p class="mb-1">@GetSanitizedErrorMessage(selectedExecution.ErrorDetails)</p>
<small class="text-muted"><i class="fas fa-info-circle"></i> Per i dettagli tecnici completi consultare i log dell'applicazione.</small>
}
</div>
}
@@ -1,6 +1,7 @@
using CredentialManager.Models;
using CredentialManager.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
@@ -11,6 +12,29 @@ public partial class SchedulingHistory : ComponentBase
[Inject] private IProfileScheduleService ScheduleService { get; set; } = null!;
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
[Inject] private ILogger<SchedulingHistory> Logger { get; set; } = null!;
[Inject] private IWebHostEnvironment WebHostEnvironment { get; set; } = null!;
protected bool IsDevelopment => WebHostEnvironment.IsDevelopment();
/// <summary>
/// Restituisce solo il messaggio dell'eccezione (senza stack trace) per la visualizzazione in produzione.
/// </summary>
protected static string GetSanitizedErrorMessage(string errorDetails)
{
if (string.IsNullOrEmpty(errorDetails))
return string.Empty;
// Prende solo le righe fino al primo stack frame (riga che inizia con " at")
var lines = errorDetails.Split('\n');
var messageLines = new System.Collections.Generic.List<string>();
foreach (var line in lines)
{
if (line.TrimStart().StartsWith("at ", StringComparison.Ordinal))
break;
messageLines.Add(line.TrimEnd());
}
return string.Join("\n", messageLines).Trim();
}
protected List<ScheduleExecutionHistory>? executionHistory;
protected ScheduleExecutionHistory? selectedExecution;
@@ -133,7 +133,9 @@ namespace Data_Coupler.Services
// Per Salesforce usiamo i campi specifici ClientId e ClientSecret
options.ApiKey = credential.ClientId; // ClientId -> ApiKey
options.AuthToken = credential.ClientSecret; // ClientSecret -> AuthToken
_logger.LogInformation("Salesforce mapping - ClientId: '{ClientId}', ClientSecret: {HasSecret}, Username: '{Username}', Password: {HasPassword}",
options.SalesforceGrantType = credential.GrantType;
_logger.LogInformation("Salesforce mapping - GrantType: {GrantType}, ClientId: '{ClientId}', ClientSecret: {HasSecret}, Username: '{Username}', Password: {HasPassword}",
credential.GrantType,
credential.ClientId,
!string.IsNullOrEmpty(credential.ClientSecret),
credential.Username,
@@ -215,7 +217,9 @@ namespace Data_Coupler.Services
{
var httpClientFactory = _serviceProvider.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient();
return new SalesforceServiceClient(httpClient, options);
var loggerFactory = _serviceProvider.GetService<Microsoft.Extensions.Logging.ILoggerFactory>();
var sfLogger = loggerFactory?.CreateLogger<DataConnection.REST.Implementations.SalesforceServiceClient>();
return new DataConnection.REST.Implementations.SalesforceServiceClient(httpClient, options, sfLogger);
}
/// <summary>