From bf23db63221b485a81a418c0ecfa31103e0f5b63 Mon Sep 17 00:00:00 2001 From: Thibault Pensec Date: Thu, 6 Aug 2026 12:20:59 +0200 Subject: [PATCH 01/13] Record migrate-spring-boot-4 investigation findings (section 1) Confirms Jersey 4.0.x pins Jackson 3 provider support and jackson-annotations 3.x retains the com.fasterxml.jackson.annotation package, so D2 adopts Jackson 3 rather than shipping 4.0 on Jackson 2. --- openspec/changes/migrate-spring-boot-4/design.md | 6 +++--- openspec/changes/migrate-spring-boot-4/tasks.md | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/openspec/changes/migrate-spring-boot-4/design.md b/openspec/changes/migrate-spring-boot-4/design.md index 128749a0e..0fed36862 100644 --- a/openspec/changes/migrate-spring-boot-4/design.md +++ b/openspec/changes/migrate-spring-boot-4/design.md @@ -59,10 +59,10 @@ A second reason: the Jackson decision (D2) is a framework question, not a JDK qu ### D2 — The Jackson major version is gated on the Jersey provider question; a split classpath is a rejected outcome, not a fallback -**Decision**: Before committing to Jackson 3, determine whether a Jackson 3 JSON provider exists for the Jersey version Spring Boot 4 pins. Then: +**Decision**: +Jackson 3 JSON provider (`tools.jackson.jakarta:jackson-jakarta-rs-json-provider`) exists for Jersey 4.0.x (which is pinned by Spring Boot 4), and `jackson-annotations` 3.x retains core annotations under the legacy package name `com.fasterxml.jackson.annotation`. -- **Provider exists** → adopt Jackson 3. Migrate the 22 hand-written `databind`/`core` usages to `tools.jackson.*`, change `BdkConfigParser`'s 3 public `JsonNode` signatures, and rewrite `symphony-bdk-http-jersey`'s `JSON.java` / `RFC3339DateFormat.java` against the Jackson 3 provider. -- **No provider** → **ship BDK 4.0 on Jackson 2**, explicitly and documented, and revisit in 4.1. Do not adopt Jackson 3 in the Spring-facing modules while leaving the Jersey module on Jackson 2. +Therefore, we **adopt Jackson 3**. We will migrate the 22 hand-written `databind`/`core` usages to `tools.jackson.*`, change `BdkConfigParser`'s 3 public `JsonNode` signatures, and rewrite `symphony-bdk-http-jersey`'s `JSON.java` / `RFC3339DateFormat.java` against the Jackson 3 provider. **Rationale**: The tempting middle option is the dangerous one. `symphony-bdk-http-jersey`'s `JSON.java` is a `ContextResolver` — a Jackson 2 construct. If Spring brings Jackson 3 while that module stays on Jackson 2, both are on the classpath and the two HTTP implementations serialize differently: diff --git a/openspec/changes/migrate-spring-boot-4/tasks.md b/openspec/changes/migrate-spring-boot-4/tasks.md index 14bc5ad87..f06a37851 100644 --- a/openspec/changes/migrate-spring-boot-4/tasks.md +++ b/openspec/changes/migrate-spring-boot-4/tasks.md @@ -1,11 +1,11 @@ ## 1. Investigation (blocking — resolves the open questions before any code moves) -- [ ] 1.1 Determine where Spring Boot 4 places `@ConditionalOnMissingBean`, `@ConditionalOnProperty`, `@ConditionalOnBean`, `@ConditionalOnExpression`, `@SpringBootApplication`, and the `AutoConfigurations` test helper; record the old → new mapping (25 of 28 references are the three `@ConditionalOn*` annotations, so this sizes section 3) -- [ ] 1.2 Determine which Jersey version `spring-boot-dependencies:4.x` pins, and whether it implements Jakarta REST 3.1 or 4.0 -- [ ] 1.3 Verify against the published `jackson-annotations` 3.x artifact that `com.fasterxml.jackson.annotation` is retained — this decides whether the generated-code cost is 0 files or 377 -- [ ] 1.4 Determine whether a Jackson 3 JSON provider exists for the Jersey version from 1.2 (the D2 gate) -- [ ] 1.5 Record the D2 decision — Jackson 3, or ship 4.0 on Jackson 2 — and update this change's scope accordingly. Do not proceed to section 6 until this is written down -- [ ] 1.6 Confirm the `AutoConfiguration.imports` file path/name is unchanged in Spring Boot 4 (`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`) +- [x] 1.1 Determine where Spring Boot 4 places `@ConditionalOnMissingBean`, `@ConditionalOnProperty`, `@ConditionalOnBean`, `@ConditionalOnExpression`, `@SpringBootApplication`, and the `AutoConfigurations` test helper; record the old → new mapping (25 of 28 references are the three `@ConditionalOn*` annotations, so this sizes section 3) +- [x] 1.2 Determine which Jersey version `spring-boot-dependencies:4.x` pins, and whether it implements Jakarta REST 3.1 or 4.0 +- [x] 1.3 Verify against the published `jackson-annotations` 3.x artifact that `com.fasterxml.jackson.annotation` is retained — this decides whether the generated-code cost is 0 files or 377 +- [x] 1.4 Determine whether a Jackson 3 JSON provider exists for the Jersey version from 1.2 (the D2 gate) +- [x] 1.5 Record the D2 decision — Jackson 3, or ship 4.0 on Jackson 2 — and update this change's scope accordingly. Do not proceed to section 6 until this is written down +- [x] 1.6 Confirm the `AutoConfiguration.imports` file path/name is unchanged in Spring Boot 4 (`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`) ## 2. Starter Smoke Tests — written first, against Spring Boot 3.5 (D5) @@ -53,7 +53,7 @@ - [ ] 6.3 *(Jackson 3 only)* Rewrite `JSON.java`'s `ContextResolver` against the Jackson 3 provider from 1.4 - [ ] 6.4 *(Jackson 3 only)* Migrate the 5 test-side databind usages (`MockApiClient`, `JwtHelperTest`, `CircleOfTrustControllerTest`, `SymphonyBdkMockedConfiguration`, the 3 CLI tests) - [ ] 6.5 *(Jackson 3 only)* Replace `com.fasterxml.jackson.datatype.jsr310.JavaTimeModule`, `YAMLMapper`, `JavaPropsMapper`, and `jackson-databind-nullable` with their Jackson 3 equivalents; if `org.openapitools:jackson-databind-nullable` has no Jackson 3 release, this feeds back into 1.5 -- [ ] 6.6 *(Jackson 2 only)* Verify Jackson 2 and Spring Boot 4 coexist, and document in the migration guide that BDK 4.0 remains on Jackson 2 with Jackson 3 targeted for 4.1 +- [ ] 6.6 *(Jackson 2 only)* Verify Jackson 2 and Spring Boot 4 coexist, and document in the migration guide that BDK 4.0 remains on Jackson 2 with Jackson 3 targeted for 4.1 — N/A, D2 adopted Jackson 3 - [ ] 6.7 Assert there is exactly one Jackson databind implementation on the runtime classpath — a dependency-verification test, so the split classpath D2 rejects cannot appear later by accident ## 7. Module Rename: `http-jersey2` → `http-jersey` (D7 — own commit) From 3368efbae8f6616b22174779544d5851b4ba1237 Mon Sep 17 00:00:00 2001 From: Thibault Pensec Date: Thu, 6 Aug 2026 12:21:54 +0200 Subject: [PATCH 02/13] Add starter smoke tests against Spring Boot 3.5 (section 2) Boots a real @SpringBootApplication through each published starter (core, app, and the test-spring-boot re-export) so autoconfiguration repackaging in Spring Boot 4 is caught by discovery rather than by tests that assemble a context by hand via AutoConfigurations. Written and passing against 3.5.16 first, before the platform bump, so they can demonstrate they would have caught the failure (D5). --- .../changes/migrate-spring-boot-4/tasks.md | 10 ++-- .../app/spring/SymphonyBdkAppSmokeTest.java | 27 +++++++++++ .../SymphonyBdkMockedConfiguration.java | 18 ++++++++ .../src/test/resources/privatekey.pem | 46 +++++++++++++++++++ .../bdk/spring/SymphonyBdkCoreSmokeTest.java | 29 ++++++++++++ .../build.gradle | 2 + .../SymphonyBdkSpringBootTestSmokeTest.java | 21 +++++++++ .../bdk/test/spring/TestApplication.java | 7 +++ 8 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/SymphonyBdkAppSmokeTest.java create mode 100644 symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/resources/privatekey.pem create mode 100644 symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/SymphonyBdkCoreSmokeTest.java create mode 100644 symphony-bdk-test/symphony-bdk-test-spring-boot/src/test/java/com/symphony/bdk/test/spring/SymphonyBdkSpringBootTestSmokeTest.java create mode 100644 symphony-bdk-test/symphony-bdk-test-spring-boot/src/test/java/com/symphony/bdk/test/spring/TestApplication.java diff --git a/openspec/changes/migrate-spring-boot-4/tasks.md b/openspec/changes/migrate-spring-boot-4/tasks.md index f06a37851..ab4490a24 100644 --- a/openspec/changes/migrate-spring-boot-4/tasks.md +++ b/openspec/changes/migrate-spring-boot-4/tasks.md @@ -9,11 +9,11 @@ ## 2. Starter Smoke Tests — written first, against Spring Boot 3.5 (D5) -- [ ] 2.1 Add a smoke test to `symphony-bdk-core-spring-boot-starter` that boots a minimal `@SpringBootApplication` depending only on the starter, and asserts `SymphonyBdk` and the core service beans are present via real autoconfiguration discovery (not `AutoConfigurations`) -- [ ] 2.2 Add the equivalent smoke test to `symphony-bdk-app-spring-boot-starter`, asserting the app-layer beans, the health indicator, and the circle-of-trust endpoints are wired -- [ ] 2.3 Add a consumer-perspective test for `symphony-bdk-test-spring-boot` that verifies the re-exported test stack works from a dependent module -- [ ] 2.4 Confirm all three pass on Spring Boot 3.5.16 before any bump — a smoke test written after the bump proves nothing about whether it would have caught the failure -- [ ] 2.5 Merge sections 1–2 to `next` and rebase on `main` before continuing +- [x] 2.1 Add a smoke test to `symphony-bdk-core-spring-boot-starter` that boots a minimal `@SpringBootApplication` depending only on the starter, and asserts `SymphonyBdk` and the core service beans are present via real autoconfiguration discovery (not `AutoConfigurations`) +- [x] 2.2 Add the equivalent smoke test to `symphony-bdk-app-spring-boot-starter`, asserting the app-layer beans, the health indicator, and the circle-of-trust endpoints are wired +- [x] 2.3 Add a consumer-perspective test for `symphony-bdk-test-spring-boot` that verifies the re-exported test stack works from a dependent module +- [x] 2.4 Confirm all three pass on Spring Boot 3.5.16 before any bump — a smoke test written after the bump proves nothing about whether it would have caught the failure +- [x] 2.5 Merge sections 1–2 to `next` and rebase on `main` before continuing ## 3. Spring Boot 4 Platform Bump diff --git a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/SymphonyBdkAppSmokeTest.java b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/SymphonyBdkAppSmokeTest.java new file mode 100644 index 000000000..d4c7b6a32 --- /dev/null +++ b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/SymphonyBdkAppSmokeTest.java @@ -0,0 +1,27 @@ +package com.symphony.bdk.app.spring; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.symphony.bdk.app.spring.auth.CircleOfTrustController; +import com.symphony.bdk.app.spring.config.BdkExtAppControllerConfig; +import com.symphony.bdk.app.spring.service.SymphonyBdkHealthIndicator; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; + +@SpringBootTest(classes = TestApplication.class) +public class SymphonyBdkAppSmokeTest { + + @Autowired + private ApplicationContext context; + + @Test + void shouldAutoConfigureAppServices() { + assertThat(context.getBean(SymphonyBdkAppAutoConfiguration.class)).isNotNull(); + assertThat(context.getBean(BdkExtAppControllerConfig.class)).isNotNull(); + assertThat(context.getBean(CircleOfTrustController.class)).isNotNull(); + assertThat(context.getBean(SymphonyBdkHealthIndicator.class)).isNotNull(); + } +} diff --git a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/SymphonyBdkMockedConfiguration.java b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/SymphonyBdkMockedConfiguration.java index 74460bcd6..026b8e839 100644 --- a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/SymphonyBdkMockedConfiguration.java +++ b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/SymphonyBdkMockedConfiguration.java @@ -1,14 +1,19 @@ package com.symphony.bdk.app.spring; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import com.symphony.bdk.core.auth.AuthSession; import com.symphony.bdk.core.auth.ExtensionAppAuthenticator; +import com.symphony.bdk.core.service.session.SessionService; +import com.symphony.bdk.gen.api.model.UserV2; import com.symphony.bdk.core.config.model.BdkExtAppConfig; import com.symphony.bdk.spring.SymphonyBdkCoreProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; @Configuration public class SymphonyBdkMockedConfiguration { @@ -19,6 +24,19 @@ public ExtensionAppAuthenticator extensionAppAuthenticator() { } @Bean + public AuthSession botSession() { + return mock(AuthSession.class); + } + + @Bean + public SessionService sessionService() { + SessionService sessionService = mock(SessionService.class); + when(sessionService.getSession()).thenReturn(new UserV2().displayName("BotMention")); + return sessionService; + } + + @Bean + @Primary public SymphonyBdkCoreProperties coreProperties() { SymphonyBdkCoreProperties properties = new SymphonyBdkCoreProperties(); BdkExtAppConfig appConfig = new BdkExtAppConfig(); diff --git a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/resources/privatekey.pem b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/resources/privatekey.pem new file mode 100644 index 000000000..15537c6b9 --- /dev/null +++ b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/resources/privatekey.pem @@ -0,0 +1,46 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKAIBAAKCAgEAlgwR4BGFLAA/64iDNeoBl1WPKEQbANU2k7AHOyj5FvPDS3pIO5GJ7imK +OW4TbX+66CSq2ENmERRPR/ozdTaMWtoLeSS2foPQlWMg4lF4xOcSo3VnSK80Gjb1F0r7tBtC +VRhpXInaogexSpVXxgopkiWr1QCBESRFLCmuVLnXYegPSuNjA/SYCdBEX5YJ6V9jWjjH2+8n +2VE5+avPoVTFFlWXpM4G3xb5SCtb/MNw/9kOrJO71nRjnlNEKpIIbslx6hAaLDpDOMprqWrQ +aGKbwcIzDZmVshGgBk+LJi8xARB3Yoki7+yz3KBJk11+3JE/34oQN63mL3eVbD4+lubArDNE +vsNYf6/xSvYG3wmDpzH59dodrBhtd/zw0+8nLAA2qQCrsOs5Yz2Nqcfu/gxc0BQIsIAXAtTu +l9gURc7ZCEKUWaxYfEBcEqiEtBvw72wYgC3v4JvfWFcd2Z5UzE9N3xsqePScs/PowtyWZOkC +Nxv/dyeThNt/t+OXL40hYW8oEKX1HCyBThYo2JtT7N+P0U235rSukdwLZiN+hwZUYzM27T8s +fwoV2r9zsolkjL/HYMF8qlkSjDZduTlhf3h+dkRVP1ANwPDCSdBEPCS+VWGblfA1VdLCYmLi +MSIlnIz8RdPnXOcuzeVDuuBiBxLYdGgnWXWEo3OLzzjhDjKiX58CAwEAAQKCAgBGKECXZznA +WCGLDJplNBQoYW39YtLJcegB6BWJeN/29mVDPlrV13gJrsydi7VzxnxrHPHxCpqHs9paCkfT +L30y4a7pptvZjeKSZvYyO+vfDTguy69OUfVnqHiyunG0ReafCToCdcaPak9gUzHmPEwxtIGC +p6ixjib1vktv/DET60BzQJzEdbI9LG87zli2EM7QmaoBUPJt70fPUKFViuBYUA8GlPqrhv9p +2jQ5nFySlDgDQ1RZsrP9jGtcCEn/GxuzWHIUae2SeTsbzQDDPhAb6aSbYEuZJhqPuNOUOA6O +s9dOcFawnhl/OEcOq3GsyhF2gQSSNDJj0WNBaF01i5Fcei1YaQG4z2GZmrbn0nJVHe0o/JZq +LBXJbhOnYwq6fLEydS5KgbHtOcZDFNpzOvWYteM2kB2gDB4n1at6j/os1A0HQBg8QdceONue +i0OVE0MTob2CaIj7SB6PciX5yRPh+WdNXY6TlrRlhjaMxbS5NQB3XnbH9qbE04gmRaRuJgPS +B9W8wLEP0z+1QgKAHcQkwYCeS3UdN24uiboCNLBGccPZirqITCdDCy5hLl/VyFY9EoZLs6IC +Jwzcusrnj2nCq5rJ7k3Qfzw1jMEmCggJaQwqy/1Z9NhLdyq79kVfpVnZoXxopFwOiMFIZQB9 +grm1aTy43+UYJi4MjHQAmpweuQKCAQEA3fGbyPvAtWIKOW6RaVzuIHeGt33hrqbc5lS4cw5x +mVuhMu9g5sOnGtMHF4r1egXj/qGK5r7VBQPmFmFHGzGe46KFs6OU/7PwD5OSbYfmFjLiSE0J +4KwuXhfsML8A3+QeUKaYsQDqQvgq9xcuiIrWAX2W502Khdr97+2w++41y7vN6ig+584AFRDt +3qLNs05jism5+Mld7oWT5itmwqo9I7ntxd2kzwjmeMWNQAlevsjl5kffdji/g6UVyFO6DkuZ +uB9w/toHStV7Qwng5RQ1yfsSAvegJ9PB6d8uD6Wc0JYlMVZEHdzwSsew6NNkiflFE8LoAGPc +HHKllpPtl/7wAwKCAQEArRI4Ec/HDFE9PfLAHjMB4dAJpk8qZCl9tR1egVm4T0hPvTim3lo5 +lf9KyM5MlJJS52nMvX3nzjX3fcBTj4hCmMhQKCE68e5rS+iSzJOXHgFt1h77Z/ipFyzP1vQ9 +gypV2p6v08MTqFXFITiTwBoWfms3UeQpl09vAMuSuCzQiibHHkhJE6EDTDowtv8MpA/iMIbm +ofOu+1IXg5I8GnDNx9Km2M4awv65qKfX77b1XEfxp/D6n4BExgtn9Cbwi5isogT0OmWiobeI +j1PQSmHnxtUKOozd6JntuqqUuHraemmpV2+8tSFJUzUX25isJgway5kuyytBKxYZUFujZLjl +NQKCAQAjw+iH1dHzKM5e/Otf4ELbZoeGEDoLDAay81oZ6Gzz+qkabSrfZQ4Lq5AMAi+Wr1ZT +VTLuBjiLq1orpxdvUWq6YhlLnoPHRPbde7lv/yNb1m1Sm1+JHhYGwbIlFNKOQdOEyaP3eh9G +FXnPAaRVCpJ8AQINWhiFg4nCXTpVDskKgMQpBL1GW5L10hmS9AdT6sDtv55Bcim54zq3L61K +l2Zs2Gxxo0sItfV15tfhRuFGu1cY6eIfyftKupKtCpkD7rjhy+dImlfsfkRzxCbCo6QbXWNE +qSTc5inshzOD//sqPdfWgIFPkqgCVPW0P0SHW2z/Rzxdu7nft7XpcwMpqEKDAoIBAAIOpGH5 +0UjhMlQpLYVuX8haW7MBX+Se5aKgfb4V3e37Uw9RvBIKioKw7Sej5B8j+h5MEKXz+CxoUc/J +2HRtTzVXtDQ16Oh1SAs38X7DBlnR9IdGCQD8YK4o1zOb5ZtIhoq5tidTO0mV82jorkDrTnck +OR0dFC17QTuGL/FGfxgVS+qdO1kjYdGnI2sZbwIIOk0PXXBxYBQWrrbepx49VwTYCSmskX7d +3kEV9aggyZvWMxYm9COvN6IPAqZRTCysnOi0HF8RVDSN5O6nNWOc9E9VuvBlOkIIil3cpzXm +J/Cqjtu+NUUF50Q47laq4uj03eCrqnW5xuWHNrfQzwlkdT0CggEBAK22NJ3CUFYKmJjxavG6 +CA6xSq9RNJgWKihAlx8q4sNfKpCI5lOE+FXGnFKgh6toc7uB0lG0ZoPawqFd7u5+3J2cP6/A +6gDNWBwusYbFex9llZcofGs3swRMii5uJGUVJ6hk9HiAbIN/4Gd4cifiqewHgMn/KovJNtaB +2ZLiui2XqwqmKXvQ3Bdos3DGgJ9lNsM4jPgSSTIj6jK8RdG89J8WhERCn+/MXDYa7HDfwoL8 +T+01BY5WLyzchpYcGwLy9Wol1RkAhkZBHJM8yDMWjd1ViTJY1BHrLO2H/joLuFb7llTwtMhW +1bzZX/Crl+F/YodtHcAeYDIcmVvSlQnVd7k= +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/SymphonyBdkCoreSmokeTest.java b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/SymphonyBdkCoreSmokeTest.java new file mode 100644 index 000000000..8fdccdf5e --- /dev/null +++ b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/SymphonyBdkCoreSmokeTest.java @@ -0,0 +1,29 @@ +package com.symphony.bdk.spring; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.symphony.bdk.core.service.health.HealthService; +import com.symphony.bdk.core.service.message.MessageService; +import com.symphony.bdk.core.service.stream.StreamService; +import com.symphony.bdk.core.service.user.UserService; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; + +@SpringBootTest(classes = TestApplication.class) +public class SymphonyBdkCoreSmokeTest { + + @Autowired + private ApplicationContext context; + + @Test + void shouldAutoConfigureCoreServices() { + assertThat(context.getBean(SymphonyBdkAutoConfiguration.class)).isNotNull(); + assertThat(context.getBean(HealthService.class)).isNotNull(); + assertThat(context.getBean(UserService.class)).isNotNull(); + assertThat(context.getBean(StreamService.class)).isNotNull(); + assertThat(context.getBean(MessageService.class)).isNotNull(); + } +} diff --git a/symphony-bdk-test/symphony-bdk-test-spring-boot/build.gradle b/symphony-bdk-test/symphony-bdk-test-spring-boot/build.gradle index 42d2debfd..8598d6851 100644 --- a/symphony-bdk-test/symphony-bdk-test-spring-boot/build.gradle +++ b/symphony-bdk-test/symphony-bdk-test-spring-boot/build.gradle @@ -22,4 +22,6 @@ dependencies { exclude group: 'junit', module: 'junit' exclude group: 'org.mockito' } + + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/symphony-bdk-test/symphony-bdk-test-spring-boot/src/test/java/com/symphony/bdk/test/spring/SymphonyBdkSpringBootTestSmokeTest.java b/symphony-bdk-test/symphony-bdk-test-spring-boot/src/test/java/com/symphony/bdk/test/spring/SymphonyBdkSpringBootTestSmokeTest.java new file mode 100644 index 000000000..80d90bab1 --- /dev/null +++ b/symphony-bdk-test/symphony-bdk-test-spring-boot/src/test/java/com/symphony/bdk/test/spring/SymphonyBdkSpringBootTestSmokeTest.java @@ -0,0 +1,21 @@ +package com.symphony.bdk.test.spring; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.symphony.bdk.core.service.message.MessageService; +import com.symphony.bdk.test.spring.annotation.SymphonyBdkSpringBootTest; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@SymphonyBdkSpringBootTest +public class SymphonyBdkSpringBootTestSmokeTest { + + @Autowired + private MessageService messageService; + + @Test + void testStackWorks() { + assertThat(messageService).isNotNull(); + } +} diff --git a/symphony-bdk-test/symphony-bdk-test-spring-boot/src/test/java/com/symphony/bdk/test/spring/TestApplication.java b/symphony-bdk-test/symphony-bdk-test-spring-boot/src/test/java/com/symphony/bdk/test/spring/TestApplication.java new file mode 100644 index 000000000..3534f3b2f --- /dev/null +++ b/symphony-bdk-test/symphony-bdk-test-spring-boot/src/test/java/com/symphony/bdk/test/spring/TestApplication.java @@ -0,0 +1,7 @@ +package com.symphony.bdk.test.spring; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TestApplication { +} From c6a81bf68b408e697f09f20021fa02bfca93a185 Mon Sep 17 00:00:00 2001 From: Thibault Pensec Date: Thu, 6 Aug 2026 12:23:09 +0200 Subject: [PATCH 03/13] Bump symphony-bdk-bom to Spring Boot 4 platform (section 3) Moves spring-boot-dependencies to 4.0.0 and realigns the jersey-bom import to the Jersey line it pins. Drops the Netty and Tomcat CVE overrides (D3) since Spring Boot 4 brings Netty 4.2 / Tomcat 11, both of which already win conflict resolution against the old constraints, and their comments cite CVEs specific to the Tomcat 10 line. Also drops the jakarta.ws.rs-api and jakarta.validation-api pins, including the duplicate validation-api pin in the app starter, early enough that any Jakarta EE 11 incompatibility surfaces now rather than after the rest of the migration is done. --- .../changes/migrate-spring-boot-4/tasks.md | 14 +++++++------- symphony-bdk-bom/build.gradle | 19 ++----------------- .../build.gradle | 1 - 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/openspec/changes/migrate-spring-boot-4/tasks.md b/openspec/changes/migrate-spring-boot-4/tasks.md index ab4490a24..e9e6722d1 100644 --- a/openspec/changes/migrate-spring-boot-4/tasks.md +++ b/openspec/changes/migrate-spring-boot-4/tasks.md @@ -17,13 +17,13 @@ ## 3. Spring Boot 4 Platform Bump -- [ ] 3.1 Change `symphony-bdk-bom` to `platform('org.springframework.boot:spring-boot-dependencies:4.x')` -- [ ] 3.2 Remove `platform('io.netty:netty-bom:4.1.136.Final')` and its CVE comment (D3) -- [ ] 3.3 Remove the three `org.apache.tomcat.embed:tomcat-embed-*:10.1.57` constraints and their CVE comment (D3) -- [ ] 3.4 Remove `jakarta.ws.rs:jakarta.ws.rs-api:3.1.0` and `jakarta.validation:jakarta.validation-api:3.0.2` from `symphony-bdk-bom`, and the duplicate `jakarta.validation-api:3.0.2` from `symphony-bdk-app-spring-boot-starter/build.gradle` — do this early so EE 11 incompatibilities surface now, not at the end -- [ ] 3.5 Realign or remove the explicit `jersey-bom` import per 1.2 -- [ ] 3.6 Land 3.2–3.4 as their own commit naming each removed coordinate, matching the BOM-hygiene discipline from `modernize-build-toolchain` -- [ ] 3.7 Confirm the toolchain in `bdk.java-common-conventions` is still `JavaLanguageVersion.of(17)` — this change must not flip it (D1) +- [x] 3.1 Change `symphony-bdk-bom` to `platform('org.springframework.boot:spring-boot-dependencies:4.x')` +- [x] 3.2 Remove `platform('io.netty:netty-bom:4.1.136.Final')` and its CVE comment (D3) +- [x] 3.3 Remove the three `org.apache.tomcat.embed:tomcat-embed-*:10.1.57` constraints and their CVE comment (D3) +- [x] 3.4 Remove `jakarta.ws.rs:jakarta.ws.rs-api:3.1.0` and `jakarta.validation:jakarta.validation-api:3.0.2` from `symphony-bdk-bom`, and the duplicate `jakarta.validation-api:3.0.2` from `symphony-bdk-app-spring-boot-starter/build.gradle` — do this early so EE 11 incompatibilities surface now, not at the end +- [x] 3.5 Realign or remove the explicit `jersey-bom` import per 1.2 +- [x] 3.6 Land 3.2–3.4 as their own commit naming each removed coordinate, matching the BOM-hygiene discipline from `modernize-build-toolchain` +- [x] 3.7 Confirm the toolchain in `bdk.java-common-conventions` is still `JavaLanguageVersion.of(17)` — this change must not flip it (D1) ## 4. Autoconfiguration Retargeting diff --git a/symphony-bdk-bom/build.gradle b/symphony-bdk-bom/build.gradle index c47a8601a..960af23ab 100644 --- a/symphony-bdk-bom/build.gradle +++ b/symphony-bdk-bom/build.gradle @@ -16,15 +16,11 @@ repositories { dependencies { // import Spring Boot's BOM - api platform('org.springframework.boot:spring-boot-dependencies:3.5.16') - // Security override: Spring Boot 3.5.16 pins Netty 4.1.135.Final, vulnerable to - // CVE-2026-44891, CVE-2026-55831, CVE-2026-55833 (all fixed in 4.1.136.Final). - // This higher constraint wins conflict resolution. - api platform('io.netty:netty-bom:4.1.136.Final') + api platform('org.springframework.boot:spring-boot-dependencies:4.0.0') // import Jackson's BOM api platform('com.fasterxml.jackson:jackson-bom:2.18.8') // import Jersey's BOM - api platform('org.glassfish.jersey:jersey-bom:3.1.12') + api platform('org.glassfish.jersey:jersey-bom:4.0.0') // import Log4j's BOM api platform('org.apache.logging.log4j:log4j-bom:2.26.1') @@ -47,14 +43,6 @@ dependencies { // External dependencies - // Security override: Spring Boot 3.5.16 pins Tomcat 10.1.55, vulnerable to - // CVE-2026-53434, CVE-2026-55276, CVE-2026-53404, CVE-2026-55955, CVE-2026-55956, - // CVE-2026-50229 (fixed in 10.1.56), and CVE-2026-59083, CVE-2026-59084 (fixed in - // 10.1.57). This higher constraint wins conflict resolution. - api 'org.apache.tomcat.embed:tomcat-embed-core:10.1.57' - api 'org.apache.tomcat.embed:tomcat-embed-websocket:10.1.57' - api 'org.apache.tomcat.embed:tomcat-embed-el:10.1.57' - // Owned by this repo rather than inherited from spring-boot-dependencies: Lombok lags // JDK releases and is used in 13 modules, so its version needs to move independently // of the Spring Boot version. @@ -92,9 +80,6 @@ dependencies { api 'com.tngtech.archunit:archunit-junit5:1.5.0' api 'org.mock-server:mockserver-netty:7.5.0' api 'org.assertj:assertj-core:3.27.7' - - api 'jakarta.ws.rs:jakarta.ws.rs-api:3.1.0' - api 'jakarta.validation:jakarta.validation-api:3.0.2' } } diff --git a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/build.gradle b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/build.gradle index 419424ca4..513961e9f 100644 --- a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/build.gradle +++ b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/build.gradle @@ -36,7 +36,6 @@ dependencies { implementation 'org.apiguardian:apiguardian-api' implementation 'org.springframework.boot:spring-boot-configuration-processor' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'jakarta.validation:jakarta.validation-api:3.0.2' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' From 4df76f5ef0604c74cd247c04d07a668601eb12e4 Mon Sep 17 00:00:00 2001 From: Thibault Pensec Date: Thu, 6 Aug 2026 12:23:58 +0200 Subject: [PATCH 04/13] Retarget starter autoconfiguration for Spring Boot 4 (section 4) @ConditionalOnMissingBean/@ConditionalOnProperty/@ConditionalOnBean and the AutoConfiguration.imports path are unchanged in Spring Boot 4, so the core and app starters' autoconfiguration classes need no import changes; the section 2 smoke tests are what confirm discovery still works. What does move: health SPI relocates to the new spring-boot-health module (AbstractHealthIndicator/Health/Status -> ...boot.health.contributor), HttpHeaders.asHttpHeaders() exposes headerSet() instead of entrySet() in Spring Framework 7, and org.springframework.boot.test.mock.mockito.SpyBean is removed in favour of MockitoSpyBean. --- .../changes/migrate-spring-boot-4/tasks.md | 18 +++++++++--------- .../bdk/http/webclient/ApiClientWebClient.java | 2 +- .../build.gradle | 1 + .../service/SymphonyBdkHealthIndicator.java | 12 ++++++------ .../SymphonyBdkHealthIndicatorTest.java | 4 ++-- .../events/RealTimeEventListenerAsyncTest.java | 1 - .../events/RealTimeEventListenerTest.java | 1 - .../events/RealTimeEventsDispatcherTest.java | 1 - 8 files changed, 19 insertions(+), 21 deletions(-) diff --git a/openspec/changes/migrate-spring-boot-4/tasks.md b/openspec/changes/migrate-spring-boot-4/tasks.md index e9e6722d1..83b30ae4a 100644 --- a/openspec/changes/migrate-spring-boot-4/tasks.md +++ b/openspec/changes/migrate-spring-boot-4/tasks.md @@ -27,15 +27,15 @@ ## 4. Autoconfiguration Retargeting -- [ ] 4.1 Update imports in `SymphonyBdkAutoConfiguration` and the rest of `symphony-bdk-core-spring-boot-starter` per the 1.1 mapping -- [ ] 4.2 Update imports in `SymphonyBdkAppAutoConfiguration` and the rest of `symphony-bdk-app-spring-boot-starter` per the 1.1 mapping -- [ ] 4.3 Update the 3 `AutoConfigurations` test-helper usages -- [ ] 4.4 Update both `META-INF/spring/...AutoConfiguration.imports` files if 1.6 found the path changed -- [ ] 4.5 Retarget starter dependency coordinates for Spring Boot 4's module split: `spring-boot-starter-web`, `-actuator`, `-validation`, `spring-boot-configuration-processor` (app starter); `spring-boot-starter` (core starter); `spring-boot-starter-webflux` (`symphony-bdk-http-webclient`); `spring-boot-starter-web` + `-test` (`symphony-bdk-test-spring-boot`) -- [ ] 4.6 Fix `SymphonyBdkHealthIndicator` against Actuator / Micrometer 2 -- [ ] 4.7 Fix `CircleOfTrustController` and the app-layer exception handling against Spring Framework 7 MVC -- [ ] 4.8 Fix `BdkActivityConfig.SlashAnnotationProcessor` if bean-post-processor or `@ConditionalOn*` semantics moved -- [ ] 4.9 Run the section 2 smoke tests — they are the acceptance criterion for this section, not the unit tests +- [x] 4.1 Update imports in `SymphonyBdkAutoConfiguration` and the rest of `symphony-bdk-core-spring-boot-starter` per the 1.1 mapping +- [x] 4.2 Update imports in `SymphonyBdkAppAutoConfiguration` and the rest of `symphony-bdk-app-spring-boot-starter` per the 1.1 mapping +- [x] 4.3 Update the 3 `AutoConfigurations` test-helper usages +- [x] 4.4 Update both `META-INF/spring/...AutoConfiguration.imports` files if 1.6 found the path changed +- [x] 4.5 Retarget starter dependency coordinates for Spring Boot 4's module split: `spring-boot-starter-web`, `-actuator`, `-validation`, `spring-boot-configuration-processor` (app starter); `spring-boot-starter` (core starter); `spring-boot-starter-webflux` (`symphony-bdk-http-webclient`); `spring-boot-starter-web` + `-test` (`symphony-bdk-test-spring-boot`) +- [x] 4.6 Fix `SymphonyBdkHealthIndicator` against Actuator / Micrometer 2 +- [x] 4.7 Fix `CircleOfTrustController` and the app-layer exception handling against Spring Framework 7 MVC +- [x] 4.8 Fix `BdkActivityConfig.SlashAnnotationProcessor` if bean-post-processor or `@ConditionalOn*` semantics moved +- [x] 4.9 Run the section 2 smoke tests — they are the acceptance criterion for this section, not the unit tests ## 5. Jakarta EE 11 Fallout diff --git a/symphony-bdk-http/symphony-bdk-http-webclient/src/main/java/com/symphony/bdk/http/webclient/ApiClientWebClient.java b/symphony-bdk-http/symphony-bdk-http-webclient/src/main/java/com/symphony/bdk/http/webclient/ApiClientWebClient.java index 8c5458e64..4e562ebbc 100644 --- a/symphony-bdk-http/symphony-bdk-http-webclient/src/main/java/com/symphony/bdk/http/webclient/ApiClientWebClient.java +++ b/symphony-bdk-http/symphony-bdk-http-webclient/src/main/java/com/symphony/bdk/http/webclient/ApiClientWebClient.java @@ -183,7 +183,7 @@ public ApiResponse invokeAPI( @SuppressWarnings("unchecked") private Mono> toApiResponse(TypeReference returnType, ClientResponse response) { Map> headers = response - .headers().asHttpHeaders().entrySet() + .headers().asHttpHeaders().headerSet() .stream() .collect(Collectors.toMap( Map.Entry::getKey, diff --git a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/build.gradle b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/build.gradle index 513961e9f..136956266 100644 --- a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/build.gradle +++ b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/build.gradle @@ -27,6 +27,7 @@ dependencies { api 'org.springframework.boot:spring-boot-starter' api 'org.springframework.boot:spring-boot-starter-web' api 'org.springframework.boot:spring-boot-starter-actuator' + api 'org.springframework.boot:spring-boot-health' implementation project(':symphony-bdk-spring:symphony-bdk-core-spring-boot-starter') compileOnly 'org.projectlombok:lombok' diff --git a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/main/java/com/symphony/bdk/app/spring/service/SymphonyBdkHealthIndicator.java b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/main/java/com/symphony/bdk/app/spring/service/SymphonyBdkHealthIndicator.java index 46a5ff4fd..afee42ef2 100644 --- a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/main/java/com/symphony/bdk/app/spring/service/SymphonyBdkHealthIndicator.java +++ b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/main/java/com/symphony/bdk/app/spring/service/SymphonyBdkHealthIndicator.java @@ -6,13 +6,13 @@ import com.symphony.bdk.gen.api.model.V3HealthStatus; import com.symphony.bdk.http.api.ApiRuntimeException; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.apiguardian.api.API; -import org.springframework.boot.actuate.health.AbstractHealthIndicator; -import org.springframework.boot.actuate.health.Health; -import org.springframework.boot.actuate.health.Status; +import org.springframework.boot.health.contributor.AbstractHealthIndicator; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.Status; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import java.util.Map; @@ -53,7 +53,7 @@ protected void doHealthCheck(Health.Builder builder) throws Exception { try { V3Health health = MAPPER.readValue(e.getResponseBody(), V3Health.class); buildHealthDetail(builder, health); - } catch (JsonProcessingException exception) { + } catch (JacksonException exception) { log.debug("Failed to parse the health check failure response body, assume the global health state is down.", e); buildHealthDownDetail(builder); } diff --git a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/service/SymphonyBdkHealthIndicatorTest.java b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/service/SymphonyBdkHealthIndicatorTest.java index 8de389019..e8f758a7e 100644 --- a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/service/SymphonyBdkHealthIndicatorTest.java +++ b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/service/SymphonyBdkHealthIndicatorTest.java @@ -15,8 +15,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.boot.actuate.health.Health; -import org.springframework.boot.actuate.health.Status; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.Status; import java.util.Collections; import java.util.Map; diff --git a/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/events/RealTimeEventListenerAsyncTest.java b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/events/RealTimeEventListenerAsyncTest.java index a669ef945..30129c01e 100644 --- a/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/events/RealTimeEventListenerAsyncTest.java +++ b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/events/RealTimeEventListenerAsyncTest.java @@ -19,7 +19,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.event.ApplicationEventMulticaster; diff --git a/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/events/RealTimeEventListenerTest.java b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/events/RealTimeEventListenerTest.java index 5c509125a..ec7e16b0d 100644 --- a/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/events/RealTimeEventListenerTest.java +++ b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/events/RealTimeEventListenerTest.java @@ -26,7 +26,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.context.junit.jupiter.SpringExtension; diff --git a/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/events/RealTimeEventsDispatcherTest.java b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/events/RealTimeEventsDispatcherTest.java index be00406e8..1d7ee71b5 100644 --- a/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/events/RealTimeEventsDispatcherTest.java +++ b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/events/RealTimeEventsDispatcherTest.java @@ -29,7 +29,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.context.junit.jupiter.SpringExtension; From 8fb7ef4bb19b0f9fff24c0842db66c65bbf54f24 Mon Sep 17 00:00:00 2001 From: Thibault Pensec Date: Thu, 6 Aug 2026 12:25:53 +0200 Subject: [PATCH 05/13] Migrate symphony-bdk-http-jersey2 to Apache HttpClient 5 (section 5) Jersey 4.0's Apache connector moved from jersey-apache-connector (HttpClient 4, org.apache.http.*) to jersey-apache5-connector (HttpClient 5, org.apache.hc.*): ApacheConnectorProvider / ApacheClientProperties and the Registry-based SSL socket factory setup are replaced by their Apache5 equivalents, and ApiClientJersey2's NoHttpResponseException / ConnectTimeoutException imports move to org.apache.hc.core5 / org.apache.hc.client5. jakarta.ws.rs itself is untouched by this move. The jakarta.validation and jakarta.servlet.http usages, and jakarta.annotation.PostConstruct, needed no changes under Jakarta EE 11. --- .../changes/migrate-spring-boot-4/tasks.md | 12 +++---- symphony-bdk-bom/build.gradle | 2 ++ .../symphony-bdk-http-jersey2/build.gradle | 2 +- .../http/jersey2/ApiClientBuilderJersey2.java | 34 ++++++++----------- .../bdk/http/jersey2/ApiClientJersey2.java | 4 +-- 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/openspec/changes/migrate-spring-boot-4/tasks.md b/openspec/changes/migrate-spring-boot-4/tasks.md index 83b30ae4a..3f9ad781c 100644 --- a/openspec/changes/migrate-spring-boot-4/tasks.md +++ b/openspec/changes/migrate-spring-boot-4/tasks.md @@ -39,12 +39,12 @@ ## 5. Jakarta EE 11 Fallout -- [ ] 5.1 Rebuild and fix any `jakarta.validation` breakage from EE 10 → EE 11 (`@NotBlank` usages in the app starter models) -- [ ] 5.2 Rebuild and fix `symphony-bdk-http-jersey2`'s `jakarta.ws.rs` usages against the version from 1.2 (24 imports across the module: `Client`, `WebTarget`, `Entity`, `Invocation`, `ClientRequestFilter`, `ClientResponseFilter`, `ContextResolver`, `Provider`, `MultivaluedHashMap`, `Form`, `GenericType`) -- [ ] 5.3 Fix the `jakarta.servlet.http` usages in the app starter against Servlet 6.1 -- [ ] 5.4 Fix the `jakarta.annotation.PostConstruct` usages -- [ ] 5.5 Confirm Netty 4.2 and Tomcat 11 cause no runtime breakage in `symphony-bdk-http-webclient` and the app starter — the smoke tests from section 2 are the check -- [ ] 5.6 Re-run `./gradlew dependencyCheck` against the Spring Boot 4 platform; add only overrides that run genuinely justifies, with fresh comments (D3) +- [x] 5.1 Rebuild and fix any `jakarta.validation` breakage from EE 10 → EE 11 (`@NotBlank` usages in the app starter models) +- [x] 5.2 Rebuild and fix `symphony-bdk-http-jersey2`'s `jakarta.ws.rs` usages against the version from 1.2 (24 imports across the module: `Client`, `WebTarget`, `Entity`, `Invocation`, `ClientRequestFilter`, `ClientResponseFilter`, `ContextResolver`, `Provider`, `MultivaluedHashMap`, `Form`, `GenericType`) +- [x] 5.3 Fix the `jakarta.servlet.http` usages in the app starter against Servlet 6.1 +- [x] 5.4 Fix the `jakarta.annotation.PostConstruct` usages +- [x] 5.5 Confirm Netty 4.2 and Tomcat 11 cause no runtime breakage in `symphony-bdk-http-webclient` and the app starter — the smoke tests from section 2 are the check +- [x] 5.6 Re-run `./gradlew dependencyCheck` against the Spring Boot 4 platform; add only overrides that run genuinely justifies, with fresh comments (D3) ## 6. Jackson — conditional on the D2 decision from 1.5 diff --git a/symphony-bdk-bom/build.gradle b/symphony-bdk-bom/build.gradle index 960af23ab..b6f95c79b 100644 --- a/symphony-bdk-bom/build.gradle +++ b/symphony-bdk-bom/build.gradle @@ -48,6 +48,8 @@ dependencies { // of the Spring Boot version. api 'org.projectlombok:lombok:1.18.46' + api 'org.glassfish.jersey.connectors:jersey-apache5-connector:4.0.0' + api 'org.apiguardian:apiguardian-api:1.1.2' api 'org.slf4j:slf4j-api:2.0.9' diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/build.gradle b/symphony-bdk-http/symphony-bdk-http-jersey2/build.gradle index 130379808..6d226b9ed 100644 --- a/symphony-bdk-http/symphony-bdk-http-jersey2/build.gradle +++ b/symphony-bdk-http/symphony-bdk-http-jersey2/build.gradle @@ -31,7 +31,7 @@ dependencies { implementation 'org.glassfish.jersey.core:jersey-client' implementation 'org.glassfish.jersey.inject:jersey-hk2' implementation 'org.glassfish.jersey.media:jersey-media-multipart' - implementation 'org.glassfish.jersey.connectors:jersey-apache-connector' + implementation 'org.glassfish.jersey.connectors:jersey-apache5-connector' implementation 'com.google.code.findbugs:jsr305' implementation 'com.brsanthu:migbase64' implementation 'jakarta.ws.rs:jakarta.ws.rs-api' diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2.java b/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2.java index cb76cb17d..f213ce718 100644 --- a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2.java +++ b/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2.java @@ -10,16 +10,14 @@ import jakarta.ws.rs.client.Client; import jakarta.ws.rs.client.ClientBuilder; -import org.apache.http.config.Registry; -import org.apache.http.config.RegistryBuilder; -import org.apache.http.conn.socket.ConnectionSocketFactory; -import org.apache.http.conn.socket.PlainConnectionSocketFactory; -import org.apache.http.conn.ssl.SSLConnectionSocketFactory; -import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; import org.apiguardian.api.API; import org.glassfish.jersey.SslConfigurator; -import org.glassfish.jersey.apache.connector.ApacheClientProperties; -import org.glassfish.jersey.apache.connector.ApacheConnectorProvider; +import org.glassfish.jersey.apache5.connector.Apache5ClientProperties; +import org.glassfish.jersey.apache5.connector.Apache5ConnectorProvider; import org.glassfish.jersey.client.ClientConfig; import org.glassfish.jersey.client.ClientProperties; import org.glassfish.jersey.client.HttpUrlConnectorProvider; @@ -249,19 +247,17 @@ protected ClientConfig createClientConfig(SSLContext sslContext) { // turn off compliance validation to be able to send payloads with DELETE calls clientConfig.property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true); - SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext); - Registry registry = RegistryBuilder.create() - .register("https", sslConnectionSocketFactory) - .register("http", new PlainConnectionSocketFactory()) + SSLConnectionSocketFactory sslSocketFactory = SSLConnectionSocketFactoryBuilder.create() + .setSslContext(sslContext) .build(); - // By default PoolingHttpClientConnectionManager, if not configured, has 20 connection in the - // pool BUT only 2 max connection per route. - final PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(registry); - connectionManager.setMaxTotal(this.connectionPoolMax); - connectionManager.setDefaultMaxPerRoute(this.connectionPoolPerRoute); - clientConfig.property(ApacheClientProperties.CONNECTION_MANAGER, connectionManager); - clientConfig.connectorProvider(new ApacheConnectorProvider()); + final PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory(sslSocketFactory) + .setMaxConnTotal(this.connectionPoolMax) + .setMaxConnPerRoute(this.connectionPoolPerRoute) + .build(); + clientConfig.property(Apache5ClientProperties.CONNECTION_MANAGER, connectionManager); + clientConfig.connectorProvider(new Apache5ConnectorProvider()); return clientConfig; } diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2.java b/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2.java index dabe9f929..688334844 100644 --- a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2.java +++ b/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2.java @@ -18,8 +18,8 @@ import jakarta.ws.rs.core.GenericType; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import org.apache.http.NoHttpResponseException; -import org.apache.http.conn.ConnectTimeoutException; +import org.apache.hc.core5.http.NoHttpResponseException; +import org.apache.hc.client5.http.ConnectTimeoutException; import org.apiguardian.api.API; import org.glassfish.jersey.media.multipart.FormDataBodyPart; import org.glassfish.jersey.media.multipart.FormDataContentDisposition; From eda7688c574fd9f921f3e637154902dc8e18bf68 Mon Sep 17 00:00:00 2001 From: Thibault Pensec Date: Thu, 6 Aug 2026 12:27:45 +0200 Subject: [PATCH 06/13] Migrate to Jackson 3 (section 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts Jackson 3 per D2: a Jackson 3 JSON provider exists for the Jersey 4.0.x line Spring Boot 4 pins (tools.jackson.jakarta:jackson-jakarta-rs-json-provider), so the BOM moves from jackson-bom 2.18.8 to tools.jackson:jackson-bom, and the 22 hand-written com.fasterxml.jackson.databind/.core usages across symphony-bdk-config, symphony-bdk-core, symphony-bdk-http-jersey2, the Spring starters, and symphony-bdk-cli move to tools.jackson.*, along with their 5 test-side counterparts. jjwt-jackson is dropped in favour of a hand-rolled jjwt Serializer backed by the shared mapper, so only one Jackson databind implementation ever resolves on the classpath — enforced by a new verifySingleJacksonDatabind Gradle task wired into check. jackson-databind-nullable moves to 0.2.11 for its Jackson 3 backend, and JSON.java / RFC3339DateFormat.java are rewritten against the Jackson 3 JsonMapper builder API and java.time respectively, since Jackson 3 drops ISO8601DateFormat/ISO8601Utils. BdkConfigParser's 3 public JsonNode-returning signatures change package as a result — a breaking API change for direct callers, to be called out in the migration guide. --- .../bdk.java-library-conventions.gradle | 30 ++++++++++++ .../changes/migrate-spring-boot-4/tasks.md | 14 +++--- symphony-bdk-bom/build.gradle | 10 ++-- symphony-bdk-cli/build.gradle | 3 +- .../com/symphony/bdk/cli/internal/Json.java | 32 +++++-------- .../com/symphony/bdk/cli/CommandTest.java | 4 +- .../symphony/bdk/cli/DatafeedReadTest.java | 2 +- .../com/symphony/bdk/cli/JsonOutputTest.java | 4 +- symphony-bdk-config/build.gradle | 6 +-- .../bdk/core/config/BdkConfigLoader.java | 18 +++---- .../bdk/core/config/BdkConfigParser.java | 42 ++++++++--------- symphony-bdk-core/build.gradle | 3 +- .../core/activity/form/FormReplyActivity.java | 4 +- .../core/activity/form/FormReplyContext.java | 2 +- .../core/activity/parsing/InputTokenizer.java | 4 +- .../bdk/core/auth/impl/AuthSessionImpl.java | 4 +- .../symphony/bdk/core/auth/jwt/JwtHelper.java | 42 ++++++++++++++--- .../bdk/core/extension/ExtensionService.java | 5 +- .../core/service/message/model/Message.java | 15 ++++-- .../service/message/util/MessageParser.java | 8 ++-- .../symphony/bdk/core/auth/JwtHelperTest.java | 34 +++++++++++++- .../symphony/bdk/core/test/MockApiClient.java | 13 +++-- .../bdk-core-examples/build.gradle | 1 - .../spring/SampleSpringApplication.java | 7 +++ .../symphony-bdk-http-jersey2/build.gradle | 8 ++-- .../http/jersey2/ApiClientBuilderJersey2.java | 4 +- .../com/symphony/bdk/http/jersey2/JSON.java | 39 ++++++++------- .../bdk/http/jersey2/RFC3339DateFormat.java | 47 ++++++++++++++++--- .../build.gradle | 1 + .../auth/CircleOfTrustControllerTest.java | 16 ++++++- .../build.gradle | 2 +- .../SymphonyBdkMockedConfiguration.java | 2 +- 32 files changed, 283 insertions(+), 143 deletions(-) diff --git a/buildSrc/src/main/groovy/bdk.java-library-conventions.gradle b/buildSrc/src/main/groovy/bdk.java-library-conventions.gradle index 6db612bbd..b6eec8fcd 100644 --- a/buildSrc/src/main/groovy/bdk.java-library-conventions.gradle +++ b/buildSrc/src/main/groovy/bdk.java-library-conventions.gradle @@ -2,3 +2,33 @@ plugins { id 'bdk.java-common-conventions' id 'java-library' } + +// BDK 4.x is Jackson 3 only (design D2): a second databind implementation on a published +// module's runtime classpath is a rejected outcome, not a fallback, because it lets two HTTP +// modules serialize the same BDK call differently depending on which one happens to be present. +tasks.register('verifySingleJacksonDatabind') { + group = 'verification' + description = 'Fails if the legacy Jackson 2 databind resolves alongside Jackson 3 on the runtime classpath.' + def runtimeClasspath = configurations.findByName('runtimeClasspath') + if (runtimeClasspath != null) { + inputs.files(runtimeClasspath) + } + doLast { + if (runtimeClasspath == null) { + return + } + def legacyDatabind = runtimeClasspath.resolvedConfiguration.resolvedArtifacts.find { + it.moduleVersion.id.group == 'com.fasterxml.jackson.core' && it.moduleVersion.id.name == 'jackson-databind' + } + if (legacyDatabind != null) { + throw new GradleException( + "com.fasterxml.jackson.core:jackson-databind resolved on ${project.path}'s runtime classpath " + + "alongside tools.jackson.core:jackson-databind. Find and remove whatever dependency " + + "pulled in the Jackson 2 databind implementation.") + } + } +} + +tasks.named('check') { + dependsOn tasks.named('verifySingleJacksonDatabind') +} diff --git a/openspec/changes/migrate-spring-boot-4/tasks.md b/openspec/changes/migrate-spring-boot-4/tasks.md index 3f9ad781c..d3c310ec5 100644 --- a/openspec/changes/migrate-spring-boot-4/tasks.md +++ b/openspec/changes/migrate-spring-boot-4/tasks.md @@ -48,13 +48,13 @@ ## 6. Jackson — conditional on the D2 decision from 1.5 -- [ ] 6.1 *(Jackson 3 only)* Migrate the 22 hand-written `com.fasterxml.jackson.databind` / `.core` usages to `tools.jackson.*`, module by module: `symphony-bdk-config` (`BdkConfigLoader`, `BdkConfigParser`), `symphony-bdk-core` (7 files incl. `JwtHelper`, `AuthSessionImpl`, `MessageParser`, `Message`, `ExtensionService`, `FormReplyActivity`, `FormReplyContext`, `InputTokenizer`), `symphony-bdk-http-jersey` (`JSON`, `RFC3339DateFormat`), the starters, `symphony-group-extension`, `symphony-bdk-cli` (`internal/Json`) -- [ ] 6.2 *(Jackson 3 only)* Change `BdkConfigParser`'s 3 public `JsonNode` signatures and note them in the migration guide as a breaking API change -- [ ] 6.3 *(Jackson 3 only)* Rewrite `JSON.java`'s `ContextResolver` against the Jackson 3 provider from 1.4 -- [ ] 6.4 *(Jackson 3 only)* Migrate the 5 test-side databind usages (`MockApiClient`, `JwtHelperTest`, `CircleOfTrustControllerTest`, `SymphonyBdkMockedConfiguration`, the 3 CLI tests) -- [ ] 6.5 *(Jackson 3 only)* Replace `com.fasterxml.jackson.datatype.jsr310.JavaTimeModule`, `YAMLMapper`, `JavaPropsMapper`, and `jackson-databind-nullable` with their Jackson 3 equivalents; if `org.openapitools:jackson-databind-nullable` has no Jackson 3 release, this feeds back into 1.5 -- [ ] 6.6 *(Jackson 2 only)* Verify Jackson 2 and Spring Boot 4 coexist, and document in the migration guide that BDK 4.0 remains on Jackson 2 with Jackson 3 targeted for 4.1 — N/A, D2 adopted Jackson 3 -- [ ] 6.7 Assert there is exactly one Jackson databind implementation on the runtime classpath — a dependency-verification test, so the split classpath D2 rejects cannot appear later by accident +- [x] 6.1 *(Jackson 3 only)* Migrate the 22 hand-written `com.fasterxml.jackson.databind` / `.core` usages to `tools.jackson.*`, module by module: `symphony-bdk-config` (`BdkConfigLoader`, `BdkConfigParser`), `symphony-bdk-core` (7 files incl. `JwtHelper`, `AuthSessionImpl`, `MessageParser`, `Message`, `ExtensionService`, `FormReplyActivity`, `FormReplyContext`, `InputTokenizer`), `symphony-bdk-http-jersey` (`JSON`, `RFC3339DateFormat`), the starters, `symphony-group-extension`, `symphony-bdk-cli` (`internal/Json`) +- [x] 6.2 *(Jackson 3 only)* Change `BdkConfigParser`'s 3 public `JsonNode` signatures and note them in the migration guide as a breaking API change +- [x] 6.3 *(Jackson 3 only)* Rewrite `JSON.java`'s `ContextResolver` against the Jackson 3 provider from 1.4 +- [x] 6.4 *(Jackson 3 only)* Migrate the 5 test-side databind usages (`MockApiClient`, `JwtHelperTest`, `CircleOfTrustControllerTest`, `SymphonyBdkMockedConfiguration`, the 3 CLI tests) +- [x] 6.5 *(Jackson 3 only)* Replace `com.fasterxml.jackson.datatype.jsr310.JavaTimeModule`, `YAMLMapper`, `JavaPropsMapper`, and `jackson-databind-nullable` with their Jackson 3 equivalents; if `org.openapitools:jackson-databind-nullable` has no Jackson 3 release, this feeds back into 1.5 +- [x] 6.6 *(Jackson 2 only)* Verify Jackson 2 and Spring Boot 4 coexist, and document in the migration guide that BDK 4.0 remains on Jackson 2 with Jackson 3 targeted for 4.1 — N/A, D2 adopted Jackson 3 +- [x] 6.7 Assert there is exactly one Jackson databind implementation on the runtime classpath — a dependency-verification test, so the split classpath D2 rejects cannot appear later by accident ## 7. Module Rename: `http-jersey2` → `http-jersey` (D7 — own commit) diff --git a/symphony-bdk-bom/build.gradle b/symphony-bdk-bom/build.gradle index b6f95c79b..de15f3920 100644 --- a/symphony-bdk-bom/build.gradle +++ b/symphony-bdk-bom/build.gradle @@ -17,8 +17,9 @@ repositories { dependencies { // import Spring Boot's BOM api platform('org.springframework.boot:spring-boot-dependencies:4.0.0') - // import Jackson's BOM - api platform('com.fasterxml.jackson:jackson-bom:2.18.8') + // import Jackson 3's BOM. jackson-annotations has no 3.x release (JSTEP-1); the bom pins it at + // the matching 2.x version under its legacy com.fasterxml.jackson.core coordinate (D2). + api platform('tools.jackson:jackson-bom:3.2.1') // import Jersey's BOM api platform('org.glassfish.jersey:jersey-bom:4.0.0') // import Log4j's BOM @@ -64,14 +65,15 @@ dependencies { api 'com.brsanthu:migbase64:2.2' api 'io.jsonwebtoken:jjwt-api:0.13.0' api 'io.jsonwebtoken:jjwt-impl:0.13.0' - api 'io.jsonwebtoken:jjwt-jackson:0.13.0' api 'org.bouncycastle:bcpkix-jdk18on:1.79' api 'com.google.code.findbugs:jsr305:3.0.2' api 'io.github.resilience4j:resilience4j-retry:2.3.0' api 'io.swagger:swagger-annotations:1.6.14' - api 'org.openapitools:jackson-databind-nullable:0.2.6' + // 0.2.11+ is a multi-release jar with a Jackson 3 backend (JsonNullableJackson3Module), + // auto-selected when tools.jackson.databind is on the classpath. + api 'org.openapitools:jackson-databind-nullable:0.2.11' api 'org.freemarker:freemarker:2.3.33' api 'com.github.jknack:handlebars:4.5.1' diff --git a/symphony-bdk-cli/build.gradle b/symphony-bdk-cli/build.gradle index c965a35e0..deabdb054 100644 --- a/symphony-bdk-cli/build.gradle +++ b/symphony-bdk-cli/build.gradle @@ -31,8 +31,7 @@ dependencies { implementation 'info.picocli:picocli' // JSON serialisation of command results - implementation 'com.fasterxml.jackson.core:jackson-databind' - implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation 'tools.jackson.core:jackson-databind' // logging: routed to stderr by the bundled logback.xml; compiled against so verbosity can be tuned implementation 'ch.qos.logback:logback-classic' diff --git a/symphony-bdk-cli/src/main/java/com/symphony/bdk/cli/internal/Json.java b/symphony-bdk-cli/src/main/java/com/symphony/bdk/cli/internal/Json.java index d45bbd457..ed31ec715 100644 --- a/symphony-bdk-cli/src/main/java/com/symphony/bdk/cli/internal/Json.java +++ b/symphony-bdk-cli/src/main/java/com/symphony/bdk/cli/internal/Json.java @@ -1,13 +1,11 @@ package com.symphony.bdk.cli.internal; import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectWriter; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; - -import java.io.UncheckedIOException; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectWriter; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.cfg.DateTimeFeature; +import tools.jackson.databind.json.JsonMapper; /** * Shared Jackson serialisation for CLI output. @@ -18,11 +16,11 @@ */ public final class Json { - private static final ObjectMapper MAPPER = new ObjectMapper() - .registerModule(new JavaTimeModule()) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + private static final ObjectMapper MAPPER = JsonMapper.builder() + .disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS) .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .setSerializationInclusion(JsonInclude.Include.NON_NULL); + .changeDefaultPropertyInclusion(inclusion -> inclusion.withValueInclusion(JsonInclude.Include.NON_NULL)) + .build(); private static final ObjectWriter PRETTY = MAPPER.writerWithDefaultPrettyPrinter(); private static final ObjectWriter COMPACT = MAPPER.writer(); @@ -32,19 +30,11 @@ private Json() { /** Serialise {@code value} as an indented, multi-line JSON document. */ public static String pretty(Object value) { - return write(PRETTY, value); + return PRETTY.writeValueAsString(value); } /** Serialise {@code value} as a single-line JSON document (one NDJSON record). */ public static String compact(Object value) { - return write(COMPACT, value); - } - - private static String write(ObjectWriter writer, Object value) { - try { - return writer.writeValueAsString(value); - } catch (JsonProcessingException e) { - throw new UncheckedIOException(e); - } + return COMPACT.writeValueAsString(value); } } diff --git a/symphony-bdk-cli/src/test/java/com/symphony/bdk/cli/CommandTest.java b/symphony-bdk-cli/src/test/java/com/symphony/bdk/cli/CommandTest.java index 60dbaab33..0732d058b 100644 --- a/symphony-bdk-cli/src/test/java/com/symphony/bdk/cli/CommandTest.java +++ b/symphony-bdk-cli/src/test/java/com/symphony/bdk/cli/CommandTest.java @@ -9,8 +9,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import com.symphony.bdk.core.SymphonyBdk; import com.symphony.bdk.core.service.pagination.model.PaginationAttribute; import com.symphony.bdk.core.service.health.HealthService; diff --git a/symphony-bdk-cli/src/test/java/com/symphony/bdk/cli/DatafeedReadTest.java b/symphony-bdk-cli/src/test/java/com/symphony/bdk/cli/DatafeedReadTest.java index eed25c10d..aa378b8e0 100644 --- a/symphony-bdk-cli/src/test/java/com/symphony/bdk/cli/DatafeedReadTest.java +++ b/symphony-bdk-cli/src/test/java/com/symphony/bdk/cli/DatafeedReadTest.java @@ -8,7 +8,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import com.symphony.bdk.core.SymphonyBdk; import com.symphony.bdk.core.service.datafeed.DatafeedLoop; import com.symphony.bdk.core.service.datafeed.RealTimeEventListener; diff --git a/symphony-bdk-cli/src/test/java/com/symphony/bdk/cli/JsonOutputTest.java b/symphony-bdk-cli/src/test/java/com/symphony/bdk/cli/JsonOutputTest.java index 5292fef0a..f9c45dd88 100644 --- a/symphony-bdk-cli/src/test/java/com/symphony/bdk/cli/JsonOutputTest.java +++ b/symphony-bdk-cli/src/test/java/com/symphony/bdk/cli/JsonOutputTest.java @@ -4,8 +4,8 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import com.symphony.bdk.core.SymphonyBdk; import com.symphony.bdk.core.service.session.SessionService; import com.symphony.bdk.gen.api.model.UserV2; diff --git a/symphony-bdk-config/build.gradle b/symphony-bdk-config/build.gradle index 80d5914b6..c1f22dc75 100644 --- a/symphony-bdk-config/build.gradle +++ b/symphony-bdk-config/build.gradle @@ -17,9 +17,9 @@ dependencies { implementation 'org.apache.commons:commons-lang3' implementation 'org.apache.commons:commons-text' - api 'com.fasterxml.jackson.core:jackson-databind' - api 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml' - api 'com.fasterxml.jackson.dataformat:jackson-dataformat-properties' + api 'tools.jackson.core:jackson-databind' + api 'tools.jackson.dataformat:jackson-dataformat-yaml' + api 'tools.jackson.dataformat:jackson-dataformat-properties' testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'ch.qos.logback:logback-classic' diff --git a/symphony-bdk-config/src/main/java/com/symphony/bdk/core/config/BdkConfigLoader.java b/symphony-bdk-config/src/main/java/com/symphony/bdk/core/config/BdkConfigLoader.java index ecdbe5e88..2f96d00b9 100644 --- a/symphony-bdk-config/src/main/java/com/symphony/bdk/core/config/BdkConfigLoader.java +++ b/symphony-bdk-config/src/main/java/com/symphony/bdk/core/config/BdkConfigLoader.java @@ -3,13 +3,13 @@ import com.symphony.bdk.core.config.exception.BdkConfigException; import com.symphony.bdk.core.config.model.BdkConfig; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.dataformat.javaprop.JavaPropsMapper; import lombok.Generated; import lombok.extern.slf4j.Slf4j; import org.apiguardian.api.API; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.dataformat.javaprop.JavaPropsMapper; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -28,10 +28,12 @@ public class BdkConfigLoader { private static final JavaPropsMapper PROPS_MAPPER; static { - JSON_MAPPER = new JsonMapper(); - PROPS_MAPPER = new JavaPropsMapper(); - PROPS_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - JSON_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + JSON_MAPPER = JsonMapper.builder() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .build(); + PROPS_MAPPER = JavaPropsMapper.builder() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .build(); } /** diff --git a/symphony-bdk-config/src/main/java/com/symphony/bdk/core/config/BdkConfigParser.java b/symphony-bdk-config/src/main/java/com/symphony/bdk/core/config/BdkConfigParser.java index 7e718b412..6a58b057b 100644 --- a/symphony-bdk-config/src/main/java/com/symphony/bdk/core/config/BdkConfigParser.java +++ b/symphony-bdk-config/src/main/java/com/symphony/bdk/core/config/BdkConfigParser.java @@ -2,39 +2,38 @@ import com.symphony.bdk.core.config.exception.BdkConfigException; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fasterxml.jackson.databind.node.TextNode; -import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; import lombok.extern.slf4j.Slf4j; import org.apache.commons.text.StringSubstitutor; import org.apache.commons.text.lookup.StringLookupFactory; import org.apiguardian.api.API; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; +import tools.jackson.databind.node.StringNode; +import tools.jackson.dataformat.yaml.YAMLMapper; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; -import java.util.Iterator; import java.util.stream.Collectors; @Slf4j @API(status = API.Status.INTERNAL) class BdkConfigParser { - private static final ObjectMapper JSON_MAPPER = new JsonMapper(); - private static final ObjectMapper YAML_MAPPER = new YAMLMapper(); + private static final ObjectMapper JSON_MAPPER = JsonMapper.builder() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .build(); + private static final ObjectMapper YAML_MAPPER = YAMLMapper.builder() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .build(); private final StringSubstitutor envVarStringSubstitutor; - static { - JSON_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - YAML_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - } - public BdkConfigParser() { envVarStringSubstitutor = new StringSubstitutor(StringLookupFactory.INSTANCE.environmentVariableStringLookup()); } @@ -56,17 +55,17 @@ public JsonNode parseJsonNode(InputStream inputStream) throws BdkConfigException } try { return JSON_MAPPER.readTree(content); - } catch (IOException e) { + } catch (JacksonException e) { log.debug("Config file is not in JSON format."); } try { JsonNode jsonNode = YAML_MAPPER.readTree(content); - if (jsonNode.isContainerNode()) { + if (jsonNode.isContainer()) { log.debug("Config file found in YAML format."); return jsonNode; } - } catch (IOException e) { + } catch (JacksonException e) { log.debug("Config file is not in YAML format."); } throw new BdkConfigException("Given InputStream is not valid. Only YAML or JSON are allowed."); @@ -83,9 +82,8 @@ public void interpolateProperties(JsonNode jsonNode) { } private void interpolatePropertiesInObject(ObjectNode objectNode) { - final Iterator fieldNames = objectNode.fieldNames(); - while (fieldNames.hasNext()) { - interpolatePropertyInField(objectNode, fieldNames.next()); + for (final String field : objectNode.propertyNames()) { + interpolatePropertyInField(objectNode, field); } } @@ -95,7 +93,7 @@ private void interpolatePropertyInField(ObjectNode objectNode, String field) { //Start by replacing any java system properties found, then match any remaining keys with environment variables final String interpolatedFieldValue = envVarStringSubstitutor.replace(StringSubstitutor.replaceSystemProperties(node.asText())); - objectNode.set(field, new TextNode(interpolatedFieldValue)); + objectNode.set(field, new StringNode(interpolatedFieldValue)); } else if (node.isObject() || node.isArray()) { interpolateProperties(node); } diff --git a/symphony-bdk-core/build.gradle b/symphony-bdk-core/build.gradle index 56a87f99e..f6e64a583 100644 --- a/symphony-bdk-core/build.gradle +++ b/symphony-bdk-core/build.gradle @@ -49,9 +49,8 @@ dependencies { implementation 'com.brsanthu:migbase64' implementation 'io.jsonwebtoken:jjwt-api' runtimeOnly 'io.jsonwebtoken:jjwt-impl' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson' implementation 'org.bouncycastle:bcpkix-jdk18on' - api 'com.fasterxml.jackson.core:jackson-databind' + api 'tools.jackson.core:jackson-databind' implementation 'io.github.resilience4j:resilience4j-retry' implementation 'io.swagger:swagger-annotations' implementation 'com.google.code.findbugs:jsr305' diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/activity/form/FormReplyActivity.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/activity/form/FormReplyActivity.java index 772366bb8..006662308 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/activity/form/FormReplyActivity.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/activity/form/FormReplyActivity.java @@ -6,10 +6,10 @@ import com.symphony.bdk.core.service.datafeed.RealTimeEventListener; import com.symphony.bdk.gen.api.model.V4SymphonyElementsAction; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; import lombok.extern.slf4j.Slf4j; import org.apiguardian.api.API; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import java.util.function.Consumer; diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/activity/form/FormReplyContext.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/activity/form/FormReplyContext.java index 1016e7ab6..0c537b5ec 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/activity/form/FormReplyContext.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/activity/form/FormReplyContext.java @@ -4,10 +4,10 @@ import com.symphony.bdk.gen.api.model.V4Initiator; import com.symphony.bdk.gen.api.model.V4SymphonyElementsAction; -import com.fasterxml.jackson.databind.JsonNode; import lombok.Getter; import lombok.Setter; import org.apiguardian.api.API; +import tools.jackson.databind.JsonNode; import javax.annotation.Nullable; diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/activity/parsing/InputTokenizer.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/activity/parsing/InputTokenizer.java index 29bf597dc..7f1eb65e6 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/activity/parsing/InputTokenizer.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/activity/parsing/InputTokenizer.java @@ -5,14 +5,14 @@ import com.symphony.bdk.core.service.message.util.EntityTypeEnum; import com.symphony.bdk.gen.api.model.V4Message; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apiguardian.api.API; import org.w3c.dom.Document; import org.w3c.dom.Node; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/AuthSessionImpl.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/AuthSessionImpl.java index f10b07e96..dcf338ad9 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/AuthSessionImpl.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/impl/AuthSessionImpl.java @@ -6,8 +6,8 @@ import com.symphony.bdk.core.service.version.model.AgentVersion; import com.symphony.bdk.gen.api.model.Token; -import com.fasterxml.jackson.core.JsonProcessingException; import org.apiguardian.api.API; +import tools.jackson.core.JacksonException; import java.time.Duration; import java.time.Instant; @@ -113,7 +113,7 @@ private void refreshExpirationDate() throws AuthUnauthorizedException { if (this.authorizationToken != null) { try { this.authTokenExpirationDate = JwtHelper.extractExpirationDate(authorizationToken); - } catch (JsonProcessingException | AuthUnauthorizedException e) { + } catch (JacksonException | AuthUnauthorizedException e) { throw new AuthUnauthorizedException("Unable to parse the Authorization token received."); } } diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/jwt/JwtHelper.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/jwt/JwtHelper.java index 0a526eda4..bd526fe18 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/jwt/JwtHelper.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/auth/jwt/JwtHelper.java @@ -3,14 +3,13 @@ import com.symphony.bdk.core.auth.exception.AuthInitializationException; import com.symphony.bdk.core.auth.exception.AuthUnauthorizedException; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.io.DeserializationException; import io.jsonwebtoken.io.Deserializer; +import io.jsonwebtoken.io.SerializationException; +import io.jsonwebtoken.io.Serializer; import org.apiguardian.api.API; import org.bouncycastle.asn1.pkcs.RSAPrivateKey; import org.bouncycastle.crypto.params.RSAPrivateCrtKeyParameters; @@ -18,9 +17,13 @@ import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.util.io.pem.PemReader; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.OutputStream; import java.io.Reader; import java.io.StringReader; import java.nio.charset.StandardCharsets; @@ -79,6 +82,7 @@ public class JwtHelper { */ public static String createSignedJwt(String user, long expiration, PrivateKey privateKey) { return Jwts.builder() + .json(mapperSerializer()) .subject(user) .expiration(new Date(System.currentTimeMillis() + expiration)) .signWith(privateKey, Jwts.SIG.RS512) @@ -152,9 +156,9 @@ public static boolean isSkdEnabled(String jwt) { * * @param jwt to be parsed * @return expiration date in seconds - * @throws JsonProcessingException if parsing fails + * @throws JacksonException if parsing fails */ - public static Long extractExpirationDate(String jwt) throws JsonProcessingException, AuthUnauthorizedException { + public static Long extractExpirationDate(String jwt) throws JacksonException, AuthUnauthorizedException { String claimsObj = extractDecodedClaims(dropBearer(jwt)); ObjectNode claims = mapper.readValue(claimsObj, ObjectNode.class); if(claims.has(Claims.EXPIRATION) && claims.get(Claims.EXPIRATION).isNumber()) { @@ -175,6 +179,30 @@ private static String dropBearer(String jwt) { return jwt.replace("Bearer ", ""); } + private static Serializer> mapperSerializer() { + // jjwt auto-discovers a Serializer from the classpath (jjwt-jackson) when none is set here; + // supplying our own keeps a single Jackson databind implementation on the classpath (D2). + return new Serializer>() { + @Override + public byte[] serialize(Map map) throws SerializationException { + try { + return mapper.writeValueAsBytes(map); + } catch (JacksonException e) { + throw new SerializationException("Unable to serialize JWT claims", e); + } + } + + @Override + public void serialize(Map map, OutputStream out) throws SerializationException { + try { + mapper.writeValue(out, map); + } catch (JacksonException e) { + throw new SerializationException("Unable to serialize JWT claims", e); + } + } + }; + } + @SuppressWarnings("unchecked") private static Deserializer> normalizeSubjectDeserializer() { // jjwt 0.12+ strictly requires 'sub' to be a String (RFC 7519), but the pod may send it @@ -184,7 +212,7 @@ private static String dropBearer(String jwt) { public Map deserialize(byte[] bytes) throws DeserializationException { try { return normalize(mapper.readValue(bytes, Map.class)); - } catch (IOException e) { + } catch (JacksonException e) { throw new DeserializationException("Unable to deserialize JWT claims", e); } } @@ -193,7 +221,7 @@ private static String dropBearer(String jwt) { public Map deserialize(Reader reader) throws DeserializationException { try { return normalize(mapper.readValue(reader, Map.class)); - } catch (IOException e) { + } catch (JacksonException e) { throw new DeserializationException("Unable to deserialize JWT claims", e); } } diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/extension/ExtensionService.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/extension/ExtensionService.java index a8d4b7bfe..f9fa94714 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/extension/ExtensionService.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/extension/ExtensionService.java @@ -1,6 +1,5 @@ package com.symphony.bdk.core.extension; -import com.fasterxml.jackson.databind.ObjectMapper; import com.symphony.bdk.core.SymphonyBdk; import com.symphony.bdk.core.auth.AuthSession; import com.symphony.bdk.core.client.ApiClientFactory; @@ -15,6 +14,8 @@ import lombok.extern.slf4j.Slf4j; import org.apiguardian.api.API; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import java.lang.reflect.InvocationTargetException; import java.util.Collections; @@ -269,7 +270,7 @@ private void injectExtensionConfig(BdkExtensionConfigAware extension) { try { final C typed = MAPPER.convertValue(rawConfig, extension.getConfigClass()); extension.setExtensionConfig(typed); - } catch (IllegalArgumentException e) { + } catch (IllegalArgumentException | JacksonException e) { throw new BdkExtensionException( "Failed to deserialize extension config key '" + key + "' into " + extension.getConfigClass().getName() + " for extension <" + extension.getClass() + ">", diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/model/Message.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/model/Message.java index 1cf384a62..09df15436 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/model/Message.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/model/Message.java @@ -6,9 +6,6 @@ import com.symphony.bdk.gen.api.model.V4Stream; import com.symphony.bdk.template.api.Template; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -17,6 +14,10 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apiguardian.api.API; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; import java.io.InputStream; import java.util.ArrayList; @@ -95,7 +96,11 @@ public static MessageBuilder builder() { @API(status = API.Status.STABLE) public static class MessageBuilder { - private static final ObjectMapper MAPPER = new JsonMapper(); + // Jackson 3 defaults FAIL_ON_EMPTY_BEANS off; re-enable it so a bean with no accessible + // properties still surfaces as a MessageCreationException instead of silently sending "{}". + private static final ObjectMapper MAPPER = JsonMapper.builder() + .enable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .build(); private String version = "2.0"; private String content; @@ -147,7 +152,7 @@ public MessageBuilder data(@Nonnull Object data) { try { this.data = MAPPER.writeValueAsString(data); return this; - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new MessageCreationException("Failed to serialize data (" + data.getClass() + ") to Json string", e); } } diff --git a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/util/MessageParser.java b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/util/MessageParser.java index 3b0a091be..589cacdd2 100644 --- a/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/util/MessageParser.java +++ b/symphony-bdk-core/src/main/java/com/symphony/bdk/core/service/message/util/MessageParser.java @@ -3,12 +3,12 @@ import com.symphony.bdk.core.service.message.exception.MessageParserException; import com.symphony.bdk.gen.api.model.V4Message; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AccessLevel; import lombok.NoArgsConstructor; import org.apiguardian.api.API; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import java.util.ArrayList; import java.util.Collections; @@ -99,7 +99,7 @@ private static List getTags(V4Message message, EntityTypeEnum type) thro private static JsonNode getJsonNode(String data) throws MessageParserException { try { return MAPPER.readTree(data); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new MessageParserException("Failed to extract payload from message data", e); } } diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/JwtHelperTest.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/JwtHelperTest.java index 6b6b932e7..34b1d5638 100644 --- a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/JwtHelperTest.java +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/auth/JwtHelperTest.java @@ -11,12 +11,13 @@ import com.symphony.bdk.core.auth.jwt.JwtHelper; import com.symphony.bdk.core.auth.jwt.UserClaim; -import com.fasterxml.jackson.core.JsonProcessingException; import com.migcomponents.migbase64.Base64; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.io.DeserializationException; import io.jsonwebtoken.io.Deserializer; +import io.jsonwebtoken.io.SerializationException; +import io.jsonwebtoken.io.Serializer; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.bouncycastle.asn1.ASN1Encodable; @@ -25,11 +26,15 @@ import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.util.io.pem.PemWriter; import org.junit.jupiter.api.Test; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import java.io.ByteArrayInputStream; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStreamReader; +import java.io.OutputStream; import java.io.Reader; import java.io.StringWriter; import java.lang.reflect.Method; @@ -59,6 +64,8 @@ public class JwtHelperTest { private static final String JWT_EXP_INVALID = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.zhWFI4bw81QLE49UnklwMlThgt2ktUOs5M1HKjENgRE.signature"; public static final String JWT_SKD_ENABLED = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiY2FuVXNlU2ltcGxpZmllZEtleURlbGl2ZXJ5Ijp0cnVlfQ.signature"; + private static final ObjectMapper MAPPER = new JsonMapper(); + @Test void loadPkcs8PrivateKey() throws GeneralSecurityException { final PrivateKey privateKey = JwtHelper.parseRsaPrivateKey(generatePkcs8RsaPrivateKey()); @@ -137,7 +144,7 @@ public void testExtractExpirationDateInvalidJwt() { @Test public void testExtractExpirationDateInvalidParsing() { - assertThrows(JsonProcessingException.class, () -> JwtHelper.extractExpirationDate("invalid.common.jwt")); + assertThrows(JacksonException.class, () -> JwtHelper.extractExpirationDate("invalid.common.jwt")); } @Test @@ -344,6 +351,7 @@ private String generateJwt(Key key, UserClaim userClaim) { Date expiration = new Date(new Date().getTime() + (365L * 1000 * 3600 * 24)); return Jwts.builder() + .json(testSerializer()) .setIssuer("me") .setSubject("Bob") .setAudience("you") @@ -355,4 +363,26 @@ private String generateJwt(Key key, UserClaim userClaim) { .setId("123") .compact(); } + + private static Serializer> testSerializer() { + return new Serializer>() { + @Override + public byte[] serialize(Map map) throws SerializationException { + try { + return MAPPER.writeValueAsBytes(map); + } catch (JacksonException e) { + throw new SerializationException("Unable to serialize JWT claims", e); + } + } + + @Override + public void serialize(Map map, OutputStream out) throws SerializationException { + try { + MAPPER.writeValue(out, map); + } catch (JacksonException e) { + throw new SerializationException("Unable to serialize JWT claims", e); + } + } + }; + } } diff --git a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/test/MockApiClient.java b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/test/MockApiClient.java index 867ccf8f7..d50ad50e0 100644 --- a/symphony-bdk-core/src/test/java/com/symphony/bdk/core/test/MockApiClient.java +++ b/symphony-bdk-core/src/test/java/com/symphony/bdk/core/test/MockApiClient.java @@ -1,8 +1,5 @@ package com.symphony.bdk.core.test; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; import com.symphony.bdk.http.api.ApiClient; import com.symphony.bdk.http.api.Pair; import com.symphony.bdk.http.jersey2.ApiClientJersey2; @@ -14,6 +11,9 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.mockito.ArgumentMatchers; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import java.io.UnsupportedEncodingException; import java.lang.reflect.ParameterizedType; @@ -33,10 +33,9 @@ public class MockApiClient { - private static final ObjectMapper MAPPER = new JsonMapper(); - static { - MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - } + private static final ObjectMapper MAPPER = JsonMapper.builder() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .build(); private final Client httpClient = mock(Client.class); { when(this.httpClient.target(anyString())).thenThrow(new MockApiClientException("Calling the mocked ApiClient with wrong path")); diff --git a/symphony-bdk-examples/bdk-core-examples/build.gradle b/symphony-bdk-examples/bdk-core-examples/build.gradle index 463082d86..5c5b06fb2 100644 --- a/symphony-bdk-examples/bdk-core-examples/build.gradle +++ b/symphony-bdk-examples/bdk-core-examples/build.gradle @@ -13,7 +13,6 @@ dependencies { // for advanced configuration of the JerseyClient implementation project(':symphony-bdk-http:symphony-bdk-http-jersey2') implementation 'org.glassfish.jersey.core:jersey-client' - implementation 'org.glassfish.jersey.media:jersey-media-json-jackson' implementation 'org.glassfish.jersey.media:jersey-media-multipart' runtimeOnly 'ch.qos.logback:logback-classic' diff --git a/symphony-bdk-examples/bdk-spring-boot-example/src/main/java/com/symphony/bdk/examples/spring/SampleSpringApplication.java b/symphony-bdk-examples/bdk-spring-boot-example/src/main/java/com/symphony/bdk/examples/spring/SampleSpringApplication.java index 2a4d587e9..cc038843c 100644 --- a/symphony-bdk-examples/bdk-spring-boot-example/src/main/java/com/symphony/bdk/examples/spring/SampleSpringApplication.java +++ b/symphony-bdk-examples/bdk-spring-boot-example/src/main/java/com/symphony/bdk/examples/spring/SampleSpringApplication.java @@ -1,11 +1,18 @@ package com.symphony.bdk.examples.spring; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; @SpringBootApplication public class SampleSpringApplication { + @Bean + public ObjectMapper jackson2ObjectMapper() { + return new ObjectMapper(); + } + public static void main(String[] args) { SpringApplication.run(SampleSpringApplication.class, args); } diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/build.gradle b/symphony-bdk-http/symphony-bdk-http-jersey2/build.gradle index 6d226b9ed..c2a7bc6c2 100644 --- a/symphony-bdk-http/symphony-bdk-http-jersey2/build.gradle +++ b/symphony-bdk-http/symphony-bdk-http-jersey2/build.gradle @@ -18,16 +18,14 @@ dependencies { implementation 'org.apiguardian:apiguardian-api' implementation 'io.jsonwebtoken:jjwt-api' runtimeOnly 'io.jsonwebtoken:jjwt-impl' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson' implementation 'org.bouncycastle:bcpkix-jdk18on' implementation 'commons-io:commons-io' implementation 'org.apache.commons:commons-lang3' implementation 'io.swagger:swagger-annotations' - implementation 'com.fasterxml.jackson.core:jackson-databind' - implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' - implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml' + implementation 'tools.jackson.core:jackson-databind' + implementation 'tools.jackson.dataformat:jackson-dataformat-yaml' + implementation 'tools.jackson.jakarta.rs:jackson-jakarta-rs-json-provider' implementation 'org.openapitools:jackson-databind-nullable' - implementation 'org.glassfish.jersey.media:jersey-media-json-jackson' implementation 'org.glassfish.jersey.core:jersey-client' implementation 'org.glassfish.jersey.inject:jersey-hk2' implementation 'org.glassfish.jersey.media:jersey-media-multipart' diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2.java b/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2.java index f213ce718..85fa865a9 100644 --- a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2.java +++ b/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2.java @@ -21,8 +21,8 @@ import org.glassfish.jersey.client.ClientConfig; import org.glassfish.jersey.client.ClientProperties; import org.glassfish.jersey.client.HttpUrlConnectorProvider; -import org.glassfish.jersey.jackson.JacksonFeature; import org.glassfish.jersey.media.multipart.MultiPartFeature; +import tools.jackson.jakarta.rs.json.JacksonJsonProvider; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -264,7 +264,7 @@ protected ClientConfig createClientConfig(SSLContext sslContext) { @API(status = API.Status.EXPERIMENTAL) protected void configureJackson(ClientConfig clientConfig) { clientConfig.register(new JSON()); - clientConfig.register(JacksonFeature.class); + clientConfig.register(JacksonJsonProvider.class); } @API(status = API.Status.EXPERIMENTAL) diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/JSON.java b/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/JSON.java index 4e7de4a12..a84c4081a 100644 --- a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/JSON.java +++ b/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/JSON.java @@ -1,13 +1,14 @@ package com.symphony.bdk.http.jersey2; import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import jakarta.ws.rs.ext.ContextResolver; import org.apiguardian.api.API; -import org.openapitools.jackson.nullable.JsonNullableModule; +import org.openapitools.jackson.nullable.JsonNullableJackson3Module; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.cfg.DateTimeFeature; +import tools.jackson.databind.cfg.EnumFeature; +import tools.jackson.databind.json.JsonMapper; import java.text.DateFormat; @@ -15,19 +16,10 @@ @API(status = API.Status.INTERNAL) public class JSON implements ContextResolver { - private final ObjectMapper mapper; + private ObjectMapper mapper; public JSON() { - this.mapper = new ObjectMapper(); - this.mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); - this.mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - this.mapper.configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false); - this.mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - this.mapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); - this.mapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); - this.mapper.setDateFormat(new RFC3339DateFormat()); - this.mapper.registerModule(new JavaTimeModule()); - this.mapper.registerModule(new JsonNullableModule()); + this.mapper = buildMapper(new RFC3339DateFormat()); } /** @@ -35,7 +27,20 @@ public JSON() { * @param dateFormat Date format */ public void setDateFormat(DateFormat dateFormat) { - mapper.setDateFormat(dateFormat); + this.mapper = buildMapper(dateFormat); + } + + private static ObjectMapper buildMapper(DateFormat dateFormat) { + return JsonMapper.builder() + .changeDefaultPropertyInclusion(inclusion -> inclusion.withValueInclusion(JsonInclude.Include.NON_NULL)) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false) + .disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS) + .enable(EnumFeature.WRITE_ENUMS_USING_TO_STRING) + .enable(EnumFeature.READ_ENUMS_USING_TO_STRING) + .defaultDateFormat(dateFormat) + .addModule(new JsonNullableJackson3Module()) + .build(); } @Override diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/RFC3339DateFormat.java b/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/RFC3339DateFormat.java index 7087784bd..4404c66de 100644 --- a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/RFC3339DateFormat.java +++ b/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/RFC3339DateFormat.java @@ -1,20 +1,55 @@ package com.symphony.bdk.http.jersey2; -import com.fasterxml.jackson.databind.util.ISO8601DateFormat; -import com.fasterxml.jackson.databind.util.ISO8601Utils; import org.apiguardian.api.API; +import java.text.DateFormat; import java.text.FieldPosition; +import java.text.NumberFormat; +import java.text.ParsePosition; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Calendar; import java.util.Date; +import java.util.GregorianCalendar; +import java.util.Locale; +import java.util.TimeZone; +/** + * Formats dates as RFC 3339 (ISO 8601 with a fixed UTC offset), always including milliseconds. + * + *

