Skip to content

Commit

Permalink
Match multiple ocelot routes for given downstream endpoint (#284)
Browse files Browse the repository at this point in the history
* Match multiple Ocelot routes for downstream path

* Remove redundant routes

* Add test

* Version 8.1.0
  • Loading branch information
satano authored Dec 4, 2023
1 parent cfa70b3 commit c1abe11
Show file tree
Hide file tree
Showing 9 changed files with 289 additions and 85 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ private RouteDocs GetRouteDocs(RouteOptions route)
JToken paths = docs[OpenApiProperties.Paths];
string downstreamPath = GetDownstreamPath(route);
JProperty path = paths.OfType<JProperty>().FirstOrDefault(p =>
downstreamPath.Equals(p.Name.WithShashEnding(), StringComparison.CurrentCultureIgnoreCase));
downstreamPath.Equals(p.Name.WithSlashEnding(), StringComparison.OrdinalIgnoreCase));

return new RouteDocs()
{
Expand Down
4 changes: 2 additions & 2 deletions src/MMLib.SwaggerForOcelot/Configuration/RouteOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ public RouteOptions(
/// </summary>
public string DownstreamPath => DownstreamPathWithVirtualDirectory.RemoveSlashFromEnd();

internal string DownstreamPathWithSlash => DownstreamPathWithVirtualDirectory.WithShashEnding();
internal string DownstreamPathWithSlash => DownstreamPathWithVirtualDirectory.WithSlashEnding();

private readonly string _downstreamPathWithVirtualDirectory = null;

Expand Down Expand Up @@ -162,7 +162,7 @@ private string DownstreamPathWithVirtualDirectory
/// Gets a value indicating whether this instance can catch all.
/// </summary>
public bool CanCatchAll
=> DownstreamPathTemplate.EndsWith(CatchAllPlaceHolder, StringComparison.CurrentCultureIgnoreCase);
=> DownstreamPathTemplate.EndsWith(CatchAllPlaceHolder, StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Gets the upstream path.
Expand Down
2 changes: 1 addition & 1 deletion src/MMLib.SwaggerForOcelot/MMLib.SwaggerForOcelot.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net6.0;net7.0;net8.0</TargetFrameworks>
<Version>8.0.0</Version>
<Version>8.1.0</Version>
<Authors>Milan Martiniak</Authors>
<Company>MMLib</Company>
<Description>Swagger generator for Ocelot downstream services.</Description>
Expand Down
17 changes: 10 additions & 7 deletions src/MMLib.SwaggerForOcelot/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,19 @@ internal static class StringExtensions
/// </summary>
/// <param name="value">The value.</param>
public static string RemoveSlashFromEnd(this string value)
=> value.TrimEnd().EndsWith("/")
? value.TrimEnd().TrimEnd('/')
: value;
=> value.TrimEnd().TrimEnd('/');

/// <summary>
/// Add slash to end.
/// </summary>
public static string WithShashEnding(this string value)
=> !value.TrimEnd().EndsWith("/")
? value + "/"
: value;
public static string WithSlashEnding(this string value)
{
value = value.TrimEnd();
if (!value.EndsWith('/'))
{
value += "/";
}
return value;
}
}
}
148 changes: 74 additions & 74 deletions src/MMLib.SwaggerForOcelot/Transformation/SwaggerJsonTransformer.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
using Kros.IO;
using Kros.IO;
using Microsoft.Extensions.Caching.Memory;
using MMLib.SwaggerForOcelot.Configuration;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
Expand Down Expand Up @@ -171,81 +170,54 @@ private string TransformOpenApi(

private void RenameAndRemovePaths(IEnumerable<RouteOptions> routes, JToken paths, string basePath)
{
var forRemove = new List<JProperty>();

var oldPaths = new List<JProperty>();
var newPaths = new Dictionary<string, JProperty>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < paths.Count(); i++)
{
var path = paths.ElementAt(i) as JProperty;
string downstreamPath = path.Name.RemoveSlashFromEnd();
RouteOptions route = FindRoute(routes, path.Name.WithShashEnding(), basePath);

if (route != null && RemoveMethods(path, route))
{
AddSecurityDefinitions(path, route);

RenameToken(path, ConvertDownstreamPathToUpstreamPath(downstreamPath, route.DownstreamPath, route.UpstreamPath, basePath));
}
else
var oldPath = paths.ElementAt(i) as JProperty;
oldPaths.Add(oldPath);
string downstreamPath = oldPath.Name.RemoveSlashFromEnd();
foreach (var tmpMethod in oldPath.First)
{
forRemove.Add(path);
var method = tmpMethod as JProperty;
List<RouteOptions> matchedRoutes = FindRoutes(routes, oldPath.Name.WithSlashEnding(), method.Name, basePath);
foreach (var route in matchedRoutes)
{
string newPath = ConvertDownstreamPathToUpstreamPath(
downstreamPath, route.DownstreamPath, route.UpstreamPath, basePath);
if (!newPaths.TryGetValue(newPath, out JProperty newPathJson))
{
newPathJson = new JProperty(newPath, new JObject());
newPaths.Add(newPath, newPathJson);
}
var newMethod = (method.DeepClone() as JProperty);
AddSecurityDefinition(newMethod, route);
((JObject)newPathJson.Value).Add(newMethod);
}
}
}

foreach (JProperty p in forRemove)
{
p.Remove();
}
oldPaths.ForEach(oldPath => oldPath.Remove());
newPaths.Select(item => item.Value)
.OrderBy(newPath => newPath.Name)
.ForEach(newPath => ((JObject)paths).Add(newPath));
}

private bool RemoveMethods(JProperty path, RouteOptions route)
{
var forRemove = new List<JProperty>();
var method = path.First.First as JProperty;

while (method != null)
{
if (!route.ContainsHttpMethod(method.Name))
{
forRemove.Add(method);
}
method = method.Next as JProperty;
}

foreach (JProperty m in forRemove)
{
m.Remove();
}

return path.First.Any();
}

private void AddSecurityDefinitions(JProperty path, RouteOptions route)
private void AddSecurityDefinition(JProperty method, RouteOptions route)
{
var authProviderKey = route.AuthenticationOptions?.AuthenticationProviderKey;

if (string.IsNullOrEmpty(authProviderKey))
{
return;
}

if (_ocelotSwaggerGenOptions.AuthenticationProviderKeyMap.TryGetValue(
authProviderKey,
out var securityScheme))
if (_ocelotSwaggerGenOptions.AuthenticationProviderKeyMap.TryGetValue(authProviderKey, out var securityScheme))
{
var method = path.First.First as JProperty;

while (method != null)
{
var securityProperty = new JProperty(OpenApiProperties.Security,
new JArray(
new JObject(
new JProperty(securityScheme,
new JArray(route.AuthenticationOptions?.AllowedScopes?.ToArray() ?? Array.Empty<string>())))));

((JObject)method.Value).Add(securityProperty);

method = method.Next as JProperty;
}
var securityProperty = new JProperty(OpenApiProperties.Security,
new JArray(
new JObject(
new JProperty(securityScheme,
new JArray(route.AuthenticationOptions?.AllowedScopes?.ToArray() ?? [])))));
((JObject)method.Value).Add(securityProperty);
}
}

Expand Down Expand Up @@ -297,13 +269,47 @@ private static void CheckSubreferences<T>(IEnumerable<JToken> token, Func<T, str
}
}

