Repository: camunda/camunda
Pull request #28674: [Backport main] feat: add new REST API endpoints for fetching resource content + metadata
Author: login: backport-action
Labels: name: component/zeebe
description: Related to the Zeebe component/team
name: component/c8-api
description: All things unified C8 API, e.g. C8 REST
Created: 2025-02-24T11:37:16Z | Merged: 2025-03-24T12:18:32Z

# Description
Backport of #28223 to `main`.

relates to #25281
original author: @jfriedenstab


Diff:
diff --git a/service/src/main/java/io/camunda/service/ResourceServices.java b/service/src/main/java/io/camunda/service/ResourceServices.java
index 5063ca54e37a..4a3e66e892a1 100644
--- a/service/src/main/java/io/camunda/service/ResourceServices.java
+++ b/service/src/main/java/io/camunda/service/ResourceServices.java
@@ -12,7 +12,9 @@
 import io.camunda.zeebe.broker.client.api.BrokerClient;
 import io.camunda.zeebe.gateway.impl.broker.request.BrokerDeleteResourceRequest;
 import io.camunda.zeebe.gateway.impl.broker.request.BrokerDeployResourceRequest;
+import io.camunda.zeebe.gateway.impl.broker.request.BrokerFetchResourceRequest;
 import io.camunda.zeebe.protocol.impl.record.value.deployment.DeploymentRecord;
+import io.camunda.zeebe.protocol.impl.record.value.deployment.ResourceRecord;
 import io.camunda.zeebe.protocol.impl.record.value.resource.ResourceDeletionRecord;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
@@ -49,7 +51,14 @@ public CompletableFuture<ResourceDeletionRecord> deleteResource(
     return sendBrokerRequest(brokerRequest);
   }
 
+  public CompletableFuture<ResourceRecord> fetchResource(final ResourceFetchRequest request) {
+    return sendBrokerRequest(
+        new BrokerFetchResourceRequest().setResourceKey(request.resourceKey()));
+  }
+
   public record DeployResourcesRequest(Map<String, byte[]> resources, String tenantId) {}
 
   public record ResourceDeletionRequest(long resourceKey, Long operationReference) {}
+
+  public record ResourceFetchRequest(long resourceKey) {}
 }
diff --git a/zeebe/broker/src/main/java/io/camunda/zeebe/broker/transport/commandapi/CommandApiRequestReader.java b/zeebe/broker/src/main/java/io/camunda/zeebe/broker/transport/commandapi/CommandApiRequestReader.java
index b7c0d85f8b75..436b5e4e6ccb 100644
--- a/zeebe/broker/src/main/java/io/camunda/zeebe/broker/transport/commandapi/CommandApiRequestReader.java
+++ b/zeebe/broker/src/main/java/io/camunda/zeebe/broker/transport/commandapi/CommandApiRequestReader.java
@@ -23,6 +23,7 @@
 import io.camunda.zeebe.protocol.impl.record.value.compensation.CompensationSubscriptionRecord;
 import io.camunda.zeebe.protocol.impl.record.value.decision.DecisionEvaluationRecord;
 import io.camunda.zeebe.protocol.impl.record.value.deployment.DeploymentRecord;
+import io.camunda.zeebe.protocol.impl.record.value.deployment.ResourceRecord;
 import io.camunda.zeebe.protocol.impl.record.value.distribution.CommandDistributionRecord;
 import io.camunda.zeebe.protocol.impl.record.value.group.GroupRecord;
 import io.camunda.zeebe.protocol.impl.record.value.incident.IncidentRecord;
@@ -88,6 +89,7 @@ public class CommandApiRequestReader implements RequestReader<ExecuteCommandRequ
     RECORDS_BY_TYPE.put(ValueType.GROUP, GroupRecord::new);
     RECORDS_BY_TYPE.put(ValueType.MAPPING, MappingRecord::new);
     RECORDS_BY_TYPE.put(ValueType.IDENTITY_SETUP, IdentitySetupRecord::new);
+    RECORDS_BY_TYPE.put(ValueType.RESOURCE, ResourceRecord::new);
   }
 
   private UnifiedRecordValue value;
