From 9e9e352d1d3ed8775a960df02e0adffd06f96461 Mon Sep 17 00:00:00 2001 From: Danny Date: Mon, 11 May 2026 15:18:52 -0500 Subject: [PATCH] synchronize ecs library in gitHub --- README.md | 473 +++++++++++------- build.gradle | 16 +- dependencyMgmt.gradle | 32 +- ecs-core/build.gradle | 7 +- .../middleware/MiddlewareEcsBusiness.java | 18 +- .../domain/middleware/MiddlewareEcsExcp.java | 3 + .../ecs/domain/model/ExceptionLevel.java | 27 + .../ecs/domain/model/LogRecord.java | 7 +- .../ecs/helpers/DataSanitizer.java | 36 +- .../ecs/helpers/HandlerHelper.java | 85 ++++ .../ecs/helpers/SamplingHelper.java | 63 ++- .../ecs/helpers/SensitiveHelper.java | 5 +- .../ecs/infra/config/EcsContextAccessor.java | 28 ++ .../ecs/infra/config/EcsPropertiesConfig.java | 34 +- .../infra/config/LoggerEcsInitializer.java | 63 ++- .../ecs/infra/config/MessageIdProperties.java | 15 + .../infra/config/PrintOnErrorProperties.java | 57 +++ .../application/MessageIdMngUseCase.java | 101 ++++ .../application/MessageIdMngValidator.java | 81 +++ .../domain/MessageIdRequestProperties.java | 59 +++ .../infra/config/managementid/infra/.gitkeep | 0 .../com/bancolombia/ecs/infra/shared/.gitkeep | 0 .../infra/shared/common/application/.gitkeep | 0 .../shared/common/domain/ContextECS.java | 25 + .../ecs/infra/shared/common/infra/.gitkeep | 0 ...itional-spring-configuration-metadata.json | 28 ++ ecs-core/src/main/resources/application.yaml | 5 + .../middleware/MiddlewareEcsBusinessTest.java | 73 +++ .../ecs/helpers/DataSanitizerTest.java | 224 ++++++++- .../ecs/helpers/HandlerHelperTest.java | 125 +++++ .../ecs/helpers/SamplingHelperTest.java | 53 ++ .../co/com/bancolombia/ecs/infra/.gitkeep | 0 .../infra/config/EcsPropertiesConfigTest.java | 43 +- .../config/LoggerEcsInitializerTest.java | 147 +++--- .../PrintOnErrorPropertiesBindingTest.java | 49 ++ .../config/PrintOnErrorPropertiesTest.java | 111 ++++ .../ecs/infra/config/SamplingConfigTest.java | 6 +- .../ecs/infra/config/managementid/.gitkeep | 0 .../application/MessageIdMngUseCaseTest.java | 89 ++++ .../MessageIdMngValidatorTest.java | 51 ++ .../src/test/resources/application-test.yaml | 3 + ecs-imperative/build.gradle | 2 +- .../ImperativeLogsConfiguration.java | 15 +- .../filter/ImperativeLogsHandler.java | 138 ++--- .../ImperativeLogsConfigurationTest.java | 37 ++ .../ImperativeLogsHandlerTest.java | 301 ++++++++++- .../management/BusinessExceptionECS.java | 2 +- .../management/BusinessExceptionECSTest.java | 15 +- ecs-reactive/build.gradle | 2 +- .../ReactiveLogsConfiguration.java | 23 +- .../filter/ReactiveLogsHandler.java | 174 ++++--- .../handler/EcsErrorContextHandler.java | 34 ++ .../ReactiveLogsConfigurationTest.java | 37 ++ .../application/ReactiveLogsHandlerTest.java | 462 ++++++++++++++++- .../src/main/resources/application.yaml | 39 +- .../config/UseCasesConfigTest.java | 7 +- examples/ecs_logs_reactive_test/build.gradle | 2 +- .../entry-points/reactive-web/build.gradle | 1 + .../application/GlobalExceptionHandler.java | 7 +- .../domain/response/ErrorApiResponse.java | 3 + .../api/shared/common/infra/CorsConfig.java | 29 ++ .../shared/common/infra/OptionsRouter.java | 18 + gradle.properties | 2 +- main.gradle | 4 + 64 files changed, 3044 insertions(+), 552 deletions(-) create mode 100644 ecs-core/src/main/java/co/com/bancolombia/ecs/domain/model/ExceptionLevel.java create mode 100644 ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/HandlerHelper.java create mode 100644 ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/EcsContextAccessor.java create mode 100644 ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/MessageIdProperties.java create mode 100644 ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/PrintOnErrorProperties.java create mode 100644 ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngUseCase.java create mode 100644 ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngValidator.java create mode 100644 ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/domain/MessageIdRequestProperties.java create mode 100644 ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/infra/.gitkeep create mode 100644 ecs-core/src/main/java/co/com/bancolombia/ecs/infra/shared/.gitkeep create mode 100644 ecs-core/src/main/java/co/com/bancolombia/ecs/infra/shared/common/application/.gitkeep create mode 100644 ecs-core/src/main/java/co/com/bancolombia/ecs/infra/shared/common/domain/ContextECS.java create mode 100644 ecs-core/src/main/java/co/com/bancolombia/ecs/infra/shared/common/infra/.gitkeep create mode 100644 ecs-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 ecs-core/src/test/java/co/com/bancolombia/ecs/domain/middleware/MiddlewareEcsBusinessTest.java create mode 100644 ecs-core/src/test/java/co/com/bancolombia/ecs/helpers/HandlerHelperTest.java create mode 100644 ecs-core/src/test/java/co/com/bancolombia/ecs/infra/.gitkeep create mode 100644 ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/PrintOnErrorPropertiesBindingTest.java create mode 100644 ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/PrintOnErrorPropertiesTest.java create mode 100644 ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/managementid/.gitkeep create mode 100644 ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngUseCaseTest.java create mode 100644 ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngValidatorTest.java create mode 100644 ecs-imperative/src/test/java/co/com/bancolombia/ecs/application/ImperativeLogsConfigurationTest.java create mode 100644 ecs-reactive/src/main/java/co/com/bancolombia/ecs/application/handler/EcsErrorContextHandler.java create mode 100644 ecs-reactive/src/test/java/co/com/bancolombia/ecs/application/ReactiveLogsConfigurationTest.java create mode 100644 examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/infra/CorsConfig.java create mode 100644 examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/infra/OptionsRouter.java diff --git a/README.md b/README.md index e3c5538..419f3f3 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,36 @@ -# Java ECS Library + +# Librería de Logging ECS -A comprehensive Java library that enables structured logging in ECS (Elastic Common Schema) format, providing standardized log output for better observability and monitoring in Java applications. +![Diagram](./docs/componentes.png) -## **General description** +# **Descripción General** -The **ECS Logging Library** is a Java-based library designed to generate structured logs compliant with the `Elastic Common Schema (ECS)` format. It supports both imperative and reactive programming paradigms for Java projects generated from templates (scaffolds). The library simplifies the creation of logs following a predefined schema, enabling consistent and efficient analysis of request, response, and error logs. +La **Librería de Logging ECS** es una biblioteca basada en Java diseñada para generar logs estructurados que cumplen con el formato del `Elastic Common Schema (ECS)`. Soporta los paradigmas de programación imperativa y reactiva, para proyectos Java generados a partir de plantillas (scaffold). La biblioteca facilita la generación de logs con un esquema previamente definido, permitiendo un y análisis de los logs de request/response/error. -## **Modules** -The library is composed of four main modules: +## **Módulos** -**ecs-model:** Defines the data structures and schema required for logs to comply with the ECS standard. This module serves as a dependency for the other modules. -**ecs-core:** Contains the core logic for building logs in ECS format, using *ecs-model* for schema definitions. -**ecs-imperative:** Provides logging functionality for projects following an imperative programming approach, leveraging *ecs-core*. -**ecs-reactive:** Provides logging functionality for projects following a reactive programming approach (e.g., Project Reactor), also leveraging *ecs-core*. +La librería está compuesta por cuatro módulos principales: -## **Prerequisites** +**ecs-model:** Define las estructuras de datos y el esquema necesario para que los logs cumplan con el estándar ECS. Este módulo es una dependencia para los demás módulos.\ +**ecs-core:** Contiene la lógica central para construir logs en formato ECS, utilizando ecs-model para las definiciones de esquema.\ +**ecs-imperative:** Proporciona funcionalidad de logging para proyectos que siguen un enfoque de programación imperativa, utilizando ecs-core.\ +**ecs-reactive:** Proporciona funcionalidad de logging para proyectos que siguen un enfoque de programación reactiva (ej. Project Reactor), también utilizando ecs-core. -**Java:** Version 21 or higher. -**Gradle:** For dependency management. +## **Prerrequisitos** -## Getting Started +**Java:** Versión 21 o superior.\ +**Gradle:** Para la gestión de dependencias.\ +**Azure DevOps:** Para pipelines de CI/CD. -**IMPORTANT:** To implement the library in reactive projects, you must have a global error handler in the project and not control the exception at the handler level. This is because when the error is controlled internally in each handler, the response is no longer propagated to the library as an error, generating unexpected log prints. +## Uso en Proyectos generados con Scaffold -If the global exception handler cannot be implemented, the imperative library must be used in the project. +La Librería de Logging ECS está diseñada para integrarse fácilmente en proyectos Java. -Example of error control at the handler level: +### Proyectos Imperativos/Reactivo -``` java -return validateHeader.handler(request) - .flatMap(metaData -> process(request, metaData)) - .onErrorResume(BusinessException.class, exception -> - handlerResponse.createErrorResponse(exception, request)) - .onErrorResume(handlerResponse::runtimeException); -``` - - -## Importing the Library -To use the ECS Logging Library in a Java project, add the corresponding dependencies to your `build.gradle` (Gradle) file. - -For imperative projects: -```gradle -dependencies { - implementation ':ecs-imperative:' -} -```` -For reactive projects: -```gradle -dependencies { - implementation ':ecs-reactive:' -} -```` -For the model: -```gradle -dependencies { - implementation ':ecs-model:' -} -```` - -### Imperative/Reactive Projects +**Agregar Dependencia:** Incluye `ecs-model` en el `main.gradle` principal como se muestra posteriomente. -**Add Dependency:** Include `ecs-model` in the main `main.gradle` as shown below. - -``` gradle +``` subprojects { apply plugin: 'java' apply plugin: 'jacoco' @@ -71,7 +39,7 @@ subprojects { compileJava.dependsOn validateStructure java { - sourceCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } @@ -84,31 +52,24 @@ subprojects { dependencies { ... - implementation ':ecs-model:' + implementation 'co.com.bancolombia:ecs-model:' } } ``` -Configure the library in **whitelistedDependencies** to avoid errors in the **validate-structure** task for scaffold projects, in the `main.gradle` file. +Configurar en el **whitelistedDependencies** la librería para evitar el error en la tarea de **validate-structure** de scaffold\ [validate-structure](https://bancolombia.github.io/scaffold-clean-architecture/docs/tasks/validate-structure) -```gradle -cleanPlugin { - modelProps { - whitelistedDependencies = "ecs-model" - } -} -``` - -Link for more information about [validate-structure](https://bancolombia.github.io/scaffold-clean-architecture/docs/tasks/validate-structure) - -In the exception class example `BusinessException` or `AppException`, you must extend the `BusinessExceptionECS` class from the library model. -Example: - -```java -import ecs.model.management.BusinessExceptionECS; +En la clase de excepción ejemplo `BusinessException` o `AppException` debe extender de la clase `BusinessExceptionECS` del modelo de la librería\ +ejemplo: +``` +package co.com.bancolombia.model.exception; + +import co.com.bancolombia.ecs.model.management.BusinessExceptionECS; + public class BusinessException extends BusinessExceptionECS { + ... public BusinessException(ConstantBusinessException value) { @@ -119,34 +80,47 @@ public class BusinessException extends BusinessExceptionECS { } ``` +En la clase de las constantes de las excepciones ejemplo `ConstantBusinessException` debe implementar la interfaz `ErrorManagement` del modelo de la librería\ +ejemplo: -In the exception constant class, the `ConstantBusinessException` example must implement the `ErrorManagement` interface of the library model. -Example: - -```java -import ecs.model.management.ErrorManagement; - +``` +package co.com.bancolombia.model.exception; + +import co.com.bancolombia.ecs.model.management.ErrorManagement; + +import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; + public enum ConstantBusinessException implements ErrorManagement { + ... + DEFAULT_EXCEPTION(HTTP_INTERNAL_ERROR, + CodeMessage.INITIAL_CODE_DETAIL, + BusinessCode.BASIC_INITIAL_CODE, + InternalMessage.TECHNICAL_ERROR, + CodeLog.LOG500_00), + ... + } ``` -### Imperative Projects +### Proyectos Imperativos -**Add Dependency:** Include `ecs-imperative` in the `build.gradle` of the application module where the Main application is located, as shown below. +**Agregar Dependencia:** Incluye `ecs-imperative` en el `build.gradle` del modulo de aplicación donde se encuentra el Main application como se muestra posteriomente. -```gradle +``` dependencies { ... - implementation ':ecs-imperative:' + implementation 'co.com.bancolombia:ecs-imperative:' } ``` -Import the configuration of `ImperativeLogsConfiguration` into the main class: +Importar la configuracion de el `ImperativeLogsConfiguration` en la clase de main principal: -```java -import ecs.application.ImperativeLogsConfiguration; +``` +package co.com.bancolombia; + +import co.com.bancolombia.ecs.application.ImperativeLogsConfiguration; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @@ -162,23 +136,24 @@ public class MainApplication { } ``` -### Reactive Projects +### Proyectos Reactivos -**Add Dependency:** Include `ecs-reactive` in the `build.gradle` of the application module where the Main application is located, as shown below. +**Agregar Dependencia:** Incluye `ecs-reactive` en el `build.gradle` del modulo de aplicación donde se encuentra el Main application como se muestra posteriomente. -```gradle +``` dependencies { ... - implementation ':ecs-reactive:' + implementation 'co.com.bancolombia:ecs-reactive:' } ``` -Import the configuration of `ReactiveLogsConfiguration` into the main class: - -```java +Importar la configuracion de el `ReactiveLogsConfiguration` en la clase de main principal: -import ecs.application.ReactiveLogsConfiguration; +``` +package co.com.bancolombia; + +import co.com.bancolombia.ecs.application.ReactiveLogsConfiguration; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @@ -194,124 +169,282 @@ public class MainApplication { } ``` -**Output:** Logs are generated across all REST requests and application errors in JSON ECS format. +**Salida:** Los logs se generan de manera transversal a todas las peticiones rest y errores del aplicativo en formato JSON ECS. -## Log contract structure: +## Estructura del contrato de los logs: -Definition of the structure of the records generated by the ECS Logging Library. +# Variables de entorno ECS -## ECS environment variables +**`Variables por default`** -**`Default variables`** +``` +spring: + +  application: + +    name: "ms_ecs" -```yaml -adapter:   ecs: +     logs: +       request: +         replacement: "" +         patterns: "" +         delimiter: "" +         fields: "" +         allow-headers: "" +         excluded-paths: "/actuator" +         show: false +       response: +         replacement: "" +         delimiter: "" +         fields: "" +         patterns: "" +         show: false - sampling: - rules20XJson: "" - rules40XJson: "" - sensitive-rules: - sensitive-data: "" + +      print-on-error: + +        print-req-resp: + +        print-req-resp-level: + +      message-id: + +        enable_auto_register_message_id: ``` +| Variable de Entorno | Descripción | Valor por Defecto | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------- | +| `spring.application.name` | Nombre del servicio | "ms_ecs" | +| `spring.ecs.logs.request.replacement` | Reemplazos para campos en los logs de solicitudes | "" | +| `spring.ecs.logs.request.patterns` | Patrones a filtrar el json de las solicitudes | "" | +| `spring.ecs.logs.request.delimiter` | Delimitador utilizado para separar campos en las variables de solicitudes | "" | +| `spring.ecs.logs.request.fields` | Campos a aplicar la sanitización en los logs de solicitudes | "" | +| `spring.ecs.logs.request.allow-headers` | Encabezados HTTP permitidos para incluir en los logs de solicitudes | "" | +| `spring.ecs.logs.request.excluded-paths` | Rutas excluidas del registro de solicitudes | "/actuator" | +| `spring.ecs.logs.request.show` | Indica si se deben mostrar los logs de las solicitudes | false | +| `spring.ecs.logs.response.replacement` | Reemplazos para campos en los logs de respuestas | "" | +| `spring.ecs.logs.response.delimiter` | Delimitador utilizado para separar campos de las variables de respuestas | "" | +| `spring.ecs.logs.response.fields` | Campos a aplicar la sanitización en los logs de respuestas | "" | +| `spring.ecs.logs.response.patterns` | Patrones a buscar en los logs de respuestas | "" | +| `spring.ecs.logs.response.show` | Indica si se deben mostrar los logs de las respuestas | false | +| `spring.ecs.logs.print-on-error.print-req-resp` | Activa la impresión condicional de request/response solo en errores | null | +| `spring.ecs.logs.print-on-error.print-req-resp-level` | Nivel único de excepción que dispara la impresión (`BusinessExceptionECS`, `Exception`, `Throwable`) | null | +| `adapter.ecs.logs.message-id.enable_auto_register_message_id` | Controla la autogeneración del `message-id` UUID por request. Solo acepta `"true"` o `"false"` | *(no declarada)* | + +## Gestión automática de `message-id` + +Esta funcionalidad permite que la librería genere y propague automáticamente un identificador único (`message-id` UUID) por cada request HTTP entrante, con comportamiento configurable vía YAML sin necesidad de cambios en el código del microservicio consumidor. + +### Variable de configuración +```yaml +adapter: + ecs: + logs: + message-id: + enable_auto_register_message_id: "true" # "true" | "false" | (omitir) +``` + +| Variable | Tipo | Descripción | Valor por Defecto | +| ----------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------- | ----------------- | +| `spring.ecs.logs.message-id.enable_auto_register_message_id` | String | Controla la autogeneración del `message-id`. Solo acepta `"true"` o `"false"` (valores distintos fallan al arrancar) | *(no declarada)* | + +> **Por qué el tipo es `String` y no `Boolean`?** Spring Boot convierte silenciosamente `"yes"`, `"on"`, `"1"` → `true` con tipo `Boolean`. Con tipo `String` + validador explícito, cualquier valor no permitido lanza un error al iniciar la aplicación. + +### Comportamiento según configuración + +| Valor YAML | Header enviado por el cliente | Header ausente en request | Excepción sin `message-id` previo | +| -------------- | ----------------------------- | ------------------------------ | ------------------------------- | +| *(no declarada)* | Se propaga tal como viene | Se genera UUID automáticamente | Se genera UUID siempre | +| `"false"` | Se propaga tal como viene | No se genera UUID (`null`) | Se genera UUID siempre | +| `"true"` | Se propaga tal como viene | Se genera UUID automáticamente | Se genera UUID siempre | + +> El header del cliente **siempre se propaga**, independientemente del valor configurado. La diferencia entre modos solo aplica cuando el cliente **no** envía el header. + +> Las **excepciones siempre tienen trazabilidad**: `resolveForException` genera UUID incondicionalmente cuando no hay un `message-id` previo, sin importar el valor de `enable_auto_register_message_id`. + +### Normalización del header + +El header se reconoce en cualquiera de sus variantes: `message-id`, `Message-Id`, `messageId`, `message_id`. Internamente se normaliza a la clave canónica `message-id`. + +--- + +## Impresión condicional de request/response (`print-on-error`) + +Esta funcionalidad permite que el cuerpo de request y response **solo se imprima cuando ocurre un error** que coincida con los tipos configurados. Es útil para reducir el volumen de logs en peticiones exitosas sin perder visibilidad en los errores. + +### Variables de configuración + +```yaml +adapter: + ecs: + logs: + print-on-error: + print-req-resp: true + print-req-resp-level: "Throwable" +``` + +| Variable | Tipo | Descripción | Valor por Defecto | +| ---------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------- | ------------------ | +| `adapter.ecs.logs.print-on-error.print-req-resp` | Boolean | Activa la impresión condicional de request/response solo en errores | `null` (desactivado) | +| `adapter.ecs.logs.print-on-error.print-req-resp-level` | String | Nivel único de excepción que dispara la impresión. Valores permitidos: `BusinessExceptionECS`, `Exception`, `Throwable` | `null` | + +### Comportamiento + +| `print-on-error.print-req-resp` | `print-on-error.print-req-resp-level` | Resultado | +| ----------------------------- | ----------------------------------- | -------------------------------------------------------------------------------- | +| `null` / `false` | — | Comportamiento estándar (usa `request.show`, `response.show` y sampling normalmente) | +| `true` | `"Throwable"` | Imprime request/response para cualquier `Throwable` | +| `true` | `"Exception"` | Imprime request/response para cualquier `Exception` (incluye `BusinessExceptionECS`) | +| `true` | `"BusinessExceptionECS"` | Imprime request/response solo para `BusinessExceptionECS` y subclases | +| `true` | `null` | "" | No imprime nada | + +### Precedencia y efectos + +Cuando `print-on-error.print-req-resp: true`: + +- **Desactiva `request.show` y `response.show`**: La impresión de body pasa a ser controlada exclusivamente por esta funcionalidad. +- **Suspende el sampling**: Las reglas de muestreo (`rules20XJson`, `rules40XJson`) no aplican. Los errores que coincidan con el nivel configurado siempre se imprimen. +- **Excluded paths**: Siguen funcionando normalmente (se aplican antes de la lógica de `print-on-error`). +- **Validación temprana**: Si `print-req-resp-level` tiene un valor diferente a `BusinessExceptionECS`, `Exception` o `Throwable`, la aplicación falla en la inicialización de configuración con un mensaje explícito. + +### Coincidencia de errores + +La coincidencia se realiza con un **nivel único jerárquico** definido en `print-on-error.print-req-resp-level`. Ejemplos: + +- `"BusinessExceptionECS"` → coincide con `BusinessExceptionECS` y todas sus subclases (ej. `BusinessException` del MS consumidor) +- `"Exception"` → coincide con todas las excepciones +- `"Throwable"` → coincide con todas las implementaciones de la interfaz Throwable (ej. excepciones, errores) +- `null` o `""` → no coincide con ningún error, no se imprime nada + +### Ejemplo de uso + +Para imprimir request/response **solo cuando ocurra un `BusinessExceptionECS`**: + +```yaml +adapter: + ecs: + logs: + print-on-error: + print-req-resp: true + print-req-resp-level: "BusinessExceptionECS" +``` + +# Contrato del Esquema de Logs ECS + +Definición de la estructura de los logs generados por la Librería de Logging ECS. A continuación, se describe la estructura de los logs para los niveles `INFO` y `ERROR`, especificando los campos, sus tipos de datos y su obligatoriedad. -| Environment Variable | Description | Default Value | -| :--- | :--- | :--- | -| `adapter.ecs.logs.request.replacement` | Replacements for fields in request logs | “” | -| `adapter.ecs.logs.request.patterns` | Patterns to filter the JSON of requests | “” | -| `adapter.ecs.logs.request.delimiter` | Delimiter used to separate fields in request variables | “” | -| `adapter.ecs.logs.request.fields` | Fields to sanitize in request logs | “” | -| `adapter.ecs.logs.request.allow-headers` | HTTP headers allowed to include in request logs | “” | -| `adapter.ecs.logs.request.excluded-paths` | Paths excluded from request logging | “/actuator” | -| `adapter.ecs.logs.request.show` | Indicates whether request logs should be displayed | false | -| `adapter.ecs.logs.response.replacement` | Replacements for fields in response logs | “” | -| `adapter.ecs.logs.response.delimiter` | Delimiter used to separate fields from response variables | “” | -| `adapter.ecs.logs.response.fields` | Fields to apply sanitization to in response logs | “” | -| `adapter.ecs.logs.response.patterns` | Patterns to search for in response logs | “” | -| `adapter.ecs.logs.response.show` | Indicates whether response logs should be displayed | false | -| `adapter.ecs.logs.sampling.rules20XJson` | List of sampling rules for HTTP 20X response codes in Json format | “”| -| `adapter.ecs.logs.sampling.rules40XJson` | List of sampling rules for HTTP 40X response codes in JSON format | “”| -| `adapter.ecs.logs.sensitive-rules.sensitive-data` | List of rules for masking sensitive information by path | “” | --- -# ECS Logging Scheme Contract +## Estructura General del Log -Definition of the structure of logs generated by the ECS Logging Library. The structure of logs for the `INFO` and `ERROR` levels is described below, specifying the fields, their data types, and whether they are mandatory. +| Campo | Tipo de Dato | Descripción | Obligatorio | +| -------------- | ------------- | ---------------------------------------------------------------- | ----------- | +| messageId | String (UUID) | Identificador único del mensaje en formato UUID. | Sí | +| date | String | Fecha y hora del evento en formato `DD/MM/YYYY HH:MM:SS:SSSS`. | Sí | +| service | String | Nombre del servicio que genera el log (ej. `ms_actor`). | Sí | +| consumer | String | Identificador del consumidor o cliente que realiza la solicitud. | Sí | +| additionalInfo | Object | Información adicional sobre la solicitud (ver detalle abajo). | Sí | +| level | String | Nivel del log (`INFO` o `ERROR`). | Sí | +| error | Object/Null | Detalles del error (presente en logs `ERROR`, `null` en `INFO`). | No | --- -## General Log Structure +## Detalle de `additionalInfo` -| Field | Data Type | Description | Required | -|-----------------|---------------|------------------------------------------------------------------|----------| -| `messageId` | String (UUID) | Unique message identifier in UUID format. | Yes | -| `date` | String | Date and time of the event in `DD/MM/YYYY HH:MM:SS:SSSS` format. | Yes | -| `service` | String | Name of the service that generates the log (e.g., `ms_person`). | Yes | -| `consumer` | String | Identifier of the consumer or client making the request. | Yes | -| `additionalInfo` | Object | Additional information about the request (see details below). | Yes | -| `level` | String | Log level (`INFO` or `ERROR`). | Yes | -| `error` | Object/Null | Error details (present in `ERROR` logs, `null` in `INFO`). | No | +### Campos principales + +| Campo | Tipo de Dato | Descripción | Obligatorio | Notas | +| -------------- | ------------ | ----------------------------------------------- | ----------- | --------------------------- | +| method | String | Método HTTP utilizado (ej. `POST`, `GET`). | Sí | | +| uri | String | URI de la solicitud (ej. `/actors/retrieveUser`). | Sí | | +| headers | Object | Cabeceras HTTP de la solicitud. | Sí | Ver detalle de subcampos. | +| requestBody | Object | Cuerpo de la solicitud. | Sí | Varía según el tipo de log. | +| responseBody | Object/Null | Cuerpo de la respuesta. | No | Presente en logs `INFO`. | +| responseResult | String | Resultado de la respuesta (ej. `OK`). | Sí | | +| responseCode | String | Código de estado HTTP (ej. `200`, `400`). | Sí | | --- +### Subcampos de `headers` + +| Campo | Tipo de Dato | Descripción | Obligatorio | Notas | +| ----------- | ------------- | -------------------------------- | ----------- | ------------------------------------- | +| code | String | Código del consumidor (ej. `CAP`). | No | Coincide con `consumer` en la raíz. | +| message-id | String (UUID) | Identificador del mensaje. | Sí | Coincide con `messageId` en la raíz. | +| aid-creator | String | Identificador del creador. | No | Ej. `A8A77339260DA412B8238F21BBC1CF398` | + +--- -## Sequence Diagram +### Subcampos de `requestBody` -### Reactive Java +#### Para logs en el que nos e puede convertir el body en json + +| Campo | Tipo de Dato | Descripción | Obligatorio | +| ----- | ------------ | ---------------------------------------- | ----------- | +| raw | String | Cuerpo de la solicitud como texto plano. | No | + +| Campo | Tipo de Dato | Descripción | Obligatorio | +| --------------------- | ------------- | -------------------------------- | ----------- | +| meta | Object | Metadatos de la respuesta. | No | +| meta.\_messageId | String (UUID) | Identificador único del mensaje. | No | +| meta.\_requestDateTime | String | Fecha y hora de la solicitud. | No | +| data | Object | Datos de la respuesta. | No | + +--- + +### Subcampos de `responseBody` (Solo logs `INFO`) + +#### Para logs en el que nos e puede convertir el body en json + +| Campo | Tipo de Dato | Descripción | Obligatorio | +| ----- | ------------ | ---------------------------------------- | ----------- | +| raw | String | Cuerpo de la solicitud como texto plano. | No | + +| Campo | Tipo de Dato | Descripción | Obligatorio | +| --------------------- | ------------- | -------------------------------- | ----------- | +| meta | Object | Metadatos de la respuesta. | No | +| meta.\_messageId | String (UUID) | Identificador único del mensaje. | No | +| meta.\_requestDateTime | String | Fecha y hora de la solicitud. | No | +| data | Object | Datos de la respuesta. | No | + +--- + +## Detalle de `error` (Solo logs `ERROR`) + +| Campo | Tipo de Dato | Descripción | Obligatorio | +| --------------------- | ------------ | ------------------------------------------ | ----------- | +| type | String | Código del error (ej. `SAER400-01-11`). | Sí | +| message | String | Mensaje descriptivo del error. | Sí | +| description | String | Descripción detallada del error. | Sí | +| optionalInfo | Object | Información adicional del error. | No | +| optionalInfo.OPTIONAL | String | Detalle adicional. Ej. falta de cabeceras. | No | + +--- + +## Diagrama de Secuencia + +### Java Reactivo ![Diagram](./docs/secuencia_reactivo.png) --- -### Imperative Java - -![Diagram](./docs/secuencia_imperativo.png) - -## Contributing - -We welcome contributions! Please follow these steps: - -1. **Fork** the repository -2. **Create a feature branch** - ```bash - git checkout -b feature/amazing-feature - ``` -3. Make your changes -4. Add tests for new functionality -5. Ensure all tests pass - ``` bash - ./gradlew test - ``` -6. Commit your changes - ```bash - git commit -m 'feat(user_module): Add amazing feature' - ``` -7. Push to the branch - ```bash - git push origin feature/amazing-feature - ``` -8. Open a **Pull Request** - -### Development Guidelines - -- Follow Java naming conventions -- Write comprehensive tests for new features -- Update documentation for API changes -- Ensure code passes all quality checks -- Add typespecs for public functions +### Java Imperativo + +![Diagram](./docs/secuencia_imperativo.png) \ No newline at end of file diff --git a/build.gradle b/build.gradle index 180f946..ab2ad60 100644 --- a/build.gradle +++ b/build.gradle @@ -8,14 +8,18 @@ buildscript { lombokVersion = '1.18.38' junitVersion = '5.12.2' reactorVersion = '3.7.6' - jacksonDatabindVersion = '2.19.0' - log4jVersion = '2.24.3' + fasterxmlJacksonCoreVersion = '2.21.1' + toolsJacksonCoreVersion = '3.1.0' + log4jVersion = '2.25.4' commonslang3= '3.18.0' - nettyVersion = '4.1.127.Final' - logbackVersion = '1.5.19' - tomcatEmbedCoreVersion = "10.1.47" + nettyVersion = '4.1.132.Final' + netty2Version = '4.2.11.Final' + logbackVersion = '1.5.25' + tomcatEmbedCoreVersion = "10.1.54" + assertjCoreVersion = "3.27.7" + springWebfluxVersion = "7.0.6" + springWebmvcVersion = "7.0.6" } - } plugins { diff --git a/dependencyMgmt.gradle b/dependencyMgmt.gradle index 5faf2c2..9796d44 100644 --- a/dependencyMgmt.gradle +++ b/dependencyMgmt.gradle @@ -1,5 +1,4 @@ configurations.configureEach { - /** * ------------------------------- * Forzado de versiones @@ -15,12 +14,43 @@ configurations.configureEach { } if (details.requested.group == 'io.netty' && details.requested.name == 'netty-codec-http') { details.useVersion("${nettyVersion}") + details.because("CVE-2025-67735, CVE-2026-33870") + } + if (details.requested.group == 'io.netty' && details.requested.name == 'netty-codec-http2') { + details.useVersion("${netty2Version}") + details.because("CVE-2026-33871") } if (details.requested.group == 'ch.qos.logback') { details.useVersion("${logbackVersion}") + details.because("CVE-2026-1225 fix") } if (details.requested.group == 'org.apache.tomcat.embed' && details.requested.name == 'tomcat-embed-core') { details.useVersion("${tomcatEmbedCoreVersion}") + details.because("CVE-2025-66614, CVE-2026-24733, CVE-2026-24734") + } + if (details.requested.group == 'org.apache.logging.log4j' && details.requested.name == 'log4j-core') { + details.useVersion("${log4jVersion}") + details.because("CVE-2025-68161 fix") + } + if (details.requested.group == 'com.fasterxml.jackson.core' && details.requested.name == 'jackson-core') { + details.useVersion("${fasterxmlJacksonCoreVersion}") + details.because("GHSA-72hv-8253-57qq") + } + if (details.requested.group == 'tools.jackson.core' && details.requested.name == 'jackson-core') { + details.useVersion("${toolsJacksonCoreVersion}") + details.because("CVE-2026-29062, GHSA-72hv-8253-57qq") + } + if (details.requested.group == 'org.assertj' && details.requested.name == 'assertj-core') { + details.useVersion("${assertjCoreVersion}") + details.because("CVE-2026-24400") + } + if (details.requested.group == 'org.springframework' && details.requested.name == 'spring-webflux') { + details.useVersion("${springWebfluxVersion}") + details.because("CVE-2026-22737, CVE-2026-22735") + } + if (details.requested.group == 'org.springframework' && details.requested.name == 'spring-webmvc') { + details.useVersion("${springWebmvcVersion}") + details.because("CVE-2026-22737, CVE-2026-22735") } } } \ No newline at end of file diff --git a/ecs-core/build.gradle b/ecs-core/build.gradle index 82477f3..363be46 100644 --- a/ecs-core/build.gradle +++ b/ecs-core/build.gradle @@ -1,11 +1,16 @@ dependencies { // Core dependencies api project(':ecs-model') - implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonDatabindVersion}" + implementation "com.fasterxml.jackson.core:jackson-databind:${fasterxmlJacksonCoreVersion}" implementation "org.apache.logging.log4j:log4j-core:${log4jVersion}" // Spring configuration implementation("org.springframework.boot:spring-boot") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation "org.springframework:spring-web" + implementation "io.projectreactor:reactor-core" + implementation "io.micrometer:context-propagation" + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor:${springBootVersion}") } ext { diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/middleware/MiddlewareEcsBusiness.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/middleware/MiddlewareEcsBusiness.java index 586617d..9e6e273 100644 --- a/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/middleware/MiddlewareEcsBusiness.java +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/middleware/MiddlewareEcsBusiness.java @@ -1,8 +1,8 @@ package co.com.bancolombia.ecs.domain.middleware; - import co.com.bancolombia.ecs.application.LoggerEcs; import co.com.bancolombia.ecs.domain.model.AbstractMiddlewareEcsLog; +import co.com.bancolombia.ecs.infra.config.managementid.application.MessageIdMngUseCase; import co.com.bancolombia.ecs.domain.model.LogRecord; import co.com.bancolombia.ecs.model.management.BusinessExceptionECS; @@ -10,18 +10,16 @@ public class MiddlewareEcsBusiness extends AbstractMiddlewareEcsLog { private AbstractMiddlewareEcsLog next; @Override - public void process(Object request, - String service) { + public void process(Object request, String service) { if (request instanceof BusinessExceptionECS exp) { - LogRecord.ErrorLog optionalMap = new LogRecord.ErrorLog<>(); - optionalMap.setOptionalInfo(exp.getOptionalInfo()); - - var messageId = exp.getMetaInfo().getMessageId(); + String messageId = MessageIdMngUseCase.resolveForException( + exp.getMetaInfo().getMessageId() + ); var logError = new LogRecord.ErrorLog(); - logError.setOptionalInfo(optionalMap.getOptionalInfo()); + logError.setOptionalInfo(exp.getOptionalInfo()); logError.setDescription(exp.getConstantBusinessException().getInternalMessage()); logError.setMessage(exp.getConstantBusinessException().getMessage()); logError.setType(exp.getConstantBusinessException().getLogCode()); @@ -30,7 +28,9 @@ public void process(Object request, logExp.setError(logError); logExp.setLevel(LogRecord.Level.ERROR); logExp.setService(service); - logExp.setMessageId(messageId); + if (messageId != null) { + logExp.setMessageId(messageId); + } LoggerEcs.print(logExp); diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/middleware/MiddlewareEcsExcp.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/middleware/MiddlewareEcsExcp.java index 989b07c..af480ee 100644 --- a/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/middleware/MiddlewareEcsExcp.java +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/middleware/MiddlewareEcsExcp.java @@ -3,6 +3,7 @@ import co.com.bancolombia.ecs.application.LoggerEcs; import co.com.bancolombia.ecs.domain.model.AbstractMiddlewareEcsLog; +import co.com.bancolombia.ecs.infra.config.managementid.application.MessageIdMngUseCase; import co.com.bancolombia.ecs.domain.model.LogRecord; public class MiddlewareEcsExcp extends AbstractMiddlewareEcsLog { @@ -23,6 +24,8 @@ public void process(Object request, logExp.setLevel(LogRecord.Level.ERROR); logExp.setService(service); + MessageIdMngUseCase.getFromContext().ifPresent(logExp::setMessageId); + LoggerEcs.print(logExp); } else if (next != null) { next.handler(request, service); diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/model/ExceptionLevel.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/model/ExceptionLevel.java new file mode 100644 index 0000000..e43a913 --- /dev/null +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/model/ExceptionLevel.java @@ -0,0 +1,27 @@ +package co.com.bancolombia.ecs.domain.model; + +import lombok.Getter; + +@Getter +public enum ExceptionLevel { + BUSINESS_EXCEPTION_ECS("BusinessExceptionECS"), + EXCEPTION("Exception"), + THROWABLE("Throwable"); + + private final String levelString; + + ExceptionLevel(String levelString) { + this.levelString = levelString; + } + + public static ExceptionLevel toExceptionLevelIgnoreCase(String value) { + if (value != null) { + for (ExceptionLevel exceptionLevel : ExceptionLevel.values()) { + if (exceptionLevel.levelString.equalsIgnoreCase(value.trim())) { + return exceptionLevel; + } + } + } + return null; + } +} diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/model/LogRecord.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/model/LogRecord.java index b452370..3ed60d4 100644 --- a/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/model/LogRecord.java +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/domain/model/LogRecord.java @@ -1,6 +1,7 @@ package co.com.bancolombia.ecs.domain.model; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AllArgsConstructor; @@ -14,6 +15,8 @@ import java.util.Map; import java.util.UUID; +import static aQute.bnd.annotation.headers.Category.json; + @Data @AllArgsConstructor @@ -23,8 +26,9 @@ public class LogRecord { public static final String DATE_FORMAT = "dd/MM/yyyy HH:mm:ss:SSSS"; - + public static final String MESSAGE_ID = "message-id"; @Builder.Default + @JsonProperty(MESSAGE_ID) private String messageId = UUID.randomUUID().toString(); @Builder.Default private String date = currentDate(); @@ -38,7 +42,6 @@ private static String currentDate() { var date = LocalDateTime.now(ZoneOffset.of("-05:00")); return date.format(DateTimeFormatter.ofPattern(DATE_FORMAT)); } - public String toJson() { var objectMapper = new ObjectMapper(); diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/DataSanitizer.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/DataSanitizer.java index 26d99c6..6b5c64b 100644 --- a/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/DataSanitizer.java +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/DataSanitizer.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.experimental.UtilityClass; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -34,23 +35,34 @@ public static String sanitize(String body, Set sensitiveFields, List sanitizeHeaders( Set>> requestHeaders, Set allowedHeaders) { - Set allowedLower = allowedHeaders.stream() - .map(String::toLowerCase) - .collect(Collectors.toSet()); - - return requestHeaders.stream() - .filter(entry -> { - String lowerKey = entry.getKey().toLowerCase(); - List values = entry.getValue(); - return allowedLower.contains(lowerKey) && !values.isEmpty(); - }) + Map allowedHeadersNormalizedToCanonical = allowedHeaders.stream() .collect(Collectors.toMap( - Map.Entry::getKey, - entry -> entry.getValue().get(0) + DataSanitizer::normalizeKey, + String::toLowerCase, + (first, second) -> first )); + + Map result = new HashMap<>(); + for (Map.Entry> entry : requestHeaders) { + List values = entry.getValue(); + if (values.isEmpty()) { + continue; + } + String normalized = normalizeKey(entry.getKey()); + String canonical = allowedHeadersNormalizedToCanonical.get(normalized); + if (canonical != null) { + result.putIfAbsent(canonical, values.getFirst()); + } + } + return result; + } + + private static String normalizeKey(String key) { + return key.replace("-", "").replace("_", "").toLowerCase(); } private static void sanitizeJsonMap(Map map, Set sensitiveFields, String replacement) { diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/HandlerHelper.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/HandlerHelper.java new file mode 100644 index 0000000..a12ad83 --- /dev/null +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/HandlerHelper.java @@ -0,0 +1,85 @@ +package co.com.bancolombia.ecs.helpers; + +import co.com.bancolombia.ecs.domain.model.ExceptionLevel; +import co.com.bancolombia.ecs.infra.config.PrintOnErrorProperties; +import co.com.bancolombia.ecs.model.management.BusinessExceptionECS; +import co.com.bancolombia.ecs.model.request.LogRequest; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.experimental.UtilityClass; +import org.springframework.http.HttpStatus; +import org.springframework.web.ErrorResponse; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +@UtilityClass +public class HandlerHelper { + public static final String ROOT_PATH = "/"; + public static final String RAW_BODY = "raw"; + public static final int MIN_ERROR_STATUS_CODE = 400; + public static final Set CONSUMER_ACRONYMS = Set.of("consumer-acronym", "code", "channel"); + public static final ObjectMapper objectMapper = new ObjectMapper(); + + public static void setConsumer(LogRequest logRequest, Map headers) { + String consumer = CONSUMER_ACRONYMS.stream() + .map(headers::get) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + logRequest.setConsumer(consumer); + } + + public static HttpStatus resolveHttpStatus(Throwable ex) { + if (ex instanceof BusinessExceptionECS businessExceptionECS) { + HttpStatus resolvedStatus = resolveStatusCode( + businessExceptionECS.getConstantBusinessException() != null + ? businessExceptionECS.getConstantBusinessException().getStatus() + : null + ); + if (resolvedStatus != null) { + return resolvedStatus; + } + } + if (ex instanceof ErrorResponse errorResponse) { + HttpStatus resolvedStatus = resolveStatusCode(errorResponse.getStatusCode().value()); + if (resolvedStatus != null) { + return resolvedStatus; + } + } + return HttpStatus.INTERNAL_SERVER_ERROR; + } + + public static HttpStatus resolveStatusCode(Integer statusCode) { + return statusCode != null + ? HttpStatus.resolve(statusCode) + : null; + } + + public static Map parseToMap(String body) { + try { + return objectMapper.readValue(body, Map.class); + } catch (JsonProcessingException e) { + return Map.of(RAW_BODY, body); + } + } + + public static boolean isErrorStatusCode(int statusCode) { + return statusCode >= MIN_ERROR_STATUS_CODE; + } + + public boolean isPathExcluded(String path, Set excludedPaths) { + return ROOT_PATH.equals(path) + || (excludedPaths != null && excludedPaths.stream().anyMatch(path::startsWith)); + } + + public boolean errorDoesntMatchLevel(Throwable error, ExceptionLevel exceptionLevel) { + return !PrintOnErrorProperties.matchesConfiguredLevel(error, exceptionLevel); + } + + public static ResponseStatusException buildStatusException(HttpStatus status) { + return new ResponseStatusException(status, status.getReasonPhrase()); + } +} diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/SamplingHelper.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/SamplingHelper.java index 27ea8c9..275a63a 100644 --- a/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/SamplingHelper.java +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/SamplingHelper.java @@ -18,6 +18,8 @@ public class SamplingHelper { private static final Map counters = new ConcurrentHashMap<>(); private static final String INIT_MESSAGE = "{} sampling rules have been loaded successfully"; private static final String START_CODE_40X = "40"; + private static final int MIN_ERROR_CODE_PARTS = 2; + private static boolean samplingEnabled = true; public static void init(Map rulesMap) { if (rulesMap != null) { @@ -26,9 +28,14 @@ public static void init(Map rulesMap) { } } + public static void setSamplingEnabled(boolean enabled) { + samplingEnabled = enabled; + } + public static void reset() { rules = Collections.emptyMap(); counters.clear(); + samplingEnabled = true; } private static void incrementCounter(String key) { @@ -40,41 +47,47 @@ private static long getCount(String key) { } public static boolean validatePrint(LogRecord ex) { - boolean result = false; - if (ex.getAdditionalInfo() == null) return true; - + if (!samplingEnabled || ex.getAdditionalInfo() == null) { + return true; + } String uri = ex.getAdditionalInfo().getUri(); String responseCode = ex.getAdditionalInfo().getResponseCode(); - if (uri != null && responseCode != null) { - String key = responseCode.startsWith(START_CODE_40X) - ? uri + "|" + getErrorCode(ex.getError().getType()) - : uri + "|" + responseCode; - - if (!rules.containsKey(key)) { - return true; - } - - SamplingConfig.SamplingRule rule = rules.get(key); - int cycle = rule.getShowCount() + rule.getSkipCount(); + if (uri == null || responseCode == null) { + return false; + } + return evaluateSamplingRule(ex, uri, responseCode); + } - incrementCounter(key); - long current = getCount(key); - long position = (current - 1) % cycle; + private static boolean evaluateSamplingRule(LogRecord ex, String uri, String responseCode) { + String key = buildKey(ex, uri, responseCode); + if (!rules.containsKey(key)) { + return true; + } + var rule = rules.get(key); + int cycle = rule.getShowCount() + rule.getSkipCount(); + incrementCounter(key); + long current = getCount(key); + long position = (current - 1) % cycle; + if (current >= cycle) { + counters.put(key, new LongAdder()); + } + return position < rule.getShowCount(); + } - if (current >= cycle) { - counters.put(key, new LongAdder()); + private static String buildKey(LogRecord ex, String uri, String responseCode) { + if (responseCode.startsWith(START_CODE_40X)) { + String errorType = ex.getError() != null ? ex.getError().getType() : null; + if (errorType != null) { + return uri + "|" + getErrorCode(errorType); } - - result = position < rule.getShowCount(); } - - return result; + return uri + "|" + responseCode; } private String getErrorCode(String errorCode){ String[] parts = errorCode.split("-"); - String result = ""; - if (parts.length >= 2) { + var result = ""; + if (parts.length >= MIN_ERROR_CODE_PARTS) { result = parts[0] + "-" + parts[1]; } return result; diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/SensitiveHelper.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/SensitiveHelper.java index 3234d76..79eae46 100644 --- a/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/SensitiveHelper.java +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/helpers/SensitiveHelper.java @@ -59,12 +59,11 @@ public static void init(Map * @return The JSON content with sensitive data masked or removed according to the rules. */ public static String filterSensitiveData(String jsonContent, String uri) { - if (rules.isEmpty() || jsonContent == null || jsonContent.isEmpty()) { + List applicableRules = findApplicableRules(uri); + if (rules.isEmpty() || jsonContent == null || jsonContent.isEmpty() || applicableRules.isEmpty()) { log.debug(NO_SENSITIVE_CONFIGURED); return jsonContent; } - List applicableRules = findApplicableRules(uri); - if (applicableRules.isEmpty()) return jsonContent; try { JsonNode root = MAPPER.readTree(jsonContent); diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/EcsContextAccessor.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/EcsContextAccessor.java new file mode 100644 index 0000000..7aead6a --- /dev/null +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/EcsContextAccessor.java @@ -0,0 +1,28 @@ +package co.com.bancolombia.ecs.infra.config; + +import co.com.bancolombia.ecs.infra.shared.common.domain.ContextECS; +import io.micrometer.context.ThreadLocalAccessor; + + +public class EcsContextAccessor implements ThreadLocalAccessor { + + @Override + public Object key() { + return ContextECS.KEY_MESSAGE_ID; + } + + @Override + public String getValue() { + return ContextECS.getMessageId(); + } + + @Override + public void setValue(String value) { + ContextECS.setMessageId(value); + } + + @Override + public void setValue() { + ContextECS.clear(); + } +} diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/EcsPropertiesConfig.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/EcsPropertiesConfig.java index 936cd0a..9acf44f 100644 --- a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/EcsPropertiesConfig.java +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/EcsPropertiesConfig.java @@ -1,9 +1,11 @@ package co.com.bancolombia.ecs.infra.config; +import co.com.bancolombia.ecs.domain.model.ExceptionLevel; import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveRequestProperties; import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveResponseProperties; import co.com.bancolombia.ecs.infra.config.service.ServiceProperties; import lombok.Getter; +import lombok.extern.log4j.Log4j2; import java.util.Collections; import java.util.List; @@ -14,6 +16,7 @@ import java.util.stream.Stream; @Getter +@Log4j2 public class EcsPropertiesConfig { // Service private final String serviceName; @@ -31,16 +34,36 @@ public class EcsPropertiesConfig { private List sensitiveResponsePatterns; private String sensitiveResponseReplacement; private String delimiterResponse; + // Print request/response only for configured errors + private final Boolean printReqRespOnErrorOnly; + private final ExceptionLevel printReqRespLevels; - public EcsPropertiesConfig(ServiceProperties serviceProperties, SensitiveRequestProperties requestProps, - SensitiveResponseProperties responseProps) { + public EcsPropertiesConfig(ServiceProperties serviceProperties, + SensitiveRequestProperties requestProps, + SensitiveResponseProperties responseProps, + PrintOnErrorProperties printOnErrorProperties) { // Service this.serviceName = serviceProperties.getName(); + // Print request/response on error + this.printReqRespOnErrorOnly = printOnErrorProperties != null + ? printOnErrorProperties.getPrintReqResp() + : null; + this.printReqRespLevels = + printOnErrorProperties != null && printOnErrorProperties.getPrintReqRespLevel() != null + ? printOnErrorProperties.getPrintReqRespLevel() + : null; + + boolean printReqRespActive = Boolean.TRUE.equals(this.printReqRespOnErrorOnly); + + // When print-on-error.print-req-resp is active, override show flags to false + this.showRequestLogs = printReqRespActive ? Boolean.FALSE : requestProps.getShow(); + this.showResponseLogs = printReqRespActive ? Boolean.FALSE : responseProps.getShow(); + log.info("print-on-error.print-req-resp is enabled: request.show and response.show are overridden"); + // Request - this.showRequestLogs = requestProps.getShow(); - if (Boolean.TRUE.equals(showRequestLogs)) { + if (Boolean.TRUE.equals(requestProps.getShow()) || printReqRespActive) { this.delimiterRequest = requestProps.getDelimiter(); this.allowRequestHeaders = splitToSet(requestProps.getAllowHeaders(), delimiterRequest); this.sensitiveRequestFields = splitToSet(requestProps.getFields(), delimiterRequest); @@ -51,8 +74,7 @@ public EcsPropertiesConfig(ServiceProperties serviceProperties, SensitiveRequest } // Response - this.showResponseLogs = responseProps.getShow(); - if (Boolean.TRUE.equals(showResponseLogs)) { + if (Boolean.TRUE.equals(responseProps.getShow()) || printReqRespActive) { setRequestDefault(requestProps); this.delimiterResponse = responseProps.getDelimiter(); this.sensitiveResponseFields = splitToSet(responseProps.getFields(), delimiterResponse); diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/LoggerEcsInitializer.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/LoggerEcsInitializer.java index ac8c080..c5c70dd 100644 --- a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/LoggerEcsInitializer.java +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/LoggerEcsInitializer.java @@ -1,15 +1,23 @@ package co.com.bancolombia.ecs.infra.config; +import co.com.bancolombia.ecs.infra.config.managementid.application.MessageIdMngUseCase; +import co.com.bancolombia.ecs.infra.config.managementid.domain.MessageIdRequestProperties; import co.com.bancolombia.ecs.helpers.SamplingHelper; +import io.micrometer.context.ContextRegistry; import lombok.extern.log4j.Log4j2; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import reactor.core.publisher.Hooks; import java.util.Arrays; import java.util.Map; +import java.util.regex.Pattern; import java.util.stream.Collectors; @Configuration @Log4j2 +@EnableConfigurationProperties(MessageIdRequestProperties.class) public class LoggerEcsInitializer { private static final String START_CODE_40X = "40"; @@ -17,20 +25,48 @@ public class LoggerEcsInitializer { private static final String VERSION_MESSAGE = "This application is built with Spring Boot {} and requires Java {}"; private static final String SB_VERSION = "4.0.2"; private static final String JAVA_VERSION = "21"; + public static final Pattern ERROR_CODE_REGEX = Pattern.compile("^[\\w\\-]+$"); - public LoggerEcsInitializer(SamplingConfig samplingConfig) { + public LoggerEcsInitializer(SamplingConfig samplingConfig, + PrintOnErrorProperties printOnErrorProperties) { log.info(VERSION_MESSAGE, SB_VERSION, JAVA_VERSION); + + if (Boolean.TRUE.equals(printOnErrorProperties.getPrintReqResp())) { + SamplingHelper.setSamplingEnabled(false); + log.info("print-on-error.print-req-resp is enabled: sampling has been suspended."); + } + + registerContextEcsAccessor(); + Map rulesMap = buildRulesMap(samplingConfig); SamplingHelper.init(rulesMap); } + /** + * Exposes {@link MessageIdMngUseCase} as a Spring bean so it can be injected + * into {@code ReactiveLogsHandler} and any other Spring-managed component. + * The use case constructor also publishes the flag to {@link co.com.bancolombia.ecs.infra.shared.common.domain.ContextECS} + * for use by non-Spring domain objects (middleware chain of responsibility). + */ + @Bean + public MessageIdMngUseCase messageIdMngUseCase(MessageIdRequestProperties messageIdRequestProperties) { + return new MessageIdMngUseCase(messageIdRequestProperties); + } + + private void registerContextEcsAccessor() { + ContextRegistry.getInstance().registerThreadLocalAccessor(new EcsContextAccessor()); + Hooks.enableAutomaticContextPropagation(); + log.info("ContextECS ThreadLocalAccessor registrado y propagación automática de Reactor Context habilitada: " + + "ContextECS.getMessageId() está disponible en cualquier hilo del pipeline reactivo."); + } + private Map buildRulesMap(SamplingConfig samplingConfig) { if (samplingConfig.getRules() != null && !samplingConfig.getRules().isEmpty()) { validateRules(samplingConfig); return samplingConfig.getRules().stream() .flatMap(rule -> expandRule(rule).entrySet().stream()) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - }else{ + } else { return Map.of(); } } @@ -59,10 +95,7 @@ private void validateRules(SamplingConfig samplingConfig) { private void validateNoErrorCodesFor20X(SamplingConfig.SamplingRule rule) { boolean is2xx = rule.getResponseCode().startsWith(START_CODE_20X); - - if (is2xx && - rule.getErrorCodes() != null && - !rule.getErrorCodes().isBlank()) { + if (is2xx && rule.getErrorCodes() != null && !rule.getErrorCodes().isBlank()) { throw new IllegalArgumentException(String.format( "The rule with URI [%s] and code [%s] must not have errorCodes configured", rule.getUri(), rule.getResponseCode() @@ -71,22 +104,20 @@ private void validateNoErrorCodesFor20X(SamplingConfig.SamplingRule rule) { } private void validateErrorCodesRequiredFor40X(SamplingConfig.SamplingRule rule) { - if (rule.getResponseCode().startsWith(START_CODE_40X) && - (rule.getErrorCodes() == null || rule.getErrorCodes().isBlank())) { - throw new IllegalArgumentException(String.format( - "The rule with URI [%s] and code [%s] must have errorCodes configured", - rule.getUri(), rule.getResponseCode() - )); - } - + if (rule.getResponseCode().startsWith(START_CODE_40X) + && (rule.getErrorCodes() == null || rule.getErrorCodes().isBlank())) { + throw new IllegalArgumentException(String.format( + "The rule with URI [%s] and code [%s] must have errorCodes configured", + rule.getUri(), rule.getResponseCode() + )); + } } private void validateErrorCodesFormat(SamplingConfig.SamplingRule rule) { if (rule.getErrorCodes() != null && !rule.getErrorCodes().isBlank()) { String[] codes = rule.getErrorCodes().trim().split("\\|"); - for (String code : codes) { - if (code.isBlank() || !code.matches("^[\\w\\-]+$")) { + if (code.isBlank() || !ERROR_CODE_REGEX.matcher(code).matches()) { throw new IllegalArgumentException(String.format( "The rule with URI [%s] has an invalid error code: [%s]. " + "Expected format: code1|code2|...|codeN with alphanumeric codes and hyphens only.", diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/MessageIdProperties.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/MessageIdProperties.java new file mode 100644 index 0000000..cc19a09 --- /dev/null +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/MessageIdProperties.java @@ -0,0 +1,15 @@ +package co.com.bancolombia.ecs.infra.config; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@ConfigurationProperties(prefix = "adapter.ecs.logs.message-id") +public class MessageIdProperties { + + private final Boolean enableAutoRegisterMessageId; + + public MessageIdProperties(Boolean enableAutoRegisterMessageId) { + this.enableAutoRegisterMessageId = enableAutoRegisterMessageId; + } +} diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/PrintOnErrorProperties.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/PrintOnErrorProperties.java new file mode 100644 index 0000000..f2197b3 --- /dev/null +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/PrintOnErrorProperties.java @@ -0,0 +1,57 @@ +package co.com.bancolombia.ecs.infra.config; + +import co.com.bancolombia.ecs.domain.model.ExceptionLevel; +import co.com.bancolombia.ecs.model.management.BusinessExceptionECS; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.stream.Collectors; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "adapter.ecs.logs.print-on-error") +public class PrintOnErrorProperties { + private static final String EXCEPTION_LEVEL_PROPERTY_NAME = "adapter.ecs.logs.print-on-error.print-req-resp-level"; + + private Boolean printReqResp; + private ExceptionLevel printReqRespLevel; + + public void setPrintReqRespLevel(String printReqRespLevel) { + if (printReqRespLevel == null || printReqRespLevel.isBlank()) { + this.printReqRespLevel = null; + return; + } + + String normalizedLevel = printReqRespLevel.trim().toLowerCase(); + var exceptionLevel = ExceptionLevel.toExceptionLevelIgnoreCase(normalizedLevel); + if (exceptionLevel == null) { + throw new IllegalArgumentException(String.format( + "Valor no permitido para la variable de configuración %s: %s | Valores permitidos: %s.", + EXCEPTION_LEVEL_PROPERTY_NAME, + printReqRespLevel, + Arrays.stream(ExceptionLevel.values()) + .map(ExceptionLevel::getLevelString) + .collect(Collectors.joining(", ")) + )); + } + + this.printReqRespLevel = exceptionLevel; + } + + public static boolean matchesConfiguredLevel(Throwable error, ExceptionLevel configuredLevel) { + if (error == null || configuredLevel == null) { + return false; + } + return switch (configuredLevel) { + case BUSINESS_EXCEPTION_ECS -> error instanceof BusinessExceptionECS; + case EXCEPTION -> error instanceof Exception; + // By definition of method params error is a Throwable + case THROWABLE -> true; + default -> false; + }; + } +} diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngUseCase.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngUseCase.java new file mode 100644 index 0000000..0e292d8 --- /dev/null +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngUseCase.java @@ -0,0 +1,101 @@ +package co.com.bancolombia.ecs.infra.config.managementid.application; + +import co.com.bancolombia.ecs.helpers.DataSanitizer; +import co.com.bancolombia.ecs.infra.config.managementid.domain.MessageIdRequestProperties; +import co.com.bancolombia.ecs.infra.shared.common.domain.ContextECS; +import lombok.extern.log4j.Log4j2; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.stream.Stream; + +@Log4j2 +public class MessageIdMngUseCase { + + private static volatile Boolean enabledFlag; + private static final String HEADER_NAME = "message-id"; + + public MessageIdMngUseCase(MessageIdRequestProperties messageIdRequestProperties) { + enabledFlag = messageIdRequestProperties.getEnabled(); + if (Boolean.TRUE.equals(enabledFlag)) { + log.info("message-id: UUID will be auto-generated on every request without message-id header."); + } else if (Boolean.FALSE.equals(enabledFlag)) { + log.info("message-id: UUID will be generated for exceptions that lack a message-id."); + } else { + log.info("message-id: auto-generation disabled. Library behaves as before."); + } + } + + + /** + * Extrae y normaliza el header message-id desde un mapa de headers HTTP. + * Soporta variantes como {@code message-id}, {@code messageId}, {@code Message-Id}, etc. + * Acepta directamente {@code HttpHeaders} (Spring Reactive/MVC) o cualquier + * {@code Map>} sin conversión en el caller. + * Delega en {@link #resolveFromRequestEnvironment(String)} para aplicar la lógica del flag. + * + * @param headers mapa de headers crudos del request; {@code null} equivale a sin headers + * @param allowedHeaders set de headers permitidos configurado en {@link co.com.bancolombia.ecs.infra.config.EcsPropertiesConfig} + */ + public String resolveFromHeaders(Set>> headers, Set allowedHeaders) { + if (headers == null || headers.isEmpty()) { + return resolveFromRequestEnvironment(null); + } + Map normalized = DataSanitizer.sanitizeHeaders(headers, allowedHeaders); + return resolveFromRequestEnvironment(normalized.get(HEADER_NAME)); + } + + /** + * Resuelve el messageId para una petición entrante a partir del valor raw del header. + *
    + *
  • {@code true} – genera UUID si el header está ausente o en blanco.
  • + *
  • {@code true/null} – genera UUID si el header está ausente o en blanco.
  • + *
  • {@code false} – devuelve el header tal como llega; {@code null} si no hay header.
  • + *
