From 50a93e7c704041596827c798fdda4420232b244e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gys=20Borges=20da=20Silveira?= Date: Fri, 10 Jul 2026 11:49:13 -0300 Subject: [PATCH 1/2] feat: implementar lifecycle hooks no core e nos roteadores tree e radix --- README.md | 1 + README.pt-BR.md | 3 +- doc/index.md | 2 + doc/index.pt-BR.md | 2 + doc/lifecycle-hooks.md | 181 ++++++++++++ doc/lifecycle-hooks.pt-BR.md | 181 ++++++++++++ .../ConsoleLifecycleHooks.dpr | 68 +++++ .../ConsoleLifecycleHooks.lpi | 68 +++++ .../ConsoleLifecycleHooks.lpr | 71 +++++ src/Horse.Core.Router.Radix.pas | 263 ++++++++++-------- src/Horse.Core.RouterTree.NextCaller.pas | 82 ++++-- src/Horse.Core.RouterTree.pas | 87 +++--- src/Horse.Core.pas | 214 ++++++++++++++ src/Horse.Response.pas | 144 +++++----- src/Horse.pas | 2 + tests/src/Console.dpr | 1 + tests/src/tests/Tests.CleanupHelper.pas | 5 +- .../Tests.Integration.LifecycleHooks.pas | 172 ++++++++++++ 18 files changed, 1304 insertions(+), 243 deletions(-) create mode 100644 doc/lifecycle-hooks.md create mode 100644 doc/lifecycle-hooks.pt-BR.md create mode 100644 samples/delphi/console_lifecycle_hooks/ConsoleLifecycleHooks.dpr create mode 100644 samples/lazarus/console_lifecycle_hooks/ConsoleLifecycleHooks.lpi create mode 100644 samples/lazarus/console_lifecycle_hooks/ConsoleLifecycleHooks.lpr create mode 100644 tests/src/tests/Tests.Integration.LifecycleHooks.pas diff --git a/README.md b/README.md index 0fe43d5d..7c19ff5b 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ The full guide lives in [`doc/`](./doc/index.md) — a small wiki that complemen | Defining routes, route params, route groups, query strings | [Routing](./doc/routing.md) | | `THorseRequest` / `THorseResponse` — body, headers, cookies, sessions, status, streaming | [Request & Response](./doc/request-response.md) | | Using middleware, registration order, the `Next` proc | [Middleware](./doc/middleware.md) | +| Request lifecycle hooks — onRequest, preParsing, preValidation, onSend, onResponse | [Lifecycle Hooks](./doc/lifecycle-hooks.md) | | **Writing & publishing your own middleware** — skeleton, thread safety, Provider neutrality, Boss packaging | [**Writing a Middleware**](./doc/writing-middleware.md) | | **Choosing a transport provider** — Indy (default), CrossSocket, mORMot2, ICS, HttpSys, Apache, ISAPI, CGI, daemons | [**Providers**](./doc/providers.md) | | **Deploy** as Console / VCL / Daemon / Windows Service / LCL / HTTPApplication — one-page recipe | [**Deployment Cheatsheet**](./doc/deployment.md) | diff --git a/README.pt-BR.md b/README.pt-BR.md index 70eb18f8..7d3f01b1 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -71,7 +71,8 @@ O guia completo fica em [`doc/`](./doc/index.pt-BR.md) — um pequeno wiki que c | Definir rotas, parâmetros de rota, grupos de rotas, query strings | [Roteamento](./doc/routing.pt-BR.md) | | `THorseRequest` / `THorseResponse` — body, headers, cookies, sessions, status, streaming | [Request e Response](./doc/request-response.pt-BR.md) | | Usar middleware, ordem de registro, o `Next` proc | [Middleware](./doc/middleware.pt-BR.md) | -| **Criar e publicar seu próprio middleware** — esqueleto, thread safety, neutralidade a Provider, empacotamento Boss | [**Criando um Middleware**](./doc/writing-middleware.pt-BR.md) | +| Ganchos de Ciclo de Vida — onRequest, preParsing, preValidation, onSend, onResponse | [Ganchos de Ciclo de Vida](./doc/lifecycle-hooks.pt-BR.md) | +| **Criar e publicar o seu próprio middleware** — esqueleto, thread safety, neutralidade de Provider, empacotamento Boss | [**Criando um Middleware**](./doc/writing-middleware.pt-BR.md) | | **Escolher um provider de transporte** — Indy (padrão), CrossSocket, mORMot2, ICS, HttpSys, Apache, ISAPI, CGI, daemons | [**Providers**](./doc/providers.pt-BR.md) | | **Deploy** como Console / VCL / Daemon / Serviço Windows / LCL / HTTPApplication — receita de uma página | [**Cheatsheet de Deploy**](./doc/deployment.pt-BR.md) | | Catálogo completo de middlewares com descrições estendidas | [Ecossistema de Middlewares](./doc/middleware-ecosystem.pt-BR.md) | diff --git a/doc/index.md b/doc/index.md index 5dc5c38a..25498cef 100644 --- a/doc/index.md +++ b/doc/index.md @@ -41,6 +41,7 @@ graph TD | [Routing](./routing.md) | `THorse.Get` / `Post` / `Put` / `Delete` / `Patch` / `Head` / `Use`; path params; route groups; wildcards; HTTP method enum. | | [Request & Response](./request-response.md) | `THorseRequest` (body, params, query, headers, cookies, sessions, multipart). `THorseResponse` (`Send`, `Status`, `ContentType`, `AddHeader`, `RedirectTo`, `SendFile`, `Download`, `RawWebResponse`). | | [Middleware](./middleware.md) | The `Next` proc model; built-in vs custom; registration order; per-route vs global. | +| [Lifecycle Hooks](./lifecycle-hooks.md) | Request lifecycle hooks (`onRequest`, `preParsing`, `preValidation`, `onSend`, `onResponse`) to extend and intercept request/response pipelines. | | [Writing a Middleware](./writing-middleware.md) | Authoring a production-quality middleware: skeleton, configuration patterns, thread safety, Provider-neutral coding, cross-compiler pitfalls, testing matrix, Boss packaging, publishing. | | [Providers & Application types](./providers.md) | The two-axis model: **Provider** (transport — Indy default; CrossSocket, mORMot2, ICS optional; HttpSys, epoll and IOCP built-in) × **Application type** (Console / VCL / Daemon / LCL / HTTPApplication, plus host-managed Apache / ISAPI / CGI / FCGI). Compatibility matrix and selection guidance. | | [Middleware Ecosystem](./middleware-ecosystem.md) | Official `HashLoad/*` packages and the community-maintained list. | @@ -59,6 +60,7 @@ doc/ ├── routing.md ← URL → handler binding ├── request-response.md ← THorseRequest and THorseResponse API ├── middleware.md ← chaining handlers +├── lifecycle-hooks.md ← request lifecycle hooks (onRequest, etc.) ├── providers.md ← choosing a transport ├── iocp.md ← Windows async I/O completion ports ├── epoll.md ← Linux async event loop diff --git a/doc/index.pt-BR.md b/doc/index.pt-BR.md index ab467c86..d7613c64 100644 --- a/doc/index.pt-BR.md +++ b/doc/index.pt-BR.md @@ -41,6 +41,7 @@ graph TD | [Roteamento](./routing.pt-BR.md) | `THorse.Get` / `Post` / `Put` / `Delete` / `Patch` / `Head` / `Use`; parâmetros de caminho; grupos de rotas; wildcards; enum de método HTTP. | | [Request e Response](./request-response.pt-BR.md) | `THorseRequest` (body, params, query, headers, cookies, sessions, multipart). `THorseResponse` (`Send`, `Status`, `ContentType`, `AddHeader`, `RedirectTo`, `SendFile`, `Download`, `RawWebResponse`). | | [Middleware](./middleware.pt-BR.md) | O modelo `Next` proc; built-in vs custom; ordem de registro; por-rota vs global. | +| [Ganchos de Ciclo de Vida](./lifecycle-hooks.pt-BR.md) | Ganchos de ciclo de vida (`onRequest`, `preParsing`, `preValidation`, `onSend`, `onResponse`) para estender e interceptar requisições. | | [Criando um Middleware](./writing-middleware.pt-BR.md) | Criando um middleware de qualidade de produção: esqueleto, padrões de configuração, thread safety, código neutro a Provider, armadilhas entre compiladores, matriz de testes, empacotamento Boss, publicação. | | [Providers e Tipos de aplicação](./providers.pt-BR.md) | O modelo de dois eixos: **Provider** (transporte — Indy padrão; CrossSocket, mORMot2, ICS opcionais; HttpSys, epoll e IOCP embutidos) × **Tipo de aplicação** (Console / VCL / Daemon / LCL / HTTPApplication, mais host-managed Apache / ISAPI / CGI / FCGI). Matriz de compatibilidade e guia de escolha. | | [Ecossistema de Middlewares](./middleware-ecosystem.pt-BR.md) | Pacotes oficiais `HashLoad/*` e a lista mantida pela comunidade. | @@ -59,6 +60,7 @@ doc/ ├── routing.*.md ← ligação URL → handler ├── request-response.*.md ← API de THorseRequest e THorseResponse ├── middleware.*.md ← encadeamento de handlers +├── lifecycle-hooks.*.md ← ganchos de ciclo de vida (onRequest, etc.) ├── providers.*.md ← escolha de transporte ├── iocp.*.md ← portas de conclusão assíncronas (Windows) ├── epoll.*.md ← laço de eventos assíncronos (Linux) diff --git a/doc/lifecycle-hooks.md b/doc/lifecycle-hooks.md new file mode 100644 index 00000000..ee5e1312 --- /dev/null +++ b/doc/lifecycle-hooks.md @@ -0,0 +1,181 @@ +# Lifecycle Hooks + +*Read this in [English](./lifecycle-hooks.md) or [Português (BR)](./lifecycle-hooks.pt-BR.md).* + +The **Lifecycle Hooks** in Horse provide standardized, guaranteed extension points throughout the lifecycle of an HTTP request. + +Unlike traditional middleware, hooks run at precise architectural phases, allowing you to intercept and manipulate request/response data without relying on the ordering of middlewares in the `Next` chain. + +--- + +## 🗺️ Request Lifecycle Flow + +When an HTTP request hits the Horse server, the request pipeline strictly follows this sequence: + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Core as Horse Core + participant Router as Router / Radix + participant Hooks as Local Hooks + + Client->>Core: HTTP Request + Note over Core: Phase 1: onRequest + Core->>Core: Run global onRequest hooks + + Note over Core: Phase 2: preParsing + Core->>Core: Run global preParsing hooks + + Core->>Router: Resolve Routing + Note over Router: Phase 3: preValidation + Router->>Router: Run preValidation hooks (before Endpoint) + + Router->>Hooks: Run route middleware & endpoint + Hooks->>Hooks: Res.Send(Payload) + + Note over Hooks: Phase 4: onSend + Hooks->>Hooks: Run onSend hooks (modify Payload) + Hooks->>Client: Send Physical HTTP Response + + Note over Core: Phase 5: onResponse (Guaranteed in finally) + Core->>Core: Run onResponse hooks (Audit/Cleanup) +``` + +--- + +## 🔌 1. onRequest (Entry Phase) + +The `onRequest` hook is executed at the very beginning of the request handling, **before routing** and before checking any route path segments. + +* **Signature:** `THorseCallback` +* **Use Cases:** + * Simple firewalls (WAF) or IP blacklisting. + * Fast global validation that shouldn't pay the performance price of a complex router lookup. + * Modifying or injecting early request headers. + +### Example: +```delphi +THorse.AddOnRequest( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + // Block requests without a corporate ID header + if Req.Headers['X-Corporate-ID'] = '' then + Res.Send('Unauthorized').Status(THTTPStatus.Unauthorized) + else + Next; // Continue to the next phase + end); +``` + +--- + +## 📦 2. preParsing (Raw Payload Phase) + +The `preParsing` hook runs after `onRequest` but before any body parsing middleware (like the `Jhonson` JSON parser) reads or interprets the request `Body`. + +* **Signature:** `THorseCallback` +* **Use Cases:** + * Decrypting incoming payloads. If the request body is encrypted, you can decrypt and re-inject it so that downstream JSON parser middlewares see the clean, unencrypted JSON. + * Uncompressing custom input compression formats. + +### Example: +```delphi +THorse.AddPreParsing( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + // Decrypt the raw body received from the client before parsing it to JSON + var RawEncrypted := Req.Body; + var DecryptedJSON := MyCryptoHelper.Decrypt(RawEncrypted); + Req.Body(DecryptedJSON); // Replace the body in the request + Next; + end); +``` + +--- + +## 🛡️ 3. preValidation (Rules Phase) + +The `preValidation` hook is executed once the active route is resolved, but **before** executing the first route-level middleware or the endpoint handler. + +* **Signature:** `THorseCallback` +* **Use Cases:** + * Declarative validation (such as DTO schema validation). + * Validating permissions and authentication tokens specific to the active route. + +### Example: +```delphi +THorse.AddPreValidation( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + // Example: JWT token validation specific to the resolved route + if not IsTokenValidForRoute(Req.MatchedRoute, Req.Headers['Authorization']) then + Res.Send('Forbidden').Status(THTTPStatus.Forbidden) + else + Next; + end); +``` + +--- + +## ✉️ 4. onSend (Send Phase) + +The `onSend` hook intercepts calls to `Res.Send(string)` and `Res.Send(TBytes)` right before the payload is physically written to the client socket or socket provider. It allows modifying the response body "in transit". + +* **Signature:** + * `THorseOnSendString = reference to procedure(const Req: THorseRequest; const Res: THorseResponse; var AContent: string);` + * `THorseOnSendBytes = reference to procedure(const Req: THorseRequest; const Res: THorseResponse; var AContent: TBytes);` +* **Use Cases:** + * Auto-encrypting outgoing responses. + * Automatically appending digital signatures, watermarks, or formatting the payload right before sending. + +### Example (String): +```delphi +THorse.AddOnSend( + procedure(const Req: THorseRequest; const Res: THorseResponse; var AContent: string) + begin + // Encrypt the outgoing response JSON transparently before sending to the client + AContent := MyCryptoHelper.Encrypt(AContent); + end); +``` + +--- + +## 🏁 5. onResponse (Exit / Guaranteed Phase) + +The `onResponse` hook executes at the very exit of the request lifecycle. It is wrapped inside a `try..finally` block at the transport layer of the Provider, which guarantees that it **always runs**, regardless of any controller exceptions (like Access Violations or Database connection errors). + +* **Signature:** `THorseCallback` +* **Use Cases:** + * Auditing requests (logging the final physical HTTP status code). + * Colecting performance metrics (Telemetry/OpenTelemetry). + * Request-scoped resource cleanup (releasing database connections or transaction scopes allocated for the current thread). + +### Example: +```delphi +THorse.AddOnResponse( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + try + // Always close/release the database connection opened for this specific thread + ReleaseConnectionForCurrentThread; + finally + Next; + end; + end); +``` + +--- + +## 🧵 Thread Safety and Concurrency + +Since Horse handles HTTP requests concurrently using multiple worker threads (in socket providers like Indy, epoll, or IOCP): +* All hooks run under the context of the worker thread processing the active request. +* You can pass state safely between different hooks using the thread-safe request dictionary (`Req.State`). + +--- + +## 🚀 Executable Samples + +You can find ready-to-run audit projects to see hooks executing in real-time (compatible with Windows, Linux, and macOS): +* **Delphi (Windows/Linux):** [samples/delphi/console_lifecycle_hooks/ConsoleLifecycleHooks.dpr](file:///d:/Delphi/horse/samples/delphi/console_lifecycle_hooks/ConsoleLifecycleHooks.dpr) +* **Lazarus/FPC (Windows/Linux/macOS):** [samples/lazarus/console_lifecycle_hooks/ConsoleLifecycleHooks.lpr](file:///d:/Delphi/horse/samples/lazarus/console_lifecycle_hooks/ConsoleLifecycleHooks.lpr) diff --git a/doc/lifecycle-hooks.pt-BR.md b/doc/lifecycle-hooks.pt-BR.md new file mode 100644 index 00000000..b0350dd9 --- /dev/null +++ b/doc/lifecycle-hooks.pt-BR.md @@ -0,0 +1,181 @@ +# Ganchos de Ciclo de Vida (Lifecycle Hooks) + +*Read this in [English](./lifecycle-hooks.md) or [Português (BR)](./lifecycle-hooks.pt-BR.md).* + +Os **Lifecycle Hooks** (Ganchos de Ciclo de Vida da Requisição) no Horse fornecem pontos de extensão padronizados e garantidos ao longo do processamento de uma requisição HTTP. + +Ao contrário dos middlewares tradicionais, os ganchos executam em momentos arquiteturais muito bem definidos, permitindo que você intercepte e manipule dados em fases específicas sem depender da ordem direta de declaração dos middlewares na cadeia `Next`. + +--- + +## 🗺️ O Ciclo de Vida da Requisição + +Quando uma requisição atinge o servidor Horse, o pipeline de processamento segue rigorosamente a seguinte sequência temporal de ganchos: + +```mermaid +sequenceDiagram + autonumber + participant Cliente + participant Core as Horse Core + participant Router as Roteador / Radix + participant Hooks as Ganchos Locais + + Cliente->>Core: Requisição HTTP + Note over Core: Fase 1: onRequest + Core->>Core: Executa ganchos globais onRequest + + Note over Core: Fase 2: preParsing + Core->>Core: Executa ganchos globais preParsing + + Core->>Router: Resolve Roteamento + Note over Router: Fase 3: preValidation + Router->>Router: Executa ganchos preValidation (antes do Endpoint) + + Router->>Hooks: Executa middlewares da rota & endpoint + Hooks->>Hooks: Res.Send(Payload) + + Note over Hooks: Fase 4: onSend + Hooks->>Hooks: Executa ganchos onSend (modifica Payload) + Hooks->>Cliente: Envia Resposta HTTP Física + + Note over Core: Fase 5: onResponse (Garantido no finally) + Core->>Core: Executa ganchos onResponse (Auditoria/Limpeza) +``` + +--- + +## 🔌 1. onRequest (Fase de Entrada) + +O gancho `onRequest` é executado logo no início do recebimento da requisição, **antes do roteamento** e antes de qualquer verificação de segmentos de rota. + +* **Assinatura:** `THorseCallback` +* **Ganhos no Dia a Dia:** + * Implementação de firewalls simples (WAF) ou bloqueio de IP. + * Validações globais rápidas que não devem pagar o preço de performance de um roteamento complexo. + * Modificação e injeção precoce de headers da requisição. + +### Exemplo: +```delphi +THorse.AddOnRequest( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + // Bloqueia requisições sem o header de identificação corporativa + if Req.Headers['X-Corporate-ID'] = '' then + Res.Send('Unauthorized').Status(THTTPStatus.Unauthorized) + else + Next; // Continua para a próxima fase + end); +``` + +--- + +## 📦 2. preParsing (Fase de Payload Bruto) + +O gancho `preParsing` executa após o `onRequest`, mas antes que qualquer middleware de parsing (como o `Jhonson` para JSON) leia ou interprete o `Body` da requisição. + +* **Assinatura:** `THorseCallback` +* **Ganhos no Dia a Dia:** + * Descriptografia de payloads de entrada. Se o payload vem criptografado por segurança, este gancho permite descriptografar e reinjetar no request para que os middlewares subsequentes já leiam o JSON limpo. + * Compressão customizada na entrada de tráfego. + +### Exemplo: +```delphi +THorse.AddPreParsing( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + // Descriptografa o body bruto recebido do cliente antes de parsear para JSON + var RawEncrypted := Req.Body; + var DecryptedJSON := MinhaCestaCripto.Decrypt(RawEncrypted); + Req.Body(DecryptedJSON); // Substitui o body no request + Next; + end); +``` + +--- + +## 🛡️ 3. preValidation (Fase de Regras) + +O gancho `preValidation` é executado no momento em que a rota ativa foi resolvida, mas **antes** de começar a rodar o primeiro middleware local ou o handler de endpoint da rota. + +* **Assinatura:** `THorseCallback` +* **Ganhos no Dia a Dia:** + * Validação declarativa de esquemas (como DTO auto-binding). + * Verificações de permissão e autenticação local para a rota selecionada. + +### Exemplo: +```delphi +THorse.AddPreValidation( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + // Exemplo: Validação geral do token JWT local para a rota + if not ValidaTokenParaRota(Req.MatchedRoute, Req.Headers['Authorization']) then + Res.Send('Acesso proibido para a rota').Status(THTTPStatus.Forbidden) + else + Next; + end); +``` + +--- + +## ✉️ 4. onSend (Fase de Envio) + +O gancho `onSend` intercepta as chamadas para `Res.Send(string)` e `Res.Send(TBytes)` logo antes de o payload ser enviado fisicamente ao cliente ou provider de socket. Ele permite alterar o conteúdo "em trânsito". + +* **Assinatura:** + * `THorseOnSendString = reference to procedure(const Req: THorseRequest; const Res: THorseResponse; var AContent: string);` + * `THorseOnSendBytes = reference to procedure(const Req: THorseRequest; const Res: THorseResponse; var AContent: TBytes);` +* **Ganhos no Dia a Dia:** + * Criptografia automática de respostas de saída. + * Modificação e injeção automática de assinaturas digitais, marcas d'água no payload, ou formatações globais tardias. + +### Exemplo (String): +```delphi +THorse.AddOnSend( + procedure(const Req: THorseRequest; const Res: THorseResponse; var AContent: string) + begin + // Criptografa o JSON de saída de forma transparente antes de enviar ao cliente + AContent := MinhaCestaCripto.Encrypt(AContent); + end); +``` + +--- + +## 🏁 5. onResponse (Fase de Saída / Garantida) + +O gancho `onResponse` executa na saída do pipeline de processamento do request. Ele é envelopado de forma centralizada em um bloco `try..finally` na raiz do Provider, o que garante que **sempre executará**, independentemente de erros internos do servidor, interrupções ou exceções de banco de dados no seu controller. + +* **Assinatura:** `THorseCallback` +* **Ganhos no Dia a Dia:** + * Auditoria final da requisição (gravação de logs definitivos com status HTTP físico final). + * Coleta de métricas detalhadas (Telemetria/OpenTelemetry). + * Encerramento e liberação de recursos alocados para a requisição (como transações ativas de banco de dados ou escopo de Request Context). + +### Exemplo: +```delphi +THorse.AddOnResponse( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + try + // Garante que a transação de banco aberta nesta Thread específica seja finalizada e limpa + DesconectarBancoDeDadosDaThread; + finally + Next; + end; + end); +``` + +--- + +## 🧵 Thread Safety e Concorrência + +Como o Horse processa conexões de forma concorrente em múltiplos sockets ou loops de eventos, todos os hooks executam de forma thread-safe: +* Os ganchos executam sob o contexto da thread que está atendendo a requisição ativa. +* A alteração de estado no `Req.State` (um dicionário thread-safe privado de cada requisição) permite passar informações entre diferentes fases dos hooks de forma isolada e segura. + +--- + +## 🚀 Exemplos Práticos Executáveis + +Você pode encontrar projetos prontos e auditáveis para executar e ver os hooks rodando no console em tempo real (compatíveis com Windows, Linux e macOS): +* **Delphi (Windows/Linux):** [samples/delphi/console_lifecycle_hooks/ConsoleLifecycleHooks.dpr](file:///d:/Delphi/horse/samples/delphi/console_lifecycle_hooks/ConsoleLifecycleHooks.dpr) +* **Lazarus/FPC (Windows/Linux/macOS):** [samples/lazarus/console_lifecycle_hooks/ConsoleLifecycleHooks.lpr](file:///d:/Delphi/horse/samples/lazarus/console_lifecycle_hooks/ConsoleLifecycleHooks.lpr) diff --git a/samples/delphi/console_lifecycle_hooks/ConsoleLifecycleHooks.dpr b/samples/delphi/console_lifecycle_hooks/ConsoleLifecycleHooks.dpr new file mode 100644 index 00000000..d465383d --- /dev/null +++ b/samples/delphi/console_lifecycle_hooks/ConsoleLifecycleHooks.dpr @@ -0,0 +1,68 @@ +program ConsoleLifecycleHooks; + +{$APPTYPE CONSOLE} + +uses + Horse, + System.SysUtils; + +begin + Writeln('=== Horse Lifecycle Hooks Audit Sample ==='); + + // 1. onRequest + THorse.AddOnRequest( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Writeln('[Hook] 1. onRequest executado para: ' + Req.PathInfo); + Next; + end); + + // 2. preParsing + THorse.AddPreParsing( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Writeln('[Hook] 2. preParsing executado.'); + Next; + end); + + // 3. preValidation + THorse.AddPreValidation( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Writeln('[Hook] 3. preValidation executado para a rota ativa.'); + Next; + end); + + // 4. onSend (String) + THorse.AddOnSend( + procedure(const Req: THorseRequest; const Res: THorseResponse; var AContent: string) + begin + Writeln('[Hook] 4. onSend (String) interceptado. Payload original: ' + AContent); + AContent := AContent + ' (Assinado pelo onSend)'; + end); + + // 5. onResponse + THorse.AddOnResponse( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Writeln('[Hook] 5. onResponse finalizado com status: ' + Res.Status.ToString); + Writeln('--------------------------------------------------'); + Next; + end); + + // Rota de teste + THorse.Get('/ping', + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Res.Send('pong'); + end); + + THorse.Listen(9000, + procedure + begin + Writeln('Servidor ativo na porta: ' + THorse.Port.ToString); + Writeln('Envie uma requisicao para http://localhost:9000/ping para auditar o ciclo de vida.'); + Writeln('Pressione enter para encerrar...'); + end); + Readln; +end. diff --git a/samples/lazarus/console_lifecycle_hooks/ConsoleLifecycleHooks.lpi b/samples/lazarus/console_lifecycle_hooks/ConsoleLifecycleHooks.lpi new file mode 100644 index 00000000..d3b679f2 --- /dev/null +++ b/samples/lazarus/console_lifecycle_hooks/ConsoleLifecycleHooks.lpi @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + <UseAppBundle Value="False"/> + <ResourceType Value="res"/> + </General> + <BuildModes Count="1"> + <Item1 Name="Default" Default="True"/> + </BuildModes> + <PublishOptions> + <Version Value="2"/> + <UseFileFilters Value="True"/> + </PublishOptions> + <RunParams> + <FormatVersion Value="2"/> + </RunParams> + <Units Count="1"> + <Unit0> + <Filename Value="ConsoleLifecycleHooks.lpr"/> + <IsPartOfProject Value="True"/> + </Unit0> + </Units> + </ProjectOptions> + <CompilerOptions> + <Version Value="11"/> + <PathDelim Value="\"/> + <Target> + <Filename Value="ConsoleLifecycleHooks"/> + </Target> + <SearchPaths> + <IncludeFiles Value="$(ProjOutDir)"/> + <OtherUnitFiles Value="..\..\..\src"/> + <UnitOutputDirectory Value="lib\$(TargetCPU)-$(TargetOS)"/> + </SearchPaths> + <Linking> + <Debugging> + <DebugInfoType Value="dsDwarf2Set"/> + </Debugging> + </Linking> + <Other> + <CustomOptions Value="-dUseCThreads"/> + </Other> + </CompilerOptions> + <Debugging> + <Exceptions Count="3"> + <Item1> + <Name Value="EAbort"/> + </Item1> + <Item2> + <Name Value="ECodetoolError"/> + </Item2> + <Item3> + <Name Value="EFOpenError"/> + </Item3> + </Exceptions> + </Debugging> +</CONFIG> diff --git a/samples/lazarus/console_lifecycle_hooks/ConsoleLifecycleHooks.lpr b/samples/lazarus/console_lifecycle_hooks/ConsoleLifecycleHooks.lpr new file mode 100644 index 00000000..75956143 --- /dev/null +++ b/samples/lazarus/console_lifecycle_hooks/ConsoleLifecycleHooks.lpr @@ -0,0 +1,71 @@ +program ConsoleLifecycleHooks; + +{$MODE DELPHI}{$H+} + +uses + {$IFDEF UNIX} + cthreads, + {$ENDIF} + Horse, + SysUtils; + +begin + Writeln('=== Horse Lifecycle Hooks Audit Sample ==='); + + // 1. onRequest + THorse.AddOnRequest( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Writeln('[Hook] 1. onRequest executado para: ' + Req.PathInfo); + Next; + end); + + // 2. preParsing + THorse.AddPreParsing( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Writeln('[Hook] 2. preParsing executado.'); + Next; + end); + + // 3. preValidation + THorse.AddPreValidation( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Writeln('[Hook] 3. preValidation executado para a rota ativa.'); + Next; + end); + + // 4. onSend (String) + THorse.AddOnSend( + procedure(const Req: THorseRequest; const Res: THorseResponse; var AContent: string) + begin + Writeln('[Hook] 4. onSend (String) interceptado. Payload original: ' + AContent); + AContent := AContent + ' (Assinado pelo onSend)'; + end); + + // 5. onResponse + THorse.AddOnResponse( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Writeln('[Hook] 5. onResponse finalizado com status: ' + IntToStr(Res.Status)); + Writeln('--------------------------------------------------'); + Next; + end); + + // Rota de teste + THorse.Get('/ping', + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Res.Send('pong'); + end); + + THorse.Listen(9000, + procedure + begin + Writeln('Servidor ativo na porta: ' + IntToStr(THorse.Port)); + Writeln('Envie uma requisicao para http://localhost:9000/ping para auditar o ciclo de vida.'); + Writeln('Pressione enter para encerrar...'); + end); + Readln; +end. diff --git a/src/Horse.Core.Router.Radix.pas b/src/Horse.Core.Router.Radix.pas index 6e5323ba..05d04d88 100644 --- a/src/Horse.Core.Router.Radix.pas +++ b/src/Horse.Core.Router.Radix.pas @@ -501,140 +501,167 @@ function THorseRadixRouter.FindNode(const ASegments: TArray<THorseBufferSlice>; function THorseRadixRouter.Execute(const ARequest: THorseRequest; const AResponse: THorseResponse): Boolean; var - LSegments: TArray<THorseBufferSlice>; - LMethodType: TMethodType; - LRawWebRequest: {$IF DEFINED(FPC)}TRequest{$ELSE}TWebRequest{$ENDIF}; - LNode: TRadixNode; - LMiddlewares: TList<THorseCallback>; - LParams: TDictionary<string, string>; - LCallbacksList: TList<THorseCallback>; - LRouteCallbacks: TArray<THorseCallback>; - LFlow: TRadixFlow; - LStartSegmentIndex: Integer; - LKeys: TArray<string>; - I: Integer; - LKey: TMethodType; - LAllow: string; + LResult: Boolean; + LRoot: TRadixNode; + LGlobalMiddlewares: TList<THorseCallback>; begin + LResult := False; + AResponse.Request := ARequest; + LRoot := FRoot; + LGlobalMiddlewares := FGlobalMiddlewares; try - Result := False; - LRawWebRequest := ARequest.RawWebRequest; - if not Assigned(LRawWebRequest) then - LMethodType := ARequest.MethodType - else - LMethodType := TMethodType.FromString(LRawWebRequest.Method); - - LSegments := ARequest.GetPathSegments; - - LStartSegmentIndex := 0; - if (Length(LSegments) > 0) and LSegments[0].Compare('', True) then - LStartSegmentIndex := 1; - - LMiddlewares := TList<THorseCallback>.Create; - LParams := nil; try - LNode := FindNode(LSegments, LStartSegmentIndex, FRoot, LMethodType, LMiddlewares, LParams); - - if LNode <> nil then - begin - ARequest.MatchedRoute := LNode.FullPath; - if LParams <> nil then + THorse.ExecuteOnRequest(ARequest, AResponse, + procedure begin - LKeys := LParams.Keys.ToArray; - for I := 0 to Length(LKeys) - 1 do - ARequest.Params.Dictionary.AddOrSetValue(LKeys[I], DecodeParam(LParams.Items[LKeys[I]])); - end; - - LCallbacksList := TList<THorseCallback>.Create; - try - LCallbacksList.AddRange(FGlobalMiddlewares); - LCallbacksList.AddRange(LMiddlewares); - - if LNode.Callbacks.TryGetValue(LMethodType, LRouteCallbacks) or LNode.Callbacks.TryGetValue(mtAny, LRouteCallbacks) then - begin - LCallbacksList.AddRange(LRouteCallbacks); - end - else - begin - if LNode.Callbacks.Count > 0 then + THorse.ExecutePreParsing(ARequest, AResponse, + procedure + var + LSegments: TArray<THorseBufferSlice>; + LNode: TRadixNode; + LMiddlewares: TList<THorseCallback>; + LParams: TDictionary<string, string>; + LCallbacksList: TList<THorseCallback>; + LRouteCallbacks: TArray<THorseCallback>; + LFlow: TRadixFlow; + LStartSegmentIndex: Integer; + LKeys: TArray<string>; + I: Integer; + LKey: TMethodType; + LAllow: string; + LMethodType: TMethodType; + LRawWebRequest: {$IF DEFINED(FPC)}TRequest{$ELSE}TWebRequest{$ENDIF}; begin - LAllow := ''; - for LKey in LNode.Callbacks.Keys do - begin - if LKey <> TMethodType.mtAny then + LRawWebRequest := ARequest.RawWebRequest; + if not Assigned(LRawWebRequest) then + LMethodType := ARequest.MethodType + else + LMethodType := TMethodType.FromString(LRawWebRequest.Method); + + LSegments := ARequest.GetPathSegments; + + LStartSegmentIndex := 0; + if (Length(LSegments) > 0) and LSegments[0].Compare('', True) then + LStartSegmentIndex := 1; + + LMiddlewares := TList<THorseCallback>.Create; + LParams := nil; + try + LNode := FindNode(LSegments, LStartSegmentIndex, LRoot, LMethodType, LMiddlewares, LParams); + + if LNode <> nil then + begin + ARequest.MatchedRoute := LNode.FullPath; + if LParams <> nil then + begin + LKeys := LParams.Keys.ToArray; + for I := 0 to Length(LKeys) - 1 do + ARequest.Params.Dictionary.AddOrSetValue(LKeys[I], DecodeParam(LParams.Items[LKeys[I]])); + end; + + LCallbacksList := TList<THorseCallback>.Create; + try + LCallbacksList.AddRange(LGlobalMiddlewares); + + // Injeção síncrona do gancho preValidation + LCallbacksList.Add( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + THorse.ExecutePreValidation(Req, Res, Next); + end); + + LCallbacksList.AddRange(LMiddlewares); + + if LNode.Callbacks.TryGetValue(LMethodType, LRouteCallbacks) or LNode.Callbacks.TryGetValue(mtAny, LRouteCallbacks) then + begin + LCallbacksList.AddRange(LRouteCallbacks); + end + else + begin + if LNode.Callbacks.Count > 0 then + begin + LAllow := ''; + for LKey in LNode.Callbacks.Keys do + begin + if LKey <> TMethodType.mtAny then + begin + if LAllow <> '' then + LAllow := LAllow + ', '; + LAllow := LAllow + UpperCase(LKey.ToString); + end; + end; + if LAllow <> '' then + AResponse.AddHeader('Allow', LAllow); + {$IF DEFINED(FPC)}LCallbacksList.Add(@RadixMethodNotAllowedFinalizer){$ELSE}LCallbacksList.Add(RadixMethodNotAllowedFinalizer){$ENDIF}; + end + else + {$IF DEFINED(FPC)}LCallbacksList.Add(@RadixNotFoundFinalizer){$ELSE}LCallbacksList.Add(RadixNotFoundFinalizer){$ENDIF}; + end; + + LFlow := TRadixFlow.Create(LCallbacksList, ARequest, AResponse); + try + LFlow.Next; + finally + LFlow.Free; + end; + finally + LCallbacksList.Free; + end; + LResult := True; + end + else begin - if LAllow <> '' then - LAllow := LAllow + ', '; - LAllow := LAllow + UpperCase(LKey.ToString); + LCallbacksList := TList<THorseCallback>.Create; + try + LCallbacksList.AddRange(LGlobalMiddlewares); + {$IF DEFINED(FPC)}LCallbacksList.Add(@RadixNotFoundFinalizer){$ELSE}LCallbacksList.Add(RadixNotFoundFinalizer){$ENDIF}; + + LFlow := TRadixFlow.Create(LCallbacksList, ARequest, AResponse); + try + LFlow.Next; + finally + LFlow.Free; + end; + finally + LCallbacksList.Free; + end; + LResult := True; end; + finally + LMiddlewares.Free; + if LParams <> nil then + LParams.Free; end; - if LAllow <> '' then - AResponse.AddHeader('Allow', LAllow); - {$IF DEFINED(FPC)}LCallbacksList.Add(@RadixMethodNotAllowedFinalizer){$ELSE}LCallbacksList.Add(RadixMethodNotAllowedFinalizer){$ENDIF}; - end - else - {$IF DEFINED(FPC)}LCallbacksList.Add(@RadixNotFoundFinalizer){$ELSE}LCallbacksList.Add(RadixNotFoundFinalizer){$ENDIF}; - end; - - LFlow := TRadixFlow.Create(LCallbacksList, ARequest, AResponse); - try - LFlow.Next; - finally - LFlow.Free; - end; - finally - LCallbacksList.Free; - end; - Result := True; - end - else - begin - LCallbacksList := TList<THorseCallback>.Create; - try - LCallbacksList.AddRange(FGlobalMiddlewares); - {$IF DEFINED(FPC)}LCallbacksList.Add(@RadixNotFoundFinalizer){$ELSE}LCallbacksList.Add(RadixNotFoundFinalizer){$ENDIF}; - - LFlow := TRadixFlow.Create(LCallbacksList, ARequest, AResponse); - try - LFlow.Next; - finally - LFlow.Free; - end; - finally - LCallbacksList.Free; - end; - Result := True; - end; - finally - LMiddlewares.Free; - if LParams <> nil then - LParams.Free; - end; - - AResponse.FlushCookiesToWebResponse; - except - on E: Exception do - begin - if THorse.HasOnError then + end); + end); + Result := LResult; + AResponse.FlushCookiesToWebResponse; + except + on E: Exception do begin - if E is EHorseCallbackInterrupted then + if THorse.HasOnError then begin - Result := True; + if E is EHorseCallbackInterrupted then + begin + Result := True; + end + else + begin + THorse.ExecuteOnError(ARequest, AResponse, E); + Result := True; + end; end else begin - THorse.ExecuteOnError(ARequest, AResponse, E); - Result := True; + {$IF DEFINED(FPC)} + Writeln('CRITICAL RADIX ERROR: ', E.ClassName, ': ', E.Message); Flush(Output); + {$ENDIF} + raise; end; - end - else - begin - {$IF DEFINED(FPC)} - Writeln('CRITICAL RADIX ERROR: ', E.ClassName, ': ', E.Message); Flush(Output); - {$ENDIF} - raise; end; end; + finally + THorse.ExecuteOnResponse(ARequest, AResponse); end; end; diff --git a/src/Horse.Core.RouterTree.NextCaller.pas b/src/Horse.Core.RouterTree.NextCaller.pas index 8a1849b3..ad965263 100644 --- a/src/Horse.Core.RouterTree.NextCaller.pas +++ b/src/Horse.Core.RouterTree.NextCaller.pas @@ -188,34 +188,72 @@ procedure TNextCaller.Next; {$ENDIF} if (LCallbackCount > FIndexCallback) then begin - try - FFound^ := True; - {$IF DEFINED(FPC)} - THorseCallbackProc(LCallback.Items[FIndexCallback])(FRequest, FResponse, Next); - {$ELSE} - LCallback[FIndexCallback](FRequest, FResponse, Next); - {$ENDIF} - except - on E: Exception do - begin - if E is EHorseCallbackInterrupted then - raise; - if E is EHorseException then + if FIndexCallback = 0 then + begin + THorse.ExecutePreValidation(FRequest, FResponse, + procedure begin - FResponse.Send(EHorseException(E).Error).Status(EHorseException(E).Status); - Exit; - end; - if THorse.HasOnError then + try + FFound^ := True; + {$IF DEFINED(FPC)} + THorseCallbackProc(LCallback.Items[FIndexCallback])(FRequest, FResponse, Next); + {$ELSE} + LCallback[FIndexCallback](FRequest, FResponse, Next); + {$ENDIF} + except + on E: Exception do + begin + if E is EHorseCallbackInterrupted then + raise; + if E is EHorseException then + begin + FResponse.Send(EHorseException(E).Error).Status(EHorseException(E).Status); + Exit; + end; + if THorse.HasOnError then + begin + THorse.ExecuteOnError(FRequest, FResponse, E); + Exit; + end; + if FResponse.Status < Integer(THTTPStatus.BadRequest) then + FResponse.Send('Internal Application Error: ' + E.Message).Status(THTTPStatus.InternalServerError); + Exit; + end; + end; + Next; + end); + end + else + begin + try + FFound^ := True; + {$IF DEFINED(FPC)} + THorseCallbackProc(LCallback.Items[FIndexCallback])(FRequest, FResponse, Next); + {$ELSE} + LCallback[FIndexCallback](FRequest, FResponse, Next); + {$ENDIF} + except + on E: Exception do begin - THorse.ExecuteOnError(FRequest, FResponse, E); + if E is EHorseCallbackInterrupted then + raise; + if E is EHorseException then + begin + FResponse.Send(EHorseException(E).Error).Status(EHorseException(E).Status); + Exit; + end; + if THorse.HasOnError then + begin + THorse.ExecuteOnError(FRequest, FResponse, E); + Exit; + end; + if FResponse.Status < Integer(THTTPStatus.BadRequest) then + FResponse.Send('Internal Application Error: ' + E.Message).Status(THTTPStatus.InternalServerError); Exit; end; - if FResponse.Status < Integer(THTTPStatus.BadRequest) then - FResponse.Send('Internal Application Error: ' + E.Message).Status(THTTPStatus.InternalServerError); - Exit; end; + Next; end; - Next; end; end else diff --git a/src/Horse.Core.RouterTree.pas b/src/Horse.Core.RouterTree.pas index 4df08404..f7025aa0 100644 --- a/src/Horse.Core.RouterTree.pas +++ b/src/Horse.Core.RouterTree.pas @@ -264,52 +264,69 @@ function THorseRouterTree.Execute(const ARequest: THorseRequest; const AResponse LMethodType: TMethodType; LRawWebRequest: {$IF DEFINED(FPC)}TRequest{$ELSE}TWebRequest{$ENDIF}; LBufferNotFound: TBytes; + LResult: Boolean; begin + LResult := False; + AResponse.Request := ARequest; try - LRawWebRequest := ARequest.RawWebRequest; - if not Assigned(LRawWebRequest) then - begin - LMethodType := ARequest.MethodType; - end - else - begin - LMethodType := TMethodType.FromString(LRawWebRequest.Method); - end; - LSegments := ARequest.GetPathSegments; - Result := ExecuteInternal(LSegments, 0, LMethodType, ARequest, AResponse); - if not Result then - begin - SetLength(LSegmentsNotFound, 2); - LBufferNotFound := TEncoding.UTF8.GetBytes('/*'); - LSegmentsNotFound[0] := THorseBufferSlice.Create(LBufferNotFound, 0, 0); - LSegmentsNotFound[1] := THorseBufferSlice.Create(LBufferNotFound, 1, 1); - - Result := ExecuteInternal(LSegmentsNotFound, 0, LMethodType, ARequest, AResponse); - if Result and (AResponse.Status = THTTPStatus.MethodNotAllowed.ToInteger) then - AResponse.Send('Not Found').Status(THTTPStatus.NotFound); - end; - except - on E: Exception do - begin - if THorse.HasOnError then + try + LRawWebRequest := ARequest.RawWebRequest; + if not Assigned(LRawWebRequest) then begin - if E is EHorseCallbackInterrupted then + LMethodType := ARequest.MethodType; + end + else + begin + LMethodType := TMethodType.FromString(LRawWebRequest.Method); + end; + + THorse.ExecuteOnRequest(ARequest, AResponse, + procedure + begin + THorse.ExecutePreParsing(ARequest, AResponse, + procedure + begin + LSegments := ARequest.GetPathSegments; + LResult := ExecuteInternal(LSegments, 0, LMethodType, ARequest, AResponse); + if not LResult then + begin + SetLength(LSegmentsNotFound, 2); + LBufferNotFound := TEncoding.UTF8.GetBytes('/*'); + LSegmentsNotFound[0] := THorseBufferSlice.Create(LBufferNotFound, 0, 0); + LSegmentsNotFound[1] := THorseBufferSlice.Create(LBufferNotFound, 1, 1); + + LResult := ExecuteInternal(LSegmentsNotFound, 0, LMethodType, ARequest, AResponse); + if LResult and (AResponse.Status = THTTPStatus.MethodNotAllowed.ToInteger) then + AResponse.Send('Not Found').Status(THTTPStatus.NotFound); + end; + end); + end); + Result := LResult; + except + on E: Exception do + begin + if THorse.HasOnError then begin - Result := True; + if E is EHorseCallbackInterrupted then + begin + Result := True; + end + else + begin + THorse.ExecuteOnError(ARequest, AResponse, E); + Result := True; + end; end else begin - THorse.ExecuteOnError(ARequest, AResponse, E); - Result := True; + raise; end; - end - else - begin - raise; end; end; + AResponse.FlushCookiesToWebResponse; + finally + THorse.ExecuteOnResponse(ARequest, AResponse); end; - AResponse.FlushCookiesToWebResponse; end; function THorseRouterTree.ExecuteInternal(const ASegments: TArray<THorseBufferSlice>; AIndex: Integer; const AHTTPType: TMethodType; const ARequest: THorseRequest; diff --git a/src/Horse.Core.pas b/src/Horse.Core.pas index 86407e64..a70c8062 100644 --- a/src/Horse.Core.pas +++ b/src/Horse.Core.pas @@ -29,6 +29,8 @@ interface type THorseOnError = procedure(const ARequest: THorseRequest; const AResponse: THorseResponse; const AException: Exception); + THorseOnSendString = reference to procedure(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: string); + THorseOnSendBytes = reference to procedure(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: TBytes); THorseCore = class; PHorseCore = ^THorseCore; @@ -51,6 +53,12 @@ THorseCore = class(THorseCoreBase) private class var FRoutes: IHorseRouter; class var FCallbacks: TList<THorseCallback>; + class var FOnRequest: TList<THorseCallback>; + class var FPreParsing: TList<THorseCallback>; + class var FPreValidation: TList<THorseCallback>; + class var FOnSendString: TList<THorseOnSendString>; + class var FOnSendBytes: TList<THorseOnSendBytes>; + class var FOnResponse: TList<THorseCallback>; class function TrimPath(const APath: string): string; class function RegisterRoute(const AHTTPType: TMethodType; const APath: string; const ACallback: THorseCallback): THorseCore; class function RegisterRouteMiddleware(const AHTTPType: TMethodType; const APath: string; const ACallback: THorseCallback): THorseCore; @@ -94,6 +102,21 @@ THorseCore = class(THorseCoreBase) class function HasOnError: Boolean; static; class procedure ExecuteOnError(const ARequest: THorseRequest; const AResponse: THorseResponse; const AException: Exception); static; + class procedure AddOnRequest(const ACallback: THorseCallback); static; + class procedure AddPreParsing(const ACallback: THorseCallback); static; + class procedure AddPreValidation(const ACallback: THorseCallback); static; + class procedure AddOnSend(const ACallback: THorseOnSendString); overload; static; + class procedure AddOnSend(const ACallback: THorseOnSendBytes); overload; static; + class procedure AddOnResponse(const ACallback: THorseCallback); static; + class procedure ResetHooks; static; + + class procedure ExecuteOnRequest(const ARequest: THorseRequest; const AResponse: THorseResponse; const AOnComplete: TProc); static; + class procedure ExecutePreParsing(const ARequest: THorseRequest; const AResponse: THorseResponse; const AOnComplete: TProc); static; + class procedure ExecutePreValidation(const ARequest: THorseRequest; const AResponse: THorseResponse; const AOnComplete: TProc); static; + class procedure ExecuteOnSend(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: string); overload; static; + class procedure ExecuteOnSend(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: TBytes); overload; static; + class procedure ExecuteOnResponse(const ARequest: THorseRequest; const AResponse: THorseResponse); static; + class function Use(const APath: string; const ACallback: THorseCallback): THorseCore; overload; class function Use(const ACallback: THorseCallback): THorseCore; overload; class function Use(const APath: string; const ACallbacks: array of THorseCallback): THorseCore; overload; @@ -291,6 +314,49 @@ implementation {$ENDIF} ; +type + IHorseLifecycleExecutor = interface + ['{69A45BBE-C54D-4158-9A3E-9457DE85D833}'] + procedure Next; + end; + + THorseLifecycleExecutor = class(TInterfacedObject, IHorseLifecycleExecutor) + private + FCallbacks: TList<THorseCallback>; + FIndex: Integer; + FRequest: THorseRequest; + FResponse: THorseResponse; + FOnComplete: TProc; + public + constructor Create(const ACallbacks: TList<THorseCallback>; const ARequest: THorseRequest; const AResponse: THorseResponse; const AOnComplete: TProc); + procedure Next; + end; + +constructor THorseLifecycleExecutor.Create(const ACallbacks: TList<THorseCallback>; const ARequest: THorseRequest; const AResponse: THorseResponse; const AOnComplete: TProc); +begin + FCallbacks := ACallbacks; + FIndex := -1; + FRequest := ARequest; + FResponse := AResponse; + FOnComplete := AOnComplete; +end; + +procedure THorseLifecycleExecutor.Next; +begin + if FResponse.Aborted then + Exit; + + Inc(FIndex); + if (FCallbacks <> nil) and (FIndex < FCallbacks.Count) then + begin + FCallbacks[FIndex](FRequest, FResponse, Next); + end + else if Assigned(FOnComplete) then + begin + FOnComplete(); + end; +end; + {$I Horse.Core.Wrappers.inc} class function THorseCore.AddCallback(const ACallback: THorseCallback): THorseCore; @@ -474,6 +540,18 @@ class function THorseCore.MakeHorseModule: THorseModule; FRoutes := nil; if FCallbacks <> nil then FreeAndNil(FCallbacks); + if FOnRequest <> nil then + FreeAndNil(FOnRequest); + if FPreParsing <> nil then + FreeAndNil(FPreParsing); + if FPreValidation <> nil then + FreeAndNil(FPreValidation); + if FOnSendString <> nil then + FreeAndNil(FOnSendString); + if FOnSendBytes <> nil then + FreeAndNil(FOnSendBytes); + if FOnResponse <> nil then + FreeAndNil(FOnResponse); end; {$IF (defined(fpc) or (CompilerVersion > 27.0))} @@ -1265,6 +1343,142 @@ class procedure THorseCore.ExecuteOnError(const ARequest: THorseRequest; const A end; end; +class procedure THorseCore.AddOnRequest(const ACallback: THorseCallback); +begin + if FOnRequest = nil then + FOnRequest := TList<THorseCallback>.Create; + FOnRequest.Add(ACallback); +end; + +class procedure THorseCore.AddPreParsing(const ACallback: THorseCallback); +begin + if FPreParsing = nil then + FPreParsing := TList<THorseCallback>.Create; + FPreParsing.Add(ACallback); +end; + +class procedure THorseCore.AddPreValidation(const ACallback: THorseCallback); +begin + if FPreValidation = nil then + FPreValidation := TList<THorseCallback>.Create; + FPreValidation.Add(ACallback); +end; + +class procedure THorseCore.AddOnSend(const ACallback: THorseOnSendString); +begin + if FOnSendString = nil then + FOnSendString := TList<THorseOnSendString>.Create; + FOnSendString.Add(ACallback); +end; + +class procedure THorseCore.AddOnSend(const ACallback: THorseOnSendBytes); +begin + if FOnSendBytes = nil then + FOnSendBytes := TList<THorseOnSendBytes>.Create; + FOnSendBytes.Add(ACallback); +end; + +class procedure THorseCore.AddOnResponse(const ACallback: THorseCallback); +begin + if FOnResponse = nil then + FOnResponse := TList<THorseCallback>.Create; + FOnResponse.Add(ACallback); +end; + +class procedure THorseCore.ResetHooks; +begin + if FOnRequest <> nil then + FOnRequest.Clear; + if FPreParsing <> nil then + FPreParsing.Clear; + if FPreValidation <> nil then + FPreValidation.Clear; + if FOnSendString <> nil then + FOnSendString.Clear; + if FOnSendBytes <> nil then + FOnSendBytes.Clear; + if FOnResponse <> nil then + FOnResponse.Clear; +end; + +class procedure THorseCore.ExecuteOnRequest(const ARequest: THorseRequest; const AResponse: THorseResponse; const AOnComplete: TProc); +var + LExecutor: IHorseLifecycleExecutor; +begin + if Assigned(ARequest) and (FOnRequest <> nil) and (FOnRequest.Count > 0) then + begin + LExecutor := THorseLifecycleExecutor.Create(FOnRequest, ARequest, AResponse, AOnComplete); + LExecutor.Next; + end + else + AOnComplete(); +end; + +class procedure THorseCore.ExecutePreParsing(const ARequest: THorseRequest; const AResponse: THorseResponse; const AOnComplete: TProc); +var + LExecutor: IHorseLifecycleExecutor; +begin + if Assigned(ARequest) and (FPreParsing <> nil) and (FPreParsing.Count > 0) then + begin + LExecutor := THorseLifecycleExecutor.Create(FPreParsing, ARequest, AResponse, AOnComplete); + LExecutor.Next; + end + else + AOnComplete(); +end; + +class procedure THorseCore.ExecutePreValidation(const ARequest: THorseRequest; const AResponse: THorseResponse; const AOnComplete: TProc); +var + LExecutor: IHorseLifecycleExecutor; +begin + if Assigned(ARequest) and (FPreValidation <> nil) and (FPreValidation.Count > 0) then + begin + LExecutor := THorseLifecycleExecutor.Create(FPreValidation, ARequest, AResponse, AOnComplete); + LExecutor.Next; + end + else + AOnComplete(); +end; + +class procedure THorseCore.ExecuteOnSend(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: string); +var + LHook: THorseOnSendString; +begin + if Assigned(ARequest) and (FOnSendString <> nil) then + begin + for LHook in FOnSendString do + LHook(ARequest, AResponse, AContent); + end; +end; + +class procedure THorseCore.ExecuteOnSend(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: TBytes); +var + LHook: THorseOnSendBytes; +begin + if Assigned(ARequest) and (FOnSendBytes <> nil) then + begin + for LHook in FOnSendBytes do + LHook(ARequest, AResponse, AContent); + end; +end; + +class procedure THorseCore.ExecuteOnResponse(const ARequest: THorseRequest; const AResponse: THorseResponse); +var + LCallback: THorseCallback; +begin + if Assigned(ARequest) and (FOnResponse <> nil) then + begin + for LCallback in FOnResponse do + begin + try + LCallback(ARequest, AResponse, procedure begin end); + except + // Abafar exceções no onResponse para não crashar a finalização da thread de socket + end; + end; + end; +end; + initialization GetHorseCoreInstance := @THorseCore.GetInstance; diff --git a/src/Horse.Response.pas b/src/Horse.Response.pas index 07cccc97..7dee4cb0 100644 --- a/src/Horse.Response.pas +++ b/src/Horse.Response.pas @@ -22,7 +22,7 @@ interface {$ENDIF} {$ENDIF} { =========================================================================== - PATCH-RES-1 added System.Generics.Collections (Delphi only) + PATCH-RES-1 — added System.Generics.Collections (Delphi only) Reason: FCustomHeaders is TList<TPair<string,string>> on Delphi. FPC path uses TStringList (Classes) which is already imported above. =========================================================================== } @@ -37,24 +37,25 @@ interface THorseResponse = class private FWebResponse: {$IF DEFINED(FPC)}TResponse{$ELSE}TWebResponse{$ENDIF}; + FRequest: TObject; FAborted: Boolean; FContent: TObject; { =========================================================================== - PATCH-RES-1 added FCustomHeaders field + PATCH-RES-1 — added FCustomHeaders field Reason: CrossSocket has no TWebResponse. TResponseBridge.CopyHeaders iterates this list directly to write headers to ICrossHttpResponse. AddHeader writes to both FWebResponse.SetCustomHeader (Indy path) and this map (CrossSocket path) so all existing middleware that calls Res.AddHeader continues to work on both providers without any change. - Delphi: TDictionary<string,string> O(1) lookup; last value wins for + Delphi: TDictionary<string,string> — O(1) lookup; last value wins for duplicate keys (AddOrSetValue overwrites). - FPC: TStringList same key=value string storage used on the Lazarus path. + FPC: TStringList — same key=value string storage used on the Lazarus path. =========================================================================== } FCustomHeaders: {$IF NOT DEFINED(FPC)}TDictionary<string, string>{$ELSE}TStringList{$ENDIF}; { =========================================================================== } { =========================================================================== - PATCH-COOKIE-1 typed Set-Cookie list (RFC 6265). + PATCH-COOKIE-1 — typed Set-Cookie list (RFC 6265). A key-value header map cannot hold multiple Set-Cookie headers, so cookies added via AddCookie/Cookie are kept here (owned) and each response bridge emits one Set-Cookie line per entry. Lazy-created; nil when no cookie set. @@ -62,32 +63,32 @@ THorseResponse = class FCookies: TObjectList<THorseCookie>; { =========================================================================== } { =========================================================================== - PATCH-RES-4 CrossSocket shadow fields + PATCH-RES-4 — CrossSocket shadow fields Reason: On the CrossSocket path FWebResponse is nil (no TWebResponse exists). Every public method that previously wrote to FWebResponse now checks for nil and falls through to these fields instead. The bridge reads them via the read-only properties BodyText, ContentStream, and CSContentType. - FCSStatusCode integer HTTP status (default 200) - FCSBody string body set by Send(string) or Send<T> - FCSContentType Content-Type set by ContentType(string) or SendFile - FCSContentStream stream body set by SendFile/Download/Render + FCSStatusCode — integer HTTP status (default 200) + FCSBody — string body set by Send(string) or Send<T> + FCSContentType — Content-Type set by ContentType(string) or SendFile + FCSContentStream — stream body set by SendFile/Download/Render =========================================================================== } FCSStatusCode: Integer; FCSBody: string; FCSBodyBytes: TBytes; FCSContentType: string; FCSContentStream: TStream; // see FCSOwnsContentStream -{ PATCH-SENDFILE-1 SendFile/Download on the shadow (CrossSocket/mORMot) path +{ PATCH-SENDFILE-1 — SendFile/Download on the shadow (CrossSocket/mORMot) path COPY the source into a response-owned stream so the caller may free their own stream immediately; the provider flushes AFTER the handler returns. When FCSOwnsContentStream is True, Clear/Destroy free FCSContentStream. } FCSOwnsContentStream: Boolean; { =========================================================================== } { =========================================================================== - PATCH-RES-6 owned RawWebResponse adapter for CrossSocket path + PATCH-RES-6 — owned RawWebResponse adapter for CrossSocket path Mirrors PATCH-REQ-8: when FWebResponse is nil (CrossSocket), middleware that - calls Res.RawWebResponse.SetCustomHeader(...) e.g. Horse.CORS would + calls Res.RawWebResponse.SetCustomHeader(...) — e.g. Horse.CORS — would crash with an AV. This field holds a TCrossSocketWebResponse adapter so RawWebResponse returns a non-nil value. @@ -99,7 +100,7 @@ THorseResponse = class FCSRawWebResponse: {$IF DEFINED(FPC)}TResponse{$ELSE}TWebResponse{$ENDIF}; { =========================================================================== } { =========================================================================== - PATCH-RES-7 lazy-allocation helper for FCustomHeaders. + PATCH-RES-7 — lazy-allocation helper for FCustomHeaders. Was eagerly created in the constructor; now created only when AddHeader is first called. Eager allocation paid an unconditional cost on every Indy/ISAPI/CGI request that never read or wrote a custom header. @@ -123,12 +124,12 @@ THorseResponse = class function Status: Integer; overload; virtual; function AddHeader(const AName, AValue: string): THorseResponse; virtual; function RemoveHeader(const AName: string): THorseResponse; virtual; -{ PATCH-COOKIE-1 typed Set-Cookie API (RFC 6265). AddCookie takes ownership of +{ PATCH-COOKIE-1 — typed Set-Cookie API (RFC 6265). AddCookie takes ownership of ACookie; Cookie(name,value) creates one, adds it, and returns it for fluent attribute setting. Each provider bridge emits one Set-Cookie line per entry. } function AddCookie(const ACookie: THorseCookie): THorseResponse; function Cookie(const AName, AValue: string): THorseCookie; -{ PATCH-COOKIE-1 (Indy) called by THorseRouterTree.Execute after the pipeline. +{ PATCH-COOKIE-1 (Indy) — called by THorseRouterTree.Execute after the pipeline. Maps the typed cookie list onto the WebBroker TWebResponse.Cookies so Indy emits one Set-Cookie line per cookie. No-op when FWebResponse is nil (CrossSocket/mORMot read FCookies directly in their bridges). } @@ -140,37 +141,37 @@ THorseResponse = class function RawWebResponse: {$IF DEFINED(FPC)}TResponse{$ELSE}TWebResponse{$ENDIF}; virtual; constructor Create(const AWebResponse: {$IF DEFINED(FPC)}TResponse{$ELSE}TWebResponse{$ENDIF}); { =========================================================================== - PATCH-RES-2 added Clear procedure + PATCH-RES-2 — added Clear procedure Reason: THorseContext.Reset recycles pooled objects between requests. Resets FContent and clears FCustomHeaders in place (dictionary object - reused avoids heap churn on the request hot path). - FWebResponse is set to nil belongs to the previous Indy context. + reused — avoids heap churn on the request hot path). + FWebResponse is set to nil — belongs to the previous Indy context. =========================================================================== } procedure Clear; { =========================================================================== } { =========================================================================== - PATCH-RES-6 setter for the owned RawWebResponse adapter. Called once per + PATCH-RES-6 — setter for the owned RawWebResponse adapter. Called once per request by the CrossSocket provider after pool acquire. Replaces any prior - adapter instance (defence in depth Clear normally nils it first). + adapter instance (defence in depth — Clear normally nils it first). =========================================================================== } procedure SetCSRawWebResponse(const ARawWebResponse: {$IF DEFINED(FPC)}TResponse{$ELSE}TWebResponse{$ENDIF}); { =========================================================================== } { =========================================================================== - PATCH-RES-3 added CustomHeaders read-only property + PATCH-RES-3 — added CustomHeaders read-only property Reason: TResponseBridge.CopyHeaders reads this property to iterate and - forward response headers to ICrossHttpResponse. Read-only the bridge + forward response headers to ICrossHttpResponse. Read-only — the bridge iterates only; all writes go through AddHeader as before. =========================================================================== } property CustomHeaders: {$IF NOT DEFINED(FPC)}TDictionary<string, string>{$ELSE}TStringList{$ENDIF} read FCustomHeaders; { =========================================================================== } { =========================================================================== - PATCH-COOKIE-1 read-only cookie list for the response bridges. nil until + PATCH-COOKIE-1 — read-only cookie list for the response bridges. nil until the first AddCookie/Cookie call. =========================================================================== } property Cookies: TObjectList<THorseCookie> read FCookies; { =========================================================================== } { =========================================================================== - PATCH-RES-4 read-only properties for the CrossSocket bridge + PATCH-RES-4 — read-only properties for the CrossSocket bridge TResponseBridge.Flush reads these to write the response body and Content-Type to ICrossHttpResponse. All three are populated only when FWebResponse is nil (CrossSocket path); on the Indy path they are empty. @@ -182,6 +183,7 @@ THorseResponse = class { =========================================================================== } function Abort: THorseResponse; virtual; property Aborted: Boolean read FAborted; + property Request: TObject read FRequest write FRequest; destructor Destroy; override; end; @@ -189,17 +191,19 @@ implementation uses Horse.Core.Files, - Horse.Mime; + Horse.Mime, + Horse.Request, + Horse.Core; function THorseResponse.AddHeader(const AName, AValue: string): THorseResponse; begin -{ PATCH-RES-4 nil-guard: skip FWebResponse on CrossSocket path } +{ PATCH-RES-4 — nil-guard: skip FWebResponse on CrossSocket path } if Assigned(FWebResponse) then FWebResponse.SetCustomHeader(AName, AValue); { end PATCH-RES-4 } { =========================================================================== - PATCH-RES-1 also populate FCustomHeaders so CrossSocket bridge can read it. - PATCH-RES-7 allocate the headers store on first use (was eager in Create). + PATCH-RES-1 — also populate FCustomHeaders so CrossSocket bridge can read it. + PATCH-RES-7 — allocate the headers store on first use (was eager in Create). Delphi: TDictionary.AddOrSetValue FPC: TStringList.Values[name] := value =========================================================================== } EnsureCustomHeaders; @@ -213,7 +217,7 @@ function THorseResponse.AddHeader(const AName, AValue: string): THorseResponse; end; { =========================================================================== - PATCH-RES-7 EnsureCustomHeaders implementation + PATCH-RES-7 — EnsureCustomHeaders implementation =========================================================================== } procedure THorseResponse.EnsureCustomHeaders; begin @@ -239,7 +243,7 @@ function THorseResponse.Content: TObject; function THorseResponse.ContentType(const AContentType: string): THorseResponse; begin -{ PATCH-RES-4 nil-guard } +{ PATCH-RES-4 — nil-guard } if not Assigned(FWebResponse) then begin FCSContentType := AContentType; @@ -253,7 +257,7 @@ function THorseResponse.ContentType(const AContentType: string): THorseResponse; constructor THorseResponse.Create(const AWebResponse: {$IF DEFINED(FPC)}TResponse{$ELSE}TWebResponse{$ENDIF}); begin FWebResponse := AWebResponse; -{ PATCH-RES-4 initialise FCSStatusCode to 200 (HTTP OK) } +{ PATCH-RES-4 — initialise FCSStatusCode to 200 (HTTP OK) } FCSStatusCode := 200; { end PATCH-RES-4 } if Assigned(FWebResponse) then @@ -264,14 +268,14 @@ constructor THorseResponse.Create(const AWebResponse: {$IF DEFINED(FPC)}TRespons {$ENDIF} end; { =========================================================================== - PATCH-RES-7 FCustomHeaders is no longer eagerly allocated here. + PATCH-RES-7 — FCustomHeaders is no longer eagerly allocated here. AddHeader calls EnsureCustomHeaders on first use. Indy/ISAPI/CGI requests that never call AddHeader pay nothing. =========================================================================== } end; { =========================================================================== - PATCH-RES-2 Clear implementation + PATCH-RES-2 — Clear implementation =========================================================================== } procedure THorseResponse.Clear; begin @@ -283,11 +287,11 @@ procedure THorseResponse.Clear; if Assigned(FCustomHeaders) then FCustomHeaders.Clear; -{ PATCH-RES-4 wipe CrossSocket shadow fields } +{ PATCH-RES-4 — wipe CrossSocket shadow fields } FCSBody := ''; FCSBodyBytes := nil; FCSContentType := ''; -{ PATCH-SENDFILE-1 free the owned copy (SendFile/Download); else just nil it. } +{ PATCH-SENDFILE-1 — free the owned copy (SendFile/Download); else just nil it. } if FCSOwnsContentStream and Assigned(FCSContentStream) then FreeAndNil(FCSContentStream) else @@ -295,12 +299,12 @@ procedure THorseResponse.Clear; FCSOwnsContentStream := False; FCSStatusCode := 200; { end PATCH-RES-4 } -{ PATCH-RES-6 free the per-request TWebResponse adapter (owned). +{ PATCH-RES-6 — free the per-request TWebResponse adapter (owned). Nil on the Indy path (never assigned there); owned on CrossSocket path. } if Assigned(FCSRawWebResponse) then FreeAndNil(FCSRawWebResponse); { end PATCH-RES-6 } -{ PATCH-COOKIE-1 drop the owned cookies on pool recycle. } +{ PATCH-COOKIE-1 — drop the owned cookies on pool recycle. } if Assigned(FCookies) then FCookies.Clear; { end PATCH-COOKIE-1 } @@ -314,7 +318,7 @@ function THorseResponse.Abort: THorseResponse; end; { =========================================================================== - PATCH-RES-6 SetCSRawWebResponse implementation + PATCH-RES-6 — SetCSRawWebResponse implementation =========================================================================== } procedure THorseResponse.SetCSRawWebResponse( const ARawWebResponse: {$IF DEFINED(FPC)}TResponse{$ELSE}TWebResponse{$ENDIF}); @@ -329,20 +333,20 @@ destructor THorseResponse.Destroy; if Assigned(FContent) then FContent.Free; { =========================================================================== - PATCH-RES-1 free FCustomHeaders + PATCH-RES-1 — free FCustomHeaders =========================================================================== } if Assigned(FCustomHeaders) then FCustomHeaders.Free; { =========================================================================== } -{ PATCH-RES-6 free the owned TWebResponse adapter if Clear was not called +{ PATCH-RES-6 — free the owned TWebResponse adapter if Clear was not called before Destroy (e.g. pool shutdown path). } if Assigned(FCSRawWebResponse) then FCSRawWebResponse.Free; { end PATCH-RES-6 } -{ PATCH-SENDFILE-1 free the owned SendFile/Download copy if still held. } +{ PATCH-SENDFILE-1 — free the owned SendFile/Download copy if still held. } if FCSOwnsContentStream and Assigned(FCSContentStream) then FreeAndNil(FCSContentStream); -{ PATCH-COOKIE-1 free the owned cookie list. } +{ PATCH-COOKIE-1 — free the owned cookie list. } if Assigned(FCookies) then FreeAndNil(FCookies); inherited; @@ -350,7 +354,7 @@ destructor THorseResponse.Destroy; function THorseResponse.RawWebResponse: {$IF DEFINED(FPC)}TResponse{$ELSE}TWebResponse{$ENDIF}; begin -{ PATCH-RES-6 return the CrossSocket adapter when FWebResponse is nil, +{ PATCH-RES-6 — return the CrossSocket adapter when FWebResponse is nil, so middleware calling Res.RawWebResponse.SetCustomHeader (e.g. Horse.CORS) works on the CrossSocket path without an AV. } if Assigned(FWebResponse) then @@ -360,39 +364,47 @@ function THorseResponse.RawWebResponse: {$IF DEFINED(FPC)}TResponse{$ELSE}TWebRe end; function THorseResponse.Send(const AContent: TBytes): THorseResponse; +var + LContent: TBytes; begin + LContent := AContent; + THorseCore.ExecuteOnSend(THorseRequest(FRequest), Self, LContent); if not Assigned(FWebResponse) then begin - FCSBodyBytes := AContent; + FCSBodyBytes := LContent; Exit(Self); end; - if Length(AContent) = 0 then + if Length(LContent) = 0 then begin FWebResponse.ContentStream := TMemoryStream.Create; FWebResponse.ContentLength := 0; Exit(Self); end; - FWebResponse.ContentStream := TBytesStream.Create(AContent); + FWebResponse.ContentStream := TBytesStream.Create(LContent); Result := Self; end; function THorseResponse.Send(const AContent: string): THorseResponse; +var + LContent: string; begin -{ PATCH-RES-4 nil-guard } + LContent := AContent; + THorseCore.ExecuteOnSend(THorseRequest(FRequest), Self, LContent); +{ PATCH-RES-4 — nil-guard } if not Assigned(FWebResponse) then begin - FCSBody := AContent; + FCSBody := LContent; Exit(Self); end; { end PATCH-RES-4 } {$IF NOT DEFINED(FPC)} -{ PATCH-RES-5 Indy empty-body fix +{ PATCH-RES-5 — Indy empty-body fix When ContentText = '' and ContentStream = nil, TIdHTTPResponseInfo.WriteContent substitutes a default HTML body (<HTML><BODY><B>200 OK</B></BODY></HTML>). Assigning an empty TMemoryStream forces Indy into the stream path: it sends 0 bytes and no HTML is generated. FreeContentStream defaults to True so Indy owns and frees the stream. } - if AContent = '' then + if LContent = '' then begin FWebResponse.ContentStream := TMemoryStream.Create; FWebResponse.ContentLength := 0; @@ -400,7 +412,7 @@ function THorseResponse.Send(const AContent: string): THorseResponse; end; { end PATCH-RES-5 } {$ENDIF} - FWebResponse.Content := AContent; + FWebResponse.Content := LContent; Result := Self; end; @@ -412,7 +424,7 @@ function THorseResponse.Send<T>(AContent: T): THorseResponse; function THorseResponse.RedirectTo(const ALocation: string): THorseResponse; begin -{ PATCH-RES-4 nil-guard: on CrossSocket path FWebResponse is nil; +{ PATCH-RES-4 — nil-guard: on CrossSocket path FWebResponse is nil; AddHeader already dual-writes to FCustomHeaders so Location is captured. Status delegates to FCSStatusCode when FWebResponse is nil. } if Assigned(FWebResponse) then @@ -425,7 +437,7 @@ function THorseResponse.RedirectTo(const ALocation: string): THorseResponse; function THorseResponse.RedirectTo(const ALocation: string; const AStatus: THTTPStatus): THorseResponse; begin -{ PATCH-RES-4 nil-guard } +{ PATCH-RES-4 — nil-guard } if Assigned(FWebResponse) then FWebResponse.SetCustomHeader('Location', ALocation) else @@ -438,7 +450,7 @@ function THorseResponse.RemoveHeader(const AName: string): THorseResponse; var I: Integer; begin -{ PATCH-RES-4 nil-guard: skip FWebResponse access on CrossSocket path } +{ PATCH-RES-4 — nil-guard: skip FWebResponse access on CrossSocket path } if Assigned(FWebResponse) then begin I := FWebResponse.CustomHeaders.IndexOfName(AName); @@ -447,8 +459,8 @@ function THorseResponse.RemoveHeader(const AName: string): THorseResponse; end; { end PATCH-RES-4 } { =========================================================================== - PATCH-RES-1 also remove from FCustomHeaders - PATCH-RES-7 FCustomHeaders is allocated lazily; nothing to remove if it + PATCH-RES-1 — also remove from FCustomHeaders + PATCH-RES-7 — FCustomHeaders is allocated lazily; nothing to remove if it was never created (no AddHeader call ever ran). Delphi: TDictionary.Remove FPC: TStringList delete by IndexOfName =========================================================================== } @@ -467,7 +479,7 @@ function THorseResponse.RemoveHeader(const AName: string): THorseResponse; end; { =========================================================================== - PATCH-COOKIE-1 typed Set-Cookie API implementation + PATCH-COOKIE-1 — typed Set-Cookie API implementation =========================================================================== } function THorseResponse.AddCookie(const ACookie: THorseCookie): THorseResponse; begin @@ -510,12 +522,12 @@ procedure THorseResponse.FlushCookiesToWebResponse; if LState.Path <> '' then LWebCookie.Path := LState.Path; if LState.HasExpires then - LWebCookie.Expires := LState.Expires; // TCookie has no Max-Age use .Expires() on Indy + LWebCookie.Expires := LState.Expires; // TCookie has no Max-Age — use .Expires() on Indy LWebCookie.Secure := LState.Secure; -{$IF CompilerVersion >= 31} // Delphi 10.1 Berlin+ TCookie.HttpOnly +{$IF CompilerVersion >= 31} // Delphi 10.1 Berlin+ — TCookie.HttpOnly LWebCookie.HttpOnly := LState.HttpOnly; {$IFEND} -{$IF CompilerVersion >= 34} // Delphi 10.4 Sydney+ TCookie.SameSite (string) +{$IF CompilerVersion >= 34} // Delphi 10.4 Sydney+ — TCookie.SameSite (string) case LState.SameSite of ssStrict: LWebCookie.SameSite := 'Strict'; ssLax: LWebCookie.SameSite := 'Lax'; @@ -529,7 +541,7 @@ procedure THorseResponse.FlushCookiesToWebResponse; function THorseResponse.Status(const AStatus: THTTPStatus): THorseResponse; begin -{ PATCH-RES-4 nil-guard } +{ PATCH-RES-4 — nil-guard } if not Assigned(FWebResponse) then begin FCSStatusCode := AStatus.ToInteger; @@ -552,7 +564,7 @@ function THorseResponse.SendFile(const AFileStream: TStream; const AFileName: st if LContentType = EmptyStr then LContentType := Horse.Mime.THorseMimeTypes.GetFileType(LFileName); -{ PATCH-RES-4 / PATCH-SENDFILE-1 nil-guard: on the CrossSocket/mORMot path, +{ PATCH-RES-4 / PATCH-SENDFILE-1 — nil-guard: on the CrossSocket/mORMot path, COPY the source into a response-owned stream. The response is flushed AFTER the handler returns, so a non-owning reference would dangle the moment the caller frees AFileStream (the common `try SendFile finally FreeAndNil` idiom); @@ -619,7 +631,7 @@ function THorseResponse.Download(const AFileStream: TStream; const AFileName: st if LContentType = EmptyStr then LContentType := Horse.Mime.THorseMimeTypes.GetFileType(LFileName); -{ PATCH-RES-4 / PATCH-SENDFILE-1 nil-guard: copy into a response-owned stream +{ PATCH-RES-4 / PATCH-SENDFILE-1 — nil-guard: copy into a response-owned stream so the caller may free AFileStream immediately (flush happens post-handler). } if not Assigned(FWebResponse) then begin @@ -686,7 +698,7 @@ function THorseResponse.Render(const AFileName: string): THorseResponse; function THorseResponse.Status: Integer; begin -{ PATCH-RES-4 nil-guard } +{ PATCH-RES-4 — nil-guard } if not Assigned(FWebResponse) then Exit(FCSStatusCode); { end PATCH-RES-4 } @@ -695,7 +707,7 @@ function THorseResponse.Status: Integer; function THorseResponse.Status(const AStatus: Integer): THorseResponse; begin -{ PATCH-RES-4 nil-guard } +{ PATCH-RES-4 — nil-guard } if not Assigned(FWebResponse) then begin FCSStatusCode := AStatus; diff --git a/src/Horse.pas b/src/Horse.pas index 5085116f..810d2539 100644 --- a/src/Horse.pas +++ b/src/Horse.pas @@ -360,6 +360,8 @@ interface type EHorseException = Horse.Exception.EHorseException; + THorseOnSendString = Horse.Core.THorseOnSendString; + THorseOnSendBytes = Horse.Core.THorseOnSendBytes; EHorseCallbackInterrupted = Horse.Exception.Interrupted.EHorseCallbackInterrupted; TProc = Horse.Proc.TProc; TNextProc = Horse.Proc.TNextProc; diff --git a/tests/src/Console.dpr b/tests/src/Console.dpr index 47909e44..5b13aea2 100644 --- a/tests/src/Console.dpr +++ b/tests/src/Console.dpr @@ -79,6 +79,7 @@ uses Tests.Integration.LargePayload in 'tests\Tests.Integration.LargePayload.pas', Tests.Integration.ReadTimeout in 'tests\Tests.Integration.ReadTimeout.pas', Tests.Integration.Query in 'tests\Tests.Integration.Query.pas', + Tests.Integration.LifecycleHooks in 'tests\Tests.Integration.LifecycleHooks.pas', Horse.Mime in '..\..\src\Horse.Mime.pas', Horse.Utils in '..\..\src\Horse.Utils.pas', Horse.Provider.Config in '..\..\src\Horse.Provider.Config.pas', diff --git a/tests/src/tests/Tests.CleanupHelper.pas b/tests/src/tests/Tests.CleanupHelper.pas index 9d62bfbc..5ac3aff8 100644 --- a/tests/src/tests/Tests.CleanupHelper.pas +++ b/tests/src/tests/Tests.CleanupHelper.pas @@ -33,7 +33,10 @@ procedure ClearGlobalState; THorse.Host := '0.0.0.0'; THorse.MaxConnections := 0; - // 4. Limpa a lista privada de middlewares globais (FCallbacks) no THorseCore e THorse via RTTI + // 4. Limpa todos os ganchos registrados de forma nativa e estática + THorseCore.ResetHooks; + + // 5. Limpa a lista privada de middlewares globais (FCallbacks) no THorseCore e THorse via RTTI LContext := TRttiContext.Create; try LType := LContext.GetType(THorseCore) as TRttiInstanceType; diff --git a/tests/src/tests/Tests.Integration.LifecycleHooks.pas b/tests/src/tests/Tests.Integration.LifecycleHooks.pas new file mode 100644 index 00000000..074085bd --- /dev/null +++ b/tests/src/tests/Tests.Integration.LifecycleHooks.pas @@ -0,0 +1,172 @@ +unit Tests.Integration.LifecycleHooks; + +interface + +uses + DUnitX.TestFramework, Horse, Horse.Commons, RESTRequest4D, + System.SysUtils, System.Classes, System.Threading, Tests.CleanupHelper; + +type + [TestFixture] + TTestIntegrationLifecycleHooks = class + private + const TEST_PORT = 9097; + class var FOnResponseCalled: Boolean; + public + [SetupFixture] + procedure SetupFixture; + [TearDownFixture] + procedure TearDownFixture; + + [Test] + procedure TestOnRequestAndPreParsingAndPreValidationAndOnSend; + [Test] + procedure TestOnRequestAbortingEarly; + [Test] + procedure TestOnResponseExecution; + end; + +implementation + +{ TTestIntegrationLifecycleHooks } + +procedure TTestIntegrationLifecycleHooks.SetupFixture; +begin + FOnResponseCalled := False; + + // 1. Registro dos Hooks de teste + THorse.AddOnRequest( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Req.Headers.Dictionary.AddOrSetValue('X-OnRequest-Hook', 'Passed-OnRequest'); + Next; + end); + + THorse.AddPreParsing( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Req.Headers.Dictionary.AddOrSetValue('X-PreParsing-Hook', 'Passed-PreParsing'); + Next; + end); + + THorse.AddPreValidation( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Req.Headers.Dictionary.AddOrSetValue('X-PreValidation-Hook', 'Passed-PreValidation'); + Next; + end); + + THorse.AddOnSend( + procedure(const Req: THorseRequest; const Res: THorseResponse; var AContent: string) + begin + AContent := AContent + '-ModifiedByOnSend'; + end); + + THorse.AddOnResponse( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + FOnResponseCalled := True; + Next; + end); + + // 2. Registro do gancho que aborta antecipadamente + THorse.Get('/abort-early', + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + Res.Send('Should not reach here'); + end); + + // Registra um OnRequest exclusivo para a rota de abortar antecipadamente + THorse.AddOnRequest( + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + begin + if Req.PathInfo = '/abort-early' then + begin + Res.Send('AbortedEarly').Status(THTTPStatus.Forbidden); + // Não chama Next + end + else + Next; + end); + + // 3. Rota de teste padrão + THorse.Get('/test-lifecycle', + procedure(Req: THorseRequest; Res: THorseResponse; Next: TProc) + var + LResultJson: string; + begin + LResultJson := Format('{"onrequest":"%s","preparsing":"%s","prevalidation":"%s"}', [ + Req.Headers['X-OnRequest-Hook'], + Req.Headers['X-PreParsing-Hook'], + Req.Headers['X-PreValidation-Hook'] + ]); + Res.Send(LResultJson); + end); + + // Inicializa o servidor em background + TThread.CreateAnonymousThread( + procedure + begin + THorse.Listen(TEST_PORT); + end).Start; + + Sleep(2000); +end; + +procedure TTestIntegrationLifecycleHooks.TearDownFixture; +begin + ClearGlobalState; + Sleep(500); +end; + +procedure TTestIntegrationLifecycleHooks.TestOnRequestAndPreParsingAndPreValidationAndOnSend; +var + LReq: IRequest; + LRes: IResponse; + LExpectedContent: string; +begin + LReq := TRequest.New; + LRes := LReq.BaseURL(Format('http://localhost:%d/test-lifecycle', [TEST_PORT])) + .Accept('application/json') + .Get; + + Assert.AreEqual(200, LRes.StatusCode, 'Should return HTTP 200'); + + LExpectedContent := '{"onrequest":"Passed-OnRequest","preparsing":"Passed-PreParsing","prevalidation":"Passed-PreValidation"}-ModifiedByOnSend'; + Assert.AreEqual(LExpectedContent, LRes.Content, 'Should have executed and Modified by OnSend'); +end; + +procedure TTestIntegrationLifecycleHooks.TestOnRequestAbortingEarly; +var + LReq: IRequest; + LRes: IResponse; +begin + LReq := TRequest.New; + LRes := LReq.BaseURL(Format('http://localhost:%d/abort-early', [TEST_PORT])) + .Get; + + Assert.AreEqual(403, LRes.StatusCode, 'Should return HTTP 403 Forbidden due to early abort'); + Assert.AreEqual('AbortedEarly-ModifiedByOnSend', LRes.Content, 'Should return the aborted content (modified by OnSend)'); +end; + +procedure TTestIntegrationLifecycleHooks.TestOnResponseExecution; +var + LReq: IRequest; + LRes: IResponse; +begin + FOnResponseCalled := False; + LReq := TRequest.New; + LRes := LReq.BaseURL(Format('http://localhost:%d/test-lifecycle', [TEST_PORT])) + .Get; + + Assert.AreEqual(200, LRes.StatusCode); + + // Como o onResponse é disparado no final, vamos dar um pequeno sleep para garantir que a thread de socket terminou + Sleep(200); + Assert.IsTrue(FOnResponseCalled, 'OnResponse hook should have been called'); +end; + +initialization + TDUnitX.RegisterTestFixture(TTestIntegrationLifecycleHooks); + +end. From 89f5e8859e0f3f3f5bf9f7204fd4319d7638b0de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gys=20Borges=20da=20Silveira?= <regys.silveira@gmail.com> Date: Fri, 10 Jul 2026 11:51:54 -0300 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20atualizar=20roadmap=20e=20matriz=20?= =?UTF-8?q?de=20prioriza=C3=A7=C3=A3o=20registrando=20a=20conclus=C3=A3o?= =?UTF-8?q?=20dos=20ganchos=20de=20ciclo=20de=20vida?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/roadmap/README.md | 10 ++++------ doc/roadmap/prioritization_matrix.md | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/doc/roadmap/README.md b/doc/roadmap/README.md index a34b9625..7d898ccf 100644 --- a/doc/roadmap/README.md +++ b/doc/roadmap/README.md @@ -43,12 +43,6 @@ Este documento detalha o planejamento de melhorias arquiteturais de longo prazo ### 8. Roteamento Avançado (Regex e Parâmetros Opcionais) * **Descrição:** Permitir parâmetros opcionais (`/users/:id?`) e restrições de rotas baseadas em Expressões Regulares (`/users/:id(\d+)`) na árvore do Radix Router. - -### 9. Ganchos de Ciclo de Vida da Requisição (*Lifecycle Hooks*) -* **Descrição:** Oferecer eventos padronizados ao longo do pipeline (como `onRequest`, `preParsing`, `preValidation`, `onSend`, `onResponse`) inspirados no Fastify. -* **Ganhos:** - * Permite que middlewares implementem caching inteligente, auditoria detalhada de payloads ou criptografia em tempo de tráfego sem acoplamento. - ### 10. Injeção de Dependência Contextual (*Request Scope / Context*) * **Descrição:** Prover um mecanismo estruturado para gerência de dependências cujo ciclo de vida está acoplado ao ciclo da requisição (ex: uma transação de banco de dados ou conexão FireDAC ativa). * **Ganhos:** @@ -77,6 +71,10 @@ Este documento detalha o planejamento de melhorias arquiteturais de longo prazo * **Status:** 🟢 **Concluído e Liberado** * **Implementação:** Desenvolvido o middleware de alta performance e thread-safe para servir arquivos estáticos locais de forma totalmente provider-agnostic, com suporte a HTTP 206 (Range/Partial Content) e controle de cache por ETag fraca e Last-Modified ([horse-static](https://github.com/regyssilveira/horse-static)). +### 6. Ganchos de Ciclo de Vida da Requisição (Lifecycle Hooks) +* **Status:** 🟢 **Concluído e Liberado** +* **Implementação:** Adicionado suporte nativo e thread-safe a ganchos de ciclo de vida (`onRequest`, `preParsing`, `preValidation`, `onSend` e `onResponse`) em cascata cooperativa (CPS) no Core e integrado a ambos os roteadores (`THorseRouterTree` e `THorseRadixRouter`), com testes de integração e exemplos documentados. + --- ## ✅ Entregas Recentes de Testes & CI/CD (Concluído) diff --git a/doc/roadmap/prioritization_matrix.md b/doc/roadmap/prioritization_matrix.md index 52bdd7e0..e36ccba9 100644 --- a/doc/roadmap/prioritization_matrix.md +++ b/doc/roadmap/prioritization_matrix.md @@ -17,7 +17,7 @@ Esta tabela classifica as 13 melhorias pendentes do roadmap técnico do Horse co | 12 | **Middleware de Rate Limiting** | Segurança | 4 | 2 | **2.00** | ➕ **Novo Middleware** (Opcional) | 🟢 **Concluído** (Implementado e Liberado) | | 11 | **Middleware de Compressão (Gzip/Deflate/Brotli)** | Otimização | 4 | 3 | **1.33** | ➕ **Novo Middleware** (Opcional) | 🟢 **Concluído** (Implementado e Liberado) | | 13 | **Static File Server com Range/Cache** | DX / Recursos | 4 | 3 | **1.33** | ➕ **Novo Middleware** (Opcional) | 🟢 **Concluído** (Implementado e Liberado) | -| 9 | **Ganchos de Ciclo de Vida (Hooks)** | Ecossistema | 4 | 3 | **1.33** | ➕ **Novo Recurso** (Opcional) | 📅 **Planejar execução** (Estruturar pipeline) | +| 9 | **Ganchos de Ciclo de Vida (Hooks)** | Ecossistema | 4 | 3 | **1.33** | ➕ **Novo Recurso** (Opcional) | 🟢 **Concluído** (Implementado e Liberado) | | 4 | **Desligamento Suave (Graceful)** | Resiliência | 4 | 3 | **1.33** | ➕ **Novo Recurso** (Opcional) | 📅 **Planejar execução** (Vital para Docker/K8s) | | 10 | **Injeção de Dependência Contextual** | Arquitetura | 4 | 3 | **1.33** | ➕ **Novo Recurso** (Opcional) | 📅 **Planejar execução** (Gerência de banco) | | 1 | **Refatoração Multi-Instance** | Arquitetura | 5 | 4 | **1.25** | ⚙️ **Transparente** (Mantém retrocompatibilidade) | 🎯 **Projeto Estratégico** (Exige refatoração global) |