diff --git a/zeebe/gateway-protocol/src/main/proto/rest-api.yaml b/zeebe/gateway-protocol/src/main/proto/rest-api.yaml
index e4161a34fe51..a414cbfe6eeb 100644
--- a/zeebe/gateway-protocol/src/main/proto/rest-api.yaml
+++ b/zeebe/gateway-protocol/src/main/proto/rest-api.yaml
@@ -3341,6 +3341,72 @@ paths:
                 $ref: "#/components/schemas/ProblemDetail"
         "500":
           $ref: "#/components/responses/InternalServerError"
+  /resources/{resourceKey}:
+    get:
+      tags:
+        - Resource
+      operationId: getResource
+      summary: Get resource
+      description: |
+        Returns a deployed resource.
+        :::info
+        Currently, this endpoint only supports RPA resources.
+        :::
+      parameters:
+        - name: resourceKey
+          in: path
+          required: true
+          description: The unique key identifying the resource.
+          schema:
+            type: string
+      responses:
+        "200":
+          description: The resource is successfully returned.
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ResourceResult"
+        "404":
+          description: A resource with the given key was not found.
+          content:
+            application/problem+json:
+              schema:
+                $ref: "#/components/schemas/ProblemDetail"
+        "500":
+          $ref: "#/components/responses/InternalServerError"
+  /resources/{resourceKey}/content:
+    get:
+      tags:
+        - Resource
+      operationId: getResourceContent
+      summary: Get resource content
+      description: |
+        Returns the content of a deployed resource.
+        :::info
+        Currently, this endpoint only supports RPA resources.
+        :::
+      parameters:
+        - name: resourceKey
+          in: path
+          required: true
+          description: The unique key identifying the resource.
+          schema:
+            type: string
+      responses:
+        "200":
+          description: The resource content is successfully returned.
+          content:
+            application/json:
+              schema:
+                type: string
+        "404":
+          description: A resource with the given key was not found.
+          content:
+            application/problem+json:
+              schema:
+                $ref: "#/components/schemas/ProblemDetail"
+        "500":
+          $ref: "#/components/responses/InternalServerError"
 
   /element-instances/{elementInstanceKey}/variables:
     put:
@@ -7362,6 +7428,28 @@ components:
         formKey:
           description: The assigned key, which acts as a unique identifier for this form.
           type: string
+    ResourceResult:
+      type: object
+      properties:
+        resourceName:
+          description: The resource name from which this resource was parsed.
+          type: string
+        version:
+          description: The assigned resource version.
+          type: integer
+          format: int32
+        versionTag:
+          description: The version tag of this resource.
+          type: string
+        resourceId:
+          description: The resource ID of this resource.
+          type: string
+        tenantId:
+          description: The tenant ID of this resource.
+          type: string
+        resourceKey:
+          description: The unique key of this resource.
+          type: string
     BatchOperationCreatedResult:
       type: object
       properties:
diff --git a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/ResponseMapper.java b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/ResponseMapper.java
index b22fb5436ef2..d72a3ed9710b 100644
--- a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/ResponseMapper.java
+++ b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/ResponseMapper.java
@@ -49,6 +49,7 @@
 import io.camunda.zeebe.gateway.protocol.rest.MatchedDecisionRuleItem;
 import io.camunda.zeebe.gateway.protocol.rest.MessageCorrelationResult;
 import io.camunda.zeebe.gateway.protocol.rest.MessagePublicationResult;
+import io.camunda.zeebe.gateway.protocol.rest.ResourceResult;
 import io.camunda.zeebe.gateway.protocol.rest.RoleCreateResult;
 import io.camunda.zeebe.gateway.protocol.rest.SignalBroadcastResult;
 import io.camunda.zeebe.gateway.protocol.rest.TenantCreateResult;
@@ -67,6 +68,7 @@
 import io.camunda.zeebe.protocol.impl.record.value.deployment.DeploymentRecord;
 import io.camunda.zeebe.protocol.impl.record.value.deployment.FormMetadataRecord;
 import io.camunda.zeebe.protocol.impl.record.value.deployment.ResourceMetadataRecord;
