diff --git a/examples/examples-release-latest/README.md b/examples/examples-release-latest/README.md index 80c34ea272..12312d8be3 100644 --- a/examples/examples-release-latest/README.md +++ b/examples/examples-release-latest/README.md @@ -11,3 +11,23 @@ mvn compile mvn exec:java -Dexec.mainClass="io.kubernetes.client.examples.Example" ``` +## Patching custom objects + +Kubernetes PATCH requests require a patch-specific content type. Use `PatchUtils` rather than +calling a generated custom-object patch request's `execute()` method directly. + +The +[`PatchCustomObjectExample`](src/main/java/io/kubernetes/client/examples/PatchCustomObjectExample.java) +shows how to patch a namespaced custom object with JSON Patch: + +```sh +cat > /tmp/widget-patch.json <<'EOF' +[ + {"op": "replace", "path": "/spec/size", "value": 3} +] +EOF + +mvn exec:java \ + -Dexec.mainClass="io.kubernetes.client.examples.PatchCustomObjectExample" \ + -Dexec.args="example.com v1 default widgets my-widget /tmp/widget-patch.json" +``` diff --git a/examples/examples-release-latest/src/main/java/io/kubernetes/client/examples/PatchCustomObjectExample.java b/examples/examples-release-latest/src/main/java/io/kubernetes/client/examples/PatchCustomObjectExample.java new file mode 100644 index 0000000000..2487bc063a --- /dev/null +++ b/examples/examples-release-latest/src/main/java/io/kubernetes/client/examples/PatchCustomObjectExample.java @@ -0,0 +1,71 @@ +/* +Copyright 2026 The Kubernetes 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 io.kubernetes.client.examples; + +import io.kubernetes.client.custom.V1Patch; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CustomObjectsApi; +import io.kubernetes.client.util.Config; +import io.kubernetes.client.util.PatchUtils; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Demonstrates JSON-patching a namespaced custom object. + * + *

The generated patch API defaults to {@code application/json}. Kubernetes patch requests must + * instead use a patch-specific media type, which {@link PatchUtils} supplies. + * + *

Run with arguments {@code }. For + * example: + * + *

{@code
+ * mvn exec:java \
+ *   -Dexec.mainClass="io.kubernetes.client.examples.PatchCustomObjectExample" \
+ *   -Dexec.args="example.com v1 default widgets my-widget /tmp/widget-patch.json"
+ * }
+ */ +public final class PatchCustomObjectExample { + + private PatchCustomObjectExample() {} + + public static void main(String[] args) throws IOException, ApiException { + if (args.length != 6) { + throw new IllegalArgumentException( + "Expected arguments: "); + } + + ApiClient client = Config.defaultClient(); + CustomObjectsApi api = new CustomObjectsApi(client); + String patchJson = Files.readString(Path.of(args[5])); + + Object result = + PatchUtils.patch( + Object.class, + () -> + api.patchNamespacedCustomObject( + args[0], + args[1], + args[2], + args[3], + args[4], + new V1Patch(patchJson)) + .buildCall(null), + V1Patch.PATCH_FORMAT_JSON_PATCH, + client); + + System.out.println(result); + } +} diff --git a/util/src/test/java/io/kubernetes/client/util/PatchUtilsTest.java b/util/src/test/java/io/kubernetes/client/util/PatchUtilsTest.java index 36937fee1d..3bebcfdd22 100644 --- a/util/src/test/java/io/kubernetes/client/util/PatchUtilsTest.java +++ b/util/src/test/java/io/kubernetes/client/util/PatchUtilsTest.java @@ -14,16 +14,23 @@ import static com.github.tomakehurst.wiremock.client.WireMock.*; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.junit.jupiter.api.Assertions.assertEquals; import com.github.tomakehurst.wiremock.junit5.WireMockExtension; import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiClient; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.apis.CustomObjectsApi; import io.kubernetes.client.openapi.models.V1Pod; +import java.util.Map; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; class PatchUtilsTest { @@ -106,4 +113,76 @@ void strategicMergePatchPod() throws ApiException { apiServer.verify(1, patchRequestedFor(urlPathEqualTo("/api/v1/namespaces/default/pods/foo"))); } + + private static Stream customObjectPatchFormats() { + return Stream.of( + Arguments.of( + V1Patch.PATCH_FORMAT_JSON_PATCH, + "[{\"op\":\"replace\",\"path\":\"/spec/size\",\"value\":3}]"), + Arguments.of( + V1Patch.PATCH_FORMAT_JSON_MERGE_PATCH, "{\"spec\":{\"size\":3}}")); + } + + @ParameterizedTest + @MethodSource("customObjectPatchFormats") + void patchNamespacedCustomObject(String patchFormat, String patchBody) throws ApiException { + CustomObjectsApi customObjectsApi = new CustomObjectsApi(client); + String resourcePath = "/apis/example.com/v1/namespaces/default/widgets/foo"; + apiServer.stubFor( + patch(urlPathEqualTo(resourcePath)) + .withHeader("Content-Type", containing(patchFormat)) + .withRequestBody(equalToJson(patchBody)) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"metadata\":{\"name\":\"foo\"}}"))); + + Object result = + PatchUtils.patch( + Object.class, + () -> + customObjectsApi + .patchNamespacedCustomObject( + "example.com", + "v1", + "default", + "widgets", + "foo", + new V1Patch(patchBody)) + .buildCall(null), + patchFormat, + client); + + assertEquals("foo", ((Map) ((Map) result).get("metadata")).get("name")); + apiServer.verify(1, patchRequestedFor(urlPathEqualTo(resourcePath))); + } + + @Test + void jsonPatchClusterCustomObject() throws ApiException { + CustomObjectsApi customObjectsApi = new CustomObjectsApi(client); + String patchBody = "[{\"op\":\"replace\",\"path\":\"/spec/size\",\"value\":3}]"; + String resourcePath = "/apis/example.com/v1/widgets/foo"; + apiServer.stubFor( + patch(urlPathEqualTo(resourcePath)) + .withHeader("Content-Type", containing(V1Patch.PATCH_FORMAT_JSON_PATCH)) + .withRequestBody(equalToJson(patchBody)) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{}"))); + + PatchUtils.patch( + Object.class, + () -> + customObjectsApi + .patchClusterCustomObject( + "example.com", "v1", "widgets", "foo", new V1Patch(patchBody)) + .buildCall(null), + V1Patch.PATCH_FORMAT_JSON_PATCH, + client); + + apiServer.verify(1, patchRequestedFor(urlPathEqualTo(resourcePath))); + } }