private static RouteOptions FindRoute(IEnumerable<RouteOptions> routes, string downstreamPath, string basePath)
private static List<RouteOptions> FindRoutes(
IEnumerable<RouteOptions> routes,
string downstreamPath,
string method,
string basePath)
{
static bool MatchPaths(RouteOptions route, string downstreamPath)
=> route.CanCatchAll
? downstreamPath.StartsWith(route.DownstreamPathWithSlash, StringComparison.OrdinalIgnoreCase)
: route.DownstreamPathWithSlash.Equals(downstreamPath, StringComparison.OrdinalIgnoreCase);

string downstreamPathWithBasePath = PathHelper.BuildPath(basePath, downstreamPath);
return routes.FirstOrDefault(p
=> p.CanCatchAll
? downstreamPathWithBasePath.StartsWith(p.DownstreamPathWithSlash, StringComparison.CurrentCultureIgnoreCase)
: p.DownstreamPathWithSlash.Equals(downstreamPathWithBasePath, StringComparison.CurrentCultureIgnoreCase));
var matchedRoutes = routes
.Where(route => route.ContainsHttpMethod(method) && MatchPaths(route, downstreamPathWithBasePath))
.ToList();

RemoveRedundantRoutes(matchedRoutes);
return matchedRoutes;
}

