diff --git a/doc/routing.md b/doc/routing.md index 44e66c1..1c2320d 100644 --- a/doc/routing.md +++ b/doc/routing.md @@ -23,6 +23,8 @@ THorse.Head ('/items/:id', ...); Method-routing is exact: `THorse.Get` only matches `GET` requests to that path. A request with the wrong method on a known path returns `405 Method Not Allowed`. A request with an unknown path returns `404 Not Found`. +When `THorse.CaseSensitive` is `False`, route matching folds ASCII letters only. UTF-8 route text is supported, but non-ASCII case folding is intentionally not implied: `/AÇÃO` and `/ação` should be registered and requested with the same non-ASCII spelling. + ## Path parameters Use a colon-prefixed segment to capture part of the URL: diff --git a/doc/routing.pt-BR.md b/doc/routing.pt-BR.md index 066ada6..3ce23b1 100644 --- a/doc/routing.pt-BR.md +++ b/doc/routing.pt-BR.md @@ -23,6 +23,8 @@ THorse.Head ('/items/:id', ...); O roteamento por método é exato: `THorse.Get` só atende requisições `GET` para aquele caminho. Uma requisição com método errado em um caminho conhecido retorna `405 Method Not Allowed`. Uma requisição com caminho desconhecido retorna `404 Not Found`. +Quando `THorse.CaseSensitive` é `False`, a comparação ignora maiúsculas e minúsculas apenas para letras ASCII. Rotas UTF-8 são suportadas, mas isso não implica case folding de caracteres não ASCII: `/AÇÃO` e `/ação` devem ser registrados e requisitados com a mesma grafia não ASCII. + ## Parâmetros de caminho Use um segmento prefixado com dois-pontos para capturar parte da URL: diff --git a/src/Horse.Core.ByteSpan.pas b/src/Horse.Core.ByteSpan.pas index ec92a31..955ea38 100644 --- a/src/Horse.Core.ByteSpan.pas +++ b/src/Horse.Core.ByteSpan.pas @@ -41,7 +41,7 @@ function TByteSpan.ToString(const ABuffer: TBytes): string; if IsEmpty or (ABuffer = nil) or (FOffset < 0) or (FOffset + FLength > System.Length(ABuffer)) then Result := '' else - SetString(Result, PChar(@ABuffer[FOffset]), FLength); + Result := TEncoding.UTF8.GetString(ABuffer, FOffset, FLength); end; end. diff --git a/src/Horse.Core.Param.pas b/src/Horse.Core.Param.pas index 2914c99..2567594 100644 --- a/src/Horse.Core.Param.pas +++ b/src/Horse.Core.Param.pas @@ -34,6 +34,7 @@ THorseCoreParam = class FFields: TDictionary; FContent: TStrings; FRequired: Boolean; + FDecodeValues: Boolean; function GetItem(const AKey: string): string; function GetDictionary: THorseList; @@ -66,7 +67,8 @@ THorseCoreParam = class function AddStream(const AKey: string; const AContent: TStream): THorseCoreParam; overload; function AddStream(const AKey: string; const AContent: TStream; const AOwnsStream: Boolean): THorseCoreParam; overload; - constructor Create(const AParams: THorseList); + constructor Create(const AParams: THorseList; + const ADecodeValues: Boolean = True); destructor Destroy; override; end; @@ -81,11 +83,13 @@ implementation Horse.Utils, Horse.Core.Param.Config; -constructor THorseCoreParam.Create(const AParams: THorseList); +constructor THorseCoreParam.Create(const AParams: THorseList; + const ADecodeValues: Boolean); begin inherited Create; FParams := AParams; FRequired := False; + FDecodeValues := ADecodeValues; end; destructor THorseCoreParam.Destroy; @@ -146,7 +150,10 @@ function THorseCoreParam.TryGetValue(const AKey: string; var AValue: string): Bo Result := FParams.TryGetValue(AKey, LVal); if Result then begin - AValue := DecodeParam(LVal); + if FDecodeValues then + AValue := DecodeParam(LVal) + else + AValue := LVal; if AValue <> LVal then FParams.AddOrSetValue(AKey, AValue); end; @@ -158,7 +165,10 @@ function THorseCoreParam.GetItem(const AKey: string): string; begin if FParams.TryGetValue(AKey, LVal) then begin - Result := DecodeParam(LVal); + if FDecodeValues then + Result := DecodeParam(LVal) + else + Result := LVal; if Result <> LVal then FParams.AddOrSetValue(AKey, Result); end @@ -263,7 +273,10 @@ function THorseCoreParam.GetContent: TStrings; begin FContent := TStringList.Create; for LPair in FParams do - FContent.Add(LPair.Key + '=' + DecodeParam(LPair.Value)); + if FDecodeValues then + FContent.Add(LPair.Key + '=' + DecodeParam(LPair.Value)) + else + FContent.Add(LPair.Key + '=' + LPair.Value); end; Result := FContent; end; @@ -273,10 +286,13 @@ function THorseCoreParam.ToArray: TArray>; I: Integer; begin Result := FParams.ToArray; - for I := 0 to Length(Result) - 1 do + if FDecodeValues then begin - Result[I].Value := DecodeParam(Result[I].Value); - FParams.AddOrSetValue(Result[I].Key, Result[I].Value); + for I := 0 to Length(Result) - 1 do + begin + Result[I].Value := DecodeParam(Result[I].Value); + FParams.AddOrSetValue(Result[I].Key, Result[I].Value); + end; end; end; diff --git a/src/Horse.Core.Router.Radix.pas b/src/Horse.Core.Router.Radix.pas index 3f75d7b..0a91252 100644 --- a/src/Horse.Core.Router.Radix.pas +++ b/src/Horse.Core.Router.Radix.pas @@ -38,6 +38,9 @@ TRadixFlow = class TRadixNode = class public Part: string; +{$IF SizeOf(Char) > 1} + PartBytes: TArray; +{$ENDIF} IsParam: Boolean; ParamName: string; IsOptional: Boolean; @@ -110,12 +113,18 @@ implementation {$IFDEF FPC} function StringToBytes(const AStr: string): TBytes; +{$IF SizeOf(Char) = 1} var I: Integer; +{$ENDIF} begin +{$IF SizeOf(Char) = 1} SetLength(Result, Length(AStr)); for I := 1 to Length(AStr) do Result[I - 1] := Byte(AStr[I]); +{$ELSE} + Result := TEncoding.UTF8.GetBytes(AStr); +{$ENDIF} end; {$ENDIF} @@ -165,6 +174,12 @@ procedure RadixExecutorDoNext; GCurrentNext(); end; +procedure RadixExecutorDoPreValidation(Req: THorseRequest; Res: THorseResponse; + Next: TNextProc); +begin + TRadixExecutor(GCurrentExecutor).DoPreValidation(Req, Res, Next); +end; + constructor TRadixExecutor.Create(ARouter: THorseRadixRouter; AReq: THorseRequest; ARes: THorseResponse); begin FRouter := ARouter; @@ -176,9 +191,13 @@ constructor TRadixExecutor.Create(ARouter: THorseRadixRouter; AReq: THorseReques function TRadixExecutor.Run: Boolean; var LStopwatch: TStopwatch; + LPreviousExecutor: Pointer; + LPreviousNext: TNextProc; begin LStopwatch := TStopwatch.StartNew; FResponse.Request := FRequest; + LPreviousExecutor := GCurrentExecutor; + LPreviousNext := GCurrentNext; GCurrentExecutor := Self; try try @@ -192,9 +211,14 @@ function TRadixExecutor.Run: Boolean; end; end; finally - LStopwatch.Stop; - THorseCore.ExecuteOnTelemetry(FRequest, FResponse, LStopwatch.Elapsed.TotalMilliseconds); - THorse.ExecuteOnResponse(FRequest, FResponse); + try + LStopwatch.Stop; + THorseCore.ExecuteOnTelemetry(FRequest, FResponse, LStopwatch.Elapsed.TotalMilliseconds); + THorse.ExecuteOnResponse(FRequest, FResponse); + finally + GCurrentNext := LPreviousNext; + GCurrentExecutor := LPreviousExecutor; + end; end; end; @@ -239,14 +263,15 @@ procedure TRadixExecutor.DoExecuteRoute; begin LKeys := LParams.Keys.ToArray; for I := 0 to Length(LKeys) - 1 do - FRequest.Params.Dictionary.AddOrSetValue(LKeys[I], DecodeParam(LParams.Items[LKeys[I]])); + FRequest.Params.Dictionary.AddOrSetValue(LKeys[I], + FRequest.DecodePathParam(LParams.Items[LKeys[I]])); end; LCallbacksList := TList.Create; try LCallbacksList.AddRange(FRouter.FGlobalMiddlewares); - LCallbacksList.Add(THorseCallback(DoPreValidation)); + LCallbacksList.Add(Pointer(@RadixExecutorDoPreValidation)); LCallbacksList.AddRange(LMiddlewares); @@ -387,6 +412,9 @@ constructor TRadixNode.Create(const APart: string); LCloseParenthesis: Integer; begin Part := APart; +{$IF SizeOf(Char) > 1} + PartBytes := TEncoding.UTF8.GetBytes(APart); +{$ENDIF} Children := TObjectList.Create(True); Callbacks := TDictionary>.Create; Middlewares := TList.Create; @@ -763,7 +791,13 @@ 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, not THorseCore.CaseSensitive) then + if (not LChild.IsParam) and (LChild.Part <> '*') and +{$IF SizeOf(Char) = 1} + LCurrentSlice.Compare(LChild.Part, not THorseCore.CaseSensitive) then +{$ELSE} + LCurrentSlice.CompareBytes(LChild.PartBytes, 0, Length(LChild.PartBytes), + not THorseCore.CaseSensitive) then +{$ENDIF} begin LTempNode := LChild; LBestMatch := FindNode(ASegments, AIndex + 1, LTempNode, AHTTPType, AMiddlewares, AParams); @@ -878,7 +912,8 @@ function THorseRadixRouter.Execute(const ARequest: THorseRequest; const ARespons begin LKeys := LParams.Keys.ToArray; for I := 0 to Length(LKeys) - 1 do - ARequest.Params.Dictionary.AddOrSetValue(LKeys[I], DecodeParam(LParams.Items[LKeys[I]])); + ARequest.Params.Dictionary.AddOrSetValue(LKeys[I], + ARequest.DecodePathParam(LParams.Items[LKeys[I]])); end; LCallbacksList := TList.Create; diff --git a/src/Horse.Core.RouterTree.NextCaller.pas b/src/Horse.Core.RouterTree.NextCaller.pas index 10e9eec..ec348c2 100644 --- a/src/Horse.Core.RouterTree.NextCaller.pas +++ b/src/Horse.Core.RouterTree.NextCaller.pas @@ -184,7 +184,8 @@ procedure TNextCaller.Init; begin for LTag in FTags do begin - FRequest.Params.Dictionary.AddOrSetValue(LTag, DecodeParam(LCurrentStr)); + FRequest.Params.Dictionary.AddOrSetValue(LTag, + FRequest.DecodePathParam(LCurrentStr)); end; end; end; diff --git a/src/Horse.Core.RouterTree.pas b/src/Horse.Core.RouterTree.pas index b5bc726..05be216 100644 --- a/src/Horse.Core.RouterTree.pas +++ b/src/Horse.Core.RouterTree.pas @@ -35,6 +35,9 @@ THorseRouterTree = class(TInterfacedObject, IHorseRouter) procedure PopulateQueuePath(AQueue: TQueue; APath: string; const AUsePrefix: Boolean = True); private FPart: string; +{$IF SizeOf(Char) > 1} + FPartBytes: TArray; +{$ENDIF} FTags: TArray; FFullPath: string; FIsParamsKey: Boolean; @@ -60,6 +63,7 @@ THorseRouterTree = class(TInterfacedObject, IHorseRouter) function CallNextPath(const ASegments: TArray; AIndex: Integer; const AHTTPType: TMethodType; const ARequest: THorseRequest; const AResponse: THorseResponse): Boolean; function HasNext(const AMethod: TMethodType; const APaths: TArray; AIndex: Integer = 0): Boolean; function CountLiteralSegments(const AMethod: TMethodType; const APaths: TArray; AIndex: Integer = 0): Integer; + function MatchesPart(const APart: THorseBufferSlice): Boolean; class function NormalizeParamKey(const APart: string): string; static; public function CreateRouter(const APath: string): THorseRouterTree; @@ -229,6 +233,16 @@ class function THorseRouterTree.NormalizeParamKey(const APart: string): string; Result := APart; end; +function THorseRouterTree.MatchesPart(const APart: THorseBufferSlice): Boolean; +begin +{$IF SizeOf(Char) = 1} + Result := APart.Compare(FPart, not THorseCore.CaseSensitive); +{$ELSE} + Result := APart.CompareBytes(FPartBytes, 0, Length(FPartBytes), + not THorseCore.CaseSensitive); +{$ENDIF} +end; + procedure THorseRouterTree.RegisterRoute(const AHTTPType: TMethodType; const APath: string; const ACallback: THorseCallback); var LPathChain: TQueue; @@ -290,7 +304,7 @@ function THorseRouterTree.CallNextPath(const ASegments: TArray '*') and LCurrent.Compare(LPair.Key, not THorseCore.CaseSensitive) then + if (LPair.Key <> '*') and LPair.Value.MatchesPart(LCurrent) then begin LAcceptable := LPair.Value; LFound := True; @@ -610,7 +624,7 @@ function THorseRouterTree.CountLiteralSegments(const AMethod: TMethodType; const LNextRoute := nil; for LPair in FRoute do begin - if LNext.Compare(LPair.Key, not THorseCore.CaseSensitive) or (LPair.Key = '*') then + if LPair.Value.MatchesPart(LNext) or (LPair.Key = '*') then begin LNextRoute := LPair.Value; LFound := True; @@ -653,7 +667,8 @@ function THorseRouterTree.HasNext(const AMethod: TMethodType; const APaths: TArr if Length(APaths) - 1 = AIndex then Exit(FCallBack.ContainsKey(AMethod) or (AMethod = mtAny)); end - else if (Length(APaths) - 1 = AIndex) and (APaths[AIndex].Compare(FPart, not THorseCore.CaseSensitive) or FIsParamsKey) then + else if (Length(APaths) - 1 = AIndex) and + (MatchesPart(APaths[AIndex]) or FIsParamsKey) then begin Exit(FCallBack.ContainsKey(AMethod) or (AMethod = mtAny)); end; @@ -665,7 +680,7 @@ function THorseRouterTree.HasNext(const AMethod: TMethodType; const APaths: TArr LNextRoute := nil; for LPair in FRoute do begin - if LNext.Compare(LPair.Key, not THorseCore.CaseSensitive) or (LPair.Key = '*') then + if LPair.Value.MatchesPart(LNext) or (LPair.Key = '*') then begin LNextRoute := LPair.Value; LFound := True; @@ -706,6 +721,9 @@ procedure THorseRouterTree.RegisterInternal(const AHTTPType: TMethodType; var AP begin LRawPart := APath.Dequeue; FPart := LRawPart; +{$IF SizeOf(Char) > 1} + FPartBytes := TEncoding.UTF8.GetBytes(FPart); +{$ENDIF} FIsOptional := False; FIsRouterRegex := False; @@ -868,6 +886,9 @@ procedure THorseRouterTree.RegisterMiddlewareInternal(var APath: TQueue; begin LRawPart := APath.Dequeue; FPart := LRawPart; +{$IF SizeOf(Char) > 1} + FPartBytes := TEncoding.UTF8.GetBytes(FPart); +{$ENDIF} FIsParamsKey := FPart.StartsWith(':'); if FIsParamsKey then begin diff --git a/src/Horse.Request.pas b/src/Horse.Request.pas index 2109fec..1c90145 100644 --- a/src/Horse.Request.pas +++ b/src/Horse.Request.pas @@ -199,6 +199,7 @@ THorseRequest = class ); function RemoteAddr: string; virtual; function GetPathSegments: TArray; + function DecodePathParam(const AValue: string): string; { =========================================================================== PATCH-REQ-5 RawPathInfo Returns the undecoded (percent-encoded) request path on the Delphi/Indy @@ -624,7 +625,7 @@ procedure THorseRequest.AddCookiePair(const APair: string); procedure THorseRequest.InitializeParams; begin - FParams := THorseCoreParam.Create(THorseList.Create).Required(True); + FParams := THorseCoreParam.Create(THorseList.Create, False).Required(True); end; { =========================================================================== @@ -855,27 +856,60 @@ function THorseRequest.GetArena: THorseArenaAllocator; function THorseRequest.GetPathSegments: TArray; var LPath: string; - LPathLen: Integer; LByteCount: Integer; LBytes: TBytes; LTempBytes: TBytes; LSlice: THorseBufferSlice; LStartOffset: Integer; - I, LLen: Integer; - LStart, LCount: Integer; + I, LInputEnd, LLen: Integer; + LStart, LWrite, LCount: Integer; + LDecodedByte: Byte; + LDecodePercent: Boolean; + + function HexValue(const AByte: Byte): Integer; + begin + case AByte of + Ord('0')..Ord('9'): + Result := AByte - Ord('0'); + Ord('A')..Ord('F'): + Result := AByte - Ord('A') + 10; + Ord('a')..Ord('f'): + Result := AByte - Ord('a') + 10; + else + Result := -1; + end; + end; + + function TryDecodePercent(const AIndex: Integer; out AByte: Byte): Boolean; + var + LHigh, LLow: Integer; + begin + Result := False; + if AIndex + 2 >= LInputEnd then + Exit; + LHigh := HexValue(LBytes[AIndex + 1]); + LLow := HexValue(LBytes[AIndex + 2]); + if (LHigh < 0) or (LLow < 0) then + Exit; + AByte := Byte((LHigh shl 4) or LLow); + Result := True; + end; begin LPath := RawPathInfo; - LPathLen := Length(LPath); - if LPathLen = 0 then + if Length(LPath) = 0 then Exit(nil); + // WebBroker and FPC's TRequest expose the original percent-encoded path. + // Populate() supplies the provider's path directly and keeps legacy decoding. + LDecodePercent := Assigned(FWebRequest); + if FArena = nil then begin FArena := THorseArenaAllocator.Create(64 * 1024); FOwnsArena := True; end; -{$IF DEFINED(FPC)} +{$IF SizeOf(Char) = 1} LByteCount := Length(LPath); LSlice := FArena.Allocate(LByteCount); LBytes := LSlice.Buffer; @@ -893,27 +927,44 @@ function THorseRequest.GetPathSegments: TArray; {$ENDIF} SetLength(Result, 0); + LInputEnd := LStartOffset + LByteCount; LStart := LStartOffset; + LWrite := LStartOffset; LCount := 0; I := LStartOffset; - while I < LStartOffset + LByteCount do + while I < LInputEnd do begin if LBytes[I] = 47 then // '/' begin - LLen := I - LStart; - if (LLen > 0) or (LStart = LStartOffset) then + LLen := LWrite - LStart; + if (LLen > 0) or (LCount = 0) then begin Inc(LCount); SetLength(Result, LCount); Result[LCount - 1] := THorseBufferSlice.Create(LBytes, LStart, LLen); end; - LStart := I + 1; + Inc(I); + LStart := LWrite; + Continue; end; + + if LDecodePercent and (LBytes[I] = Ord('%')) and + TryDecodePercent(I, LDecodedByte) then + begin + LBytes[LWrite] := LDecodedByte; + Inc(LWrite); + Inc(I, 3); + Continue; + end; + + if LWrite <> I then + LBytes[LWrite] := LBytes[I]; + Inc(LWrite); Inc(I); end; - LLen := (LStartOffset + LByteCount) - LStart; + LLen := LWrite - LStart; if LLen > 0 then begin Inc(LCount); @@ -923,6 +974,15 @@ function THorseRequest.GetPathSegments: TArray; end; { =========================================================================== } +function THorseRequest.DecodePathParam(const AValue: string): string; +begin + // GetPathSegments decodes WebBroker/FPC paths; CrossSocket supplies a decoded path. + if Assigned(FWebRequest) or Assigned(FCSRawWebRequest) then + Result := AValue + else + Result := DecodeParam(AValue); +end; + { =========================================================================== PATCH-REQ-4 � PopulateCookiesFromHeader implementation Parses the RFC 6265 Cookie header value: diff --git a/tests/src/Console.dpr b/tests/src/Console.dpr index 61d538f..7db2fd9 100644 --- a/tests/src/Console.dpr +++ b/tests/src/Console.dpr @@ -73,6 +73,8 @@ uses Tests.Horse.Core.Group in 'tests\Tests.Horse.Core.Group.pas', {$IFNDEF FPC} Tests.Horse.Core.Router.Radix in 'tests\Tests.Horse.Core.Router.Radix.pas', + {$ELSE} + Tests.Horse.Core.Router.Radix.FPC in 'tests\Tests.Horse.Core.Router.Radix.FPC.pas', {$ENDIF} Tests.Horse.Request.Recycle in 'tests\Tests.Horse.Request.Recycle.pas', Tests.Horse.Core.Middleware in 'tests\Tests.Horse.Core.Middleware.pas', @@ -91,11 +93,11 @@ uses Tests.Integration.Telemetry in 'tests\Tests.Integration.Telemetry.pas', Tests.Integration.WebSocket in 'tests\Tests.Integration.WebSocket.pas', Tests.Integration.AdvancedRouting in 'tests\Tests.Integration.AdvancedRouting.pas', - Tests.Integration.Streaming in 'tests\Tests.Integration.Streaming.pas', - {$IFDEF HORSE_PROVIDER_IOCP} - Tests.Horse.Provider.IOCP in 'tests\Tests.Horse.Provider.IOCP.pas', - {$ENDIF} - Horse.Mime in '..\..\src\Horse.Mime.pas', + Tests.Integration.Streaming in 'tests\Tests.Integration.Streaming.pas', + {$IFDEF HORSE_PROVIDER_IOCP} + Tests.Horse.Provider.IOCP in 'tests\Tests.Horse.Provider.IOCP.pas', + {$ENDIF} + Horse.Mime in '..\..\src\Horse.Mime.pas', Horse.Utils in '..\..\src\Horse.Utils.pas', Horse.Provider.Config in '..\..\src\Horse.Provider.Config.pas', Horse.Provider.IOHandleSSL.Contract in '..\..\src\Horse.Provider.IOHandleSSL.Contract.pas', diff --git a/tests/src/tests/Tests.Horse.Commons.pas b/tests/src/tests/Tests.Horse.Commons.pas index 74b920f..0fc4933 100644 --- a/tests/src/tests/Tests.Horse.Commons.pas +++ b/tests/src/tests/Tests.Horse.Commons.pas @@ -40,6 +40,10 @@ TTestHorseCommons = class(TObject) [Test] [TestCase('Test08', 'BufferSlice IndexOf')] procedure TestBufferSliceIndexOf; +{$IF SizeOf(Char) > 1} + [Test] + procedure TestBufferSliceUtf8String; +{$ENDIF} end; implementation @@ -191,6 +195,19 @@ procedure TTestHorseCommons.TestBufferSliceIndexOf; Assert.AreEqual(-1, LSlice.IndexOf(Ord('?'))); end; +{$IF SizeOf(Char) > 1} +procedure TTestHorseCommons.TestBufferSliceUtf8String; +var + LBuf: TBytes; + LSlice: THorseBufferSlice; +begin + LBuf := TEncoding.UTF8.GetBytes('ação'); + LSlice := THorseBufferSlice.Create(LBuf, 0, Length(LBuf)); + + Assert.AreEqual('ação', LSlice.ToString); +end; +{$ENDIF} + initialization TDUnitX.RegisterTestFixture(TTestHorseCommons); diff --git a/tests/src/tests/Tests.Horse.Core.Router.Radix.FPC.pas b/tests/src/tests/Tests.Horse.Core.Router.Radix.FPC.pas new file mode 100644 index 0000000..d6967f0 --- /dev/null +++ b/tests/src/tests/Tests.Horse.Core.Router.Radix.FPC.pas @@ -0,0 +1,201 @@ +unit Tests.Horse.Core.Router.Radix.FPC; + +interface + +{$IF DEFINED(FPC)} +{$MODE DELPHI}{$H+} +{$ENDIF} + +uses + DUnitX.TestFramework; + +type + [TestFixture] + TTestHorseCoreRouterRadixFPC = class + private + procedure ExecuteNested(const APreValidation: Boolean); + public + [Test] + procedure ExecuteAsciiCallback; + [Test] + procedure ExecuteNestedOnRequest; + [Test] + procedure ExecuteNestedPreValidation; +{$IF SizeOf(Char) > 1} + [Test] + procedure ExecuteUtf8Literal; +{$ENDIF} + end; + +implementation + +uses + Horse, + Horse.Callback, + Horse.Commons, + Horse.Core.Router.Radix, + Horse.Proc, + Horse.Request, + Horse.Response; + +var + GCalled: Boolean; + GInnerCalled: Boolean; + GInnerExecuted: Boolean; + GInsideNestedExecute: Boolean; + GInnerRouter: THorseRadixRouter; + GInnerRequest: THorseRequest; + GInnerResponse: THorseResponse; + +procedure RouteCallback(Req: THorseRequest; Res: THorseResponse; Next: TNextProc); +begin + GCalled := True; +end; + +procedure InnerRouteCallback(Req: THorseRequest; Res: THorseResponse; + Next: TNextProc); +begin + GInnerCalled := True; +end; + +procedure ExecuteInnerRoute; +begin + GInsideNestedExecute := True; + try + GInnerExecuted := GInnerRouter.Execute(GInnerRequest, GInnerResponse); + finally + GInsideNestedExecute := False; + end; +end; + +procedure NestedHook(Req: THorseRequest; Res: THorseResponse; + Next: TNextProc); +begin + if not GInsideNestedExecute then + ExecuteInnerRoute; + Next; +end; + +procedure TTestHorseCoreRouterRadixFPC.ExecuteAsciiCallback; +var + Callback: THorseCallback; + Request: THorseRequest; + Response: THorseResponse; + Router: THorseRadixRouter; +begin + GCalled := False; + Callback := Pointer(@RouteCallback); + Router := THorseRadixRouter.Create; + Request := THorseRequest.Create(nil); + Response := THorseResponse.Create(nil); + try + Router.RegisterRoute(mtGet, '/ping', Callback); + Request.Populate('GET', mtGet, '/ping', '', ''); + + Assert.IsTrue(Router.Execute(Request, Response)); + Assert.IsTrue(GCalled); + Assert.AreEqual('/ping', Request.MatchedRoute); + finally + Response.Free; + Request.Free; + Router.Free; + end; +end; + +procedure TTestHorseCoreRouterRadixFPC.ExecuteNested( + const APreValidation: Boolean); +var + Callback: THorseCallback; + Request: THorseRequest; + Response: THorseResponse; + Router: THorseRadixRouter; +begin + GCalled := False; + GInnerCalled := False; + GInnerExecuted := False; + GInsideNestedExecute := False; + Router := THorseRadixRouter.Create; + Request := THorseRequest.Create(nil); + Response := THorseResponse.Create(nil); + GInnerRouter := THorseRadixRouter.Create; + GInnerRequest := THorseRequest.Create(nil); + GInnerResponse := THorseResponse.Create(nil); + try + Callback := Pointer(@RouteCallback); + Router.RegisterRoute(mtGet, '/outer', Callback); + Callback := Pointer(@InnerRouteCallback); + GInnerRouter.RegisterRoute(mtGet, '/inner', Callback); + Request.Populate('GET', mtGet, '/outer', '', ''); + GInnerRequest.Populate('GET', mtGet, '/inner', '', ''); + if APreValidation then + begin + Callback := Pointer(@NestedHook); + THorse.AddPreValidation(Callback); + end + else + begin + Callback := Pointer(@NestedHook); + THorse.AddOnRequest(Callback); + end; + + Assert.IsTrue(Router.Execute(Request, Response)); + Assert.IsTrue(GInnerExecuted); + Assert.IsTrue(GInnerCalled); + Assert.IsTrue(GCalled); + finally + THorse.ResetHooks; + GInnerResponse.Free; + GInnerRequest.Free; + GInnerRouter.Free; + GInnerResponse := nil; + GInnerRequest := nil; + GInnerRouter := nil; + Response.Free; + Request.Free; + Router.Free; + end; +end; + +procedure TTestHorseCoreRouterRadixFPC.ExecuteNestedOnRequest; +begin + ExecuteNested(False); +end; + +procedure TTestHorseCoreRouterRadixFPC.ExecuteNestedPreValidation; +begin + ExecuteNested(True); +end; + +{$IF SizeOf(Char) > 1} +procedure TTestHorseCoreRouterRadixFPC.ExecuteUtf8Literal; +var + Callback: THorseCallback; + Request: THorseRequest; + Response: THorseResponse; + Router: THorseRadixRouter; +begin + Callback := Pointer(@RouteCallback); + Router := THorseRadixRouter.Create; + Request := THorseRequest.Create(nil); + Response := THorseResponse.Create(nil); + try + GCalled := False; + Router.RegisterRoute(mtGet, '/ação/:id', Callback); + Request.Populate('GET', mtGet, '/ação/42', '', ''); + + Assert.IsTrue(Router.Execute(Request, Response)); + Assert.IsTrue(GCalled); + Assert.AreEqual('/ação/:id', Request.MatchedRoute); + Assert.AreEqual('42', Request.Params.Items['id']); + finally + Response.Free; + Request.Free; + Router.Free; + end; +end; +{$ENDIF} + +initialization + TDUnitX.RegisterTestFixture(TTestHorseCoreRouterRadixFPC); + +end. diff --git a/tests/src/tests/Tests.Horse.Core.Router.Radix.pas b/tests/src/tests/Tests.Horse.Core.Router.Radix.pas index 9297d1a..350584c 100644 --- a/tests/src/tests/Tests.Horse.Core.Router.Radix.pas +++ b/tests/src/tests/Tests.Horse.Core.Router.Radix.pas @@ -48,12 +48,36 @@ TTestHorseCoreRouterRadix = class procedure ExecuteRouteWithMethodNotAllowedAllowHeader; [Test] procedure ExecuteRouteWithPrefix; +{$IF SizeOf(Char) > 1} + [Test] + procedure ExecuteRouteWithUtf8LiteralAndParam; +{$ENDIF} end; implementation { TTestHorseCoreRouterRadix } +{$IF SizeOf(Char) > 1} +procedure TTestHorseCoreRouterRadix.ExecuteRouteWithUtf8LiteralAndParam; +var + LCalled: Boolean; +begin + LCalled := False; + FRouter.RegisterRoute(mtGet, '/ação/:id', + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + LCalled := True; + Assert.AreEqual('42', Req.Params.Items['id']); + end); + + FRequest.Populate('GET', mtGet, '/ação/42', '', ''); + Assert.IsTrue(FRouter.Execute(FRequest, FResponse)); + Assert.IsTrue(LCalled); +end; + +{$ENDIF} + procedure TTestHorseCoreRouterRadix.Setup; begin FRouter := THorseRadixRouter.Create; diff --git a/tests/src/tests/Tests.Horse.Core.RouterTree.pas b/tests/src/tests/Tests.Horse.Core.RouterTree.pas index 683f1d2..6837a5a 100644 --- a/tests/src/tests/Tests.Horse.Core.RouterTree.pas +++ b/tests/src/tests/Tests.Horse.Core.RouterTree.pas @@ -68,12 +68,35 @@ TTestHorseCoreRouterTree = class procedure ExecuteRouteWithDifferentParamNamesAndSharedPrefix; [Test] procedure ExecuteRouteCaseSensitivity; +{$IF SizeOf(Char) > 1} + [Test] + procedure ExecuteRouteWithUtf8LiteralAndParam; +{$ENDIF} end; implementation { TTestHorseCoreRouterTree } +{$IF SizeOf(Char) > 1} +procedure TTestHorseCoreRouterTree.ExecuteRouteWithUtf8LiteralAndParam; +var + LCalled: Boolean; +begin + LCalled := False; + FRouterTree.RegisterRoute(mtGet, '/ação/:id', + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + LCalled := True; + Assert.AreEqual('42', Req.Params.Items['id']); + end); + + FRequest.Populate('GET', mtGet, '/ação/42', '', ''); + Assert.IsTrue(FRouterTree.Execute(FRequest, FResponse)); + Assert.IsTrue(LCalled); +end; +{$ENDIF} + procedure TTestHorseCoreRouterTree.Setup; begin FRouterTree := THorseRouterTree.Create; diff --git a/tests/src/tests/Tests.Integration.AdvancedRouting.pas b/tests/src/tests/Tests.Integration.AdvancedRouting.pas index eb9a242..8ef94f3 100644 --- a/tests/src/tests/Tests.Integration.AdvancedRouting.pas +++ b/tests/src/tests/Tests.Integration.AdvancedRouting.pas @@ -1,4 +1,4 @@ -unit Tests.Integration.AdvancedRouting; +unit Tests.Integration.AdvancedRouting; interface @@ -53,9 +53,12 @@ procedure TTestIntegrationAdvancedRouting.RunRoutingTest(const AUseRadix: Boolea LClient: THTTPClient; LRes: IHTTPResponse; LThread: TThread; + LPreviousCaseSensitive: Boolean; begin FMatchedRoute := ''; FParamId := ''; + LPreviousCaseSensitive := THorse.CaseSensitive; + THorse.CaseSensitive := False; // 1. Chaveia o Roteador sob teste if AUseRadix then @@ -92,6 +95,52 @@ procedure TTestIntegrationAdvancedRouting.RunRoutingTest(const AUseRadix: Boolea Res.Send('user-optional'); end); + // Registra primeiro a rota parametrizada para provar que a literal UTF-8 + // continua tendo precedência, independentemente da ordem de registro. + THorse.Get('/ação/:id', + procedure(Req: THorseRequest; Res: THorseResponse) + begin + Res.Send('utf8-param:' + Req.Params.Items['id']); + end); + + THorse.Get('/ação/fixo', + procedure(Req: THorseRequest; Res: THorseResponse) + begin + Res.Send('utf8-literal'); + end); + + THorse.Use('/área', + procedure(Req: THorseRequest; Res: THorseResponse; Next: TNextProc) + begin + Res.AddHeader('X-UTF8-Middleware', 'matched'); + Next; + end); + + THorse.Get('/área/recurso', + procedure(Req: THorseRequest; Res: THorseResponse) + begin + Res.Send('utf8-middleware'); + end); + + THorse.Group.Prefix('/catálogo') + .Get('/produto/:id', + procedure(Req: THorseRequest; Res: THorseResponse) + begin + Res.Send('utf8-group:' + Req.Params.Items['id']); + end); + + THorse.Get('/ação/CASE', + procedure(Req: THorseRequest; Res: THorseResponse) + begin + Res.Send('ascii-case-fold'); + end); + + THorse.Get('/encoded/:id/tail', + procedure(Req: THorseRequest; Res: THorseResponse) + begin + Res.Send('encoded-param:' + Req.Params.Items['id']); + end); + // Inicia o Servidor em Background LThread := TThread.CreateAnonymousThread( procedure @@ -103,6 +152,7 @@ procedure TTestIntegrationAdvancedRouting.RunRoutingTest(const AUseRadix: Boolea LClient := THTTPClient.Create; try + LClient.CustomHeaders['Connection'] := 'close'; try // Caso 1: Rota Estática (/users/new) LRes := LClient.Get(Format('http://localhost:%d/users/new', [TEST_PORT])); @@ -136,9 +186,52 @@ procedure TTestIntegrationAdvancedRouting.RunRoutingTest(const AUseRadix: Boolea LRes := LClient.Get(Format('http://localhost:%d/users/123/edit', [TEST_PORT])); Assert.AreEqual(404, LRes.StatusCode); + // Caso 6: UTF-8 bruto e precedência da rota literal sobre :id. + LRes := LClient.Get(Format('http://localhost:%d/ação/fixo', [TEST_PORT])); + Assert.AreEqual(200, LRes.StatusCode); + Assert.AreEqual('utf8-literal', LRes.ContentAsString); + + // Caso 7: URI percent-encoded atravessando o provider e parâmetro UTF-8. + LRes := LClient.Get(Format( + 'http://localhost:%d/a%%C3%%A7%%C3%%A3o/caf%%C3%%A9', [TEST_PORT])); + Assert.AreEqual(200, LRes.StatusCode); + Assert.AreEqual('utf8-param:café', LRes.ContentAsString); + + // Caso 8: middleware registrado em path UTF-8. + LRes := LClient.Get(Format( + 'http://localhost:%d/%%C3%%A1rea/recurso', [TEST_PORT])); + Assert.AreEqual(200, LRes.StatusCode); + Assert.AreEqual('utf8-middleware', LRes.ContentAsString); + Assert.AreEqual('matched', LRes.HeaderValue['X-UTF8-Middleware']); + + // Caso 9: prefixo de grupo UTF-8. + LRes := LClient.Get(Format( + 'http://localhost:%d/cat%%C3%%A1logo/produto/7', [TEST_PORT])); + Assert.AreEqual(200, LRes.StatusCode); + Assert.AreEqual('utf8-group:7', LRes.ContentAsString); + + // Caso 10: CaseSensitive=False continua dobrando ASCII dentro de UTF-8. + LRes := LClient.Get(Format( + 'http://localhost:%d/a%%C3%%A7%%C3%%A3o/case', [TEST_PORT])); + Assert.AreEqual(200, LRes.StatusCode); + Assert.AreEqual('ascii-case-fold', LRes.ContentAsString); + + // Caso 11: uma barra percent-encoded pertence ao parâmetro, não ao path. + LRes := LClient.Get(Format( + 'http://localhost:%d/encoded/a%%2Fb/tail', [TEST_PORT])); + Assert.AreEqual(200, LRes.StatusCode); + Assert.AreEqual('encoded-param:a/b', LRes.ContentAsString); + + // Caso 12: o path e o parâmetro são decodificados exatamente uma vez. + LRes := LClient.Get(Format( + 'http://localhost:%d/encoded/a%%252Fb/tail', [TEST_PORT])); + Assert.AreEqual(200, LRes.StatusCode); + Assert.AreEqual('encoded-param:a%2Fb', LRes.ContentAsString); + finally THorse.StopListen; Sleep(500); // Aguarda liberação física da porta + THorse.CaseSensitive := LPreviousCaseSensitive; end; finally LClient.Free;