+import io.camunda.zeebe.protocol.impl.record.value.deployment.ResourceRecord;
 import io.camunda.zeebe.protocol.impl.record.value.group.GroupRecord;
 import io.camunda.zeebe.protocol.impl.record.value.job.JobRecord;
 import io.camunda.zeebe.protocol.impl.record.value.message.MessageCorrelationRecord;
@@ -315,6 +317,23 @@ public static ResponseEntity<Object> toDeployResourceResponse(
     return new ResponseEntity<>(response, HttpStatus.OK);
   }
 
+  public static ResponseEntity<Object> toGetResourceResponse(final ResourceRecord resourceRecord) {
+    final var response =
+        new ResourceResult()
+            .resourceName(resourceRecord.getResourceName())
+            .version(resourceRecord.getVersion())
+            .versionTag(resourceRecord.getVersionTag())
+            .resourceId(resourceRecord.getResourceId())
+            .tenantId(resourceRecord.getTenantId())
+            .resourceKey(String.valueOf(resourceRecord.getResourceKey()));
+    return ResponseEntity.ok(response);
+  }
+
+  public static ResponseEntity<Object> toGetResourceContentResponse(
+      final ResourceRecord resourceRecord) {
+    return ResponseEntity.ok(resourceRecord.getResourceProp());
+  }
+
   public static ResponseEntity<Object> toMessagePublicationResponse(
       final BrokerResponse<MessageRecord> brokerResponse) {
 
diff --git a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/ResourceController.java b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/ResourceController.java
index 6845c37762fa..a6194ef780f3 100644
--- a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/ResourceController.java
+++ b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/ResourceController.java
@@ -11,11 +11,14 @@
 import io.camunda.service.ResourceServices;
 import io.camunda.service.ResourceServices.DeployResourcesRequest;
 import io.camunda.service.ResourceServices.ResourceDeletionRequest;
+import io.camunda.service.ResourceServices.ResourceFetchRequest;
 import io.camunda.zeebe.gateway.protocol.rest.DeleteResourceRequest;
 import io.camunda.zeebe.gateway.rest.RequestMapper;
 import io.camunda.zeebe.gateway.rest.ResponseMapper;
 import io.camunda.zeebe.gateway.rest.RestErrorMapper;
+import io.camunda.zeebe.gateway.rest.annotation.CamundaGetMapping;
 import io.camunda.zeebe.gateway.rest.annotation.CamundaPostMapping;
+import io.camunda.zeebe.protocol.impl.record.value.deployment.ResourceRecord;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import org.springframework.http.MediaType;
@@ -56,6 +59,20 @@ public CompletableFuture<ResponseEntity<Object>> deleteResource(
         .fold(RestErrorMapper::mapProblemToCompletedResponse, this::delete);
   }
 
+  @CamundaGetMapping(path = "/resources/{resourceKey}")
+  public CompletableFuture<ResponseEntity<Object>> getResource(
+      @PathVariable final long resourceKey) {
+    return RequestMapper.executeServiceMethod(
+        () -> fetchResource(resourceKey), ResponseMapper::toGetResourceResponse);
+  }
+
+  @CamundaGetMapping(path = "/resources/{resourceKey}/content")
+  public CompletableFuture<ResponseEntity<Object>> getResourceContent(
+      @PathVariable final long resourceKey) {
+    return RequestMapper.executeServiceMethod(
+        () -> fetchResource(resourceKey), ResponseMapper::toGetResourceContentResponse);
+  }
+
   private CompletableFuture<ResponseEntity<Object>> deployResources(
       final DeployResourcesRequest request) {
     return RequestMapper.executeServiceMethod(
@@ -73,4 +90,10 @@ private CompletableFuture<ResponseEntity<Object>> delete(final ResourceDeletionR
                 .withAuthentication(RequestMapper.getAuthentication())
                 .deleteResource(request));
   }
+
+  private CompletableFuture<ResourceRecord> fetchResource(final long resourceKey) {
+    return resourceServices
+        .withAuthentication(RequestMapper.getAuthentication())
+        .fetchResource(new ResourceFetchRequest(resourceKey));
+  }
 }
diff --git a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/ResourceControllerTest.java b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/ResourceControllerTest.java
index dc11bf863f47..a79fa8929fc6 100644
--- a/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/ResourceControllerTest.java
+++ b/zeebe/gateway-rest/src/test/java/io/camunda/zeebe/gateway/rest/controller/ResourceControllerTest.java
@@ -17,9 +17,17 @@
 import io.camunda.service.ResourceServices;
 import io.camunda.service.ResourceServices.DeployResourcesRequest;
 import io.camunda.service.ResourceServices.ResourceDeletionRequest;
+import io.camunda.service.ResourceServices.ResourceFetchRequest;
+import io.camunda.service.exception.CamundaBrokerException;
+import io.camunda.zeebe.broker.client.api.dto.BrokerError;
+import io.camunda.zeebe.broker.client.api.dto.BrokerRejection;
 import io.camunda.zeebe.gateway.rest.RestControllerTest;
 import io.camunda.zeebe.protocol.impl.record.value.deployment.DeploymentRecord;
+import io.camunda.zeebe.protocol.impl.record.value.deployment.ResourceRecord;
 import io.camunda.zeebe.protocol.impl.record.value.resource.ResourceDeletionRecord;
+import io.camunda.zeebe.protocol.record.ErrorCode;
+import io.camunda.zeebe.protocol.record.RejectionType;
+import io.camunda.zeebe.protocol.record.intent.ResourceIntent;
 import io.camunda.zeebe.util.buffer.BufferUtil;
 import java.util.concurrent.CompletableFuture;
 import org.junit.jupiter.api.BeforeEach;
@@ -29,6 +37,7 @@
 import org.mockito.Mockito;
 import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
 import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.http.client.MultipartBodyBuilder;
 
@@ -38,6 +47,8 @@ public class ResourceControllerTest extends RestControllerTest {
   static final String RESOURCES_BASE_URL = "/v2";
   static final String DEPLOY_RESOURCES_ENDPOINT = RESOURCES_BASE_URL + "/deployments";
   static final String DELETE_RESOURCE_ENDPOINT = RESOURCES_BASE_URL + "/resources/%s/deletion";
+  static final String GET_RESOURCE_ENDPOINT = RESOURCES_BASE_URL + "/resources/%s";
+  static final String GET_RESOURCE_CONTENT_ENDPOINT = RESOURCES_BASE_URL + "/resources/%s/content";
 
   @MockBean ResourceServices resourceServices;
   @MockBean MultiTenancyConfiguration multiTenancyCfg;
@@ -482,4 +493,274 @@ void shouldRejectDeleteResourceWithOperationReferenceNotValid() {
         .expectBody()
         .json(expectedBody);
   }
+
+  @Test
+  void shouldGetResource() {
+    // given
+    when(resourceServices.fetchResource(new ResourceFetchRequest(1)))
+        .thenReturn(
+            CompletableFuture.completedFuture(
+                new ResourceRecord()
+                    .setResourceName("test.rpa")
+                    .setResourceId("test")
+                    .setVersion(2)
+                    .setVersionTag("v2.0")
+                    .setTenantId("tenant-1")
+                    .setResourceKey(1)));
+
+    // when / then
+    webClient
+        .get()
+        .uri(GET_RESOURCE_ENDPOINT.formatted(1))
+        .exchange()
+        .expectStatus()
+        .isOk()
+        .expectBody()
+        .json(
+            """
+          {
+            "resourceName": "test.rpa",
+            "version": 2,
+            "versionTag": "v2.0",
+            "resourceId": "test",
+            "tenantId": "tenant-1",
+            "resourceKey": "1"
+          }
+          """);
+  }
+
+  @Test
+  void getResourceShouldYieldNotFoundWhenResourceNotFound() {
+    // given
+    when(resourceServices.fetchResource(new ResourceFetchRequest(1)))
+        .thenReturn(
+            CompletableFuture.failedFuture(
+                new CamundaBrokerException(
+                    new BrokerRejection(
+                        ResourceIntent.FETCH, 1L, RejectionType.NOT_FOUND, "Resource not found"))));
+    final var url = GET_RESOURCE_ENDPOINT.formatted(1);
+
+    // when / then
+    webClient
+        .get()
+        .uri(url)
+        .exchange()
+        .expectStatus()
+        .isNotFound()
+        .expectHeader()
+        .contentType(MediaType.APPLICATION_PROBLEM_JSON)
+        .expectBody()
+        .json(
+            """
+            {
+              "type": "about:blank",
+              "status": 404,
+              "title": "NOT_FOUND",
+              "detail": "Command 'FETCH' rejected with code 'NOT_FOUND': Resource not found",
+              "instance": "%s"
+            }
+            """
+                .formatted(url));
+  }
+
+  @Test
+  void getResourceShouldYieldInternalServerErrorForProcessingErrorRejection() {
+    // given
+    when(resourceServices.fetchResource(new ResourceFetchRequest(1)))
+        .thenReturn(
+            CompletableFuture.failedFuture(
+                new CamundaBrokerException(
+                    new BrokerRejection(
+                        ResourceIntent.FETCH,
+                        1L,
+                        RejectionType.PROCESSING_ERROR,
+                        "something went wrong"))));
+    final var url = GET_RESOURCE_ENDPOINT.formatted(1);
+
+    // when / then
+    webClient
+        .get()
+        .uri(url)
+        .exchange()
+        .expectStatus()
+        .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)
+        .expectHeader()
+        .contentType(MediaType.APPLICATION_PROBLEM_JSON)
+        .expectBody()
+        .json(
+            """
+            {
+              "type": "about:blank",
+              "status": 500,
+              "title": "PROCESSING_ERROR",
+              "detail": "Command 'FETCH' rejected with code 'PROCESSING_ERROR': something went wrong",
+              "instance": "%s"
+            }
+            """
+                .formatted(url));
+  }
+
+  @Test
+  void getResourceShouldYieldInternalServerErrorForBrokerError() {
+    // given
+    when(resourceServices.fetchResource(new ResourceFetchRequest(1)))
+        .thenReturn(
+            CompletableFuture.failedFuture(
+                new CamundaBrokerException(
+                    new BrokerError(ErrorCode.INTERNAL_ERROR, "something went wrong"))));
+    final var url = GET_RESOURCE_ENDPOINT.formatted(1);
+
+    // when / then
+    webClient
+        .get()
+        .uri(url)
+        .exchange()
+        .expectStatus()
+        .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)
+        .expectHeader()
+        .contentType(MediaType.APPLICATION_PROBLEM_JSON)
+        .expectBody()
+        .json(
+            """
+            {
+              "type": "about:blank",
+              "status": 500,
+              "title": "INTERNAL_ERROR",
+              "detail": "Received an unexpected error from the broker, code: INTERNAL_ERROR, message: something went wrong",
+              "instance": "%s"
+            }
+            """
+                .formatted(url));
+  }
+
+  @Test
+  void shouldGetResourceContent() {
+    // given
+    final var content =
+        """
+        {
+          "id": "test",
+          "name": "test RPA script",
+          "script": "foo"
+        }
+        """;
+    when(resourceServices.fetchResource(new ResourceFetchRequest(1)))
+        .thenReturn(
+            CompletableFuture.completedFuture(
+                new ResourceRecord().setResource(BufferUtil.wrapString(content))));
+
+    // when / then
+    webClient
+        .get()
+        .uri(GET_RESOURCE_CONTENT_ENDPOINT.formatted(1))
+        .exchange()
+        .expectStatus()
+        .isOk()
+        .expectBody()
+        .json(content);
+  }
+
+  @Test
+  void getResourceContentShouldYieldNotFoundWhenResourceNotFound() {
+    // given
+    when(resourceServices.fetchResource(new ResourceFetchRequest(1)))
+        .thenReturn(
+            CompletableFuture.failedFuture(
+                new CamundaBrokerException(
+                    new BrokerRejection(
+                        ResourceIntent.FETCH, 1L, RejectionType.NOT_FOUND, "Resource not found"))));
+    final var url = GET_RESOURCE_CONTENT_ENDPOINT.formatted(1);
+
+    // when / then
+    webClient
+        .get()
+        .uri(url)
+        .exchange()
+        .expectStatus()
+        .isNotFound()
+        .expectHeader()
+        .contentType(MediaType.APPLICATION_PROBLEM_JSON)
+        .expectBody()
+        .json(
+            """
+            {
+              "type": "about:blank",
+              "status": 404,
+              "title": "NOT_FOUND",
+              "detail": "Command 'FETCH' rejected with code 'NOT_FOUND': Resource not found",
+              "instance": "%s"
+            }
+            """
+                .formatted(url));
+  }
+
+  @Test
+  void getResourceContentShouldYieldInternalServerErrorForProcessingErrorRejection() {
+    // given
+    when(resourceServices.fetchResource(new ResourceFetchRequest(1)))
+        .thenReturn(
+            CompletableFuture.failedFuture(
+                new CamundaBrokerException(
+                    new BrokerRejection(
+                        ResourceIntent.FETCH,
+                        1L,
+                        RejectionType.PROCESSING_ERROR,
+                        "something went wrong"))));
+    final var url = GET_RESOURCE_CONTENT_ENDPOINT.formatted(1);
+
+    // when / then
+    webClient
+        .get()
+        .uri(url)
+        .exchange()
+        .expectStatus()
+        .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)
+        .expectHeader()
+        .contentType(MediaType.APPLICATION_PROBLEM_JSON)
+        .expectBody()
+        .json(
+            """
+            {
+              "type": "about:blank",
+              "status": 500,
+              "title": "PROCESSING_ERROR",
+              "detail": "Command 'FETCH' rejected with code 'PROCESSING_ERROR': something went wrong",
+              "instance": "%s"
+            }
+            """
+                .formatted(url));
+  }
+
+  @Test
+  void getResourceContentShouldYieldInternalServerErrorForBrokerError() {
+    // given
+    when(resourceServices.fetchResource(new ResourceFetchRequest(1)))
+        .thenReturn(
+            CompletableFuture.failedFuture(
+                new CamundaBrokerException(
+                    new BrokerError(ErrorCode.INTERNAL_ERROR, "something went wrong"))));
+    final var url = GET_RESOURCE_CONTENT_ENDPOINT.formatted(1);
+
+    // when / then
+    webClient
+        .get()
+        .uri(url)
+        .exchange()
+        .expectStatus()
+        .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)
+        .expectHeader()
+        .contentType(MediaType.APPLICATION_PROBLEM_JSON)
+        .expectBody()
+        .json(
+            """
+            {
+              "type": "about:blank",
+              "status": 500,
+              "title": "INTERNAL_ERROR",
+              "detail": "Received an unexpected error from the broker, code: INTERNAL_ERROR, message: something went wrong",
+              "instance": "%s"
+            }
+            """
+                .formatted(url));
+  }
 }
