| | | 1 | | using Kestrun.Models; |
| | | 2 | | using System.Text.RegularExpressions; |
| | | 3 | | using Serilog.Events; |
| | | 4 | | namespace Kestrun.Callback; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Factory for creating <see cref="CallbackRequest"/> instances from callback plans and runtime context. |
| | | 8 | | /// </summary> |
| | | 9 | | public static partial class CallbackRequestFactory |
| | | 10 | | { |
| | 1 | 11 | | private static readonly Regex TemplateParamRegex = |
| | 1 | 12 | | TemplateParameterRegex(); |
| | | 13 | | // - captures {id} and also {id:int} style constraints (keeps "id") |
| | | 14 | | |
| | | 15 | | private static HashSet<string> ExtractTemplateParams(string urlTemplate) |
| | | 16 | | { |
| | 2 | 17 | | var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | | 18 | | |
| | 2 | 19 | | if (string.IsNullOrWhiteSpace(urlTemplate)) |
| | | 20 | | { |
| | 0 | 21 | | return names; |
| | | 22 | | } |
| | | 23 | | |
| | 8 | 24 | | foreach (Match m in TemplateParamRegex.Matches(urlTemplate)) |
| | | 25 | | { |
| | 2 | 26 | | var name = m.Groups["name"].Value.Trim(); |
| | 2 | 27 | | if (!string.IsNullOrEmpty(name)) |
| | | 28 | | { |
| | 2 | 29 | | _ = names.Add(name); |
| | | 30 | | } |
| | | 31 | | } |
| | | 32 | | |
| | 2 | 33 | | return names; |
| | | 34 | | } |
| | | 35 | | |
| | | 36 | | /// <summary> |
| | | 37 | | /// Creates a list of <see cref="CallbackRequest"/> instances from the given callback plans and runtime context. |
| | | 38 | | /// </summary> |
| | | 39 | | /// <param name="executionPlan">The callback plan to create a request from.</param> |
| | | 40 | | /// <param name="ctx">The callback runtime context providing values for tokens and JSON data.</param> |
| | | 41 | | /// <param name="urlResolver">The URL resolver to resolve callback URLs.</param> |
| | | 42 | | /// <param name="bodySerializer">The body serializer to serialize callback request bodies.</param> |
| | | 43 | | /// <param name="options">The callback dispatch options.</param> |
| | | 44 | | /// <returns>A list of created <see cref="CallbackRequest"/> instances.</returns> |
| | | 45 | | public static CallbackRequest FromPlan( |
| | | 46 | | CallBackExecutionPlan executionPlan, |
| | | 47 | | KestrunContext ctx, |
| | | 48 | | ICallbackUrlResolver urlResolver, |
| | | 49 | | ICallbackBodySerializer bodySerializer, |
| | | 50 | | CallbackDispatchOptions options) |
| | | 51 | | { |
| | 2 | 52 | | ArgumentNullException.ThrowIfNull(executionPlan); |
| | 2 | 53 | | ArgumentNullException.ThrowIfNull(ctx); |
| | 2 | 54 | | ArgumentNullException.ThrowIfNull(urlResolver); |
| | 2 | 55 | | ArgumentNullException.ThrowIfNull(bodySerializer); |
| | 2 | 56 | | ArgumentNullException.ThrowIfNull(options); |
| | | 57 | | |
| | 2 | 58 | | var correlationId = ctx.TraceIdentifier; |
| | 2 | 59 | | var plan = executionPlan.Plan; |
| | | 60 | | // Build callback runtime context: |
| | | 61 | | // - Start with request-derived vars/body (for {$request.body#/...} runtime expressions) |
| | | 62 | | // - Overlay callback execution-plan parameters (for {token} placeholders like {paymentId}) |
| | 2 | 63 | | var requestRt = CallbackRuntimeContextFactory.FromHttpContext(ctx); |
| | 2 | 64 | | var mergedVars = new Dictionary<string, object?>(requestRt.Vars, StringComparer.OrdinalIgnoreCase); |
| | 10 | 65 | | foreach (var (name, value) in executionPlan.Parameters) |
| | | 66 | | { |
| | 3 | 67 | | mergedVars[name] = value; |
| | | 68 | | } |
| | | 69 | | |
| | 2 | 70 | | var rt = requestRt with { Vars = mergedVars }; |
| | | 71 | | |
| | | 72 | | // 1) Extract placeholder names from the template |
| | 2 | 73 | | var templateParamNames = ExtractTemplateParams(plan.UrlTemplate); |
| | | 74 | | |
| | | 75 | | // 2) Build a stable seed based on those placeholders + resolved values |
| | | 76 | | // (sorted so ordering in template doesn't change the key) |
| | 2 | 77 | | var seedParts = templateParamNames |
| | 2 | 78 | | .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) |
| | 2 | 79 | | .Select(n => |
| | 2 | 80 | | { |
| | 2 | 81 | | if (!mergedVars.TryGetValue(n, out var resolved) || resolved is null) |
| | 2 | 82 | | { |
| | 0 | 83 | | return null; |
| | 2 | 84 | | } |
| | 2 | 85 | | |
| | 2 | 86 | | var s = resolved.ToString(); |
| | 2 | 87 | | return string.IsNullOrWhiteSpace(s) ? null : $"{n}={s}"; |
| | 2 | 88 | | }) |
| | 2 | 89 | | .Where(x => x is not null) |
| | 2 | 90 | | .ToArray(); |
| | | 91 | | |
| | 2 | 92 | | var idSeed = seedParts.Length > 0 |
| | 2 | 93 | | ? string.Join("&", seedParts) |
| | 2 | 94 | | : correlationId; |
| | | 95 | | |
| | 2 | 96 | | var idempotencyKey = $"{idSeed}:{plan.CallbackId}:{plan.OperationId}"; |
| | | 97 | | |
| | 2 | 98 | | var targetUrl = urlResolver.Resolve(plan.UrlTemplate, rt); |
| | | 99 | | //Todo: Add option to override content type? |
| | 2 | 100 | | var (contentType, _) = bodySerializer.Serialize(plan, rt); |
| | | 101 | | //Todo: Add option to override content type? |
| | | 102 | | //Todo: Add custom headers |
| | 2 | 103 | | var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) |
| | 2 | 104 | | { |
| | 2 | 105 | | ["X-Correlation-Id"] = correlationId, |
| | 2 | 106 | | ["Idempotency-Key"] = idempotencyKey, |
| | 2 | 107 | | ["X-Kestrun-CallbackId"] = plan.CallbackId |
| | 2 | 108 | | }; |
| | | 109 | | // Add body |
| | 2 | 110 | | var bodyBytes = (executionPlan.BodyParameterName == null) ? |
| | 2 | 111 | | null : |
| | 2 | 112 | | System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(executionPlan.Parameters[executionPlan.BodyParameterNam |
| | 2 | 113 | | if (ctx.Logger.IsEnabled(LogEventLevel.Debug)) |
| | | 114 | | { |
| | | 115 | | // Sanitize idempotency key for logging |
| | 2 | 116 | | var safeIdempotencyKeyForLog = idempotencyKey |
| | 2 | 117 | | .Replace("\r", string.Empty) |
| | 2 | 118 | | .Replace("\n", string.Empty); |
| | | 119 | | // Log details |
| | 2 | 120 | | ctx.Logger.Debug("Created CallbackRequest: CallbackId={CallbackId}, OperationId={OperationId}, TargetUrl={Ta |
| | 2 | 121 | | plan.CallbackId, |
| | 2 | 122 | | plan.OperationId, |
| | 2 | 123 | | targetUrl, |
| | 2 | 124 | | plan.Method.Method.ToUpperInvariant(), |
| | 2 | 125 | | contentType, |
| | 2 | 126 | | bodyBytes?.Length ?? 0, |
| | 2 | 127 | | correlationId, |
| | 2 | 128 | | safeIdempotencyKeyForLog); |
| | | 129 | | |
| | 2 | 130 | | ctx.Logger.Debug("CallbackRequest Headers: {Headers}", headers); |
| | 2 | 131 | | ctx.Logger.Debug("CallbackRequest Body: {Body}", bodyBytes is null ? "<null>" : System.Text.Encoding.UTF8.Ge |
| | | 132 | | } |
| | | 133 | | // Create CallbackRequest |
| | 2 | 134 | | return new CallbackRequest( |
| | 2 | 135 | | callbackId: plan.CallbackId, |
| | 2 | 136 | | operationId: plan.OperationId, |
| | 2 | 137 | | targetUrl: targetUrl, |
| | 2 | 138 | | httpMethod: plan.Method.Method.ToUpperInvariant(), |
| | 2 | 139 | | headers: headers, |
| | 2 | 140 | | contentType: contentType, |
| | 2 | 141 | | body: bodyBytes, |
| | 2 | 142 | | correlationId: correlationId, |
| | 2 | 143 | | idempotencyKey: idempotencyKey, |
| | 2 | 144 | | timeout: options.DefaultTimeout |
| | 2 | 145 | | ); |
| | | 146 | | } |
| | | 147 | | |
| | | 148 | | [GeneratedRegex(@"\{(?<name>[^{}:/\?]+)(?:[:][^{}]+)?\}", RegexOptions.Compiled)] |
| | | 149 | | private static partial Regex TemplateParameterRegex(); |
| | | 150 | | } |