Repository: camunda/camunda
Pull request #29628: feat: setup Swagger UI for Camunda APIs [stable/8.7]
Author: login: nathansandi
Labels: name: component/zeebe
description: Related to the Zeebe component/team
name: component/operate
description: Related to the Operate component/team
name: component/tasklist
description: Related to the Tasklist component/team
name: component/optimize
description: Related to Optimize component/team
name: backport stable/8.6
description: OUTDATED, DON'T USE (Backport a pull request to stable/8.6)
name: version:8.6.13
name: version:8.7.0
description: Marks an issue as being completely or in parts released in 8.7.0
Created: 2025-03-14T15:48:00Z | Merged: 2025-03-25T11:50:14Z

## Description

This PR addresses the issue where **Swagger-UI was not functioning correctly** in Camunda 8.7 due to:
- **Incompatible `spring-docs` version (2.6.0) with Spring 3** causing API documentation failures.
- **Missing OpenAPI configuration**, preventing proper loading of the static YAML file for C8 Unified API.

**Changes Implemented**:
- Upgraded `spring-docs` to **2.8.5**, ensuring compatibility with **Spring 3.4**.
- Create a custom controller to load static file
- Implemented proper OpenAPI setup to **load and generate documentation** from `rest-api.yaml`.
- Ensured Swagger groups are correctly displayed, including **V1 APIs** plus the static file

## Related issues

closes #https://github.com/camunda/camunda/issues/29626



Diff:
diff --git a/dist/src/main/resources/application.properties b/dist/src/main/resources/application.properties
index 2f1bba1eafd1..bac5733a8040 100644
--- a/dist/src/main/resources/application.properties
+++ b/dist/src/main/resources/application.properties
@@ -84,3 +84,7 @@ spring.autoconfigure.exclude=\
   org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration, \
   org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration, \
   org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
+
+# Add a custom path for Camunda 8 Docs - Swagger UI
+springdoc.swagger-ui.urls[0].name=Camunda 8 API
+springdoc.swagger-ui.urls[0].url=/rest-api.yaml
diff --git a/parent/pom.xml b/parent/pom.xml
index 24f33e83ec31..cf4a9da1d5e1 100644
--- a/parent/pom.xml
+++ b/parent/pom.xml
@@ -150,7 +150,7 @@
     <version.thymeleaf>3.1.3.RELEASE</version.thymeleaf>
     <version.unboundid-ldapsdk>7.0.2</version.unboundid-ldapsdk>
     <version.parsson>1.1.7</version.parsson>
-    <version.springdoc>2.6.0</version.springdoc>
+    <version.springdoc>2.8.5</version.springdoc>
     <version.jakarta.json>2.0.1</version.jakarta.json>
 
     <version.node>v20.12.2</version.node>
diff --git a/zeebe/gateway-rest/pom.xml b/zeebe/gateway-rest/pom.xml
index 006bdd6a5bc4..ddf073b0b4d0 100644
--- a/zeebe/gateway-rest/pom.xml
+++ b/zeebe/gateway-rest/pom.xml
@@ -196,6 +196,11 @@
       <artifactId>jackson-annotations</artifactId>
     </dependency>
 
+    <dependency>
+      <groupId>com.fasterxml.jackson.dataformat</groupId>
+      <artifactId>jackson-dataformat-yaml</artifactId>
+    </dependency>
+
     <dependency>
       <groupId>com.fasterxml.jackson.core</groupId>
       <artifactId>jackson-databind</artifactId>
@@ -357,6 +362,15 @@
   </dependencies>
 
   <build>
+    <resources>
+      <resource>
+        <targetPath>apidoc</targetPath>
+        <directory>../gateway-protocol/src/main/proto</directory>
+        <includes>
+          <include>rest-api.yaml</include>
+        </includes>
+      </resource>
+    </resources>
     <plugins>
       <plugin>
         <groupId>org.openapitools</groupId>
