using GB5Shared.ActionProcessor.Query;
using GB5Shared.DTO.Framework.Login;
using GB5Shared.QueryExecutor;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace GB5Shared.ActionProcessor
{
public interface IWebhookEndpointResolver
{
///
/// Host (and, unlike config, optionally scheme) to substitute for a template's
/// literal {BaseURI} token, taking precedence over
/// SysJobSettings:Gb4ServiceBaseUrl when supplied. Pass the live incoming
/// request's HttpContext.Request.Host ("zero config" — see BaseEndpoint's
/// RequestUrl capture / ActionExecuteEndpoint's frameworkBaseUrl for the same
/// pattern) when the target is reachable through the same gateway the caller
/// itself came in on. Leave null to keep the existing static-config behavior.
///
///
/// MSERVERCONFIG.CONNECTIONNAME for this tenant — NOT the physical database name.
/// ApplicationConnection resolves DB connections by this value, not DatabaseName;
/// passing the raw database name here fails with "No server configuration found".
///
Task ResolveAsync(
int webServiceId,
string? uriParameterValue,
int tenantId,
string connectionName,
CancellationToken ct = default,
string? baseUriOverride = null);
// MWEBSERVICE.REQUESTSCHEMATYPE — 0 means "batch" (caller submits every row as one list in
// a single call), 1 (the default, including every pre-existing row where the column is
// NULL) means "per-row", i.e. today's existing one-call-per-row behavior. Only IceImport's
// MenuWebServiceDispatcher consults this today; WebhookActionHandler (SysJob) has no batch
// concept and never calls it.
Task GetRequestSchemaTypeAsync(int webServiceId, int tenantId, string databaseName, CancellationToken ct = default);
}
///
/// Resolves a full webhook URL by loading MWEBSERVICE.URITEMPLATE/SECONDURITEMPLATE
/// for the given WebServiceId, substituting a literal {BaseURI} token (if present)
/// with either the caller-supplied baseUriOverride or the configured base URL, then
/// substituting any remaining {param} placeholders with UriParameterValue.
///
/// Config key: SysJobSettings:Gb4ServiceBaseUrl (host[:port], no scheme — the
/// templates already carry a literal "http://" ahead of {BaseURI}).
///
public sealed class WebhookEndpointResolver : IWebhookEndpointResolver
{
private static readonly Regex _placeholder = new(
@"\{[^}]+\}", RegexOptions.Compiled);
private readonly IQueryExecutor _qe;
private readonly IConfiguration _config;
private readonly ILogger _logger;
public WebhookEndpointResolver(
IQueryExecutor qe,
IConfiguration config,
ILogger logger)
{
_qe = qe;
_config = config;
_logger = logger;
}
public async Task ResolveAsync(
int webServiceId,
string? uriParameterValue,
int tenantId,
string connectionName,
CancellationToken ct = default,
string? baseUriOverride = null)
{
try
{
// ✅ DatabaseName here must hold the CONNECTION NAME (matches how a real,
// live user session's LoginDTO.DatabaseName is populated by AuthenticationBLL,
// and what ApplicationConnection.DBConnectionStringCached keys its lookup on)
// — not the tenant's literal database name.
var login = new LoginDTO
{
ClientId = tenantId,
DatabaseName = connectionName
};
var row = await _qe.QuerySingleAsync(
login,
WebhookEndpointQB.GET_WEBSERVICE_URL,
new { WebServiceId = webServiceId, TenantId = tenantId },
cancellationToken: ct).ConfigureAwait(false);
if (row is null || string.IsNullOrWhiteSpace(row.UriTemplate))
{
_logger.LogWarning(
"WebhookEndpointResolver: no active MWEBSERVICE for WebServiceId={Id} TenantId={T}",
webServiceId, tenantId);
return null;
}
// SECONDURITEMPLATE, when set, takes priority over URITEMPLATE, but is NOT
// necessarily already a complete URL — it can carry the exact same literal
// {BaseURI} token URITEMPLATE does (e.g. "http://{BaseURI}/prs/TimeSlip/
// GetTimeSlip/?TimeSlipId={TimeSlipId}"). Route it through the SAME
// {BaseURI}-then-{param} resolution below instead of a bare, single-value
// regex replace — the old code here replaced {BaseURI} itself with
// uriParameterValue (e.g. an ObjectId), producing a URL with the record's ID
// as its HOST. Both templates share one resolution path from this point on.
var template = !string.IsNullOrWhiteSpace(row.SecondUriTemplate)
? row.SecondUriTemplate
: row.UriTemplate;
// {BaseURI} must be resolved BEFORE the generic {param} substitution below:
// _placeholder's regex matches any {...} (including {BaseURI} itself), so
// resolving it first keeps the two placeholder kinds from clobbering each other.
if (template.Contains("{BaseURI}"))
{
// baseUriOverride (the caller's own live request Host — "zero config",
// same convention as BaseEndpoint.RequestUrl / ActionExecuteEndpoint's
// frameworkBaseUrl) wins when supplied; falls back to static config so
// existing non-HTTP-triggered callers (SysJob) keep working unchanged.
var baseUrl = !string.IsNullOrWhiteSpace(baseUriOverride)
? baseUriOverride.TrimEnd('/')
: _config["SysJobSettings:Gb4ServiceBaseUrl"]?.TrimEnd('/') ?? string.Empty;
if (string.IsNullOrEmpty(baseUrl))
{
_logger.LogWarning(
"WebhookEndpointResolver: no baseUriOverride supplied and " +
"SysJobSettings:Gb4ServiceBaseUrl not configured; cannot resolve template={T}",
template);
return null;
}
template = template.Replace("{BaseURI}", baseUrl);
}
// Substitute any remaining {placeholder} with UriParameterValue if provided
if (!string.IsNullOrEmpty(uriParameterValue))
template = _placeholder.Replace(template, uriParameterValue);
return template;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex, "WebhookEndpointResolver: failed to resolve URL for WebServiceId={Id} TenantId={T}",
webServiceId, tenantId);
return null;
}
}
public async Task GetRequestSchemaTypeAsync(
int webServiceId, int tenantId, string databaseName, CancellationToken ct = default)
{
try
{
var login = new LoginDTO
{
ClientId = tenantId,
DatabaseName = databaseName
};
return await _qe.QuerySingleAsync(
login,
WebhookEndpointQB.GET_REQUEST_SCHEMA_TYPE,
new { WebServiceId = webServiceId },
cancellationToken: ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex, "WebhookEndpointResolver: failed to resolve RequestSchemaType for WebServiceId={Id} TenantId={T} — defaulting to per-row (1)",
webServiceId, tenantId);
return 1;
}
}
private sealed class WebServiceUrlRow
{
public string? UriTemplate { get; set; }
public string? SecondUriTemplate { get; set; }
}
}
}