| | | 1 | | using System.Text.RegularExpressions; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | namespace Kestrun.Callback; |
| | | 4 | | |
| | | 5 | | /// <summary> |
| | | 6 | | /// Default implementation of <see cref="ICallbackUrlResolver"/> that resolves callback URLs |
| | | 7 | | /// using JSON Pointer expressions and variable tokens. |
| | | 8 | | /// </summary> |
| | | 9 | | public sealed partial class DefaultCallbackUrlResolver : ICallbackUrlResolver |
| | | 10 | | { |
| | 1 | 11 | | private static readonly Regex RuntimeExpr = GeneratedRuntimeRegex(); |
| | | 12 | | |
| | 1 | 13 | | private static readonly Regex Token = GeneratedTokenRegex(); |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Resolves the given URL template into a full URI using the provided callback runtime context. |
| | | 17 | | /// </summary> |
| | | 18 | | /// <param name="urlTemplate">The URL template containing tokens and JSON Pointer expressions.</param> |
| | | 19 | | /// <param name="ctx">The callback runtime context providing values for tokens and JSON data.</param> |
| | | 20 | | /// <returns>A fully resolved URI based on the template and context.</returns> |
| | | 21 | | /// <exception cref="ArgumentException">Thrown when the urlTemplate is null, empty, or whitespace.</exception> |
| | | 22 | | /// <exception cref="InvalidOperationException">Thrown when the URL template cannot be resolved to a valid URI.</exc |
| | | 23 | | public Uri Resolve(string urlTemplate, CallbackRuntimeContext ctx) |
| | | 24 | | { |
| | 5 | 25 | | if (string.IsNullOrWhiteSpace(urlTemplate)) |
| | | 26 | | { |
| | 0 | 27 | | throw new ArgumentException("urlTemplate is empty.", nameof(urlTemplate)); |
| | | 28 | | } |
| | | 29 | | |
| | 5 | 30 | | ArgumentNullException.ThrowIfNull(ctx); |
| | | 31 | | |
| | | 32 | | // 1) Resolve {$request.body#/json/pointer} using the typed body |
| | 5 | 33 | | var s = RuntimeExpr.Replace(urlTemplate, m => |
| | 5 | 34 | | { |
| | 3 | 35 | | var ptr = m.Groups["ptr"].Value; // like "/callbackUrls/status" |
| | 5 | 36 | | |
| | 3 | 37 | | if (ctx.CallbackPayload is null) |
| | 5 | 38 | | { |
| | 1 | 39 | | throw new InvalidOperationException( |
| | 1 | 40 | | $"Callback url uses request.body pointer '{ptr}' but the request body is null."); |
| | 5 | 41 | | } |
| | 5 | 42 | | // Convert typed object -> JsonElement (on demand) |
| | 2 | 43 | | var root = SerializePayloadToJsonElement(ctx.CallbackPayload, ptr); |
| | 5 | 44 | | |
| | 2 | 45 | | var value = ResolveJsonPointer(root, ptr); |
| | 5 | 46 | | |
| | 5 | 47 | | // Runtime expressions are inserted as text; strings are inserted raw, |
| | 5 | 48 | | // non-strings use their JSON textual form. |
| | 2 | 49 | | return value.ValueKind == JsonValueKind.String |
| | 2 | 50 | | ? (value.GetString() ?? "") |
| | 2 | 51 | | : value.GetRawText(); |
| | 5 | 52 | | }); |
| | | 53 | | |
| | | 54 | | // 2) Replace {token} placeholders from Vars |
| | 4 | 55 | | s = ReplaceTokens(s, ctx); |
| | | 56 | | |
| | | 57 | | // 3) Make Uri |
| | 3 | 58 | | if (Uri.TryCreate(s, UriKind.Absolute, out var abs)) |
| | | 59 | | { |
| | | 60 | | // On Unix, a leading-slash path like "/v1/foo" parses as an absolute file URI (file:///v1/foo). |
| | | 61 | | // Callback URLs are expected to be HTTP(S). Treat file URIs from leading-slash inputs as relative. |
| | 3 | 62 | | if (!(abs.Scheme == Uri.UriSchemeFile && s.StartsWith('/'))) |
| | | 63 | | { |
| | 1 | 64 | | return abs; |
| | | 65 | | } |
| | | 66 | | } |
| | | 67 | | // Relative Uri: combine with DefaultBaseUri |
| | 2 | 68 | | return ctx.DefaultBaseUri is null |
| | 2 | 69 | | ? throw new InvalidOperationException( |
| | 2 | 70 | | $"Callback url resolved to '{s}' (not absolute) and DefaultBaseUri is null.") |
| | 2 | 71 | | : new Uri(ctx.DefaultBaseUri, s); |
| | | 72 | | } |
| | | 73 | | |
| | | 74 | | /// Replaces {token} placeholders in the input string using values from the callback runtime context. |
| | | 75 | | /// <param name="input">The input string containing {token} placeholders.</param> |
| | | 76 | | /// <param name="ctx">The callback runtime context providing values for tokens.</param> |
| | | 77 | | /// <returns>The input string with all {token} placeholders replaced with their corresponding values.</returns> |
| | | 78 | | private static string ReplaceTokens(string input, CallbackRuntimeContext ctx) |
| | | 79 | | { |
| | 4 | 80 | | return Token.Replace(input, m => |
| | 4 | 81 | | { |
| | 4 | 82 | | var name = m.Groups["name"].Value; |
| | 4 | 83 | | |
| | 4 | 84 | | return !ctx.Vars.TryGetValue(name, out var v) || v is null |
| | 4 | 85 | | ? throw new InvalidOperationException( |
| | 4 | 86 | | $"Callback url requires token '{name}' but it was not found in runtime Vars.") |
| | 4 | 87 | | : Uri.EscapeDataString(v.ToString()!); |
| | 4 | 88 | | }); |
| | | 89 | | } |
| | | 90 | | |
| | | 91 | | /// <summary> |
| | | 92 | | /// Serializes the given payload object to a JsonElement for JSON Pointer resolution. |
| | | 93 | | /// </summary> |
| | | 94 | | /// <param name="payload">The payload object to serialize.</param> |
| | | 95 | | /// <param name="ptr">The JSON Pointer string (for error context).</param> |
| | | 96 | | /// <returns>The serialized JsonElement.</returns> |
| | | 97 | | private static JsonElement SerializePayloadToJsonElement(object payload, string ptr) |
| | | 98 | | { |
| | | 99 | | try |
| | | 100 | | { |
| | 2 | 101 | | return JsonSerializer.SerializeToElement(payload); |
| | | 102 | | } |
| | 0 | 103 | | catch (Exception ex) |
| | | 104 | | { |
| | 0 | 105 | | throw new InvalidOperationException( |
| | 0 | 106 | | $"Failed to serialize request body for evaluating pointer '{ptr}'.", ex); |
| | | 107 | | } |
| | 2 | 108 | | } |
| | | 109 | | // Minimal JSON Pointer resolver (RFC 6901-ish) |
| | | 110 | | private static JsonElement ResolveJsonPointer(JsonElement root, string pointer) |
| | | 111 | | { |
| | 2 | 112 | | if (pointer is "" or "/") |
| | | 113 | | { |
| | 0 | 114 | | return root; |
| | | 115 | | } |
| | | 116 | | |
| | 2 | 117 | | if (!pointer.StartsWith('/')) |
| | | 118 | | { |
| | 0 | 119 | | throw new FormatException($"Invalid JSON pointer '{pointer}'."); |
| | | 120 | | } |
| | | 121 | | |
| | 2 | 122 | | var current = root; |
| | 2 | 123 | | var segments = pointer.Split('/', StringSplitOptions.RemoveEmptyEntries); |
| | | 124 | | |
| | 12 | 125 | | foreach (var raw in segments) |
| | | 126 | | { |
| | 4 | 127 | | var seg = raw.Replace("~1", "/").Replace("~0", "~"); |
| | | 128 | | |
| | 4 | 129 | | if (current.ValueKind == JsonValueKind.Object) |
| | | 130 | | { |
| | 4 | 131 | | if (!current.TryGetProperty(seg, out current)) |
| | | 132 | | { |
| | 0 | 133 | | throw new KeyNotFoundException($"JSON pointer segment '{seg}' not found."); |
| | | 134 | | } |
| | | 135 | | } |
| | 0 | 136 | | else if (current.ValueKind == JsonValueKind.Array) |
| | | 137 | | { |
| | 0 | 138 | | if (!int.TryParse(seg, out var idx)) |
| | | 139 | | { |
| | 0 | 140 | | throw new FormatException($"JSON pointer segment '{seg}' is not a valid array index."); |
| | | 141 | | } |
| | | 142 | | |
| | 0 | 143 | | if (idx < 0 || idx >= current.GetArrayLength()) |
| | | 144 | | { |
| | 0 | 145 | | throw new IndexOutOfRangeException($"JSON pointer index {idx} out of range."); |
| | | 146 | | } |
| | | 147 | | |
| | 0 | 148 | | current = current[idx]; |
| | | 149 | | } |
| | | 150 | | else |
| | | 151 | | { |
| | 0 | 152 | | throw new InvalidOperationException($"Cannot traverse JSON pointer through {current.ValueKind}."); |
| | | 153 | | } |
| | | 154 | | } |
| | | 155 | | |
| | 2 | 156 | | return current; |
| | | 157 | | } |
| | | 158 | | |
| | | 159 | | [GeneratedRegex(@"\{\$request\.body#(?<ptr>\/[^}]*)\}", RegexOptions.Compiled | RegexOptions.CultureInvariant)] |
| | | 160 | | private static partial Regex GeneratedRuntimeRegex(); |
| | | 161 | | |
| | | 162 | | [GeneratedRegex(@"\{(?<name>[A-Za-z_][A-Za-z0-9_]*)\}", RegexOptions.Compiled | RegexOptions.CultureInvariant)] |
| | | 163 | | private static partial Regex GeneratedTokenRegex(); |
| | | 164 | | } |