+ */ + public String resolveFromRequestEnvironment(String incomingHeaderValue) { + if (!Boolean.FALSE.equals(enabledFlag)) { + return Optional.ofNullable(incomingHeaderValue) + .filter(Predicate.not(String::isBlank)) + .orElseGet(() -> UUID.randomUUID().toString()); + } + return (incomingHeaderValue != null && !incomingHeaderValue.isBlank()) + ? incomingHeaderValue + : null; + } + + /** + * Resuelve el messageId para una excepción. + *
    + *
  • Si alguno de los candidatos (ctx, meta) es no vacío, lo retorna.
  • + *
  • Si ninguno tiene valor, genera siempre un UUID para garantizar trazabilidad, + * independientemente del valor de {@code enable_auto_register_message_id}.
  • + *
+ */ + public static String resolveForException(String metaInfoMessageId) { + String contextMessageId = MessageIdMngUseCase.getFromContext().orElse(null); + String resolved = Stream.of(contextMessageId, metaInfoMessageId) + .filter(s -> s != null && !s.isBlank()) + .findFirst() + .orElse(null); + return (resolved != null) ? resolved: + UUID.randomUUID().toString(); + } + + + /** + * Lee el messageId del contexto (Reactor Context / ThreadLocal). + * Encapsula el acceso a {@link ContextECS} para que los handlers/middlewares + * no dependan directamente de la infraestructura de contexto. + * + * @return Optional con el messageId si existe y no está en blanco; vacío en caso contrario. + */ + public static Optional getFromContext() { + String id = ContextECS.getMessageId(); + return (id != null && !id.isBlank()) ? Optional.of(id) : Optional.empty(); + } +} diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngValidator.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngValidator.java new file mode 100644 index 0000000..a23cef5 --- /dev/null +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngValidator.java @@ -0,0 +1,81 @@ +package co.com.bancolombia.ecs.infra.config.managementid.application; + +import org.springframework.beans.factory.BeanInitializationException; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.Environment; + +import java.util.Set; + +/** + * Validates that the raw value of {@code enable_auto_register_message_id} + * is strictly {@code "true"} or {@code "false"}. + * + *

Spring Boot silently converts values like {@code "yes"}, {@code "on"}, + * {@code "1"} to {@code Boolean.TRUE} when the property type is {@code Boolean}. + * Using {@code String} as the property type and calling this validator at startup + * produces a clear error, as close as possible to a compile-time check. + */ +public final class MessageIdMngValidator { + + static final String MESSAGE_ID_PREFIX = "adapter.ecs.logs.message-id"; + + private static final Set VALID_KEYS = Set.of( + "adapter.ecs.logs.message-id.enable_auto_register_message_id", + "adapter.ecs.logs.message-id.enable-auto-register-message-id", + "adapter.ecs.logs.message-id.enableautoregistermessageid" + ); + + private static final Set VALID_VALUES = Set.of("true", "false"); + + private static final String ERROR_VALUE_MSG = + "Invalid value '%s' for property '%s'. " + + "Accepted values are: 'true' or 'false'. " + + "Please review your application.yaml configuration."; + + private static final String ERROR_KEY_MSG = + "Invalid or unknown property '%s' under the 'adapter.ecs.logs.message-id' namespace. " + + "The only accepted property is " + + "'adapter.ecs.logs.message-id.enable_auto_register_message_id'. " + + "Check for typos in the property key (e.g. 'message-id-' instead of 'message-id'). " + + "Please review your application.yaml configuration."; + + private MessageIdMngValidator() {} + + /** + * Validates {@code rawValue} against the allowed domain {"true", "false"}. + * + * @param rawValue the raw string read from the YAML property + * @param propertyKey the canonical property key (for the error message) + * @throws BeanInitializationException if the value is non-null and not accepted + */ + public static void validate(String rawValue, String propertyKey) { + if (rawValue != null && !VALID_VALUES.contains(rawValue.toLowerCase())) { + throw new BeanInitializationException( + String.format(ERROR_VALUE_MSG, rawValue, propertyKey)); + } + } + + /** + * Scans all enumerable property sources for keys that start with the + * {@code adapter.ecs.logs.message-id} namespace but are not the recognized + * canonical key. This catches prefix typos such as {@code message-id-:}. + * + * @param environment the application environment + * @throws BeanInitializationException if an unknown key is found + */ + public static void validatePropertyKeys(Environment environment) { + if (!(environment instanceof ConfigurableEnvironment ce)) return; + for (var source : ce.getPropertySources()) { + if (source instanceof EnumerablePropertySource ep) { + for (String key : ep.getPropertyNames()) { + if (key.toLowerCase().startsWith(MESSAGE_ID_PREFIX) + && !VALID_KEYS.contains(key.toLowerCase())) { + throw new BeanInitializationException( + String.format(ERROR_KEY_MSG, key)); + } + } + } + } + } +} diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/domain/MessageIdRequestProperties.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/domain/MessageIdRequestProperties.java new file mode 100644 index 0000000..b9a28eb --- /dev/null +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/domain/MessageIdRequestProperties.java @@ -0,0 +1,59 @@ +package co.com.bancolombia.ecs.infra.config.managementid.domain; + +import co.com.bancolombia.ecs.infra.config.managementid.application.MessageIdMngValidator; +import lombok.Getter; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.env.Environment; + +/** + * Configuration properties for the message-id feature. + * + *

The field type is intentionally {@code String} (not {@code Boolean}) to capture + * the raw YAML value before Spring Boot's relaxed binding can silently convert + * ambiguous values like {@code "yes"}, {@code "on"}, or {@code "1"} to {@code Boolean.TRUE}. + * {@link MessageIdMngValidator} enforces that only {@code "true"} or {@code "false"} are accepted. + * + *

Accepted YAML key: {@code enable_auto_register_message_id} (snake_case canonical form). + * Spring's relaxed binding also accepts kebab-case for backwards compatibility. + * + *

Semantics of the parsed {@link #enabled} value: + *

    + *
  • {@code null} – feature disabled; library behaves as before
  • + *
  • {@code false} – generates a UUID only when a BusinessException has no messageId + * (exception traceability)
  • + *
  • {@code true} – generates a UUID on every request that lacks a message-id header
  • + *
+ */ +@ConfigurationProperties(prefix = "adapter.ecs.logs.message-id") +public class MessageIdRequestProperties implements InitializingBean, EnvironmentAware { + + private static final String PROPERTY_KEY = + "adapter.ecs.logs.message-id.enable_auto_register_message_id"; + + private final String enableAutoRegisterMessageId; + private Environment environment; + + /** Validated and parsed value; available after {@link #afterPropertiesSet()}. */ + @Getter + private Boolean enabled; + + public MessageIdRequestProperties(String enableAutoRegisterMessageId) { + this.enableAutoRegisterMessageId = enableAutoRegisterMessageId; + } + + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } + + @Override + public void afterPropertiesSet() { + MessageIdMngValidator.validatePropertyKeys(environment); + MessageIdMngValidator.validate(enableAutoRegisterMessageId, PROPERTY_KEY); + this.enabled = enableAutoRegisterMessageId == null + ? null + : Boolean.parseBoolean(enableAutoRegisterMessageId.toLowerCase()); + } +} diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/infra/.gitkeep b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/config/managementid/infra/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/shared/.gitkeep b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/shared/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/shared/common/application/.gitkeep b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/shared/common/application/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/shared/common/domain/ContextECS.java b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/shared/common/domain/ContextECS.java new file mode 100644 index 0000000..37b63a7 --- /dev/null +++ b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/shared/common/domain/ContextECS.java @@ -0,0 +1,25 @@ +package co.com.bancolombia.ecs.infra.shared.common.domain; + +public final class ContextECS { + + /* + * Deberia retornar un nulo si el parametro esta en false o no esta activo + */ + public static final String KEY_MESSAGE_ID = "ECS_MESSAGE_ID"; + + private static final ThreadLocal messageIdHolder = new ThreadLocal<>(); + + private ContextECS() {} + + public static void setMessageId(String messageId) { + messageIdHolder.set(messageId); + } + + public static String getMessageId() { + return messageIdHolder.get(); + } + + public static void clear() { + messageIdHolder.remove(); + } +} \ No newline at end of file diff --git a/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/shared/common/infra/.gitkeep b/ecs-core/src/main/java/co/com/bancolombia/ecs/infra/shared/common/infra/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ecs-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/ecs-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..b33e484 --- /dev/null +++ b/ecs-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,28 @@ +{ + "properties": [ + { + "name": "adapter.ecs.logs.print-on-error.print-req-resp-level", + "type": "java.lang.String", + "description": "Controls which error type triggers request and response logging when adapter.ecs.logs.print-on-error.print-req-resp is enabled." + } + ], + "hints": [ + { + "name": "adapter.ecs.logs.print-on-error.print-req-resp-level", + "values": [ + { + "value": "BusinessExceptionECS", + "description": "Logs request and response payloads only when the error is a BusinessExceptionECS." + }, + { + "value": "Exception", + "description": "Logs request and response payloads for any Exception, including BusinessExceptionECS." + }, + { + "value": "Throwable", + "description": "Logs request and response payloads for any Throwable." + } + ] + } + ] +} diff --git a/ecs-core/src/main/resources/application.yaml b/ecs-core/src/main/resources/application.yaml index 56e1154..c6808e6 100644 --- a/ecs-core/src/main/resources/application.yaml +++ b/ecs-core/src/main/resources/application.yaml @@ -22,3 +22,8 @@ spring: rules40XJson: "" sensitive-rules: sensitive-data: "" + print-on-error: + print-req-resp: + print-req-resp-level: + message-id: + enable_auto_register_message_id: diff --git a/ecs-core/src/test/java/co/com/bancolombia/ecs/domain/middleware/MiddlewareEcsBusinessTest.java b/ecs-core/src/test/java/co/com/bancolombia/ecs/domain/middleware/MiddlewareEcsBusinessTest.java new file mode 100644 index 0000000..00d9ebc --- /dev/null +++ b/ecs-core/src/test/java/co/com/bancolombia/ecs/domain/middleware/MiddlewareEcsBusinessTest.java @@ -0,0 +1,73 @@ +package co.com.bancolombia.ecs.domain.middleware; + +import co.com.bancolombia.ecs.infra.shared.common.domain.ContextECS; +import co.com.bancolombia.ecs.model.management.BusinessExceptionECS; +import co.com.bancolombia.ecs.model.management.ErrorManagement; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(OutputCaptureExtension.class) +class MiddlewareEcsBusinessTest { + + private MiddlewareEcsBusiness middleware; + + @BeforeEach + void setUp() { + middleware = new MiddlewareEcsBusiness(); + ContextECS.clear(); + } + + @AfterEach + void tearDown() { + ContextECS.clear(); + } + + @Test + void shouldUseContextMessageIdWhenAvailable(CapturedOutput output) { + ContextECS.setMessageId("ctx-message-id-123"); + var exception = new BusinessExceptionECS(ErrorManagement.DEFAULT_EXCEPTION); + + middleware.process(exception, "test-service"); + + assertTrue(output.getOut().contains("ctx-message-id-123")); + } + + @Test + void shouldUseMetaInfoMessageIdWhenContextIsAbsent(CapturedOutput output) { + String expectedMessageId = "business-exception-message-id"; + var metaInfo = BusinessExceptionECS.MetaInfo.builder() + .messageId(expectedMessageId) + .build(); + var exception = new BusinessExceptionECS(ErrorManagement.DEFAULT_EXCEPTION, metaInfo); + + middleware.process(exception, "test-service"); + + assertTrue(output.getOut().contains(expectedMessageId)); + } + + @Test + void shouldGenerateUuidWhenNoMessageIdFromAnySource(CapturedOutput output) { + var metaInfo = BusinessExceptionECS.MetaInfo.builder() + .messageId(null) + .build(); + var exception = new BusinessExceptionECS(ErrorManagement.DEFAULT_EXCEPTION, metaInfo); + + middleware.process(exception, "test-service"); + + String log = output.getOut(); + assertTrue(log.contains("message-id")); + assertFalse(log.contains("\"message-id\":null")); + } + + @Test + void shouldDelegateToNextWhenRequestIsNotBusinessException(CapturedOutput output) { + assertDoesNotThrow(() -> new MiddlewareEcsBusiness().process("not-an-exception", "test-service")); + assertTrue(output.getOut().isEmpty()); + } +} diff --git a/ecs-core/src/test/java/co/com/bancolombia/ecs/helpers/DataSanitizerTest.java b/ecs-core/src/test/java/co/com/bancolombia/ecs/helpers/DataSanitizerTest.java index 91a947f..5674ca3 100644 --- a/ecs-core/src/test/java/co/com/bancolombia/ecs/helpers/DataSanitizerTest.java +++ b/ecs-core/src/test/java/co/com/bancolombia/ecs/helpers/DataSanitizerTest.java @@ -1,7 +1,10 @@ package co.com.bancolombia.ecs.helpers; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -77,7 +80,7 @@ void testSanitizeHeadersShouldFilterAllowedHeaders() { Map result = DataSanitizer.sanitizeHeaders(headers, allowed); assertEquals(1, result.size()); - assertEquals("Bearer token", result.get("Authorization")); + assertEquals("Bearer token", result.get("authorization")); } @Test @@ -180,4 +183,223 @@ void testSanitizeHeadersShouldValuesEmptyAllowedHeaders() { assertEquals(0, result.size()); assertNull(result.get("Authorization")); } + + @ParameterizedTest + @ValueSource(strings = {"message-id", "Message-Id", "MESSAGE-ID", "message_id", "Message_Id", + "MessageId", "messageid", "MESSAGEID", "MESSAGE_ID"}) + void testSanitizeHeadersShouldMatchMessageIdRegardlessOfCasing(String headerKey) { + Set>> headers = Set.of( + Map.entry(headerKey, List.of("app-uuid-123")) + ); + Set allowed = Set.of("message-id"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertEquals(1, result.size()); + assertEquals("app-uuid-123", result.get("message-id")); + } + + @Test + void testSanitizeHeadersShouldOutputCanonicalLowercaseKeys() { + Set>> headers = Set.of( + Map.entry("Consumer-Acronym", List.of("ABC")), + Map.entry("Message-Id", List.of("uuid-1")), + Map.entry("X-Channel", List.of("web")) + ); + Set allowed = Set.of("consumer-acronym", "message-id", "x-channel"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertEquals(3, result.size()); + assertEquals("ABC", result.get("consumer-acronym")); + assertEquals("uuid-1", result.get("message-id")); + assertEquals("web", result.get("x-channel")); + assertNull(result.get("Consumer-Acronym")); + assertNull(result.get("Message-Id")); + assertNull(result.get("X-Channel")); + } + + @Test + void testSanitizeHeadersShouldMatchUnderscoreVariant() { + Set>> headers = Set.of( + Map.entry("consumer_acronym", List.of("XYZ")) + ); + Set allowed = Set.of("consumer-acronym"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertEquals(1, result.size()); + assertEquals("XYZ", result.get("consumer-acronym")); + } + + @Test + void testSanitizeHeadersShouldMatchCamelCaseVariant() { + Set>> headers = Set.of( + Map.entry("consumerAcronym", List.of("DEF")) + ); + Set allowed = Set.of("consumer-acronym"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertEquals(1, result.size()); + assertEquals("DEF", result.get("consumer-acronym")); + } + + @Test + void testSanitizeHeadersShouldNotMatchOtherSymbolsVariant() { + Set>> headers = Set.of( + Map.entry("message.id", List.of("uuid-1")) + ); + Set allowed = Set.of("message-id"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertTrue(result.isEmpty()); + } + + @Test + void testSanitizeHeadersShouldMatchOtherSymbolsIfAllowedVariant() { + Set>> headers = Set.of( + Map.entry("message.id", List.of("uuid-1")) + ); + Set allowed = Set.of("message.id"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertEquals(1, result.size()); + assertEquals("uuid-1", result.get("message.id")); + } + + @Test + void testSanitizeHeadersShouldMatchMultipleAllowedHeadersWithMixedCasing() { + Set>> headers = Set.of( + Map.entry("Message_Id", List.of("id-1")), + Map.entry("AUTHORIZATION", List.of("Bearer tok")), + Map.entry("X-Not-Allowed", List.of("nope")) + ); + Set allowed = Set.of("message-id", "authorization"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertEquals(2, result.size()); + assertEquals("id-1", result.get("message-id")); + assertEquals("Bearer tok", result.get("authorization")); + assertNull(result.get("x-not-allowed")); + } + + @Test + void testSanitizeHeadersShouldReturnEmptyWhenNoHeadersMatchAllowList() { + Set>> headers = Set.of( + Map.entry("X-Custom", List.of("val1")), + Map.entry("X-Other", List.of("val2")) + ); + Set allowed = Set.of("message-id", "authorization"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertTrue(result.isEmpty()); + } + + @Test + void testSanitizeHeadersShouldSkipHeadersWithEmptyValuesList() { + Set>> headers = Set.of( + Map.entry("message-id", List.of()), + Map.entry("authorization", List.of("Bearer token")) + ); + Set allowed = Set.of("message-id", "authorization"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertEquals(1, result.size()); + assertNull(result.get("message-id")); + assertEquals("Bearer token", result.get("authorization")); + } + + @Test + void testSanitizeHeadersShouldReturnEmptyWhenAllowedSetIsEmpty() { + Set>> headers = Set.of( + Map.entry("message-id", List.of("uuid-1")), + Map.entry("authorization", List.of("Bearer token")) + ); + Set allowed = Set.of(); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertTrue(result.isEmpty()); + } + + @Test + void testSanitizeHeadersShouldReturnEmptyWhenRequestHeadersAreEmpty() { + Set>> headers = Set.of(); + Set allowed = Set.of("message-id"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertTrue(result.isEmpty()); + } + + @Test + void testSanitizeHeadersShouldKeepFirstValueWhenDuplicateNormalizedKeys() { + // Use LinkedHashSet to control iteration order + Set>> headers = new LinkedHashSet<>(); + headers.add(Map.entry("message-id", List.of("first-value"))); + headers.add(Map.entry("Message_Id", List.of("second-value"))); + Set allowed = Set.of("message-id"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertEquals(1, result.size()); + assertEquals("first-value", result.get("message-id")); + } + + @Test + void testSanitizeHeadersShouldUseFirstValueFromMultiValueHeader() { + Set>> headers = Set.of( + Map.entry("message-id", List.of("first", "second", "third")) + ); + Set allowed = Set.of("message-id"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertEquals(1, result.size()); + assertEquals("first", result.get("message-id")); + } + + @Test + void testSanitizeHeadersShouldHandleAllowListWithMixedCasing() { + Set>> headers = Set.of( + Map.entry("message-id", List.of("uuid-1")) + ); + Set allowed = Set.of("Message-Id"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertEquals(1, result.size()); + assertEquals("uuid-1", result.get("message-id")); + } + + @Test + void testSanitizeHeadersShouldHandleHeaderKeyWithOnlySeparators() { + Set>> headers = Set.of( + Map.entry("--__--", List.of("value")) + ); + Set allowed = Set.of("authorization"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertTrue(result.isEmpty()); + } + + @Test + void testSanitizeHeadersShouldNotMatchPartialKeys() { + Set>> headers = Set.of( + Map.entry("message-id-extra", List.of("val1")), + Map.entry("xmessage-id", List.of("val2")) + ); + Set allowed = Set.of("message-id"); + + Map result = DataSanitizer.sanitizeHeaders(headers, allowed); + + assertTrue(result.isEmpty()); + } } \ No newline at end of file diff --git a/ecs-core/src/test/java/co/com/bancolombia/ecs/helpers/HandlerHelperTest.java b/ecs-core/src/test/java/co/com/bancolombia/ecs/helpers/HandlerHelperTest.java new file mode 100644 index 0000000..87e02d3 --- /dev/null +++ b/ecs-core/src/test/java/co/com/bancolombia/ecs/helpers/HandlerHelperTest.java @@ -0,0 +1,125 @@ +package co.com.bancolombia.ecs.helpers; + +import co.com.bancolombia.ecs.model.management.BusinessExceptionECS; +import co.com.bancolombia.ecs.model.management.ErrorManagement; +import co.com.bancolombia.ecs.model.request.LogRequest; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HandlerHelperTest { + + @Test + void testSetConsumerShouldUseConsumerAcronymHeader() { + LogRequest logRequest = new LogRequest(); + + HandlerHelper.setConsumer(logRequest, Map.of("consumer-acronym", "mobile-app")); + + assertEquals("mobile-app", logRequest.getConsumer()); + } + + @Test + void testSetConsumerShouldLeaveConsumerNullWhenHeadersDoNotContainKnownKeys() { + LogRequest logRequest = new LogRequest(); + + HandlerHelper.setConsumer(logRequest, Map.of("message-id", "12345")); + + assertNull(logRequest.getConsumer()); + } + + @Test + void testResolveHttpStatusShouldPreserveResponseStatusExceptionStatus() { + ResponseStatusException exception = + new ResponseStatusException(HttpStatus.FORBIDDEN, "Invalid CORS request"); + + HttpStatus resolvedStatus = HandlerHelper.resolveHttpStatus(exception); + + assertEquals(HttpStatus.FORBIDDEN, resolvedStatus); + } + + @Test + void testResolveHttpStatusShouldReturnBusinessExceptionStatus() { + BusinessExceptionECS exception = new BusinessExceptionECS(errorManagementWithStatus(HttpStatus.BAD_REQUEST.value())); + + HttpStatus resolvedStatus = HandlerHelper.resolveHttpStatus(exception); + + assertEquals(HttpStatus.BAD_REQUEST, resolvedStatus); + } + + @Test + void testResolveHttpStatusShouldFallbackToInternalServerErrorWhenBusinessStatusIsNull() { + BusinessExceptionECS exception = new BusinessExceptionECS(errorManagementWithStatus(null)); + + HttpStatus resolvedStatus = HandlerHelper.resolveHttpStatus(exception); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resolvedStatus); + } + + @Test + void testResolveStatusCodeShouldResolveKnownValuesAndNulls() { + assertEquals(HttpStatus.OK, HandlerHelper.resolveStatusCode(HttpStatus.OK.value())); + assertNull(HandlerHelper.resolveStatusCode(999)); + } + + @Test + void testParseToMapShouldReturnParsedJsonMap() { + Map body = HandlerHelper.parseToMap("{\"key\":\"value\"}"); + + assertEquals(Map.of("key", "value"), body); + } + + @Test + void testParseToMapShouldWrapRawBodyWhenJsonIsInvalid() { + String invalidBody = "plain-text-body"; + + Map body = HandlerHelper.parseToMap(invalidBody); + + assertEquals(Map.of(HandlerHelper.RAW_BODY, invalidBody), body); + } + + @Test + void testIsErrorStatusCodeShouldOnlyReturnTrueFor4xxAnd5xxStatuses() { + assertFalse(HandlerHelper.isErrorStatusCode(HttpStatus.OK.value())); + assertTrue(HandlerHelper.isErrorStatusCode(HttpStatus.BAD_REQUEST.value())); + assertTrue(HandlerHelper.isErrorStatusCode(HttpStatus.INTERNAL_SERVER_ERROR.value())); + } + + private ErrorManagement errorManagementWithStatus(Integer status) { + return new ErrorManagement() { + @Override + public Integer getStatus() { + return status; + } + + @Override + public String getMessage() { + return "Invalid CORS request"; + } + + @Override + public String getErrorCode() { + return "CORS-403"; + } + + @Override + public String getInternalMessage() { + return "CORS validation failed"; + } + + @Override + public String getLogCode() { + return "CORS-403-00"; + } + }; + } +} + + + diff --git a/ecs-core/src/test/java/co/com/bancolombia/ecs/helpers/SamplingHelperTest.java b/ecs-core/src/test/java/co/com/bancolombia/ecs/helpers/SamplingHelperTest.java index afd44c4..89fd4c2 100644 --- a/ecs-core/src/test/java/co/com/bancolombia/ecs/helpers/SamplingHelperTest.java +++ b/ecs-core/src/test/java/co/com/bancolombia/ecs/helpers/SamplingHelperTest.java @@ -142,6 +142,47 @@ void shouldRespectShowsWithResponseCode409() { assertArrayEquals(expected, actual); } + @Test + void shouldFallbackToResponseCodeFor4xxWhenErrorIsMissing() { + LogRecord corsLogRecord = new LogRecord<>(); + LogRecord.AdditionalInfo info = new LogRecord.AdditionalInfo<>(); + info.setUri("/test/endpoint"); + info.setResponseCode("403"); + corsLogRecord.setAdditionalInfo(info); + + SamplingConfig.SamplingRule rule = new SamplingConfig.SamplingRule(); + rule.setResponseCode("403"); + rule.setUri("/test/endpoint"); + rule.setShowCount(1); + rule.setSkipCount(1); + + SamplingHelper.init(Map.of("/test/endpoint|403", rule)); + + assertTrue(SamplingHelper.validatePrint(corsLogRecord)); + assertFalse(SamplingHelper.validatePrint(corsLogRecord)); + } + + @Test + void shouldFallbackToResponseCodeFor4xxWhenErrorTypeIsMissing() { + LogRecord.ErrorLog errorLog = LogRecord.ErrorLog.builder() + .message("Forbidden") + .description("CORS rejection") + .build(); + logRecordError.getAdditionalInfo().setResponseCode("403"); + logRecordError.setError(errorLog); + + SamplingConfig.SamplingRule rule = new SamplingConfig.SamplingRule(); + rule.setResponseCode("403"); + rule.setUri("/test/endpoint"); + rule.setShowCount(1); + rule.setSkipCount(1); + + SamplingHelper.init(Map.of("/test/endpoint|403", rule)); + + assertTrue(SamplingHelper.validatePrint(logRecordError)); + assertFalse(SamplingHelper.validatePrint(logRecordError)); + } + @Test void shouldResetCounterAfterCycle() { SamplingConfig.SamplingRule rule = new SamplingConfig.SamplingRule(); @@ -154,4 +195,16 @@ void shouldResetCounterAfterCycle() { assertFalse(SamplingHelper.validatePrint(logRecord)); assertTrue(SamplingHelper.validatePrint(logRecord)); } + + @Test + void shouldReturnTrueWhenSamplingIsDisabled() { + SamplingConfig.SamplingRule rule = new SamplingConfig.SamplingRule(); + rule.setShowCount(0); + rule.setSkipCount(1); + SamplingHelper.init(Map.of("/test/endpoint|200", rule)); + + SamplingHelper.setSamplingEnabled(false); + + assertTrue(SamplingHelper.validatePrint(logRecord)); + } } diff --git a/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/.gitkeep b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/EcsPropertiesConfigTest.java b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/EcsPropertiesConfigTest.java index f64acf3..6e0f715 100644 --- a/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/EcsPropertiesConfigTest.java +++ b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/EcsPropertiesConfigTest.java @@ -1,5 +1,6 @@ package co.com.bancolombia.ecs.infra.config; +import co.com.bancolombia.ecs.domain.model.ExceptionLevel; import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveRequestProperties; import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveResponseProperties; import co.com.bancolombia.ecs.infra.config.service.ServiceProperties; @@ -7,6 +8,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class EcsPropertiesConfigTest { @@ -14,6 +16,8 @@ class EcsPropertiesConfigTest { private ServiceProperties serviceProps; private SensitiveRequestProperties reqProps; private SensitiveResponseProperties resProps; + private PrintOnErrorProperties printOnErrorProperties; + private EcsPropertiesConfig config; @BeforeEach @@ -37,7 +41,9 @@ void setUp() { resProps.setPatterns(".*tokenValue.*"); resProps.setReplacement("***"); - config = new EcsPropertiesConfig(serviceProps, reqProps, resProps); + printOnErrorProperties = new PrintOnErrorProperties(); + + config = new EcsPropertiesConfig(serviceProps, reqProps, resProps, printOnErrorProperties); } @Test @@ -60,7 +66,7 @@ void testShouldParseResponsePropertiesCorrectly() { void testShouldParseRequestPropertiesShowFalse() { reqProps.setShow(Boolean.FALSE); resProps.setShow(Boolean.FALSE); - config = new EcsPropertiesConfig(serviceProps, reqProps, resProps); + config = new EcsPropertiesConfig(serviceProps, reqProps, resProps, printOnErrorProperties); assertEquals("ecs-test", config.getServiceName()); } @@ -70,7 +76,7 @@ void testShouldParseRequestPropertiesShowRequest() { resProps.setShow(Boolean.FALSE); reqProps.setExcludedPaths(null); reqProps.setAllowHeaders(null); - config = new EcsPropertiesConfig(serviceProps, reqProps, resProps); + config = new EcsPropertiesConfig(serviceProps, reqProps, resProps, printOnErrorProperties); assertEquals("ecs-test", config.getServiceName()); assertTrue(config.getExcludedPaths().contains("/actuator")); assertTrue(config.getAllowRequestHeaders().contains("message-id")); @@ -83,7 +89,7 @@ void testShouldParseRequestPropertiesShowResponse() { reqProps.setDelimiter(null); reqProps.setExcludedPaths(null); reqProps.setAllowHeaders(null); - config = new EcsPropertiesConfig(serviceProps, reqProps, resProps); + config = new EcsPropertiesConfig(serviceProps, reqProps, resProps, printOnErrorProperties); assertEquals("ecs-test", config.getServiceName()); assertTrue(config.getExcludedPaths().contains("/actuator")); assertTrue(config.getAllowRequestHeaders().contains("message-id")); @@ -96,9 +102,34 @@ void testShowRequestWithPropertiesEmpty() { reqProps.setDelimiter(""); reqProps.setExcludedPaths(""); reqProps.setAllowHeaders(""); - config = new EcsPropertiesConfig(serviceProps, reqProps, resProps); + config = new EcsPropertiesConfig(serviceProps, reqProps, resProps, printOnErrorProperties); assertEquals("ecs-test", config.getServiceName()); assertTrue(config.getExcludedPaths().contains("/actuator")); assertTrue(config.getAllowRequestHeaders().contains("message-id")); } -} \ No newline at end of file + + @Test + void testShouldActivatePrintOnErrorAndOverrideShowFlags() { + reqProps.setShow(Boolean.FALSE); + resProps.setShow(Boolean.FALSE); + reqProps.setDelimiter("\\|"); + reqProps.setAllowHeaders("message-id"); + reqProps.setExcludedPaths("/actuator"); + reqProps.setFields(""); + reqProps.setPatterns(""); + reqProps.setReplacement("***"); + resProps.setDelimiter("\\|"); + resProps.setFields(""); + resProps.setPatterns(""); + resProps.setReplacement("***"); + printOnErrorProperties.setPrintReqResp(Boolean.TRUE); + printOnErrorProperties.setPrintReqRespLevel("Exception"); + + config = new EcsPropertiesConfig(serviceProps, reqProps, resProps, printOnErrorProperties); + + assertFalse(config.getShowRequestLogs()); + assertFalse(config.getShowResponseLogs()); + assertEquals(Boolean.TRUE, config.getPrintReqRespOnErrorOnly()); + assertEquals(ExceptionLevel.EXCEPTION, config.getPrintReqRespLevels()); + } +} diff --git a/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/LoggerEcsInitializerTest.java b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/LoggerEcsInitializerTest.java index 42c9d7b..2e438bc 100644 --- a/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/LoggerEcsInitializerTest.java +++ b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/LoggerEcsInitializerTest.java @@ -2,10 +2,14 @@ import co.com.bancolombia.ecs.helpers.SamplingHelper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.MockedStatic; import org.mockito.Mockito; import java.util.Map; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -29,13 +33,11 @@ void shouldInitializeLoggerEcsWithRules() { rule2.setSkipCount(7); SamplingConfig samplingConfig = new SamplingConfig(); - samplingConfig.setRules20XJson("[{\"uri\":\"/actors/updateCustomer\",\"responseCode\":\"200\",\"showCount\":3," + "\"skipCount\":7}]"); samplingConfig.setRules40XJson("[{\"uri\":\"/actors/createCustomerParameters\",\"responseCode\":\"409\"" + ",\"showCount\":5,\"skipCount\":10,\"errorCodes\":\"BPER409-01|BPER409-02\"}]"); - Map expectedMap = Map.of( "/actors/createCustomerParameters|BPER409-01", rule1, "/actors/createCustomerParameters|BPER409-02", rule1, @@ -43,130 +45,77 @@ void shouldInitializeLoggerEcsWithRules() { ); try (MockedStatic mockedLogger = Mockito.mockStatic(SamplingHelper.class)) { - new LoggerEcsInitializer(samplingConfig); + new LoggerEcsInitializer(samplingConfig, new PrintOnErrorProperties()); mockedLogger.verify(() -> SamplingHelper.init(expectedMap), times(1)); } } @Test void notInitializeLoggerEcsWithError200() { - SamplingConfig.SamplingRule rule1 = new SamplingConfig.SamplingRule(); - rule1.setUri("/actors/createCustomerParameters"); - rule1.setResponseCode("200"); - rule1.setShowCount(5); - rule1.setSkipCount(10); - - SamplingConfig.SamplingRule rule2 = new SamplingConfig.SamplingRule(); - rule2.setUri("/actors/updateCustomer"); - rule2.setResponseCode("200"); - rule2.setShowCount(3); - rule2.setSkipCount(7); - rule2.setErrorCodes("BPER500-01"); - SamplingConfig samplingConfig = new SamplingConfig(); samplingConfig.setRules20XJson("[{\"uri\":\"/actors/createCustomerParameters\",\"responseCode\":\"200\"" + ",\"showCount\":5,\"skipCount\":10}," + "{\"uri\":\"/actors/updateCustomer\",\"responseCode\":\"200\",\"showCount\":3," + "\"skipCount\":7,\"errorCodes\":\"BPER500-01\"}]"); + var props = new PrintOnErrorProperties(); IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, - () -> new LoggerEcsInitializer(samplingConfig) + () -> new LoggerEcsInitializer(samplingConfig, props) ); - assertTrue(exception.getMessage().contains("/actors/updateCustomer")); } - @Test - void notInitializeLoggerEcsWithError409ErrorCodeNull() { + @ParameterizedTest + @MethodSource("provideInvalidError409Rules") + void notInitializeLoggerEcsWithError409InvalidErrorCodes(String rules40XJson, String expectedUri) { SamplingConfig samplingConfig = new SamplingConfig(); - samplingConfig.setRules40XJson("[{\"uri\":\"/actors/v2/createCustomerParameters\",\"responseCode\":\"409\"" + - ",\"showCount\":5,\"skipCount\":10}]"); + samplingConfig.setRules40XJson(rules40XJson); + var props = new PrintOnErrorProperties(); IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, - () -> new LoggerEcsInitializer(samplingConfig) + () -> new LoggerEcsInitializer(samplingConfig, props) ); - - assertTrue(exception.getMessage().contains("/actors/v2/createCustomerParameters")); + assertTrue(exception.getMessage().contains(expectedUri)); } - @Test - void notInitializeLoggerEcsWithError409ErrorCodeBlank() { - SamplingConfig samplingConfig = new SamplingConfig(); - samplingConfig.setRules40XJson("[{\"uri\":\"/actors/createCustomerParameters\",\"responseCode\":\"409\"" + - ",\"showCount\":5,\"skipCount\":10,\"errorCodes\":\"\"}]"); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new LoggerEcsInitializer(samplingConfig) + private static Stream provideInvalidError409Rules() { + return Stream.of( + Arguments.of( + "[{\"uri\":\"/actors/v2/createCustomerParameters\",\"responseCode\":\"409\",\"showCount\":5,\"skipCount\":10}]", + "/actors/v2/createCustomerParameters" + ), + Arguments.of( + "[{\"uri\":\"/actors/createCustomerParameters\",\"responseCode\":\"409\",\"showCount\":5,\"skipCount\":10,\"errorCodes\":\"\"}]", + "/actors/createCustomerParameters" + ), + Arguments.of( + "[{\"uri\":\"/actors/createCustomerParameters\",\"responseCode\":\"409\",\"showCount\":5,\"skipCount\":10,\"errorCodes\":\"BPER409-01,BPER409-02\"}]", + "/actors/createCustomerParameters" + ) ); - - assertTrue(exception.getMessage().contains("/actors/createCustomerParameters")); - } - - @Test - void notInitializeLoggerEcsWithError409ErrorCodeNoFormat() { - SamplingConfig.SamplingRule rule1 = new SamplingConfig.SamplingRule(); - rule1.setUri("/actors/createCustomerParameters"); - rule1.setResponseCode("409"); - rule1.setShowCount(5); - rule1.setSkipCount(10); - rule1.setErrorCodes("BPER409-01,BPER409-02"); - - SamplingConfig.SamplingRule rule2 = new SamplingConfig.SamplingRule(); - rule2.setUri("/actors/updateCustomer"); - rule2.setResponseCode("200"); - rule2.setShowCount(3); - rule2.setSkipCount(7); - - SamplingConfig samplingConfig = new SamplingConfig(); - samplingConfig.setRules20XJson("[{\"uri\":\"/actors/updateCustomer\",\"responseCode\":\"200\",\"showCount\":3," + - "\"skipCount\":7}]"); - samplingConfig.setRules40XJson("[{\"uri\":\"/actors/createCustomerParameters\",\"responseCode\":\"409\"" + - ",\"showCount\":5,\"skipCount\":10,\"errorCodes\":\"BPER409-01,BPER409-02\"}]"); - - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new LoggerEcsInitializer(samplingConfig) - ); - - assertTrue(exception.getMessage().contains("/actors/createCustomerParameters")); } @Test void notInitializeLoggerEcsWithError409ErrorCodeNoFormatEmpty() { - SamplingConfig.SamplingRule rule1 = new SamplingConfig.SamplingRule(); - rule1.setUri("/actors/createCustomerParameters"); - rule1.setResponseCode("409"); - rule1.setShowCount(5); - rule1.setSkipCount(10); - rule1.setErrorCodes("|BPER409-01|BPER409-02|"); - - SamplingConfig.SamplingRule rule2 = new SamplingConfig.SamplingRule(); - rule2.setUri("/actors/updateCustomer"); - rule2.setResponseCode("200"); - rule2.setShowCount(3); - rule2.setSkipCount(7); - SamplingConfig samplingConfig = new SamplingConfig(); samplingConfig.setRules40XJson("[{\"uri\":\"/actors/createCustomerParameters\",\"responseCode\":\"409\"" + ",\"showCount\":5,\"skipCount\":10,\"errorCodes\":\"|BPER409-01|BPER409-02|\"}]"); + var props = new PrintOnErrorProperties(); IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, - () -> new LoggerEcsInitializer(samplingConfig) + () -> new LoggerEcsInitializer(samplingConfig, props) ); - assertTrue(exception.getMessage().contains("/actors/createCustomerParameters")); } @Test void shouldInitializeLoggerEcsWithEmptyMapWhenRulesAreNull() { SamplingConfig samplingConfig = new SamplingConfig(); - try (MockedStatic mockedLogger = Mockito.mockStatic(SamplingHelper.class)) { - new LoggerEcsInitializer(samplingConfig); + new LoggerEcsInitializer(samplingConfig, new PrintOnErrorProperties()); mockedLogger.verify(() -> SamplingHelper.init(Map.of()), times(1)); } } @@ -175,9 +124,8 @@ void shouldInitializeLoggerEcsWithEmptyMapWhenRulesAreNull() { void shouldInitializeLoggerEcsWithEmptyMapWhenRulesAreEmpty() { SamplingConfig samplingConfig = new SamplingConfig(); samplingConfig.setRules20XJson(""); - try (MockedStatic mockedLogger = Mockito.mockStatic(SamplingHelper.class)) { - new LoggerEcsInitializer(samplingConfig); + new LoggerEcsInitializer(samplingConfig, new PrintOnErrorProperties()); mockedLogger.verify(() -> SamplingHelper.init(Map.of()), times(1)); } } @@ -189,11 +137,38 @@ void notInitializeLoggerEcsWithRulesJsonBadFormat() { "\"responseCode\":\"200\",\"showCount\":5,\"skipCount\":10,\"errorCodes\":\"|BPER409-01\"" + "},{\"uri\":\"/actors/updateCustomer\"\"showCount\":3,\"skipCount\":7}]"); + var props = new PrintOnErrorProperties(); IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, - () -> new LoggerEcsInitializer(samplingConfig) + () -> new LoggerEcsInitializer(samplingConfig, props) ); - assertTrue(exception.getMessage().contains("adapter.ecs.logs.sampling.rules20XJson")); } + + @Test + void shouldDisableSamplingWhenPrintOnErrorEnabled() { + SamplingConfig samplingConfig = new SamplingConfig(); + PrintOnErrorProperties printOnErrorProperties = new PrintOnErrorProperties(); + printOnErrorProperties.setPrintReqResp(Boolean.TRUE); + + try (MockedStatic mockedLogger = Mockito.mockStatic(SamplingHelper.class)) { + new LoggerEcsInitializer(samplingConfig, printOnErrorProperties); + mockedLogger.verify(() -> SamplingHelper.setSamplingEnabled(false), times(1)); + mockedLogger.verify(() -> SamplingHelper.init(Map.of()), times(1)); + } + } + + @Test + void shouldNotDisableSamplingWhenPrintOnErrorDisabled() { + SamplingConfig samplingConfig = new SamplingConfig(); + PrintOnErrorProperties printOnErrorProperties = new PrintOnErrorProperties(); + printOnErrorProperties.setPrintReqResp(Boolean.FALSE); + + try (MockedStatic mockedLogger = Mockito.mockStatic(SamplingHelper.class)) { + new LoggerEcsInitializer(samplingConfig, printOnErrorProperties); + mockedLogger.verify(() -> SamplingHelper.setSamplingEnabled(false), times(0)); + mockedLogger.verify(() -> SamplingHelper.setSamplingEnabled(true), times(0)); + mockedLogger.verify(() -> SamplingHelper.init(Map.of()), times(1)); + } + } } diff --git a/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/PrintOnErrorPropertiesBindingTest.java b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/PrintOnErrorPropertiesBindingTest.java new file mode 100644 index 0000000..c63fc54 --- /dev/null +++ b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/PrintOnErrorPropertiesBindingTest.java @@ -0,0 +1,49 @@ +package co.com.bancolombia.ecs.infra.config; + +import co.com.bancolombia.ecs.domain.model.ExceptionLevel; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +class PrintOnErrorPropertiesBindingTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestConfig.class); + + @Test + void shouldBindValidLevelSuccessfully() { + contextRunner + .withPropertyValues( + "adapter.ecs.logs.print-on-error.print-req-resp=true", + "adapter.ecs.logs.print-on-error.print-req-resp-level=Exception") + .run(context -> { + assertThat(context).hasNotFailed(); + PrintOnErrorProperties properties = context.getBean(PrintOnErrorProperties.class); + assertThat(properties.getPrintReqRespLevel()).isEqualTo(ExceptionLevel.EXCEPTION); + }); + } + + @Test + void shouldFailContextWhenLevelIsInvalid() { + contextRunner + .withPropertyValues( + "adapter.ecs.logs.print-on-error.print-req-resp=true", + "adapter.ecs.logs.print-on-error.print-req-resp-level=NoValido") + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasRootCauseInstanceOf(IllegalArgumentException.class); + assertThat(context.getStartupFailure()).hasStackTraceContaining("Valor no permitido"); + assertThat(context.getStartupFailure()).hasStackTraceContaining("NoValido"); + assertThat(context.getStartupFailure()).hasStackTraceContaining( + "Valores permitidos: BusinessExceptionECS, Exception, Throwable."); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(PrintOnErrorProperties.class) + static class TestConfig { + } +} diff --git a/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/PrintOnErrorPropertiesTest.java b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/PrintOnErrorPropertiesTest.java new file mode 100644 index 0000000..f5a0771 --- /dev/null +++ b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/PrintOnErrorPropertiesTest.java @@ -0,0 +1,111 @@ +package co.com.bancolombia.ecs.infra.config; + +import co.com.bancolombia.ecs.domain.model.ExceptionLevel; +import co.com.bancolombia.ecs.model.management.BusinessExceptionECS; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PrintOnErrorPropertiesTest { + + @Test + void shouldSetAndGetProperties() { + PrintOnErrorProperties props = new PrintOnErrorProperties(); + + props.setPrintReqResp(Boolean.TRUE); + props.setPrintReqRespLevel("Throwable"); + + assertEquals(Boolean.TRUE, props.getPrintReqResp()); + assertEquals(ExceptionLevel.THROWABLE, props.getPrintReqRespLevel()); + } + + @Test + void shouldSetAndGetPropertiesIgnoringCase() { + PrintOnErrorProperties props = new PrintOnErrorProperties(); + + props.setPrintReqResp(Boolean.TRUE); + props.setPrintReqRespLevel("businessexceptionecs"); + + assertEquals(Boolean.TRUE, props.getPrintReqResp()); + assertEquals(ExceptionLevel.BUSINESS_EXCEPTION_ECS, props.getPrintReqRespLevel()); + } + + @Test + void shouldAllowNullDefaults() { + PrintOnErrorProperties props = new PrintOnErrorProperties(); + + assertNull(props.getPrintReqResp()); + assertNull(props.getPrintReqRespLevel()); + } + + @Test + void shouldFailWhenLevelIsInvalid() { + PrintOnErrorProperties props = new PrintOnErrorProperties(); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> props.setPrintReqRespLevel("OtroNivel") + ); + + System.out.println(exception.getMessage()); + + assertTrue(exception.getMessage().contains("Valor no permitido")); + assertTrue(exception.getMessage().contains("OtroNivel")); + assertTrue(exception.getMessage().contains("Valores permitidos: BusinessExceptionECS, Exception, Throwable")); + } + + @Test + void shouldMatchBusinessExceptionEcsOnlyWhenConfiguredAsBusinessException() { + Throwable businessException = new BusinessExceptionECS("error"); + Throwable genericException = new RuntimeException("error"); + Throwable throwable = new Throwable("error"); + + assertTrue(PrintOnErrorProperties.matchesConfiguredLevel( + businessException, + ExceptionLevel.BUSINESS_EXCEPTION_ECS)); + assertFalse(PrintOnErrorProperties.matchesConfiguredLevel( + genericException, + ExceptionLevel.BUSINESS_EXCEPTION_ECS)); + assertFalse(PrintOnErrorProperties.matchesConfiguredLevel( + throwable, + ExceptionLevel.BUSINESS_EXCEPTION_ECS)); + } + + @Test + void shouldMatchAnyExceptionWhenConfiguredAsException() { + Throwable businessException = new BusinessExceptionECS("error"); + Throwable genericException = new RuntimeException("error"); + Throwable throwable = new Throwable("error"); + + assertTrue(PrintOnErrorProperties.matchesConfiguredLevel( + businessException, + ExceptionLevel.EXCEPTION)); + assertTrue(PrintOnErrorProperties.matchesConfiguredLevel( + genericException, + ExceptionLevel.EXCEPTION)); + assertFalse(PrintOnErrorProperties.matchesConfiguredLevel( + throwable, + ExceptionLevel.EXCEPTION)); + } + + @Test + void shouldMatchAnyThrowableWhenConfiguredAsThrowable() { + Throwable businessException = new BusinessExceptionECS("error"); + Throwable genericException = new RuntimeException("error"); + Throwable throwable = new Throwable("error"); + + assertTrue(PrintOnErrorProperties.matchesConfiguredLevel( + businessException, + ExceptionLevel.THROWABLE)); + assertTrue(PrintOnErrorProperties.matchesConfiguredLevel( + genericException, + ExceptionLevel.THROWABLE)); + assertTrue(PrintOnErrorProperties.matchesConfiguredLevel( + throwable, + ExceptionLevel.THROWABLE)); + } +} diff --git a/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/SamplingConfigTest.java b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/SamplingConfigTest.java index 9db4aa8..7ad704f 100644 --- a/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/SamplingConfigTest.java +++ b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/SamplingConfigTest.java @@ -53,7 +53,7 @@ void shouldBindPropertiesCorrectlyRules40X() { } @Test - void shouldBindPropertiesCorrectlyRules20X_40X() { + void shouldBindPropertiesCorrectlyRules20xAnd40x() { samplingConfig.setRules20XJson("[{\"uri\":\"/actors/createLegalPerson\",\"responseCode\":\"200\"," + "\"showCount\":2,\"skipCount\":2},{\"uri\":\"/actors/createNaturalPerson\",\"responseCode\":\"200\"," + "\"showCount\":2,\"skipCount\":2}]"); @@ -124,7 +124,7 @@ void shouldNotBindProperties40XRulesWithErrorFormat() { } @Test - void shouldBindEmptyPropertiesCorrectlyRules20X_40X() { + void shouldBindEmptyPropertiesCorrectlyRules20xAnd40x() { samplingConfig.setRules20XJson(""); samplingConfig.setRules40XJson(""); @@ -135,7 +135,7 @@ void shouldBindEmptyPropertiesCorrectlyRules20X_40X() { } @Test - void shouldBindPropertiesNullRules20X_40X() { + void shouldBindPropertiesNullRules20xAnd40x() { samplingConfig.setRules20XJson(null); samplingConfig.setRules40XJson(null); diff --git a/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/managementid/.gitkeep b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/managementid/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngUseCaseTest.java b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngUseCaseTest.java new file mode 100644 index 0000000..47df5b6 --- /dev/null +++ b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngUseCaseTest.java @@ -0,0 +1,89 @@ +package co.com.bancolombia.ecs.infra.config.managementid.application; + +import co.com.bancolombia.ecs.infra.config.managementid.domain.MessageIdRequestProperties; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class MessageIdMngUseCaseTest { + + private static MessageIdRequestProperties propsEnabled() { + MessageIdRequestProperties p = new MessageIdRequestProperties("true"); + p.afterPropertiesSet(); + return p; + } + + private static MessageIdRequestProperties propsDisabled() { + MessageIdRequestProperties p = new MessageIdRequestProperties("false"); + p.afterPropertiesSet(); + return p; + } + + private final MessageIdMngUseCase useCase = new MessageIdMngUseCase(propsEnabled()); + + + @Test + void shouldReturnHeaderValueWhenPresent() { + assertEquals("header-id-123", useCase.resolveFromRequestEnvironment("header-id-123")); + } + + @Test + void shouldGenerateUuidWhenNoHeader() { + assertNotNull(useCase.resolveFromRequestEnvironment(null)); + } + + @Test + void shouldGenerateUuidWhenHeaderIsBlank() { + assertNotNull(useCase.resolveFromRequestEnvironment(" ")); + } + + + @Test + void shouldUseContextMessageIdFirst() { + assertEquals("meta-id", MessageIdMngUseCase.resolveForException( "meta-id")); + } + + @Test + void shouldFallbackToMetaInfoWhenContextIsNull() { + assertEquals("meta-id", MessageIdMngUseCase.resolveForException("meta-id")); + } + + @Test + void shouldGenerateUuidWhenNoSource() { + assertNotNull(MessageIdMngUseCase.resolveForException( null)); + } + + @Test + void shouldGenerateUuidForExceptionWhenFlagIsNull() { + new MessageIdMngUseCase(new MessageIdRequestProperties(null)); + assertNotNull(MessageIdMngUseCase.resolveForException( null)); + } + + + @Test + void shouldGenerateUuidWhenFlagIsNullAndNoHeader() { + MessageIdMngUseCase useCaseNullFlag = new MessageIdMngUseCase(new MessageIdRequestProperties(null)); + assertNotNull(useCaseNullFlag.resolveFromRequestEnvironment(null)); + } + + @Test + void shouldPropagateHeaderWhenFlagIsNullAndHeaderPresent() { + MessageIdMngUseCase useCaseNullFlag = new MessageIdMngUseCase(new MessageIdRequestProperties(null)); + assertEquals("from-client", useCaseNullFlag.resolveFromRequestEnvironment("from-client")); + } + + // ── resolveFromRequest con flag=false ───────────────────────────────────── + + @Test + void shouldReturnNullWhenFlagIsFalseAndNoHeader() { + // flag=false: no genera UUID, solo propaga si el cliente lo envía + MessageIdMngUseCase useCaseFalse = new MessageIdMngUseCase(propsDisabled()); + assertNull(useCaseFalse.resolveFromRequestEnvironment(null)); + } + + @Test + void shouldPropagateHeaderWhenFlagIsFalseAndHeaderPresent() { + MessageIdMngUseCase useCaseFalse = new MessageIdMngUseCase(propsDisabled()); + assertEquals("from-client", useCaseFalse.resolveFromRequestEnvironment("from-client")); + } +} diff --git a/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngValidatorTest.java b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngValidatorTest.java new file mode 100644 index 0000000..fae77b7 --- /dev/null +++ b/ecs-core/src/test/java/co/com/bancolombia/ecs/infra/config/managementid/application/MessageIdMngValidatorTest.java @@ -0,0 +1,51 @@ +package co.com.bancolombia.ecs.infra.config.managementid.application; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.BeanInitializationException; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MessageIdMngValidatorTest { + + private static final String PROPERTY_KEY = + "adapter.ecs.logs.message-id.enable_auto_register_message_id"; + + // ── Valores inválidos ───────────────────────────────────────────────────── + + @ParameterizedTest + @ValueSource(strings = {"1", "0", "yes", "no", "on", "off", "maybe", "2", "si", "verdadero"}) + void shouldThrowWhenValueIsNotStrictlyTrueOrFalse(String invalidValue) { + BeanInitializationException ex = assertThrows( + BeanInitializationException.class, + () -> MessageIdMngValidator.validate(invalidValue, PROPERTY_KEY) + ); + assertTrue(ex.getMessage().contains(invalidValue), + "El mensaje de error debe incluir el valor inválido recibido"); + assertTrue(ex.getMessage().contains("true") && ex.getMessage().contains("false"), + "El mensaje de error debe indicar los valores aceptados"); + } + + // ── Valores válidos ─────────────────────────────────────────────────────── + + @ParameterizedTest + @ValueSource(strings = {"true", "false", "TRUE", "FALSE", "True", "False"}) + void shouldAcceptTrueOrFalseInAnyCase(String validValue) { + assertDoesNotThrow( + () -> MessageIdMngValidator.validate(validValue, PROPERTY_KEY), + "El valor '" + validValue + "' debe ser aceptado sin excepción" + ); + } + + @Test + void shouldAcceptNullValue() { + // null = propiedad no configurada → feature desactivada, no debe lanzar excepción + assertDoesNotThrow( + () -> MessageIdMngValidator.validate(null, PROPERTY_KEY), + "null debe ser aceptado (feature desactivada)" + ); + } +} diff --git a/ecs-core/src/test/resources/application-test.yaml b/ecs-core/src/test/resources/application-test.yaml index 8d94ac9..1b2e7fa 100644 --- a/ecs-core/src/test/resources/application-test.yaml +++ b/ecs-core/src/test/resources/application-test.yaml @@ -64,3 +64,6 @@ adapter: "enabled": true } ]' + print-on-error: + print-req-resp: + print-req-resp-level: diff --git a/ecs-imperative/build.gradle b/ecs-imperative/build.gradle index f00750f..e3741c6 100644 --- a/ecs-imperative/build.gradle +++ b/ecs-imperative/build.gradle @@ -17,7 +17,7 @@ dependencies { testImplementation "org.springframework.boot:spring-boot-starter-test:${springBootVersion}" - implementation "com.fasterxml.jackson.core:jackson-databind" + implementation "com.fasterxml.jackson.core:jackson-databind:${fasterxmlJacksonCoreVersion}" } ext { diff --git a/ecs-imperative/src/main/java/co/com/bancolombia/ecs/application/ImperativeLogsConfiguration.java b/ecs-imperative/src/main/java/co/com/bancolombia/ecs/application/ImperativeLogsConfiguration.java index 5772bf9..94a1e18 100644 --- a/ecs-imperative/src/main/java/co/com/bancolombia/ecs/application/ImperativeLogsConfiguration.java +++ b/ecs-imperative/src/main/java/co/com/bancolombia/ecs/application/ImperativeLogsConfiguration.java @@ -2,6 +2,8 @@ import co.com.bancolombia.ecs.application.filter.ImperativeLogsHandler; import co.com.bancolombia.ecs.infra.config.EcsPropertiesConfig; +import co.com.bancolombia.ecs.infra.config.PrintOnErrorProperties; +import co.com.bancolombia.ecs.infra.config.managementid.application.MessageIdMngUseCase; import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveRequestProperties; import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveResponseProperties; import co.com.bancolombia.ecs.infra.config.service.ServiceProperties; @@ -17,11 +19,12 @@ public class ImperativeLogsConfiguration { @Bean @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) @ConditionalOnClass(Filter.class) - public ImperativeLogsHandler imperativeLogsHandler( - ServiceProperties serviceProps, - SensitiveRequestProperties requestProps, - SensitiveResponseProperties responseProps) { - EcsPropertiesConfig config = new EcsPropertiesConfig(serviceProps, requestProps, responseProps); - return new ImperativeLogsHandler(config); + public ImperativeLogsHandler imperativeLogsHandler(ServiceProperties serviceProps, + SensitiveRequestProperties requestProps, + SensitiveResponseProperties responseProps, + PrintOnErrorProperties printOnErrorProperties, + MessageIdMngUseCase messageIdMngUseCase) { + var config = new EcsPropertiesConfig(serviceProps, requestProps, responseProps, printOnErrorProperties); + return new ImperativeLogsHandler(config, messageIdMngUseCase); } } diff --git a/ecs-imperative/src/main/java/co/com/bancolombia/ecs/application/filter/ImperativeLogsHandler.java b/ecs-imperative/src/main/java/co/com/bancolombia/ecs/application/filter/ImperativeLogsHandler.java index c1f73dc..57b2f58 100644 --- a/ecs-imperative/src/main/java/co/com/bancolombia/ecs/application/filter/ImperativeLogsHandler.java +++ b/ecs-imperative/src/main/java/co/com/bancolombia/ecs/application/filter/ImperativeLogsHandler.java @@ -1,12 +1,14 @@ package co.com.bancolombia.ecs.application.filter; +import co.com.bancolombia.ecs.domain.model.LogRecord; +import co.com.bancolombia.ecs.infra.shared.common.domain.ContextECS; +import co.com.bancolombia.ecs.infra.config.managementid.application.MessageIdMngUseCase; +import co.com.bancolombia.ecs.domain.model.ExceptionLevel; import co.com.bancolombia.ecs.helpers.DataSanitizer; +import co.com.bancolombia.ecs.helpers.HandlerHelper; import co.com.bancolombia.ecs.infra.EcsImperativeLogger; import co.com.bancolombia.ecs.infra.config.EcsPropertiesConfig; -import co.com.bancolombia.ecs.model.management.BusinessExceptionECS; import co.com.bancolombia.ecs.model.request.LogRequest; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -16,6 +18,7 @@ import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.util.ContentCachingRequestWrapper; import org.springframework.web.util.ContentCachingResponseWrapper; @@ -23,75 +26,96 @@ import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; @Order(Ordered.HIGHEST_PRECEDENCE) public class ImperativeLogsHandler extends OncePerRequestFilter { private static final int MAX_PAYLOAD_SIZE = 1024 * 1024; - private static final Set CONSUMER_ACRONYMS = Set.of("consumer-acronym", "code", "channel"); private static final String HANDLED_EXCEPTION_PROPERTY = "handledException"; - private static final int MIN_REQUEST_ERROR_CODE = 400; - private static final String MESSAGE_ID = "message-id"; - private static final String RAW_BODY = "raw"; - private static final ObjectMapper objectMapper = new ObjectMapper(); private final EcsPropertiesConfig ecsPropertiesConfig; private final Boolean showRequestLogs; private final Boolean showResponseLogs; + private final boolean printReqRespOnErrorOnlyActive; + private final ExceptionLevel printReqRespLevel; + private final MessageIdMngUseCase messageIdMngUseCase; - public ImperativeLogsHandler(EcsPropertiesConfig ecsPropertiesConfig) { + public ImperativeLogsHandler(EcsPropertiesConfig ecsPropertiesConfig, + MessageIdMngUseCase messageIdMngUseCase) { this.ecsPropertiesConfig = ecsPropertiesConfig; this.showRequestLogs = ecsPropertiesConfig.getShowRequestLogs(); this.showResponseLogs = ecsPropertiesConfig.getShowResponseLogs(); - } - - private static void setConsumer(LogRequest logRequest, Map headers) { - String consumer = CONSUMER_ACRONYMS.stream() - .map(headers::get) - .filter(Objects::nonNull) - .findFirst() - .orElse(null); - logRequest.setConsumer(consumer); + this.printReqRespOnErrorOnlyActive = Boolean.TRUE.equals(ecsPropertiesConfig.getPrintReqRespOnErrorOnly()); + this.printReqRespLevel = ecsPropertiesConfig.getPrintReqRespLevels(); + this.messageIdMngUseCase = messageIdMngUseCase; } @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain) throws ServletException, IOException { + String resolvedMessageId = resolveMessageId(request); + if (resolvedMessageId != null) { + ContextECS.setMessageId(resolvedMessageId); + } - if (Boolean.FALSE.equals(showRequestLogs) && Boolean.FALSE.equals(showResponseLogs)) { - chain.doFilter(request, response); + if (!printReqRespOnErrorOnlyActive + && Boolean.FALSE.equals(showRequestLogs) + && Boolean.FALSE.equals(showResponseLogs)) { + doFilterWithClear(chain, request, response); return; } String path = request.getRequestURI(); - if (ecsPropertiesConfig.getExcludedPaths().stream().anyMatch(path::startsWith)) { - chain.doFilter(request, response); + if (HandlerHelper.isPathExcluded(path, ecsPropertiesConfig.getExcludedPaths())) { + doFilterWithClear(chain, request, response); return; } var wrappedRequest = new ContentCachingRequestWrapper(request, MAX_PAYLOAD_SIZE); var wrappedResponse = new ContentCachingResponseWrapper(response); - try { chain.doFilter(wrappedRequest, wrappedResponse); } finally { - int status = wrappedResponse.getStatus(); - if (status >= MIN_REQUEST_ERROR_CODE) { - Throwable ex = (Throwable) wrappedRequest.getAttribute(HANDLED_EXCEPTION_PROPERTY); - if (ex != null) { - logError(ex, wrappedRequest); - } else { - logRequest(wrappedRequest, wrappedResponse); - } - } else { - logRequest(wrappedRequest, wrappedResponse); - } + handleRequestOrError(wrappedRequest, wrappedResponse); wrappedResponse.copyBodyToResponse(); + ContextECS.clear(); + } + } + + private String resolveMessageId(HttpServletRequest request) { + Map> rawHeadersMap = Collections.list(request.getHeaderNames()).stream() + .filter(name -> request.getHeader(name) != null) + .collect(Collectors.toMap(name -> name, name -> List.of(request.getHeader(name)))); + return messageIdMngUseCase.resolveFromHeaders( + rawHeadersMap.entrySet(), ecsPropertiesConfig.getAllowRequestHeaders()); + } + + private void doFilterWithClear(FilterChain chain, HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + try { + chain.doFilter(request, response); + } finally { + ContextECS.clear(); + } + } + + private void handleRequestOrError(ContentCachingRequestWrapper wrappedRequest, + ContentCachingResponseWrapper wrappedResponse) { + int status = wrappedResponse.getStatus(); + if (HandlerHelper.isErrorStatusCode(status)) { + Throwable ex = (Throwable) wrappedRequest.getAttribute(HANDLED_EXCEPTION_PROPERTY); + logError(ex != null ? ex : buildStatusException(status), wrappedRequest); + } else { + logRequest(wrappedRequest, wrappedResponse); } } private void logRequest(ContentCachingRequestWrapper request, ContentCachingResponseWrapper response) { + if (printReqRespOnErrorOnlyActive) { + return; + } + var logRequest = new LogRequest(); setRequestParameters(request, logRequest); @@ -107,14 +131,18 @@ private void logRequest(ContentCachingRequestWrapper request, ContentCachingResp } private void logError(Throwable error, ContentCachingRequestWrapper request) { + if (printReqRespOnErrorOnlyActive && HandlerHelper.errorDoesntMatchLevel(error, printReqRespLevel)) { + return; + } + var logRequest = new LogRequest(); setRequestParameters(request, logRequest); sensitiveRequestBody(request, logRequest); // Response - int status = resolveHttpStatus(error).value(); - logRequest.setResponseCode(String.valueOf(status)); - logRequest.setResponseResult(resolveHttpStatus(error).getReasonPhrase()); + var status = HandlerHelper.resolveHttpStatus(error); + logRequest.setResponseCode(String.valueOf(status.value())); + logRequest.setResponseResult(status.getReasonPhrase()); logRequest.setError(error); EcsImperativeLogger.build(logRequest, ecsPropertiesConfig.getServiceName()); @@ -131,7 +159,7 @@ private void sensitiveResponseBody(ContentCachingResponseWrapper response, LogRe ecsPropertiesConfig.getSensitiveResponseFields(), ecsPropertiesConfig.getSensitiveResponsePatterns(), ecsPropertiesConfig.getSensitiveResponseReplacement()); - logRequest.setResponseBody(parseToMap(sanitizedResponse)); + logRequest.setResponseBody(HandlerHelper.parseToMap(sanitizedResponse)); } private void sensitiveRequestBody(ContentCachingRequestWrapper request, LogRequest logRequest) { @@ -141,35 +169,31 @@ private void sensitiveRequestBody(ContentCachingRequestWrapper request, LogReque ecsPropertiesConfig.getSensitiveRequestFields(), ecsPropertiesConfig.getSensitiveRequestPatterns(), ecsPropertiesConfig.getSensitiveRequestReplacement()); - logRequest.setRequestBody(parseToMap(sanitizedRequest)); + logRequest.setRequestBody(HandlerHelper.parseToMap(sanitizedRequest)); } private void setRequestParameters(ContentCachingRequestWrapper request, LogRequest logRequest) { logRequest.setMethod(request.getMethod()); logRequest.setUrl(request.getRequestURI()); Set>> requestHeaders = Collections.list(request.getHeaderNames()).stream() - .map(name -> Map.entry(name, List.of(request.getHeader(name).toLowerCase()))) + .flatMap(name -> { + String headerValue = request.getHeader(name); + return headerValue == null + ? Stream.empty() + : Stream.of(Map.entry(name, List.of(headerValue.toLowerCase()))); + }) .collect(Collectors.toSet()); Map headers = DataSanitizer.sanitizeHeaders(requestHeaders, ecsPropertiesConfig.getAllowRequestHeaders()); - setConsumer(logRequest, headers); - logRequest.setMessageId(headers.get(MESSAGE_ID)); + HandlerHelper.setConsumer(logRequest, headers); + logRequest.setMessageId(headers.get(LogRecord.MESSAGE_ID)); logRequest.setHeaders(headers); } - private HttpStatus resolveHttpStatus(Throwable ex) { - if (ex instanceof BusinessExceptionECS businessExceptionECS) { - return HttpStatus.valueOf(businessExceptionECS.getConstantBusinessException().getStatus()); - } - return HttpStatus.INTERNAL_SERVER_ERROR; - } - - private Map parseToMap(String body) { - try { - return objectMapper.readValue(body, Map.class); - } catch (JsonProcessingException e) { - return Map.of(RAW_BODY, body); - } + private ResponseStatusException buildStatusException(int statusCode) { + HttpStatus status = HandlerHelper.resolveStatusCode(statusCode); + HttpStatus resolvedStatus = status != null ? status:HttpStatus.INTERNAL_SERVER_ERROR; + return new ResponseStatusException(resolvedStatus, resolvedStatus.getReasonPhrase()); } -} \ No newline at end of file +} diff --git a/ecs-imperative/src/test/java/co/com/bancolombia/ecs/application/ImperativeLogsConfigurationTest.java b/ecs-imperative/src/test/java/co/com/bancolombia/ecs/application/ImperativeLogsConfigurationTest.java new file mode 100644 index 0000000..2052f47 --- /dev/null +++ b/ecs-imperative/src/test/java/co/com/bancolombia/ecs/application/ImperativeLogsConfigurationTest.java @@ -0,0 +1,37 @@ +package co.com.bancolombia.ecs.application; + +import co.com.bancolombia.ecs.application.filter.ImperativeLogsHandler; +import co.com.bancolombia.ecs.infra.config.PrintOnErrorProperties; +import co.com.bancolombia.ecs.infra.config.managementid.application.MessageIdMngUseCase; +import co.com.bancolombia.ecs.infra.config.managementid.domain.MessageIdRequestProperties; +import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveRequestProperties; +import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveResponseProperties; +import co.com.bancolombia.ecs.infra.config.service.ServiceProperties; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class ImperativeLogsConfigurationTest { + + @Test + void shouldCreateImperativeLogsHandlerBean() { + ServiceProperties serviceProperties = new ServiceProperties(); + serviceProperties.setName("test-service"); + + SensitiveRequestProperties requestProperties = new SensitiveRequestProperties(); + requestProperties.setShow(Boolean.FALSE); + + SensitiveResponseProperties responseProperties = new SensitiveResponseProperties(); + responseProperties.setShow(Boolean.FALSE); + + PrintOnErrorProperties printOnErrorProperties = new PrintOnErrorProperties(); + + MessageIdMngUseCase messageIdMngUseCase = new MessageIdMngUseCase(new MessageIdRequestProperties(null)); + + ImperativeLogsConfiguration configuration = new ImperativeLogsConfiguration(); + ImperativeLogsHandler handler = configuration.imperativeLogsHandler( + serviceProperties, requestProperties, responseProperties, printOnErrorProperties, messageIdMngUseCase); + + assertNotNull(handler); + } +} diff --git a/ecs-imperative/src/test/java/co/com/bancolombia/ecs/application/ImperativeLogsHandlerTest.java b/ecs-imperative/src/test/java/co/com/bancolombia/ecs/application/ImperativeLogsHandlerTest.java index 5f0df40..0348f58 100644 --- a/ecs-imperative/src/test/java/co/com/bancolombia/ecs/application/ImperativeLogsHandlerTest.java +++ b/ecs-imperative/src/test/java/co/com/bancolombia/ecs/application/ImperativeLogsHandlerTest.java @@ -1,30 +1,54 @@ package co.com.bancolombia.ecs.application; import co.com.bancolombia.ecs.application.filter.ImperativeLogsHandler; +import co.com.bancolombia.ecs.domain.model.ExceptionLevel; import co.com.bancolombia.ecs.infra.config.EcsPropertiesConfig; +import co.com.bancolombia.ecs.infra.config.PrintOnErrorProperties; +import co.com.bancolombia.ecs.infra.config.managementid.application.MessageIdMngUseCase; import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveRequestProperties; import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveResponseProperties; import co.com.bancolombia.ecs.infra.config.service.ServiceProperties; import co.com.bancolombia.ecs.model.management.BusinessExceptionECS; +import co.com.bancolombia.ecs.model.management.ErrorManagement; +import co.com.bancolombia.ecs.model.request.LogRequest; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.util.ContentCachingRequestWrapper; import org.springframework.web.util.ContentCachingResponseWrapper; import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -44,9 +68,15 @@ class ImperativeLogsHandlerTest { @Mock private EcsPropertiesConfig ecsPropertiesConfig; + @Mock + private MessageIdMngUseCase messageIdMngUseCase; + @Mock private FilterChain filterChain; + @Mock + private PrintOnErrorProperties printOnErrorProperties; + @InjectMocks private ImperativeLogsHandler imperativeLogsHandler; @@ -63,27 +93,26 @@ void setUp() { mockResponse = new MockHttpServletResponse(); wrappedRequest = new ContentCachingRequestWrapper(mockRequest, MAX_PAYLOAD_SIZE); wrappedResponse = new ContentCachingResponseWrapper(mockResponse); - imperativeLogsHandler = new ImperativeLogsHandler(ecsPropertiesConfig); + imperativeLogsHandler = new ImperativeLogsHandler(ecsPropertiesConfig, messageIdMngUseCase); } - @Test - void testShouldSkipFilterForExcludedPath() throws IOException, ServletException { - mocksPropertiesConfig(true, true); - mockRequest.setRequestURI("/actuator/health/check"); + @ParameterizedTest + @MethodSource("pathShowRequestShowResponseCases") + void testShouldSkipFilter(String path, boolean showRequest, boolean showResponse) + throws ServletException, IOException { + mocksPropertiesConfig(showRequest, showResponse); + mockRequest.setRequestURI(path); imperativeLogsHandler.doFilter(mockRequest, mockResponse, filterChain); verify(filterChain).doFilter(any(), any()); } - - @Test - void testShouldSkipFilterWhenLogsAreDisabled() throws IOException, ServletException { - mocksPropertiesConfig(false, false); - mockRequest.setRequestURI("/api/test"); - - imperativeLogsHandler.doFilter(mockRequest, mockResponse, filterChain); - - verify(filterChain).doFilter(any(), any()); + private static Stream pathShowRequestShowResponseCases() { + return Stream.of( + Arguments.of("/actuator/health/check", true, true), + Arguments.of("/", true, true), + Arguments.of("/api/test", false, false) + ); } @Test @@ -157,20 +186,36 @@ void testShouldLogRequestOnErrorStatusWithException() throws IOException, Servle } @Test - void testShouldLogRequestOnErrorStatusWithoutException() throws IOException, ServletException { + void testShouldLogStatusOnlyErrorResponseThroughErrorPath() throws IOException, ServletException { mocksPropertiesConfig(true, true); mockRequest.setRequestURI("/api/test"); mockRequest.setMethod("GET"); mockRequest.addHeader("message-id", "12345"); mockRequest.setContent("{\"data\": \"test\"}".getBytes()); - wrappedResponse.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); wrappedResponse.getWriter().write("{\"error\": \"Internal error\"}"); - - assertDoesNotThrow(() -> - imperativeLogsHandler.doFilter(wrappedRequest, wrappedResponse, filterChain)); + AtomicReference capturedRequest = new AtomicReference<>(); + + try (MockedStatic mockedLogger = + Mockito.mockStatic(co.com.bancolombia.ecs.infra.EcsImperativeLogger.class)) { + mockedLogger.when(() -> co.com.bancolombia.ecs.infra.EcsImperativeLogger.build( + Mockito.any(LogRequest.class), Mockito.anyString())) + .thenAnswer(invocation -> { + capturedRequest.set(invocation.getArgument(0)); + return null; + }); + + assertDoesNotThrow(() -> + imperativeLogsHandler.doFilter(wrappedRequest, wrappedResponse, filterChain)); + } verify(filterChain).doFilter(any(), any()); + assertNotNull(capturedRequest.get()); + assertEquals("500", capturedRequest.get().getResponseCode()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), capturedRequest.get().getResponseResult()); + ResponseStatusException loggedException = + assertInstanceOf(ResponseStatusException.class, capturedRequest.get().getError()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, loggedException.getStatusCode()); } @Test @@ -193,6 +238,168 @@ void testShouldLogRequestOnErrorStatusWithUnhandledException() throws IOExceptio verify(filterChain).doFilter(any(), any()); } + @ParameterizedTest + @ValueSource(strings = {"Exception", "BusinessExceptionECS"}) + @NullSource + void testShouldHandlePrintOnErrorDifferentLevels(String level) throws ServletException, IOException { + mocksPropertiesConfig(false, false, true, level, "/actuator"); + mockRequest.setRequestURI("/api/test"); + mockRequest.setMethod("POST"); + mockRequest.addHeader("message-id", "12345"); + mockRequest.setContent("{\"data\":\"ok\"}".getBytes()); + wrappedResponse.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); + wrappedRequest.setAttribute("handledException", new RuntimeException("boom")); + + assertDoesNotThrow(() -> + imperativeLogsHandler.doFilter(wrappedRequest, wrappedResponse, filterChain)); + verify(filterChain).doFilter(any(), any()); + } + + @Test + void testShouldLogCompletedForbiddenResponseInPrintOnErrorMode() throws IOException, ServletException { + mocksPropertiesConfig(false, false, true, "Exception", "/actuator"); + mockRequest.setRequestURI("/api/cors"); + mockRequest.setMethod("OPTIONS"); + mockRequest.addHeader("message-id", "12345"); + mockRequest.setContent("{\"data\":\"ok\"}".getBytes()); + wrappedResponse.setStatus(HttpStatus.FORBIDDEN.value()); + + AtomicReference capturedRequest = new AtomicReference<>(); + + try (MockedStatic mockedLogger = + Mockito.mockStatic(co.com.bancolombia.ecs.infra.EcsImperativeLogger.class)) { + mockedLogger.when(() -> co.com.bancolombia.ecs.infra.EcsImperativeLogger.build( + Mockito.any(LogRequest.class), Mockito.anyString())) + .thenAnswer(invocation -> { + capturedRequest.set(invocation.getArgument(0)); + return null; + }); + + assertDoesNotThrow(() -> + imperativeLogsHandler.doFilter(wrappedRequest, wrappedResponse, filterChain)); + } + + verify(filterChain).doFilter(any(), any()); + assertNotNull(capturedRequest.get()); + assertEquals("403", capturedRequest.get().getResponseCode()); + assertEquals(HttpStatus.FORBIDDEN.getReasonPhrase(), capturedRequest.get().getResponseResult()); + ResponseStatusException loggedException = + assertInstanceOf(ResponseStatusException.class, capturedRequest.get().getError()); + assertEquals(HttpStatus.FORBIDDEN, loggedException.getStatusCode()); + } + + @Test + void testShouldIgnoreHeadersWithNullValue() { + mocksPropertiesConfig(true, false); + AtomicReference capturedRequest = new AtomicReference<>(); + MockHttpServletRequest requestWithNullHeader = Mockito.spy(new MockHttpServletRequest()); + doAnswer(inv -> Collections.enumeration(List.of("message-id", "x-null"))).when(requestWithNullHeader).getHeaderNames(); + doReturn("12345").when(requestWithNullHeader).getHeader("message-id"); + doReturn(null).when(requestWithNullHeader).getHeader("x-null"); + requestWithNullHeader.setRequestURI("/api/test"); + requestWithNullHeader.setMethod("GET"); + requestWithNullHeader.setContent("{\"data\": \"test\"}".getBytes()); + + try (MockedStatic mockedLogger = + Mockito.mockStatic(co.com.bancolombia.ecs.infra.EcsImperativeLogger.class)) { + mockedLogger.when(() -> co.com.bancolombia.ecs.infra.EcsImperativeLogger.build( + Mockito.any(LogRequest.class), Mockito.anyString())) + .thenAnswer(invocation -> { + capturedRequest.set(invocation.getArgument(0)); + return null; + }); + + assertDoesNotThrow(() -> + imperativeLogsHandler.doFilter(requestWithNullHeader, new MockHttpServletResponse(), filterChain)); + } + + assertNotNull(capturedRequest.get()); + assertFalse(capturedRequest.get().getHeaders().containsKey("x-null")); + assertEquals("12345", capturedRequest.get().getHeaders().get("message-id")); + } + + @Test + void testShouldPreserveResponseStatusExceptionStatusWhenLoggingErrors() { + mocksPropertiesConfig(true, false); + mockRequest.setRequestURI("/api/test"); + mockRequest.setMethod("OPTIONS"); + mockRequest.addHeader("message-id", "12345"); + wrappedResponse.setStatus(HttpStatus.FORBIDDEN.value()); + + AtomicReference capturedRequest = new AtomicReference<>(); + ResponseStatusException exception = + new ResponseStatusException(HttpStatus.FORBIDDEN, "Invalid CORS request"); + wrappedRequest.setAttribute("handledException", exception); + + try (MockedStatic mockedLogger = + Mockito.mockStatic(co.com.bancolombia.ecs.infra.EcsImperativeLogger.class)) { + mockedLogger.when(() -> co.com.bancolombia.ecs.infra.EcsImperativeLogger.build( + Mockito.any(LogRequest.class), Mockito.anyString())) + .thenAnswer(invocation -> { + capturedRequest.set(invocation.getArgument(0)); + return null; + }); + + assertDoesNotThrow(() -> + imperativeLogsHandler.doFilter(wrappedRequest, wrappedResponse, filterChain)); + } + + assertNotNull(capturedRequest.get()); + assertEquals("403", capturedRequest.get().getResponseCode()); + assertEquals(HttpStatus.FORBIDDEN.getReasonPhrase(), capturedRequest.get().getResponseResult()); + assertSame(exception, capturedRequest.get().getError()); + } + + @Test + void testShouldFallbackToInternalServerErrorWhenBusinessStatusIsNull() { + mocksPropertiesConfig(true, false); + mockRequest.setRequestURI("/api/test"); + mockRequest.setMethod("GET"); + mockRequest.addHeader("message-id", "12345"); + wrappedResponse.setStatus(HttpStatus.BAD_REQUEST.value()); + + AtomicReference capturedRequest = new AtomicReference<>(); + BusinessExceptionECS exception = new BusinessExceptionECS(errorManagementWithNullStatus()); + wrappedRequest.setAttribute("handledException", exception); + + try (MockedStatic mockedLogger = + Mockito.mockStatic(co.com.bancolombia.ecs.infra.EcsImperativeLogger.class)) { + mockedLogger.when(() -> co.com.bancolombia.ecs.infra.EcsImperativeLogger.build( + Mockito.any(LogRequest.class), Mockito.anyString())) + .thenAnswer(invocation -> { + capturedRequest.set(invocation.getArgument(0)); + return null; + }); + + assertDoesNotThrow(() -> + imperativeLogsHandler.doFilter(wrappedRequest, wrappedResponse, filterChain)); + } + + assertNotNull(capturedRequest.get()); + assertEquals("500", capturedRequest.get().getResponseCode()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), capturedRequest.get().getResponseResult()); + assertSame(exception, capturedRequest.get().getError()); + } + + @Test + void testShouldParseValidJsonBodies() { + mocksPropertiesConfig(true, true); + mockRequest.setRequestURI("/api/test"); + mockRequest.setMethod("POST"); + mockRequest.addHeader("message-id", "12345"); + mockRequest.setContent("{\"data\":\"test\"}".getBytes()); + + FilterChain parsingChain = (request, response) -> { + ContentCachingRequestWrapper requestWrapper = (ContentCachingRequestWrapper) request; + requestWrapper.getInputStream().readAllBytes(); + ContentCachingResponseWrapper responseWrapper = (ContentCachingResponseWrapper) response; + responseWrapper.setStatus(HttpStatus.OK.value()); + responseWrapper.getWriter().write("{\"result\":\"success\"}"); + }; + + assertDoesNotThrow(() -> imperativeLogsHandler.doFilter(mockRequest, mockResponse, parsingChain)); + } + private void testFilter() throws IOException, ServletException { mockRequest.setRequestURI("/api/test"); @@ -210,29 +417,71 @@ private void testFilter() throws IOException, ServletException { } private void mocksPropertiesConfig(boolean showRequest, boolean showResponse) { + mocksPropertiesConfig(showRequest, showResponse, null, null, "/actuator"); + } + + private void mocksPropertiesConfig(boolean showRequest, boolean showResponse, + Boolean printOnError, String level, + String excludedPaths) { when(serviceProperties.getName()).thenReturn("test-service"); when(sensitiveRequestProperties.getShow()).thenReturn(showRequest); - if (showRequest) { + boolean printOnErrorActive = Boolean.TRUE.equals(printOnError); + if (showRequest || printOnErrorActive) { when(sensitiveRequestProperties.getDelimiter()).thenReturn("\\|"); when(sensitiveRequestProperties.getAllowHeaders()).thenReturn("message-id|code|channel|acronym-channel"); when(sensitiveRequestProperties.getFields()).thenReturn(""); - when(sensitiveRequestProperties.getExcludedPaths()).thenReturn("/actuator"); + when(sensitiveRequestProperties.getExcludedPaths()).thenReturn(excludedPaths); when(sensitiveRequestProperties.getPatterns()).thenReturn(""); when(sensitiveRequestProperties.getReplacement()).thenReturn("*****"); } when(sensitiveResponseProperties.getShow()).thenReturn(showResponse); - - if (showResponse) { + if (showResponse || printOnErrorActive) { when(sensitiveResponseProperties.getDelimiter()).thenReturn("\\|"); when(sensitiveResponseProperties.getFields()).thenReturn(""); when(sensitiveResponseProperties.getPatterns()).thenReturn(""); when(sensitiveResponseProperties.getReplacement()).thenReturn("*****"); } - ecsPropertiesConfig = new EcsPropertiesConfig(serviceProperties, sensitiveRequestProperties, - sensitiveResponseProperties); - imperativeLogsHandler = new ImperativeLogsHandler(ecsPropertiesConfig); + when(printOnErrorProperties.getPrintReqResp()).thenReturn(printOnError); + when(printOnErrorProperties.getPrintReqRespLevel()).thenReturn( + ExceptionLevel.toExceptionLevelIgnoreCase(level)); + + ecsPropertiesConfig = new EcsPropertiesConfig( + serviceProperties, + sensitiveRequestProperties, + sensitiveResponseProperties, + printOnErrorProperties); + imperativeLogsHandler = new ImperativeLogsHandler(ecsPropertiesConfig, messageIdMngUseCase); + } + + private ErrorManagement errorManagementWithNullStatus() { + return new ErrorManagement() { + @Override + public Integer getStatus() { + return null; + } + + @Override + public String getMessage() { + return "Invalid CORS request"; + } + + @Override + public String getErrorCode() { + return "CORS-403"; + } + + @Override + public String getInternalMessage() { + return "CORS validation failed"; + } + + @Override + public String getLogCode() { + return "CORS-403-00"; + } + }; } } \ No newline at end of file diff --git a/ecs-model/src/main/java/co/com/bancolombia/ecs/model/management/BusinessExceptionECS.java b/ecs-model/src/main/java/co/com/bancolombia/ecs/model/management/BusinessExceptionECS.java index a6b0ded..7a7fe20 100644 --- a/ecs-model/src/main/java/co/com/bancolombia/ecs/model/management/BusinessExceptionECS.java +++ b/ecs-model/src/main/java/co/com/bancolombia/ecs/model/management/BusinessExceptionECS.java @@ -45,7 +45,7 @@ public BusinessExceptionECS(ErrorManagement message, MetaInfo metaInfo) { super(validateMessage(message).getLogCode()); this.constantBusinessException = validateMessage(message); this.optionalInfo = fillMap(EMPTY_VALUE); - this.metaInfo = metaInfo; + this.metaInfo = (metaInfo != null) ? metaInfo : MetaInfo.builder().build(); } public BusinessExceptionECS(ErrorManagement message, Map optionalInfo) { diff --git a/ecs-model/src/test/java/co/com/bancolombia/ecs/model/management/BusinessExceptionECSTest.java b/ecs-model/src/test/java/co/com/bancolombia/ecs/model/management/BusinessExceptionECSTest.java index 007cb37..804cacf 100644 --- a/ecs-model/src/test/java/co/com/bancolombia/ecs/model/management/BusinessExceptionECSTest.java +++ b/ecs-model/src/test/java/co/com/bancolombia/ecs/model/management/BusinessExceptionECSTest.java @@ -162,6 +162,19 @@ void testConstructorWithNullMetaInfoReturnsGenerated() { assertNotNull(ex.getConstantBusinessException().getLogCode()); } + @Test + void testConstructorWithNullMetaInfoInMetaConstructorReturnsGenerated() { + ErrorManagement err = ErrorManagement.DEFAULT_EXCEPTION; + BusinessExceptionECS ex = new BusinessExceptionECS(err, (BusinessExceptionECS.MetaInfo) null); + assertNotNull(ex.getMetaInfo()); + assertNotNull(ex.getMetaInfo().getMessageId()); + assertNotNull(ex.getConstantBusinessException().getMessage()); + assertNotNull(ex.getConstantBusinessException().getErrorCode()); + assertNotNull(ex.getConstantBusinessException().getInternalMessage()); + assertNotNull(ex.getConstantBusinessException().getStatus()); + assertNotNull(ex.getConstantBusinessException().getLogCode()); + } + @Test void testMetaInfoBuilderCreatesUniqueIds() { String id1 = BusinessExceptionECS.MetaInfo.builder().build().getMessageId(); @@ -169,4 +182,4 @@ void testMetaInfoBuilderCreatesUniqueIds() { assertNotEquals(id1, id2); } -} \ No newline at end of file +} diff --git a/ecs-reactive/build.gradle b/ecs-reactive/build.gradle index f0da969..df61cb1 100644 --- a/ecs-reactive/build.gradle +++ b/ecs-reactive/build.gradle @@ -19,7 +19,7 @@ dependencies { implementation "io.projectreactor:reactor-core:${reactorVersion}" testImplementation "io.projectreactor:reactor-test:${reactorVersion}" - implementation "com.fasterxml.jackson.core:jackson-databind" + implementation "com.fasterxml.jackson.core:jackson-databind:${fasterxmlJacksonCoreVersion}" } ext { diff --git a/ecs-reactive/src/main/java/co/com/bancolombia/ecs/application/ReactiveLogsConfiguration.java b/ecs-reactive/src/main/java/co/com/bancolombia/ecs/application/ReactiveLogsConfiguration.java index d110791..3f2eb18 100644 --- a/ecs-reactive/src/main/java/co/com/bancolombia/ecs/application/ReactiveLogsConfiguration.java +++ b/ecs-reactive/src/main/java/co/com/bancolombia/ecs/application/ReactiveLogsConfiguration.java @@ -1,7 +1,10 @@ package co.com.bancolombia.ecs.application; import co.com.bancolombia.ecs.application.filter.ReactiveLogsHandler; +import co.com.bancolombia.ecs.application.handler.EcsErrorContextHandler; import co.com.bancolombia.ecs.infra.config.EcsPropertiesConfig; +import co.com.bancolombia.ecs.infra.config.PrintOnErrorProperties; +import co.com.bancolombia.ecs.infra.config.managementid.application.MessageIdMngUseCase; import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveRequestProperties; import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveResponseProperties; import co.com.bancolombia.ecs.infra.config.service.ServiceProperties; @@ -17,11 +20,19 @@ public class ReactiveLogsConfiguration { @Bean @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) @ConditionalOnClass(WebFilter.class) - public ReactiveLogsHandler reactiveLogsHandler( - ServiceProperties serviceProps, - SensitiveRequestProperties requestProps, - SensitiveResponseProperties responseProps) { - EcsPropertiesConfig config = new EcsPropertiesConfig(serviceProps, requestProps, responseProps); - return new ReactiveLogsHandler(config); + public ReactiveLogsHandler reactiveLogsHandler(ServiceProperties serviceProps, + SensitiveRequestProperties requestProps, + SensitiveResponseProperties responseProps, + PrintOnErrorProperties printOnErrorProperties, + MessageIdMngUseCase messageIdMngUseCase) { + var config = new EcsPropertiesConfig( + serviceProps, requestProps, responseProps, printOnErrorProperties); + return new ReactiveLogsHandler(config, messageIdMngUseCase); + } + + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + public EcsErrorContextHandler ecsErrorContextHandler() { + return new EcsErrorContextHandler(); } } diff --git a/ecs-reactive/src/main/java/co/com/bancolombia/ecs/application/filter/ReactiveLogsHandler.java b/ecs-reactive/src/main/java/co/com/bancolombia/ecs/application/filter/ReactiveLogsHandler.java index 51f9046..747c088 100644 --- a/ecs-reactive/src/main/java/co/com/bancolombia/ecs/application/filter/ReactiveLogsHandler.java +++ b/ecs-reactive/src/main/java/co/com/bancolombia/ecs/application/filter/ReactiveLogsHandler.java @@ -1,17 +1,17 @@ package co.com.bancolombia.ecs.application.filter; +import co.com.bancolombia.ecs.domain.model.LogRecord; +import co.com.bancolombia.ecs.infra.shared.common.domain.ContextECS; +import co.com.bancolombia.ecs.infra.config.managementid.application.MessageIdMngUseCase; +import co.com.bancolombia.ecs.domain.model.ExceptionLevel; import co.com.bancolombia.ecs.helpers.DataSanitizer; +import co.com.bancolombia.ecs.helpers.HandlerHelper; import co.com.bancolombia.ecs.infra.EcsReactiveLogger; import co.com.bancolombia.ecs.infra.config.EcsPropertiesConfig; import co.com.bancolombia.ecs.infra.config.RequestLoggingDecorator; import co.com.bancolombia.ecs.infra.config.ResponseLoggingDecorator; -import co.com.bancolombia.ecs.model.management.BusinessExceptionECS; import co.com.bancolombia.ecs.model.request.LogRequest; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; -import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.http.HttpStatus; import org.springframework.web.server.ServerWebExchange; @@ -20,50 +20,60 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import reactor.util.annotation.NonNull; +import reactor.util.context.Context; +import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; -import java.util.stream.Collectors; -@Order(Ordered.HIGHEST_PRECEDENCE) +@Order(Integer.MIN_VALUE) public class ReactiveLogsHandler implements WebFilter { - public static final String RAW_BODY = "raw"; - public static final Set CONSUMER_ACRONYMS = Set.of("consumer-acronym", "code", "channel"); - public static final String MESSAGE_ID = "message-id"; - private static final ObjectMapper objectMapper = new ObjectMapper(); private final EcsPropertiesConfig ecsPropertiesConfig; private final Boolean showRequestLogs; private final Boolean showResponseLogs; + private final boolean printReqRespOnErrorOnlyActive; + private final ExceptionLevel printReqRespLevel; + private final MessageIdMngUseCase messageIdMngUseCase; - public ReactiveLogsHandler(EcsPropertiesConfig ecsPropertiesConfig) { + public ReactiveLogsHandler(EcsPropertiesConfig ecsPropertiesConfig, + MessageIdMngUseCase messageIdMngUseCase) { this.ecsPropertiesConfig = ecsPropertiesConfig; this.showRequestLogs = ecsPropertiesConfig.getShowRequestLogs(); this.showResponseLogs = ecsPropertiesConfig.getShowResponseLogs(); - } - - private static void setConsumer(LogRequest requestInfo, Map headers) { - var consumer = CONSUMER_ACRONYMS.stream() - .map(headers::get) - .filter(Objects::nonNull) - .findFirst().orElse(null); - requestInfo.setConsumer(consumer); + this.printReqRespOnErrorOnlyActive = Boolean.TRUE.equals(ecsPropertiesConfig.getPrintReqRespOnErrorOnly()); + this.printReqRespLevel = ecsPropertiesConfig.getPrintReqRespLevels(); + this.messageIdMngUseCase = messageIdMngUseCase; } @Override @NonNull public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { - if (Boolean.FALSE.equals(showRequestLogs) && Boolean.FALSE.equals(showResponseLogs)) { + String messageId = messageIdMngUseCase.resolveFromHeaders( + extractRequestHeaders(exchange), ecsPropertiesConfig.getAllowRequestHeaders()); + + if (messageId != null) { + exchange.getAttributes().put(ContextECS.KEY_MESSAGE_ID, messageId); + } + + return buildFilterChain(exchange, chain) + .contextWrite(ctx -> writeMessageIdToContext(ctx, messageId)); + } + + + private Mono buildFilterChain(ServerWebExchange exchange, WebFilterChain chain) { + if (!printReqRespOnErrorOnlyActive + && Boolean.FALSE.equals(showRequestLogs) + && Boolean.FALSE.equals(showResponseLogs)) { return chain.filter(exchange); } String path = exchange.getRequest().getURI().getPath(); - if (showRequestLogs && ecsPropertiesConfig.getExcludedPaths().stream().anyMatch(path::startsWith)) { + if (HandlerHelper.isPathExcluded(path, ecsPropertiesConfig.getExcludedPaths())) { return chain.filter(exchange); } - DataBufferFactory bufferFactory = new DefaultDataBufferFactory(); + var bufferFactory = new DefaultDataBufferFactory(); var decoratedRequest = new RequestLoggingDecorator( exchange.getRequest(), bufferFactory); @@ -79,24 +89,52 @@ public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilter .build(); return chain.filter(mutatedExchange) - .then(Mono.defer(() -> logRequest(decoratedRequest, decoratedResponse) - .subscribeOn(Schedulers.boundedElastic()))) - .onErrorResume(error -> logError(error, decoratedRequest) - .subscribeOn(Schedulers.boundedElastic()) - .then(Mono.error(error))); + .then(Mono.defer(() -> logRequestOrError(decoratedRequest, decoratedResponse) + .subscribeOn(Schedulers.boundedElastic()))) + .onErrorResume(error -> logError(error, decoratedRequest) + .subscribeOn(Schedulers.boundedElastic()) + .then(Mono.error(error))); + } + + private Set>> extractRequestHeaders(ServerWebExchange exchange) { + Set>> headers = new HashSet<>(); + exchange.getRequest().getHeaders().forEach((key, values) -> headers.add(Map.entry(key, values))); + return headers; + } + + private Context writeMessageIdToContext(Context ctx, String messageId) { + return messageId != null ? ctx.put(ContextECS.KEY_MESSAGE_ID, messageId) : ctx; + } + + private void applyContextMessageId(LogRequest logRequest) { + if (logRequest.getMessageId() == null) { + MessageIdMngUseCase.getFromContext().ifPresent(logRequest::setMessageId); + } + } + + private Mono logRequestOrError(RequestLoggingDecorator request, ResponseLoggingDecorator response) { + var status = resolveHttpStatus(response); + if (status != null && HandlerHelper.isErrorStatusCode(status.value())) { + return logError(HandlerHelper.buildStatusException(status), request); + } + return logRequest(request, response); } private Mono logRequest(RequestLoggingDecorator request, ResponseLoggingDecorator response) { + if (printReqRespOnErrorOnlyActive) { + return Mono.empty(); + } + var logRequest = new LogRequest(); setRequestParameters(request, logRequest); + applyContextMessageId(logRequest); Mono requestBodyMono = getRequestBodyMono(request, logRequest); - Mono responseBodyMono = getResponseBodyMono(response, logRequest); return Mono.when(requestBodyMono, responseBodyMono) - .then(Mono.defer(() -> EcsReactiveLogger.build(logRequest, ecsPropertiesConfig.getServiceName())) - .subscribeOn(Schedulers.boundedElastic())); + .then(Mono.defer(() -> EcsReactiveLogger.build(logRequest, ecsPropertiesConfig.getServiceName())) + .subscribeOn(Schedulers.boundedElastic())); } private Mono getResponseBodyMono(ResponseLoggingDecorator response, LogRequest logRequest) { @@ -111,12 +149,16 @@ private Mono getResponseBodyMono(ResponseLoggingDecorator response, LogReq body, ecsPropertiesConfig.getSensitiveResponseFields(), ecsPropertiesConfig.getSensitiveResponsePatterns(), ecsPropertiesConfig.getSensitiveResponseReplacement()); - logRequest.setResponseBody(parseToMap(sanitized)); + logRequest.setResponseBody(HandlerHelper.parseToMap(sanitized)); return null; }).subscribeOn(Schedulers.boundedElastic())).then() : Mono.empty(); } + private Mono readBody(ResponseLoggingDecorator response) { + return Mono.justOrEmpty(response.getBodyAsString()); + } + private Mono getRequestBodyMono(RequestLoggingDecorator request, LogRequest logRequest) { return Boolean.TRUE.equals(showRequestLogs) ? readBody(request).flatMap(body -> Mono.fromCallable(() -> { @@ -124,77 +166,59 @@ private Mono getRequestBodyMono(RequestLoggingDecorator request, LogReques body, ecsPropertiesConfig.getSensitiveRequestFields(), ecsPropertiesConfig.getSensitiveRequestPatterns(), ecsPropertiesConfig.getSensitiveRequestReplacement()); - logRequest.setRequestBody(parseToMap(sanitized)); + logRequest.setRequestBody(HandlerHelper.parseToMap(sanitized)); return null; }).subscribeOn(Schedulers.boundedElastic())).then() : Mono.empty(); } private Mono logError(Throwable error, RequestLoggingDecorator request) { + if (printReqRespOnErrorOnlyActive && HandlerHelper.errorDoesntMatchLevel(error, printReqRespLevel)) { + return Mono.empty(); + } + var logRequest = new LogRequest(); setRequestParameters(request, logRequest); + applyContextMessageId(logRequest); - var status = resolveHttpStatus(error); + var status = HandlerHelper.resolveHttpStatus(error); logRequest.setResponseCode(String.valueOf(status.value())); logRequest.setResponseResult(status.getReasonPhrase()); logRequest.setError(error); return readBody(request) - .flatMap(body -> Mono.fromCallable(() -> { - String sanitized = DataSanitizer.sanitize( - body, ecsPropertiesConfig.getSensitiveRequestFields(), - ecsPropertiesConfig.getSensitiveRequestPatterns(), - ecsPropertiesConfig.getSensitiveRequestReplacement()); - logRequest.setRequestBody(parseToMap(sanitized)); - return null; - }).subscribeOn(Schedulers.boundedElastic())) - .then(Mono.defer(() -> EcsReactiveLogger.build(logRequest, ecsPropertiesConfig.getServiceName())) - .subscribeOn(Schedulers.boundedElastic())); + .flatMap(body -> Mono.fromCallable(() -> { + String sanitized = DataSanitizer.sanitize( + body, ecsPropertiesConfig.getSensitiveRequestFields(), + ecsPropertiesConfig.getSensitiveRequestPatterns(), + ecsPropertiesConfig.getSensitiveRequestReplacement()); + logRequest.setRequestBody(HandlerHelper.parseToMap(sanitized)); + return null; + }).subscribeOn(Schedulers.boundedElastic())) + .then(Mono.defer(() -> EcsReactiveLogger.build(logRequest, ecsPropertiesConfig.getServiceName())) + .subscribeOn(Schedulers.boundedElastic())); } private Mono readBody(RequestLoggingDecorator request) { return Mono.justOrEmpty(request.getBodyAsString()); } - private Mono readBody(ResponseLoggingDecorator response) { - return Mono.justOrEmpty(response.getBodyAsString()); - } - private void setRequestParameters(RequestLoggingDecorator decoratedRequest, LogRequest requestInfo) { requestInfo.setMethod(decoratedRequest.getMethod().name()); requestInfo.setUrl(decoratedRequest.getURI().getPath()); - Set>> headers = - decoratedRequest.getHeaders() - .toSingleValueMap() - .entrySet() - .stream() - .collect(Collectors.toMap( - Map.Entry::getKey, - e -> List.of(e.getValue()) - )) - .entrySet(); + Set>> headers = new HashSet<>(); + decoratedRequest.getHeaders().forEach((key, values) -> headers.add(Map.entry(key, values))); Map sanitizeHeaders = DataSanitizer.sanitizeHeaders(headers, ecsPropertiesConfig.getAllowRequestHeaders()); - setConsumer(requestInfo, sanitizeHeaders); - requestInfo.setMessageId(sanitizeHeaders.get(MESSAGE_ID)); + HandlerHelper.setConsumer(requestInfo, sanitizeHeaders); + requestInfo.setMessageId(sanitizeHeaders.get(LogRecord.MESSAGE_ID)); requestInfo.setHeaders(sanitizeHeaders); } - private HttpStatus resolveHttpStatus(Throwable ex) { - if (ex instanceof BusinessExceptionECS businessExceptionECS) { - return HttpStatus.valueOf(businessExceptionECS.getConstantBusinessException().getStatus()); - } - return HttpStatus.INTERNAL_SERVER_ERROR; - } - - - private Map parseToMap(String body) { - try { - return objectMapper.readValue(body, Map.class); - } catch (JsonProcessingException e) { - return Map.of(RAW_BODY, body); - } + private HttpStatus resolveHttpStatus(ResponseLoggingDecorator response) { + var statusCode = response.getStatusCode(); + return statusCode != null ? HandlerHelper.resolveStatusCode(statusCode.value()) : null; } } diff --git a/ecs-reactive/src/main/java/co/com/bancolombia/ecs/application/handler/EcsErrorContextHandler.java b/ecs-reactive/src/main/java/co/com/bancolombia/ecs/application/handler/EcsErrorContextHandler.java new file mode 100644 index 0000000..703c774 --- /dev/null +++ b/ecs-reactive/src/main/java/co/com/bancolombia/ecs/application/handler/EcsErrorContextHandler.java @@ -0,0 +1,34 @@ +package co.com.bancolombia.ecs.application.handler; + +import co.com.bancolombia.ecs.infra.shared.common.domain.ContextECS; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebExceptionHandler; +import reactor.core.publisher.Mono; +import reactor.util.annotation.NonNull; + +/** + * Restaura el {@code messageId} en el ThreadLocal antes de que el + * {@code WebExceptionHandler} de la aplicación procese el error. + * + *

{@code AbstractErrorWebExceptionHandler} crea un pipeline reactivo nuevo + * que no hereda el Reactor Context del {@code WebFilter}. Este handler lee el + * messageId almacenado en los atributos del {@link ServerWebExchange} por + * {@code ReactiveLogsHandler} y lo restaura en {@link ContextECS} de forma + * sincrónica, garantizando que {@code ContextECS.getMessageId()} retorne el + * valor correcto dentro de cualquier {@code WebExceptionHandler} posterior. + */ +@Order(Ordered.HIGHEST_PRECEDENCE) +public class EcsErrorContextHandler implements WebExceptionHandler { + + @Override + @NonNull + public Mono handle(@NonNull ServerWebExchange exchange, @NonNull Throwable ex) { + String messageId = exchange.getAttribute(ContextECS.KEY_MESSAGE_ID); + if (messageId != null) { + ContextECS.setMessageId(messageId); + } + return Mono.error(ex); + } +} diff --git a/ecs-reactive/src/test/java/co/com/bancolombia/ecs/application/ReactiveLogsConfigurationTest.java b/ecs-reactive/src/test/java/co/com/bancolombia/ecs/application/ReactiveLogsConfigurationTest.java new file mode 100644 index 0000000..0585c9c --- /dev/null +++ b/ecs-reactive/src/test/java/co/com/bancolombia/ecs/application/ReactiveLogsConfigurationTest.java @@ -0,0 +1,37 @@ +package co.com.bancolombia.ecs.application; + +import co.com.bancolombia.ecs.application.filter.ReactiveLogsHandler; +import co.com.bancolombia.ecs.infra.config.PrintOnErrorProperties; +import co.com.bancolombia.ecs.infra.config.managementid.application.MessageIdMngUseCase; +import co.com.bancolombia.ecs.infra.config.managementid.domain.MessageIdRequestProperties; +import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveRequestProperties; +import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveResponseProperties; +import co.com.bancolombia.ecs.infra.config.service.ServiceProperties; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class ReactiveLogsConfigurationTest { + + @Test + void shouldCreateReactiveLogsHandlerBean() { + ServiceProperties serviceProperties = new ServiceProperties(); + serviceProperties.setName("test-service"); + + SensitiveRequestProperties requestProperties = new SensitiveRequestProperties(); + requestProperties.setShow(Boolean.FALSE); + + SensitiveResponseProperties responseProperties = new SensitiveResponseProperties(); + responseProperties.setShow(Boolean.FALSE); + + PrintOnErrorProperties printOnErrorProperties = new PrintOnErrorProperties(); + + MessageIdMngUseCase messageIdMngUseCase = new MessageIdMngUseCase(new MessageIdRequestProperties(null)); + + ReactiveLogsConfiguration configuration = new ReactiveLogsConfiguration(); + ReactiveLogsHandler handler = configuration.reactiveLogsHandler( + serviceProperties, requestProperties, responseProperties, printOnErrorProperties, messageIdMngUseCase); + + assertNotNull(handler); + } +} diff --git a/ecs-reactive/src/test/java/co/com/bancolombia/ecs/application/ReactiveLogsHandlerTest.java b/ecs-reactive/src/test/java/co/com/bancolombia/ecs/application/ReactiveLogsHandlerTest.java index 30c2cb5..abe78ab 100644 --- a/ecs-reactive/src/test/java/co/com/bancolombia/ecs/application/ReactiveLogsHandlerTest.java +++ b/ecs-reactive/src/test/java/co/com/bancolombia/ecs/application/ReactiveLogsHandlerTest.java @@ -1,7 +1,10 @@ package co.com.bancolombia.ecs.application; import co.com.bancolombia.ecs.application.filter.ReactiveLogsHandler; +import co.com.bancolombia.ecs.domain.model.ExceptionLevel; import co.com.bancolombia.ecs.infra.config.EcsPropertiesConfig; +import co.com.bancolombia.ecs.infra.config.PrintOnErrorProperties; +import co.com.bancolombia.ecs.infra.config.managementid.application.MessageIdMngUseCase; import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveRequestProperties; import co.com.bancolombia.ecs.infra.config.sensitive.SensitiveResponseProperties; import co.com.bancolombia.ecs.infra.config.service.ServiceProperties; @@ -13,14 +16,17 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatusCode; +import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Flux; @@ -28,13 +34,15 @@ import reactor.test.StepVerifier; import java.net.URI; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@ExtendWith(MockitoExtension.class) +@ExtendWith({MockitoExtension.class, OutputCaptureExtension.class}) class ReactiveLogsHandlerTest { @Mock @@ -61,18 +69,33 @@ class ReactiveLogsHandlerTest { @Mock private ServerHttpResponse response; + @Mock + private PrintOnErrorProperties printOnErrorProperties; + + @Mock + private MessageIdMngUseCase messageIdMngUseCase; + @InjectMocks private ReactiveLogsHandler webHandler; @BeforeEach void setUp() { - webHandler = new ReactiveLogsHandler(ecsPropertiesConfig); + webHandler = new ReactiveLogsHandler(ecsPropertiesConfig, messageIdMngUseCase); } @Test void testShouldSkipExcludedPaths() { + when(ecsPropertiesConfig.getPrintReqRespOnErrorOnly()).thenReturn(Boolean.FALSE); + when(ecsPropertiesConfig.getShowRequestLogs()).thenReturn(Boolean.TRUE); + when(ecsPropertiesConfig.getShowResponseLogs()).thenReturn(Boolean.TRUE); + when(exchange.getRequest()).thenReturn(request); + when(request.getURI()).thenReturn(URI.create("/excluded-path")); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + when(ecsPropertiesConfig.getExcludedPaths()).thenReturn(Set.of("/excluded-path")); when(chain.filter(exchange)).thenReturn(Mono.empty()); + webHandler = new ReactiveLogsHandler(ecsPropertiesConfig, messageIdMngUseCase); + StepVerifier.create(webHandler.filter(exchange, chain)) .verifyComplete(); @@ -84,8 +107,10 @@ void testShouldSkipWhenLogsAreNoShow() { when(ecsPropertiesConfig.getShowRequestLogs()).thenReturn(Boolean.FALSE); when(ecsPropertiesConfig.getShowResponseLogs()).thenReturn(Boolean.FALSE); - webHandler = new ReactiveLogsHandler(ecsPropertiesConfig); + webHandler = new ReactiveLogsHandler(ecsPropertiesConfig, messageIdMngUseCase); + when(exchange.getRequest()).thenReturn(request); + when(request.getHeaders()).thenReturn(new HttpHeaders()); when(chain.filter(exchange)).thenReturn(Mono.empty()); StepVerifier.create(webHandler.filter(exchange, chain)) @@ -94,6 +119,25 @@ void testShouldSkipWhenLogsAreNoShow() { verify(chain).filter(exchange); } + @Test + void testShouldSkipRootPath() { + when(ecsPropertiesConfig.getShowRequestLogs()).thenReturn(Boolean.TRUE); + when(ecsPropertiesConfig.getShowResponseLogs()).thenReturn(Boolean.TRUE); + webHandler = new ReactiveLogsHandler(ecsPropertiesConfig, messageIdMngUseCase); + + when(exchange.getRequest()).thenReturn(request); + when(request.getURI()).thenReturn(URI.create("/")); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + + WebFilterChain mockChain = mock(WebFilterChain.class); + when(mockChain.filter(exchange)).thenReturn(Mono.empty()); + + StepVerifier.create(webHandler.filter(exchange, mockChain)) + .verifyComplete(); + + verify(mockChain).filter(exchange); + } + @Test void testShouldLogRequestAndResponse() { mocksPropertiesConfig(); @@ -107,7 +151,7 @@ void testShouldLogRequestAndResponse() { when(request.getMethod()).thenReturn(HttpMethod.GET); mockBodyFactory(); - when(response.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); + when(response.getStatusCode()).thenReturn(HttpStatus.OK); WebFilterChain mockChain = mock(WebFilterChain.class); when(mockChain.filter(any())).thenReturn(Mono.empty()); @@ -164,6 +208,332 @@ void testShouldHandleGenericException() { .verify(); } + @Test + void testShouldLogCompletedForbiddenResponseThroughErrorPath(CapturedOutput output) { + mocksPropertiesConfig(); + URI uri = URI.create("/api/cors"); + + mockExchange(); + + when(exchange.getRequest()).thenReturn(request); + when(exchange.getResponse()).thenReturn(response); + when(request.getURI()).thenReturn(uri); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + when(request.getMethod()).thenReturn(HttpMethod.OPTIONS); + when(response.getStatusCode()).thenReturn(HttpStatus.FORBIDDEN); + + mockBodyFactory(); + + WebFilterChain mockChain = mock(WebFilterChain.class); + when(mockChain.filter(any())).thenReturn(Mono.empty()); + + StepVerifier.create(webHandler.filter(exchange, mockChain)) + .verifyComplete(); + + assertTrue(output.getOut().contains("\"uri\":\"/api/cors\"")); + assertTrue(output.getOut().contains("\"responseCode\":\"403\"")); + assertTrue(output.getOut().contains("\"responseResult\":\"Forbidden\"")); + assertTrue(output.getOut().contains("\"level\":\"ERROR\"")); + assertTrue(output.getOut().contains("\"type\":\"org.springframework.web.server.ResponseStatusException\"")); + } + + @Test + void testShouldPreserveResponseStatusExceptionStatusWhenLoggingErrors(CapturedOutput output) { + mocksPropertiesConfig(); + URI uri = URI.create("/api/cors"); + ResponseStatusException exception = + new ResponseStatusException(HttpStatus.FORBIDDEN, "Invalid CORS request"); + + mockExchange(); + + when(exchange.getRequest()).thenReturn(request); + when(exchange.getResponse()).thenReturn(response); + when(request.getURI()).thenReturn(uri); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + when(request.getMethod()).thenReturn(HttpMethod.OPTIONS); + + mockBodyFactory(); + + WebFilterChain mockChain = mock(WebFilterChain.class); + when(mockChain.filter(any())).thenReturn(Mono.error(exception)); + + StepVerifier.create(webHandler.filter(exchange, mockChain)) + .expectErrorMatches(error -> error==exception) + .verify(); + + assertTrue(output.getOut().contains("\"uri\":\"/api/cors\"")); + assertTrue(output.getOut().contains("\"responseCode\":\"403\"")); + assertTrue(output.getOut().contains("\"responseResult\":\"Forbidden\"")); + assertTrue(output.getOut().contains("\"level\":\"ERROR\"")); + assertTrue(output.getOut().contains("\"type\":\"org.springframework.web.server.ResponseStatusException\"")); + } + + @Test + void testShouldFallbackToInternalServerErrorWhenBusinessStatusIsNull(CapturedOutput output) { + mocksPropertiesConfig(); + URI uri = URI.create("/api/error"); + BusinessExceptionECS exception = new BusinessExceptionECS(errorManagementWithNullStatus()); + + mockExchange(); + + when(exchange.getRequest()).thenReturn(request); + when(exchange.getResponse()).thenReturn(response); + when(request.getURI()).thenReturn(uri); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + when(request.getMethod()).thenReturn(HttpMethod.GET); + + mockBodyFactory(); + + WebFilterChain mockChain = mock(WebFilterChain.class); + when(mockChain.filter(any())).thenReturn(Mono.error(exception)); + + StepVerifier.create(webHandler.filter(exchange, mockChain)) + .expectErrorMatches(error -> error==exception) + .verify(); + + assertTrue(output.getOut().contains("\"uri\":\"/api/error\"")); + assertTrue(output.getOut().contains("\"responseCode\":\"500\"")); + assertTrue(output.getOut().contains("\"responseResult\":\"Internal Server Error\"")); + assertTrue(output.getOut().contains("\"level\":\"ERROR\"")); + assertTrue(output.getOut().contains("\"type\":\"CORS-403-00\"")); + } + + @Test + void testShouldHandlePrintOnErrorWhenLevelMatches() { + mocksPropertiesConfig(true, true, true, "Exception", "/excluded"); + URI uri = URI.create("/api/error"); + mockExchange(); + when(exchange.getRequest()).thenReturn(request); + when(exchange.getResponse()).thenReturn(response); + when(request.getURI()).thenReturn(uri); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + when(request.getMethod()).thenReturn(HttpMethod.GET); + mockBodyFactory(); + + WebFilterChain mockChain = mock(WebFilterChain.class); + when(mockChain.filter(any())).thenReturn(Mono.error(new RuntimeException("boom"))); + + StepVerifier.create(webHandler.filter(exchange, mockChain)) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testShouldLogCompletedForbiddenResponseInPrintOnErrorMode(CapturedOutput output) { + mocksPropertiesConfig(true, true, true, "Exception", "/excluded"); + URI uri = URI.create("/api/cors"); + + mockExchange(); + + when(exchange.getRequest()).thenReturn(request); + when(exchange.getResponse()).thenReturn(response); + when(request.getURI()).thenReturn(uri); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + when(request.getMethod()).thenReturn(HttpMethod.OPTIONS); + when(response.getStatusCode()).thenReturn(HttpStatus.FORBIDDEN); + + mockBodyFactory(); + + WebFilterChain mockChain = mock(WebFilterChain.class); + when(mockChain.filter(any())).thenReturn(Mono.empty()); + + StepVerifier.create(webHandler.filter(exchange, mockChain)) + .verifyComplete(); + + assertTrue(output.getOut().contains("\"uri\":\"/api/cors\"")); + assertTrue(output.getOut().contains("\"responseCode\":\"403\"")); + assertTrue(output.getOut().contains("\"responseResult\":\"Forbidden\"")); + assertTrue(output.getOut().contains("\"level\":\"ERROR\"")); + assertTrue(output.getOut().contains("\"type\":\"org.springframework.web.server.ResponseStatusException\"")); + } + + @Test + void testShouldHandlePrintOnErrorWhenLevelDoesNotMatch() { + mocksPropertiesConfig(true, true, true, "BusinessExceptionECS", "/excluded"); + URI uri = URI.create("/api/error"); + mockExchange(); + when(exchange.getRequest()).thenReturn(request); + when(exchange.getResponse()).thenReturn(response); + when(request.getURI()).thenReturn(uri); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + mockBodyFactory(); + + WebFilterChain mockChain = mock(WebFilterChain.class); + when(mockChain.filter(any())).thenReturn(Mono.error(new RuntimeException("boom"))); + + StepVerifier.create(webHandler.filter(exchange, mockChain)) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testShouldHandleNullLevelsFromConfig() { + when(ecsPropertiesConfig.getShowRequestLogs()).thenReturn(Boolean.FALSE); + when(ecsPropertiesConfig.getShowResponseLogs()).thenReturn(Boolean.FALSE); + when(ecsPropertiesConfig.getPrintReqRespOnErrorOnly()).thenReturn(Boolean.FALSE); + when(ecsPropertiesConfig.getPrintReqRespLevels()).thenReturn(null); + + ReactiveLogsHandler localHandler = new ReactiveLogsHandler(ecsPropertiesConfig, messageIdMngUseCase); + when(exchange.getRequest()).thenReturn(request); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + when(chain.filter(exchange)).thenReturn(Mono.empty()); + + StepVerifier.create(localHandler.filter(exchange, chain)) + .verifyComplete(); + + verify(chain).filter(exchange); + } + + @Test + void testShouldHandlePrintOnErrorWhenLevelsEmpty() { + mocksPropertiesConfig(true, true, true, null, "/excluded"); + URI uri = URI.create("/api/error"); + mockExchange(); + when(exchange.getRequest()).thenReturn(request); + when(exchange.getResponse()).thenReturn(response); + when(request.getURI()).thenReturn(uri); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + mockBodyFactory(); + + WebFilterChain mockChain = mock(WebFilterChain.class); + when(mockChain.filter(any())).thenReturn(Mono.error(new RuntimeException("boom"))); + + StepVerifier.create(webHandler.filter(exchange, mockChain)) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testShouldHandlePrintOnErrorWhenPathExcluded() { + mocksPropertiesConfig(true, true, true, "Exception", "/api"); + URI uri = URI.create("/api/data"); + when(exchange.getRequest()).thenReturn(request); + when(request.getURI()).thenReturn(uri); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + when(chain.filter(exchange)).thenReturn(Mono.empty()); + + StepVerifier.create(webHandler.filter(exchange, chain)) + .verifyComplete(); + + verify(chain).filter(exchange); + } + + @Test + void testShouldLogWhenOnlyRequestIsEnabled() { + mocksPropertiesConfig(null, true, false, null, ""); + URI uri = URI.create("/api/data"); + mockExchange(); + when(exchange.getRequest()).thenReturn(request); + when(exchange.getResponse()).thenReturn(response); + when(request.getURI()).thenReturn(uri); + when(request.getMethod()).thenReturn(HttpMethod.POST); + HttpHeaders headers = new HttpHeaders(); + headers.add("message-id", "123"); + when(request.getHeaders()).thenReturn(headers); + mockBodyFactory(); + + WebFilterChain mockChain = mock(WebFilterChain.class); + when(mockChain.filter(any())).thenReturn(Mono.empty()); + + StepVerifier.create(webHandler.filter(exchange, mockChain)) + .verifyComplete(); + } + + @Test + void testShouldLogWhenOnlyResponseIsEnabled() { + mocksPropertiesConfig(null, false, true, null, ""); + URI uri = URI.create("/api/data"); + mockExchange(); + when(exchange.getRequest()).thenReturn(request); + when(exchange.getResponse()).thenReturn(response); + when(request.getURI()).thenReturn(uri); + when(request.getMethod()).thenReturn(HttpMethod.GET); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + mockBodyFactory(); + when(response.getStatusCode()).thenReturn(HttpStatus.OK); + + WebFilterChain mockChain = mock(WebFilterChain.class); + when(mockChain.filter(any())).thenReturn(Mono.empty()); + + StepVerifier.create(webHandler.filter(exchange, mockChain)) + .verifyComplete(); + } + + // ── Tests Regla 1: resolveMessageId con flag true/false/null ───────────── + + @Test + void testShouldNotGenerateUuidWhenFlagIsFalseAndNoHeader() { + // Bug fix: flag=false NO debe generar UUID (antes con != null lo generaba) + when(sensitiveRequestProperties.getShow()).thenReturn(Boolean.FALSE); + when(sensitiveResponseProperties.getShow()).thenReturn(Boolean.FALSE); + ecsPropertiesConfig = new EcsPropertiesConfig( + serviceProperties, + sensitiveRequestProperties, + sensitiveResponseProperties, + printOnErrorProperties); + webHandler = new ReactiveLogsHandler(ecsPropertiesConfig, messageIdMngUseCase); + + when(exchange.getRequest()).thenReturn(request); + HttpHeaders headers = new HttpHeaders(); // sin message-id + when(request.getHeaders()).thenReturn(headers); + when(chain.filter(exchange)).thenReturn(Mono.empty()); + + StepVerifier.create(webHandler.filter(exchange, chain)) + .verifyComplete(); + // No lanza excepción: la cadena se completa sin generar UUID cuando flag=false + } + + @Test + void testShouldGenerateUuidWhenFlagIsNullAndNoHeader() { + // flag=null → igual que true: debe generar UUID cuando no hay header + when(sensitiveRequestProperties.getShow()).thenReturn(Boolean.FALSE); + when(sensitiveResponseProperties.getShow()).thenReturn(Boolean.FALSE); + ecsPropertiesConfig = new EcsPropertiesConfig( + serviceProperties, + sensitiveRequestProperties, + sensitiveResponseProperties, + printOnErrorProperties); + webHandler = new ReactiveLogsHandler(ecsPropertiesConfig, messageIdMngUseCase); + + when(exchange.getRequest()).thenReturn(request); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + when(messageIdMngUseCase.resolveFromHeaders(any(), any())).thenReturn("auto-generated-uuid"); + java.util.Map attributes = new java.util.HashMap<>(); + when(exchange.getAttributes()).thenReturn(attributes); + when(chain.filter(exchange)).thenReturn(Mono.empty()); + + StepVerifier.create(webHandler.filter(exchange, chain)) + .verifyComplete(); + + assertTrue(attributes.containsKey("ECS_MESSAGE_ID"), "flag=null debe añadir messageId al contexto del exchange"); + } + + @Test + void testShouldUseHeaderMessageIdWhenPresent() { + // Cuando el header trae message-id, siempre se usa (independiente del flag) + when(serviceProperties.getName()).thenReturn("test-service"); + when(sensitiveRequestProperties.getShow()).thenReturn(Boolean.FALSE); + when(sensitiveResponseProperties.getShow()).thenReturn(Boolean.FALSE); + when(printOnErrorProperties.getPrintReqResp()).thenReturn(null); + when(printOnErrorProperties.getPrintReqRespLevel()).thenReturn(null); + + ecsPropertiesConfig = new EcsPropertiesConfig( + serviceProperties, + sensitiveRequestProperties, + sensitiveResponseProperties, + printOnErrorProperties); + webHandler = new ReactiveLogsHandler(ecsPropertiesConfig, messageIdMngUseCase); + + when(exchange.getRequest()).thenReturn(request); + HttpHeaders headers = new HttpHeaders(); + headers.add("message-id", "header-message-id-456"); + when(request.getHeaders()).thenReturn(headers); + when(chain.filter(exchange)).thenReturn(Mono.empty()); + + StepVerifier.create(webHandler.filter(exchange, chain)) + .verifyComplete(); + } + private void mockBodyFactory() { DataBufferFactory bufferFactory = new DefaultDataBufferFactory(); DataBuffer dataBuffer = bufferFactory.wrap("{\"key\":\"value\"}".getBytes()); @@ -181,25 +551,75 @@ private void mockExchange() { } private void mocksPropertiesConfig() { - when(serviceProperties.getName()).thenReturn("test-service"); + mocksPropertiesConfig(null, true, true, null, ""); + } - when(sensitiveRequestProperties.getDelimiter()).thenReturn("\\|"); - when(sensitiveRequestProperties.getShow()).thenReturn(Boolean.TRUE); - when(sensitiveRequestProperties.getAllowHeaders()).thenReturn("message-id|code|channel|acronym-channel"); - when(sensitiveRequestProperties.getFields()).thenReturn(""); - when(sensitiveRequestProperties.getExcludedPaths()).thenReturn(""); - when(sensitiveRequestProperties.getPatterns()).thenReturn(""); - when(sensitiveRequestProperties.getReplacement()).thenReturn("*****"); + private void mocksPropertiesConfig(Boolean printOnError, + boolean showRequest, + boolean showResponse, + String level, + String excludedPaths) { + when(serviceProperties.getName()).thenReturn("test-service"); - when(sensitiveResponseProperties.getDelimiter()).thenReturn("\\|"); - when(sensitiveResponseProperties.getShow()).thenReturn(Boolean.TRUE); - when(sensitiveResponseProperties.getFields()).thenReturn(""); - when(sensitiveResponseProperties.getPatterns()).thenReturn(""); - when(sensitiveResponseProperties.getReplacement()).thenReturn("*****"); + boolean printOnErrorActive = Boolean.TRUE.equals(printOnError); + when(sensitiveRequestProperties.getShow()).thenReturn(showRequest); + if (showRequest || printOnErrorActive) { + when(sensitiveRequestProperties.getDelimiter()).thenReturn("\\|"); + when(sensitiveRequestProperties.getAllowHeaders()).thenReturn("message-id|code|channel|acronym-channel"); + when(sensitiveRequestProperties.getFields()).thenReturn(""); + when(sensitiveRequestProperties.getExcludedPaths()).thenReturn(excludedPaths); + when(sensitiveRequestProperties.getPatterns()).thenReturn(""); + when(sensitiveRequestProperties.getReplacement()).thenReturn("*****"); + } + + when(sensitiveResponseProperties.getShow()).thenReturn(showResponse); + if (showResponse || printOnErrorActive) { + when(sensitiveResponseProperties.getDelimiter()).thenReturn("\\|"); + when(sensitiveResponseProperties.getFields()).thenReturn(""); + when(sensitiveResponseProperties.getPatterns()).thenReturn(""); + when(sensitiveResponseProperties.getReplacement()).thenReturn("*****"); + } + + when(printOnErrorProperties.getPrintReqResp()).thenReturn(printOnError); + when(printOnErrorProperties.getPrintReqRespLevel()).thenReturn( + ExceptionLevel.toExceptionLevelIgnoreCase(level)); + + ecsPropertiesConfig = new EcsPropertiesConfig( + serviceProperties, + sensitiveRequestProperties, + sensitiveResponseProperties, + printOnErrorProperties); + webHandler = new ReactiveLogsHandler(ecsPropertiesConfig, messageIdMngUseCase); + } - ecsPropertiesConfig = new EcsPropertiesConfig(serviceProperties, sensitiveRequestProperties, - sensitiveResponseProperties); - webHandler = new ReactiveLogsHandler(ecsPropertiesConfig); + private ErrorManagement errorManagementWithNullStatus() { + return new ErrorManagement() { + @Override + public Integer getStatus() { + return null; + } + + @Override + public String getMessage() { + return "Invalid CORS request"; + } + + @Override + public String getErrorCode() { + return "CORS-403"; + } + + @Override + public String getInternalMessage() { + return "CORS validation failed"; + } + + @Override + public String getLogCode() { + return "CORS-403-00"; + } + }; } + } \ No newline at end of file diff --git a/examples/ecs_logs_reactive_test/applications/app-service/src/main/resources/application.yaml b/examples/ecs_logs_reactive_test/applications/app-service/src/main/resources/application.yaml index 2b70738..a6e7347 100644 --- a/examples/ecs_logs_reactive_test/applications/app-service/src/main/resources/application.yaml +++ b/examples/ecs_logs_reactive_test/applications/app-service/src/main/resources/application.yaml @@ -1,6 +1,8 @@ ##Spring Configuration server: port: 8080 +cors: + allowed-origins: "http://localhost:4200,http://localhost:8080" spring: application: name: ecs_logs_test @@ -15,6 +17,11 @@ spring: adapter: ecs: logs: + print-on-error: + print-req-resp: false + print-req-resp-level: Exception + message-id: + enable_auto_register_message_id: true request: replacement: "" patterns: "" @@ -30,7 +37,33 @@ adapter: patterns: "" show: true sampling: - rules20XJson: "" - rules40XJson: "" + rules20XJson: > + [{"uri":"/getClient","responseCode":"200","showCount":2,"skipCount":2}, + {"uri":"/retrieveUser","responseCode":"201","showCount":2,"skipCount":2}] + rules40XJson: > + [{"uri":"/getClient","responseCode":"400","showCount":2,"skipCount":2,"errorCodes":"SAER400-01|SAER400-02"}, + {"uri":"/retrieveUser","responseCode":"404","showCount":2,"skipCount":2,"errorCodes":"BPER404-52"}] sensitive-rules: - sensitive-data: "" + sensitive-data: > + [ + { + "uriPattern": "/getClient", + "fieldPaths": [ + "additionalInfo.requestBody.data.identification.number" + ], + "maskingType": "PARTIAL", + "enabled": true, + "maskingChar": "*", + "visibilityPercentage": 0.5 + }, + { + "uriPattern": "/getClient", + "fieldPaths": [ + "additionalInfo.responseBody.data.identification" + ], + "maskingType": "PARTIAL", + "enabled": true, + "maskingChar": "*", + "visibilityPercentage": 0.5 + } + ] diff --git a/examples/ecs_logs_reactive_test/applications/app-service/src/test/java/co/com/bancolombia/config/UseCasesConfigTest.java b/examples/ecs_logs_reactive_test/applications/app-service/src/test/java/co/com/bancolombia/config/UseCasesConfigTest.java index 05f8e95..7980bd8 100644 --- a/examples/ecs_logs_reactive_test/applications/app-service/src/test/java/co/com/bancolombia/config/UseCasesConfigTest.java +++ b/examples/ecs_logs_reactive_test/applications/app-service/src/test/java/co/com/bancolombia/config/UseCasesConfigTest.java @@ -2,10 +2,8 @@ import co.com.bancolombia.customerservice.client.search.config.UseCasesConfig; import org.junit.jupiter.api.Test; -import org.springframework.context.annotation.AnnotationConfigApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.*; + import static org.junit.jupiter.api.Assertions.assertTrue; public class UseCasesConfigTest { @@ -29,6 +27,7 @@ void testUseCaseBeansExist() { @Configuration @Import(UseCasesConfig.class) + @ComponentScan(basePackages = "co.com.bancolombia.core.coustumerservice.client.search.application") static class TestConfig { @Bean diff --git a/examples/ecs_logs_reactive_test/build.gradle b/examples/ecs_logs_reactive_test/build.gradle index 7a5354b..cd2678f 100644 --- a/examples/ecs_logs_reactive_test/build.gradle +++ b/examples/ecs_logs_reactive_test/build.gradle @@ -6,7 +6,7 @@ buildscript { jacocoVersion = '0.8.13' pitestVersion = '1.19.0-rc.1' lombokVersion = '1.18.38' - ecsversion = '0.0.10' + ecsversion = '1.2.1' } } diff --git a/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/build.gradle b/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/build.gradle index dfb4845..1a790da 100644 --- a/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/build.gradle +++ b/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/build.gradle @@ -2,4 +2,5 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation project(':model') implementation project(':usecase') + implementation "co.com.bancolombia:ecs-core:${ecsversion}" } diff --git a/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/application/GlobalExceptionHandler.java b/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/application/GlobalExceptionHandler.java index c75a12b..1651805 100644 --- a/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/application/GlobalExceptionHandler.java +++ b/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/application/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import co.com.bancolombia.api.shared.common.domain.HeaderConstant; import co.com.bancolombia.api.shared.common.domain.response.ErrorApiResponse; +import co.com.bancolombia.ecs.infra.shared.common.domain.ContextECS; import co.com.bancolombia.ecs.model.management.BusinessExceptionECS; import co.com.bancolombia.model.shared.common.value.Constants; import co.com.bancolombia.model.shared.exception.BusinessException; @@ -48,10 +49,14 @@ private Mono buildErrorResponse(ServerRequest request) { } public Mono createResponseFromBusiness(BusinessException exception, ServerRequest request) { + String ecsMessageId = ContextECS.getMessageId(); + var body = ecsMessageId != null + ? ErrorApiResponse.build(exception, ecsMessageId) + : ErrorApiResponse.build(exception); return ServerResponse .status(exception.getConstantBusinessException().getStatus()) .headers(buildHeaders(request)) - .bodyValue(ErrorApiResponse.build(exception)); + .bodyValue(body); } private Consumer buildHeaders(ServerRequest serverRequest) { diff --git a/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/domain/response/ErrorApiResponse.java b/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/domain/response/ErrorApiResponse.java index 1452dfb..5248d33 100644 --- a/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/domain/response/ErrorApiResponse.java +++ b/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/domain/response/ErrorApiResponse.java @@ -20,6 +20,9 @@ public static ErrorApiResponse build(BusinessException exception) { return new ErrorApiResponse(new Meta(exception.getMetaInfo().getMessageId()), exception); } + public static ErrorApiResponse build(BusinessException exception, String messageId) { + return new ErrorApiResponse(new Meta(messageId), exception); + } private List getErrors(ErrorManagement exception) { return List.of(Error.builder().code(exception.getErrorCode()).detail(exception.getMessage()).build()); } diff --git a/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/infra/CorsConfig.java b/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/infra/CorsConfig.java new file mode 100644 index 0000000..d3c4e05 --- /dev/null +++ b/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/infra/CorsConfig.java @@ -0,0 +1,29 @@ +package co.com.bancolombia.api.shared.common.infra; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.reactive.CorsWebFilter; +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +@Configuration +public class CorsConfig { + + @Bean + CorsWebFilter corsWebFilter(@Value("${cors.allowed-origins}") List origins) { + var config = new CorsConfiguration(); + config.setAllowCredentials(Boolean.TRUE); + config.setAllowedOrigins(origins); + config.setAllowedMethods(Arrays.asList("POST", "GET")); + config.setAllowedHeaders(List.of(CorsConfiguration.ALL)); + + var source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return new CorsWebFilter(source); + } +} diff --git a/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/infra/OptionsRouter.java b/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/infra/OptionsRouter.java new file mode 100644 index 0000000..d6fa953 --- /dev/null +++ b/examples/ecs_logs_reactive_test/infrastructure/entry-points/reactive-web/src/main/java/co/com/bancolombia/api/shared/common/infra/OptionsRouter.java @@ -0,0 +1,18 @@ +package co.com.bancolombia.api.shared.common.infra; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; + +import static org.springframework.web.reactive.function.server.RequestPredicates.OPTIONS; +import static org.springframework.web.reactive.function.server.RouterFunctions.route; + +@Configuration +public class OptionsRouter { + + @Bean + public RouterFunction rootOptions() { + return route(OPTIONS("/**"), request -> ServerResponse.ok().build()); + } +} diff --git a/gradle.properties b/gradle.properties index ad710a4..59cbbad 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,5 +6,5 @@ metrics=true language=java org.gradle.parallel=true systemProp.sonar.gradle.skipCompile=true -version=2.0.0 +version=1.2.1 onlyUpdater=true \ No newline at end of file diff --git a/main.gradle b/main.gradle index f9398de..1985b1e 100644 --- a/main.gradle +++ b/main.gradle @@ -127,6 +127,10 @@ subprojects { targetCompatibility = JavaVersion.VERSION_21 } + tasks.withType(JavaCompile).configureEach { + options.compilerArgs << '-parameters' + } + javadoc { if (JavaVersion.current().isJava9Compatible()) { options.addBooleanOption('html5', true)