diff --git a/zeebe/gateway/src/main/java/io/camunda/zeebe/gateway/impl/broker/request/BrokerFetchResourceRequest.java b/zeebe/gateway/src/main/java/io/camunda/zeebe/gateway/impl/broker/request/BrokerFetchResourceRequest.java
new file mode 100644
index 000000000000..a8b8c8169450
--- /dev/null
+++ b/zeebe/gateway/src/main/java/io/camunda/zeebe/gateway/impl/broker/request/BrokerFetchResourceRequest.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH under
+ * one or more contributor license agreements. See the NOTICE file distributed
+ * with this work for additional information regarding copyright ownership.
+ * Licensed under the Camunda License 1.0. You may not use this file
+ * except in compliance with the Camunda License 1.0.
+ */
+package io.camunda.zeebe.gateway.impl.broker.request;
+
+import io.camunda.zeebe.broker.client.api.dto.BrokerExecuteCommand;
+import io.camunda.zeebe.protocol.impl.record.value.deployment.ResourceRecord;
+import io.camunda.zeebe.protocol.record.ValueType;
+import io.camunda.zeebe.protocol.record.intent.ResourceIntent;
+import org.agrona.DirectBuffer;
+
+public final class BrokerFetchResourceRequest extends BrokerExecuteCommand<ResourceRecord> {
+
+  private final ResourceRecord requestDto = new ResourceRecord();
+
+  public BrokerFetchResourceRequest() {
+    super(ValueType.RESOURCE, ResourceIntent.FETCH);
+  }
+
+  public BrokerFetchResourceRequest setResourceKey(final long resourceKey) {
+    requestDto.setResourceKey(resourceKey);
+    request.setKey(resourceKey);
+    return this;
+  }
+
+  @Override
+  public ResourceRecord getRequestWriter() {
+    return requestDto;
+  }
+
+  @Override
+  protected ResourceRecord toResponseDto(final DirectBuffer buffer) {
+    final ResourceRecord responseDto = new ResourceRecord();
+    responseDto.wrap(buffer);
+    return responseDto;
+  }
+}