// Redundant routes are routes with the ALMOST same upstream path templates. For example these path templates
// are redundant:
// - /api/projects/Projects
// - /api/projects/Projects/
// - /api/projects/Projects/{everything}
//
// `route.UpstreamPath` contains route without trailing slash and without catch-all placeholder, so all previous
// routes have the same upstream path `/api/projects/Projects`. The logic is to keep just the shortestof the path
// templates. If we would keep all routes, it will throw an exception during the generation of the swagger document
// later because of the same paths.
private static void RemoveRedundantRoutes(List<RouteOptions> routes)
{
IEnumerable<IGrouping<string, RouteOptions>> groups = routes
.GroupBy(route => route.UpstreamPath, StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1);
foreach (var group in groups)
{
group.OrderBy(r => r.DownstreamPathTemplate.Length)
.Skip(1)
.ForEach(r => routes.Remove(r));
}
}

private static void AddHost(JObject swagger, string swaggerHost)
Expand All @@ -325,20 +331,14 @@ private static string ConvertDownstreamPathToUpstreamPath(string downstreamPath,
downstreamPath = PathHelper.BuildPath(downstreamBasePath, downstreamPath);
}

int pos = downstreamPath.IndexOf(downstreamPattern, StringComparison.CurrentCultureIgnoreCase);
int pos = downstreamPath.IndexOf(downstreamPattern, StringComparison.OrdinalIgnoreCase);
if (pos < 0)
{
return downstreamPath;
}
return $"{downstreamPath.Substring(0, pos)}{upstreamPattern}{downstreamPath.Substring(pos + downstreamPattern.Length)}";
}

private static void RenameToken(JProperty property, string newName)
{
var newProperty = new JProperty(newName, property.Value);
property.Replace(newProperty);
}

private static void TransformServerPaths(JObject openApi, string serverOverride, bool takeServersFromDownstreamService)
{
if (!openApi.ContainsKey(OpenApiProperties.Servers) || takeServersFromDownstreamService)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
</ItemGroup>
<ItemGroup>
<None Remove="Resources\AggregatesOpenApiResource.json" />
<None Remove="Resources\DifferentOcelotRoutesForOneDownstream.json" />
<None Remove="Resources\DifferentOcelotRoutesForOneDownstreamTransformed.json" />
<None Remove="Tests\BasicConfigurationWithSchemaInHostOverride.json" />
<None Remove="Tests\Issue_128.json" />
<None Remove="Tests\Issue_135.json" />
Expand All @@ -20,6 +22,8 @@
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\AggregatesOpenApiResource.json" />
<EmbeddedResource Include="Resources\DifferentOcelotRoutesForOneDownstream.json" />
<EmbeddedResource Include="Resources\DifferentOcelotRoutesForOneDownstreamTransformed.json" />
<EmbeddedResource Include="Resources\OpenApiWithVersionPlaceholderBase.json" />
<EmbeddedResource Include="Resources\OpenApiWithVersionPlaceholderBaseTransformed.json" />
<EmbeddedResource Include="Resources\OpenApiBase.json" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"openapi": "3.0.1",
"info": {
"title": "WebApplication1",
"version": "1.0"
},
"paths": {
"/api/test": {
"get": {
"tags": [
"WebApplication1"
],
"operationId": "testGet",
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
},
"post": {
"tags": [
"WebApplication1"
],
"operationId": "testPost",
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
}
},
"components": {}
}
Loading

0 comments on commit c1abe11

Please sign in to comment.