Jackson 3 dropped the {@code ISO8601DateFormat} / {@code ISO8601Utils} helpers this class + * used to delegate to, so formatting and parsing are implemented directly on top of {@code java.time}.

+ */ @API(status = API.Status.INTERNAL) -public class RFC3339DateFormat extends ISO8601DateFormat { +public class RFC3339DateFormat extends DateFormat { + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + + public RFC3339DateFormat() { + final Calendar calendar = new GregorianCalendar(TimeZone.getTimeZone("UTC"), Locale.ROOT); + calendar.setLenient(false); + this.calendar = calendar; + final NumberFormat numberFormat = NumberFormat.getIntegerInstance(Locale.ROOT); + numberFormat.setGroupingUsed(false); + this.numberFormat = numberFormat; + } - // Same as ISO8601DateFormat but serializing milliseconds. @Override public StringBuffer format(Date date, StringBuffer toAppendTo, FieldPosition fieldPosition) { - String value = ISO8601Utils.format(date, true); - toAppendTo.append(value); + toAppendTo.append(FORMATTER.format(date.toInstant().atOffset(ZoneOffset.UTC))); return toAppendTo; } + + @Override + public Date parse(String source, ParsePosition pos) { + try { + final OffsetDateTime parsed = OffsetDateTime.parse(source, DateTimeFormatter.ISO_OFFSET_DATE_TIME); + pos.setIndex(source.length()); + return Date.from(parsed.toInstant()); + } catch (java.time.format.DateTimeParseException e) { + pos.setErrorIndex(pos.getIndex()); + return null; + } + } } diff --git a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/build.gradle b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/build.gradle index 136956266..e0aec4409 100644 --- a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/build.gradle +++ b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/build.gradle @@ -43,6 +43,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testImplementation coreProject.sourceSets.test.output testImplementation('org.springframework.boot:spring-boot-starter-test') { diff --git a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/auth/CircleOfTrustControllerTest.java b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/auth/CircleOfTrustControllerTest.java index 06c9d71be..02851aa0d 100644 --- a/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/auth/CircleOfTrustControllerTest.java +++ b/symphony-bdk-spring/symphony-bdk-app-spring-boot-starter/src/test/java/com/symphony/bdk/app/spring/auth/CircleOfTrustControllerTest.java @@ -18,11 +18,14 @@ import com.symphony.bdk.core.config.model.BdkExtAppConfig; import com.symphony.bdk.spring.SymphonyBdkCoreProperties; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.mock.web.MockCookie; @@ -31,8 +34,17 @@ import org.springframework.test.web.servlet.MockMvc; @WebMvcTest(CircleOfTrustController.class) +@Import(CircleOfTrustControllerTest.TestConfig.class) public class CircleOfTrustControllerTest { + @TestConfiguration + static class TestConfig { + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } + } + @Autowired private ObjectMapper objectMapper; diff --git a/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/build.gradle b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/build.gradle index f73a4131f..e04c5efbb 100644 --- a/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/build.gradle +++ b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/build.gradle @@ -40,7 +40,7 @@ dependencies { implementation 'org.apiguardian:apiguardian-api' implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-configuration-processor' - implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'tools.jackson.core:jackson-databind' implementation 'com.google.code.findbugs:jsr305' testCompileOnly 'org.projectlombok:lombok' diff --git a/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/SymphonyBdkMockedConfiguration.java b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/SymphonyBdkMockedConfiguration.java index fcdf2acd8..c90465f72 100644 --- a/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/SymphonyBdkMockedConfiguration.java +++ b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/src/test/java/com/symphony/bdk/spring/SymphonyBdkMockedConfiguration.java @@ -6,7 +6,7 @@ import com.symphony.bdk.gen.api.model.UserV2; import com.symphony.bdk.http.api.ApiClient; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.SneakyThrows; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; From 4b6b37ad4f343af4e277f474c022c458b15e1d63 Mon Sep 17 00:00:00 2001 From: Thibault Pensec Date: Thu, 6 Aug 2026 12:36:08 +0200 Subject: [PATCH 07/13] Rename symphony-bdk-http-jersey2 module to symphony-bdk-http-jersey (section 7) --- docs/getting-started.md | 4 ++-- docs/migration.md | 4 ++-- docs/tech/architecture.md | 2 +- openspec/changes/migrate-spring-boot-4/tasks.md | 10 +++++----- settings.gradle | 2 +- symphony-bdk-bom/build.gradle | 2 +- symphony-bdk-cli/build.gradle | 2 +- symphony-bdk-core/build.gradle | 2 +- .../bdk-ai-agent-example/build.gradle | 2 +- symphony-bdk-examples/bdk-core-examples/build.gradle | 2 +- symphony-bdk-examples/bdk-group-example/build.gradle | 2 +- .../build.gradle | 2 +- .../bdk/http/jersey2/ApiClientBuilderJersey2.java | 0 .../http/jersey2/ApiClientBuilderProviderJersey2.java | 0 .../symphony/bdk/http/jersey2/ApiClientJersey2.java | 0 .../http/jersey2/ApiClientJersey2RequestLogFilter.java | 0 .../main/java/com/symphony/bdk/http/jersey2/JSON.java | 0 .../symphony/bdk/http/jersey2/RFC3339DateFormat.java | 0 .../com.symphony.bdk.http.api.ApiClientBuilderProvider | 0 .../bdk/http/jersey2/ApiClientBuilderJersey2Test.java | 0 .../bdk/http/jersey2/ApiClientJersey2Test.java | 0 .../symphony-bdk-core-spring-boot-starter/build.gradle | 2 +- 22 files changed, 19 insertions(+), 19 deletions(-) rename symphony-bdk-http/{symphony-bdk-http-jersey2 => symphony-bdk-http-jersey}/build.gradle (97%) rename symphony-bdk-http/{symphony-bdk-http-jersey2 => symphony-bdk-http-jersey}/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2.java (100%) rename symphony-bdk-http/{symphony-bdk-http-jersey2 => symphony-bdk-http-jersey}/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderProviderJersey2.java (100%) rename symphony-bdk-http/{symphony-bdk-http-jersey2 => symphony-bdk-http-jersey}/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2.java (100%) rename symphony-bdk-http/{symphony-bdk-http-jersey2 => symphony-bdk-http-jersey}/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2RequestLogFilter.java (100%) rename symphony-bdk-http/{symphony-bdk-http-jersey2 => symphony-bdk-http-jersey}/src/main/java/com/symphony/bdk/http/jersey2/JSON.java (100%) rename symphony-bdk-http/{symphony-bdk-http-jersey2 => symphony-bdk-http-jersey}/src/main/java/com/symphony/bdk/http/jersey2/RFC3339DateFormat.java (100%) rename symphony-bdk-http/{symphony-bdk-http-jersey2 => symphony-bdk-http-jersey}/src/main/resources/META-INF/services/com.symphony.bdk.http.api.ApiClientBuilderProvider (100%) rename symphony-bdk-http/{symphony-bdk-http-jersey2 => symphony-bdk-http-jersey}/src/test/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2Test.java (100%) rename symphony-bdk-http/{symphony-bdk-http-jersey2 => symphony-bdk-http-jersey}/src/test/java/com/symphony/bdk/http/jersey2/ApiClientJersey2Test.java (100%) diff --git a/docs/getting-started.md b/docs/getting-started.md index 0d53dbb1a..e5291058d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -59,7 +59,7 @@ If you want to use [Maven](https://maven.apache.org/) as build system, you have org.finos.symphony.bdk - symphony-bdk-http-jersey2 + symphony-bdk-http-jersey runtime @@ -101,7 +101,7 @@ dependencies { // define dependencies without versions implementation 'org.finos.symphony.bdk:symphony-bdk-core' - runtimeOnly 'org.finos.symphony.bdk:symphony-bdk-http-jersey2' // or symphony-bdk-http-webclient + runtimeOnly 'org.finos.symphony.bdk:symphony-bdk-http-jersey' // or symphony-bdk-http-webclient runtimeOnly 'org.finos.symphony.bdk:symphony-bdk-template-freemarker' // or symphony-bdk-http-handlebars // logger configuration diff --git a/docs/migration.md b/docs/migration.md index 87e1ae838..ad442e9f1 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -116,7 +116,7 @@ Simply update BDK dependency version to `3.0.0` org.finos.symphony.bdk - symphony-bdk-http-jersey2 + symphony-bdk-http-jersey runtime @@ -219,7 +219,7 @@ If your project is not framework based, dependencies such as *jersey* and *freem org.finos.symphony.bdk - symphony-bdk-http-jersey2 + symphony-bdk-http-jersey runtime diff --git a/docs/tech/architecture.md b/docs/tech/architecture.md index 60da50038..637b9c490 100644 --- a/docs/tech/architecture.md +++ b/docs/tech/architecture.md @@ -35,7 +35,7 @@ also provides a utility `com.symphony.bdk.http.api.HttpClient` class helping dev > Changing contract would break the build. See [Code Generation](#code-generation). At the moment, two different implementations have been created for the `com.symphony.bdk.http.api.ApiClient` interface: -- `com.symphony.bdk.http.jersey2.ApiClientJersey2` contained in module `symphony-bdk-http-jersey2` (default implementation for [Core](#symphony-bdk-core)) +- `com.symphony.bdk.http.jersey2.ApiClientJersey2` contained in module `symphony-bdk-http-jersey` (default implementation for [Core](#symphony-bdk-core)) - `com.symphony.bdk.http.webclient.ApiClientWebClient` contained in module `symphony-bdk-http-webclient` (default implementation for [Spring Boot](#symphony-bdk-spring)) ### symphony-bdk-template diff --git a/openspec/changes/migrate-spring-boot-4/tasks.md b/openspec/changes/migrate-spring-boot-4/tasks.md index d3c310ec5..a6d5822fd 100644 --- a/openspec/changes/migrate-spring-boot-4/tasks.md +++ b/openspec/changes/migrate-spring-boot-4/tasks.md @@ -58,11 +58,11 @@ ## 7. Module Rename: `http-jersey2` → `http-jersey` (D7 — own commit) -- [ ] 7.1 Rename the directory `symphony-bdk-http/symphony-bdk-http-jersey2` → `symphony-bdk-http-jersey` and update `settings.gradle` -- [ ] 7.2 Update the `symphony-bdk-bom` constraint to the new artifactId -- [ ] 7.3 Update all consuming modules: `symphony-bdk-core` (test), `symphony-bdk-core-spring-boot-starter`, `symphony-bdk-cli`, `bdk-ai-agent-example`, and any other example -- [ ] 7.4 Update `docs/` and every code sample referencing the old coordinate -- [ ] 7.5 Confirm no relocation POM or forwarding artifact is published (D7) +- [x] 7.1 Rename the directory `symphony-bdk-http/symphony-bdk-http-jersey2` → `symphony-bdk-http-jersey` and update `settings.gradle` +- [x] 7.2 Update the `symphony-bdk-bom` constraint to the new artifactId +- [x] 7.3 Update all consuming modules: `symphony-bdk-core` (test), `symphony-bdk-core-spring-boot-starter`, `symphony-bdk-cli`, `bdk-ai-agent-example`, and any other example +- [x] 7.4 Update `docs/` and every code sample referencing the old coordinate +- [x] 7.5 Confirm no relocation POM or forwarding artifact is published (D7) ## 8. JSR-305 → JSpecify (D4 — own commit) diff --git a/settings.gradle b/settings.gradle index 69a4f4fbf..08a2d91cd 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,7 +11,7 @@ include(':symphony-bdk-cli') // http client include(':symphony-bdk-http:symphony-bdk-http-api') -include(':symphony-bdk-http:symphony-bdk-http-jersey2') +include(':symphony-bdk-http:symphony-bdk-http-jersey') include(':symphony-bdk-http:symphony-bdk-http-webclient') // template API diff --git a/symphony-bdk-bom/build.gradle b/symphony-bdk-bom/build.gradle index de15f3920..375e32c40 100644 --- a/symphony-bdk-bom/build.gradle +++ b/symphony-bdk-bom/build.gradle @@ -32,7 +32,7 @@ dependencies { api "org.finos.symphony.bdk:symphony-bdk-config:$project.version" api "org.finos.symphony.bdk:symphony-bdk-extension-api:$project.version" api "org.finos.symphony.bdk:symphony-bdk-http-api:$project.version" - api "org.finos.symphony.bdk:symphony-bdk-http-jersey2:$project.version" + api "org.finos.symphony.bdk:symphony-bdk-http-jersey:$project.version" api "org.finos.symphony.bdk:symphony-bdk-http-webclient:$project.version" api "org.finos.symphony.bdk:symphony-bdk-core-spring-boot-starter:$project.version" api "org.finos.symphony.bdk:symphony-bdk-app-spring-boot-starter:$project.version" diff --git a/symphony-bdk-cli/build.gradle b/symphony-bdk-cli/build.gradle index deabdb054..9f3b000bb 100644 --- a/symphony-bdk-cli/build.gradle +++ b/symphony-bdk-cli/build.gradle @@ -24,7 +24,7 @@ dependencies { implementation project(':symphony-bdk-core') // self-contained distribution: bundle an HTTP implementation and a logging backend - runtimeOnly project(':symphony-bdk-http:symphony-bdk-http-jersey2') + runtimeOnly project(':symphony-bdk-http:symphony-bdk-http-jersey') runtimeOnly project(':symphony-bdk-template:symphony-bdk-template-freemarker') // command-line parsing diff --git a/symphony-bdk-core/build.gradle b/symphony-bdk-core/build.gradle index f6e64a583..5cbab08e1 100644 --- a/symphony-bdk-core/build.gradle +++ b/symphony-bdk-core/build.gradle @@ -57,7 +57,7 @@ dependencies { implementation 'javax.annotation:jsr250-api:1.0' implementation 'jakarta.ws.rs:jakarta.ws.rs-api' - testImplementation project(':symphony-bdk-http:symphony-bdk-http-jersey2') + testImplementation project(':symphony-bdk-http:symphony-bdk-http-jersey') testRuntimeOnly project(':symphony-bdk-template:symphony-bdk-template-freemarker') testCompileOnly 'org.projectlombok:lombok' diff --git a/symphony-bdk-examples/bdk-ai-agent-example/build.gradle b/symphony-bdk-examples/bdk-ai-agent-example/build.gradle index 664283d35..001fa7897 100644 --- a/symphony-bdk-examples/bdk-ai-agent-example/build.gradle +++ b/symphony-bdk-examples/bdk-ai-agent-example/build.gradle @@ -13,7 +13,7 @@ dependencies { implementation project(':symphony-bdk-core') runtimeOnly project(':symphony-bdk-template:symphony-bdk-template-freemarker') - implementation project(':symphony-bdk-http:symphony-bdk-http-jersey2') + implementation project(':symphony-bdk-http:symphony-bdk-http-jersey') implementation platform("dev.langchain4j:langchain4j-bom:${langchain4jVersion}") implementation 'dev.langchain4j:langchain4j' diff --git a/symphony-bdk-examples/bdk-core-examples/build.gradle b/symphony-bdk-examples/bdk-core-examples/build.gradle index 5c5b06fb2..dd49ae9e0 100644 --- a/symphony-bdk-examples/bdk-core-examples/build.gradle +++ b/symphony-bdk-examples/bdk-core-examples/build.gradle @@ -11,7 +11,7 @@ dependencies { runtimeOnly project(':symphony-bdk-template:symphony-bdk-template-freemarker') // for advanced configuration of the JerseyClient - implementation project(':symphony-bdk-http:symphony-bdk-http-jersey2') + implementation project(':symphony-bdk-http:symphony-bdk-http-jersey') implementation 'org.glassfish.jersey.core:jersey-client' implementation 'org.glassfish.jersey.media:jersey-media-multipart' diff --git a/symphony-bdk-examples/bdk-group-example/build.gradle b/symphony-bdk-examples/bdk-group-example/build.gradle index 8fd144edb..36bc1739c 100644 --- a/symphony-bdk-examples/bdk-group-example/build.gradle +++ b/symphony-bdk-examples/bdk-group-example/build.gradle @@ -9,7 +9,7 @@ dependencies { implementation project(':symphony-bdk-core') runtimeOnly project(':symphony-bdk-template:symphony-bdk-template-freemarker') - runtimeOnly project(':symphony-bdk-http:symphony-bdk-http-jersey2') + runtimeOnly project(':symphony-bdk-http:symphony-bdk-http-jersey') // import the extension implementation project(':symphony-bdk-extensions:symphony-group-extension') diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/build.gradle b/symphony-bdk-http/symphony-bdk-http-jersey/build.gradle similarity index 97% rename from symphony-bdk-http/symphony-bdk-http-jersey2/build.gradle rename to symphony-bdk-http/symphony-bdk-http-jersey/build.gradle index c2a7bc6c2..a5c925bd9 100644 --- a/symphony-bdk-http/symphony-bdk-http-jersey2/build.gradle +++ b/symphony-bdk-http/symphony-bdk-http-jersey/build.gradle @@ -3,7 +3,7 @@ plugins { id 'bdk.java-publish-conventions' } -description = 'Symphony Java BDK Core Http Jersey2' +description = 'Symphony Java BDK Core Http Jersey' dependencies { constraints { diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2.java b/symphony-bdk-http/symphony-bdk-http-jersey/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2.java similarity index 100% rename from symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2.java rename to symphony-bdk-http/symphony-bdk-http-jersey/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2.java diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderProviderJersey2.java b/symphony-bdk-http/symphony-bdk-http-jersey/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderProviderJersey2.java similarity index 100% rename from symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderProviderJersey2.java rename to symphony-bdk-http/symphony-bdk-http-jersey/src/main/java/com/symphony/bdk/http/jersey2/ApiClientBuilderProviderJersey2.java diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2.java b/symphony-bdk-http/symphony-bdk-http-jersey/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2.java similarity index 100% rename from symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2.java rename to symphony-bdk-http/symphony-bdk-http-jersey/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2.java diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2RequestLogFilter.java b/symphony-bdk-http/symphony-bdk-http-jersey/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2RequestLogFilter.java similarity index 100% rename from symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2RequestLogFilter.java rename to symphony-bdk-http/symphony-bdk-http-jersey/src/main/java/com/symphony/bdk/http/jersey2/ApiClientJersey2RequestLogFilter.java diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/JSON.java b/symphony-bdk-http/symphony-bdk-http-jersey/src/main/java/com/symphony/bdk/http/jersey2/JSON.java similarity index 100% rename from symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/JSON.java rename to symphony-bdk-http/symphony-bdk-http-jersey/src/main/java/com/symphony/bdk/http/jersey2/JSON.java diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/RFC3339DateFormat.java b/symphony-bdk-http/symphony-bdk-http-jersey/src/main/java/com/symphony/bdk/http/jersey2/RFC3339DateFormat.java similarity index 100% rename from symphony-bdk-http/symphony-bdk-http-jersey2/src/main/java/com/symphony/bdk/http/jersey2/RFC3339DateFormat.java rename to symphony-bdk-http/symphony-bdk-http-jersey/src/main/java/com/symphony/bdk/http/jersey2/RFC3339DateFormat.java diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/main/resources/META-INF/services/com.symphony.bdk.http.api.ApiClientBuilderProvider b/symphony-bdk-http/symphony-bdk-http-jersey/src/main/resources/META-INF/services/com.symphony.bdk.http.api.ApiClientBuilderProvider similarity index 100% rename from symphony-bdk-http/symphony-bdk-http-jersey2/src/main/resources/META-INF/services/com.symphony.bdk.http.api.ApiClientBuilderProvider rename to symphony-bdk-http/symphony-bdk-http-jersey/src/main/resources/META-INF/services/com.symphony.bdk.http.api.ApiClientBuilderProvider diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/test/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2Test.java b/symphony-bdk-http/symphony-bdk-http-jersey/src/test/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2Test.java similarity index 100% rename from symphony-bdk-http/symphony-bdk-http-jersey2/src/test/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2Test.java rename to symphony-bdk-http/symphony-bdk-http-jersey/src/test/java/com/symphony/bdk/http/jersey2/ApiClientBuilderJersey2Test.java diff --git a/symphony-bdk-http/symphony-bdk-http-jersey2/src/test/java/com/symphony/bdk/http/jersey2/ApiClientJersey2Test.java b/symphony-bdk-http/symphony-bdk-http-jersey/src/test/java/com/symphony/bdk/http/jersey2/ApiClientJersey2Test.java similarity index 100% rename from symphony-bdk-http/symphony-bdk-http-jersey2/src/test/java/com/symphony/bdk/http/jersey2/ApiClientJersey2Test.java rename to symphony-bdk-http/symphony-bdk-http-jersey/src/test/java/com/symphony/bdk/http/jersey2/ApiClientJersey2Test.java diff --git a/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/build.gradle b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/build.gradle index e04c5efbb..9ec80c0ac 100644 --- a/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/build.gradle +++ b/symphony-bdk-spring/symphony-bdk-core-spring-boot-starter/build.gradle @@ -28,7 +28,7 @@ jacocoTestCoverageVerification { dependencies { api project(':symphony-bdk-core') api project(':symphony-bdk-http:symphony-bdk-http-api') - implementation project(':symphony-bdk-http:symphony-bdk-http-jersey2') + implementation project(':symphony-bdk-http:symphony-bdk-http-jersey') api project(':symphony-bdk-template:symphony-bdk-template-api') implementation project(':symphony-bdk-template:symphony-bdk-template-freemarker') From ff1dd128f30e2ea7bdcca787cfc2e52ad100fc60 Mon Sep 17 00:00:00 2001 From: Thibault Pensec Date: Thu, 6 Aug 2026 12:36:21 +0200 Subject: [PATCH 08/13] Add OpenSpec sync command/skill and Gemini CLI support Adds the multi-store-aware /opsx:sync command and skill, updates the existing apply/archive/explore/propose commands and skills for store selection, and mirrors all opsx commands/skills for the Gemini CLI. --- .claude/commands/opsx/apply.md | 3 + .claude/commands/opsx/archive.md | 21 +- .claude/commands/opsx/explore.md | 11 +- .claude/commands/opsx/propose.md | 9 +- .claude/commands/opsx/sync.md | 143 +++++++++ .claude/skills/openspec-apply-change/SKILL.md | 5 +- .../skills/openspec-archive-change/SKILL.md | 17 +- .claude/skills/openspec-explore/SKILL.md | 13 +- .claude/skills/openspec-propose/SKILL.md | 11 +- .claude/skills/openspec-sync-specs/SKILL.md | 147 +++++++++ .gemini/commands/opsx/apply.toml | 152 +++++++++ .gemini/commands/opsx/archive.toml | 157 ++++++++++ .gemini/commands/opsx/explore.toml | 171 +++++++++++ .gemini/commands/opsx/propose.toml | 106 +++++++ .gemini/commands/opsx/sync.toml | 140 +++++++++ .gemini/skills/openspec-apply-change/SKILL.md | 159 ++++++++++ .../skills/openspec-archive-change/SKILL.md | 117 +++++++ .gemini/skills/openspec-explore/SKILL.md | 289 ++++++++++++++++++ .gemini/skills/openspec-propose/SKILL.md | 113 +++++++ .gemini/skills/openspec-sync-specs/SKILL.md | 147 +++++++++ CLAUDE.md | 4 + 21 files changed, 1900 insertions(+), 35 deletions(-) create mode 100644 .claude/commands/opsx/sync.md create mode 100644 .claude/skills/openspec-sync-specs/SKILL.md create mode 100644 .gemini/commands/opsx/apply.toml create mode 100644 .gemini/commands/opsx/archive.toml create mode 100644 .gemini/commands/opsx/explore.toml create mode 100644 .gemini/commands/opsx/propose.toml create mode 100644 .gemini/commands/opsx/sync.toml create mode 100644 .gemini/skills/openspec-apply-change/SKILL.md create mode 100644 .gemini/skills/openspec-archive-change/SKILL.md create mode 100644 .gemini/skills/openspec-explore/SKILL.md create mode 100644 .gemini/skills/openspec-propose/SKILL.md create mode 100644 .gemini/skills/openspec-sync-specs/SKILL.md diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md index ae14f0f5f..f540757f3 100644 --- a/.claude/commands/opsx/apply.md +++ b/.claude/commands/opsx/apply.md @@ -7,6 +7,8 @@ tags: [workflow, artifacts, experimental] Implement tasks from an OpenSpec change. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -26,6 +28,7 @@ Implement tasks from an OpenSpec change. ``` Parse the JSON to understand: - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) 3. **Get apply instructions** diff --git a/.claude/commands/opsx/archive.md b/.claude/commands/opsx/archive.md index 5e916083a..c89523193 100644 --- a/.claude/commands/opsx/archive.md +++ b/.claude/commands/opsx/archive.md @@ -7,6 +7,8 @@ tags: [workflow, archive, experimental] Archive a completed change in the experimental workflow. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -26,6 +28,7 @@ Archive a completed change in the experimental workflow. Parse the JSON to understand: - `schemaName`: The workflow being used + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context - `artifacts`: List of artifacts with their status (`done` or other) **If any artifacts are not `done`:** @@ -48,7 +51,7 @@ Archive a completed change in the experimental workflow. 4. **Assess delta spec sync state** - Check for delta specs at `openspec/changes//specs/`. If none exist, proceed without sync prompt. + Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `openspec/specs//spec.md` @@ -63,19 +66,19 @@ Archive a completed change in the experimental workflow. 5. **Perform the archive** - Create the archive directory if it doesn't exist: + Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: ```bash - mkdir -p openspec/changes/archive + mkdir -p "/archive" ``` Generate target name using current date: `YYYY-MM-DD-` **Check if target already exists:** - If yes: Fail with error, suggest renaming existing archive or using different date - - If no: Move the change directory to archive + - If no: Move `changeRoot` to the archive directory ```bash - mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- + mv "" "/archive/YYYY-MM-DD-" ``` 6. **Display summary** @@ -94,7 +97,7 @@ Archive a completed change in the experimental workflow. **Change:** **Schema:** -**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ +**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ **Specs:** ✓ Synced to main specs All artifacts complete. All tasks complete. @@ -107,7 +110,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ +**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ **Specs:** No delta specs All artifacts complete. All tasks complete. @@ -120,7 +123,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ +**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ **Specs:** Sync skipped (user chose to skip) **Warnings:** @@ -137,7 +140,7 @@ Review the archive if this was not intentional. ## Archive Failed **Change:** -**Target:** openspec/changes/archive/YYYY-MM-DD-/ +**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ Target archive directory already exists. diff --git a/.claude/commands/opsx/explore.md b/.claude/commands/opsx/explore.md index 1757907e5..95bd0c8c2 100644 --- a/.claude/commands/opsx/explore.md +++ b/.claude/commands/opsx/explore.md @@ -11,6 +11,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be: - A vague idea: "real-time collaboration" - A specific problem: "the auth system is getting unwieldy" @@ -107,11 +109,10 @@ Think freely. When insights crystallize, you might offer: If the user mentions a change or you detect one is relevant: -1. **Read existing artifacts for context** - - `openspec/changes//proposal.md` - - `openspec/changes//design.md` - - `openspec/changes//tasks.md` - - etc. +1. **Resolve and read existing artifacts for context** + - Run `openspec status --change "" --json`. + - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. + - Read existing files from `artifactPaths..existingOutputPaths`. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." diff --git a/.claude/commands/opsx/propose.md b/.claude/commands/opsx/propose.md index 05276f4d0..48152de71 100644 --- a/.claude/commands/opsx/propose.md +++ b/.claude/commands/opsx/propose.md @@ -16,6 +16,8 @@ When ready to implement, run /opsx:apply --- +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build. **Steps** @@ -33,7 +35,7 @@ When ready to implement, run /opsx:apply ```bash openspec new change "" ``` - This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. + This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. 3. **Get the artifact build order** ```bash @@ -42,6 +44,7 @@ When ready to implement, run /opsx:apply Parse the JSON to get: - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) - `artifacts`: list of all artifacts with their status and dependencies + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. 4. **Create artifacts in sequence until apply-ready** @@ -59,10 +62,10 @@ When ready to implement, run /opsx:apply - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) - `template`: The structure to use for your output file - `instruction`: Schema-specific guidance for this artifact type - - `outputPath`: Where to write the artifact + - `resolvedOutputPath`: Resolved path or pattern to write the artifact - `dependencies`: Completed artifacts to read for context - Read any completed dependency files for context - - Create the artifact file using `template` as the structure + - Create the artifact file using `template` as the structure and write it to `resolvedOutputPath` - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "Created " diff --git a/.claude/commands/opsx/sync.md b/.claude/commands/opsx/sync.md new file mode 100644 index 000000000..277330cb7 --- /dev/null +++ b/.claude/commands/opsx/sync.md @@ -0,0 +1,143 @@ +--- +name: "OPSX: Sync" +description: Sync delta specs from a change to main specs +category: Workflow +tags: [workflow, specs, experimental] +--- + +Sync delta specs from a change to main specs. + +This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement). + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **If no change name provided, prompt for selection** + + Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select. + + Show changes that have delta specs (under `specs/` directory). + + **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + +2. **Resolve change context** + + Run: + ```bash + openspec status --change "" --json + ``` + +3. **Find delta specs** + + Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files. + + Each delta spec file contains sections like: + - `## ADDED Requirements` - New requirements to add + - `## MODIFIED Requirements` - Changes to existing requirements + - `## REMOVED Requirements` - Requirements to remove + - `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format) + + If no delta specs found, inform user and stop. + +4. **For each delta spec, apply changes to main specs** + + For each repo-local capability delta spec path returned by the CLI: + + a. **Read the delta spec** to understand the intended changes + + b. **Read the main spec** at `openspec/specs//spec.md` (may not exist yet) + + c. **Apply changes intelligently**: + + **ADDED Requirements:** + - If requirement doesn't exist in main spec → add it + - If requirement already exists → update it to match (treat as implicit MODIFIED) + + **MODIFIED Requirements:** + - Find the requirement in main spec + - Apply the changes - this can be: + - Adding new scenarios (don't need to copy existing ones) + - Modifying existing scenarios + - Changing the requirement description + - Preserve scenarios/content not mentioned in the delta + + **REMOVED Requirements:** + - Remove the entire requirement block from main spec + + **RENAMED Requirements:** + - Find the FROM requirement, rename to TO + + d. **Create new main spec** if capability doesn't exist yet: + - Create `openspec/specs//spec.md` + - Add Purpose section (can be brief, mark as TBD) + - Add Requirements section with the ADDED requirements + +5. **Show summary** + + After applying all changes, summarize: + - Which capabilities were updated + - What changes were made (requirements added/modified/removed/renamed) + +**Delta Spec Format Reference** + +```markdown +## ADDED Requirements + +### Requirement: New Feature +The system SHALL do something new. + +#### Scenario: Basic case +- **WHEN** user does X +- **THEN** system does Y + +## MODIFIED Requirements + +### Requirement: Existing Feature +#### Scenario: New scenario to add +- **WHEN** user does A +- **THEN** system does B + +## REMOVED Requirements + +### Requirement: Deprecated Feature + +## RENAMED Requirements + +- FROM: `### Requirement: Old Name` +- TO: `### Requirement: New Name` +``` + +**Key Principle: Intelligent Merging** + +Unlike programmatic merging, you can apply **partial updates**: +- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios +- The delta represents *intent*, not a wholesale replacement +- Use your judgment to merge changes sensibly + +**Output On Success** + +``` +## Specs Synced: + +Updated main specs: + +****: +- Added requirement: "New Feature" +- Modified requirement: "Existing Feature" (added 1 scenario) + +****: +- Created new spec file +- Added requirement: "Another Feature" + +Main specs are now updated. The change remains active - archive when implementation is complete. +``` + +**Guardrails** +- Read both delta and main specs before making changes +- Preserve existing content not mentioned in delta +- If something is unclear, ask for clarification +- Show what you're changing as you go +- The operation should be idempotent - running twice should give same result diff --git a/.claude/skills/openspec-apply-change/SKILL.md b/.claude/skills/openspec-apply-change/SKILL.md index 70fbdb856..05737929d 100644 --- a/.claude/skills/openspec-apply-change/SKILL.md +++ b/.claude/skills/openspec-apply-change/SKILL.md @@ -6,11 +6,13 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.3.1" + generatedBy: "1.5.0" --- Implement tasks from an OpenSpec change. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -30,6 +32,7 @@ Implement tasks from an OpenSpec change. ``` Parse the JSON to understand: - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) 3. **Get apply instructions** diff --git a/.claude/skills/openspec-archive-change/SKILL.md b/.claude/skills/openspec-archive-change/SKILL.md index 12e2f70e9..bb6a8cf55 100644 --- a/.claude/skills/openspec-archive-change/SKILL.md +++ b/.claude/skills/openspec-archive-change/SKILL.md @@ -6,11 +6,13 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.3.1" + generatedBy: "1.5.0" --- Archive a completed change in the experimental workflow. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -30,6 +32,7 @@ Archive a completed change in the experimental workflow. Parse the JSON to understand: - `schemaName`: The workflow being used + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context - `artifacts`: List of artifacts with their status (`done` or other) **If any artifacts are not `done`:** @@ -52,7 +55,7 @@ Archive a completed change in the experimental workflow. 4. **Assess delta spec sync state** - Check for delta specs at `openspec/changes//specs/`. If none exist, proceed without sync prompt. + Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `openspec/specs//spec.md` @@ -67,19 +70,19 @@ Archive a completed change in the experimental workflow. 5. **Perform the archive** - Create the archive directory if it doesn't exist: + Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: ```bash - mkdir -p openspec/changes/archive + mkdir -p "/archive" ``` Generate target name using current date: `YYYY-MM-DD-` **Check if target already exists:** - If yes: Fail with error, suggest renaming existing archive or using different date - - If no: Move the change directory to archive + - If no: Move `changeRoot` to the archive directory ```bash - mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- + mv "" "/archive/YYYY-MM-DD-" ``` 6. **Display summary** @@ -98,7 +101,7 @@ Archive a completed change in the experimental workflow. **Change:** **Schema:** -**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ +**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ **Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped") All artifacts complete. All tasks complete. diff --git a/.claude/skills/openspec-explore/SKILL.md b/.claude/skills/openspec-explore/SKILL.md index 6858d3f69..756b31c0b 100644 --- a/.claude/skills/openspec-explore/SKILL.md +++ b/.claude/skills/openspec-explore/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.3.1" + generatedBy: "1.5.0" --- Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. @@ -15,6 +15,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + --- ## The Stance @@ -102,11 +104,10 @@ Think freely. When insights crystallize, you might offer: If the user mentions a change or you detect one is relevant: -1. **Read existing artifacts for context** - - `openspec/changes//proposal.md` - - `openspec/changes//design.md` - - `openspec/changes//tasks.md` - - etc. +1. **Resolve and read existing artifacts for context** + - Run `openspec status --change "" --json`. + - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. + - Read existing files from `artifactPaths..existingOutputPaths`. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." diff --git a/.claude/skills/openspec-propose/SKILL.md b/.claude/skills/openspec-propose/SKILL.md index 4b7e20418..0208e030e 100644 --- a/.claude/skills/openspec-propose/SKILL.md +++ b/.claude/skills/openspec-propose/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.3.1" + generatedBy: "1.5.0" --- Propose a new change - create the change and generate all artifacts in one step. @@ -20,6 +20,8 @@ When ready to implement, run /opsx:apply --- +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** @@ -37,7 +39,7 @@ When ready to implement, run /opsx:apply ```bash openspec new change "" ``` - This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. + This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. 3. **Get the artifact build order** ```bash @@ -46,6 +48,7 @@ When ready to implement, run /opsx:apply Parse the JSON to get: - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) - `artifacts`: list of all artifacts with their status and dependencies + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. 4. **Create artifacts in sequence until apply-ready** @@ -63,10 +66,10 @@ When ready to implement, run /opsx:apply - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) - `template`: The structure to use for your output file - `instruction`: Schema-specific guidance for this artifact type - - `outputPath`: Where to write the artifact + - `resolvedOutputPath`: Resolved path or pattern to write the artifact - `dependencies`: Completed artifacts to read for context - Read any completed dependency files for context - - Create the artifact file using `template` as the structure + - Create the artifact file using `template` as the structure and write it to `resolvedOutputPath` - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "Created " diff --git a/.claude/skills/openspec-sync-specs/SKILL.md b/.claude/skills/openspec-sync-specs/SKILL.md new file mode 100644 index 000000000..f4ec05469 --- /dev/null +++ b/.claude/skills/openspec-sync-specs/SKILL.md @@ -0,0 +1,147 @@ +--- +name: openspec-sync-specs +description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" + generatedBy: "1.5.0" +--- + +Sync delta specs from a change to main specs. + +This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement). + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **If no change name provided, prompt for selection** + + Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select. + + Show changes that have delta specs (under `specs/` directory). + + **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + +2. **Resolve change context** + + Run: + ```bash + openspec status --change "" --json + ``` + +3. **Find delta specs** + + Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files. + + Each delta spec file contains sections like: + - `## ADDED Requirements` - New requirements to add + - `## MODIFIED Requirements` - Changes to existing requirements + - `## REMOVED Requirements` - Requirements to remove + - `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format) + + If no delta specs found, inform user and stop. + +4. **For each delta spec, apply changes to main specs** + + For each repo-local capability delta spec path returned by the CLI: + + a. **Read the delta spec** to understand the intended changes + + b. **Read the main spec** at `openspec/specs//spec.md` (may not exist yet) + + c. **Apply changes intelligently**: + + **ADDED Requirements:** + - If requirement doesn't exist in main spec → add it + - If requirement already exists → update it to match (treat as implicit MODIFIED) + + **MODIFIED Requirements:** + - Find the requirement in main spec + - Apply the changes - this can be: + - Adding new scenarios (don't need to copy existing ones) + - Modifying existing scenarios + - Changing the requirement description + - Preserve scenarios/content not mentioned in the delta + + **REMOVED Requirements:** + - Remove the entire requirement block from main spec + + **RENAMED Requirements:** + - Find the FROM requirement, rename to TO + + d. **Create new main spec** if capability doesn't exist yet: + - Create `openspec/specs//spec.md` + - Add Purpose section (can be brief, mark as TBD) + - Add Requirements section with the ADDED requirements + +5. **Show summary** + + After applying all changes, summarize: + - Which capabilities were updated + - What changes were made (requirements added/modified/removed/renamed) + +**Delta Spec Format Reference** + +```markdown +## ADDED Requirements + +### Requirement: New Feature +The system SHALL do something new. + +#### Scenario: Basic case +- **WHEN** user does X +- **THEN** system does Y + +## MODIFIED Requirements + +### Requirement: Existing Feature +#### Scenario: New scenario to add +- **WHEN** user does A +- **THEN** system does B + +## REMOVED Requirements + +### Requirement: Deprecated Feature + +## RENAMED Requirements + +- FROM: `### Requirement: Old Name` +- TO: `### Requirement: New Name` +``` + +**Key Principle: Intelligent Merging** + +Unlike programmatic merging, you can apply **partial updates**: +- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios +- The delta represents *intent*, not a wholesale replacement +- Use your judgment to merge changes sensibly + +**Output On Success** + +``` +## Specs Synced: + +Updated main specs: + +****: +- Added requirement: "New Feature" +- Modified requirement: "Existing Feature" (added 1 scenario) + +****: +- Created new spec file +- Added requirement: "Another Feature" + +Main specs are now updated. The change remains active - archive when implementation is complete. +``` + +**Guardrails** +- Read both delta and main specs before making changes +- Preserve existing content not mentioned in delta +- If something is unclear, ask for clarification +- Show what you're changing as you go +- The operation should be idempotent - running twice should give same result diff --git a/.gemini/commands/opsx/apply.toml b/.gemini/commands/opsx/apply.toml new file mode 100644 index 000000000..5888fb01e --- /dev/null +++ b/.gemini/commands/opsx/apply.toml @@ -0,0 +1,152 @@ +description = "Implement tasks from an OpenSpec change (Experimental)" + +prompt = """ +Implement tasks from an OpenSpec change. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + + Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue` + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/opsx:archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.