Reviews:
user: login: korthout
body: Thanks @jfriedenstab 

💭 While the changes should result in similar behavior, this backport is not entirely the same as its source, with differences in the API spec. For example some changes are made in 47bbf5e6fcc0e870331ed8f452d87204ed186f48. However, why these differences exist is not explained.

💭 I don't think the changes lead to differences in behavior.

👍 LGTM

🔧 Please explain why this changes the API spec before merging.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-24T10:29:34Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T10:33:57Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T10:35:08Z

Review comments:
diff_hunk: @@ -3341,6 +3341,72 @@ paths:
                 $ref: "#/components/schemas/ProblemDetail"
         "500":
           $ref: "#/components/responses/InternalServerError"
+  /resources/{resourceKey}:
+    get:
+      tags:
+        - Resource
+      operationId: getResource
path: zeebe/gateway-protocol/src/main/proto/rest-api.yaml
user: login: korthout
body: ❓ The `operationId` is not in the original PR. Is this expected?
created_at: 2025-03-24T10:21:11Z
author_association: MEMBER
diff_hunk: @@ -3341,6 +3341,72 @@ paths:
                 $ref: "#/components/schemas/ProblemDetail"
         "500":
           $ref: "#/components/responses/InternalServerError"
+  /resources/{resourceKey}:
+    get:
+      tags:
+        - Resource
+      operationId: getResource
+      summary: Get resource
+      description: |
+        Returns a deployed resource.
+        :::info
+        Currently, this endpoint only supports RPA resources.
+        :::
+      parameters:
+        - name: resourceKey
+          in: path
+          required: true
+          description: The unique key identifying the resource.
+          schema:
+            type: string
+      responses:
+        "200":
+          description: The resource is successfully returned.
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ResourceResult"
+        "404":
+          description: A resource with the given key was not found.
+          content:
+            application/problem+json:
+              schema:
+                $ref: "#/components/schemas/ProblemDetail"
+        "500":
+          $ref: "#/components/responses/InternalServerError"
path: zeebe/gateway-protocol/src/main/proto/rest-api.yaml
user: login: korthout
body: In the original PR the 500 is described in more detail. Is this expected?
created_at: 2025-03-24T10:22:10Z
author_association: MEMBER
diff_hunk: @@ -3341,6 +3341,72 @@ paths:
                 $ref: "#/components/schemas/ProblemDetail"
         "500":
           $ref: "#/components/responses/InternalServerError"
