Skip to content

Commit 06ca569

Browse files
authored
Merge pull request #5 from mxritzdev/addPlaceholders
Add placeholders - this was pain asf
2 parents 422130e + 1f08232 commit 06ca569

File tree

7 files changed

+189
-52
lines changed

7 files changed

+189
-52
lines changed

LinkRouter/App/Configuration/Config.cs

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
using LinkRouter.App.Models;
1+
using System.Text;
2+
using System.Text.Json.Serialization;
3+
using System.Text.RegularExpressions;
4+
using LinkRouter.App.Models;
25

36
namespace LinkRouter.App.Configuration;
47

@@ -8,7 +11,8 @@ public class Config
811

912
public NotFoundBehaviorConfig NotFoundBehavior { get; set; } = new();
1013

11-
public RedirectRoute[] Routes { get; set; } = [
14+
public RedirectRoute[] Routes { get; set; } =
15+
[
1216
new RedirectRoute()
1317
{
1418
Route = "/instagram",
@@ -20,10 +24,85 @@ public class Config
2024
RedirectUrl = "https://example.com"
2125
},
2226
];
23-
27+
2428
public class NotFoundBehaviorConfig
2529
{
2630
public bool RedirectOn404 { get; set; } = false;
2731
public string RedirectUrl { get; set; } = "https://example.com/404";
2832
}
33+
34+
[JsonIgnore] public CompiledRoute[]? CompiledRoutes { get; set; }
35+
36+
public void CompileRoutes()
37+
{
38+
var compiledRoutes = new List<CompiledRoute>();
39+
40+
foreach (var route in Routes)
41+
{
42+
if (!route.Route.StartsWith("/"))
43+
route.Route = "/" + route.Route;
44+
45+
if (!route.Route.EndsWith("/"))
46+
route.Route += "/";
47+
48+
var compiled = new CompiledRoute
49+
{
50+
Route = route.Route,
51+
RedirectUrl = route.RedirectUrl
52+
};
53+
54+
var replacements = new List<(int Index, int Length, string NewText)>();
55+
56+
var escaped = Regex.Escape(route.Route);
57+
58+
var pattern = new Regex(@"\\\{(\d|\w+)\}", RegexOptions.CultureInvariant);
59+
60+
var matches = pattern.Matches(escaped);
61+
62+
foreach (var match in matches.Select(x => x))
63+
{
64+
// Check if the placeholder is immediately followed by another placeholder
65+
if (escaped.Length >= match.Index + match.Length + 2
66+
&& escaped.Substring(match.Index + match.Length, 2) == "\\{")
67+
throw new InvalidOperationException(
68+
$"Placeholder {match.Groups[1].Value} cannot be immediately followed by another placeholder. " +
69+
$"Please add any separator.");
70+
71+
replacements.Add((match.Index, match.Length, "(.+)"));
72+
}
73+
74+
var compiledRouteBuilder = new StringBuilder(escaped);
75+
76+
foreach (var replacement in replacements.OrderByDescending(r => r.Index))
77+
{
78+
compiledRouteBuilder.Remove(replacement.Index, replacement.Length);
79+
compiledRouteBuilder.Insert(replacement.Index, replacement.NewText);
80+
}
81+
82+
compiled.CompiledPattern = new Regex(compiledRouteBuilder.ToString(),
83+
RegexOptions.Compiled | RegexOptions.CultureInvariant);
84+
85+
var duplicate = matches
86+
.Select((m, i) => m.Groups[1].Value)
87+
.GroupBy(x => x)
88+
.FirstOrDefault(x => x.Count() > 1);
89+
90+
if (duplicate != null)
91+
throw new InvalidOperationException("Cannot use a placeholder twice in the route: " + duplicate.Key);
92+
93+
compiled.Placeholders = matches
94+
.Select((m, i) => m.Groups[1].Value)
95+
.Distinct()
96+
.Select((name, i) => (name, i))
97+
.ToDictionary(x => x.name, x => x.i + 1);
98+
99+
compiledRoutes.Add(compiled);
100+
}
101+
102+
CompiledRoutes = compiledRoutes
103+
.ToArray();
104+
}
105+
106+
[JsonIgnore] public static Regex ErrorCodePattern = new(@"\s*\-\>\s*(\d+)\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
107+
29108
}

LinkRouter/App/Http/Controllers/RedirectController.cs

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ public class RedirectController : Controller
1010
{
1111

1212
private readonly Config Config;
13-
1413

1514
private readonly Counter RouteCounter = Metrics.CreateCounter(
1615
"linkrouter_requests",
@@ -37,38 +36,71 @@ public RedirectController(Config config)
3736
}
3837

3938
[HttpGet("/{*path}")]
40-
public IActionResult RedirectToExternalUrl(string path)
39+
public async Task<ActionResult> RedirectToExternalUrl(string path)
4140
{
42-
var redirectRoute = Config.Routes.FirstOrDefault(x => x.Route == path || x.Route == path + "/" || x.Route == "/" + path);
43-
44-
if (redirectRoute != null)
41+
if (!path.EndsWith("/"))
42+
path += "/";
43+
44+
path = "/" + path;
45+
46+
Console.WriteLine(path);
47+
48+
var redirectRoute = Config.CompiledRoutes?.FirstOrDefault(x => x.CompiledPattern.IsMatch(path));
49+
50+
if (redirectRoute == null)
4551
{
46-
RouteCounter
47-
.WithLabels(redirectRoute.Route)
52+
NotFoundCounter
53+
.WithLabels(path)
4854
.Inc();
4955

50-
return Redirect(redirectRoute.RedirectUrl);
51-
}
56+
if (Config.NotFoundBehavior.RedirectOn404)
57+
if (Config.ErrorCodePattern.IsMatch(Config.NotFoundBehavior.RedirectUrl))
58+
{
59+
var errorCodeMatch = Config.ErrorCodePattern.Match(Config.NotFoundBehavior.RedirectUrl);
60+
var errorCode = int.Parse(errorCodeMatch.Groups[1].Value);
61+
return StatusCode(errorCode);
62+
} else
63+
return Redirect(Config.NotFoundBehavior.RedirectUrl);
5264

53-
NotFoundCounter
54-
.WithLabels("/" + path)
55-
.Inc();
65+
return NotFound();
66+
}
5667

57-
if (Config.NotFoundBehavior.RedirectOn404)
58-
return Redirect(Config.NotFoundBehavior.RedirectUrl);
68+
var match = redirectRoute.CompiledPattern.Match(path);
5969

60-
return NotFound();
70+
string redirectUrl = redirectRoute.RedirectUrl;
71+
72+
if (Config.ErrorCodePattern.IsMatch(redirectUrl))
73+
{
74+
var errorCodeMatch = Config.ErrorCodePattern.Match(redirectUrl);
75+
var errorCode = int.Parse(errorCodeMatch.Groups[1].Value);
76+
return StatusCode(errorCode);
77+
}
78+
79+
foreach (var placeholder in redirectRoute.Placeholders)
80+
{
81+
var value = match.Groups[placeholder.Value].Value;
82+
redirectUrl = redirectUrl.Replace("{" + placeholder.Key + "}", value);
83+
}
84+
85+
return Redirect(redirectUrl);
6186
}
62-
87+
6388
[HttpGet("/")]
6489
public IActionResult GetRootRoute()
6590
{
6691
RouteCounter
6792
.WithLabels("/")
6893
.Inc();
69-
94+
7095
string url = Config.RootRoute;
7196

97+
if (Config.ErrorCodePattern.IsMatch(url))
98+
{
99+
var errorCodeMatch = Config.ErrorCodePattern.Match(url);
100+
var errorCode = int.Parse(errorCodeMatch.Groups[1].Value);
101+
return StatusCode(errorCode);
102+
}
103+
72104
return Redirect(url);
73105
}
74106
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using System.Text.RegularExpressions;
2+
3+
namespace LinkRouter.App.Models;
4+
5+
public class CompiledRoute : RedirectRoute
6+
{
7+
public Regex CompiledPattern { get; set; }
8+
9+
public Dictionary<string, int> Placeholders { get; set; } = new();
10+
}

LinkRouter/App/Models/RedirectRoute.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
public class RedirectRoute
44
{
55
public string Route { get; set; }
6-
6+
77
public string RedirectUrl { get; set; }
88
}

LinkRouter/App/Services/ConfigWatcher.cs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,32 +22,44 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken)
2222
{
2323
Logger.LogWarning("Watched file does not exist: {FilePath}", ConfigPath);
2424
}
25-
25+
2626
Watcher = new FileSystemWatcher(Path.GetDirectoryName(ConfigPath) ?? throw new InvalidOperationException())
2727
{
2828
Filter = Path.GetFileName(ConfigPath),
2929
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime
3030
};
31-
31+
32+
OnChanged(Watcher, new FileSystemEventArgs(WatcherChangeTypes.Created, string.Empty, string.Empty));
33+
3234
Watcher.Changed += OnChanged;
33-
35+
3436
Watcher.EnableRaisingEvents = true;
35-
37+
3638
return Task.CompletedTask;
3739
}
38-
40+
3941
private void OnChanged(object sender, FileSystemEventArgs e)
4042
{
4143
try
4244
{
4345
var content = File.ReadAllText(ConfigPath);
44-
46+
4547
var config = JsonSerializer.Deserialize<Config>(content);
46-
48+
4749
Config.Routes = config?.Routes ?? [];
4850
Config.RootRoute = config?.RootRoute ?? "https://example.com";
49-
51+
5052
Logger.LogInformation("Config file changed.");
53+
54+
try
55+
{
56+
Config.CompileRoutes();
57+
}
58+
catch (InvalidOperationException ex)
59+
{
60+
Logger.LogError("Failed to compile routes: " + ex.Message);
61+
Environment.Exit(1);
62+
}
5163
}
5264
catch (IOException ex)
5365
{

LinkRouter/Program.cs

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,46 +12,44 @@ public abstract class Program
1212
public static void Main(string[] args)
1313
{
1414
var builder = WebApplication.CreateBuilder(args);
15-
15+
1616
Directory.CreateDirectory(PathBuilder.Dir("data"));
17-
17+
1818
builder.Services.AddControllers();
19-
19+
2020
var loggerProviders = LoggerBuildHelper.BuildFromConfiguration(configuration =>
2121
{
2222
configuration.Console.Enable = true;
2323
configuration.Console.EnableAnsiMode = true;
2424
});
25-
25+
2626
builder.Logging.ClearProviders();
2727
builder.Logging.AddProviders(loggerProviders);
2828

2929
builder.Services.AddHostedService<ConfigWatcher>();
30-
30+
3131
var configPath = Path.Combine("data", "config.json");
32-
32+
3333
if (!File.Exists(configPath))
3434
File.WriteAllText(
35-
configPath,
36-
JsonSerializer.Serialize(new Config(), new JsonSerializerOptions {WriteIndented = true}
35+
configPath,
36+
JsonSerializer.Serialize(new Config(), new JsonSerializerOptions { WriteIndented = true }
3737
));
38-
38+
3939
Config config = JsonSerializer.Deserialize<Config>(File.ReadAllText(configPath)) ?? new Config();
4040

41-
File.WriteAllText(configPath, JsonSerializer.Serialize(config, new JsonSerializerOptions {WriteIndented = true}));
42-
41+
File.WriteAllText(configPath,
42+
JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }));
43+
4344
builder.Services.AddSingleton(config);
44-
45-
builder.Services.AddMetricServer(options =>
46-
{
47-
options.Port = 5000;
48-
});
49-
45+
46+
builder.Services.AddMetricServer(options => { options.Port = 5000; });
47+
5048
var app = builder.Build();
5149

5250
app.UseMetricServer();
5351
app.MapControllers();
5452

5553
app.Run();
5654
}
57-
}
55+
}

