Repository: floci-io/floci (2026-04-05T10:11:09Z)

Issue:
[FEAT] Forward event resources array and use it in rule matching
## Service

Event Bridge

## API Action / Feature

<!-- e.g. S3 Object Tagging, Kinesis GetShardIterator -->
- Forwarding the `Resources` array from the events to downstream targets ([here](https://github.com/hectorvent/floci/blob/7b87bf2c1fa8f9cff7aef4be488d7b2cbf3fe26d/src/main/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeService.java#L450)).
- Use that array for rule matching

## AWS Documentation

<!-- Link to the AWS API reference for this action -->
The event structure [doc](https://docs.aws.amazon.com/eventbridge/latest/ref/events-structure.html).
## Why is this needed?

<!-- Describe your use case — what breaks without it? -->
The `Resources` can used to filter out some events (similarly to what's already done [here](https://github.com/hectorvent/floci/blob/7b87bf2c1fa8f9cff7aef4be488d7b2cbf3fe26d/src/main/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeService.java#L353)). 

On my side, I populate that field with a custom format. In ITs, I have an SQS target plugged to event bridge and I'm validating the SQS message have the proper content.

## Are you willing to contribute a PR?

- [x] Yes
- [ ] No

I've already taken a look at the code and can work on a PR. 

One question though, in [EventBridgeServiceTest](https://github.com/hectorvent/floci/blob/7b87bf2c1fa8f9cff7aef4be488d7b2cbf3fe26d/src/test/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeServiceTest.java#L19), I can't see that the actual target invoke being tested. 

Would some unit tests using some mocks for `sqsService`, `lambdaService` and  `snsService` be ok or would you prefer creating integration tests such as [these](https://github.com/hectorvent/floci/blob/7b87bf2c1fa8f9cff7aef4be488d7b2cbf3fe26d/src/test/java/io/github/hectorvent/floci/services/lambda/EsmIntegrationTest.java#L18)? 


Discussion:
Hey @fl0rian-p , thanks for opening this!

Great that you're willing to work on it. Feel free to go ahead, the issue is yours!

About the test, all test you can add will be welcome. 

:tada: This issue has been resolved in version 1.3.0 :tada:

The release is available on [GitHub release](https://github.com/floci-io/floci/releases/tag/1.3.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:



Patch:
diff --git a/src/main/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeHandler.java b/src/main/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeHandler.java
index 5018f466..383572d0 100644
--- a/src/main/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeHandler.java
+++ b/src/main/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeHandler.java
@@ -246,6 +246,9 @@ private Response handlePutEvents(JsonNode request, String region) {
                 if (!entryNode.path("Detail").isMissingNode()) {
                     entry.put("Detail", entryNode.path("Detail").asText(null));
                 }
+                if (!entryNode.path("Resources").isMissingNode()) {
+                    entry.put("Resources", entryNode.path("Resources"));
+                }
                 entries.add(entry);
             }
         }
diff --git a/src/main/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeService.java b/src/main/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeService.java
index 9f727d12..a57a18a0 100644
--- a/src/main/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeService.java
+++ b/src/main/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeService.java
@@ -1,5 +1,6 @@
 package io.github.hectorvent.floci.services.eventbridge;
 
+import com.fasterxml.jackson.databind.node.ArrayNode;
 import io.github.hectorvent.floci.config.EmulatorConfig;
 import io.github.hectorvent.floci.core.common.AwsArnUtils;
 import io.github.hectorvent.floci.core.common.AwsException;
@@ -389,6 +390,17 @@ boolean matchesPattern(Map<String, Object> event, String eventPattern) {
                     }
                 }
             }
+            JsonNode resourcesPattern = pattern.get("resources");
+            if (resourcesPattern != null && resourcesPattern.isArray()) {
+                var resources = ((ArrayNode) event.get("Resources")).elements();
+                while (resources.hasNext()) {
+                    var resource = resources.next().asText(null);
+                    if (matchesArrayField(resourcesPattern, resource)) {
+                        return true;
+                    }
+                }
+                return false;
+            }
             return true;
         } catch (Exception e) {
             LOG.warnv("Failed to parse event pattern: {0}", e.getMessage());
@@ -438,6 +450,7 @@ private String buildEventEnvelope(Map<String, Object> entry, String busName, Str
             String source = (String) entry.getOrDefault("Source", "");
             String detailType = (String) entry.getOrDefault("DetailType", "");
             String detail = (String) entry.getOrDefault("Detail", "{}");
+            ArrayNode resources = (ArrayNode) entry.getOrDefault("Resources", objectMapper.createArrayNode());
             ObjectNode node = objectMapper.createObjectNode();
             node.put("version", "0");
             node.put("id", eventId);
@@ -446,7 +459,7 @@ private String buildEventEnvelope(Map<String, Object> entry, String busName, Str
             node.put("account", regionResolver.getAccountId());
             node.put("time", Instant.now().toString());
             node.put("region", regionResolver.getDefaultRegion());
-            node.putArray("resources");
+            node.putArray("resources").addAll(resources);
             node.set("detail", objectMapper.readTree(detail));
             node.put("event-bus-name", busName);
             return objectMapper.writeValueAsString(node);


Test patch:
diff --git a/src/test/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeIntegrationTest.java
new file mode 100644
index 00000000..4503169d
--- /dev/null
+++ b/src/test/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeIntegrationTest.java
@@ -0,0 +1,112 @@
+package io.github.hectorvent.floci.services.eventbridge;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.RestAssured;
+import io.restassured.config.EncoderConfig;
+import io.restassured.http.ContentType;
+import org.junit.jupiter.api.*;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.matchesPattern;
+
+@QuarkusTest
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class EventBridgeIntegrationTest {
+
+    private static final String SQS_CONTENT_TYPE = "application/x-amz-json-1.0";
+    private static final String EVENT_BRIDGE_CONTENT_TYPE = "application/x-amz-json-1.1";
+
+    private static String sinkQueueUrl;
+
+    @BeforeAll
+    static void configureRestAssured() {
+        RestAssured.config = RestAssured.config().encoderConfig(
+                EncoderConfig.encoderConfig()
+                        .encodeContentTypeAs(SQS_CONTENT_TYPE, ContentType.TEXT)
+                        .encodeContentTypeAs(EVENT_BRIDGE_CONTENT_TYPE, ContentType.TEXT)
+        );
+    }
+
+    @Test
+    @Order(1)
+    void createSinkQueue() {
+        sinkQueueUrl =  given()
+                .contentType("application/x-amz-json-1.0")
+                .header("X-Amz-Target", "AmazonSQS.CreateQueue")
+                .body("{\"QueueName\":\"integration-test-queue\"}")
+                .when()
+                .post("/")
+                .then()
+                .statusCode(200)
+                .extract().jsonPath().getString("QueueUrl");
+    }
+
+    @Test
+    @Order(2)
+    void createEventBridgeRule() {
+        given()
+                .contentType("application/x-amz-json-1.1")
+                .header("X-Amz-Target", "AWSEvents.PutRule")
+                .body("{\"Name\":\"integration-test-rule\"}")
+                .when().post("/")
+                .then().statusCode(200);
+
+        String queueArn = given()
+                .contentType(SQS_CONTENT_TYPE)
+                .header("X-Amz-Target", "AmazonSQS.GetQueueAttributes")
+                .body("{\"QueueUrl\":\"" + sinkQueueUrl + "\",\"AttributeNames\":[\"All\"]}")
+                .when()
+                .post("/0000000000/integration-test-queue")
+                .then()
+                .statusCode(200)
+                .extract().jsonPath().getString("Attributes.QueueArn");
+
+        given()
+                .contentType("application/x-amz-json-1.1")
+                .header("X-Amz-Target", "AWSEvents.PutTargets")
+                .body("{\"Rule\":\"integration-test-rule\",\"Targets\":[{\"Arn\":\"" + queueArn+"\"}]}")
+                .when().post("/")
+                .then().statusCode(200);
+    }
+
+    @Test
+    @Order(3)
+    void publishEventAndExpectMessageInQueue() {
+        given()
+                .contentType("application/x-amz-json-1.1")
+                .header("X-Amz-Target", "AWSEvents.PutEvents")
+                .body("""
+                {
+                  "Entries" : [
+                      {
+                        "Source": "com.mycompany.myapp",
+                        "Detail": "{ \\"key1\\": \\"value1\\", \\"key2\\": \\"value2\\" }",
+                        "Resources": [
+                          "resource1",
+                          "resource2"
+                        ],
+                        "DetailType": "myDetailType"
+                      }
+                  ]
+                }
+                """)
+                .when().post("/")
+                .then().statusCode(200);
+
+        String expectedMessage = "\\{\"version\":\"0\",\"id\":\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\"source\":\"com.mycompany.myapp\"," +
+                "\"detail-type\":\"myDetailType\",\"account\":\"000000000000\",\"time\":\"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{9}Z\"," +
+                "\"region\":\"us-east-1\",\"resources\":\\[\"resource1\",\"resource2\"],\"detail\":\\{\"key1\":\"value1\",\"key2\":\"value2\"},\"event-bus-name\":\"default\"}";
+
+        given()
+            .contentType(SQS_CONTENT_TYPE)
+            .header("X-Amz-Target", "AmazonSQS.ReceiveMessage")
+            .body("{\"QueueUrl\":\"" + sinkQueueUrl + "\",\"MaxNumberOfMessages\":1}")
+            .when()
+                .post("/0000000000/integration-test-queue")
+            .then()
+                .statusCode(200)
+                .body("Messages", hasSize(1))
+                .body("Messages[0].Body", matchesPattern(expectedMessage));
+    }
+}
diff --git a/src/test/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeServiceTest.java
index 85ce75d7..6823b5e8 100644
--- a/src/test/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeServiceTest.java
+++ b/src/test/java/io/github/hectorvent/floci/services/eventbridge/EventBridgeServiceTest.java
@@ -1,6 +1,7 @@
 package io.github.hectorvent.floci.services.eventbridge;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
 import io.github.hectorvent.floci.core.common.AwsException;
 import io.github.hectorvent.floci.core.common.RegionResolver;
 import io.github.hectorvent.floci.core.storage.InMemoryStorage;
@@ -8,6 +9,11 @@
 import io.github.hectorvent.floci.services.eventbridge.model.Rule;
 import io.github.hectorvent.floci.services.eventbridge.model.RuleState;
 import io.github.hectorvent.floci.services.eventbridge.model.Target;
+import io.github.hectorvent.floci.services.lambda.LambdaService;
+import io.github.hectorvent.floci.services.lambda.model.InvocationType;
+import io.github.hectorvent.floci.services.sns.SnsService;
+import io.github.hectorvent.floci.services.sqs.SqsService;
+
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
@@ -15,21 +21,31 @@
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 
 class EventBridgeServiceTest {
 
     private static final String REGION = "us-east-1";
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
     private EventBridgeService service;
+    private LambdaService lambdaServiceMock;
+    private SqsService sqsServiceMock;
+    private SnsService snsServiceMock;
 
     @BeforeEach
     void setUp() {
+        snsServiceMock = mock(SnsService.class);
+        sqsServiceMock = mock(SqsService.class);
+        lambdaServiceMock = mock(LambdaService.class);
         service = new EventBridgeService(
                 new InMemoryStorage<>(),
                 new InMemoryStorage<>(),
                 new InMemoryStorage<>(),
                 new RegionResolver("us-east-1", "000000000000"),
-                null, null, null,
+                lambdaServiceMock, sqsServiceMock, snsServiceMock,
                 new ObjectMapper()
         );
     }
@@ -291,6 +307,21 @@ void matchesPatternByDetail() {
         assertFalse(service.matchesPattern(event, "{\"detail\":{\"status\":[\"PENDING\"]}}"));
     }
 
+    @Test
+    void matchesPatternByResources() {
+     Map<String, Object> event = Map.of(
+        "Source", "my.app",
+        "Detail", "Payload",
+        "Resources", OBJECT_MAPPER.createArrayNode().add("resource1").add("resource2")
+     );
+
+     assertTrue(service.matchesPattern(event, "{\"resources\":[\"resource1\"]}"));
+     assertTrue(service.matchesPattern(event, "{\"resources\":[\"resource2\"]}"));
+     assertTrue(service.matchesPattern(event, "{\"resources\":[\"resource1\",\"resource2\"]}"));
+     assertFalse(service.matchesPattern(event, "{\"resources\":[\"resource3\"]}"));
+     assertFalse(service.matchesPattern(event, "{\"resources\":[\"*\"]}"));
+    }
+
     @Test
     void putEventsReturnsEventIds() {
         List<Map<String, Object>> entries = List.of(
@@ -314,4 +345,78 @@ void putEventsFailsForNonExistentBus() {
         EventBridgeService.PutEventsResult result = service.putEvents(entries, REGION);
         assertEquals(1, result.failedCount());
     }
+
+    @Test
+    void putEventsShouldInvokeLambdaTarget() {
+        service.putRule("my-rule", null, "{\"source\":[\"my.app\"]}", null, RuleState.ENABLED,
+                "A test rule", null, null, REGION);
+        Target target = new Target();
+        target.setId("t1");
+        target.setArn("arn:aws:lambda:us-east-1:000000000000:function:my-function");
+        service.putTargets("my-rule", null, List.of(target), "us-east-1");
+
+        ArrayNode resources = OBJECT_MAPPER.createArrayNode().add("resource1");
+        List<Map<String, Object>> entries = List.of(
+                Map.of("Source", "my.app", "DetailType", "Test", "Detail", "{}", "Resources", resources)
+        );
+
+        EventBridgeService.PutEventsResult result = service.putEvents(entries, REGION);
+        assertEquals(0, result.failedCount());
+        assertEquals(1, result.entries().size());
+        assertNotNull(result.entries().getFirst().get("EventId"));
+        String expectedMessage = "\\{\"version\":\"0\",\"id\":\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\"source\":\"my.app\"," +
+                "\"detail-type\":\"Test\",\"account\":\"000000000000\",\"time\":\"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{9}Z\"," +
+                "\"region\":\"us-east-1\",\"resources\":\\[\"resource1\"],\"detail\":\\{},\"event-bus-name\":\"default\"}";
+        verify(lambdaServiceMock).invoke(eq(REGION), eq("my-function"),
+                argThat(bytes -> new String(bytes).matches(expectedMessage)), eq(InvocationType.Event));
+    }
+
+    @Test
+    void putEventsShouldInvokeSqsTarget() {
+        service.putRule("my-rule", null, "{\"source\":[\"my.app\"]}", null, RuleState.ENABLED,
+                "A test rule", null, null, REGION);
+        Target target = new Target();
+        target.setId("t1");
+        target.setArn("arn:aws:sqs:us-east-1:000000000000:my-queue");
+        service.putTargets("my-rule", null, List.of(target), "us-east-1");
+
+        ArrayNode resources = OBJECT_MAPPER.createArrayNode().add("resource1");
+        List<Map<String, Object>> entries = List.of(
+                Map.of("Source", "my.app", "DetailType", "Test", "Detail", "{}", "Resources", resources)
+        );
+
+        EventBridgeService.PutEventsResult result = service.putEvents(entries, REGION);
+        assertEquals(0, result.failedCount());
+        assertEquals(1, result.entries().size());
+        assertNotNull(result.entries().getFirst().get("EventId"));
+        String expectedMessage = "\\{\"version\":\"0\",\"id\":\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\"source\":\"my.app\"," +
+                "\"detail-type\":\"Test\",\"account\":\"000000000000\",\"time\":\"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{9}Z\"," +
+                "\"region\":\"us-east-1\",\"resources\":\\[\"resource1\"],\"detail\":\\{},\"event-bus-name\":\"default\"}";
+        verify(sqsServiceMock).sendMessage(eq("http://localhost:4566/000000000000/my-queue"), matches(expectedMessage), eq(0));
+    }
+
+    @Test
+    void putEventsShouldInvokeSnsTarget() {
+        service.putRule("my-rule", null, "{\"source\":[\"my.app\"]}", null, RuleState.ENABLED,
+                "A test rule", null, null, REGION);
+        Target target = new Target();
+        target.setId("t1");
+        target.setArn("arn:aws:sns:us-east-1:000000000000:my-topic");
+        service.putTargets("my-rule", null, List.of(target), "us-east-1");
+
+        ArrayNode resources = OBJECT_MAPPER.createArrayNode().add("resource1");
+        List<Map<String, Object>> entries = List.of(
+                Map.of("Source", "my.app", "DetailType", "Test", "Detail", "{}", "Resources", resources)
+        );
+
+        EventBridgeService.PutEventsResult result = service.putEvents(entries, REGION);
+        assertEquals(0, result.failedCount());
+        assertEquals(1, result.entries().size());
+        assertNotNull(result.entries().getFirst().get("EventId"));
+
+        String expectedMessage = "\\{\"version\":\"0\",\"id\":\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\"source\":\"my.app\"," +
+                "\"detail-type\":\"Test\",\"account\":\"000000000000\",\"time\":\"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{9}Z\"," +
+                "\"region\":\"us-east-1\",\"resources\":\\[\"resource1\"],\"detail\":\\{},\"event-bus-name\":\"default\"}";
+        verify(snsServiceMock).publish(eq("arn:aws:sns:us-east-1:000000000000:my-topic"), isNull(), matches(expectedMessage), eq("EventBridge"), eq(REGION));
+    }
 }
\ No newline at end of file