+  /resources/{resourceKey}:
+    get:
+      tags:
+        - Resource
+      operationId: getResource
+      summary: Get resource
+      description: |
+        Returns a deployed resource.
+        :::info
+        Currently, this endpoint only supports RPA resources.
+        :::
+      parameters:
+        - name: resourceKey
+          in: path
+          required: true
+          description: The unique key identifying the resource.
+          schema:
+            type: string
+      responses:
+        "200":
+          description: The resource is successfully returned.
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ResourceResult"
+        "404":
+          description: A resource with the given key was not found.
+          content:
+            application/problem+json:
+              schema:
+                $ref: "#/components/schemas/ProblemDetail"
+        "500":
+          $ref: "#/components/responses/InternalServerError"
path: zeebe/gateway-protocol/src/main/proto/rest-api.yaml
user: login: tmetzke
body: Yes, we streamlined some API responses on `main` in the OpenAPI spec for a more consistent API UX.
created_at: 2025-03-24T10:33:57Z
author_association: MEMBER
diff_hunk: @@ -3341,6 +3341,72 @@ paths:
                 $ref: "#/components/schemas/ProblemDetail"
         "500":
           $ref: "#/components/responses/InternalServerError"
+  /resources/{resourceKey}:
+    get:
+      tags:
+        - Resource
+      operationId: getResource
path: zeebe/gateway-protocol/src/main/proto/rest-api.yaml
user: login: tmetzke
body: Yes, this is only enforced for the spec on `main` to provide more consistent URLs in our generated public docs starting with 8.8. Otherwise, they depend on the summary of the path.
created_at: 2025-03-24T10:35:08Z
author_association: MEMBER

