diff --git a/.circleci/config.yml b/.circleci/config.yml index 7baff2e69..0d49242ae 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -23,6 +23,25 @@ commands: setup-build-environment: description: 'Setup Java and Maven dependencies with unified caching' steps: + - restore_cache: + keys: + - jdk28-v1-{{ checksum ".circleci/jdk28/Dockerfile" }} + - run: + name: 'Install JDK 28 early access' + command: | + jdk28_home="$HOME/jdk28" + if [ ! -d "$jdk28_home" ]; then + image=$(sed -n 's/^FROM //p' .circleci/jdk28/Dockerfile) + docker pull -q "$image" + image_java_home=$(docker run --rm --entrypoint sh "$image" -c 'echo "$JAVA_HOME"') + container=$(docker create "$image") + docker cp "$container:$image_java_home" "$jdk28_home" + docker rm "$container" + fi + - save_cache: + paths: + - ~/jdk28 + key: jdk28-v1-{{ checksum ".circleci/jdk28/Dockerfile" }} - restore_cache: keys: - dependencies-v1-{{ checksum "pom.xml" }} @@ -69,7 +88,7 @@ commands: ./mvnw -ntp -B \ org.apache.maven.plugins:maven-dependency-plugin:3.8.1:go-offline \ de.qaware.maven:go-offline-maven-plugin:1.2.8:resolve-dependencies \ - -T2 -pl -:feign-benchmark + -T2 -Pjdk28-modules -pl -:feign-benchmark - save_cache: paths: - ~/.sdkman @@ -78,6 +97,9 @@ commands: restore-build-environment: description: 'Restore cached build environment' steps: + - restore_cache: + keys: + - jdk28-v1-{{ checksum ".circleci/jdk28/Dockerfile" }} - restore_cache: keys: - dependencies-v1-{{ checksum "pom.xml" }} @@ -100,7 +122,7 @@ commands: no_output_timeout: 30m command: | source "$HOME/.sdkman/bin/sdkman-init.sh" - ./mvnw -ntp -nsu --serial -s .circleci/settings.xml -P release -pl -:feign-benchmark,-:feign-vertx4-test,-:feign-vertx5-test,-:feign-example-github,-:feign-example-github-with-coroutine,-:feign-example-wikipedia,-:feign-example-wikipedia-with-springboot -DskipTests=true deploy + ./mvnw -ntp -nsu --serial -s .circleci/settings.xml -P release,jdk28-modules -pl -:feign-benchmark,-:feign-vertx4-test,-:feign-vertx5-test,-:feign-example-github,-:feign-example-github-with-coroutine,-:feign-example-wikipedia,-:feign-example-wikipedia-with-springboot -DskipTests=true deploy # our job defaults defaults: &defaults @@ -148,7 +170,7 @@ jobs: name: 'Test' command: | source "$HOME/.sdkman/bin/sdkman-init.sh" - ./mvnw -ntp -B verify + ./mvnw -ntp -B -Pjdk28-modules verify - verify-formatting deploy: diff --git a/.circleci/jdk28/Dockerfile b/.circleci/jdk28/Dockerfile new file mode 100644 index 000000000..5f634a0bb --- /dev/null +++ b/.circleci/jdk28/Dockerfile @@ -0,0 +1 @@ +FROM openjdk:28-ea-jdk@sha256:74a5c5b7deb04aa7aba908ffc2a420e66e9de33088f10e63eb377f8386aa339c diff --git a/.circleci/toolchains.xml b/.circleci/toolchains.xml index 48764b609..47657e793 100644 --- a/.circleci/toolchains.xml +++ b/.circleci/toolchains.xml @@ -59,4 +59,13 @@ /home/circleci/.sdkman/candidates/java/25.0.2-tem - \ No newline at end of file + + jdk + + 28 + + + /home/circleci/jdk28 + + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d25a05feb..80ef2547d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -102,3 +102,8 @@ updates: ignore: - dependency-name: "io.vertx:*" update-types: ["version-update:semver-major"] + + - package-ecosystem: "docker" + directory: "/.circleci/jdk28" + schedule: + interval: "daily" diff --git a/.github/workflows/auto-merge-dependabot.yml b/.github/workflows/auto-merge-dependabot.yml index cc3a140b9..a1332925c 100644 --- a/.github/workflows/auto-merge-dependabot.yml +++ b/.github/workflows/auto-merge-dependabot.yml @@ -25,7 +25,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Enable auto-merge for Dependabot PRs - run: gh pr merge --auto --merge "$PR_URL" + run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} GH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/AGENTS.md b/AGENTS.md index 929d41c04..5859b3651 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ Feign is a declarative HTTP client library with a modular design: - `micrometer/`, `dropwizard-metrics4/5/` - Metrics integration - `validation/`, `validation-jakarta/` - JSR-303 / Jakarta Bean Validation via the `MethodInterceptor` extension point - `http-cache/` - Conditional revalidation (`ETag` / `Last-Modified` / `304 Not Modified`) via the `MethodInterceptor` extension point +- `jdk-json/` - Codec for the JDK Simple JSON API (`jdk.incubator.json`, JEP 540). It only builds if you use `-Pjdk28-modules` and a JDK 28 toolchain. ### Key Design Patterns - **Builder Pattern**: `Feign.builder()` for configuring clients @@ -106,6 +107,7 @@ Example: - `dropwizard-metrics5/` - Java 17 (for Metrics 5.x) - `apt-test-generator/` - Java 17 (for Handlebars 4.5.0+) - `soap-jakarta/`, `jaxb-jakarta/` - Java 11 (for Jakarta namespace) +- `jdk-json/` - This uses Java 28 (for `jdk.incubator.json`). It is in the `jdk28-modules` profile. This means builds without a JDK 28 toolchain still work. CI extracts the JDK 28 early-access build from the `openjdk:28-ea-jdk` image. This image is pinned to a specific digest in `.circleci/jdk28/Dockerfile`. Dependabot keeps this image up to date. ### Dependabot Configuration diff --git a/feign-bom/pom.xml b/feign-bom/pom.xml index dd67dedb4..4ab4d71f0 100644 --- a/feign-bom/pom.xml +++ b/feign-bom/pom.xml @@ -54,6 +54,11 @@ feign-http-cache 13.18-SNAPSHOT + + io.github.openfeign + feign-jdk-json + 13.18-SNAPSHOT + io.github.openfeign feign-jaxrs diff --git a/jdk-json/README.md b/jdk-json/README.md new file mode 100644 index 000000000..413fbd1bb --- /dev/null +++ b/jdk-json/README.md @@ -0,0 +1,69 @@ +JDK JSON Codec +=================== + +This module lets you encode and decode JSON using the JDK's built-in [Simple JSON API][JEP 540] (`jdk.incubator.json`). +You do not need to use any third party JSON libraries. + +This API is an incubator module for JDK 28, so this module requires Java 28 or higher. You also must resolve the module explicitly: + +``` +java --add-modules jdk.incubator.json ... +``` + +Incubator APIs can change incompatibly between JDK releases, so expect this module to follow along. + +## Dependency + +```xml + + io.github.openfeign + feign-jdk-json + ${feign.version} + +``` + +## Usage + +Add `JdkJsonEncoder` and/or `JdkJsonDecoder` to your `Feign.Builder`: + +```java +interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + JsonArray contributors(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("POST /repos/{owner}/{repo}/issues") + @Headers("Content-Type: application/json") + JsonObject createIssue(@Param("owner") String owner, @Param("repo") String repo, JsonValue issue); +} + +GitHub github = Feign.builder() + .decoder(new JdkJsonDecoder()) + .encoder(new JdkJsonEncoder()) + .target(GitHub.class, "https://api.github.com"); + +for (JsonValue contributor : github.contributors("OpenFeign", "feign").asList()) { + System.out.println(contributor.get("login").asString()); +} + +JsonObject issue = github.createIssue("OpenFeign", "feign", JsonObject.of(Map.of( + "title", JsonString.of("Found a bug"), + "labels", JsonArray.of(List.of(JsonString.of("bug")))))); +``` + +The JDK API has no data binding, so the codec works with the JSON tree: + +* `JdkJsonDecoder` decodes into `JsonValue` or any of its subtypes (`JsonObject`, `JsonArray`, `JsonString`, `JsonNumber`, `JsonBoolean`, `JsonNull`). It can also return the raw `String` body. + If the JSON is malformed (like having duplicate member names), it throws a `DecodeException` caused by a `JsonParseException`. If the JSON type is wrong (for example, you get an array but asked for a `JsonObject`), it throws a `DecodeException`. + `404` (if you use `dismiss404()`), `204`, and empty bodies decode to `null`. +* `JdkJsonEncoder` writes any `JsonValue` as compact JSON. + +## Building + +The module is part of the `jdk28-modules` Maven profile and needs a JDK 28 entry in `~/.m2/toolchains.xml`: + +``` +./mvnw -Pjdk28-modules -pl jdk-json -am install +``` + +[JEP 540]: https://openjdk.org/jeps/540 diff --git a/jdk-json/pom.xml b/jdk-json/pom.xml new file mode 100644 index 000000000..faedd3aac --- /dev/null +++ b/jdk-json/pom.xml @@ -0,0 +1,76 @@ + + + + 4.0.0 + + + io.github.openfeign + feign-parent + 13.18-SNAPSHOT + + + feign-jdk-json + Feign JDK JSON + Feign codec for the JDK Simple JSON API (jdk.incubator.json, JEP 540) + + + 28 + 28 + -Duser.language=en --add-modules jdk.incubator.json + true + + + + + ${project.groupId} + feign-core + + + + ${project.groupId} + feign-mock + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + --add-modules + jdk.incubator.json + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + --add-modules + jdk.incubator.json + + + + + + diff --git a/jdk-json/src/main/java/feign/jdkjson/JdkJsonDecoder.java b/jdk-json/src/main/java/feign/jdkjson/JdkJsonDecoder.java new file mode 100644 index 000000000..5278557c2 --- /dev/null +++ b/jdk-json/src/main/java/feign/jdkjson/JdkJsonDecoder.java @@ -0,0 +1,103 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.jdkjson; + +import static java.lang.String.format; + +import feign.Response; +import feign.Util; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.codec.PredicatedDecoder; +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import jdk.incubator.json.Json; +import jdk.incubator.json.JsonArray; +import jdk.incubator.json.JsonBoolean; +import jdk.incubator.json.JsonNull; +import jdk.incubator.json.JsonNumber; +import jdk.incubator.json.JsonObject; +import jdk.incubator.json.JsonParseException; +import jdk.incubator.json.JsonString; +import jdk.incubator.json.JsonValue; + +public class JdkJsonDecoder implements Decoder, PredicatedDecoder { + + @Override + public Object decode(Response response, Type type) throws IOException { + Class expectedClass = supportedTarget(response, type); + String bodyText = readBody(response); + if (bodyText == null || expectedClass == String.class) { + return bodyText; + } + return parseJsonValueAs(expectedClass, bodyText, response); + } + + private static Class supportedTarget(Response response, Type type) { + if (type instanceof Class expectedClass + && (expectedClass == String.class || JsonValue.class.isAssignableFrom(expectedClass))) { + return expectedClass; + } + throw new DecodeException( + response.status(), + format("%s is not a type supported by this decoder.", type), + response.request()); + } + + private static String readBody(Response response) throws IOException { + if (response.status() == 404 || response.status() == 204 || response.body() == null) { + return null; + } + try (Reader reader = response.body().asReader(response.charset())) { + String bodyText = Util.toString(reader); + return bodyText.isEmpty() ? null : bodyText; + } + } + + private static Object parseJsonValueAs( + Class expectedClass, String bodyText, Response response) { + JsonValue parsedJson; + try { + parsedJson = Json.parse(bodyText); + } catch (JsonParseException e) { + throw new DecodeException(response.status(), e.getMessage(), response.request(), e); + } + if (!expectedClass.isInstance(parsedJson)) { + throw new DecodeException( + response.status(), + format("Expected %s but got a JSON %s", expectedClass.getSimpleName(), kind(parsedJson)), + response.request()); + } + return parsedJson; + } + + private static String kind(JsonValue parsedJson) { + return switch (parsedJson) { + case JsonObject _ -> "object"; + case JsonArray _ -> "array"; + case JsonString _ -> "string"; + case JsonNumber _ -> "number"; + case JsonBoolean _ -> "boolean"; + case JsonNull _ -> "null"; + }; + } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isJsonContentType(response); + } +} diff --git a/jdk-json/src/main/java/feign/jdkjson/JdkJsonEncoder.java b/jdk-json/src/main/java/feign/jdkjson/JdkJsonEncoder.java new file mode 100644 index 000000000..4f7ee8728 --- /dev/null +++ b/jdk-json/src/main/java/feign/jdkjson/JdkJsonEncoder.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.jdkjson; + +import static java.lang.String.format; + +import feign.RequestTemplate; +import feign.Util; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; +import java.lang.reflect.Type; +import jdk.incubator.json.JsonValue; + +public class JdkJsonEncoder implements Encoder, PredicatedEncoder { + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) + throws EncodeException { + if (object == null) { + return; + } + if (!(object instanceof JsonValue jsonBody)) { + throw new EncodeException(format("%s is not a type supported by this encoder.", bodyType)); + } + template.body(jsonBody.toString()); + } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } +} diff --git a/jdk-json/src/test/java/feign/jdkjson/JdkJsonCodecTest.java b/jdk-json/src/test/java/feign/jdkjson/JdkJsonCodecTest.java new file mode 100644 index 000000000..47824d4e3 --- /dev/null +++ b/jdk-json/src/test/java/feign/jdkjson/JdkJsonCodecTest.java @@ -0,0 +1,218 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.jdkjson; + +import static feign.Util.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import feign.Feign; +import feign.Param; +import feign.Request; +import feign.RequestLine; +import feign.RequestTemplate; +import feign.codec.DecodeException; +import feign.codec.EncodeException; +import feign.mock.HttpMethod; +import feign.mock.MockClient; +import feign.mock.MockTarget; +import java.util.List; +import java.util.Map; +import jdk.incubator.json.JsonArray; +import jdk.incubator.json.JsonNumber; +import jdk.incubator.json.JsonObject; +import jdk.incubator.json.JsonParseException; +import jdk.incubator.json.JsonString; +import jdk.incubator.json.JsonValue; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class JdkJsonCodecTest { + + interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + JsonArray listContributors(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("GET /repos/{owner}/{repo}") + JsonObject getRepo(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("GET /repos/{owner}/{repo}") + JsonValue getRepoAsJsonValue(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("GET /repos/{owner}/{repo}") + String getRepoAsRawJson(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("GET /repos/{owner}/{repo}") + Map getRepoAsMap(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("POST /repos/{owner}/{repo}/contributors") + JsonObject createContributor( + @Param("owner") String owner, @Param("repo") String repo, JsonValue contributor); + + @RequestLine("POST /repos/{owner}/{repo}/contributors") + void createContributorWithObjectBody( + @Param("owner") String owner, @Param("repo") String repo, Object contributor); + } + + private MockClient mockClient; + private GitHub github; + + @BeforeEach + void setUp() { + mockClient = new MockClient(); + github = + Feign.builder() + .decoder(new JdkJsonDecoder()) + .encoder(new JdkJsonEncoder()) + .dismiss404() + .client(mockClient) + .target(new MockTarget<>(GitHub.class)); + } + + @Test + void decodesJsonArray() { + mockClient.ok( + HttpMethod.GET, + "/repos/openfeign/feign/contributors", + "[{\"login\":\"velo\",\"contributions\":42},{\"login\":\"kdavisk6\",\"contributions\":7}]"); + + JsonArray contributors = github.listContributors("openfeign", "feign"); + + assertThat(contributors.asList()).hasSize(2); + assertThat(contributors.get(0).get("login").asString()).isEqualTo("velo"); + assertThat(contributors.get(1).get("contributions").asInt()).isEqualTo(7); + } + + @Test + void decodesJsonObject() { + mockClient.ok(HttpMethod.GET, "/repos/openfeign/feign", "{\"name\":\"feign\",\"stars\":9600}"); + + JsonObject repo = github.getRepo("openfeign", "feign"); + + assertThat(repo.get("name").asString()).isEqualTo("feign"); + assertThat(repo.get("stars").asLong()).isEqualTo(9600L); + } + + @Test + void decodesJsonValueReturnTypeAsJsonString() { + mockClient.ok(HttpMethod.GET, "/repos/openfeign/feign", "\"feign\""); + + JsonValue repoName = github.getRepoAsJsonValue("openfeign", "feign"); + + assertThat(repoName).isInstanceOf(JsonString.class); + assertThat(repoName.asString()).isEqualTo("feign"); + } + + @Test + void stringReturnTypeReturnsBodyVerbatim() { + mockClient.ok(HttpMethod.GET, "/repos/openfeign/feign", "{ \"name\" : \"feign\" }"); + + assertThat(github.getRepoAsRawJson("openfeign", "feign")).isEqualTo("{ \"name\" : \"feign\" }"); + } + + @Test + void notFoundDecodesToNull() { + mockClient.add(HttpMethod.GET, "/repos/openfeign/feign", 404); + + assertThat(github.getRepo("openfeign", "feign")).isNull(); + } + + @Test + void noContentDecodesToNull() { + mockClient.add(HttpMethod.GET, "/repos/openfeign/feign", 204, "{}"); + + assertThat(github.getRepo("openfeign", "feign")).isNull(); + } + + @Test + void emptyBodyDecodesToNull() { + mockClient.ok(HttpMethod.GET, "/repos/openfeign/feign", ""); + + assertThat(github.getRepo("openfeign", "feign")).isNull(); + } + + @Test + void malformedJsonFailsWithParseCause() { + mockClient.ok(HttpMethod.GET, "/repos/openfeign/feign", "{\"name\":"); + + assertThatThrownBy(() -> github.getRepo("openfeign", "feign")) + .isInstanceOf(DecodeException.class) + .hasCauseInstanceOf(JsonParseException.class); + } + + @Test + void duplicateMemberNamesFailWithParseCause() { + mockClient.ok(HttpMethod.GET, "/repos/openfeign/feign", "{\"name\":\"a\",\"name\":\"b\"}"); + + assertThatThrownBy(() -> github.getRepo("openfeign", "feign")) + .isInstanceOf(DecodeException.class) + .hasCauseInstanceOf(JsonParseException.class); + } + + @Test + void arrayBodyFailsToDecodeAsJsonObject() { + mockClient.ok(HttpMethod.GET, "/repos/openfeign/feign", "[1,2]"); + + assertThatThrownBy(() -> github.getRepo("openfeign", "feign")) + .isInstanceOf(DecodeException.class) + .hasMessage("Expected JsonObject but got a JSON array"); + } + + @Test + void unsupportedReturnTypeFailsToDecode() { + mockClient.ok(HttpMethod.GET, "/repos/openfeign/feign", "{}"); + + assertThatThrownBy(() -> github.getRepoAsMap("openfeign", "feign")) + .isInstanceOf(DecodeException.class) + .hasMessageContaining("is not a type supported by this decoder"); + } + + @Test + void encodesJsonValue() { + mockClient.ok(HttpMethod.POST, "/repos/openfeign/feign/contributors", "{\"id\":1}"); + JsonObject contributor = + JsonObject.of( + Map.of( + "login", JsonString.of("velo"), + "repos", JsonArray.of(List.of(JsonNumber.of(1), JsonNumber.of(2))))); + + JsonObject createdContributor = github.createContributor("openfeign", "feign", contributor); + + Request request = mockClient.verifyOne(HttpMethod.POST, "/repos/openfeign/feign/contributors"); + assertThat(new String(request.body(), UTF_8)).isEqualTo(contributor.toString()); + assertThat(createdContributor.get("id").asInt()).isEqualTo(1); + } + + @Test + void nonJsonValueBodyFailsToEncode() { + assertThatThrownBy( + () -> + github.createContributorWithObjectBody( + "openfeign", "feign", Map.of("login", "velo"))) + .isInstanceOf(EncodeException.class) + .hasMessageContaining("is not a type supported by this encoder"); + } + + @Test + void nullBodyEncodesNothing() { + RequestTemplate template = new RequestTemplate(); + + new JdkJsonEncoder().encode(null, JsonValue.class, template); + + assertThat(template.body()).isNull(); + } +} diff --git a/pom.xml b/pom.xml index 6845cf298..3ca01fb46 100644 --- a/pom.xml +++ b/pom.xml @@ -287,6 +287,12 @@ ${project.version} + + ${project.groupId} + feign-jdk-json + ${project.version} + + ${project.groupId} feign-httpclient @@ -1002,6 +1008,13 @@ + + jdk28-modules + + jdk-json + + + quickbuild