From d77ade6e2e4043909c86eaaab14f836e58841706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gys=20Borges=20da=20Silveira?= Date: Fri, 31 Jul 2026 17:00:46 -0300 Subject: [PATCH] fix(routing): fix parameter tag overwriting and add configurable case-sensitivity --- src/Horse.Core.Router.Radix.pas | 38 ++++- src/Horse.Core.RouterTree.NextCaller.pas | 14 +- src/Horse.Core.RouterTree.pas | 156 +++++++++++++++--- src/Horse.Core.pas | 15 ++ src/Horse.pas | 13 ++ .../src/tests/Tests.Horse.Core.RouterTree.pas | 76 ++++++++- 6 files changed, 278 insertions(+), 34 deletions(-) diff --git a/src/Horse.Core.Router.Radix.pas b/src/Horse.Core.Router.Radix.pas index cec007a6..947c1582 100644 --- a/src/Horse.Core.Router.Radix.pas +++ b/src/Horse.Core.Router.Radix.pas @@ -567,6 +567,7 @@ function THorseRadixRouter.MatchStaticRoute(const ABuffer: TBytes; const APathSp LItem: TStaticRouteItem; LPathLen: Integer; LMatch: Boolean; + B1, B2: Byte; begin ACallbacks := nil; if not FStaticRoutesBuilt then @@ -581,7 +582,14 @@ function THorseRadixRouter.MatchStaticRoute(const ABuffer: TBytes; const APathSp LMatch := True; for J := 0 to LPathLen - 1 do begin - if ABuffer[APathSpan.Offset + J] <> LItem.PathBytes[J] then + B1 := ABuffer[APathSpan.Offset + J]; + B2 := LItem.PathBytes[J]; + if not THorseCore.CaseSensitive then + begin + if (B1 >= 65) and (B1 <= 90) then B1 := B1 + 32; + if (B2 >= 65) and (B2 <= 90) then B2 := B2 + 32; + end; + if B1 <> B2 then begin LMatch := False; Break; @@ -617,14 +625,32 @@ procedure THorseRadixRouter.InsertRoute(const APath: string; const AHTTPType: TM if (LSeg = '') and (I > 0) and (I = Length(LSegments) - 1) then Continue; + if not THorseCore.CaseSensitive then + begin + if (not LSeg.StartsWith(':')) and (not LSeg.StartsWith('(')) then + LSeg := LowerCase(LSeg); + end; + LFound := False; for LChild in LCurrent.Children do begin - if SameText(LChild.Part, LSeg) then + if THorseCore.CaseSensitive then begin - LCurrent := LChild; - LFound := True; - Break; + if LChild.Part = LSeg then + begin + LCurrent := LChild; + LFound := True; + Break; + end; + end + else + begin + if SameText(LChild.Part, LSeg) then + begin + LCurrent := LChild; + LFound := True; + Break; + end; end; end; @@ -712,7 +738,7 @@ function THorseRadixRouter.FindNode(const ASegments: TArray; // 1. Tenta correspondĂȘncia exata via SWAR 64-bit for LChild in ANode.Children do begin - if (not LChild.IsParam) and (LChild.Part <> '*') and LCurrentSlice.Compare(LChild.Part, True) then + if (not LChild.IsParam) and (LChild.Part <> '*') and LCurrentSlice.Compare(LChild.Part, not THorseCore.CaseSensitive) then begin LTempNode := LChild; LBestMatch := FindNode(ASegments, AIndex + 1, LTempNode, AHTTPType, AMiddlewares, AParams); diff --git a/src/Horse.Core.RouterTree.NextCaller.pas b/src/Horse.Core.RouterTree.NextCaller.pas index 2ff2e26c..fe2b836e 100644 --- a/src/Horse.Core.RouterTree.NextCaller.pas +++ b/src/Horse.Core.RouterTree.NextCaller.pas @@ -39,7 +39,7 @@ TNextCaller = class {$ENDIF} FCallNextPath: TCallNextPath; FIsGroup: Boolean; - FTag: string; + FTags: TArray; FIsParamsKey: Boolean; FPart: string; FFound: ^Boolean; @@ -61,7 +61,7 @@ TNextCaller = class {$ELSE} const AMiddleware: TArray; {$ENDIF} - const ATag: string; + const ATags: TArray; const AIsParamsKey: Boolean; const ACallNextPath: TCallNextPath; const APart: string; @@ -143,7 +143,7 @@ procedure TNextCaller.Configure( {$ELSE} const AMiddleware: TArray; {$ENDIF} - const ATag: string; + const ATags: TArray; const AIsParamsKey: Boolean; const ACallNextPath: TCallNextPath; const APart: string; @@ -158,7 +158,7 @@ procedure TNextCaller.Configure( FResponse := AResponse; FIsGroup := AIsGroup; FMiddleware := AMiddleware; - FTag := ATag; + FTags := ATags; FIsParamsKey := AIsParamsKey; FCallNextPath := ACallNextPath; FPart := APart; @@ -169,6 +169,7 @@ procedure TNextCaller.Init; var LCurrent: THorseBufferSlice; LCurrentStr: string; + LTag: string; begin LCurrentStr := ''; if (not FIsGroup) and (FIndexSegment < Length(FSegments)) then @@ -181,7 +182,10 @@ procedure TNextCaller.Init; FIndexCallback := -1; if FIsParamsKey then begin - FRequest.Params.Dictionary.AddOrSetValue(FTag, DecodeParam(LCurrentStr)); + for LTag in FTags do + begin + FRequest.Params.Dictionary.AddOrSetValue(LTag, DecodeParam(LCurrentStr)); + end; end; end; diff --git a/src/Horse.Core.RouterTree.pas b/src/Horse.Core.RouterTree.pas index 4e7d6050..b5bc7261 100644 --- a/src/Horse.Core.RouterTree.pas +++ b/src/Horse.Core.RouterTree.pas @@ -35,7 +35,7 @@ THorseRouterTree = class(TInterfacedObject, IHorseRouter) procedure PopulateQueuePath(AQueue: TQueue; APath: string; const AUsePrefix: Boolean = True); private FPart: string; - FTag: string; + FTags: TArray; FFullPath: string; FIsParamsKey: Boolean; FRouterRegex: string; @@ -53,6 +53,7 @@ THorseRouterTree = class(TInterfacedObject, IHorseRouter) {$ENDIF} FHandlerMethods: TList; FRoute: TObjectDictionary; + procedure AddTag(const ATag: string); procedure RegisterInternal(const AHTTPType: TMethodType; var APath: TQueue; const ACallback: THorseCallback; const AFullPath: string; const AIsMiddleware: Boolean = False); procedure RegisterMiddlewareInternal(var APath: TQueue; const AMiddleware: THorseCallback); function ExecuteInternal(const ASegments: TArray; AIndex: Integer; const AHTTPType: TMethodType; const ARequest: THorseRequest; const AResponse: THorseResponse; const AIsGroup: Boolean = False): Boolean; @@ -289,7 +290,7 @@ function THorseRouterTree.CallNextPath(const ASegments: TArray '*') and LCurrent.Compare(LPair.Key) then + if (LPair.Key <> '*') and LCurrent.Compare(LPair.Key, not THorseCore.CaseSensitive) then begin LAcceptable := LPair.Value; LFound := True; @@ -485,7 +486,7 @@ function THorseRouterTree.ExecuteInternal(const ASegments: TArray LOpenParenthesis then begin FIsRouterRegex := True; - FTag := LNormalizedNextPart.Substring(0, LOpenParenthesis); + LTag := LNormalizedNextPart.Substring(0, LOpenParenthesis); FRouterRegex := LNormalizedNextPart.Substring(LOpenParenthesis + 1, LCloseParenthesis - LOpenParenthesis - 1); FRegexMatcher := THorseRegex.Create(FRouterRegex); end; end; if not FIsRouterRegex then - FTag := LNormalizedNextPart; + LTag := LNormalizedNextPart; + + AddTag(LTag); end else begin @@ -731,18 +748,39 @@ procedure THorseRouterTree.RegisterInternal(const AHTTPType: TMethodType; var AP FIsRouterRegex := True; FRouterRegex := FPart.Substring(1, FPart.Length - 2); FRegexMatcher := THorseRegex.Create(FRouterRegex); - FTag := ''; - end - else - begin - FTag := ''; end; end; FIsInitialized := True; end else - APath.Dequeue; + begin + LRawPart := APath.Dequeue; + if FIsParamsKey then + begin + LNormalizedNextPart := LRawPart.Substring(1); + + if LNormalizedNextPart.EndsWith('?') then + begin + LNormalizedNextPart := LNormalizedNextPart.Substring(0, LNormalizedNextPart.Length - 1); + end; + + LOpenParenthesis := LNormalizedNextPart.IndexOf('('); + if LOpenParenthesis >= 0 then + begin + LCloseParenthesis := LNormalizedNextPart.IndexOf(')'); + if LCloseParenthesis > LOpenParenthesis then + begin + LTag := LNormalizedNextPart.Substring(0, LOpenParenthesis); + end; + end + else + LTag := LNormalizedNextPart; + + if LTag <> '' then + AddTag(LTag); + end; + end; if APath.Count = 0 then begin @@ -776,6 +814,11 @@ procedure THorseRouterTree.RegisterInternal(const AHTTPType: TMethodType; var AP begin LNextPart := APath.Peek; LNormalizedNextPart := NormalizeParamKey(LNextPart); + if not THorseCore.CaseSensitive then + begin + if (not LNextPart.StartsWith(':')) and (not LNextPart.StartsWith('(')) then + LNormalizedNextPart := LowerCase(LNormalizedNextPart); + end; LForceRouter := ForcePath(LNormalizedNextPart); @@ -811,19 +854,88 @@ procedure THorseRouterTree.RegisterMiddleware(const APath: string; const AMiddle end; procedure THorseRouterTree.RegisterMiddlewareInternal(var APath: TQueue; const AMiddleware: THorseCallback); +var + LNextPart: string; + LNormalizedNextPart: string; + LForceRouter: THorseRouterTree; + LRawPart: string; + LNormalizedRawPart: string; + LOpenParenthesis: Integer; + LCloseParenthesis: Integer; + LTag: string; begin - APath.Dequeue; + if not FIsInitialized then + begin + LRawPart := APath.Dequeue; + FPart := LRawPart; + FIsParamsKey := FPart.StartsWith(':'); + if FIsParamsKey then + begin + LNormalizedRawPart := FPart.Substring(1); + if LNormalizedRawPart.EndsWith('?') then + LNormalizedRawPart := LNormalizedRawPart.Substring(0, LNormalizedRawPart.Length - 1); + LOpenParenthesis := LNormalizedRawPart.IndexOf('('); + if LOpenParenthesis >= 0 then + begin + LCloseParenthesis := LNormalizedRawPart.IndexOf(')'); + if LCloseParenthesis > LOpenParenthesis then + LTag := LNormalizedRawPart.Substring(0, LOpenParenthesis); + end + else + LTag := LNormalizedRawPart; + + if LTag <> '' then + AddTag(LTag); + end; + FIsInitialized := True; + end + else + begin + LRawPart := APath.Dequeue; + if FIsParamsKey then + begin + LNormalizedRawPart := LRawPart.Substring(1); + if LNormalizedRawPart.EndsWith('?') then + LNormalizedRawPart := LNormalizedRawPart.Substring(0, LNormalizedRawPart.Length - 1); + LOpenParenthesis := LNormalizedRawPart.IndexOf('('); + if LOpenParenthesis >= 0 then + begin + LCloseParenthesis := LNormalizedRawPart.IndexOf(')'); + if LCloseParenthesis > LOpenParenthesis then + LTag := LNormalizedRawPart.Substring(0, LOpenParenthesis); + end + else + LTag := LNormalizedRawPart; + + if LTag <> '' then + AddTag(LTag); + end; + end; + if APath.Count = 0 then + begin {$IF DEFINED(FPC)} - FMiddleware.Add(AMiddleware) + FMiddleware.Add(AMiddleware); {$ELSE} - begin - SetLength(FMiddleware, Length(FMiddleware) + 1); - FMiddleware[Length(FMiddleware) - 1] := AMiddleware; - end + SetLength(FMiddleware, Length(FMiddleware) + 1); + FMiddleware[Length(FMiddleware) - 1] := AMiddleware; {$ENDIF} - else - ForcePath(APath.Peek).RegisterMiddlewareInternal(APath, AMiddleware); + end; + + if APath.Count > 0 then + begin + LNextPart := APath.Peek; + LNormalizedNextPart := NormalizeParamKey(LNextPart); + if not THorseCore.CaseSensitive then + begin + if (not LNextPart.StartsWith(':')) and (not LNextPart.StartsWith('(')) then + LNormalizedNextPart := LowerCase(LNormalizedNextPart); + end; + + LForceRouter := ForcePath(LNormalizedNextPart); + + LForceRouter.RegisterMiddlewareInternal(APath, AMiddleware); + end; end; initialization diff --git a/src/Horse.Core.pas b/src/Horse.Core.pas index 96effdd1..172875ca 100644 --- a/src/Horse.Core.pas +++ b/src/Horse.Core.pas @@ -59,6 +59,9 @@ THorseModule = record THorseCore = class(THorseCoreBase) private + class var FCaseSensitive: Boolean; + class function GetCaseSensitive: Boolean; static; inline; + class procedure SetCaseSensitive(const AValue: Boolean); static; inline; class var FRoutes: IHorseRouter; class var FCallbacks: TList; class var FOnRequest: TList; @@ -97,6 +100,7 @@ THorseCore = class(THorseCoreBase) class function RegisterCallbacksRoute(const AMethod: TMethodType; const APath: string): THorseCore; procedure EmptyNext; public + class property CaseSensitive: Boolean read GetCaseSensitive write SetCaseSensitive; constructor Create; virtual; class function ToModule: THorseModule; class destructor UnInitialize; {$IFNDEF FPC}virtual; {$ENDIF} @@ -387,6 +391,16 @@ procedure THorseLifecycleExecutor.Next; {$I Horse.Core.Wrappers.inc} +class function THorseCore.GetCaseSensitive: Boolean; +begin + Result := FCaseSensitive; +end; + +class procedure THorseCore.SetCaseSensitive(const AValue: Boolean); +begin + FCaseSensitive := AValue; +end; + class function THorseCore.AddCallback(const ACallback: THorseCallback): THorseCore; begin Result := GetInstance; @@ -1731,6 +1745,7 @@ class procedure THorseCore.ExecuteOnTelemetry(const ARequest: THorseRequest; con end; initialization + THorseCore.FCaseSensitive := False; GetHorseCoreInstance := @THorseCore.GetInstance; end. diff --git a/src/Horse.pas b/src/Horse.pas index 7d07b276..1814648c 100644 --- a/src/Horse.pas +++ b/src/Horse.pas @@ -507,7 +507,10 @@ THorse = class(THorseProvider) private class function GetActiveRequests: Integer; static; inline; class function GetIsShuttingDown: Boolean; static; inline; + class function GetCaseSensitive: Boolean; static; inline; + class procedure SetCaseSensitive(const AValue: Boolean); static; inline; public + class property CaseSensitive: Boolean read GetCaseSensitive write SetCaseSensitive; class procedure UseRadixRouter; class property ActiveRequests: Integer read GetActiveRequests; class property IsShuttingDown: Boolean read GetIsShuttingDown; @@ -533,6 +536,16 @@ implementation { THorse } +class function THorse.GetCaseSensitive: Boolean; +begin + Result := THorseCore.CaseSensitive; +end; + +class procedure THorse.SetCaseSensitive(const AValue: Boolean); +begin + THorseCore.CaseSensitive := AValue; +end; + class function THorse.GetActiveRequests: Integer; begin Result := THorseCore.GetActiveRequests; diff --git a/tests/src/tests/Tests.Horse.Core.RouterTree.pas b/tests/src/tests/Tests.Horse.Core.RouterTree.pas index 70517822..683f1d2a 100644 --- a/tests/src/tests/Tests.Horse.Core.RouterTree.pas +++ b/tests/src/tests/Tests.Horse.Core.RouterTree.pas @@ -5,7 +5,7 @@ interface uses DUnitX.TestFramework, Horse.Core.RouterTree, Horse.Request, Horse.Response, System.SysUtils, System.Generics.Collections, - {$IF DEFINED(FPC)} HTTPApp {$ELSE} Web.HTTPApp {$ENDIF}, Horse.Commons; + {$IF DEFINED(FPC)} HTTPApp {$ELSE} Web.HTTPApp {$ENDIF}, Horse.Commons, Horse.Core; type [TestFixture] @@ -64,6 +64,10 @@ TTestHorseCoreRouterTree = class procedure ExecuteRouteWithCoringaoPriority; [Test] procedure ExecuteRouteWithMethodNotAllowedAllowHeader; + [Test] + procedure ExecuteRouteWithDifferentParamNamesAndSharedPrefix; + [Test] + procedure ExecuteRouteCaseSensitivity; end; implementation @@ -506,6 +510,76 @@ procedure TTestHorseCoreRouterTree.ExecuteRouteWithMethodNotAllowedAllowHeader; Assert.IsTrue(LAllow.Contains('POST')); end; +procedure TTestHorseCoreRouterTree.ExecuteRouteWithDifferentParamNamesAndSharedPrefix; +var + L1Called, L2Called: Boolean; +begin + L1Called := False; + L2Called := False; + + FRouterTree.RegisterRoute(mtGet, '/ping/:id/teste', + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + L1Called := True; + Assert.AreEqual('123', Req.Params.Items['id']); + end); + + FRouterTree.RegisterRoute(mtGet, '/ping/:id2/teste2', + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + L2Called := True; + Assert.AreEqual('456', Req.Params.Items['id2']); + end); + + FRequest.Populate('GET', mtGet, '/ping/123/teste', '', ''); + Assert.IsTrue(FRouterTree.Execute(FRequest, FResponse)); + Assert.IsTrue(L1Called); + + FRequest.Clear; + FRequest.Populate('GET', mtGet, '/ping/456/teste2', '', ''); + Assert.IsTrue(FRouterTree.Execute(FRequest, FResponse)); + Assert.IsTrue(L2Called); +end; + +procedure TTestHorseCoreRouterTree.ExecuteRouteCaseSensitivity; +var + LCalled: Boolean; +begin + THorseCore.CaseSensitive := False; + LCalled := False; + FRouterTree.RegisterRoute(mtGet, '/PING', + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + LCalled := True; + end); + + FRequest.Populate('GET', mtGet, '/ping', '', ''); + Assert.IsTrue(FRouterTree.Execute(FRequest, FResponse)); + Assert.IsTrue(LCalled); + + FRouterTree.Free; + FRouterTree := THorseRouterTree.Create; + THorseCore.CaseSensitive := True; + + LCalled := False; + FRouterTree.RegisterRoute(mtGet, '/PING', + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + LCalled := True; + end); + + FRequest.Clear; + FRequest.Populate('GET', mtGet, '/ping', '', ''); + Assert.IsFalse(FRouterTree.Execute(FRequest, FResponse) and LCalled); + + FRequest.Clear; + FRequest.Populate('GET', mtGet, '/PING', '', ''); + Assert.IsTrue(FRouterTree.Execute(FRequest, FResponse)); + Assert.IsTrue(LCalled); + + THorseCore.CaseSensitive := False; +end; + initialization TDUnitX.RegisterTestFixture(TTestHorseCoreRouterTree);