PR comments:
user: login: backport-action
created_at: 2025-02-24T11:37:18Z
body: Please cherry-pick the changes locally and resolve any conflicts.
```bash
git fetch origin backport-28223-to-main
git worktree add --checkout .worktree/backport-28223-to-main backport-28223-to-main
cd .worktree/backport-28223-to-main
git reset --hard HEAD^
git cherry-pick -x d7e6c6b76642d9fd493fa942f38461bf14d429a3 e402a846269a0771699552c01e7ec78ac4bb5a69
git push --force-with-lease
```
author_association: COLLABORATOR
user: login: tmetzke
created_at: 2025-03-04T08:00:40Z
body: @korthout, can you assist @jfriedenstab with this forward-port if needed? It shouldn't lie around for too long since we're moving fast on `main` regarding API and this might require further changes later otherwise.
author_association: MEMBER
user: login: tmetzke
created_at: 2025-03-04T08:01:52Z
body: @jfriedenstab, I assume this is waiting for https://github.com/camunda/camunda/pull/28511 to be merged?
author_association: MEMBER
user: login: korthout
created_at: 2025-03-04T09:00:42Z
body: @jfriedenstab Let me know if you need my assistance
author_association: MEMBER
user: login: jfriedenstab
created_at: 2025-03-04T09:43:36Z
body: Hi @tmetzke,
Yes, exactly, this one is waiting for https://github.com/camunda/camunda/pull/28511 which still requires some clarification regarding the authorization checks. I hope that we can figure that out soon so that I can then also finish this PR 🙂 (probably, no assistance needed, but thank you @korthout!).
author_association: CONTRIBUTOR
user: login: tmetzke
created_at: 2025-03-20T15:06:03Z
body: @koevskinikola, this is what needs to be changed in 8.8 now to use the regular secondary storage querying instead of executing an engine command to fetch resource data. Is this tracked somewhere?
author_association: MEMBER
user: login: jfriedenstab
created_at: 2025-03-20T15:48:45Z
body: Finally, this is ready for review.

