diff --git a/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/_ReactorCloudFoundryClient.java b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/_ReactorCloudFoundryClient.java index 6fe80b2d96..2b4ef40efc 100644 --- a/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/_ReactorCloudFoundryClient.java +++ b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/_ReactorCloudFoundryClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2021 the original author or authors. + * Copyright 2013-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -58,6 +58,7 @@ import org.cloudfoundry.client.v3.deployments.DeploymentsV3; import org.cloudfoundry.client.v3.domains.DomainsV3; import org.cloudfoundry.client.v3.droplets.Droplets; +import org.cloudfoundry.client.v3.featureflags.FeatureFlagsV3; import org.cloudfoundry.client.v3.isolationsegments.IsolationSegments; import org.cloudfoundry.client.v3.jobs.JobsV3; import org.cloudfoundry.client.v3.quotas.organizations.OrganizationQuotasV3; @@ -120,6 +121,7 @@ import org.cloudfoundry.reactor.client.v3.deployments.ReactorDeploymentsV3; import org.cloudfoundry.reactor.client.v3.domains.ReactorDomainsV3; import org.cloudfoundry.reactor.client.v3.droplets.ReactorDroplets; +import org.cloudfoundry.reactor.client.v3.featureflags.ReactorFeatureFlagsV3; import org.cloudfoundry.reactor.client.v3.isolationsegments.ReactorIsolationSegments; import org.cloudfoundry.reactor.client.v3.jobs.ReactorJobsV3; import org.cloudfoundry.reactor.client.v3.quotas.organizations.ReactorOrganizationQuotasV3; @@ -256,6 +258,12 @@ public FeatureFlags featureFlags() { return new ReactorFeatureFlags(getConnectionContext(), getRootV2(), getTokenProvider(), getRequestTags()); } + @Override + @Value.Derived + public FeatureFlagsV3 featureFlagsV3() { + return new ReactorFeatureFlagsV3(getConnectionContext(), getRootV3(), getTokenProvider(), getRequestTags()); + } + @Override @Value.Derived public Info info() { diff --git a/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/v3/featureflags/ReactorFeatureFlagsV3.java b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/v3/featureflags/ReactorFeatureFlagsV3.java new file mode 100644 index 0000000000..30ac19fe90 --- /dev/null +++ b/cloudfoundry-client-reactor/src/main/java/org/cloudfoundry/reactor/client/v3/featureflags/ReactorFeatureFlagsV3.java @@ -0,0 +1,80 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.reactor.client.v3.featureflags; + +import java.util.Map; +import org.cloudfoundry.client.v3.featureflags.FeatureFlagsV3; +import org.cloudfoundry.client.v3.featureflags.GetFeatureFlagRequest; +import org.cloudfoundry.client.v3.featureflags.GetFeatureFlagResponse; +import org.cloudfoundry.client.v3.featureflags.ListFeatureFlagsRequest; +import org.cloudfoundry.client.v3.featureflags.ListFeatureFlagsResponse; +import org.cloudfoundry.client.v3.featureflags.UpdateFeatureFlagRequest; +import org.cloudfoundry.client.v3.featureflags.UpdateFeatureFlagResponse; +import org.cloudfoundry.reactor.ConnectionContext; +import org.cloudfoundry.reactor.TokenProvider; +import org.cloudfoundry.reactor.client.v3.AbstractClientV3Operations; +import reactor.core.publisher.Mono; + +/** + * The Reactor-based implementation of {@link FeatureFlagsV3} + */ +public final class ReactorFeatureFlagsV3 extends AbstractClientV3Operations + implements FeatureFlagsV3 { + + /** + * Creates an instance + * + * @param connectionContext the {@link ConnectionContext} to use when communicating with the server + * @param root the root URI of the server. Typically something like {@code https://api.run.pivotal.io}. + * @param tokenProvider the {@link TokenProvider} to use when communicating with the server + * @param requestTags map with custom http headers which will be added to web request + */ + public ReactorFeatureFlagsV3( + ConnectionContext connectionContext, + Mono root, + TokenProvider tokenProvider, + Map requestTags) { + super(connectionContext, root, tokenProvider, requestTags); + } + + @Override + public Mono get(GetFeatureFlagRequest request) { + return get( + request, + GetFeatureFlagResponse.class, + builder -> builder.pathSegment("feature_flags", request.getName())) + .checkpoint(); + } + + @Override + public Mono list(ListFeatureFlagsRequest request) { + return get( + request, + ListFeatureFlagsResponse.class, + builder -> builder.pathSegment("feature_flags")) + .checkpoint(); + } + + @Override + public Mono update(UpdateFeatureFlagRequest request) { + return patch( + request, + UpdateFeatureFlagResponse.class, + builder -> builder.pathSegment("feature_flags", request.getName())) + .checkpoint(); + } +} diff --git a/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/client/v3/featureflags/ReactorFeatureFlagsV3Test.java b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/client/v3/featureflags/ReactorFeatureFlagsV3Test.java new file mode 100644 index 0000000000..6fc7a58d9e --- /dev/null +++ b/cloudfoundry-client-reactor/src/test/java/org/cloudfoundry/reactor/client/v3/featureflags/ReactorFeatureFlagsV3Test.java @@ -0,0 +1,228 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.reactor.client.v3.featureflags; + +import static io.netty.handler.codec.http.HttpMethod.GET; +import static io.netty.handler.codec.http.HttpMethod.PATCH; +import static io.netty.handler.codec.http.HttpResponseStatus.OK; + +import java.time.Duration; +import java.util.Collections; +import org.cloudfoundry.client.v3.Link; +import org.cloudfoundry.client.v3.Pagination; +import org.cloudfoundry.client.v3.featureflags.GetFeatureFlagRequest; +import org.cloudfoundry.client.v3.featureflags.GetFeatureFlagResponse; +import org.cloudfoundry.client.v3.featureflags.ListFeatureFlagsRequest; +import org.cloudfoundry.client.v3.featureflags.ListFeatureFlagsResponse; +import org.cloudfoundry.client.v3.featureflags.UpdateFeatureFlagRequest; +import org.cloudfoundry.client.v3.featureflags.UpdateFeatureFlagResponse; +import org.cloudfoundry.reactor.InteractionContext; +import org.cloudfoundry.reactor.TestRequest; +import org.cloudfoundry.reactor.TestResponse; +import org.cloudfoundry.reactor.client.AbstractClientApiTest; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +final class ReactorFeatureFlagsV3Test extends AbstractClientApiTest { + + private static final String BASE_URL = "https://api.example.org/v3/feature_flags/"; + private static final String UPDATED_AT = "2020-03-10T15:49:32Z"; + + private final ReactorFeatureFlagsV3 featureFlags = + new ReactorFeatureFlagsV3( + CONNECTION_CONTEXT, this.root, TOKEN_PROVIDER, Collections.emptyMap()); + + @Test + void get() { + mockRequest( + InteractionContext.builder() + .request( + TestRequest.builder() + .method(GET) + .path("/feature_flags/app_scaling") + .build()) + .response( + TestResponse.builder() + .status(OK) + .payload( + "fixtures/client/v3/featureflags/GET_{feature_flag_name}_response.json") + .build()) + .build()); + + this.featureFlags + .get(GetFeatureFlagRequest.builder().name("app_scaling").build()) + .as(StepVerifier::create) + .expectNext( + GetFeatureFlagResponse.builder() + .name("app_scaling") + .enabled(true) + .updatedAt(UPDATED_AT) + .link("self", selfLink("app_scaling")) + .build()) + .expectComplete() + .verify(Duration.ofSeconds(5)); + } + + @Test + void list() { + mockRequest( + InteractionContext.builder() + .request(TestRequest.builder().method(GET).path("/feature_flags").build()) + .response( + TestResponse.builder() + .status(OK) + .payload( + "fixtures/client/v3/featureflags/GET_response.json") + .build()) + .build()); + + this.featureFlags + .list(ListFeatureFlagsRequest.builder().build()) + .as(StepVerifier::create) + .expectNext( + ListFeatureFlagsResponse.builder() + .pagination( + Pagination.builder() + .totalResults(2) + .first( + Link.builder() + .href( + "https://api.example.org/v3/feature_flags?page=1&per_page=50") + .build()) + .last( + Link.builder() + .href( + "https://api.example.org/v3/feature_flags?page=1&per_page=50") + .build()) + .build()) + .resource( + GetFeatureFlagResponse.builder() + .name("app_scaling") + .enabled(true) + .updatedAt(UPDATED_AT) + .link("self", selfLink("app_scaling")) + .build()) + .resource( + GetFeatureFlagResponse.builder() + .name("user_org_creation") + .enabled(false) + .customErrorMessage("This feature is disabled") + .updatedAt(UPDATED_AT) + .link("self", selfLink("user_org_creation")) + .build()) + .build()) + .expectComplete() + .verify(Duration.ofSeconds(5)); + } + + @Test + void listFiltered() { + mockRequest( + InteractionContext.builder() + .request( + TestRequest.builder() + .method(GET) + .path("/feature_flags?order_by=name&page=1&per_page=1") + .build()) + .response( + TestResponse.builder() + .status(OK) + .payload( + "fixtures/client/v3/featureflags/GET_response_filtered.json") + .build()) + .build()); + + this.featureFlags + .list(ListFeatureFlagsRequest.builder().orderBy("name").page(1).perPage(1).build()) + .as(StepVerifier::create) + .expectNext( + ListFeatureFlagsResponse.builder() + .pagination( + Pagination.builder() + .totalResults(2) + .totalPages(2) + .first( + Link.builder() + .href( + "https://api.example.org/v3/feature_flags?order_by=name&page=1&per_page=1") + .build()) + .last( + Link.builder() + .href( + "https://api.example.org/v3/feature_flags?order_by=name&page=2&per_page=1") + .build()) + .next( + Link.builder() + .href( + "https://api.example.org/v3/feature_flags?order_by=name&page=2&per_page=1") + .build()) + .build()) + .resource( + GetFeatureFlagResponse.builder() + .name("app_scaling") + .enabled(true) + .updatedAt(UPDATED_AT) + .link("self", selfLink("app_scaling")) + .build()) + .build()) + .expectComplete() + .verify(Duration.ofSeconds(5)); + } + + @Test + void update() { + mockRequest( + InteractionContext.builder() + .request( + TestRequest.builder() + .method(PATCH) + .path("/feature_flags/app_scaling") + .payload( + "fixtures/client/v3/featureflags/PATCH_{feature_flag_name}_request.json") + .build()) + .response( + TestResponse.builder() + .status(OK) + .payload( + "fixtures/client/v3/featureflags/PATCH_{feature_flag_name}_response.json") + .build()) + .build()); + + this.featureFlags + .update( + UpdateFeatureFlagRequest.builder() + .name("app_scaling") + .enabled(false) + .customErrorMessage("This feature is disabled") + .build()) + .as(StepVerifier::create) + .expectNext( + UpdateFeatureFlagResponse.builder() + .name("app_scaling") + .enabled(false) + .customErrorMessage("This feature is disabled") + .updatedAt(UPDATED_AT) + .link("self", selfLink("app_scaling")) + .build()) + .expectComplete() + .verify(Duration.ofSeconds(5)); + } + + private static Link selfLink(String flagName) { + return Link.builder().href(BASE_URL + flagName).build(); + } +} diff --git a/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/GET_response.json b/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/GET_response.json new file mode 100644 index 0000000000..1f3b39c0d9 --- /dev/null +++ b/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/GET_response.json @@ -0,0 +1,37 @@ +{ + "pagination": { + "total_results": 2, + "first": { + "href": "https://api.example.org/v3/feature_flags?page=1&per_page=50" + }, + "last": { + "href": "https://api.example.org/v3/feature_flags?page=1&per_page=50" + }, + "next": null, + "previous": null + }, + "resources": [ + { + "name": "app_scaling", + "enabled": true, + "custom_error_message": null, + "updated_at": "2020-03-10T15:49:32Z", + "links": { + "self": { + "href": "https://api.example.org/v3/feature_flags/app_scaling" + } + } + }, + { + "name": "user_org_creation", + "enabled": false, + "custom_error_message": "This feature is disabled", + "updated_at": "2020-03-10T15:49:32Z", + "links": { + "self": { + "href": "https://api.example.org/v3/feature_flags/user_org_creation" + } + } + } + ] +} diff --git a/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/GET_response_filtered.json b/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/GET_response_filtered.json new file mode 100644 index 0000000000..3211e8e2df --- /dev/null +++ b/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/GET_response_filtered.json @@ -0,0 +1,29 @@ +{ + "pagination": { + "total_results": 2, + "total_pages": 2, + "first": { + "href": "https://api.example.org/v3/feature_flags?order_by=name&page=1&per_page=1" + }, + "last": { + "href": "https://api.example.org/v3/feature_flags?order_by=name&page=2&per_page=1" + }, + "next": { + "href": "https://api.example.org/v3/feature_flags?order_by=name&page=2&per_page=1" + }, + "previous": null + }, + "resources": [ + { + "name": "app_scaling", + "enabled": true, + "custom_error_message": null, + "updated_at": "2020-03-10T15:49:32Z", + "links": { + "self": { + "href": "https://api.example.org/v3/feature_flags/app_scaling" + } + } + } + ] +} diff --git a/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/GET_{feature_flag_name}_response.json b/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/GET_{feature_flag_name}_response.json new file mode 100644 index 0000000000..e4565f495a --- /dev/null +++ b/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/GET_{feature_flag_name}_response.json @@ -0,0 +1,11 @@ +{ + "name": "app_scaling", + "enabled": true, + "custom_error_message": null, + "updated_at": "2020-03-10T15:49:32Z", + "links": { + "self": { + "href": "https://api.example.org/v3/feature_flags/app_scaling" + } + } +} diff --git a/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/PATCH_{feature_flag_name}_request.json b/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/PATCH_{feature_flag_name}_request.json new file mode 100644 index 0000000000..6ca4291987 --- /dev/null +++ b/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/PATCH_{feature_flag_name}_request.json @@ -0,0 +1,4 @@ +{ + "enabled": false, + "custom_error_message": "This feature is disabled" +} diff --git a/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/PATCH_{feature_flag_name}_response.json b/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/PATCH_{feature_flag_name}_response.json new file mode 100644 index 0000000000..4985c96c6f --- /dev/null +++ b/cloudfoundry-client-reactor/src/test/resources/fixtures/client/v3/featureflags/PATCH_{feature_flag_name}_response.json @@ -0,0 +1,11 @@ +{ + "name": "app_scaling", + "enabled": false, + "custom_error_message": "This feature is disabled", + "updated_at": "2020-03-10T15:49:32Z", + "links": { + "self": { + "href": "https://api.example.org/v3/feature_flags/app_scaling" + } + } +} diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/CloudFoundryClient.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/CloudFoundryClient.java index ad6d1f0df5..ab8353fead 100644 --- a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/CloudFoundryClient.java +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/CloudFoundryClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2021 the original author or authors. + * Copyright 2013-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -55,6 +55,7 @@ import org.cloudfoundry.client.v3.deployments.DeploymentsV3; import org.cloudfoundry.client.v3.domains.DomainsV3; import org.cloudfoundry.client.v3.droplets.Droplets; +import org.cloudfoundry.client.v3.featureflags.FeatureFlagsV3; import org.cloudfoundry.client.v3.isolationsegments.IsolationSegments; import org.cloudfoundry.client.v3.jobs.JobsV3; import org.cloudfoundry.client.v3.organizations.OrganizationsV3; @@ -169,6 +170,11 @@ public interface CloudFoundryClient { */ FeatureFlags featureFlags(); + /** + * Main entry point to the Cloud Foundry Feature Flags V3 Client API + */ + FeatureFlagsV3 featureFlagsV3(); + /** * Main entry point to the Cloud Foundry Info Client API */ diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/FeatureFlag.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/FeatureFlag.java new file mode 100644 index 0000000000..836656db47 --- /dev/null +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/FeatureFlag.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3.featureflags; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; +import org.cloudfoundry.AllowNulls; +import org.cloudfoundry.Nullable; +import org.cloudfoundry.client.v3.Link; + +/** + * Base class for responses that represent a feature flag + */ +public abstract class FeatureFlag { + + /** + * The name of the feature flag + */ + @JsonProperty("name") + public abstract String getName(); + + /** + * Whether the feature flag is enabled + */ + @JsonProperty("enabled") + public abstract Boolean getEnabled(); + + /** + * The custom error message displayed to users when the flag is disabled + */ + @JsonProperty("custom_error_message") + @Nullable + public abstract String getCustomErrorMessage(); + + /** + * The time the feature flag was last updated + */ + @JsonProperty("updated_at") + @Nullable + public abstract String getUpdatedAt(); + + /** + * Links to related resources and actions for the feature flag + */ + @AllowNulls + @JsonProperty("links") + @Nullable + public abstract Map getLinks(); +} diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/FeatureFlagsV3.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/FeatureFlagsV3.java new file mode 100644 index 0000000000..f6a3a7f7c1 --- /dev/null +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/FeatureFlagsV3.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3.featureflags; + +import reactor.core.publisher.Mono; + +/** + * Main entry point to the Cloud Foundry Feature Flags V3 Client API + * see: https://v3-apidocs.cloudfoundry.org/#feature-flags + */ +public interface FeatureFlagsV3 { + + /** + * Makes the Get a Feature Flag request + * + * @param request the Get Feature Flag request + * @return the response from the Get Feature Flag request + */ + Mono get(GetFeatureFlagRequest request); + + /** + * Makes the List Feature Flags request + * + * @param request the List Feature Flags request + * @return the response from the List Feature Flags request + */ + Mono list(ListFeatureFlagsRequest request); + + /** + * Makes the Update a Feature Flag request + * + * @param request the Update Feature Flag request + * @return the response from the Update Feature Flag request + */ + Mono update(UpdateFeatureFlagRequest request); +} diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/UpdateFeatureFlagRequestSerializer.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/UpdateFeatureFlagRequestSerializer.java new file mode 100644 index 0000000000..c4df851b1e --- /dev/null +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/UpdateFeatureFlagRequestSerializer.java @@ -0,0 +1,45 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3.featureflags; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import java.io.IOException; + +final class UpdateFeatureFlagRequestSerializer extends StdSerializer { + + UpdateFeatureFlagRequestSerializer() { + super(UpdateFeatureFlagRequest.class); + } + + @Override + public void serialize( + UpdateFeatureFlagRequest value, JsonGenerator gen, SerializerProvider provider) + throws IOException { + gen.writeStartObject(); + if (value.getEnabled() != null) { + gen.writeBooleanField("enabled", value.getEnabled()); + } + if (Boolean.TRUE.equals(value.clearCustomErrorMessage())) { + gen.writeNullField("custom_error_message"); + } else if (value.getCustomErrorMessage() != null) { + gen.writeStringField("custom_error_message", value.getCustomErrorMessage()); + } + gen.writeEndObject(); + } +} diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_GetFeatureFlagRequest.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_GetFeatureFlagRequest.java new file mode 100644 index 0000000000..a43659b88d --- /dev/null +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_GetFeatureFlagRequest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3.featureflags; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.immutables.value.Value; + +/** + * The request payload for the Get Feature Flag operation + * + * see https://v3-apidocs.cloudfoundry.org/#get-a-feature-flag + */ +@Value.Immutable +abstract class _GetFeatureFlagRequest { + + /** + * The name of the feature flag + */ + @JsonIgnore + abstract String getName(); + + @Value.Check + void check() { + if (getName().isEmpty()) { + throw new IllegalStateException("name must not be empty"); + } + } +} diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_GetFeatureFlagResponse.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_GetFeatureFlagResponse.java new file mode 100644 index 0000000000..e2b18f46ae --- /dev/null +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_GetFeatureFlagResponse.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3.featureflags; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.immutables.value.Value; + +/** + * The response payload for the Get Feature Flag operation + */ +@JsonDeserialize +@Value.Immutable +abstract class _GetFeatureFlagResponse extends FeatureFlag {} diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_ListFeatureFlagsRequest.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_ListFeatureFlagsRequest.java new file mode 100644 index 0000000000..0c148fcab4 --- /dev/null +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_ListFeatureFlagsRequest.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3.featureflags; + +import org.cloudfoundry.client.v3.PaginatedRequest; +import org.immutables.value.Value; + +/** + * The request payload for the List Feature Flags operation + * + * see https://v3-apidocs.cloudfoundry.org/#list-feature-flags + */ +@Value.Immutable +abstract class _ListFeatureFlagsRequest extends PaginatedRequest {} diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_ListFeatureFlagsResponse.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_ListFeatureFlagsResponse.java new file mode 100644 index 0000000000..048dcf3ab8 --- /dev/null +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_ListFeatureFlagsResponse.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3.featureflags; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.cloudfoundry.client.v3.PaginatedResponse; +import org.immutables.value.Value; + +/** + * The response payload for the List Feature Flags operation + * + * see https://v3-apidocs.cloudfoundry.org/#list-feature-flags + */ +@JsonDeserialize +@Value.Immutable +abstract class _ListFeatureFlagsResponse extends PaginatedResponse {} diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_UpdateFeatureFlagRequest.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_UpdateFeatureFlagRequest.java new file mode 100644 index 0000000000..01b54e043c --- /dev/null +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_UpdateFeatureFlagRequest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3.featureflags; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.cloudfoundry.Nullable; +import org.immutables.value.Value; + +/** + * The request payload for the Update Feature Flag operation + * + * see https://v3-apidocs.cloudfoundry.org/#update-a-feature-flag + * + * Please note that a request that defines only a "name", but no "enabled" and no "customErrorMessage" is still valid. + * It will update the "updated_at" property of the named feature flag. + */ +@JsonSerialize(using = UpdateFeatureFlagRequestSerializer.class) +@Value.Immutable +abstract class _UpdateFeatureFlagRequest { + + /** + * The name of the feature flag + */ + @JsonIgnore + abstract String getName(); + + /** + * Whether the feature flag is enabled + */ + @JsonProperty("enabled") + @Nullable + abstract Boolean getEnabled(); + + /** + * The custom error message to set. Ignored when {@link #clearCustomErrorMessage()} is true. + * When neither this nor {@link #clearCustomErrorMessage()} is set, the field is omitted from + * the request and the existing value is preserved. + */ + @JsonIgnore + @Nullable + abstract String getCustomErrorMessage(); + + /** + * When true, sends {@code "custom_error_message": null} to clear the existing message. + * Takes precedence over {@link #getCustomErrorMessage()}. + */ + @JsonIgnore + @Nullable + abstract Boolean clearCustomErrorMessage(); + + @Value.Check + void check() { + if (getName().isEmpty()) { + throw new IllegalStateException("name must not be empty"); + } + } +} diff --git a/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_UpdateFeatureFlagResponse.java b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_UpdateFeatureFlagResponse.java new file mode 100644 index 0000000000..5bd968b756 --- /dev/null +++ b/cloudfoundry-client/src/main/java/org/cloudfoundry/client/v3/featureflags/_UpdateFeatureFlagResponse.java @@ -0,0 +1,29 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3.featureflags; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.immutables.value.Value; + +/** + * The response payload for the Update Feature Flag operation + * + * see https://v3-apidocs.cloudfoundry.org/#update-a-feature-flag + */ +@JsonDeserialize +@Value.Immutable +abstract class _UpdateFeatureFlagResponse extends FeatureFlag {} diff --git a/cloudfoundry-client/src/test/java/org/cloudfoundry/client/v3/featureflags/GetFeatureFlagRequestTest.java b/cloudfoundry-client/src/test/java/org/cloudfoundry/client/v3/featureflags/GetFeatureFlagRequestTest.java new file mode 100644 index 0000000000..4a8f7df210 --- /dev/null +++ b/cloudfoundry-client/src/test/java/org/cloudfoundry/client/v3/featureflags/GetFeatureFlagRequestTest.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3.featureflags; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +final class GetFeatureFlagRequestTest { + + @Test + void noName() { + assertThrows(IllegalStateException.class, () -> GetFeatureFlagRequest.builder().build()); + } + + @Test + void valid() { + GetFeatureFlagRequest.builder().name("app_scaling").build(); + } +} diff --git a/cloudfoundry-client/src/test/java/org/cloudfoundry/client/v3/featureflags/ListFeatureFlagsRequestTest.java b/cloudfoundry-client/src/test/java/org/cloudfoundry/client/v3/featureflags/ListFeatureFlagsRequestTest.java new file mode 100644 index 0000000000..7de69422ab --- /dev/null +++ b/cloudfoundry-client/src/test/java/org/cloudfoundry/client/v3/featureflags/ListFeatureFlagsRequestTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3.featureflags; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +final class ListFeatureFlagsRequestTest { + + @Test + void invalidPage() { + assertThrows( + IllegalStateException.class, + () -> ListFeatureFlagsRequest.builder().page(0).build()); + } + + @Test + void invalidPerPage() { + assertThrows( + IllegalStateException.class, + () -> ListFeatureFlagsRequest.builder().perPage(0).build()); + } + + @Test + void valid() { + ListFeatureFlagsRequest.builder().build(); + } + + @Test + void validWithPagination() { + ListFeatureFlagsRequest.builder().page(1).perPage(50).orderBy("name").build(); + } +} diff --git a/cloudfoundry-client/src/test/java/org/cloudfoundry/client/v3/featureflags/UpdateFeatureFlagRequestTest.java b/cloudfoundry-client/src/test/java/org/cloudfoundry/client/v3/featureflags/UpdateFeatureFlagRequestTest.java new file mode 100644 index 0000000000..d87b0946e0 --- /dev/null +++ b/cloudfoundry-client/src/test/java/org/cloudfoundry/client/v3/featureflags/UpdateFeatureFlagRequestTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3.featureflags; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +final class UpdateFeatureFlagRequestTest { + + @Test + void noName() { + assertThrows( + IllegalStateException.class, + () -> UpdateFeatureFlagRequest.builder().enabled(true).build()); + } + + @Test + void valid() { + UpdateFeatureFlagRequest.builder().name("app_scaling").enabled(true).build(); + } + + @Test + void validWithCustomErrorMessage() { + UpdateFeatureFlagRequest.builder() + .name("app_scaling") + .enabled(false) + .customErrorMessage("This feature is disabled") + .build(); + } + + @Test + void validNoFields() { + UpdateFeatureFlagRequest.builder().name("app_scaling").build(); + } +} diff --git a/integration-test/src/test/java/org/cloudfoundry/client/v3/FeatureFlagsV3Test.java b/integration-test/src/test/java/org/cloudfoundry/client/v3/FeatureFlagsV3Test.java new file mode 100644 index 0000000000..40bcfa7fac --- /dev/null +++ b/integration-test/src/test/java/org/cloudfoundry/client/v3/FeatureFlagsV3Test.java @@ -0,0 +1,305 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.client.v3; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.Arrays; +import java.util.stream.Collectors; +import org.cloudfoundry.AbstractIntegrationTest; +import org.cloudfoundry.client.CloudFoundryClient; +import org.cloudfoundry.client.v3.featureflags.GetFeatureFlagRequest; +import org.cloudfoundry.client.v3.featureflags.GetFeatureFlagResponse; +import org.cloudfoundry.client.v3.featureflags.ListFeatureFlagsRequest; +import org.cloudfoundry.client.v3.featureflags.UpdateFeatureFlagRequest; +import org.cloudfoundry.util.PaginationUtils; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reactor.test.StepVerifier; + +public final class FeatureFlagsV3Test extends AbstractIntegrationTest { + + private static final String FLAG_NAME = "app_scaling"; + + @Autowired private CloudFoundryClient cloudFoundryClient; + + @Test + public void get() { + this.cloudFoundryClient + .featureFlagsV3() + .get(GetFeatureFlagRequest.builder().name(FLAG_NAME).build()) + .map(GetFeatureFlagResponse::getName) + .as(StepVerifier::create) + .expectNext(FLAG_NAME) + .expectComplete() + .verify(Duration.ofMinutes(5)); + } + + @Test + public void list() { + PaginationUtils.requestClientV3Resources( + page -> + this.cloudFoundryClient + .featureFlagsV3() + .list(ListFeatureFlagsRequest.builder().page(page).build())) + .map(GetFeatureFlagResponse::getName) + .collect(Collectors.toSet()) + .as(StepVerifier::create) + .consumeNextWith( + names -> + assertThat(names) + .containsAll( + Arrays.asList( + "app_bits_upload", + "app_scaling", + "diego_docker", + "private_domain_creation", + "route_creation", + "service_instance_creation", + "set_roles_by_username", + "unset_roles_by_username", + "user_org_creation"))) + .expectComplete() + .verify(Duration.ofMinutes(5)); + } + + @Test + public void updateCustomErrorMessage() { + String newMessage = "Integration test custom error message"; + + this.cloudFoundryClient + .featureFlagsV3() + .get(GetFeatureFlagRequest.builder().name(FLAG_NAME).build()) + .flatMap( + original -> + this.cloudFoundryClient + .featureFlagsV3() + .update( + UpdateFeatureFlagRequest.builder() + .name(FLAG_NAME) + .customErrorMessage(newMessage) + .build()) + .flatMap( + updated -> + this.cloudFoundryClient + .featureFlagsV3() + .update( + UpdateFeatureFlagRequest + .builder() + .name(FLAG_NAME) + .customErrorMessage( + original + .getCustomErrorMessage()) + .build()) + .thenReturn( + updated + .getCustomErrorMessage()))) + .as(StepVerifier::create) + .expectNext(newMessage) + .expectComplete() + .verify(Duration.ofMinutes(5)); + } + + @Test + public void updateCustomErrorMessageToNull() { + String tempMessage = "Integration test custom error message"; + + this.cloudFoundryClient + .featureFlagsV3() + .get(GetFeatureFlagRequest.builder().name(FLAG_NAME).build()) + .flatMap( + original -> + this.cloudFoundryClient + .featureFlagsV3() + .update( + UpdateFeatureFlagRequest.builder() + .name(FLAG_NAME) + .customErrorMessage(tempMessage) + .build()) + .flatMap( + ignored -> + this.cloudFoundryClient + .featureFlagsV3() + .update( + UpdateFeatureFlagRequest + .builder() + .name(FLAG_NAME) + .clearCustomErrorMessage( + true) + .build())) + .flatMap( + cleared -> { + UpdateFeatureFlagRequest.Builder restore = + UpdateFeatureFlagRequest.builder() + .name(FLAG_NAME); + if (original.getCustomErrorMessage() != null) { + restore.customErrorMessage( + original.getCustomErrorMessage()); + } else { + restore.clearCustomErrorMessage(true); + } + return this.cloudFoundryClient + .featureFlagsV3() + .update(restore.build()) + .thenReturn(cleared); + })) + .as(StepVerifier::create) + .consumeNextWith(cleared -> assertThat(cleared.getCustomErrorMessage()).isNull()) + .expectComplete() + .verify(Duration.ofMinutes(5)); + } + + @Test + public void update() { + this.cloudFoundryClient + .featureFlagsV3() + .get(GetFeatureFlagRequest.builder().name(FLAG_NAME).build()) + .flatMap( + original -> + this.cloudFoundryClient + .featureFlagsV3() + .update( + UpdateFeatureFlagRequest.builder() + .name(FLAG_NAME) + .enabled(!original.getEnabled()) + .build()) + .map( + toggled -> + toggled.getEnabled() + != original.getEnabled()) + .doFinally( + signal -> + this.cloudFoundryClient + .featureFlagsV3() + .update( + UpdateFeatureFlagRequest + .builder() + .name(FLAG_NAME) + .enabled( + original + .getEnabled()) + .build()) + .as(StepVerifier::create) + .consumeNextWith( + r -> + assertThat( + r + .getEnabled()) + .isEqualTo( + original + .getEnabled())) + .expectComplete() + .verify(Duration.ofMinutes(5)))) + .as(StepVerifier::create) + .expectNext(true) + .expectComplete() + .verify(Duration.ofMinutes(5)); + } + + @Test + public void updateMessage() { + String newMessage = "Integration test updateMessage"; + + this.cloudFoundryClient + .featureFlagsV3() + .get(GetFeatureFlagRequest.builder().name(FLAG_NAME).build()) + .flatMap( + original -> + this.cloudFoundryClient + .featureFlagsV3() + .update( + UpdateFeatureFlagRequest.builder() + .name(FLAG_NAME) + .customErrorMessage(newMessage) + .build()) + .map(updated -> updated.getCustomErrorMessage()) + .doFinally( + signal -> + this.cloudFoundryClient + .featureFlagsV3() + .update( + UpdateFeatureFlagRequest + .builder() + .name(FLAG_NAME) + .customErrorMessage( + original + .getCustomErrorMessage()) + .build()) + .as(StepVerifier::create) + .consumeNextWith( + r -> + assertThat( + r + .getCustomErrorMessage()) + .isEqualTo( + original + .getCustomErrorMessage())) + .expectComplete() + .verify(Duration.ofMinutes(5)))) + .as(StepVerifier::create) + .expectNext(newMessage) + .expectComplete() + .verify(Duration.ofMinutes(5)); + } + + @Test + public void updateNothing() { + this.cloudFoundryClient + .featureFlagsV3() + .get(GetFeatureFlagRequest.builder().name(FLAG_NAME).build()) + .flatMap( + original -> + this.cloudFoundryClient + .featureFlagsV3() + .update( + UpdateFeatureFlagRequest.builder() + .name(FLAG_NAME) + .build()) + .map( + unchanged -> + unchanged.getEnabled() + == original.getEnabled()) + .doFinally( + signal -> + this.cloudFoundryClient + .featureFlagsV3() + .update( + UpdateFeatureFlagRequest + .builder() + .name(FLAG_NAME) + .enabled( + original + .getEnabled()) + .build()) + .as(StepVerifier::create) + .consumeNextWith( + r -> + assertThat( + r + .getEnabled()) + .isEqualTo( + original + .getEnabled())) + .expectComplete() + .verify(Duration.ofMinutes(5)))) + .as(StepVerifier::create) + .expectNext(true) + .expectComplete() + .verify(Duration.ofMinutes(5)); + } +}