diff --git a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/OpenApiResourceConfig.java b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/OpenApiResourceConfig.java
new file mode 100644
index 000000000000..bc36b7ea7536
--- /dev/null
+++ b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/OpenApiResourceConfig.java
@@ -0,0 +1,30 @@
+/*
+ * 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.rest.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.camunda.zeebe.gateway.rest.util.YamlToJsonResourceTransformer;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class OpenApiResourceConfig implements WebMvcConfigurer {
+
+  @Autowired private ObjectMapper objectMapper;
+
+  @Override
+  public void addResourceHandlers(final ResourceHandlerRegistry registry) {
+    registry
+        .addResourceHandler("/rest-api.yaml")
+        .addResourceLocations("classpath:/apidoc/")
+        .resourceChain(true)
+        .addTransformer(new YamlToJsonResourceTransformer(objectMapper));
+  }
+}
diff --git a/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/util/YamlToJsonResourceTransformer.java b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/util/YamlToJsonResourceTransformer.java
new file mode 100644
index 000000000000..363f2bcbdf28
--- /dev/null
+++ b/zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/util/YamlToJsonResourceTransformer.java
@@ -0,0 +1,42 @@
+/*
+ * 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.rest.util;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import jakarta.servlet.http.HttpServletRequest;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import org.springframework.core.io.Resource;
+import org.springframework.web.servlet.resource.ResourceTransformer;
+import org.springframework.web.servlet.resource.ResourceTransformerChain;
+import org.springframework.web.servlet.resource.TransformedResource;
+
+public class YamlToJsonResourceTransformer implements ResourceTransformer {
+  private final ObjectMapper jsonMapper;
+
+  public YamlToJsonResourceTransformer(final ObjectMapper jsonMapper) {
+    this.jsonMapper = jsonMapper;
+  }
+
+  @Override
+  public Resource transform(
+      final HttpServletRequest request,
+      final Resource resource,
+      final ResourceTransformerChain transformerChain)
+      throws IOException {
+    try (final InputStream is = resource.getInputStream()) {
+      final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
+      final Object yamlObject = yamlMapper.readValue(is, Object.class);
+      final String json = jsonMapper.writeValueAsString(yamlObject);
+      final byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);
+      return new TransformedResource(resource, jsonBytes);
+    }
+  }
+}


Reviews:
user: login: houssain-barouni
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-15T14:15:50Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T14:52:33Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T14:54:26Z
user: login: nathansandi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T17:39:06Z
user: login: nathansandi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T23:40:03Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T08:21:17Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T11:06:48Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T11:16:54Z
user: login: nathansandi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T11:47:56Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T11:59:54Z
user: login: nathansandi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T12:10:04Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T13:20:56Z
user: login: nathansandi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T14:00:04Z
user: login: tmetzke
body: 👍 I really like that we use the Spring-provided resource handler now, thanks for looking into that. I think it makes our code a lot less error-prone.

💭 Can we reuse the JSON `ObjectMapper` again? That would be awesome. We would have to somehow get the object mapper into the configuration class. From there, we could pass it on to the `YamlToJsonResourceTransformer` in the constructor.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T08:34:09Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T10:34:27Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T10:35:25Z
user: login: nathansandi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T10:39:48Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T10:53:18Z
user: login: tmetzke
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T10:53:45Z
user: login: nathansandi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T11:05:39Z
user: login: danielkelemen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T11:07:48Z
user: login: tmetzke
body: Thanks, @nathansandi, for your effort here and your perseverance with all those exploratory suggestions 😅 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-25T11:29:56Z

Review comments:
diff_hunk: @@ -0,0 +1,48 @@
+/*
+ * 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.rest.config;
+
+import io.swagger.v3.oas.models.ExternalDocumentation;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.info.License;
+import org.springdoc.core.models.GroupedOpenApi;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+
+  @Bean
+  public GroupedOpenApi zeebeApi() {
+    return GroupedOpenApi.builder()
+        .group("Camunda 8 API")
+        .addOpenApiCustomizer(
+            openApi -> {
+              try {
+                openApi
+                    .info(
+                        new Info()
+                            .title("Camunda 8 API")
+                            .version("1.0")
+                            .description(
+                                "The Camunda 8 REST API is a REST API designed to interact with a Camunda 8 cluster.")
+                            .license(
+                                new License()
+                                    .name("Camunda License 1.0")
+                                    .url("https://camunda.com/legal/")))
+                    .externalDocs(
+                        new ExternalDocumentation()
+                            .description("Camunda 8 API Documentation")
+                            .url("classpath:proto/rest-api.yaml")); // Mantendo no classpath
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/OpenApiConfig.java
user: login: houssain-barouni
body: 🇧🇷  :) 
created_at: 2025-03-15T14:15:50Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,48 @@
+/*
+ * 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.rest.config;
+
+import io.swagger.v3.oas.models.ExternalDocumentation;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.info.License;
+import org.springdoc.core.models.GroupedOpenApi;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+
+  @Bean
+  public GroupedOpenApi zeebeApi() {
+    return GroupedOpenApi.builder()
+        .group("Camunda 8 API")
+        .addOpenApiCustomizer(
+            openApi -> {
+              try {
+                openApi
+                    .info(
+                        new Info()
+                            .title("Camunda 8 API")
+                            .version("1.0")
+                            .description(
+                                "The Camunda 8 REST API is a REST API designed to interact with a Camunda 8 cluster.")
+                            .license(
+                                new License()
+                                    .name("Camunda License 1.0")
+                                    .url("https://camunda.com/legal/")))
+                    .externalDocs(
+                        new ExternalDocumentation()
+                            .description("Camunda 8 API Documentation")
+                            .url("classpath:proto/rest-api.yaml"));
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/OpenApiConfig.java
user: login: tmetzke
body: ❓ Could all of this also be fetched from the YAML file? It's all declared there. Otherwise, we have to maintain it twice.
created_at: 2025-03-18T14:52:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,48 @@
+/*
+ * 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.rest.config;
+
+import io.swagger.v3.oas.models.ExternalDocumentation;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.info.License;
+import org.springdoc.core.models.GroupedOpenApi;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+
+  @Bean
+  public GroupedOpenApi zeebeApi() {
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/OpenApiConfig.java
user: login: tmetzke
body: 🔧 I guess we should rather call this `camundaApi`? What about the Zeebe REST API, will this also be picked up, by the way? It's residing in https://github.com/camunda/camunda/blob/main/zeebe/gateway-protocol/src/main/proto/rest-api-v1.yaml.
created_at: 2025-03-18T14:54:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,48 @@
+/*
+ * 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.rest.config;
+
+import io.swagger.v3.oas.models.ExternalDocumentation;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.info.License;
+import org.springdoc.core.models.GroupedOpenApi;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+
+  @Bean
+  public GroupedOpenApi zeebeApi() {
+    return GroupedOpenApi.builder()
+        .group("Camunda 8 API")
+        .addOpenApiCustomizer(
+            openApi -> {
+              try {
+                openApi
+                    .info(
+                        new Info()
+                            .title("Camunda 8 API")
+                            .version("1.0")
+                            .description(
+                                "The Camunda 8 REST API is a REST API designed to interact with a Camunda 8 cluster.")
+                            .license(
+                                new License()
+                                    .name("Camunda License 1.0")
+                                    .url("https://camunda.com/legal/")))
+                    .externalDocs(
+                        new ExternalDocumentation()
+                            .description("Camunda 8 API Documentation")
+                            .url("classpath:proto/rest-api.yaml"));
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/OpenApiConfig.java
user: login: nathansandi
body: I have corrected this to the link to the external Camunda Docs.  Related to the question, I answered down [here](https://github.com/camunda/camunda/pull/29628#issuecomment-2734130793) 


created_at: 2025-03-18T17:39:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,48 @@
+/*
+ * 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.rest.config;
+
+import io.swagger.v3.oas.models.ExternalDocumentation;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.info.License;
+import org.springdoc.core.models.GroupedOpenApi;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+
+  @Bean
+  public GroupedOpenApi zeebeApi() {
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/OpenApiConfig.java
user: login: nathansandi
body: Just worked over this one to rename the method. 

Also the Group is filtering only for v2 apis on Camunda API

Should we create a group for V1? (as a follow-up)

<img width="1478" alt="image" src="https://github.com/user-attachments/assets/fc8dbde3-2f36-4c65-8653-1a9313c1a6ba" />



created_at: 2025-03-18T23:40:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,48 @@
+/*
+ * 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.rest.config;
+
+import io.swagger.v3.oas.models.ExternalDocumentation;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.info.License;
+import org.springdoc.core.models.GroupedOpenApi;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+
+  @Bean
+  public GroupedOpenApi zeebeApi() {
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/OpenApiConfig.java
user: login: tmetzke
body: > Should we create a group for V1? (as a follow-up)

That would be good, yeah
created_at: 2025-03-19T08:21:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,62 @@
+/*
+ * 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.rest.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import io.camunda.zeebe.gateway.rest.RestErrorMapper;
+import jakarta.annotation.PostConstruct;
+import java.nio.charset.StandardCharsets;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ProblemDetail;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@CamundaRestController
+public class CamundaOpenApiController {
+
+  @Autowired private ObjectMapper objectMapper;
+
+  private String cachedJsonContent;
+
+  @PostConstruct
+  public void init() {
+    try {
+      final ClassPathResource resource = new ClassPathResource("apidoc/rest-api.yaml");
+      final String yamlContent =
+          new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+
+      final var yamlMapper = new Jackson2ObjectMapperBuilder().factory(new YAMLFactory()).build();
+
+      final Object yamlObject = yamlMapper.readValue(yamlContent, Object.class);
+
+      cachedJsonContent = objectMapper.writeValueAsString(yamlObject);
+
+    } catch (final Exception e) {
+      throw new RuntimeException("Failed to load and parse OpenAPI YAML file at startup", e);
+    }
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/CamundaOpenApiController.java
user: login: tmetzke
body: ❓ This can still happen multiple times now since this bean can be created multiple times in the application lifecycle, right? Not saying that's a blocker but wanted to raise this for understanding.
created_at: 2025-03-24T11:06:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,62 @@
+/*
+ * 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.rest.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import io.camunda.zeebe.gateway.rest.RestErrorMapper;
+import jakarta.annotation.PostConstruct;
+import java.nio.charset.StandardCharsets;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ProblemDetail;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@CamundaRestController
+public class CamundaOpenApiController {
+
+  @Autowired private ObjectMapper objectMapper;
+
+  private String cachedJsonContent;
+
+  @PostConstruct
+  public void init() {
+    try {
+      final ClassPathResource resource = new ClassPathResource("apidoc/rest-api.yaml");
+      final String yamlContent =
+          new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+
+      final var yamlMapper = new Jackson2ObjectMapperBuilder().factory(new YAMLFactory()).build();
+
+      final Object yamlObject = yamlMapper.readValue(yamlContent, Object.class);
+
+      cachedJsonContent = objectMapper.writeValueAsString(yamlObject);
+
+    } catch (final Exception e) {
+      throw new RuntimeException("Failed to load and parse OpenAPI YAML file at startup", e);
+    }
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/CamundaOpenApiController.java
user: login: tmetzke
body: To correct myself: I think this only happens once. Controller beans seem to be instantiated as singletons by Spring.
created_at: 2025-03-24T11:16:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,62 @@
+/*
+ * 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.rest.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import io.camunda.zeebe.gateway.rest.RestErrorMapper;
+import jakarta.annotation.PostConstruct;
+import java.nio.charset.StandardCharsets;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ProblemDetail;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@CamundaRestController
+public class CamundaOpenApiController {
+
+  @Autowired private ObjectMapper objectMapper;
+
+  private String cachedJsonContent;
+
+  @PostConstruct
+  public void init() {
+    try {
+      final ClassPathResource resource = new ClassPathResource("apidoc/rest-api.yaml");
+      final String yamlContent =
+          new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+
+      final var yamlMapper = new Jackson2ObjectMapperBuilder().factory(new YAMLFactory()).build();
+
+      final Object yamlObject = yamlMapper.readValue(yamlContent, Object.class);
+
+      cachedJsonContent = objectMapper.writeValueAsString(yamlObject);
+
+    } catch (final Exception e) {
+      throw new RuntimeException("Failed to load and parse OpenAPI YAML file at startup", e);
+    }
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/CamundaOpenApiController.java
user: login: nathansandi
body: You’re right in your follow-up: by default, Spring instantiates controllers as singletons, so the @PostConstruct method is called only once during the application lifecycle, right after the bean is initialized.

created_at: 2025-03-24T11:47:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,62 @@
+/*
+ * 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.rest.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import io.camunda.zeebe.gateway.rest.RestErrorMapper;
+import jakarta.annotation.PostConstruct;
+import java.nio.charset.StandardCharsets;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ProblemDetail;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@CamundaRestController
+public class CamundaOpenApiController {
+
+  @Autowired private ObjectMapper objectMapper;
+
+  private String cachedJsonContent;
+
+  @PostConstruct
+  public void init() {
+    try {
+      final ClassPathResource resource = new ClassPathResource("apidoc/rest-api.yaml");
+      final String yamlContent =
+          new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+
+      final var yamlMapper = new Jackson2ObjectMapperBuilder().factory(new YAMLFactory()).build();
+
+      final Object yamlObject = yamlMapper.readValue(yamlContent, Object.class);
+
+      cachedJsonContent = objectMapper.writeValueAsString(yamlObject);
+
+    } catch (final Exception e) {
+      throw new RuntimeException("Failed to load and parse OpenAPI YAML file at startup", e);
+    }
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/CamundaOpenApiController.java
user: login: tmetzke
body: I was wondering if we're trying to replicate something here though that Spring maybe already provides (serving static content that needs to be transformed before serving it). Could this be an alternative to do what we do here: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/static-resources.html?
created_at: 2025-03-24T11:59:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,62 @@
+/*
+ * 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.rest.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import io.camunda.zeebe.gateway.rest.RestErrorMapper;
+import jakarta.annotation.PostConstruct;
+import java.nio.charset.StandardCharsets;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ProblemDetail;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@CamundaRestController
+public class CamundaOpenApiController {
+
+  @Autowired private ObjectMapper objectMapper;
+
+  private String cachedJsonContent;
+
+  @PostConstruct
+  public void init() {
+    try {
+      final ClassPathResource resource = new ClassPathResource("apidoc/rest-api.yaml");
+      final String yamlContent =
+          new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+
+      final var yamlMapper = new Jackson2ObjectMapperBuilder().factory(new YAMLFactory()).build();
+
+      final Object yamlObject = yamlMapper.readValue(yamlContent, Object.class);
+
+      cachedJsonContent = objectMapper.writeValueAsString(yamlObject);
+
+    } catch (final Exception e) {
+      throw new RuntimeException("Failed to load and parse OpenAPI YAML file at startup", e);
+    }
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/CamundaOpenApiController.java
user: login: nathansandi
body: - From what I understand, Spring’s static resource handling is primarily designed for serving raw static files (e.g., YAML, JSON, JS, HTML), with features like caching, versioning, and compression.
- In our case, however, we’re not serving the raw YAML directly — instead, we need to load it once, convert it to JSON, and serve that transformed content via an endpoint (for use with Swagger).

Spring’s static resource pipeline doesn’t natively support content transformation like YAML → JSON. While it’s technically possible using a custom ResourceTransformer, that would introduce unnecessary complexity for what is a very specific and lightweight use case.

Since we’re not serving a static file directly, but rather processing it once and caching the result, I believe the current solution keeps things simple, efficient, and maintainable, without the added overhead of a custom resource handling pipeline.

I am open, if it is the case to revisit it, please let me know if you disagree, and I can for sure have a recheck over this and see the possibility to merge [this](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/static-resources.html) over the solution.  
created_at: 2025-03-24T12:10:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,62 @@
+/*
+ * 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.rest.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import io.camunda.zeebe.gateway.rest.RestErrorMapper;
+import jakarta.annotation.PostConstruct;
+import java.nio.charset.StandardCharsets;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ProblemDetail;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@CamundaRestController
+public class CamundaOpenApiController {
+
+  @Autowired private ObjectMapper objectMapper;
+
+  private String cachedJsonContent;
+
+  @PostConstruct
+  public void init() {
+    try {
+      final ClassPathResource resource = new ClassPathResource("apidoc/rest-api.yaml");
+      final String yamlContent =
+          new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+
+      final var yamlMapper = new Jackson2ObjectMapperBuilder().factory(new YAMLFactory()).build();
+
+      final Object yamlObject = yamlMapper.readValue(yamlContent, Object.class);
+
+      cachedJsonContent = objectMapper.writeValueAsString(yamlObject);
+
+    } catch (final Exception e) {
+      throw new RuntimeException("Failed to load and parse OpenAPI YAML file at startup", e);
+    }
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/CamundaOpenApiController.java
user: login: tmetzke
body: I see what you mean. Yes, we would have to add a dedicated transformer to the resolver chain. I am not sure if we define static resource handling anywhere in the application already, I think we don't yet.

Ultimately, this here works, as far as I can tell. Having the post construct in a REST controller to load a resource so we can transform it, though, looks a little bit off to me. It simply looks like something Spring already solved with static resource controllers that can take transformers 😅 
created_at: 2025-03-24T13:20:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,62 @@
+/*
+ * 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.rest.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import io.camunda.zeebe.gateway.rest.RestErrorMapper;
+import jakarta.annotation.PostConstruct;
+import java.nio.charset.StandardCharsets;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ProblemDetail;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@CamundaRestController
+public class CamundaOpenApiController {
+
+  @Autowired private ObjectMapper objectMapper;
+
+  private String cachedJsonContent;
+
+  @PostConstruct
+  public void init() {
+    try {
+      final ClassPathResource resource = new ClassPathResource("apidoc/rest-api.yaml");
+      final String yamlContent =
+          new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+
+      final var yamlMapper = new Jackson2ObjectMapperBuilder().factory(new YAMLFactory()).build();
+
+      final Object yamlObject = yamlMapper.readValue(yamlContent, Object.class);
+
+      cachedJsonContent = objectMapper.writeValueAsString(yamlObject);
+
+    } catch (final Exception e) {
+      throw new RuntimeException("Failed to load and parse OpenAPI YAML file at startup", e);
+    }
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/controller/CamundaOpenApiController.java
user: login: nathansandi
body: No problem at all :) like I said, I was open to revisit, so I have created the transformer, and moved it to the OpenApi resource handler

I updated the solution over this commit: https://github.com/camunda/camunda/pull/29628#:~:text=static%20resource%20handler-,495d5b0,-This%20branch%20has  (when we merge it, I am going to do the same for main)
created_at: 2025-03-24T14:00:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,4 +34,19 @@ public ObjectMapper objectMapper() {
     gatewayRestObjectMapperCustomizer().accept(builder);
     return builder.build();
   }
+
+  @Bean("yamlObjectMapper")
+  public ObjectMapper yamlObjectMapper() {
+    final var builder = Jackson2ObjectMapperBuilder.yaml();
+    builder.factory(new YAMLFactory());
+    gatewayRestObjectMapperCustomizer().accept(builder);
+    return builder.build();
+  }
+
+  // To avoid conflict between the default ObjectMapper and the custom one (yamlObjectMapper)
+  @Bean
+  public MappingJackson2HttpMessageConverter gatewayRestMessageConverter(
+      final @Qualifier("gatewayRestObjectMapper") ObjectMapper gatewayRestObjectMapper) {
+    return new MappingJackson2HttpMessageConverter(gatewayRestObjectMapper);
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/JacksonConfig.java
user: login: tmetzke
body: ❓ Could you provide a bit more detail on why this is necessary?
created_at: 2025-03-25T10:34:27Z
author_association: MEMBER
diff_hunk: @@ -31,4 +34,19 @@ public ObjectMapper objectMapper() {
     gatewayRestObjectMapperCustomizer().accept(builder);
     return builder.build();
   }
+
+  @Bean("yamlObjectMapper")
+  public ObjectMapper yamlObjectMapper() {
+    final var builder = Jackson2ObjectMapperBuilder.yaml();
+    builder.factory(new YAMLFactory());
+    gatewayRestObjectMapperCustomizer().accept(builder);
+    return builder.build();
+  }
+
+  // To avoid conflict between the default ObjectMapper and the custom one (yamlObjectMapper)
+  @Bean
+  public MappingJackson2HttpMessageConverter gatewayRestMessageConverter(
+      final @Qualifier("gatewayRestObjectMapper") ObjectMapper gatewayRestObjectMapper) {
+    return new MappingJackson2HttpMessageConverter(gatewayRestObjectMapper);
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/JacksonConfig.java
user: login: tmetzke
body: I have read https://github.com/camunda/camunda/pull/29628#discussion_r2011750515, but it doesn't clarify it for me yet 😅 
created_at: 2025-03-25T10:35:25Z
author_association: MEMBER
diff_hunk: @@ -31,4 +34,19 @@ public ObjectMapper objectMapper() {
     gatewayRestObjectMapperCustomizer().accept(builder);
     return builder.build();
   }
+
+  @Bean("yamlObjectMapper")
+  public ObjectMapper yamlObjectMapper() {
+    final var builder = Jackson2ObjectMapperBuilder.yaml();
+    builder.factory(new YAMLFactory());
+    gatewayRestObjectMapperCustomizer().accept(builder);
+    return builder.build();
+  }
+
+  // To avoid conflict between the default ObjectMapper and the custom one (yamlObjectMapper)
+  @Bean
+  public MappingJackson2HttpMessageConverter gatewayRestMessageConverter(
+      final @Qualifier("gatewayRestObjectMapper") ObjectMapper gatewayRestObjectMapper) {
+    return new MappingJackson2HttpMessageConverter(gatewayRestObjectMapper);
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/JacksonConfig.java
user: login: nathansandi
body: Yes, MappingJackson2HttpMessageConverter is used by `org.springframework.http.converter.json` (it is a compiled library), and consumes ObjectMapper. 

With the introduction of `yamlMapper`, the library was having conflict between the 2 declared beans `gatewayRestObjectMapper` and `yamlMapper`. As I cant set `gatewayRestObjectMapper` as primary (dealing problems with tasklist, operate). In another words, the Spring Boot no longer knows which ObjectMapper to use for HTTP (de)serialization, and you have to tell it explicitly. 
created_at: 2025-03-25T10:39:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,4 +34,19 @@ public ObjectMapper objectMapper() {
     gatewayRestObjectMapperCustomizer().accept(builder);
     return builder.build();
   }
+
+  @Bean("yamlObjectMapper")
+  public ObjectMapper yamlObjectMapper() {
+    final var builder = Jackson2ObjectMapperBuilder.yaml();
+    builder.factory(new YAMLFactory());
+    gatewayRestObjectMapperCustomizer().accept(builder);
+    return builder.build();
+  }
+
+  // To avoid conflict between the default ObjectMapper and the custom one (yamlObjectMapper)
+  @Bean
+  public MappingJackson2HttpMessageConverter gatewayRestMessageConverter(
+      final @Qualifier("gatewayRestObjectMapper") ObjectMapper gatewayRestObjectMapper) {
+    return new MappingJackson2HttpMessageConverter(gatewayRestObjectMapper);
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/JacksonConfig.java
user: login: tmetzke
body: OK, I see 👍 I remember that @danielkelemen ran into issues though in the past with this ([reference](https://github.com/camunda/camunda/issues/23133#issuecomment-2437206465)). If this still creates an issue, we can also leave the YAML Object mapper creation in the transformer, as this should only be created once in the application, if I understand this correctly. And it's a special mapper we don't need anywhere else.
created_at: 2025-03-25T10:53:18Z
author_association: MEMBER
diff_hunk: @@ -31,4 +34,19 @@ public ObjectMapper objectMapper() {
     gatewayRestObjectMapperCustomizer().accept(builder);
     return builder.build();
   }
+
+  @Bean("yamlObjectMapper")
+  public ObjectMapper yamlObjectMapper() {
+    final var builder = Jackson2ObjectMapperBuilder.yaml();
+    builder.factory(new YAMLFactory());
+    gatewayRestObjectMapperCustomizer().accept(builder);
+    return builder.build();
+  }
+
+  // To avoid conflict between the default ObjectMapper and the custom one (yamlObjectMapper)
+  @Bean
+  public MappingJackson2HttpMessageConverter gatewayRestMessageConverter(
+      final @Qualifier("gatewayRestObjectMapper") ObjectMapper gatewayRestObjectMapper) {
+    return new MappingJackson2HttpMessageConverter(gatewayRestObjectMapper);
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/JacksonConfig.java
user: login: tmetzke
body: Thanks for looking into this 🙏 
created_at: 2025-03-25T10:53:45Z
author_association: MEMBER
diff_hunk: @@ -31,4 +34,19 @@ public ObjectMapper objectMapper() {
     gatewayRestObjectMapperCustomizer().accept(builder);
     return builder.build();
   }
+
+  @Bean("yamlObjectMapper")
+  public ObjectMapper yamlObjectMapper() {
+    final var builder = Jackson2ObjectMapperBuilder.yaml();
+    builder.factory(new YAMLFactory());
+    gatewayRestObjectMapperCustomizer().accept(builder);
+    return builder.build();
+  }
+
+  // To avoid conflict between the default ObjectMapper and the custom one (yamlObjectMapper)
+  @Bean
+  public MappingJackson2HttpMessageConverter gatewayRestMessageConverter(
+      final @Qualifier("gatewayRestObjectMapper") ObjectMapper gatewayRestObjectMapper) {
+    return new MappingJackson2HttpMessageConverter(gatewayRestObjectMapper);
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/JacksonConfig.java
user: login: nathansandi
body: No problems :) I just move the declaration of `yaml` back to the transformer. 
I think its ready to go now
created_at: 2025-03-25T11:05:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,4 +34,19 @@ public ObjectMapper objectMapper() {
     gatewayRestObjectMapperCustomizer().accept(builder);
     return builder.build();
   }
+
+  @Bean("yamlObjectMapper")
+  public ObjectMapper yamlObjectMapper() {
+    final var builder = Jackson2ObjectMapperBuilder.yaml();
+    builder.factory(new YAMLFactory());
+    gatewayRestObjectMapperCustomizer().accept(builder);
+    return builder.build();
+  }
+
+  // To avoid conflict between the default ObjectMapper and the custom one (yamlObjectMapper)
+  @Bean
+  public MappingJackson2HttpMessageConverter gatewayRestMessageConverter(
+      final @Qualifier("gatewayRestObjectMapper") ObjectMapper gatewayRestObjectMapper) {
+    return new MappingJackson2HttpMessageConverter(gatewayRestObjectMapper);
+  }
path: zeebe/gateway-rest/src/main/java/io/camunda/zeebe/gateway/rest/config/JacksonConfig.java
user: login: danielkelemen
body: Yes, that is probably the easiest fix. 👍
Defining the `MappingJackson2HttpMessageConverter` would cause problems in other scenarios.
created_at: 2025-03-25T11:07:48Z
author_association: MEMBER

PR comments:
user: login: tmetzke
created_at: 2025-03-18T15:04:50Z
body: Overall, I am wondering if we're now generating the Swagger UI from existing Controllers, not from the defined spec file?
I just came across [this section](https://springdoc.org/#what-is-a-proper-way-to-set-up-swagger-ui-to-use-provided-spec-yml) in the SpringDoc OpenAPI guide.
author_association: MEMBER
user: login: nathansandi
created_at: 2025-03-18T17:28:26Z
body: > Overall, I am wondering if we're now generating the Swagger UI from existing Controllers, not from the defined spec file? I just came across [this section](https://springdoc.org/#what-is-a-proper-way-to-set-up-swagger-ui-to-use-provided-spec-yml) in the SpringDoc OpenAPI guide.

From the  [section](https://springdoc.org/#what-is-a-proper-way-to-set-up-swagger-ui-to-use-provided-spec-yml) in the SpringDoc OpenAPI guide. mentioned 
Currently, SpringDoc does not support loading different spec.yaml files per group via Java code. You can only load YAML once via springdoc.swagger-ui.url, and it overrides all groups. 

This would invalidate the Tasklist V1, Operate V1 groups from Swagger, once we can only serve one static file on this approach. 

Another option that could be explored, is have an extra controller for expose the static file, but the address would not be the same as swagger. But also it would increase the maintainability. 

One example of this approach: 
	•	Serve /api-docs.yaml via controller or static resources.
	•	Do NOT use springdoc.swagger-ui.url — this would break controller-based Swagger UI.
	•	Let customers manually visit /custom-swagger-ui.html to see the YAML-based UI.
	•	Use /swagger-ui.html for the auto-generated UI.

We can sync this tomorrow  

author_association: CONTRIBUTOR
user: login: tmetzke
created_at: 2025-03-19T08:51:54Z
body: Thanks, @nathansandi 👍 
So, is the current configuration that we have here generating the Swagger UI for V2 from the controller code or using the spec file we define manually? I believe it is generating it from the controller code. That is not what we should have in the end, I believe. The Swagger UI will otherwise diverge from what we have defined in the spec file. Some of the stuff we have documented in the spec file manually is not reflected in code and will thus not appear in the Swagger UI otherwise.

If this means that the Swagger UI for Tasklist V1 and Operate V1 is generated from the code and the one for V2 is taken from the spec file and they are served on different paths, I think that would be OK. It would mean that there is not simply ONE Swagger UI URL in an application that covers all APIs. We can treat the V1 Swagger path as legacy and introduce a new path for V2.
author_association: MEMBER
user: login: nathansandi
created_at: 2025-03-19T15:30:52Z
body: Hello team
Follow-up my updates over here from what we discussed this morning:  
- I have created a **rest controller** to load the yaml file in an static way (from the rest-api.yaml)
- I add a new route on **Swagger-Ui** to call this custom controller as a group (`springdoc.swagger-ui.urls[0].url=/camunda-api-docs`) - This is redirecting to the controller
This means the custom controller is accessible from Swagger-UI, and Camunda 8 API doc is visible from the path /swagger-ui/index.html

This has been loading now directly from rest-api.yaml. The authentication properties are preserved :) 
<img width="1492" alt="image" src="https://github.com/user-attachments/assets/ed390e94-4fb7-405d-8a69-2085601dab25" />

author_association: CONTRIBUTOR
user: login: tmetzke
created_at: 2025-03-21T13:18:39Z
body: This is really nice, thanks, @nathansandi 👍 
I have left some comments on the `main` PR, nothing blocking though.
author_association: MEMBER
user: login: tmetzke
created_at: 2025-03-25T08:36:19Z
body: 💭 Adding to the above, even the YAML ObjectMapper could be introduced as a bean in the new configuration class and passed on to the transformer, I guess? That way, the transformer is not responsible for creating object mappers.
author_association: MEMBER