feat: Implement ExistingDatabaseContext for managing existing databases with customizable naming strategies and auto-discovery of entities

feat: Add SqlServerSchemaProvider for extracting database schema information from SQL Server

feat: Introduce DatabaseType and NamingStrategy enums for better database management and naming conventions

feat: Create IDatabaseDiscovery and IDatabaseManager interfaces for database operations and metadata retrieval

feat: Develop REST service client architecture with BaseRestServiceClient and SAP Business One specific implementation

feat: Implement REST service discovery page with UI for connecting to SAP Business One Service Layer and displaying discovered entities
This commit is contained in:
2025-04-29 00:16:03 +02:00
parent d1103c4e7d
commit 7346db3b63
22 changed files with 758 additions and 1 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<Folder Include="Models\" />
<Folder Include="DB\Models\" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,46 @@
namespace DataConnection.REST.Configuration
{
/// <summary>
/// Configuration options for a REST service client.
/// </summary>
public class RestServiceOptions
{
/// <summary>
/// Base URL of the REST service.
/// </summary>
public string? BaseUrl { get; set; }
/// <summary>
/// API Key, if required.
/// </summary>
public string? ApiKey { get; set; }
/// <summary>
/// Username for authentication, if required.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// Password for authentication, if required.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Authentication token (e.g., Bearer token), if required.
/// </summary>
public string? AuthToken { get; set; }
/// <summary>
/// Timeout for requests in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 100; // Default timeout
/// <summary>
/// Gets or sets a value indicating whether to ignore SSL certificate errors.
/// Use with caution, especially in production environments.
/// </summary>
public bool IgnoreSslErrors { get; set; } = false;
// Add other relevant configuration properties (e.g., OAuth settings, specific headers)
}
}
@@ -0,0 +1,119 @@
using DataConnection.REST.Configuration;
using DataConnection.REST.Interfaces;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace DataConnection.REST.Implementations
{
/// <summary>
/// Base implementation for a REST service client using HttpClient.
/// </summary>
public class BaseRestServiceClient : IRestServiceClient, IDisposable
{
protected readonly HttpClient _httpClient;
protected readonly RestServiceOptions _options;
public BaseRestServiceClient(HttpClient httpClient, RestServiceOptions options)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
ConfigureHttpClient();
}
protected virtual void ConfigureHttpClient()
{
if (!string.IsNullOrWhiteSpace(_options.BaseUrl))
{
_httpClient.BaseAddress = new Uri(_options.BaseUrl);
}
_httpClient.Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds);
_httpClient.DefaultRequestHeaders.Accept.Clear();
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Add authentication headers based on options
if (!string.IsNullOrWhiteSpace(_options.ApiKey))
{
// Example: Add API key header (adjust header name as needed)
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation("X-API-KEY", _options.ApiKey);
}
else if (!string.IsNullOrWhiteSpace(_options.AuthToken))
{
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _options.AuthToken);
}
else if (!string.IsNullOrWhiteSpace(_options.Username) && !string.IsNullOrWhiteSpace(_options.Password))
{
var basicAuthValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_options.Username}:{_options.Password}"));
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", basicAuthValue);
}
// Add other authentication methods (e.g., OAuth) if necessary
}
public virtual async Task<T?> GetAsync<T>(string requestUri, CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync(requestUri, cancellationToken);
response.EnsureSuccessStatusCode(); // Throws exception for non-success status codes
return await response.Content.ReadFromJsonAsync<T>(cancellationToken: cancellationToken);
}
catch (HttpRequestException ex)
{
// Log or handle specific HTTP request errors
Console.WriteLine($"HTTP Request Error: {ex.Message}");
// Consider custom exception handling
throw;
}
catch (Exception ex)
{
// Log or handle other errors (e.g., deserialization)
Console.WriteLine($"Error during GET request: {ex.Message}");
throw;
}
}
public virtual async Task<TResponse?> PostAsync<TRequest, TResponse>(string requestUri, TRequest payload, CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.PostAsJsonAsync(requestUri, payload, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TResponse>(cancellationToken: cancellationToken);
}
catch (HttpRequestException ex)
{
Console.WriteLine($"HTTP Request Error: {ex.Message}");
throw;
}
catch (Exception ex)
{
Console.WriteLine($"Error during POST request: {ex.Message}");
throw;
}
}
// Implement other methods (PUT, DELETE, etc.) similarly
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
// HttpClient is typically managed by HttpClientFactory,
// but if created directly, dispose it here.
// _httpClient?.Dispose();
}
}
}
}
@@ -0,0 +1,310 @@
using DataConnection.REST.Configuration;
using DataConnection.REST.Interfaces;
using DataConnection.REST.Models; // Added for metadata models
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq; // Added for XML parsing
namespace DataConnection.REST.Implementations
{
/// <summary>
/// Client specific for SAP Business One Service Layer.
/// Handles session-based authentication and metadata discovery.
/// </summary>
public class SapB1ServiceClient : BaseRestServiceClient, IRestMetadataDiscovery // Implement the interface
{
private CookieContainer _cookieContainer;
private HttpClientHandler _httpClientHandler;
private HttpClient _sapHttpClient; // Use a dedicated HttpClient with cookie handling
// SAP B1 Specific Login Info
private string? _b1SessionId;
private string? _routeId;
private DateTime _sessionTimeout;
// Consider making options specific to SAP B1 if needed
public SapB1ServiceClient(RestServiceOptions options)
: base(CreateConfiguredHttpClient(options, out var handler, out var container), options)
{
// Store handler and container for direct cookie access if needed
_httpClientHandler = handler;
_cookieContainer = container;
_sapHttpClient = _httpClient; // Use the base class's configured HttpClient
}
private static HttpClient CreateConfiguredHttpClient(RestServiceOptions options, out HttpClientHandler handler, out CookieContainer container)
{
container = new CookieContainer();
handler = new HttpClientHandler
{
CookieContainer = container,
UseCookies = true,
// Service Layer might use self-signed certificates in test environments
// Use cautiously in production!
// ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
// Conditionally ignore SSL errors based on options
if (options.IgnoreSslErrors)
{
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
// Log a warning when ignoring SSL errors
Console.WriteLine("Warning: Ignoring SSL certificate errors. Use only for trusted development environments.");
}
var client = new HttpClient(handler);
// Configure base address, timeout etc. from base class logic
// The base constructor will call ConfigureHttpClient which uses _options
return client;
}
/// <summary>
/// Logs into SAP Business One Service Layer.
/// </summary>
/// <param name="companyDB">The Company Database name.</param>
/// <param name="userName">Service Layer username.</param>
/// <param name="password">Service Layer password.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if login is successful, false otherwise.</returns>
public async Task<bool> LoginAsync(string companyDB, string userName, string password, CancellationToken cancellationToken = default)
{
var loginPayload = new { CompanyDB = companyDB, UserName = userName, Password = password };
var loginUri = "/b1s/v2/Login"; // Assuming v1, adjust if needed
try
{
// Use the internal HttpClient configured with CookieContainer
var response = await _sapHttpClient.PostAsJsonAsync(loginUri, loginPayload, cancellationToken);
if (!response.IsSuccessStatusCode)
{
// Log error details
Console.WriteLine($"SAP B1 Login failed: {response.StatusCode}");
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
Console.WriteLine($"Error details: {errorContent}");
return false;
}
// Extract session details from response (if needed directly) and cookies
var sessionInfo = await response.Content.ReadFromJsonAsync<SapB1SessionInfo>(cancellationToken: cancellationToken);
// Cookies are automatically handled by HttpClientHandler and CookieContainer
// Optionally, store session details if needed for manual checks or info
if (sessionInfo != null)
{
_b1SessionId = GetCookieValue("B1SESSION");
_routeId = GetCookieValue("ROUTEID"); // Important for sticky sessions in load-balanced environments
_sessionTimeout = DateTime.UtcNow.AddSeconds(sessionInfo.SessionTimeout);
Console.WriteLine($"SAP B1 Login successful. Session expires around: {_sessionTimeout.ToLocalTime()}");
return true;
}
Console.WriteLine("SAP B1 Login response could not be parsed.");
return false;
}
catch (HttpRequestException ex)
{
Console.WriteLine($"HTTP Request Error during SAP B1 Login: {ex.Message}");
return false;
}
catch (JsonException ex)
{
Console.WriteLine($"JSON Parsing Error during SAP B1 Login: {ex.Message}");
return false;
}
catch (Exception ex)
{
Console.WriteLine($"Error during SAP B1 Login: {ex.Message}");
return false;
}
}
/// <summary>
/// Logs out from SAP Business One Service Layer.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task representing the asynchronous operation.</returns>
public async Task LogoutAsync(CancellationToken cancellationToken = default)
{
var logoutUri = "/b1s/v2/Logout";
try
{
// Send POST request to Logout endpoint. Cookies are sent automatically.
var response = await _sapHttpClient.PostAsync(logoutUri, null, cancellationToken);
response.EnsureSuccessStatusCode(); // Check if logout was successful
// Clear local session state
_b1SessionId = null;
_routeId = null;
// Optionally clear cookies from container if needed, though they become invalid
ClearCookies();
Console.WriteLine("SAP B1 Logout successful.");
}
catch (HttpRequestException ex)
{
// Log or handle error, maybe session already expired
Console.WriteLine($"HTTP Request Error during SAP B1 Logout: {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"Error during SAP B1 Logout: {ex.Message}");
}
}
/// <summary>
/// Checks if the current session is active based on timeout.
/// </summary>
public bool IsSessionActive()
{
return !string.IsNullOrEmpty(_b1SessionId) && DateTime.UtcNow < _sessionTimeout;
}
/// <summary>
/// Discovers entities and their properties from the SAP B1 Service Layer metadata.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of discovered entity information.</returns>
public async Task<List<RestEntityInfo>> DiscoverEntitiesAsync(CancellationToken cancellationToken = default)
{
// Ensure session is active before attempting discovery
if (!IsSessionActive())
{
// Or attempt login? For now, throw exception or return empty list
Console.WriteLine("Error: Not logged into SAP B1 Service Layer. Cannot discover metadata.");
// Consider throwing a specific exception
return new List<RestEntityInfo>();
}
var metadataUri = "/b1s/v2/$metadata"; // Adjust version (v1/v2) if necessary
var entities = new List<RestEntityInfo>();
try
{
// Use the internal HttpClient which handles cookies
var response = await _sapHttpClient.GetAsync(metadataUri, cancellationToken);
response.EnsureSuccessStatusCode();
var xmlContent = await response.Content.ReadAsStringAsync(cancellationToken);
var edmx = XDocument.Parse(xmlContent);
// Define XML namespaces (adjust if different in your specific $metadata)
XNamespace edm = "http://schemas.microsoft.com/ado/2009/11/edm";
XNamespace edmxNs = "http://schemas.microsoft.com/ado/2007/06/edmx"; // Older namespace sometimes used
XNamespace defaultEdmNs = edmx.Root?.GetDefaultNamespace() ?? edm; // Get default namespace if present
XNamespace dataServicesNs = edmx.Root?.Element(edmxNs + "DataServices")?.GetDefaultNamespace() ?? defaultEdmNs;
// Find the Schema element (might be nested)
var schemaElement = edmx.Descendants(defaultEdmNs + "Schema").FirstOrDefault() ??
edmx.Descendants(dataServicesNs + "Schema").FirstOrDefault();
if (schemaElement == null)
{
Console.WriteLine("Error: Could not find Schema element in $metadata response.");
return entities;
}
// Find all EntityType elements within the Schema
foreach (var entityTypeElement in schemaElement.Elements(defaultEdmNs + "EntityType"))
{
var entityInfo = new RestEntityInfo
{
Name = entityTypeElement.Attribute("Name")?.Value ?? string.Empty
};
if (string.IsNullOrEmpty(entityInfo.Name)) continue; // Skip if name is missing
// Find key properties for this entity type
var keyProperties = new HashSet<string>();
var keyElement = entityTypeElement.Element(defaultEdmNs + "Key");
if (keyElement != null)
{
foreach (var propRef in keyElement.Elements(defaultEdmNs + "PropertyRef"))
{
var keyName = propRef.Attribute("Name")?.Value;
if (!string.IsNullOrEmpty(keyName)) keyProperties.Add(keyName);
}
}
// Find properties for this entity type
foreach (var propertyElement in entityTypeElement.Elements(defaultEdmNs + "Property"))
{
var propName = propertyElement.Attribute("Name")?.Value;
if (string.IsNullOrEmpty(propName)) continue;
var propInfo = new RestPropertyInfo
{
Name = propName,
Type = propertyElement.Attribute("Type")?.Value ?? string.Empty,
IsKey = keyProperties.Contains(propName)
// Add extraction for Nullable, MaxLength etc. if needed from attributes
};
entityInfo.Properties.Add(propInfo);
}
entities.Add(entityInfo);
}
}
catch (HttpRequestException ex)
{
Console.WriteLine($"HTTP Request Error during metadata discovery: {ex.Message}");
// Handle error appropriately
}
catch (XmlException ex)
{
Console.WriteLine($"XML Parsing Error during metadata discovery: {ex.Message}");
// Handle error appropriately
}
catch (Exception ex)
{
Console.WriteLine($"Error during metadata discovery: {ex.Message}");
// Handle error appropriately
}
return entities;
}
// Helper to get cookie value
private string? GetCookieValue(string cookieName)
{
if (_httpClient.BaseAddress == null) return null;
var cookies = _cookieContainer.GetCookies(_httpClient.BaseAddress);
return cookies[cookieName]?.Value;
}
// Helper to clear cookies
private void ClearCookies()
{
if (_httpClient.BaseAddress == null) return;
var cookies = _cookieContainer.GetCookies(_httpClient.BaseAddress);
foreach (Cookie cookie in cookies)
{
cookie.Expired = true;
}
}
// Ensure logout on dispose? Optional, depends on desired lifecycle management.
// public override void Dispose() { ... LogoutAsync().Wait(); ... base.Dispose(); }
// --- Nested class for deserializing Login response ---
private class SapB1SessionInfo
{
public string SessionId { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public int SessionTimeout { get; set; } // In seconds
}
}
}
@@ -0,0 +1,20 @@
using DataConnection.REST.Models;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace DataConnection.REST.Interfaces
{
/// <summary>
/// Interface for discovering metadata (entities, properties) from a REST service.
/// </summary>
public interface IRestMetadataDiscovery
{
/// <summary>
/// Discovers entities and their properties from the REST service metadata.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of discovered entity information.</returns>
Task<List<RestEntityInfo>> DiscoverEntitiesAsync(CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,33 @@
using System.Threading.Tasks;
namespace DataConnection.REST.Interfaces
{
/// <summary>
/// Interface for a generic REST service client.
/// </summary>
public interface IRestServiceClient
{
/// <summary>
/// Sends a GET request to the specified URI.
/// </summary>
/// <typeparam name="T">The type of the object to deserialize the response content to.</typeparam>
/// <param name="requestUri">The URI the request is sent to.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The deserialized response content.</returns>
Task<T?> GetAsync<T>(string requestUri, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a POST request to the specified URI.
/// </summary>
/// <typeparam name="TRequest">The type of the request object.</typeparam>
/// <typeparam name="TResponse">The type of the object to deserialize the response content to.</typeparam>
/// <param name="requestUri">The URI the request is sent to.</param>
/// <param name="payload">The HTTP request content sent to the server.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The deserialized response content.</returns>
Task<TResponse?> PostAsync<TRequest, TResponse>(string requestUri, TRequest payload, CancellationToken cancellationToken = default);
// Add other methods as needed (PUT, DELETE, PATCH, etc.)
// Consider adding methods for handling raw HttpResponseMessage or string responses
}
}
@@ -0,0 +1,25 @@
using System.Collections.Generic;
namespace DataConnection.REST.Models
{
/// <summary>
/// Represents information about a discovered REST entity (resource).
/// </summary>
public class RestEntityInfo
{
public string Name { get; set; } = string.Empty;
public List<RestPropertyInfo> Properties { get; set; } = new List<RestPropertyInfo>();
// Add other relevant info like Key properties if needed
}
/// <summary>
/// Represents information about a property of a discovered REST entity.
/// </summary>
public class RestPropertyInfo
{
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty; // Type as defined in the metadata (e.g., Edm.String, Edm.Int32)
public bool IsKey { get; set; } = false;
// Add other relevant info like Nullable, MaxLength etc. if needed
}
}