@korthout I assigned you as a reviewer since you also reviewed the original PR.
author_association: CONTRIBUTOR
user: login: jfriedenstab
created_at: 2025-03-20T16:13:54Z
body: > Is this tracked somewhere?

@tmetzke I don't think we have an issue for this yet, but please correct me if I'm wrong, @koevskinikola.
author_association: CONTRIBUTOR
user: login: koevskinikola
created_at: 2025-03-21T09:24:04Z
body: @tmetzke @jfriedenstab it looks like I forgot to create an issue for this. We discussed the topic [here](https://camunda.slack.com/archives/C07DA2WBMAL/p1737557015819819?thread_ts=1737388797.061439&cid=C07DA2WBMAL), but it seems I overlooked the issue creation. 

I've created the issue for that now: https://github.com/camunda/camunda/issues/29957
author_association: MEMBER
user: login: jfriedenstab
created_at: 2025-03-24T11:24:09Z
body: Thanks, @tmetzke for answering the questions! 👍🏻 I've also added a description now to https://github.com/camunda/camunda/pull/28674/commits/e57cdff56f733c68e6ddd55738b138c9c8f3c3c6.

By the way, @korthout: Without the extra commit, the [OpenAPI linting job](https://github.com/camunda/camunda/actions/runs/14034029947/job/39288173401?pr=28674) would fail; that's also how I noticed that the changes were needed 🙂.
author_association: CONTRIBUTOR
user: login: jfriedenstab
created_at: 2025-03-24T11:51:37Z
body: FYI: I noticed that the wrong property for referencing the response schema should also be fixed in 8.7. I created https://github.com/camunda/camunda/pull/30001 for this.
author_association: CONTRIBUTOR