README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
[⬇️How to install⬇️](#installation)
55

66
## Features
7-
- **Path-based Redirection:** Reads a config file that maps paths to redirect URLs. When a request hits a registered path, the router issues an HTTP redirect to the corresponding target.
7+
- **Path-based Redirection:** Reads a config file that maps paths to redirect URLs. When a request hits a registered path, the router issues an HTTP redirect to the corresponding target.
88
- **Hot Reloading:** The config is cached at startup and automatically reloaded when the file changes — no restart required. [Example config](#example-config)
99
- **Low Resource Usage:** Uses less than 50MB of RAM, making it ideal for constrained environments.
1010
- **Metrics Endpoint:** Exposes Prometheus-compatible metrics at `:5000/metrics` for easy observability and monitoring. [How to use](#metrics)
11-
- **Docker-Deployable:** Comes with a minimal Dockerfile for easy containerized deployment.
11+
- **Docker-Deployable:** Comes with a minimal Dockerfile for easy containerized deployment.
12+
- **Placeholders:** Supports placeholders in redirect URLs, allowing dynamic URL generation based on the requested path. For example, a route defined as `/user/{username}` can redirect to `https://example.com/profile/{username}`, where `{username}` is replaced with the actual value from the request.
13+
- **Status Code:** You are able to configure if the redirect should redirect to an url or just return a custom status code of your choice. Example `"RedirectUrl": "-> 418"` will return the status code 418 (I'm a teapot :) )
1214

1315
## Configuration
1416
Routes are managed via a configuration file, `/data/config.json`. You can define paths and their corresponding URLs in this file. The application automatically normalizes routes to handle both trailing and non-trailing slashes.
@@ -24,11 +26,15 @@ Routes are managed via a configuration file, `/data/config.json`. You can define
2426
"Routes": [
2527
{
2628
"Route": "/instagram", // has to start with a slash
27-
"RedirectUrl": "https://instagram.com/{yourname}"
29+
"RedirectUrl": "https://instagram.com/maxmustermann"
2830
},
2931
{
30-
"Route": "/example", // has to start with a slash
31-
"RedirectUrl": "https://example.com"
32+
"Route": "/article/{id}", // {id} is a placeholder
33+
"RedirectUrl": "https://example.com/article/{id}", // {id} will be replaced with the actual value from the request
34+
},
35+
{
36+
"Route": "/teapot",
37+
"RedirectUrl": "-> 418" // will return a 418 status code (I'm a teapot :) )
3238
}
3339
]
3440
}

0 commit comments

Comments
 (0)