├── .githooks └── pre-commit ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── create-tweet-on-x.yml │ ├── deploy-docs.yml │ ├── job-upgrade-gradle.yaml │ ├── job-upgrade-spring-boot.yaml │ ├── release-snapshot.yml │ └── release.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── build.gradle ├── examples ├── loadbalancer │ ├── build.gradle │ └── src │ │ ├── main │ │ ├── java │ │ │ └── com │ │ │ │ └── example │ │ │ │ ├── LoadBalancerApp.java │ │ │ │ └── UserApi.java │ │ └── resources │ │ │ └── application.yml │ │ └── test │ │ └── java │ │ └── com │ │ └── example │ │ └── LoadBalancerAppTests.java ├── minimal │ ├── build.gradle │ └── src │ │ ├── main │ │ ├── java │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── MinimalApp.java │ │ └── resources │ │ │ └── application.yml │ │ └── test │ │ └── java │ │ └── com │ │ └── example │ │ └── MinimalAppTest.java ├── native-image │ ├── build.gradle │ └── src │ │ ├── main │ │ ├── java │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── NativeImageApp.java │ │ └── resources │ │ │ └── application.yml │ │ └── test │ │ └── java │ │ └── com │ │ └── example │ │ └── NativeImageAppTest.java ├── processor │ ├── build.gradle │ ├── httpexchange-processor.properties │ └── src │ │ ├── main │ │ └── java │ │ │ └── com │ │ │ └── example │ │ │ ├── bar │ │ │ └── api │ │ │ │ ├── BarApi.java │ │ │ │ └── RequestMappingBarApi.java │ │ │ ├── baz │ │ │ └── api │ │ │ │ └── BazApi.java │ │ │ └── foo │ │ │ └── api │ │ │ └── FooApi.java │ │ └── test │ │ └── java │ │ └── com │ │ └── example │ │ └── api │ │ └── ProcessorTests.java ├── quick-start │ ├── build.gradle │ ├── httpexchange-processor.properties │ └── src │ │ ├── main │ │ ├── java │ │ │ └── com │ │ │ │ └── example │ │ │ │ ├── api │ │ │ │ └── UserApi.java │ │ │ │ └── server │ │ │ │ └── QuickStartApp.java │ │ └── resources │ │ │ └── application.yml │ │ └── test │ │ └── java │ │ └── com │ │ └── example │ │ └── server │ │ └── QuickStartAppTest.java └── reactive │ ├── build.gradle │ └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── example │ │ │ ├── api │ │ │ ├── UserApi.java │ │ │ ├── UserReactiveApi.java │ │ │ └── dto │ │ │ │ └── UserDTO.java │ │ │ └── server │ │ │ └── ReactiveApp.java │ └── resources │ │ └── application.yml │ └── test │ └── java │ └── com │ └── example │ └── server │ └── ReactiveTest.java ├── gradle.properties ├── gradle ├── deploy.gradle ├── generate-configuration-properties-docs.gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── httpexchange-processor ├── build.gradle └── src │ ├── main │ ├── java │ │ └── io │ │ │ └── github │ │ │ └── danielliu1123 │ │ │ └── httpexchange │ │ │ └── processor │ │ │ ├── ApiBaseProcessor.java │ │ │ ├── Finder.java │ │ │ ├── GeneratedType.java │ │ │ └── ProcessorProperties.java │ └── resources │ │ └── META-INF │ │ ├── gradle │ │ └── incremental.annotation.processors │ │ └── services │ │ └── javax.annotation.processing.Processor │ └── test │ └── java │ └── io │ └── github │ └── danielliu1123 │ └── httpexchange │ ├── it │ └── normal │ │ ├── Api.java │ │ ├── Api2.java │ │ ├── Api3.java │ │ ├── Api4.java │ │ ├── Api5.java │ │ ├── Class1.java │ │ ├── GenericTypeApi.java │ │ └── NormalTest.java │ └── processor │ └── FinderTest.java ├── httpexchange-spring-boot-autoconfigure ├── build.gradle └── src │ ├── main │ ├── java │ │ └── io │ │ │ └── github │ │ │ └── danielliu1123 │ │ │ └── httpexchange │ │ │ ├── BeanParam.java │ │ │ ├── BeanParamArgumentResolver.java │ │ │ ├── Cache.java │ │ │ ├── Checker.java │ │ │ ├── EnableExchangeClients.java │ │ │ ├── ExchangeClientCreator.java │ │ │ ├── ExchangeClientsRegistrar.java │ │ │ ├── HttpClientBeanDefinitionRegistry.java │ │ │ ├── HttpClientBeanRegistrar.java │ │ │ ├── HttpClientCustomizer.java │ │ │ ├── HttpExchangeAutoConfiguration.java │ │ │ ├── HttpExchangeBeanFactoryInitializationAotProcessor.java │ │ │ ├── HttpExchangeProperties.java │ │ │ ├── HttpExchangeRuntimeHintsRegistrar.java │ │ │ ├── HttpExchangeUtil.java │ │ │ ├── HttpServiceProxyFactoryCustomizer.java │ │ │ ├── ScanInfo.java │ │ │ ├── SpringBootVersionIncompatibleException.java │ │ │ ├── SpringBootVersionIncompatibleFailureAnalyzer.java │ │ │ ├── UrlPlaceholderStringValueResolver.java │ │ │ ├── Util.java │ │ │ └── shaded │ │ │ ├── ShadedHttpServiceMethod.java │ │ │ └── ShadedHttpServiceProxyFactory.java │ └── resources │ │ ├── META-INF │ │ ├── spring.factories │ │ └── spring │ │ │ ├── aot.factories │ │ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports │ │ └── application-http-exchange-statrer-example.yml │ └── test │ ├── java │ ├── io │ │ └── github │ │ │ └── danielliu1123 │ │ │ ├── Post.java │ │ │ ├── httpexchange │ │ │ ├── BasePackagesConfigTests.java │ │ │ ├── BaseUrlTests.java │ │ │ ├── BeanParamArgumentResolverTests.java │ │ │ ├── ClassesConfigTests.java │ │ │ ├── ClientTypeTests.java │ │ │ ├── ClientsConfigTests.java │ │ │ ├── DynamicRefreshTests.java │ │ │ ├── EnabledTests.java │ │ │ ├── ExchangeClientCreatorTest.java │ │ │ ├── ExchangeClientTests.java │ │ │ ├── ExtendTests.java │ │ │ ├── HttpClientBeanDefinitionRegistryTests.java │ │ │ ├── HttpClientCustomizerIT.java │ │ │ ├── HttpExchangeAutoConfigurationTest.java │ │ │ ├── RegisterBeanManuallyTests.java │ │ │ ├── RestClientConfigurationTests.java │ │ │ ├── RestClientCustomizerIT.java │ │ │ ├── ReturnTypeTests.java │ │ │ ├── SpringBootVersionIncompatibleFailureAnalyzerIT.java │ │ │ ├── SpringBootVersionIncompatibleFailureAnalyzerTest.java │ │ │ ├── TimeoutTests.java │ │ │ ├── UrlVariableTests.java │ │ │ ├── ValidationTests.java │ │ │ └── shaded │ │ │ │ └── ShadedHttpServiceProxyFactoryTest.java │ │ │ ├── order │ │ │ └── api │ │ │ │ └── OrderApi.java │ │ │ └── user │ │ │ └── api │ │ │ ├── DummyApi.java │ │ │ ├── UserApi.java │ │ │ └── UserHobbyApi.java │ └── issues │ │ └── issue73 │ │ ├── CfgWithHttpClientConfiguration.java │ │ ├── CfgWithoutHttpClientConfiguration.java │ │ ├── HttpClientConfiguration.java │ │ ├── Issue73Test.java │ │ └── UserApi.java │ └── resources │ ├── application-ClassesConfigTests.yml │ ├── application-ClientsConfigTests.yml │ └── application-ControllerApiTests.yml ├── secring.gpg.bin ├── settings.gradle ├── starters └── httpexchange-spring-boot-starter │ └── build.gradle └── website ├── .gitignore ├── README.md ├── babel.config.js ├── docs ├── 01-intro.mdx ├── 10-core │ ├── 10-autoconfiguration.mdx │ ├── 20-generate-server-implementation.mdx │ ├── 30-configuration.mdx │ ├── 40-validation.mdx │ └── _category_.json ├── 20-extensions │ ├── 10-request-mapping-annotation-support.mdx │ ├── 20-loadbalancer.mdx │ ├── 30-bean-to-query.mdx │ ├── 40-dynamic-refresh.mdx │ ├── 50-url-variables.mdx │ ├── 70-native-image.mdx │ ├── 80-customization.mdx │ └── _category_.json ├── 40-configuration-properties.md ├── 45-best-practice.mdx └── 50-version.mdx ├── docusaurus.config.ts ├── package.json ├── sidebars.ts ├── src ├── components │ └── HomepageFeatures │ │ ├── index.tsx │ │ └── styles.module.css ├── css │ └── custom.css └── pages │ ├── index.module.css │ ├── index.tsx │ └── markdown-page.md ├── static ├── .nojekyll └── img │ └── favicon.ico ├── tsconfig.json └── yarn.lock /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | ./gradlew spotlessApply > /dev/null 6 | 7 | # generate configuration properties docs 8 | ./gradlew classes generateConfigurationPropertiesDocs > /dev/null 9 | 10 | # escape {} in md 11 | perl -pi -e 's/{/\\{/g' build/configuration-properties.md 12 | # add sidebar_position 13 | perl -pi -e 'print "---\nsidebar_position: 40\n---\n\n" if $. == 1' build/configuration-properties.md 14 | # remove generated time 15 | perl -pi -e 's/This is a generated file.*\n//g' build/configuration-properties.md 16 | 17 | cp -f build/configuration-properties.md website/docs/40-configuration-properties.md 18 | 19 | git add -u 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gradle" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - 3.4.x 8 | - 3.3.x 9 | - 3.2.x 10 | - 3.1.x 11 | pull_request: 12 | branches: 13 | - main 14 | - 3.4.x 15 | - 3.3.x 16 | - 3.2.x 17 | - 3.1.x 18 | 19 | concurrency: 20 | group: ${{ github.workflow }}-${{ github.ref }} 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | build: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | steps: 28 | - name: Check out the repo 29 | uses: actions/checkout@v4 30 | 31 | - name: Set up JDK 32 | uses: actions/setup-java@v3 33 | with: 34 | java-version: '17' 35 | distribution: 'corretto' 36 | 37 | - name: Setup Gradle 38 | uses: gradle/actions/setup-gradle@v4 39 | 40 | - name: Build 41 | run: ./gradlew build 42 | 43 | native-image-build: 44 | runs-on: ubuntu-latest 45 | timeout-minutes: 45 46 | steps: 47 | - name: Check out the repo 48 | uses: actions/checkout@v4 49 | 50 | - name: Setup GraalVM 51 | uses: graalvm/setup-graalvm@v1 52 | with: 53 | java-version: '17' 54 | distribution: 'graalvm' 55 | github-token: ${{ secrets.GITHUB_TOKEN }} 56 | native-image-job-reports: 'true' 57 | 58 | - name: Setup Gradle 59 | uses: gradle/actions/setup-gradle@v4 60 | 61 | - name: Build native image 62 | run: | 63 | ./gradlew nativeRun 64 | -------------------------------------------------------------------------------- /.github/workflows/create-tweet-on-x.yml: -------------------------------------------------------------------------------- 1 | name: Create Tweet on X 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | tweet: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/setup-go@v5 12 | with: 13 | go-version: stable 14 | - name: Create Tweet on X 15 | run: | 16 | go install github.com/DanielLiu1123/xcli/cmd/xcli@latest 17 | 18 | RELEASE_TAG="${{ github.event.release.tag_name }}" 19 | REPO_NAME="${{ github.repository }}" 20 | REPO_DESCRIPTION="${{ github.event.repository.description }}" 21 | 22 | TWEET_TEXT=$(printf "🎉 %s has released %s!\n\n%s: %s\n\n#Java #Spring #SpringBoot\n\n🔗 Check it out: %s" \ 23 | "${REPO_NAME}" \ 24 | "${RELEASE_TAG}" \ 25 | "${REPO_NAME#*/}" \ 26 | "${REPO_DESCRIPTION}" \ 27 | "https://github.com/${REPO_NAME}/releases/tag/${RELEASE_TAG}" 28 | ) 29 | 30 | echo "Tweeting content:" 31 | echo "${TWEET_TEXT}" 32 | 33 | xcli tweet create --text="${TWEET_TEXT}" \ 34 | --api-key="${{ secrets.X_API_KEY }}" \ 35 | --api-secret="${{ secrets.X_API_SECRET }}" \ 36 | --access-token="${{ secrets.X_ACCESS_TOKEN }}" \ 37 | --access-secret="${{ secrets.X_ACCESS_SECRET }}" 38 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | # See https://docusaurus.io/docs/deployment#triggering-deployment-with-github-actions 2 | 3 | name: Deploy to GitHub Pages 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - 'website/**' 11 | - '.github/workflows/deploy-docs.yml' 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | build: 19 | name: Build 20 | runs-on: ubuntu-latest 21 | defaults: 22 | run: 23 | working-directory: ./website 24 | steps: 25 | - uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version: 22 31 | cache: yarn 32 | cache-dependency-path: ./website/yarn.lock 33 | 34 | - name: Install dependencies 35 | run: yarn install --frozen-lockfile 36 | - name: Build website 37 | run: yarn build 38 | 39 | - name: Upload Build Artifact 40 | uses: actions/upload-pages-artifact@v3 41 | with: 42 | path: ./website/build 43 | deploy: 44 | name: Deploy to GitHub Pages 45 | needs: build 46 | 47 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment 48 | permissions: 49 | pages: write # to deploy to Pages 50 | id-token: write # to verify the deployment originates from an appropriate source 51 | 52 | # Deploy to the github-pages environment 53 | environment: 54 | name: github-pages 55 | url: ${{ steps.deployment.outputs.page_url }} 56 | 57 | runs-on: ubuntu-latest 58 | steps: 59 | - name: Deploy to GitHub Pages 60 | id: deployment 61 | uses: actions/deploy-pages@v4 62 | -------------------------------------------------------------------------------- /.github/workflows/job-upgrade-gradle.yaml: -------------------------------------------------------------------------------- 1 | name: Upgrade Gradle Version 2 | 3 | on: 4 | schedule: 5 | - cron: '10 4 * * *' 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | upgrade-gradle: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Java 20 | uses: actions/setup-java@v4 21 | with: 22 | java-version: '17' 23 | distribution: 'corretto' 24 | 25 | - name: Upgrade Gradle 26 | run: | 27 | latest_version=$(curl -s 'https://services.gradle.org/versions/current' | grep '"version"' | sed 's/.*"version" *: *"\([^"]*\)".*/\1/') 28 | echo "Latest Gradle version: $latest_version" 29 | 30 | current_version=$(grep 'distributionUrl' gradle/wrapper/gradle-wrapper.properties | sed 's/.*gradle-\(.*\)-bin.*/\1/') 31 | echo "Current Gradle version: $current_version" 32 | 33 | echo "current_version=$current_version" >> $GITHUB_ENV 34 | echo "latest_version=$latest_version" >> $GITHUB_ENV 35 | 36 | if [[ "$current_version" == "$latest_version" ]]; then 37 | echo "Gradle version is up to date" 38 | exit 0 39 | fi 40 | 41 | ./gradlew wrapper --gradle-version $latest_version && ./gradlew wrapper 42 | 43 | - name: Create Pull Request 44 | if: env.latest_version != env.current_version 45 | uses: peter-evans/create-pull-request@v7 46 | with: 47 | commit-message: "Update Gradle version to ${{ env.latest_version }}" 48 | title: "Update Gradle version to ${{ env.latest_version }}" 49 | body: "" 50 | branch: upgrade-gradle-version-${{ env.latest_version }} -------------------------------------------------------------------------------- /.github/workflows/job-upgrade-spring-boot.yaml: -------------------------------------------------------------------------------- 1 | name: Upgrade Spring Boot Version 2 | 3 | on: 4 | schedule: 5 | - cron: '0 4 * * *' 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | upgrade-spring-boot: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Upgrade Spring Boot 20 | id: upgrade_spring_boot 21 | run: | 22 | latest_version=$(curl -s 'https://repo1.maven.org/maven2/org/springframework/boot/spring-boot/maven-metadata.xml' | grep "" | sed 's/.*\(.*\)<\/latest>.*/\1/') 23 | echo "Latest Spring Boot version: $latest_version" 24 | 25 | current_version=$(grep 'springBootVersion' gradle.properties | cut -d'=' -f2) 26 | echo "Current Spring Boot version: $current_version" 27 | 28 | echo "current_version=$current_version" >> $GITHUB_ENV 29 | echo "latest_version=$latest_version" >> $GITHUB_ENV 30 | 31 | if [[ "$current_version" == "$latest_version" ]]; then 32 | echo "Spring Boot version is up to date" 33 | exit 0 34 | fi 35 | 36 | sed -i "s/^springBootVersion=.*/springBootVersion=$latest_version/" gradle.properties 37 | sed -i "s/^version=.*/version=${latest_version}-SNAPSHOT/" gradle.properties 38 | 39 | - name: Create Pull Request 40 | if: env.latest_version != env.current_version 41 | uses: peter-evans/create-pull-request@v7 42 | with: 43 | commit-message: "Update Spring Boot version to ${{ env.latest_version }}" 44 | title: "Update Spring Boot version to ${{ env.latest_version }}" 45 | body: "" 46 | branch: upgrade-spring-boot-version-${{ env.latest_version }} 47 | -------------------------------------------------------------------------------- /.github/workflows/release-snapshot.yml: -------------------------------------------------------------------------------- 1 | name: Release Snapshot 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - 3.4.x 8 | - 3.3.x 9 | - 3.2.x 10 | - 3.1.x 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | release-snapshot: 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 30 20 | steps: 21 | - name: Check out the repo 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up JDK 25 | uses: actions/setup-java@v4 26 | with: 27 | java-version: '17' 28 | distribution: 'corretto' 29 | 30 | - name: Setup Gradle 31 | uses: gradle/actions/setup-gradle@v4 32 | 33 | - name: Release Snapshot 34 | run: OSSRH_USER=${{ secrets.OSSRH_USER }} OSSRH_PASSWORD=${{ secrets.OSSRH_PASSWORD }} ./gradlew publish 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 10 12 | steps: 13 | - name: Check out the repo 14 | uses: actions/checkout@v4 15 | 16 | - name: Check version 17 | run: | 18 | VERSION_TAG=${{ github.ref_name }} 19 | VERSION_TAG=${VERSION_TAG#v} 20 | PROJECT_VERSION=$(grep "^version=" gradle.properties | cut -d'=' -f2) 21 | if [[ "$PROJECT_VERSION" == "${VERSION_TAG}-SNAPSHOT" ]]; then 22 | echo "Version match: tag $VERSION_TAG matches project version $PROJECT_VERSION, proceeding with release" 23 | else 24 | echo "Version mismatch: tag $VERSION_TAG does not match project version $PROJECT_VERSION" 25 | exit 1 26 | fi 27 | 28 | - name: Set up JDK 29 | uses: actions/setup-java@v4 30 | with: 31 | java-version: '17' 32 | distribution: 'corretto' 33 | 34 | - name: Setup Gradle 35 | uses: gradle/actions/setup-gradle@v4 36 | 37 | - name: Build 38 | run: ./gradlew build 39 | 40 | - name: Decrypt secring.gpg 41 | run: openssl enc -aes-256-cbc -d -pbkdf2 -in secring.gpg.bin -out secring.gpg -pass pass:${{ secrets.privateKeyPassword }} 42 | 43 | - name: Release 44 | run: RELEASE=true OSSRH_USER=${{ secrets.OSSRH_USER }} OSSRH_PASSWORD=${{ secrets.OSSRH_PASSWORD }} ./gradlew publish -Psigning.secretKeyRingFile=$(pwd)/secring.gpg -Psigning.keyId=${{ secrets.signKeyId }} -Psigning.password=${{ secrets.signPassword }} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to HttpExchange Spring Boot Starter 2 | 3 | Welcome to the httpexchange-spring-boot-starter project! 4 | We are excited to have you onboard and contribute to this project, 5 | which aims to simplify HTTP exchange in Spring Boot applications. 6 | This project is released under the MIT license, and we welcome contributions of all forms. 7 | 8 | ## Steps for Contributing 9 | 10 | 1. **Submit an Issue**: Start by creating an issue in our GitHub repository. Your issue can be a feature request, a bug 11 | report, or any other valuable feedback. 12 | 13 | 2. **Fork the Repository**: Fork our repository on GitHub to start making your changes. 14 | 15 | 3. **Make Your Changes**: Work on the issue you've chosen. Don't forget to adhere to our coding standards and write 16 | tests as needed. 17 | 18 | 4. **Submit a Pull Request**: Once you're done with your changes, submit a pull request (PR) to merge your changes into 19 | the main branch. 20 | 21 | 5. **Code Review**: Your PR will be reviewed by our maintainers. Engage in the review process to discuss your 22 | contributions and make any necessary adjustments. 23 | 24 | 6. **Merge**: Once your PR is approved, it will be merged into the project! 25 | 26 | ## Code Style and Conventions 27 | 28 | - **Code Formatting**: We use [palantir-java-format](https://github.com/palantir/palantir-java-format) as our code 29 | formatter. 30 | 31 | Run the following command to apply formatting: 32 | 33 | ```shell 34 | ./gradlew spotlessApply 35 | ``` 36 | 37 | - **Writing Tests**: Make sure to write tests for your code to ensure everything works as expected. 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Freeman Lau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HttpExchange Spring Boot Starter [![Build](https://img.shields.io/github/actions/workflow/status/DanielLiu1123/httpexchange-spring-boot-starter/build.yml?branch=main)](https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/actions) [![Maven Central](https://img.shields.io/maven-central/v/io.github.danielliu1123/httpexchange-spring-boot-starter)](https://search.maven.org/artifact/io.github.danielliu1123/httpexchange-spring-boot-starter) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 2 | 3 | Spring 6 now supports creating HTTP clients using the [`@HttpExchange`](https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-http-interface) annotation. 4 | This removes the need for [Spring Cloud OpenFeign](https://github.com/spring-cloud/spring-cloud-openfeign). 5 | 6 | The main goals of this project are: 7 | 8 | - To promote `@HttpExchange` as the standard for defining API interfaces. 9 | - To offer an experience similar to `Spring Cloud OpenFeign` for declarative HTTP clients. 10 | - To ensure compatibility with Spring web annotations like `@RequestMapping` and `@GetMapping`. 11 | - To avoid external annotations, making it easier to switch to other implementations. 12 | 13 | ## Quick Start 14 | 15 | ```groovy 16 | implementation("io.github.danielliu1123:httpexchange-spring-boot-starter:") 17 | ``` 18 | 19 | ```java 20 | @HttpExchange("https://my-json-server.typicode.com") 21 | interface PostApi { 22 | @GetExchange("/typicode/demo/posts/{id}") 23 | Post getPost(@PathVariable("id") int id); 24 | } 25 | 26 | @SpringBootApplication 27 | @EnableExchangeClients 28 | public class App { 29 | public static void main(String[] args) { 30 | SpringApplication.run(App.class, args); 31 | } 32 | 33 | @Bean 34 | ApplicationRunner runner(PostApi api) { 35 | return args -> api.getPost(1); 36 | } 37 | } 38 | ``` 39 | 40 | Refer to [quick-start](examples/quick-start). 41 | 42 | ## Documentation 43 | 44 | Go to [Reference Documentation](https://danielliu1123.github.io/httpexchange-spring-boot-starter/docs/intro) for more information. 45 | 46 | ## Code of Conduct 47 | 48 | This project is governed by the [Code of Conduct](./CODE_OF_CONDUCT.md). 49 | By participating, you are expected to uphold this code of conduct. 50 | Please report unacceptable behavior to llw599502537@gmail.com. 51 | 52 | ## Contributing 53 | 54 | Use the [issue tracker](https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/issues) for bug reports, 55 | feature requests, and submitting pull requests. 56 | 57 | If you would like to contribute to the project, please refer to [Contributing](./CONTRIBUTING.md). 58 | 59 | ## License 60 | 61 | The MIT License. 62 | 63 | ## Special Thanks 64 | 65 | Many thanks to [JetBrains](https://www.jetbrains.com/) for sponsoring this Open Source project with a license. 66 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | mavenCentral() 4 | } 5 | dependencies { 6 | classpath "org.rodnansol:spring-configuration-property-documenter-gradle-plugin:${springConfigurationPropertyDocumenterVersion}" 7 | } 8 | } 9 | 10 | plugins { 11 | id "com.diffplug.spotless" version "${spotlessVersion}" apply false 12 | id "com.github.spotbugs" version "${spotbugsVersion}" apply false 13 | id "io.spring.dependency-management" version "${springDependencyManagementVersion}" apply false 14 | id "org.springframework.boot" version "${springBootVersion}" apply false 15 | id "org.graalvm.buildtools.native" version "${graalvmBuildToolsVersion}" apply false 16 | } 17 | 18 | allprojects { 19 | 20 | apply plugin: "java" 21 | apply plugin: "java-library" 22 | 23 | repositories { 24 | mavenCentral() 25 | maven { url = "https://repo.spring.io/snapshot" } 26 | maven { url = "https://repo.spring.io/milestone" } 27 | } 28 | 29 | sourceSets { 30 | optionalSupport 31 | } 32 | 33 | java { 34 | toolchain { 35 | languageVersion = JavaLanguageVersion.of(17) 36 | } 37 | registerFeature("optionalSupport") { 38 | usingSourceSet(sourceSets.optionalSupport) 39 | } 40 | } 41 | 42 | apply plugin: "io.spring.dependency-management" 43 | dependencyManagement { 44 | imports { 45 | mavenBom "org.springframework.boot:spring-boot-dependencies:${springBootVersion}" 46 | } 47 | } 48 | dependencies { 49 | compileOnly("org.projectlombok:lombok") 50 | annotationProcessor("org.projectlombok:lombok") 51 | testCompileOnly("org.projectlombok:lombok") 52 | testAnnotationProcessor("org.projectlombok:lombok") 53 | } 54 | 55 | compileJava { 56 | options.encoding = "UTF-8" 57 | options.compilerArgs << "-parameters" 58 | } 59 | 60 | compileTestJava { 61 | options.encoding = "UTF-8" 62 | options.compilerArgs << "-parameters" 63 | } 64 | 65 | test { 66 | systemProperties("spring.cloud.compatibility-verifier.enabled": "false") 67 | 68 | useJUnitPlatform() 69 | 70 | dependencies { 71 | // https://docs.gradle.org/current/userguide/upgrading_version_8.html#test_framework_implementation_dependencies 72 | testRuntimeOnly("org.junit.platform:junit-platform-launcher") 73 | } 74 | } 75 | 76 | afterEvaluate { 77 | tasks.findByName("bootRun")?.configure { 78 | systemProperty("spring.cloud.compatibility-verifier.enabled", "false") 79 | } 80 | } 81 | 82 | apply plugin: "com.diffplug.spotless" 83 | spotless { 84 | encoding "UTF-8" 85 | java { 86 | toggleOffOn() 87 | removeUnusedImports() 88 | trimTrailingWhitespace() 89 | endWithNewline() 90 | palantirJavaFormat() 91 | 92 | targetExclude("build/generated/**") 93 | 94 | custom("Refuse wildcard imports", { 95 | if (it =~ /\nimport .*\*;/) { 96 | throw new IllegalStateException("Do not use wildcard imports, 'spotlessApply' cannot resolve this issue, please fix it manually.") 97 | } 98 | } as Closure) 99 | } 100 | } 101 | 102 | apply plugin: "com.github.spotbugs" 103 | spotbugs { 104 | spotbugsTest.enabled = false 105 | omitVisitors.addAll("FindReturnRef", "DontReusePublicIdentifiers") 106 | } 107 | } 108 | 109 | apply from: "${rootDir}/gradle/generate-configuration-properties-docs.gradle" 110 | -------------------------------------------------------------------------------- /examples/loadbalancer/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "org.springframework.boot" 3 | } 4 | 5 | dependencies { 6 | implementation("org.springframework.boot:spring-boot-starter-web") 7 | implementation(project(":starters:httpexchange-spring-boot-starter")) 8 | implementation("org.springframework.cloud:spring-cloud-starter-loadbalancer:${springCloudCommonsVersion}") 9 | 10 | implementation("org.springframework:spring-webflux") 11 | implementation("org.springframework.retry:spring-retry") 12 | 13 | testImplementation("org.springframework.boot:spring-boot-starter-test") 14 | } 15 | -------------------------------------------------------------------------------- /examples/loadbalancer/src/main/java/com/example/LoadBalancerApp.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | import io.github.danielliu1123.httpexchange.EnableExchangeClients; 4 | import java.util.List; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.boot.builder.SpringApplicationBuilder; 7 | import org.springframework.web.bind.annotation.RestController; 8 | 9 | @SpringBootApplication 10 | @EnableExchangeClients 11 | @RestController 12 | public class LoadBalancerApp implements UserApi { 13 | 14 | public static void main(String[] args) { 15 | new SpringApplicationBuilder(LoadBalancerApp.class) 16 | .properties("server.port=0") 17 | .run(args); 18 | } 19 | 20 | @Override 21 | public UserApi.UserDTO getById(String id) { 22 | return new UserApi.UserDTO(id, "Freeman", List.of("Coding", "Reading")); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/loadbalancer/src/main/java/com/example/UserApi.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | import java.util.List; 4 | import org.springframework.web.bind.annotation.PathVariable; 5 | import org.springframework.web.service.annotation.GetExchange; 6 | import org.springframework.web.service.annotation.HttpExchange; 7 | 8 | @HttpExchange("/user") 9 | public interface UserApi { 10 | record UserDTO(String id, String name, List hobbies) {} 11 | 12 | @GetExchange("/getById/{id}") 13 | UserDTO getById(@PathVariable("id") String id); 14 | } 15 | -------------------------------------------------------------------------------- /examples/loadbalancer/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: loadbalancer 4 | cloud: 5 | discovery: 6 | client: 7 | simple: 8 | instances: 9 | user: 10 | - host: localhost 11 | port: ${server.port} 12 | - host: localhost 13 | port: ${random.int(50000,60000)} # Simulate an unavailable instance 14 | http-exchange: 15 | channels: 16 | - base-url: user # service id 17 | clients: 18 | - com.example.*Api 19 | -------------------------------------------------------------------------------- /examples/minimal/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "org.springframework.boot" 3 | } 4 | 5 | dependencies { 6 | implementation(project(":starters:httpexchange-spring-boot-starter")) 7 | 8 | testImplementation("org.springframework.boot:spring-boot-starter-test") 9 | } 10 | -------------------------------------------------------------------------------- /examples/minimal/src/main/java/com/example/MinimalApp.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | import io.github.danielliu1123.httpexchange.EnableExchangeClients; 4 | import java.util.List; 5 | import org.springframework.boot.SpringApplication; 6 | import org.springframework.boot.autoconfigure.SpringBootApplication; 7 | import org.springframework.web.service.annotation.GetExchange; 8 | import org.springframework.web.service.annotation.HttpExchange; 9 | 10 | @SpringBootApplication 11 | @EnableExchangeClients 12 | public class MinimalApp { 13 | 14 | public static void main(String[] args) { 15 | SpringApplication.run(MinimalApp.class, args); 16 | } 17 | 18 | @HttpExchange("https://my-json-server.typicode.com") 19 | interface PostApi { 20 | record Post(Integer id, String title) {} 21 | 22 | @GetExchange("/typicode/demo/posts") 23 | List list(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/minimal/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: minimal 4 | -------------------------------------------------------------------------------- /examples/minimal/src/test/java/com/example/MinimalAppTest.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | import static com.example.MinimalApp.PostApi; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.boot.test.context.SpringBootTest; 9 | 10 | @SpringBootTest 11 | class MinimalAppTest { 12 | 13 | @Autowired 14 | PostApi postApi; 15 | 16 | @Test 17 | void testPostApi() { 18 | assertThat(postApi.list()).isNotEmpty(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/native-image/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "org.springframework.boot" 3 | id "org.graalvm.buildtools.native" 4 | } 5 | 6 | dependencies { 7 | implementation(project(":starters:httpexchange-spring-boot-starter")) 8 | 9 | testImplementation("org.springframework.boot:spring-boot-starter-test") 10 | } 11 | 12 | // https://graalvm.github.io/native-build-tools/latest/gradle-plugin.html#configuration-options 13 | graalvmNative { 14 | testSupport = false 15 | binaries { 16 | main { 17 | verbose = true 18 | sharedLibrary = false 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/native-image/src/main/java/com/example/NativeImageApp.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | import io.github.danielliu1123.httpexchange.EnableExchangeClients; 4 | import java.util.List; 5 | import org.springframework.boot.SpringApplication; 6 | import org.springframework.boot.autoconfigure.SpringBootApplication; 7 | import org.springframework.web.bind.annotation.PathVariable; 8 | import org.springframework.web.service.annotation.GetExchange; 9 | 10 | @SpringBootApplication 11 | @EnableExchangeClients 12 | public class NativeImageApp { 13 | 14 | public static void main(String[] args) { 15 | var ctx = SpringApplication.run(NativeImageApp.class, args); 16 | 17 | var postApi = ctx.getBean(PostApi.class); 18 | 19 | postApi.list().forEach(System.out::println); 20 | 21 | System.out.println(postApi.get(1)); 22 | 23 | if (System.getenv("CI") != null) { 24 | ctx.close(); 25 | } 26 | } 27 | 28 | public record Post(Integer id, String title) {} 29 | 30 | public interface PostApi { 31 | @GetExchange("/typicode/demo/posts") 32 | List list(); 33 | 34 | @GetExchange("/typicode/demo/posts/{id}") 35 | Post get(@PathVariable("id") int id); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/native-image/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: native-image 4 | http-exchange: 5 | base-url: https://my-json-server.typicode.com 6 | -------------------------------------------------------------------------------- /examples/native-image/src/test/java/com/example/NativeImageAppTest.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | import static com.example.NativeImageApp.PostApi; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.boot.test.context.SpringBootTest; 9 | 10 | @SpringBootTest 11 | class NativeImageAppTest { 12 | 13 | @Autowired 14 | PostApi postApi; 15 | 16 | @Test 17 | void testPostApi() { 18 | assertThat(postApi.list()).isNotEmpty(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/processor/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation("org.springframework:spring-web") 3 | annotationProcessor(project(":httpexchange-processor")) 4 | 5 | testImplementation("org.springframework.boot:spring-boot-starter-test") 6 | } 7 | 8 | compileJava { 9 | options.compilerArgs.add("-AhttpExchangeConfig=${projectDir}/httpexchange-processor.properties") 10 | } -------------------------------------------------------------------------------- /examples/processor/httpexchange-processor.properties: -------------------------------------------------------------------------------- 1 | enabled=true 2 | prefix=Abstract 3 | suffix=Impl 4 | packages=com.example.foo.api, com.example.bar 5 | outputSubpackage=generated 6 | generatedType=ABSTRACT_CLASS 7 | -------------------------------------------------------------------------------- /examples/processor/src/main/java/com/example/bar/api/BarApi.java: -------------------------------------------------------------------------------- 1 | package com.example.bar.api; 2 | 3 | import org.springframework.web.bind.annotation.PathVariable; 4 | import org.springframework.web.service.annotation.GetExchange; 5 | 6 | public interface BarApi { 7 | 8 | @GetExchange("/bars/{id}") 9 | String get(@PathVariable("id") String id); 10 | } 11 | -------------------------------------------------------------------------------- /examples/processor/src/main/java/com/example/bar/api/RequestMappingBarApi.java: -------------------------------------------------------------------------------- 1 | package com.example.bar.api; 2 | 3 | import org.springframework.web.bind.annotation.GetMapping; 4 | import org.springframework.web.bind.annotation.PathVariable; 5 | 6 | public interface RequestMappingBarApi { 7 | 8 | @GetMapping("/bars/{id}") 9 | String get(@PathVariable("id") String id); 10 | } 11 | -------------------------------------------------------------------------------- /examples/processor/src/main/java/com/example/baz/api/BazApi.java: -------------------------------------------------------------------------------- 1 | package com.example.baz.api; 2 | 3 | import org.springframework.web.bind.annotation.PathVariable; 4 | import org.springframework.web.service.annotation.GetExchange; 5 | 6 | public interface BazApi { 7 | 8 | @GetExchange("/bars/{id}") 9 | String get(@PathVariable("id") String id); 10 | } 11 | -------------------------------------------------------------------------------- /examples/processor/src/main/java/com/example/foo/api/FooApi.java: -------------------------------------------------------------------------------- 1 | package com.example.foo.api; 2 | 3 | import org.springframework.web.bind.annotation.PathVariable; 4 | import org.springframework.web.service.annotation.GetExchange; 5 | 6 | public interface FooApi { 7 | 8 | @GetExchange("/foos/{id}") 9 | String get(@PathVariable("id") String id); 10 | } 11 | -------------------------------------------------------------------------------- /examples/processor/src/test/java/com/example/api/ProcessorTests.java: -------------------------------------------------------------------------------- 1 | package com.example.api; 2 | 3 | import static org.assertj.core.api.Assertions.assertThatCode; 4 | 5 | import org.junit.jupiter.api.Test; 6 | 7 | /** 8 | * @author Freeman 9 | */ 10 | class ProcessorTests { 11 | 12 | @Test 13 | void testGenerateCode() { 14 | assertThatCode(() -> Class.forName("com.example.foo.api.generated.AbstractFooApiImpl")) 15 | .doesNotThrowAnyException(); 16 | assertThatCode(() -> Class.forName("com.example.bar.api.generated.AbstractBarApiImpl")) 17 | .doesNotThrowAnyException(); 18 | assertThatCode(() -> Class.forName("com.example.bar.api.generated.AbstractRequestMappingBarApiImpl")) 19 | .doesNotThrowAnyException(); 20 | assertThatCode(() -> Class.forName("com.example.baz.api.generated.AbstractBazApiImpl")) 21 | .isInstanceOf(ClassNotFoundException.class); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/quick-start/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "org.springframework.boot" 3 | } 4 | 5 | dependencies { 6 | implementation("org.springframework.boot:spring-boot-starter-web") 7 | implementation("org.springframework.boot:spring-boot-starter-validation") 8 | implementation(project(":starters:httpexchange-spring-boot-starter")) 9 | 10 | annotationProcessor(project(":httpexchange-processor")) 11 | 12 | testImplementation("org.springframework.boot:spring-boot-starter-test") 13 | } 14 | 15 | compileJava { 16 | options.compilerArgs.add("-AhttpExchangeConfig=${projectDir}/httpexchange-processor.properties") 17 | } 18 | -------------------------------------------------------------------------------- /examples/quick-start/httpexchange-processor.properties: -------------------------------------------------------------------------------- 1 | enabled=true 2 | prefix= 3 | suffix=Base 4 | packages=com.*.api 5 | outputSubpackage=serverbase 6 | generatedType=ABSTRACT_CLASS 7 | -------------------------------------------------------------------------------- /examples/quick-start/src/main/java/com/example/api/UserApi.java: -------------------------------------------------------------------------------- 1 | package com.example.api; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | import java.util.List; 5 | import org.hibernate.validator.constraints.Length; 6 | import org.springframework.validation.annotation.Validated; 7 | import org.springframework.web.bind.annotation.PathVariable; 8 | import org.springframework.web.service.annotation.GetExchange; 9 | import org.springframework.web.service.annotation.HttpExchange; 10 | 11 | @Validated 12 | @HttpExchange("/user") 13 | public interface UserApi { 14 | record UserDTO(String id, String name, List hobbies) {} 15 | 16 | @GetExchange("/getById/{id}") 17 | UserDTO getById(@PathVariable("id") @NotBlank @Length(max = 5) String id); 18 | 19 | @GetExchange("/getByName/{name}") 20 | UserDTO getByName(@PathVariable("name") @NotBlank String name); 21 | } 22 | -------------------------------------------------------------------------------- /examples/quick-start/src/main/java/com/example/server/QuickStartApp.java: -------------------------------------------------------------------------------- 1 | package com.example.server; 2 | 3 | import com.example.api.UserApi; 4 | import com.example.api.serverbase.UserApiBase; 5 | import java.util.List; 6 | import org.springframework.boot.SpringApplication; 7 | import org.springframework.boot.autoconfigure.SpringBootApplication; 8 | import org.springframework.web.bind.annotation.RestController; 9 | 10 | @SpringBootApplication 11 | @RestController 12 | public class QuickStartApp extends UserApiBase { 13 | 14 | public static void main(String[] args) { 15 | SpringApplication.run(QuickStartApp.class, args); 16 | } 17 | 18 | @Override 19 | public UserApi.UserDTO getById(String id) { 20 | return new UserApi.UserDTO(id, "Freeman", List.of("Coding", "Reading")); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/quick-start/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: quick-start 4 | server: 5 | port: 50001 6 | http-exchange: 7 | base-packages: [ com.example.api ] 8 | channels: 9 | - base-url: http://localhost:${server.port} 10 | clients: 11 | - com.example.api.* 12 | -------------------------------------------------------------------------------- /examples/quick-start/src/test/java/com/example/server/QuickStartAppTest.java: -------------------------------------------------------------------------------- 1 | package com.example.server; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.assertj.core.api.Assertions.assertThatCode; 5 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; 6 | 7 | import com.example.api.UserApi; 8 | import jakarta.validation.ConstraintViolationException; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.context.SpringBootTest; 12 | import org.springframework.web.client.HttpServerErrorException; 13 | 14 | @SpringBootTest(webEnvironment = DEFINED_PORT) 15 | class QuickStartAppTest { 16 | 17 | @Autowired 18 | UserApi userApi; 19 | 20 | @Test 21 | void testGetUser_whenArgIsValid() { 22 | UserApi.UserDTO user = userApi.getById("1"); 23 | assertThat(user.id()).isEqualTo("1"); 24 | assertThat(user.name()).isEqualTo("Freeman"); 25 | assertThat(user.hobbies()).containsExactly("Coding", "Reading"); 26 | } 27 | 28 | @Test 29 | void testGetUser_whenArgIsInvalid() { 30 | assertThatCode(() -> userApi.getById("111111")) 31 | .isInstanceOf(ConstraintViolationException.class) 32 | .hasMessage("getById.id: length must be between 0 and 5"); 33 | } 34 | 35 | @Test 36 | void testGetUserByName_whenControllerNotImplement() { 37 | assertThatCode(() -> userApi.getByName("Freeman")) 38 | .isInstanceOf(HttpServerErrorException.NotImplemented.class) 39 | .hasMessageContaining("501"); // Not implemented 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/reactive/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "org.springframework.boot" 3 | } 4 | 5 | dependencies { 6 | implementation("org.springframework.boot:spring-boot-starter-webflux") 7 | implementation(project(":starters:httpexchange-spring-boot-starter")) 8 | 9 | testImplementation("org.springframework.boot:spring-boot-starter-test") 10 | } 11 | -------------------------------------------------------------------------------- /examples/reactive/src/main/java/com/example/api/UserApi.java: -------------------------------------------------------------------------------- 1 | package com.example.api; 2 | 3 | import com.example.api.dto.UserDTO; 4 | import java.util.List; 5 | import org.springframework.web.bind.annotation.PathVariable; 6 | import org.springframework.web.service.annotation.GetExchange; 7 | import org.springframework.web.service.annotation.HttpExchange; 8 | 9 | @HttpExchange("/user/blocking") 10 | public interface UserApi { 11 | 12 | @GetExchange("/{id}") 13 | UserDTO get(@PathVariable("id") String id); 14 | 15 | @GetExchange 16 | List list(); 17 | } 18 | -------------------------------------------------------------------------------- /examples/reactive/src/main/java/com/example/api/UserReactiveApi.java: -------------------------------------------------------------------------------- 1 | package com.example.api; 2 | 3 | import com.example.api.dto.UserDTO; 4 | import org.springframework.web.bind.annotation.PathVariable; 5 | import org.springframework.web.service.annotation.GetExchange; 6 | import org.springframework.web.service.annotation.HttpExchange; 7 | import reactor.core.publisher.Flux; 8 | import reactor.core.publisher.Mono; 9 | 10 | @HttpExchange("/user/reactive") 11 | public interface UserReactiveApi { 12 | 13 | @GetExchange("/{id}") 14 | Mono get(@PathVariable("id") String id); 15 | 16 | @GetExchange 17 | Flux list(); 18 | } 19 | -------------------------------------------------------------------------------- /examples/reactive/src/main/java/com/example/api/dto/UserDTO.java: -------------------------------------------------------------------------------- 1 | package com.example.api.dto; 2 | 3 | import java.util.List; 4 | 5 | public record UserDTO(String id, String name, List hobbies) {} 6 | -------------------------------------------------------------------------------- /examples/reactive/src/main/java/com/example/server/ReactiveApp.java: -------------------------------------------------------------------------------- 1 | package com.example.server; 2 | 3 | import com.example.api.UserApi; 4 | import com.example.api.UserReactiveApi; 5 | import com.example.api.dto.UserDTO; 6 | import java.util.List; 7 | import org.springframework.boot.SpringApplication; 8 | import org.springframework.boot.autoconfigure.SpringBootApplication; 9 | import org.springframework.web.bind.annotation.RestController; 10 | import reactor.core.publisher.Flux; 11 | import reactor.core.publisher.Mono; 12 | 13 | @SpringBootApplication 14 | public class ReactiveApp { 15 | 16 | public static void main(String[] args) { 17 | SpringApplication.run(ReactiveApp.class, args); 18 | } 19 | 20 | @RestController 21 | static class UserApiImpl implements UserApi { 22 | 23 | @Override 24 | public UserDTO get(String id) { 25 | return new UserDTO(id, "Freeman", List.of("Coding", "Reading")); 26 | } 27 | 28 | @Override 29 | public List list() { 30 | return List.of( 31 | new UserDTO("1", "Freeman", List.of("Coding", "Reading")), 32 | new UserDTO("2", "Jack", List.of("Coding", "Gaming"))); 33 | } 34 | } 35 | 36 | @RestController 37 | static class UserReactiveApiImpl implements UserReactiveApi { 38 | 39 | @Override 40 | public Mono get(String id) { 41 | return Mono.just(new UserDTO(id, "Freeman", List.of("Coding", "Reading"))); 42 | } 43 | 44 | @Override 45 | public Flux list() { 46 | return Flux.just( 47 | new UserDTO("1", "Freeman", List.of("Coding", "Reading")), 48 | new UserDTO("2", "Jack", List.of("Coding", "Gaming"))); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /examples/reactive/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: quick-start 4 | server: 5 | port: 50002 6 | http-exchange: 7 | base-packages: [ com.example.api ] 8 | channels: 9 | - base-url: http://localhost:${server.port} 10 | classes: 11 | - com.example.api.UserApi 12 | - base-url: http://localhost:${server.port} 13 | client-type: web_client 14 | classes: 15 | - com.example.api.UserReactiveApi 16 | -------------------------------------------------------------------------------- /examples/reactive/src/test/java/com/example/server/ReactiveTest.java: -------------------------------------------------------------------------------- 1 | package com.example.server; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; 5 | 6 | import com.example.api.UserApi; 7 | import com.example.api.UserReactiveApi; 8 | import com.example.api.dto.UserDTO; 9 | import java.util.List; 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.boot.test.context.SpringBootTest; 13 | 14 | @SpringBootTest(webEnvironment = DEFINED_PORT) 15 | class ReactiveTest { 16 | 17 | @Autowired 18 | UserApi userApi; 19 | 20 | @Autowired 21 | UserReactiveApi userReactiveApi; 22 | 23 | @Test 24 | void testBlockingApi() { 25 | assertThat(userApi).isNotInstanceOf(ReactiveApp.UserApiImpl.class); 26 | 27 | UserDTO user = userApi.get("1"); 28 | 29 | assertThat(user.id()).isEqualTo("1"); 30 | assertThat(user.name()).isEqualTo("Freeman"); 31 | assertThat(user.hobbies()).containsExactly("Coding", "Reading"); 32 | } 33 | 34 | @Test 35 | void testReactiveApi() { 36 | assertThat(userReactiveApi).isNotInstanceOf(ReactiveApp.UserReactiveApiImpl.class); 37 | 38 | UserDTO user = userReactiveApi.get("1").block(); 39 | 40 | assertThat(user).isNotNull(); 41 | assertThat(user.id()).isEqualTo("1"); 42 | assertThat(user.name()).isEqualTo("Freeman"); 43 | assertThat(user.hobbies()).containsExactly("Coding", "Reading"); 44 | 45 | List users = userReactiveApi.list().collectList().block(); 46 | 47 | assertThat(users).isNotNull().hasSize(2); 48 | assertThat(users.get(0).id()).isEqualTo("1"); 49 | assertThat(users.get(0).name()).isEqualTo("Freeman"); 50 | assertThat(users.get(0).hobbies()).containsExactly("Coding", "Reading"); 51 | assertThat(users.get(1).id()).isEqualTo("2"); 52 | assertThat(users.get(1).name()).isEqualTo("Jack"); 53 | assertThat(users.get(1).hobbies()).containsExactly("Coding", "Gaming"); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | group=io.github.danielliu1123 2 | version=3.5.0-SNAPSHOT 3 | 4 | # https://github.com/spring-projects/spring-boot 5 | springBootVersion=3.5.0 6 | # https://docs.spring.io/spring-cloud-release/reference/index.html 7 | # https://central.sonatype.com/artifact/org.springframework.cloud/spring-cloud-dependencies 8 | #springCloudVersion=2024.0.1 9 | springCloudCommonsVersion=4.2.1 10 | # https://central.sonatype.com/artifact/org.springframework.cloud/spring-cloud-dependencies 11 | springCloudOpenFeignVersion=4.2.1 12 | # https://github.com/spring-gradle-plugins/dependency-management-plugin 13 | springDependencyManagementVersion=1.1.7 14 | 15 | # https://github.com/rodnansol/spring-configuration-property-documenter 16 | springConfigurationPropertyDocumenterVersion=0.7.1 17 | 18 | # Code quality 19 | # https://plugins.gradle.org/plugin/com.diffplug.gradle.spotless 20 | spotlessVersion=7.0.3 21 | # https://plugins.gradle.org/plugin/com.github.spotbugs 22 | spotbugsVersion=6.1.12 23 | # https://github.com/spotbugs/spotbugs-gradle-plugin/blob/master/build.gradle.kts 24 | spotbugsAnnotationsVersion=4.8.6 25 | 26 | # https://github.com/graalvm/native-build-tools 27 | graalvmBuildToolsVersion=0.10.6 28 | 29 | org.gradle.jvmargs=-Xmx4g 30 | org.gradle.parallel=true 31 | org.gradle.caching=true 32 | -------------------------------------------------------------------------------- /gradle/deploy.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'maven-publish' 2 | apply plugin: 'signing' 3 | 4 | version = version as String 5 | version = System.getenv('RELEASE') ? version.substring(0, version.lastIndexOf('-SNAPSHOT')) : version 6 | 7 | def isRelease = !version.endsWith('-SNAPSHOT') 8 | 9 | tasks.register('sourcesJar', Jar) { 10 | from sourceSets.main.allJava 11 | archiveClassifier.set('sources') 12 | } 13 | 14 | def githubUrl = 'https://github.com/DanielLiu1123/httpexchange-spring-boot-starter' 15 | 16 | publishing { 17 | publications { 18 | mavenJava(MavenPublication) { 19 | artifact sourcesJar 20 | from components.java 21 | 22 | // see https://docs.gradle.org/current/userguide/publishing_maven.html 23 | versionMapping { 24 | usage('java-api') { 25 | fromResolutionOf('runtimeClasspath') 26 | } 27 | usage('java-runtime') { 28 | fromResolutionResult() 29 | } 30 | } 31 | pom { 32 | url = "${githubUrl}" 33 | licenses { 34 | license { 35 | name = 'MIT License' 36 | url = 'https://www.opensource.org/licenses/mit-license.php' 37 | distribution = 'repo' 38 | } 39 | } 40 | developers { 41 | developer { 42 | id = 'Freeman' 43 | name = 'Freeman Liu' 44 | email = 'llw599502537@gmail.com' 45 | } 46 | } 47 | scm { 48 | connection = "scm:git:git://${githubUrl.substring(8)}.git" 49 | developerConnection = "scm:git:ssh@${githubUrl.substring(8)}.git" 50 | url = "${githubUrl}" 51 | } 52 | } 53 | } 54 | } 55 | repositories { 56 | maven { 57 | credentials { 58 | username = System.getenv('OSSRH_USER') 59 | password = System.getenv('OSSRH_PASSWORD') 60 | } 61 | if (isRelease) { 62 | url = 'https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/' 63 | } else { 64 | url = 'https://central.sonatype.com/repository/maven-snapshots/' 65 | } 66 | } 67 | } 68 | 69 | tasks.withType(Sign).configureEach { 70 | onlyIf { isRelease } 71 | } 72 | 73 | signing { 74 | sign publishing.publications.mavenJava 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /gradle/generate-configuration-properties-docs.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "org.rodnansol.spring-configuration-property-documenter" 2 | 3 | // see https://github.com/rodnansol/spring-configuration-property-documenter/blob/master/docs/modules/ROOT/pages/gradle-plugin.adoc#multi-module-multiple-sub-projects 4 | tasks.register('generateConfigurationPropertiesDocs') { 5 | dependsOn generateAndAggregateDocuments { 6 | documentName = "Configuration Properties" 7 | documentDescription = """ 8 | Configuration properties for the httpexchange-spring-boot-starter project. 9 | 10 | This page was generated by [spring-configuration-property-documenter](https://github.com/rodnansol/spring-configuration-property-documenter/blob/master/docs/modules/ROOT/pages/gradle-plugin.adoc). 11 | """ 12 | type = "MARKDOWN" 13 | 14 | metadataInputs { 15 | metadata { 16 | name = "httpexchange-spring-boot-autoconfigure" 17 | input = file("httpexchange-spring-boot-autoconfigure") 18 | excludedGroups = ["Unknown group"] 19 | } 20 | } 21 | 22 | outputFile = new File("build/configuration-properties.md") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielLiu1123/httpexchange-spring-boot-starter/b377729609fab5e92d20277b647bf6a1d0b04dc1/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /httpexchange-processor/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation("org.springframework:spring-web") 3 | 4 | testImplementation("org.junit.jupiter:junit-jupiter") 5 | testAnnotationProcessor(project(":httpexchange-processor")) 6 | } 7 | 8 | apply from: "${rootDir}/gradle/deploy.gradle" 9 | -------------------------------------------------------------------------------- /httpexchange-processor/src/main/java/io/github/danielliu1123/httpexchange/processor/Finder.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange.processor; 2 | 3 | import java.io.File; 4 | import java.util.Objects; 5 | import java.util.Optional; 6 | import lombok.experimental.UtilityClass; 7 | 8 | /** 9 | * @author Freeman 10 | */ 11 | @UtilityClass 12 | class Finder { 13 | 14 | /** 15 | * Find the target file by searching the parent dir recursively. 16 | * 17 | * @param file the file to start searching 18 | * @param targetFileName the target file name 19 | * @return the target file if found, otherwise null 20 | */ 21 | public static File findFile(File file, String targetFileName) { 22 | return findFileRecursively(file, 0, 50, targetFileName); 23 | } 24 | 25 | private static File findFileRecursively(File file, int currentDepth, int maxDepth, String targetFileName) { 26 | if (file == null || currentDepth > maxDepth) { 27 | return null; 28 | } 29 | 30 | // Check if the current file (or directory) is the target file 31 | if (file.isFile() && Objects.equals(file.getName(), targetFileName)) { 32 | return file; 33 | } 34 | 35 | // If it's a directory, check if the target file is directly inside it 36 | if (file.isDirectory()) { 37 | File targetFile = Optional.ofNullable(file.listFiles((dir, name) -> Objects.equals(name, targetFileName))) 38 | .filter(files -> files.length > 0) 39 | .map(files -> files[0]) 40 | .filter(File::isFile) 41 | .orElse(null); 42 | if (targetFile != null) { 43 | return targetFile; 44 | } 45 | } 46 | 47 | // Recursively search in the parent directory 48 | return findFileRecursively(file.getParentFile(), currentDepth + 1, maxDepth, targetFileName); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /httpexchange-processor/src/main/java/io/github/danielliu1123/httpexchange/processor/GeneratedType.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange.processor; 2 | 3 | /** 4 | * @author Freeman 5 | * @since 3.2.3 6 | */ 7 | enum GeneratedType { 8 | INTERFACE, 9 | ABSTRACT_CLASS, 10 | } 11 | -------------------------------------------------------------------------------- /httpexchange-processor/src/main/java/io/github/danielliu1123/httpexchange/processor/ProcessorProperties.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange.processor; 2 | 3 | import java.util.Arrays; 4 | import java.util.List; 5 | import java.util.Optional; 6 | import java.util.Properties; 7 | import org.springframework.util.StringUtils; 8 | 9 | /** 10 | * @author Freeman 11 | */ 12 | record ProcessorProperties( 13 | boolean enabled, 14 | String prefix, 15 | String suffix, 16 | GeneratedType generatedType, 17 | List packages, 18 | String outputSubpackage) { 19 | 20 | public static ProcessorProperties from(Properties properties) { 21 | boolean enabled = Optional.ofNullable(properties.getProperty("enabled")) 22 | .map(Boolean::parseBoolean) 23 | .orElse(true); 24 | String prefix = Optional.ofNullable(properties.getProperty("prefix")).orElse(""); 25 | String suffix = Optional.ofNullable(properties.getProperty("suffix")).orElse(""); 26 | GeneratedType generatedType = Optional.ofNullable(properties.getProperty("generatedType")) 27 | .filter(StringUtils::hasText) 28 | .map(String::toUpperCase) 29 | .map(GeneratedType::valueOf) 30 | .orElse(GeneratedType.ABSTRACT_CLASS); 31 | List packages = Optional.ofNullable(properties.getProperty("packages")).stream() 32 | .map(list -> list.split(",")) 33 | .flatMap(Arrays::stream) 34 | .map(String::trim) 35 | .filter(StringUtils::hasText) 36 | .toList(); 37 | String outputSubpackage = 38 | Optional.ofNullable(properties.getProperty("outputSubpackage")).orElse(""); 39 | return new ProcessorProperties(enabled, prefix, suffix, generatedType, packages, outputSubpackage); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /httpexchange-processor/src/main/resources/META-INF/gradle/incremental.annotation.processors: -------------------------------------------------------------------------------- 1 | io.github.danielliu1123.httpexchange.processor.ApiBaseProcessor,isolating -------------------------------------------------------------------------------- /httpexchange-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor: -------------------------------------------------------------------------------- 1 | io.github.danielliu1123.httpexchange.processor.ApiBaseProcessor -------------------------------------------------------------------------------- /httpexchange-processor/src/test/java/io/github/danielliu1123/httpexchange/it/normal/Api.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange.it.normal; 2 | 3 | import org.springframework.web.service.annotation.GetExchange; 4 | import org.springframework.web.service.annotation.HttpExchange; 5 | import org.springframework.web.service.annotation.PostExchange; 6 | 7 | /** 8 | * @author Freeman 9 | */ 10 | @HttpExchange("/api") 11 | public interface Api { 12 | 13 | @GetExchange 14 | String get(); 15 | 16 | @PostExchange 17 | default String post() { 18 | throw new UnsupportedOperationException(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /httpexchange-processor/src/test/java/io/github/danielliu1123/httpexchange/it/normal/Api2.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange.it.normal; 2 | 3 | /** 4 | * @author Freeman 5 | */ 6 | public interface Api2 { 7 | 8 | String get(); 9 | } 10 | -------------------------------------------------------------------------------- /httpexchange-processor/src/test/java/io/github/danielliu1123/httpexchange/it/normal/Api3.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange.it.normal; 2 | 3 | import org.springframework.web.service.annotation.HttpExchange; 4 | 5 | /** 6 | * @author Freeman 7 | */ 8 | @HttpExchange("/api") 9 | public interface Api3 { 10 | 11 | String get(); 12 | } 13 | -------------------------------------------------------------------------------- /httpexchange-processor/src/test/java/io/github/danielliu1123/httpexchange/it/normal/Api4.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange.it.normal; 2 | 3 | import org.springframework.web.service.annotation.HttpExchange; 4 | 5 | /** 6 | * @author Freeman 7 | */ 8 | public interface Api4 { 9 | 10 | @HttpExchange("/api") 11 | String get(); 12 | 13 | interface InnerApi { 14 | 15 | @HttpExchange("/innerApi") 16 | String get(); 17 | } 18 | 19 | interface InnerApi2 { 20 | 21 | String get(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /httpexchange-processor/src/test/java/io/github/danielliu1123/httpexchange/it/normal/Api5.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange.it.normal; 2 | 3 | import org.springframework.web.service.annotation.GetExchange; 4 | import org.springframework.web.service.annotation.HttpExchange; 5 | 6 | /** 7 | * @author Freeman 8 | */ 9 | @HttpExchange("/api") 10 | interface Api5 { 11 | 12 | @GetExchange 13 | String get(); 14 | } 15 | -------------------------------------------------------------------------------- /httpexchange-processor/src/test/java/io/github/danielliu1123/httpexchange/it/normal/Class1.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange.it.normal; 2 | 3 | import org.springframework.web.service.annotation.GetExchange; 4 | import org.springframework.web.service.annotation.HttpExchange; 5 | 6 | /** 7 | * @author Freeman 8 | */ 9 | public class Class1 { 10 | 11 | public interface Api6 { 12 | @GetExchange 13 | String get(); 14 | } 15 | 16 | @HttpExchange 17 | public interface Api7 { 18 | String get(); 19 | } 20 | 21 | public interface Api8 { 22 | String get(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /httpexchange-processor/src/test/java/io/github/danielliu1123/httpexchange/it/normal/GenericTypeApi.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange.it.normal; 2 | 3 | import org.springframework.web.service.annotation.GetExchange; 4 | import org.springframework.web.service.annotation.HttpExchange; 5 | 6 | /** 7 | * @author Freeman 8 | */ 9 | @HttpExchange("/GenericTypeApi") 10 | public interface GenericTypeApi { 11 | 12 | @GetExchange 13 | String get(); 14 | 15 | interface InnerInterfaceInGenericTypeApi { 16 | @GetExchange 17 | String get(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /httpexchange-processor/src/test/java/io/github/danielliu1123/httpexchange/it/normal/NormalTest.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange.it.normal; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | 6 | import org.junit.jupiter.api.Test; 7 | 8 | class NormalTest { 9 | 10 | @Test 11 | void testApi() { 12 | assertDoesNotThrow(() -> { 13 | Class.forName("io.github.danielliu1123.httpexchange.it.normal.ApiBase") 14 | .getDeclaredMethod("get"); 15 | }); 16 | assertThrows(NoSuchMethodException.class, () -> { 17 | Class.forName("io.github.danielliu1123.httpexchange.it.normal.ApiBase") 18 | .getDeclaredMethod("post"); 19 | }); 20 | 21 | assertThrows(ClassNotFoundException.class, () -> { 22 | Class.forName("io.github.danielliu1123.httpexchange.it.normal.Api2Base"); 23 | }); 24 | 25 | assertThrows(NoSuchMethodException.class, () -> { 26 | Class.forName("io.github.danielliu1123.httpexchange.it.normal.Api3Base") 27 | .getDeclaredMethod("get"); 28 | }); 29 | 30 | assertDoesNotThrow(() -> { 31 | Class.forName("io.github.danielliu1123.httpexchange.it.normal.Api4Base") 32 | .getDeclaredMethod("get"); 33 | }); 34 | assertDoesNotThrow(() -> { 35 | Class.forName("io.github.danielliu1123.httpexchange.it.normal.InnerApiBase") 36 | .getDeclaredMethod("get"); 37 | }); 38 | assertThrows(ClassNotFoundException.class, () -> { 39 | Class.forName("io.github.danielliu1123.httpexchange.it.normal.InnerApi2Base"); 40 | }); 41 | 42 | assertDoesNotThrow(() -> { 43 | Class.forName("io.github.danielliu1123.httpexchange.it.normal.Api6Base") 44 | .getDeclaredMethod("get"); 45 | }); 46 | assertThrows(NoSuchMethodException.class, () -> { 47 | Class.forName("io.github.danielliu1123.httpexchange.it.normal.Api7Base") 48 | .getDeclaredMethod("get"); 49 | }); 50 | assertThrows(ClassNotFoundException.class, () -> { 51 | Class.forName("io.github.danielliu1123.httpexchange.it.normal.Api8Base"); 52 | }); 53 | } 54 | 55 | @Test 56 | void whenInterfaceIsGeneric_thenNotGenerateBaseClass() { 57 | assertThrows(ClassNotFoundException.class, () -> { 58 | Class.forName("io.github.danielliu1123.httpexchange.it.normal.GenericTypeApiBase"); 59 | }); 60 | } 61 | 62 | @Test 63 | void whenHasInnerInterfaceInGenericType_thenShouldGenerateBaseClass() { 64 | assertDoesNotThrow(() -> { 65 | Class.forName("io.github.danielliu1123.httpexchange.it.normal.InnerInterfaceInGenericTypeApiBase"); 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /httpexchange-processor/src/test/java/io/github/danielliu1123/httpexchange/processor/FinderTest.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange.processor; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertNotNull; 4 | 5 | import java.io.File; 6 | import lombok.SneakyThrows; 7 | import org.junit.jupiter.api.Test; 8 | import org.springframework.core.io.ClassPathResource; 9 | 10 | /** 11 | * {@link Finder} tester. 12 | */ 13 | class FinderTest { 14 | 15 | /** 16 | * {@link Finder#findFile(File, String)} 17 | */ 18 | @Test 19 | @SneakyThrows 20 | void testFindFile() { 21 | ClassPathResource resource = new ClassPathResource("META-INF/services/javax.annotation.processing.Processor"); 22 | File file = Finder.findFile(resource.getFile(), "build.gradle"); 23 | 24 | assertNotNull(file); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | api("org.springframework.boot:spring-boot-autoconfigure") 3 | api("org.springframework:spring-web") 4 | compileOnly("org.springframework:spring-webflux") 5 | compileOnly("org.springframework.cloud:spring-cloud-starter-loadbalancer:${springCloudCommonsVersion}") 6 | 7 | compileOnly("com.github.spotbugs:spotbugs-annotations:${spotbugsAnnotationsVersion}") 8 | 9 | compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") 10 | 11 | // dynamic refresh configuration for exchange clients 12 | compileOnly("org.springframework.cloud:spring-cloud-context:${springCloudCommonsVersion}") 13 | // support @SpringQueryMap 14 | compileOnly("org.springframework.cloud:spring-cloud-openfeign-core:${springCloudOpenFeignVersion}") 15 | 16 | compileOnly("org.projectlombok:lombok") 17 | annotationProcessor("org.projectlombok:lombok") 18 | annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") 19 | 20 | testImplementation("org.springframework:spring-webflux") 21 | testImplementation("org.springframework.boot:spring-boot-starter-test") 22 | 23 | testImplementation("org.springframework.boot:spring-boot-starter-web") 24 | testImplementation("org.springframework.boot:spring-boot-starter-validation") 25 | testImplementation("org.springframework.cloud:spring-cloud-context:${springCloudCommonsVersion}") 26 | testImplementation("org.springframework.cloud:spring-cloud-starter-openfeign:${springCloudOpenFeignVersion}") 27 | } 28 | 29 | apply from: "${rootDir}/gradle/deploy.gradle" 30 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/BeanParam.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | import java.util.Map; 8 | import org.springframework.web.bind.annotation.RequestParam; 9 | 10 | /** 11 | * Convert a Java bean to query parameters. 12 | * 13 | *

Correct usage: 14 | *

{@code
15 |  * @GetExchange
16 |  * User get(@BeanParam User user);
17 |  *
18 |  * @GetExchange
19 |  * User get(@RequestParam Map user);
20 |  * }
21 | * 22 | *

NOTE: if you consider using {@link Map} as a parameter type, you should use {@link RequestParam} instead. 23 | *

Incorrect usage: 24 | *

{@code
25 |  * @GetExchange
26 |  * User get(@BeanParam Map user); // use @RequestParam instead
27 |  * }
28 | * 29 | *

This annotation is equivalent to SpringQueryMap in Spring Cloud OpenFeign. 30 | * 31 | * @author Freeman 32 | * @since 3.1.2 33 | * @see BeanParamArgumentResolver 34 | */ 35 | @Retention(RetentionPolicy.RUNTIME) 36 | @Target({ElementType.PARAMETER}) 37 | public @interface BeanParam {} 38 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/Cache.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static io.github.danielliu1123.httpexchange.HttpExchangeProperties.Channel; 4 | import static io.github.danielliu1123.httpexchange.HttpExchangeProperties.ClientType; 5 | 6 | import java.util.Map; 7 | import java.util.concurrent.ConcurrentHashMap; 8 | import java.util.function.Supplier; 9 | import lombok.experimental.UtilityClass; 10 | import org.springframework.aop.framework.AopProxyUtils; 11 | 12 | /** 13 | * @author Freeman 14 | */ 15 | @UtilityClass 16 | class Cache { 17 | /** 18 | * Cache all clients. 19 | */ 20 | private static final Map, Object> classToInstance = new ConcurrentHashMap<>(); 21 | /** 22 | * {@link ClientId} to Http client instance. 23 | */ 24 | private static final Map clientIdToHttpClient = new ConcurrentHashMap<>(); 25 | 26 | /** 27 | * Add a client to cache. 28 | * 29 | * @param client client 30 | */ 31 | public static void addClient(Object client) { 32 | classToInstance.put(AopProxyUtils.ultimateTargetClass(client), client); 33 | } 34 | 35 | /** 36 | * Get clients. 37 | * 38 | * @return unmodifiable map 39 | */ 40 | public static Map, Object> getClients() { 41 | return Map.copyOf(classToInstance); 42 | } 43 | 44 | @SuppressWarnings("unchecked") 45 | public static T getHttpClient(ClientId clientId, Supplier supplier) { 46 | return (T) clientIdToHttpClient.computeIfAbsent(clientId, k -> supplier.get()); 47 | } 48 | 49 | /** 50 | * Clear cache. 51 | */ 52 | public static void clear() { 53 | classToInstance.clear(); 54 | clientIdToHttpClient.clear(); 55 | } 56 | 57 | record ClientId(Channel channel, ClientType clientType) {} 58 | } 59 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/Checker.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static io.github.danielliu1123.httpexchange.Util.nameMatch; 4 | 5 | import java.util.List; 6 | import java.util.Set; 7 | import lombok.experimental.UtilityClass; 8 | import lombok.extern.slf4j.Slf4j; 9 | 10 | /** 11 | * @author Freeman 12 | */ 13 | @Slf4j 14 | @UtilityClass 15 | class Checker { 16 | 17 | public static void checkUnusedConfig(HttpExchangeProperties properties) { 18 | // Identify the configuration items that are not taking effect and print warning messages. 19 | Set> classes = Cache.getClients().keySet(); 20 | 21 | List channels = properties.getChannels(); 22 | 23 | for (int i = 0; i < channels.size(); i++) { 24 | HttpExchangeProperties.Channel channel = channels.get(i); 25 | 26 | checkClassesConfiguration(classes, i, channel); 27 | 28 | checkClientsConfiguration(classes, i, channel); 29 | } 30 | } 31 | 32 | private static void checkClassesConfiguration( 33 | Set> classes, int i, HttpExchangeProperties.Channel channel) { 34 | int s = channel.getClasses().size(); 35 | for (int j = 0; j < s; j++) { 36 | Class clazz = channel.getClasses().get(j); 37 | if (classes.stream().noneMatch(clazz::isAssignableFrom)) { 38 | log.warn( 39 | "The configuration '{}.channels[{}].clients[{}]={}' is ineffective and should be removed", 40 | HttpExchangeProperties.PREFIX, 41 | i, 42 | j, 43 | clazz.getCanonicalName()); 44 | } 45 | } 46 | } 47 | 48 | private static void checkClientsConfiguration( 49 | Set> classes, int i, HttpExchangeProperties.Channel channel) { 50 | int size = channel.getClients().size(); 51 | for (int j = 0; j < size; j++) { 52 | String name = channel.getClients().get(j); 53 | if (!nameMatch(name, classes)) { 54 | log.warn( 55 | "The configuration '{}.channels[{}].clients[{}]={}' is ineffective and should be removed", 56 | HttpExchangeProperties.PREFIX, 57 | i, 58 | j, 59 | name); 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/EnableExchangeClients.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | import org.springframework.context.annotation.Import; 8 | import org.springframework.core.annotation.AliasFor; 9 | import org.springframework.web.service.annotation.HttpExchange; 10 | 11 | /** 12 | * Enable auto scan {@link HttpExchange} interfaces, and register them as {@link HttpExchange} client beans. 13 | * 14 | *

Examples: 15 | * 16 | *

Scan the package of the annotated class: 17 | *

{@code
18 |  * @EnableExchangeClients
19 |  * }
20 | * 21 | *

Scan the package of the specified {@link #basePackages} (not include the package of annotated class): 22 | *

{@code
23 |  * @EnableExchangeClients("org.my.pkg")
24 |  * }
25 | * 26 | *

Register specified clients (don't scan any packages): 27 | *

{@code
28 |  * @EnableExchangeClients(clients = {FooApi.class})
29 |  * }
30 | * 31 | *

Scan specified {@link #basePackages} and register specified clients: 32 | *

{@code
33 |  * @EnableExchangeClients(basePackages = "org.my.pkg", clients = {FooApi.class})
34 |  * }
35 | * 36 | *

NOTE: scanning packages will increase the program startup time, you can sacrifice some flexibility and use the {@link #clients} attribute to specify the interfaces that need to be registered as beans. 37 | * 38 | * @author Freeman 39 | */ 40 | @Retention(RetentionPolicy.RUNTIME) 41 | @Target({ElementType.TYPE}) 42 | @Import({ExchangeClientsRegistrar.class}) 43 | public @interface EnableExchangeClients { 44 | /** 45 | * Scan base packages. 46 | * 47 | *

Scan the package of the annotated class by default. 48 | *

Alias for the {@link #basePackages()} attribute. 49 | * 50 | * @return the base packages to scan 51 | */ 52 | @AliasFor("basePackages") 53 | String[] value() default {}; 54 | 55 | /** 56 | * Alias for the {@link #value()} attribute. 57 | * 58 | * @return the base packages to scan 59 | * @see #value() 60 | */ 61 | @AliasFor("value") 62 | String[] basePackages() default {}; 63 | 64 | /** 65 | * The classes to register as HttpExchange client beans. 66 | * 67 | *

clients and {@link #basePackages} can be used together. 68 | * 69 | * @return the interfaces to register as HttpExchange client beans. 70 | */ 71 | Class[] clients() default {}; 72 | } 73 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/ExchangeClientsRegistrar.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import jakarta.annotation.Nonnull; 4 | import java.util.List; 5 | import java.util.Map; 6 | import java.util.Optional; 7 | import org.springframework.beans.factory.support.BeanDefinitionRegistry; 8 | import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; 9 | import org.springframework.core.type.AnnotationMetadata; 10 | import org.springframework.util.ClassUtils; 11 | 12 | /** 13 | * @author Freeman 14 | */ 15 | class ExchangeClientsRegistrar implements ImportBeanDefinitionRegistrar { 16 | 17 | @Override 18 | public void registerBeanDefinitions( 19 | @Nonnull AnnotationMetadata metadata, @Nonnull BeanDefinitionRegistry registry) { 20 | Map attrs = Optional.ofNullable( 21 | metadata.getAnnotationAttributes(EnableExchangeClients.class.getName())) 22 | .orElse(Map.of()); 23 | 24 | // Shouldn't scan basePackages when using 'clients' property 25 | // see https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/issues/1 26 | 27 | String[] basePackages = (String[]) attrs.getOrDefault("value", new String[0]); 28 | Class[] clientClasses = (Class[]) attrs.getOrDefault("clients", new Class[0]); 29 | 30 | HttpClientBeanDefinitionRegistry.scanInfo.clients.addAll(List.of(clientClasses)); 31 | HttpClientBeanDefinitionRegistry.scanInfo.basePackages.addAll(List.of(basePackages)); 32 | 33 | if (basePackages.length == 0 && clientClasses.length == 0) { 34 | // @EnableExchangeClients 35 | // should scan the package of the annotated class 36 | HttpClientBeanDefinitionRegistry.scanInfo.basePackages.add( 37 | ClassUtils.getPackageName(metadata.getClassName())); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/HttpClientBeanDefinitionRegistry.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import jakarta.annotation.Nonnull; 4 | import org.springframework.beans.BeansException; 5 | import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; 6 | import org.springframework.beans.factory.support.BeanDefinitionRegistry; 7 | import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; 8 | import org.springframework.context.EnvironmentAware; 9 | import org.springframework.core.env.Environment; 10 | import org.springframework.util.ObjectUtils; 11 | 12 | /** 13 | * @author Freeman 14 | */ 15 | class HttpClientBeanDefinitionRegistry implements BeanDefinitionRegistryPostProcessor, EnvironmentAware { 16 | 17 | static final ScanInfo scanInfo = new ScanInfo(); 18 | 19 | private Environment environment; 20 | 21 | @Override 22 | public void setEnvironment(@Nonnull Environment environment) { 23 | this.environment = environment; 24 | } 25 | 26 | @Override 27 | public void postProcessBeanDefinitionRegistry(@Nonnull BeanDefinitionRegistry registry) throws BeansException { 28 | boolean enabled = environment.getProperty(HttpExchangeProperties.PREFIX + ".enabled", Boolean.class, true); 29 | if (!enabled) { 30 | return; 31 | } 32 | registerBeans(new HttpClientBeanRegistrar(registry, environment)); 33 | } 34 | 35 | /*private*/ void registerBeans(HttpClientBeanRegistrar registrar) { 36 | var properties = Util.getProperties(environment); 37 | scanInfo.basePackages.addAll(properties.getBasePackages()); 38 | if (!ObjectUtils.isEmpty(scanInfo.basePackages)) { 39 | registrar.register(scanInfo.basePackages.toArray(String[]::new)); 40 | } 41 | scanInfo.clients.addAll(properties.getClients()); 42 | if (!ObjectUtils.isEmpty(scanInfo.clients)) { 43 | registrar.register(scanInfo.clients.toArray(Class[]::new)); 44 | } 45 | } 46 | 47 | @Override 48 | public void postProcessBeanFactory(@Nonnull ConfigurableListableBeanFactory beanFactory) throws BeansException { 49 | // nothing to do 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/HttpClientCustomizer.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import org.springframework.web.client.RestClient; 4 | import org.springframework.web.reactive.function.client.WebClient; 5 | 6 | /** 7 | * {@link HttpClientCustomizer} customizes the configuration of the http client based on the given {@link HttpExchangeProperties.Channel}. 8 | * 9 | * @author Freeman 10 | * @see ExchangeClientCreator#buildRestClient(HttpExchangeProperties.Channel) 11 | * @see ExchangeClientCreator#buildWebClient(HttpExchangeProperties.Channel) 12 | * @since 3.2.4 13 | */ 14 | public sealed interface HttpClientCustomizer { 15 | 16 | /** 17 | * Customize the client builder with the given config. 18 | * 19 | * @param client the http client to customize 20 | * @param channel the current channel config to use 21 | */ 22 | void customize(T client, HttpExchangeProperties.Channel channel); 23 | 24 | non-sealed interface RestClientCustomizer extends HttpClientCustomizer {} 25 | 26 | non-sealed interface WebClientCustomizer extends HttpClientCustomizer {} 27 | } 28 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/HttpExchangeAutoConfiguration.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static io.github.danielliu1123.httpexchange.Checker.checkUnusedConfig; 4 | 5 | import org.springframework.beans.factory.DisposableBean; 6 | import org.springframework.beans.factory.InitializingBean; 7 | import org.springframework.beans.factory.support.BeanDefinitionRegistry; 8 | import org.springframework.boot.CommandLineRunner; 9 | import org.springframework.boot.SpringBootVersion; 10 | import org.springframework.boot.autoconfigure.AutoConfiguration; 11 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 12 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 13 | import org.springframework.boot.context.event.ApplicationReadyEvent; 14 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 15 | import org.springframework.context.ApplicationListener; 16 | import org.springframework.context.annotation.Bean; 17 | 18 | /** 19 | * Http Exchange Auto Configuration. 20 | * 21 | * @author Freeman 22 | */ 23 | @AutoConfiguration 24 | @ConditionalOnProperty(prefix = HttpExchangeProperties.PREFIX, name = "enabled", matchIfMissing = true) 25 | @EnableConfigurationProperties(HttpExchangeProperties.class) 26 | public class HttpExchangeAutoConfiguration 27 | implements DisposableBean, InitializingBean, ApplicationListener { 28 | 29 | @Override 30 | public void afterPropertiesSet() throws Exception { 31 | checkVersion(); 32 | } 33 | 34 | @Override 35 | public void onApplicationEvent(ApplicationReadyEvent event) { 36 | var bf = event.getApplicationContext().getBeanFactory(); 37 | if (bf instanceof BeanDefinitionRegistry bdr) { 38 | HttpClientBeanRegistrar.clearBeanDefinitionCache(bdr); 39 | } 40 | } 41 | 42 | @Bean 43 | static HttpClientBeanDefinitionRegistry httpClientBeanDefinitionRegistry() { 44 | return new HttpClientBeanDefinitionRegistry(); 45 | } 46 | 47 | @Bean 48 | @ConditionalOnMissingBean 49 | public BeanParamArgumentResolver beanParamArgumentResolver(HttpExchangeProperties properties) { 50 | return new BeanParamArgumentResolver(properties); 51 | } 52 | 53 | @Bean 54 | @ConditionalOnProperty( 55 | prefix = HttpExchangeProperties.PREFIX, 56 | name = "warn-unused-config-enabled", 57 | matchIfMissing = true) 58 | public CommandLineRunner httpExchangeStarterUnusedConfigChecker(HttpExchangeProperties properties) { 59 | return args -> checkUnusedConfig(properties); 60 | } 61 | 62 | @Override 63 | public void destroy() { 64 | Cache.clear(); 65 | HttpClientBeanDefinitionRegistry.scanInfo.clear(); 66 | } 67 | 68 | // AOT support 69 | 70 | @Bean 71 | static HttpExchangeBeanFactoryInitializationAotProcessor 72 | httpExchangeStarterHttpExchangeBeanFactoryInitializationAotProcessor() { 73 | return new HttpExchangeBeanFactoryInitializationAotProcessor(); 74 | } 75 | 76 | private static void checkVersion() { 77 | // Spring Boot 3.5.0 introduced extensive internal refactoring. To reduce maintenance costs, backward 78 | // compatibility has been dropped. 79 | // If you're using a Spring Boot version < 3.5.0, please stick with version 3.4.x. 80 | var version = SpringBootVersion.getVersion(); 81 | String requiredVersion = "3.5.0"; 82 | if (version.compareTo(requiredVersion) < 0) { 83 | throw new SpringBootVersionIncompatibleException(version, requiredVersion); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/HttpExchangeRuntimeHintsRegistrar.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.springframework.aot.hint.MemberCategory.DECLARED_FIELDS; 4 | 5 | import org.springframework.aot.hint.ReflectionHints; 6 | import org.springframework.aot.hint.RuntimeHints; 7 | import org.springframework.aot.hint.RuntimeHintsRegistrar; 8 | import org.springframework.lang.Nullable; 9 | import org.springframework.web.service.invoker.HttpServiceProxyFactory; 10 | 11 | /** 12 | * @author Freeman 13 | * @since 3.2.2 14 | */ 15 | class HttpExchangeRuntimeHintsRegistrar implements RuntimeHintsRegistrar { 16 | 17 | @Override 18 | public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) { 19 | ReflectionHints reflection = hints.reflection(); 20 | 21 | reflection.registerType(HttpServiceProxyFactory.Builder.class, DECLARED_FIELDS); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/HttpExchangeUtil.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static io.github.danielliu1123.httpexchange.Util.isHttpExchangeInterface; 4 | import static org.springframework.core.NativeDetector.inNativeImage; 5 | 6 | import lombok.experimental.UtilityClass; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.aop.scope.ScopedProxyUtils; 9 | import org.springframework.beans.factory.config.BeanDefinitionHolder; 10 | import org.springframework.beans.factory.support.AbstractBeanDefinition; 11 | import org.springframework.beans.factory.support.BeanDefinitionBuilder; 12 | import org.springframework.beans.factory.support.BeanDefinitionOverrideException; 13 | import org.springframework.beans.factory.support.BeanDefinitionReaderUtils; 14 | import org.springframework.beans.factory.support.DefaultListableBeanFactory; 15 | import org.springframework.boot.context.properties.bind.Binder; 16 | import org.springframework.context.ApplicationContextInitializer; 17 | import org.springframework.context.aot.AbstractAotProcessor; 18 | import org.springframework.core.env.Environment; 19 | import org.springframework.util.Assert; 20 | import org.springframework.util.ClassUtils; 21 | import org.springframework.web.service.annotation.HttpExchange; 22 | 23 | /** 24 | * @author Freeman 25 | */ 26 | @Slf4j 27 | @UtilityClass 28 | public class HttpExchangeUtil { 29 | 30 | private static final boolean SPRING_CLOUD_CONTEXT_PRESENT = 31 | ClassUtils.isPresent("org.springframework.cloud.context.scope.refresh.RefreshScope", null); 32 | 33 | /** 34 | * Register a {@link HttpExchange} annotated interface as a Spring bean. 35 | * 36 | *

NOTE: The second parameter {@code environment} is used to build {@link HttpExchangeProperties} if it can't be found in the bean factory (early stage), 37 | * do NOT try to omit this parameter, {@link Environment} can't get from {@link DefaultListableBeanFactory} on every early stage (e.g., {@link ApplicationContextInitializer}). 38 | * 39 | * @param beanFactory {@link DefaultListableBeanFactory} 40 | * @param environment {@link Environment} 41 | * @param clz {@link HttpExchange} annotated interface 42 | */ 43 | public static void registerHttpExchangeBean( 44 | DefaultListableBeanFactory beanFactory, Environment environment, Class clz) { 45 | Assert.isTrue(isHttpExchangeInterface(clz), () -> clz + " is not a HttpExchange client"); 46 | 47 | AbstractBeanDefinition beanDefinition = BeanDefinitionBuilder.genericBeanDefinition( 48 | clz, () -> new ExchangeClientCreator(beanFactory, clz).create()) 49 | .getBeanDefinition(); 50 | 51 | beanDefinition.setLazyInit(true); 52 | beanDefinition.setPrimary(true); 53 | beanDefinition.setResourceDescription("registered by httpexchange-spring-boot-starter"); 54 | 55 | String className = clz.getName(); 56 | try { 57 | if (getRefresh(environment).isEnabled() 58 | && SPRING_CLOUD_CONTEXT_PRESENT 59 | && !isAotProcessing() // Make 'aotClasses' task work 60 | && !inNativeImage() // Refresh scope is not supported with native images, see 61 | // https://docs.spring.io/spring-cloud-config/reference/server/aot-and-native-image-support.html 62 | ) { 63 | beanDefinition.setScope("refresh"); 64 | BeanDefinitionHolder scopedProxy = ScopedProxyUtils.createScopedProxy( 65 | new BeanDefinitionHolder(beanDefinition, className), beanFactory, false); 66 | BeanDefinitionReaderUtils.registerBeanDefinition(scopedProxy, beanFactory); 67 | } else { 68 | BeanDefinitionReaderUtils.registerBeanDefinition( 69 | new BeanDefinitionHolder(beanDefinition, className), beanFactory); 70 | } 71 | } catch (BeanDefinitionOverrideException ignore) { 72 | // clients are included in base packages 73 | log.warn( 74 | "Remove @HttpExchanges client '{}' from 'clients' property; it's already in base packages", 75 | className); 76 | } 77 | } 78 | 79 | /** 80 | * @see AbstractAotProcessor#process() 81 | */ 82 | private static boolean isAotProcessing() { 83 | return Boolean.getBoolean("spring.aot.processing"); 84 | } 85 | 86 | private static HttpExchangeProperties.Refresh getRefresh(Environment environment) { 87 | return Binder.get(environment) 88 | .bind(HttpExchangeProperties.Refresh.PREFIX, HttpExchangeProperties.Refresh.class) 89 | .orElseGet(HttpExchangeProperties.Refresh::new); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/HttpServiceProxyFactoryCustomizer.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import org.springframework.web.service.invoker.HttpServiceProxyFactory; 4 | 5 | /** 6 | * Callback interface that can be used to customize a {@link HttpServiceProxyFactory.Builder}. 7 | * 8 | * @author Freeman 9 | * @since 3.2.2 10 | */ 11 | @FunctionalInterface 12 | public interface HttpServiceProxyFactoryCustomizer { 13 | 14 | /** 15 | * Customize the {@link HttpServiceProxyFactory.Builder}. 16 | * 17 | * @param builder the builder to customize 18 | */ 19 | void customize(HttpServiceProxyFactory.Builder builder); 20 | } 21 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/ScanInfo.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import java.util.LinkedHashSet; 4 | 5 | /** 6 | * @author Freeman 7 | */ 8 | final class ScanInfo { 9 | public final LinkedHashSet basePackages = new LinkedHashSet<>(); 10 | public final LinkedHashSet> clients = new LinkedHashSet<>(); 11 | 12 | public void clear() { 13 | basePackages.clear(); 14 | clients.clear(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/SpringBootVersionIncompatibleException.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | /** 4 | * Exception thrown when the Spring Boot version is incompatible with the httpexchange-spring-boot-starter. 5 | * 6 | * @author Freeman 7 | */ 8 | class SpringBootVersionIncompatibleException extends RuntimeException { 9 | 10 | private final String currentVersion; 11 | private final String requiredVersion; 12 | 13 | /** 14 | * Constructs a new exception with the current and required Spring Boot versions. 15 | * 16 | * @param currentVersion the current Spring Boot version 17 | * @param requiredVersion the minimum required Spring Boot version 18 | */ 19 | public SpringBootVersionIncompatibleException(String currentVersion, String requiredVersion) { 20 | super("Spring Boot version " + currentVersion + " is incompatible with httpexchange-spring-boot-starter. " 21 | + "Minimum required version is " + requiredVersion); 22 | this.currentVersion = currentVersion; 23 | this.requiredVersion = requiredVersion; 24 | } 25 | 26 | /** 27 | * Gets the current Spring Boot version. 28 | * 29 | * @return the current Spring Boot version 30 | */ 31 | public String getCurrentVersion() { 32 | return currentVersion; 33 | } 34 | 35 | /** 36 | * Gets the required Spring Boot version. 37 | * 38 | * @return the required Spring Boot version 39 | */ 40 | public String getRequiredVersion() { 41 | return requiredVersion; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/SpringBootVersionIncompatibleFailureAnalyzer.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 4 | import org.springframework.boot.diagnostics.AbstractFailureAnalyzer; 5 | import org.springframework.boot.diagnostics.FailureAnalysis; 6 | 7 | /** 8 | * A {@link org.springframework.boot.diagnostics.FailureAnalyzer} that analyzes 9 | * {@link SpringBootVersionIncompatibleException}. 10 | * 11 | * @author Freeman 12 | */ 13 | class SpringBootVersionIncompatibleFailureAnalyzer 14 | extends AbstractFailureAnalyzer { 15 | 16 | @Override 17 | @SuppressFBWarnings("VA_FORMAT_STRING_USES_NEWLINE") 18 | protected FailureAnalysis analyze(Throwable rootFailure, SpringBootVersionIncompatibleException cause) { 19 | return new FailureAnalysis( 20 | "The current version of httpexchange-spring-boot-starter requires Spring Boot %s or higher, but found %s." 21 | .formatted(cause.getRequiredVersion(), cause.getCurrentVersion()), 22 | """ 23 | If you're using a Spring Boot version < %s, please stick with httpexchange-spring-boot-starter version 3.4.x. 24 | 25 | Spring Boot 3.5.0 introduced extensive internal refactoring. To reduce maintenance costs, backward compatibility has been dropped. 26 | 27 | For more information, see: https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/releases/tag/v3.5.0 28 | """ 29 | .formatted(cause.getRequiredVersion()), 30 | cause); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/UrlPlaceholderStringValueResolver.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import jakarta.annotation.Nonnull; 4 | import jakarta.annotation.Nullable; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.springframework.core.env.Environment; 8 | import org.springframework.util.StringValueResolver; 9 | 10 | /** 11 | * Support for resolving placeholders in {@link String} values. 12 | * 13 | * @author Freeman 14 | */ 15 | public class UrlPlaceholderStringValueResolver implements StringValueResolver { 16 | private static final Logger log = LoggerFactory.getLogger(UrlPlaceholderStringValueResolver.class); 17 | 18 | private final Environment environment; 19 | 20 | @Nullable 21 | private final StringValueResolver delegate; 22 | 23 | public UrlPlaceholderStringValueResolver(Environment environment, @Nullable StringValueResolver delegate) { 24 | this.environment = environment; 25 | this.delegate = delegate; 26 | } 27 | 28 | @Override 29 | public String resolveStringValue(@Nonnull String strVal) { 30 | String resolved = strVal; 31 | try { 32 | resolved = environment.resolvePlaceholders(strVal); 33 | } catch (Exception e) { 34 | log.warn("Placeholders in '{}' could not be resolved", strVal, e); 35 | } 36 | return delegate != null ? delegate.resolveStringValue(resolved) : resolved; 37 | } 38 | 39 | /** 40 | * Create a new {@link UrlPlaceholderStringValueResolver} instance. 41 | * 42 | * @param environment the environment 43 | * @param delegate {@link StringValueResolver} 44 | * @return {@link UrlPlaceholderStringValueResolver} 45 | */ 46 | public static UrlPlaceholderStringValueResolver create( 47 | Environment environment, @Nullable StringValueResolver delegate) { 48 | return new UrlPlaceholderStringValueResolver(environment, delegate); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | org.springframework.boot.diagnostics.FailureAnalyzer=\ 2 | io.github.danielliu1123.httpexchange.SpringBootVersionIncompatibleFailureAnalyzer 3 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/resources/META-INF/spring/aot.factories: -------------------------------------------------------------------------------- 1 | org.springframework.aot.hint.RuntimeHintsRegistrar=\ 2 | io.github.danielliu1123.httpexchange.HttpExchangeRuntimeHintsRegistrar -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports: -------------------------------------------------------------------------------- 1 | io.github.danielliu1123.httpexchange.HttpExchangeAutoConfiguration 2 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/main/resources/application-http-exchange-statrer-example.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | http: 3 | client: 4 | read-timeout: 10s 5 | connect-timeout: 1s 6 | ssl: 7 | bundle: bundle1 8 | http-exchange: 9 | base-packages: [ com.example.api ] 10 | base-url: http://api-gateway 11 | bean-to-query-enabled: false 12 | request-mapping-support-enabled: false 13 | headers: 14 | - key: X-App-Name 15 | values: ${spring.application.name} 16 | refresh: 17 | enabled: true 18 | client-type: rest_client 19 | warn-unused-config-enabled: true 20 | loadbalancer-enabled: true 21 | channels: 22 | - base-url: http://order 23 | ssl: 24 | bundle: bundle2 25 | headers: 26 | - key: X-Key 27 | values: [ value1, value2 ] 28 | clients: 29 | - com.example.api.OrderItemApi 30 | - com.**.*Api 31 | - com.example.** 32 | - base-url: user 33 | classes: 34 | - com.example.api.UserApi 35 | - com.example.api.UserDetailApi 36 | client-type: rest_client 37 | connect-timeout: 1000 38 | read-timeout: 5000 39 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/Post.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123; 2 | 3 | public record Post(Integer id, String title) {} 4 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/BasePackagesConfigTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThatCode; 4 | 5 | import io.github.danielliu1123.order.api.OrderApi; 6 | import io.github.danielliu1123.user.api.DummyApi; 7 | import io.github.danielliu1123.user.api.UserApi; 8 | import io.github.danielliu1123.user.api.UserHobbyApi; 9 | import org.junit.jupiter.params.ParameterizedTest; 10 | import org.junit.jupiter.params.provider.ValueSource; 11 | import org.springframework.beans.factory.NoSuchBeanDefinitionException; 12 | import org.springframework.boot.WebApplicationType; 13 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 14 | import org.springframework.boot.builder.SpringApplicationBuilder; 15 | import org.springframework.context.ConfigurableApplicationContext; 16 | import org.springframework.context.annotation.Configuration; 17 | 18 | /** 19 | * @author Freeman 20 | */ 21 | class BasePackagesConfigTests { 22 | 23 | @ParameterizedTest 24 | @ValueSource( 25 | strings = { 26 | "io.github", 27 | "io.github.danielliu1123.**.api", 28 | "**.api", 29 | }) 30 | void testBasePackages(String pkg) { 31 | try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(Cfg.class) 32 | .web(WebApplicationType.NONE) 33 | .properties(HttpExchangeProperties.PREFIX + ".base-packages=" + pkg) 34 | .run()) { 35 | 36 | assertThatCode(() -> ctx.getBean(HttpClientBeanDefinitionRegistry.class)) 37 | .doesNotThrowAnyException(); 38 | 39 | assertThatCode(() -> ctx.getBean(OrderApi.class)).doesNotThrowAnyException(); 40 | assertThatCode(() -> ctx.getBean(UserApi.class)).doesNotThrowAnyException(); 41 | assertThatCode(() -> ctx.getBean(UserHobbyApi.class)).doesNotThrowAnyException(); 42 | assertThatCode(() -> ctx.getBean(DummyApi.class)).isInstanceOf(NoSuchBeanDefinitionException.class); 43 | } 44 | } 45 | 46 | @Configuration(proxyBeanMethods = false) 47 | @EnableAutoConfiguration 48 | static class Cfg {} 49 | } 50 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/BaseUrlTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThatCode; 4 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 5 | 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 8 | import org.springframework.boot.builder.SpringApplicationBuilder; 9 | import org.springframework.context.annotation.Configuration; 10 | import org.springframework.web.bind.annotation.PathVariable; 11 | import org.springframework.web.bind.annotation.RestController; 12 | import org.springframework.web.client.ResourceAccessException; 13 | import org.springframework.web.service.annotation.GetExchange; 14 | 15 | /** 16 | * @author Freeman 17 | */ 18 | class BaseUrlTests { 19 | 20 | @Test 21 | void testDefaultBaseUrl() { 22 | int port = findAvailableTcpPort(); 23 | try (var ctx = new SpringApplicationBuilder(BaseUrlController.class) 24 | .properties("server.port=" + port) 25 | .properties(HttpExchangeProperties.PREFIX + ".base-url=localhost:" + port) 26 | .run()) { 27 | BaseUrlApi api = ctx.getBean(BaseUrlApi.class); 28 | 29 | assertThatCode(() -> api.delay(10)).doesNotThrowAnyException(); 30 | } 31 | } 32 | 33 | @Test 34 | void testNoBaseUrl() { 35 | int port = findAvailableTcpPort(); 36 | try (var ctx = new SpringApplicationBuilder(BaseUrlController.class) 37 | .properties("server.port=" + port) 38 | .run()) { 39 | BaseUrlApi api = ctx.getBean(BaseUrlApi.class); 40 | 41 | assertThatCode(() -> api.delay(10)) 42 | .isInstanceOf(IllegalArgumentException.class) 43 | .hasMessage("URI with undefined scheme"); 44 | } 45 | } 46 | 47 | @Test 48 | void testBaseUrl_whenClientHasBaseUrl_thenOverrideDefaultBaseUrl() { 49 | int port = findAvailableTcpPort(); 50 | try (var ctx = new SpringApplicationBuilder(BaseUrlController.class) 51 | .properties("server.port=" + port) 52 | .properties(HttpExchangeProperties.PREFIX + ".base-url=localhost:" + port) 53 | .properties(HttpExchangeProperties.PREFIX + ".channels[0].base-url=localhost:" + (port + 1)) 54 | .properties(HttpExchangeProperties.PREFIX + ".channels[0].clients[0]=BaseUrlApi") 55 | .run()) { 56 | BaseUrlApi api = ctx.getBean(BaseUrlApi.class); 57 | 58 | assertThatCode(() -> api.delay(10)).isInstanceOf(ResourceAccessException.class); 59 | } 60 | } 61 | 62 | @Configuration(proxyBeanMethods = false) 63 | @EnableAutoConfiguration 64 | @EnableExchangeClients(clients = BaseUrlApi.class) 65 | @RestController 66 | static class BaseUrlController implements BaseUrlApi { 67 | @Override 68 | public String delay(int delay) { 69 | try { 70 | Thread.sleep(delay); 71 | } catch (InterruptedException e) { 72 | throw new RuntimeException(e); 73 | } 74 | return "delay " + delay; 75 | } 76 | } 77 | 78 | interface BaseUrlApi { 79 | 80 | @GetExchange("/delay/{delay}") 81 | String delay(@PathVariable int delay); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/ClassesConfigTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 5 | 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 8 | import org.springframework.boot.builder.SpringApplicationBuilder; 9 | import org.springframework.context.annotation.Configuration; 10 | import org.springframework.web.bind.annotation.PathVariable; 11 | import org.springframework.web.bind.annotation.RestController; 12 | import org.springframework.web.service.annotation.GetExchange; 13 | import org.springframework.web.service.annotation.HttpExchange; 14 | 15 | /** 16 | * @author Freeman 17 | */ 18 | class ClassesConfigTests { 19 | 20 | @Test 21 | void clientClassConfig() { 22 | int port = findAvailableTcpPort(); 23 | try (var ctx = new SpringApplicationBuilder(FooController.class) 24 | .profiles("ClassesConfigTests") 25 | .properties("server.port=" + port) 26 | .run()) { 27 | 28 | FooApi fooApi = ctx.getBean(FooApi.class); 29 | 30 | assertThat(fooApi.getById("1")).isEqualTo("foo"); 31 | } 32 | } 33 | 34 | @HttpExchange("/foo") 35 | public interface FooApi { 36 | 37 | @GetExchange("/{id}") 38 | String getById(@PathVariable String id); 39 | } 40 | 41 | @Configuration(proxyBeanMethods = false) 42 | @EnableAutoConfiguration 43 | @EnableExchangeClients(clients = FooApi.class) 44 | @RestController 45 | static class FooController implements FooApi { 46 | 47 | @Override 48 | public String getById(String id) { 49 | return "foo"; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/ClientTypeTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 5 | 6 | import org.junit.jupiter.params.ParameterizedTest; 7 | import org.junit.jupiter.params.provider.ValueSource; 8 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 9 | import org.springframework.boot.builder.SpringApplicationBuilder; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.web.bind.annotation.RestController; 12 | import org.springframework.web.service.annotation.GetExchange; 13 | import org.springframework.web.service.annotation.HttpExchange; 14 | 15 | /** 16 | * @author Freeman 17 | */ 18 | class ClientTypeTests { 19 | 20 | @ParameterizedTest 21 | @ValueSource(strings = {"rest_client", "web_client"}) 22 | void testRestClient(String clientType) { 23 | int port = findAvailableTcpPort(); 24 | try (var ctx = new SpringApplicationBuilder(Cfg.class) 25 | .properties("server.port=" + port) 26 | .properties("http-exchange.client-type=" + clientType) 27 | .properties("http-exchange.base-url=localhost:" + port) 28 | .run()) { 29 | 30 | Api api = ctx.getBean(Api.class); 31 | 32 | assertThat(api.hi()).isEqualTo("Hi"); 33 | } 34 | } 35 | 36 | @Configuration(proxyBeanMethods = false) 37 | @EnableAutoConfiguration 38 | @EnableExchangeClients 39 | @RestController 40 | static class Cfg implements Api { 41 | 42 | @Override 43 | public String hi() { 44 | return "Hi"; 45 | } 46 | } 47 | 48 | @HttpExchange 49 | interface Api { 50 | 51 | @GetExchange("/hi") 52 | String hi(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/ClientsConfigTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThatCode; 4 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 5 | 6 | import org.junit.jupiter.params.ParameterizedTest; 7 | import org.junit.jupiter.params.provider.ValueSource; 8 | import org.springframework.boot.WebApplicationType; 9 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 10 | import org.springframework.boot.builder.SpringApplicationBuilder; 11 | import org.springframework.context.annotation.Configuration; 12 | import org.springframework.web.bind.annotation.PathVariable; 13 | import org.springframework.web.service.annotation.GetExchange; 14 | import org.springframework.web.service.annotation.HttpExchange; 15 | 16 | /** 17 | * @author Freeman 18 | */ 19 | class ClientsConfigTests { 20 | 21 | @ParameterizedTest 22 | @ValueSource( 23 | strings = { 24 | "foo-api", 25 | "FoOApI", 26 | "io.github.danielliu1123.httpexchange.ClientsConfigTests.FooApi", 27 | "io.github.danielliu1123.httpexchange.ClientsConfigTests$FooApi", 28 | "com.**", 29 | }) 30 | void notThrow_whenClientMatchesCanonicalClassName(String client) { 31 | int port = findAvailableTcpPort(); 32 | try (var ctx = new SpringApplicationBuilder(Cfg.class) 33 | .web(WebApplicationType.NONE) 34 | .properties("server.port=" + port) 35 | .properties(HttpExchangeProperties.PREFIX + ".channels[0].base-url=${server.port}") 36 | .properties(HttpExchangeProperties.PREFIX + ".channels[0].clients[0]=" + client) 37 | .run()) { 38 | 39 | assertThatCode(() -> ctx.getBean(FooApi.class)).doesNotThrowAnyException(); 40 | } 41 | } 42 | 43 | @HttpExchange("/foo") 44 | public interface FooApi { 45 | 46 | @GetExchange("/{id}") 47 | String getById(@PathVariable String id); 48 | } 49 | 50 | @Configuration(proxyBeanMethods = false) 51 | @EnableAutoConfiguration 52 | @EnableExchangeClients 53 | static class Cfg {} 54 | } 55 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/DynamicRefreshTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.assertj.core.api.Assertions.assertThatCode; 5 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 6 | 7 | import jakarta.validation.ConstraintViolationException; 8 | import jakarta.validation.constraints.Size; 9 | import lombok.SneakyThrows; 10 | import org.junit.jupiter.api.AfterEach; 11 | import org.junit.jupiter.params.ParameterizedTest; 12 | import org.junit.jupiter.params.provider.ValueSource; 13 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 14 | import org.springframework.boot.builder.SpringApplicationBuilder; 15 | import org.springframework.cloud.endpoint.event.RefreshEvent; 16 | import org.springframework.context.annotation.Configuration; 17 | import org.springframework.validation.annotation.Validated; 18 | import org.springframework.web.bind.annotation.GetMapping; 19 | import org.springframework.web.bind.annotation.RequestParam; 20 | import org.springframework.web.bind.annotation.RestController; 21 | import org.springframework.web.service.annotation.GetExchange; 22 | 23 | /** 24 | * @author Freeman 25 | */ 26 | class DynamicRefreshTests { 27 | 28 | @AfterEach 29 | void reset() { 30 | System.clearProperty("http-exchange.base-url"); 31 | } 32 | 33 | @ParameterizedTest 34 | @ValueSource(strings = {"REST_CLIENT"}) 35 | void testDynamicRefresh(String clientType) { 36 | int port = findAvailableTcpPort(); 37 | try (var ctx = new SpringApplicationBuilder(Cfg.class) 38 | .properties("server.port=" + port) 39 | .properties("http-exchange.base-url=http://localhost:" + port) 40 | .properties("http-exchange.client-type=" + clientType) 41 | .properties("http-exchange.refresh.enabled=true") 42 | .run()) { 43 | 44 | // Two beans: api bean, proxied api bean 45 | assertThat(ctx.getBeanProvider(FooApi.class)).hasSize(2); 46 | assertThat(ctx.getBeanProvider(BarApi.class)).hasSize(2); 47 | assertThat(ctx.getBeanProvider(BazApi.class)).hasSize(2); 48 | 49 | FooApi fooApi = ctx.getBean(FooApi.class); 50 | BarApi barApi = ctx.getBean(BarApi.class); 51 | BazApi bazApi = ctx.getBean(BazApi.class); 52 | 53 | assertThat(fooApi.get()).isEqualTo("OK"); 54 | assertThat(barApi.get()).isEqualTo("OK"); 55 | assertThat(bazApi.get("aaaaa")).isEqualTo("OK"); 56 | assertThatCode(() -> bazApi.get("aaaaaa")) 57 | .isInstanceOf(ConstraintViolationException.class) 58 | .hasMessageContaining("size must be between 0 and 5"); 59 | 60 | System.setProperty("http-exchange.base-url", "http://localhost:" + port + "/v2"); 61 | ctx.publishEvent(new RefreshEvent(ctx, null, null)); 62 | 63 | // base-url changed 64 | assertThat(fooApi.get()).isEqualTo("OK v2"); 65 | assertThat(barApi.get()).isEqualTo("OK v2"); 66 | assertThat(bazApi.get("aaaaa")).isEqualTo("OK v2"); 67 | assertThatCode(() -> bazApi.get("aaaaaa")) 68 | .isInstanceOf(ConstraintViolationException.class) 69 | .hasMessageContaining("size must be between 0 and 5"); 70 | } 71 | } 72 | 73 | @Configuration(proxyBeanMethods = false) 74 | @EnableAutoConfiguration 75 | @EnableExchangeClients(clients = {FooApi.class, BarApi.class, BazApi.class}) 76 | @RestController 77 | static class Cfg { 78 | 79 | @GetMapping("/get") 80 | @SneakyThrows 81 | public String get() { 82 | Thread.sleep(10); 83 | return "OK"; 84 | } 85 | 86 | @GetMapping("/v2/get") 87 | @SneakyThrows 88 | public String getV2() { 89 | Thread.sleep(10); 90 | return "OK v2"; 91 | } 92 | } 93 | 94 | interface FooApi { 95 | 96 | @GetExchange("/get") 97 | String get(); 98 | } 99 | 100 | interface BarApi { 101 | 102 | @GetExchange("/get") 103 | String get(); 104 | } 105 | 106 | @Validated 107 | interface BazApi { 108 | 109 | @GetExchange("/get") 110 | String get(@RequestParam @Size(max = 5) String str); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/EnabledTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThatCode; 4 | 5 | import io.github.danielliu1123.Post; 6 | import java.util.List; 7 | import org.junit.jupiter.api.Test; 8 | import org.springframework.beans.factory.NoSuchBeanDefinitionException; 9 | import org.springframework.boot.WebApplicationType; 10 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 11 | import org.springframework.boot.builder.SpringApplicationBuilder; 12 | import org.springframework.context.ConfigurableApplicationContext; 13 | import org.springframework.context.annotation.Configuration; 14 | import org.springframework.web.service.annotation.GetExchange; 15 | 16 | /** 17 | * @author Freeman 18 | */ 19 | class EnabledTests { 20 | 21 | @Test 22 | void testDisabled() { 23 | try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(EnabledConfig.class) 24 | .web(WebApplicationType.NONE) 25 | .properties(HttpExchangeProperties.PREFIX + ".enabled=false") 26 | .run()) { 27 | 28 | assertThatCode(() -> ctx.getBean(EnabledApi.class)).isInstanceOf(NoSuchBeanDefinitionException.class); 29 | } 30 | } 31 | 32 | @Test 33 | void testEnabled() { 34 | try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(EnabledConfig.class) 35 | .web(WebApplicationType.NONE) 36 | .run()) { 37 | 38 | assertThatCode(() -> ctx.getBean(EnabledApi.class)).doesNotThrowAnyException(); 39 | } 40 | } 41 | 42 | interface EnabledApi { 43 | @GetExchange("/posts") 44 | List getPosts(); 45 | } 46 | 47 | @Configuration(proxyBeanMethods = false) 48 | @EnableAutoConfiguration 49 | @EnableExchangeClients(clients = EnabledApi.class) 50 | static class EnabledConfig {} 51 | } 52 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/ExchangeClientCreatorTest.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static io.github.danielliu1123.httpexchange.ExchangeClientCreator.hasReactiveReturnTypeMethod; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.web.bind.annotation.RequestMapping; 8 | import org.springframework.web.service.annotation.HttpExchange; 9 | import org.springframework.web.service.invoker.HttpServiceProxyFactory; 10 | import reactor.core.publisher.Flux; 11 | import reactor.core.publisher.Mono; 12 | 13 | /** 14 | * {@link ExchangeClientCreator} tester. 15 | */ 16 | class ExchangeClientCreatorTest { 17 | 18 | /** 19 | * {@link ExchangeClientCreator#shadedProxyFactory(HttpServiceProxyFactory.Builder)} 20 | */ 21 | @Test 22 | void testShadedProxyFactory() { 23 | // used reflection, need to check whether fields are changed 24 | assertThat(HttpServiceProxyFactory.Builder.class) 25 | .hasOnlyDeclaredFields( 26 | "exchangeAdapter", "customArgumentResolvers", "conversionService", "embeddedValueResolver"); 27 | } 28 | 29 | /** 30 | * {@link ExchangeClientCreator#hasReactiveReturnTypeMethod(Class)} 31 | */ 32 | @Test 33 | void testHasReactiveReturnTypeMethod() { 34 | 35 | interface AllReactiveReturnTypeNoAnnotationInterface { 36 | Mono reactiveReturnTypeMethod1(); 37 | 38 | Flux reactiveReturnTypeMethod2(); 39 | } 40 | 41 | interface AllReactiveReturnTypeInterface { 42 | @HttpExchange 43 | Mono reactiveReturnTypeMethod1(); 44 | 45 | @HttpExchange 46 | Flux reactiveReturnTypeMethod2(); 47 | } 48 | 49 | interface AllNormalReturnTypeInterface { 50 | @HttpExchange 51 | String normalReturnTypeMethod1(); 52 | 53 | @HttpExchange 54 | String normalReturnTypeMethod2(); 55 | } 56 | 57 | interface MixedReturnTypeInterface { 58 | @RequestMapping 59 | Mono reactiveReturnTypeMethod1(); 60 | 61 | @RequestMapping 62 | String normalReturnTypeMethod1(); 63 | } 64 | 65 | assertThat(hasReactiveReturnTypeMethod(AllReactiveReturnTypeNoAnnotationInterface.class)) 66 | .isFalse(); 67 | assertThat(hasReactiveReturnTypeMethod(AllReactiveReturnTypeInterface.class)) 68 | .isTrue(); 69 | assertThat(hasReactiveReturnTypeMethod(AllNormalReturnTypeInterface.class)) 70 | .isFalse(); 71 | assertThat(hasReactiveReturnTypeMethod(MixedReturnTypeInterface.class)).isTrue(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/ExtendTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 5 | 6 | import java.util.List; 7 | import org.junit.jupiter.api.Test; 8 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 9 | import org.springframework.boot.builder.SpringApplicationBuilder; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.web.bind.annotation.PathVariable; 12 | import org.springframework.web.bind.annotation.RestController; 13 | import org.springframework.web.service.annotation.GetExchange; 14 | import org.springframework.web.service.annotation.HttpExchange; 15 | 16 | /** 17 | * @author Freeman 18 | */ 19 | class ExtendTests { 20 | 21 | @Test 22 | void userApiFirst_whenHaveControllerAndApiBeans() { 23 | int port = findAvailableTcpPort(); 24 | try (var ctx = new SpringApplicationBuilder(FooController.class) 25 | .profiles("ControllerApiTests") 26 | .properties("server.port=" + port) 27 | .run()) { 28 | assertThat(ctx.getBeanProvider(FooApi.class)).hasSize(2); 29 | 30 | FooApi fooApi = ctx.getBean(FooApi.class); 31 | assertThat(fooApi).isNotInstanceOf(FooController.class); 32 | 33 | assertThat(fooApi.getById("1")).isEqualTo(new Foo("1", "foo")); 34 | 35 | // Can't pass Object as query param by default, 36 | // but we have QueryArgumentResolver to resolve it, 37 | // if no QueryArgumentResolver, it will throw IllegalStateException 38 | // assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() -> fooApi.findAll(new Foo("1", 39 | // "foo1"))); 40 | } 41 | } 42 | 43 | record Foo(String id, String name) {} 44 | 45 | @HttpExchange("/foo") 46 | interface FooApi { 47 | 48 | @GetExchange("/{id}") 49 | Foo getById(@PathVariable String id); 50 | 51 | @GetExchange 52 | List findAll(Foo foo); 53 | } 54 | 55 | @Configuration(proxyBeanMethods = false) 56 | @EnableAutoConfiguration 57 | @EnableExchangeClients(clients = FooApi.class) 58 | @RestController 59 | static class FooController implements FooApi { 60 | 61 | @Override 62 | public Foo getById(@PathVariable String id) { 63 | return new Foo(id, "foo"); 64 | } 65 | 66 | @Override 67 | public List findAll(Foo foo) { 68 | return List.of(new Foo("1", "foo1")); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/HttpClientBeanDefinitionRegistryTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.mockito.Mockito.mock; 5 | import static org.mockito.Mockito.verify; 6 | import static org.mockito.Mockito.verifyNoInteractions; 7 | 8 | import java.util.Map; 9 | import org.junit.jupiter.api.AfterEach; 10 | import org.junit.jupiter.api.Test; 11 | import org.mockito.ArgumentCaptor; 12 | import org.springframework.beans.factory.support.BeanDefinitionRegistry; 13 | import org.springframework.core.env.MapPropertySource; 14 | import org.springframework.core.env.StandardEnvironment; 15 | 16 | /** 17 | * {@link HttpClientBeanDefinitionRegistry} 18 | */ 19 | class HttpClientBeanDefinitionRegistryTests { 20 | 21 | @AfterEach 22 | void clear() { 23 | HttpClientBeanDefinitionRegistry.scanInfo.clear(); 24 | } 25 | 26 | /** 27 | * {@link HttpClientBeanDefinitionRegistry#postProcessBeanDefinitionRegistry(BeanDefinitionRegistry)} 28 | */ 29 | @Test 30 | void testPostProcessBeanDefinitionRegistry_disabled() { 31 | // Arrange 32 | var registry = buildHttpClientBeanDefinitionRegistry(Map.of(HttpExchangeProperties.PREFIX + ".enabled", false)); 33 | var beanDefinitionRegistry = mock(BeanDefinitionRegistry.class); 34 | 35 | // Act 36 | registry.postProcessBeanDefinitionRegistry(beanDefinitionRegistry); 37 | 38 | // Assert 39 | verifyNoInteractions(beanDefinitionRegistry); 40 | } 41 | 42 | /** 43 | * {@link HttpClientBeanDefinitionRegistry#registerBeans(HttpClientBeanRegistrar)} 44 | */ 45 | @Test 46 | void testRegisterBeans_withBasePackages() { 47 | // Arrange 48 | var registry = buildHttpClientBeanDefinitionRegistry( 49 | Map.of(HttpExchangeProperties.PREFIX + ".base-packages", "com.example,com.another")); 50 | 51 | var registrar = mock(HttpClientBeanRegistrar.class); 52 | var captor = ArgumentCaptor.forClass(String[].class); 53 | 54 | // Act 55 | registry.registerBeans(registrar); 56 | 57 | // Assert 58 | verify(registrar).register(captor.capture()); 59 | assertThat(captor.getValue()).containsExactlyInAnyOrder("com.example", "com.another"); 60 | assertThat(HttpClientBeanDefinitionRegistry.scanInfo.basePackages) 61 | .containsExactlyInAnyOrder("com.example", "com.another"); 62 | } 63 | 64 | private static HttpClientBeanDefinitionRegistry buildHttpClientBeanDefinitionRegistry( 65 | Map properties) { 66 | var env = new StandardEnvironment(); 67 | env.getPropertySources().addLast(new MapPropertySource("test", properties)); 68 | var registry = new HttpClientBeanDefinitionRegistry(); 69 | registry.setEnvironment(env); 70 | return registry; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/HttpClientCustomizerIT.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 5 | 6 | import org.junit.jupiter.api.extension.ExtendWith; 7 | import org.junit.jupiter.params.ParameterizedTest; 8 | import org.junit.jupiter.params.provider.ValueSource; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | import org.springframework.boot.WebApplicationType; 12 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 13 | import org.springframework.boot.builder.SpringApplicationBuilder; 14 | import org.springframework.boot.test.system.CapturedOutput; 15 | import org.springframework.boot.test.system.OutputCaptureExtension; 16 | import org.springframework.context.annotation.Bean; 17 | import org.springframework.context.annotation.Configuration; 18 | import org.springframework.web.bind.annotation.RestController; 19 | import org.springframework.web.service.annotation.GetExchange; 20 | 21 | @ExtendWith(OutputCaptureExtension.class) 22 | class HttpClientCustomizerIT { 23 | private static final Logger log = LoggerFactory.getLogger(HttpClientCustomizerIT.class); 24 | 25 | @ParameterizedTest 26 | @ValueSource(strings = {"REST_CLIENT", "WEB_CLIENT"}) 27 | void testRestClient(String clientType, CapturedOutput output) { 28 | int port = findAvailableTcpPort(); 29 | try (var ctx = new SpringApplicationBuilder(RestClientCfg.class) 30 | .web(WebApplicationType.SERVLET) 31 | .properties("server.port=" + port) 32 | .properties("http-exchange.client-type=" + clientType) 33 | .properties("http-exchange.base-url=localhost:" + port) 34 | .run()) { 35 | 36 | ctx.getBean(Api.class).get(); 37 | 38 | assertThat(output).contains("Customizing the " + clientType + "..."); 39 | } 40 | } 41 | 42 | @Configuration(proxyBeanMethods = false) 43 | @EnableAutoConfiguration 44 | @EnableExchangeClients(clients = Api.class) 45 | @RestController 46 | static class RestClientCfg implements Api { 47 | @Bean 48 | HttpClientCustomizer.RestClientCustomizer restClientCustomizer() { 49 | return (client, channel) -> client.requestInterceptor((request, body, execution) -> { 50 | log.info("Customizing the REST_CLIENT..."); 51 | return execution.execute(request, body); 52 | }); 53 | } 54 | 55 | @Bean 56 | HttpClientCustomizer.WebClientCustomizer webClientCustomizer() { 57 | return (webClient, channel) -> webClient.filter((request, next) -> { 58 | log.info("Customizing the WEB_CLIENT..."); 59 | return next.exchange(request); 60 | }); 61 | } 62 | 63 | @Override 64 | public String get() { 65 | return "OK"; 66 | } 67 | } 68 | 69 | interface Api { 70 | @GetExchange("/get") 71 | String get(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/HttpExchangeAutoConfigurationTest.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.assertj.core.api.Assertions.assertThatCode; 5 | import static org.assertj.core.api.Assertions.assertThatExceptionOfType; 6 | import static org.mockito.Mockito.mockStatic; 7 | 8 | import org.junit.jupiter.api.Test; 9 | import org.mockito.MockedStatic; 10 | import org.springframework.boot.CommandLineRunner; 11 | import org.springframework.boot.SpringBootVersion; 12 | import org.springframework.boot.autoconfigure.AutoConfigurations; 13 | import org.springframework.boot.test.context.runner.ApplicationContextRunner; 14 | 15 | /** 16 | * {@link HttpExchangeAutoConfiguration} tester. 17 | */ 18 | class HttpExchangeAutoConfigurationTest { 19 | 20 | final ApplicationContextRunner runner = new ApplicationContextRunner() 21 | .withConfiguration(AutoConfigurations.of(HttpExchangeAutoConfiguration.class)); 22 | 23 | @Test 24 | void testDefault() { 25 | runner.run(ctx -> { 26 | assertThat(ctx).hasSingleBean(HttpClientBeanDefinitionRegistry.class); 27 | assertThat(ctx).hasSingleBean(BeanParamArgumentResolver.class); 28 | assertThat(ctx).hasSingleBean(CommandLineRunner.class); 29 | }); 30 | } 31 | 32 | @Test 33 | void testEnableIsFalse() { 34 | runner.withPropertyValues("http-exchange.enabled=false").run(ctx -> { 35 | assertThat(ctx).doesNotHaveBean(HttpClientBeanDefinitionRegistry.class); 36 | assertThat(ctx).doesNotHaveBean(BeanParamArgumentResolver.class); 37 | assertThat(ctx).doesNotHaveBean(CommandLineRunner.class); 38 | }); 39 | } 40 | 41 | @Test 42 | void testWarnUnusedConfig() { 43 | runner.run(ctx -> { 44 | assertThat(ctx).hasSingleBean(CommandLineRunner.class); 45 | }); 46 | 47 | runner.withPropertyValues("http-exchange.warn-unused-config-enabled=true") 48 | .run(ctx -> { 49 | assertThat(ctx).hasSingleBean(CommandLineRunner.class); 50 | }); 51 | 52 | runner.withPropertyValues("http-exchange.warn-unused-config-enabled=false") 53 | .run(ctx -> { 54 | assertThat(ctx).doesNotHaveBean(CommandLineRunner.class); 55 | }); 56 | } 57 | 58 | @Test 59 | void shouldThrowException_whenSpringBootVersionIsLessThan350() { 60 | try (MockedStatic mockedStatic = mockStatic(SpringBootVersion.class)) { 61 | // Mock Spring Boot version to be 3.4.9 62 | mockedStatic.when(SpringBootVersion::getVersion).thenReturn("3.4.9"); 63 | 64 | // Create an instance of HttpExchangeAutoConfiguration 65 | HttpExchangeAutoConfiguration config = new HttpExchangeAutoConfiguration(); 66 | 67 | // Should throw exception when afterPropertiesSet is called 68 | assertThatExceptionOfType(SpringBootVersionIncompatibleException.class) 69 | .isThrownBy(config::afterPropertiesSet) 70 | .withMessage( 71 | "Spring Boot version 3.4.9 is incompatible with httpexchange-spring-boot-starter. Minimum required version is 3.5.0") 72 | .satisfies(ex -> { 73 | assertThat(ex.getCurrentVersion()).isEqualTo("3.4.9"); 74 | assertThat(ex.getRequiredVersion()).isEqualTo("3.5.0"); 75 | }); 76 | } 77 | } 78 | 79 | @Test 80 | void shouldNotThrowException_whenSpringBootVersionIsEqualTo350() { 81 | try (MockedStatic mockedStatic = mockStatic(SpringBootVersion.class)) { 82 | // Mock Spring Boot version to be 3.5.0 83 | mockedStatic.when(SpringBootVersion::getVersion).thenReturn("3.5.0"); 84 | 85 | // Create an instance and call afterPropertiesSet 86 | HttpExchangeAutoConfiguration config = new HttpExchangeAutoConfiguration(); 87 | assertThatCode(config::afterPropertiesSet).doesNotThrowAnyException(); 88 | } 89 | } 90 | 91 | @Test 92 | void shouldNotThrowException_whenSpringBootVersionIsGreaterThan350() { 93 | try (MockedStatic mockedStatic = mockStatic(SpringBootVersion.class)) { 94 | // Mock Spring Boot version to be 3.6.0 95 | mockedStatic.when(SpringBootVersion::getVersion).thenReturn("3.6.0"); 96 | 97 | // Create an instance and call afterPropertiesSet 98 | HttpExchangeAutoConfiguration config = new HttpExchangeAutoConfiguration(); 99 | assertThatCode(config::afterPropertiesSet).doesNotThrowAnyException(); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/RegisterBeanManuallyTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.assertj.core.api.Assertions.assertThatCode; 5 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 6 | 7 | import org.junit.jupiter.api.Test; 8 | import org.springframework.boot.WebApplicationType; 9 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 10 | import org.springframework.boot.builder.SpringApplicationBuilder; 11 | import org.springframework.context.annotation.Bean; 12 | import org.springframework.context.annotation.Configuration; 13 | import org.springframework.context.annotation.Import; 14 | import org.springframework.web.bind.annotation.PathVariable; 15 | import org.springframework.web.bind.annotation.RestController; 16 | import org.springframework.web.client.ResourceAccessException; 17 | import org.springframework.web.client.RestClient; 18 | import org.springframework.web.client.support.RestClientAdapter; 19 | import org.springframework.web.service.annotation.GetExchange; 20 | import org.springframework.web.service.invoker.HttpServiceProxyFactory; 21 | 22 | class RegisterBeanManuallyTests { 23 | 24 | static int port = findAvailableTcpPort(); 25 | 26 | @Test 27 | void useAutoRegisteredBean_whenNoManualRegisteredBean() { 28 | try (var ctx = new SpringApplicationBuilder(CfgWithoutApiCfg.class) 29 | .web(WebApplicationType.SERVLET) 30 | .properties("server.port=" + port) 31 | .properties(HttpExchangeProperties.PREFIX + ".base-url=localhost:" + (port - 1)) 32 | .run()) { 33 | 34 | var api = ctx.getBean(Api.class); 35 | 36 | assertThatCode(() -> api.get(1)) 37 | .isInstanceOf(ResourceAccessException.class) 38 | .hasMessageContaining("I/O error"); 39 | } 40 | } 41 | 42 | @Test 43 | void useManualRegisteredBean_whenManualRegisteredBeanExists() { 44 | try (var ctx = new SpringApplicationBuilder(CfgWithApiCfg.class) 45 | .web(WebApplicationType.SERVLET) 46 | .properties("server.port=" + port) 47 | .properties(HttpExchangeProperties.PREFIX + ".base-url=localhost:" + (port - 1)) 48 | .run()) { 49 | 50 | var api = ctx.getBean(Api.class); 51 | 52 | var result = api.get(1); 53 | assertThat(result).isEqualTo("Hello 1"); 54 | } 55 | } 56 | 57 | @Configuration(proxyBeanMethods = false) 58 | @EnableAutoConfiguration 59 | @Import(ApiCfg.class) 60 | @EnableExchangeClients(clients = Api.class) 61 | @RestController 62 | static class CfgWithApiCfg { 63 | 64 | @GetExchange("/{id}") 65 | public String get(@PathVariable long id) { 66 | return "Hello " + id; 67 | } 68 | } 69 | 70 | @Configuration(proxyBeanMethods = false) 71 | @EnableAutoConfiguration 72 | @EnableExchangeClients(clients = Api.class) 73 | @RestController 74 | static class CfgWithoutApiCfg { 75 | 76 | @GetExchange("/{id}") 77 | public String get(@PathVariable long id) { 78 | return "Hello " + id; 79 | } 80 | } 81 | 82 | @Configuration(proxyBeanMethods = false) 83 | static class ApiCfg { 84 | @Bean 85 | public Api api(RestClient.Builder builder) { 86 | builder.baseUrl("http://localhost:" + port); 87 | return HttpServiceProxyFactory.builder() 88 | .exchangeAdapter(RestClientAdapter.create(builder.build())) 89 | .build() 90 | .createClient(Api.class); 91 | } 92 | } 93 | 94 | interface Api { 95 | @GetExchange("/{id}") 96 | String get(@PathVariable long id); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/RestClientConfigurationTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.assertj.core.api.Assertions.assertThatCode; 5 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 6 | 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 12 | import org.springframework.boot.builder.SpringApplicationBuilder; 13 | import org.springframework.boot.test.system.CapturedOutput; 14 | import org.springframework.boot.test.system.OutputCaptureExtension; 15 | import org.springframework.boot.web.client.RestClientCustomizer; 16 | import org.springframework.context.annotation.Bean; 17 | import org.springframework.context.annotation.Configuration; 18 | import org.springframework.web.bind.annotation.RestController; 19 | import org.springframework.web.service.annotation.GetExchange; 20 | 21 | /** 22 | * @author Freeman 23 | */ 24 | @ExtendWith(OutputCaptureExtension.class) 25 | class RestClientConfigurationTests { 26 | 27 | @Test 28 | void testRestClientCustomizer(CapturedOutput output) { 29 | int port = findAvailableTcpPort(); 30 | try (var ctx = new SpringApplicationBuilder(Controller.class) 31 | .properties("server.port=" + port) 32 | .properties(HttpExchangeProperties.PREFIX + ".base-url=localhost:" + port) 33 | .run()) { 34 | Api api = ctx.getBean(Api.class); 35 | 36 | assertThatCode(api::get).doesNotThrowAnyException(); 37 | assertThat(output).contains("Intercepted!"); 38 | } 39 | } 40 | 41 | interface Api { 42 | @GetExchange("/get") 43 | String get(); 44 | } 45 | 46 | @Configuration(proxyBeanMethods = false) 47 | @EnableAutoConfiguration 48 | @EnableExchangeClients(clients = Api.class) 49 | @RestController 50 | static class Controller implements Api { 51 | private static final Logger log = LoggerFactory.getLogger(Controller.class); 52 | 53 | @Override 54 | public String get() { 55 | return "OK"; 56 | } 57 | 58 | @Bean 59 | RestClientCustomizer restClientCustomizer() { 60 | return builder -> builder.requestInterceptor((request, body, execution) -> { 61 | log.info("Intercepted!"); 62 | return execution.execute(request, body); 63 | }); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/RestClientCustomizerIT.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 5 | 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 11 | import org.springframework.boot.builder.SpringApplicationBuilder; 12 | import org.springframework.boot.test.system.CapturedOutput; 13 | import org.springframework.boot.test.system.OutputCaptureExtension; 14 | import org.springframework.boot.web.client.RestClientCustomizer; 15 | import org.springframework.context.annotation.Bean; 16 | import org.springframework.context.annotation.Configuration; 17 | import org.springframework.http.client.ClientHttpResponse; 18 | import org.springframework.web.bind.annotation.RestController; 19 | import org.springframework.web.service.annotation.GetExchange; 20 | import org.springframework.web.service.annotation.HttpExchange; 21 | 22 | /** 23 | * @author Freeman 24 | */ 25 | @ExtendWith(OutputCaptureExtension.class) 26 | class RestClientCustomizerIT { 27 | 28 | @Test 29 | void testAddInterceptor(CapturedOutput output) { 30 | int port = findAvailableTcpPort(); 31 | try (var ctx = new SpringApplicationBuilder(Cfg.class) 32 | .properties("server.port=" + port) 33 | .properties(HttpExchangeProperties.PREFIX + ".base-url=localhost:" + port) 34 | .run()) { 35 | 36 | String resp = ctx.getBean(FooApi.class).get(); 37 | 38 | assertThat(resp).isEqualTo("Hello World!"); 39 | assertThat(output).contains("Response status: 200 OK"); 40 | } 41 | } 42 | 43 | @Configuration(proxyBeanMethods = false) 44 | @EnableAutoConfiguration 45 | @EnableExchangeClients(clients = FooApi.class) 46 | @RestController 47 | static class Cfg implements FooApi { 48 | private static final Logger log = LoggerFactory.getLogger(Cfg.class); 49 | 50 | @Bean 51 | RestClientCustomizer loggingCustomizer2() { 52 | return builder -> builder.requestInterceptor((request, body, execution) -> { 53 | ClientHttpResponse response = execution.execute(request, body); 54 | log.info("Response status: {}", response.getStatusCode()); 55 | return response; 56 | }); 57 | } 58 | 59 | @Override 60 | public String get() { 61 | return "Hello World!"; 62 | } 63 | } 64 | 65 | @HttpExchange("/foo") 66 | interface FooApi { 67 | 68 | @GetExchange 69 | String get(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/ReturnTypeTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.assertj.core.api.Assertions.assertThatCode; 5 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 6 | 7 | import java.util.List; 8 | import java.util.Map; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 11 | import org.springframework.boot.builder.SpringApplicationBuilder; 12 | import org.springframework.context.annotation.Configuration; 13 | import org.springframework.http.HttpHeaders; 14 | import org.springframework.http.HttpStatus; 15 | import org.springframework.http.ResponseEntity; 16 | import org.springframework.web.bind.annotation.GetMapping; 17 | import org.springframework.web.bind.annotation.RequestHeader; 18 | import org.springframework.web.bind.annotation.RestController; 19 | import org.springframework.web.server.ResponseStatusException; 20 | import org.springframework.web.service.annotation.GetExchange; 21 | 22 | /** 23 | * @author Freeman 24 | */ 25 | class ReturnTypeTests { 26 | 27 | @Test 28 | void testReturnType() { 29 | int port = findAvailableTcpPort(); 30 | try (var ctx = new SpringApplicationBuilder(Cfg.class) 31 | .properties("server.port=" + port) 32 | .properties("http-exchange.base-url=localhost:" + port) 33 | .run()) { 34 | 35 | Api api = ctx.getBean(Api.class); 36 | 37 | assertThatCode(api::get).doesNotThrowAnyException(); 38 | assertThat(api.getBody()).containsEntry("name", "Freeman"); 39 | assertThat(api.getHeaders()).containsEntry("foo", List.of("bar")); 40 | assertThat(api.getResponseEntity().getStatusCode()).isEqualTo(HttpStatus.OK); 41 | } 42 | } 43 | 44 | interface Api { 45 | @GetExchange("/get") 46 | ResponseEntity> getResponseEntity(); 47 | 48 | @GetExchange("/get") 49 | Map getBody(); 50 | 51 | @GetExchange("/get") 52 | HttpHeaders getHeaders(); 53 | 54 | @GetExchange("/get") 55 | void get(); 56 | } 57 | 58 | @Configuration(proxyBeanMethods = false) 59 | @EnableAutoConfiguration 60 | @EnableExchangeClients 61 | @RestController 62 | static class Cfg { 63 | 64 | @GetMapping("/get") 65 | public ResponseEntity get(@RequestHeader(value = "error", defaultValue = "false") boolean isError) { 66 | if (isError) { 67 | throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Parameter error"); 68 | } 69 | return ResponseEntity.ok() 70 | .headers(headers -> headers.put("foo", List.of("bar"))) 71 | .body(Map.of("name", "Freeman")); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/SpringBootVersionIncompatibleFailureAnalyzerIT.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.mockito.Mockito.mockStatic; 5 | 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.mockito.MockedStatic; 9 | import org.springframework.boot.SpringApplication; 10 | import org.springframework.boot.SpringBootVersion; 11 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 12 | import org.springframework.boot.test.system.CapturedOutput; 13 | import org.springframework.boot.test.system.OutputCaptureExtension; 14 | 15 | /** 16 | * Integration test for {@link SpringBootVersionIncompatibleFailureAnalyzer}. 17 | * This test verifies that the failure analyzer properly formats and displays 18 | * error messages when the application is started with an incompatible Spring Boot version. 19 | */ 20 | @ExtendWith(OutputCaptureExtension.class) 21 | class SpringBootVersionIncompatibleFailureAnalyzerIT { 22 | 23 | @Test 24 | void shouldDisplayFormattedErrorMessage_whenSpringBootVersionIsIncompatible(CapturedOutput output) { 25 | try (MockedStatic mockedStatic = mockStatic(SpringBootVersion.class)) { 26 | // Mock Spring Boot version to be 3.4.5 27 | mockedStatic.when(SpringBootVersion::getVersion).thenReturn("3.4.5"); 28 | 29 | try (var ignored = SpringApplication.run(TestApplication.class)) { 30 | } catch (Exception e) { 31 | // Expected exception, we're testing the error message format 32 | } 33 | } 34 | 35 | // Verify the error message contains the expected information 36 | assertThat(output.getOut()) 37 | .contains("Description:") 38 | .contains( 39 | "The current version of httpexchange-spring-boot-starter requires Spring Boot 3.5.0 or higher, but found 3.4.5") 40 | .contains("Action:") 41 | .contains( 42 | "If you're using a Spring Boot version < 3.5.0, please stick with httpexchange-spring-boot-starter version 3.4.x") 43 | .contains("Spring Boot 3.5.0 introduced extensive internal refactoring") 44 | .contains("https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/releases/tag/v3.5.0"); 45 | } 46 | 47 | @EnableAutoConfiguration 48 | @EnableExchangeClients 49 | static class TestApplication {} 50 | } 51 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/SpringBootVersionIncompatibleFailureAnalyzerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.boot.diagnostics.FailureAnalysis; 7 | 8 | /** 9 | * Tests for {@link SpringBootVersionIncompatibleFailureAnalyzer}. 10 | */ 11 | class SpringBootVersionIncompatibleFailureAnalyzerTest { 12 | 13 | @Test 14 | void testAnalyze() { 15 | // Given 16 | SpringBootVersionIncompatibleFailureAnalyzer analyzer = new SpringBootVersionIncompatibleFailureAnalyzer(); 17 | SpringBootVersionIncompatibleException exception = new SpringBootVersionIncompatibleException("3.4.9", "3.5.0"); 18 | 19 | // When 20 | FailureAnalysis analysis = analyzer.analyze(exception); 21 | 22 | // Then 23 | assertThat(analysis).isNotNull(); 24 | assertThat(analysis.getDescription()) 25 | .contains("requires Spring Boot 3.5.0 or higher") 26 | .contains("but found 3.4.9"); 27 | assertThat(analysis.getAction()) 28 | .contains( 29 | "If you're using a Spring Boot version < 3.5.0, please stick with httpexchange-spring-boot-starter version 3.4.x") 30 | .contains("Spring Boot 3.5.0 introduced extensive internal refactoring") 31 | .contains("https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/releases/tag/v3.5.0"); 32 | assertThat(analysis.getCause()).isSameAs(exception); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/UrlVariableTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.assertj.core.api.Assertions.assertThatCode; 5 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 6 | 7 | import io.github.danielliu1123.Post; 8 | import java.util.List; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 11 | import org.springframework.boot.builder.SpringApplicationBuilder; 12 | import org.springframework.context.ConfigurableApplicationContext; 13 | import org.springframework.context.annotation.Configuration; 14 | import org.springframework.web.bind.annotation.RestController; 15 | import org.springframework.web.service.annotation.GetExchange; 16 | import org.springframework.web.service.annotation.HttpExchange; 17 | 18 | /** 19 | * @author Freeman 20 | */ 21 | class UrlVariableTests { 22 | @Test 23 | void testUrlVariable() { 24 | int port = findAvailableTcpPort(); 25 | try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(UrlVariableController.class) 26 | .properties("server.port=" + port) 27 | .run("--api.url=http://localhost:" + port)) { 28 | 29 | assertThatCode(() -> ctx.getBean(UrlVariableApi.class)).doesNotThrowAnyException(); 30 | 31 | UrlVariableApi api = ctx.getBean(UrlVariableApi.class); 32 | List posts = api.getPosts(); 33 | 34 | assertThat(posts).isEmpty(); 35 | } 36 | } 37 | 38 | @HttpExchange("${api.url}") 39 | interface UrlVariableApi { 40 | @GetExchange("/posts") 41 | List getPosts(); 42 | } 43 | 44 | @Configuration(proxyBeanMethods = false) 45 | @EnableAutoConfiguration 46 | @EnableExchangeClients(clients = UrlVariableApi.class) 47 | @RestController 48 | @HttpExchange 49 | static class UrlVariableController implements UrlVariableApi { 50 | 51 | @Override 52 | @GetExchange("/posts") 53 | public List getPosts() { 54 | return List.of(); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/ValidationTests.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange; 2 | 3 | import static org.assertj.core.api.Assertions.assertThatCode; 4 | import static org.assertj.core.api.Assertions.assertThatExceptionOfType; 5 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 6 | 7 | import jakarta.validation.ConstraintViolationException; 8 | import jakarta.validation.constraints.Max; 9 | import jakarta.validation.constraints.Min; 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 12 | import org.springframework.boot.builder.SpringApplicationBuilder; 13 | import org.springframework.context.annotation.Configuration; 14 | import org.springframework.validation.annotation.Validated; 15 | import org.springframework.web.bind.annotation.PathVariable; 16 | import org.springframework.web.bind.annotation.RestController; 17 | import org.springframework.web.service.annotation.GetExchange; 18 | 19 | /** 20 | * @author Freeman 21 | */ 22 | class ValidationTests { 23 | 24 | @Test 25 | void worksFine_whenSpringBootGreater3_0_3() { 26 | int port = findAvailableTcpPort(); 27 | try (var ctx = new SpringApplicationBuilder(ValidateController.class) 28 | .properties("server.port=" + port) 29 | .properties(HttpExchangeProperties.PREFIX + ".base-url=localhost:" + port) 30 | .run()) { 31 | ValidateApi api = ctx.getBean(ValidateApi.class); 32 | 33 | assertThatExceptionOfType(ConstraintViolationException.class).isThrownBy(() -> api.validate(0)); 34 | assertThatCode(() -> api.validate(1)).doesNotThrowAnyException(); 35 | assertThatCode(() -> api.validate(2)).doesNotThrowAnyException(); 36 | assertThatExceptionOfType(ConstraintViolationException.class).isThrownBy(() -> api.validate(3)); 37 | } 38 | } 39 | 40 | @Validated 41 | interface ValidateApi { 42 | 43 | @GetExchange("/validate/{id}") 44 | String validate(@PathVariable @Min(1) @Max(2) int id); 45 | } 46 | 47 | @Configuration(proxyBeanMethods = false) 48 | @EnableAutoConfiguration 49 | @EnableExchangeClients(clients = ValidateApi.class) 50 | @RestController 51 | static class ValidateController implements ValidateApi { 52 | @Override 53 | public String validate(int id) { 54 | return "validated: " + id; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/httpexchange/shaded/ShadedHttpServiceProxyFactoryTest.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.httpexchange.shaded; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.lang.reflect.Field; 6 | import java.util.Arrays; 7 | import org.junit.jupiter.api.Test; 8 | import org.springframework.web.service.invoker.HttpServiceProxyFactory; 9 | 10 | /** 11 | * {@link ShadedHttpServiceProxyFactory} tester. 12 | */ 13 | class ShadedHttpServiceProxyFactoryTest { 14 | 15 | /** 16 | * Because the code uses reflection to obtain the attribute values of {@link HttpServiceProxyFactory.Builder}, 17 | * when upgrading the Spring Boot version, this test is necessary for discovering the property changes of {@link HttpServiceProxyFactory.Builder}. 18 | * 19 | * @see ShadedHttpServiceProxyFactory.Builder 20 | */ 21 | @Test 22 | void testHttpServiceProxyFactoryBuilderProperties() { 23 | Class clz = HttpServiceProxyFactory.Builder.class; 24 | Field[] fields = clz.getDeclaredFields(); 25 | 26 | assertThat(fields).hasSize(4); 27 | assertThat(Arrays.stream(fields).map(Field::getName)) 28 | .containsExactlyInAnyOrder( 29 | "exchangeAdapter", "customArgumentResolvers", "conversionService", "embeddedValueResolver"); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/order/api/OrderApi.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.order.api; 2 | 3 | import org.springframework.web.service.annotation.GetExchange; 4 | import org.springframework.web.service.annotation.HttpExchange; 5 | 6 | /** 7 | * @author Freeman 8 | */ 9 | @HttpExchange("http://localhost:8080") 10 | public interface OrderApi { 11 | 12 | @GetExchange("/order") 13 | String getOrder(); 14 | } 15 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/user/api/DummyApi.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.user.api; 2 | 3 | /** 4 | * @author Freeman 5 | */ 6 | public interface DummyApi {} 7 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/user/api/UserApi.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.user.api; 2 | 3 | import org.springframework.web.service.annotation.GetExchange; 4 | import org.springframework.web.service.annotation.HttpExchange; 5 | 6 | /** 7 | * @author Freeman 8 | */ 9 | @HttpExchange("http://localhost:8080") 10 | public interface UserApi { 11 | 12 | @GetExchange("/user") 13 | String getUser(); 14 | } 15 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/io/github/danielliu1123/user/api/UserHobbyApi.java: -------------------------------------------------------------------------------- 1 | package io.github.danielliu1123.user.api; 2 | 3 | import java.util.Map; 4 | import org.springframework.http.ResponseEntity; 5 | import org.springframework.web.bind.annotation.PathVariable; 6 | import org.springframework.web.service.annotation.GetExchange; 7 | 8 | /** 9 | * @author Freeman 10 | */ 11 | public interface UserHobbyApi { 12 | 13 | @GetExchange("https://my-json-server.typicode.com/typicode/demo/posts/{id}") 14 | ResponseEntity> getUserHobby(@PathVariable("id") String id); 15 | } 16 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/issues/issue73/CfgWithHttpClientConfiguration.java: -------------------------------------------------------------------------------- 1 | package issues.issue73; 2 | 3 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.context.annotation.Import; 6 | import org.springframework.web.bind.annotation.GetMapping; 7 | import org.springframework.web.bind.annotation.PathVariable; 8 | import org.springframework.web.bind.annotation.RequestMapping; 9 | import org.springframework.web.bind.annotation.RestController; 10 | 11 | /** 12 | * @author Freeman 13 | * @since 2024/12/1 14 | */ 15 | @Configuration(proxyBeanMethods = false) 16 | @EnableAutoConfiguration 17 | @Import(HttpClientConfiguration.class) 18 | @RestController 19 | @RequestMapping("/users") 20 | class CfgWithHttpClientConfiguration { 21 | @GetMapping("/{id}") 22 | public String getUsername(@PathVariable("id") String id) { 23 | return "Hello " + id; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/issues/issue73/CfgWithoutHttpClientConfiguration.java: -------------------------------------------------------------------------------- 1 | package issues.issue73; 2 | 3 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.web.bind.annotation.GetMapping; 6 | import org.springframework.web.bind.annotation.PathVariable; 7 | import org.springframework.web.bind.annotation.RequestMapping; 8 | import org.springframework.web.bind.annotation.RestController; 9 | 10 | /** 11 | * @author Freeman 12 | * @since 2024/12/1 13 | */ 14 | @Configuration(proxyBeanMethods = false) 15 | @EnableAutoConfiguration 16 | @RestController 17 | @RequestMapping("/users") 18 | class CfgWithoutHttpClientConfiguration { 19 | @GetMapping("/{id}") 20 | public String getUsername(@PathVariable("id") String id) { 21 | return "Hello " + id; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/issues/issue73/HttpClientConfiguration.java: -------------------------------------------------------------------------------- 1 | package issues.issue73; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.web.client.RestClient; 6 | import org.springframework.web.client.support.RestClientAdapter; 7 | import org.springframework.web.service.invoker.HttpServiceProxyFactory; 8 | 9 | /** 10 | * @author Freeman 11 | * @since 2024/12/1 12 | */ 13 | @Configuration(proxyBeanMethods = false) 14 | public class HttpClientConfiguration { 15 | 16 | @Bean 17 | public UserApi userApi(RestClient.Builder builder) { 18 | builder.baseUrl("http://localhost:" + Issue73Test.port); 19 | return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(builder.build())) 20 | .build() 21 | .createClient(UserApi.class); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/issues/issue73/Issue73Test.java: -------------------------------------------------------------------------------- 1 | package issues.issue73; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.assertj.core.api.Assertions.assertThatCode; 5 | import static org.springframework.test.util.TestSocketUtils.findAvailableTcpPort; 6 | 7 | import org.junit.jupiter.api.Test; 8 | import org.springframework.boot.WebApplicationType; 9 | import org.springframework.boot.builder.SpringApplicationBuilder; 10 | import org.springframework.web.client.ResourceAccessException; 11 | 12 | /** 13 | * @author Freeman 14 | * @see Fixes use manually registered bean not works 15 | */ 16 | class Issue73Test { 17 | 18 | static int port = findAvailableTcpPort(); 19 | 20 | @Test 21 | void useManualRegisteredBean_whenManualRegisteredBeanExists() { 22 | try (var ctx = new SpringApplicationBuilder(CfgWithHttpClientConfiguration.class) 23 | .web(WebApplicationType.SERVLET) 24 | .properties("server.port=" + port) 25 | .properties("http-exchange.base-packages=" + UserApi.class.getPackageName()) 26 | .properties("http-exchange.base-url=localhost:" + (port - 1)) // wrong base-url 27 | .run()) { 28 | 29 | var api = ctx.getBean(UserApi.class); 30 | 31 | var username = api.getUsername("1"); 32 | 33 | // Got the correct result, means the manual registered bean works 34 | assertThat(username).isEqualTo("Hello 1"); 35 | } 36 | } 37 | 38 | @Test 39 | void useAutoRegisteredBean_whenNoManualRegisteredBeanAndUsingWrongBaseUrl_thenThrowException() { 40 | try (var ctx = new SpringApplicationBuilder(CfgWithoutHttpClientConfiguration.class) 41 | .web(WebApplicationType.SERVLET) 42 | .properties("server.port=" + port) 43 | .properties("http-exchange.base-packages=" + UserApi.class.getPackageName()) 44 | .properties("http-exchange.base-url=localhost:" + (port - 1)) // wrong base-url 45 | .run()) { 46 | 47 | var api = ctx.getBean(UserApi.class); 48 | 49 | assertThatCode(() -> api.getUsername("1")) 50 | .isInstanceOf(ResourceAccessException.class) 51 | .hasMessageContaining("I/O error"); 52 | } 53 | } 54 | 55 | @Test 56 | void useAutoRegisteredBean_whenNoManualRegisteredBeanAndUsingCorrectBaseUrl_thenGotCorrectResult() { 57 | try (var ctx = new SpringApplicationBuilder(CfgWithoutHttpClientConfiguration.class) 58 | .web(WebApplicationType.SERVLET) 59 | .properties("server.port=" + port) 60 | .properties("http-exchange.base-packages=" + UserApi.class.getPackageName()) 61 | .properties("http-exchange.base-url=localhost:" + port) // correct base-url 62 | .run()) { 63 | 64 | var api = ctx.getBean(UserApi.class); 65 | 66 | var username = api.getUsername("1"); 67 | 68 | assertThat(username).isEqualTo("Hello 1"); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/java/issues/issue73/UserApi.java: -------------------------------------------------------------------------------- 1 | package issues.issue73; 2 | 3 | import org.springframework.web.bind.annotation.PathVariable; 4 | import org.springframework.web.service.annotation.GetExchange; 5 | import org.springframework.web.service.annotation.HttpExchange; 6 | 7 | @HttpExchange("/users") 8 | public interface UserApi { 9 | @GetExchange("/{id}") 10 | String getUsername(@PathVariable("id") String id); 11 | } 12 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/resources/application-ClassesConfigTests.yml: -------------------------------------------------------------------------------- 1 | http-exchange: 2 | channels: 3 | - base-url: localhost:${server.port} 4 | classes: 5 | - io.github.danielliu1123.httpexchange.ClassesConfigTests.FooApi 6 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/resources/application-ClientsConfigTests.yml: -------------------------------------------------------------------------------- 1 | http-exchange: 2 | channels: 3 | - base-url: localhost:${server.port} 4 | clients: 5 | - io.github.danielliu1123.httpexchange.ClientsConfigTests.FooApi 6 | -------------------------------------------------------------------------------- /httpexchange-spring-boot-autoconfigure/src/test/resources/application-ControllerApiTests.yml: -------------------------------------------------------------------------------- 1 | http-exchange: 2 | channels: 3 | - base-url: http://localhost:${server.port} 4 | headers: 5 | - key: X-Request-Id 6 | values: [xxx, yyy, zzz] 7 | - key: app-port 8 | values: ${server.port} 9 | clients: 10 | - FooApi 11 | -------------------------------------------------------------------------------- /secring.gpg.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielLiu1123/httpexchange-spring-boot-starter/b377729609fab5e92d20277b647bf6a1d0b04dc1/secring.gpg.bin -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | maven { url = "https://repo.spring.io/snapshot" } 5 | maven { url = "https://repo.spring.io/milestone" } 6 | } 7 | } 8 | 9 | rootProject.name = 'httpexchange-spring-boot-starter-root' 10 | 11 | include(":examples:loadbalancer") 12 | include(":examples:minimal") 13 | include(":examples:native-image") 14 | include(":examples:processor") 15 | include(":examples:quick-start") 16 | include(":examples:reactive") 17 | 18 | include(":httpexchange-processor") 19 | include(":httpexchange-spring-boot-autoconfigure") 20 | 21 | include(":starters:httpexchange-spring-boot-starter") 22 | 23 | new File("${rootDir}/.githooks").eachFile(groovy.io.FileType.FILES) { 24 | def f = new File("${rootDir}/.git/hooks") 25 | if (f.exists() && f.isDirectory()) { 26 | java.nio.file.Files.copy(it.toPath(), new File("${rootDir}/.git/hooks", it.name).toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING) 27 | } 28 | } -------------------------------------------------------------------------------- /starters/httpexchange-spring-boot-starter/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | api(project(":httpexchange-spring-boot-autoconfigure")) 3 | api("org.springframework.boot:spring-boot-starter") 4 | api("org.springframework.boot:spring-boot-starter-json") 5 | 6 | optionalSupportApi("org.springframework:spring-webflux") 7 | optionalSupportApi("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") 8 | optionalSupportApi("org.springframework.cloud:spring-cloud-context:${springCloudCommonsVersion}") 9 | 10 | optionalSupportApi("org.springframework.cloud:spring-cloud-starter-loadbalancer:${springCloudCommonsVersion}") 11 | } 12 | 13 | apply from: "${rootDir}/gradle/deploy.gradle" 14 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /website/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /website/docs/01-intro.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | 8 | # Introduction 9 | 10 | Spring 6 now supports creating HTTP clients using the [`@HttpExchange`](https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-http-interface) annotation. 11 | This removes the need for [Spring Cloud OpenFeign](https://github.com/spring-cloud/spring-cloud-openfeign). 12 | 13 | Here is an example: 14 | 15 | ```java 16 | @HttpExchange("https://my-json-server.typicode.com") 17 | interface PostApi { 18 | @GetExchange("/typicode/demo/posts/{id}") 19 | Post getPost(@PathVariable("id") int id); 20 | } 21 | 22 | @SpringBootApplication 23 | public class App { 24 | 25 | public static void main(String[] args) { 26 | SpringApplication.run(App.class, args); 27 | } 28 | 29 | // highlight-start 30 | @Bean 31 | PostApi postApi(RestClient.Builder builder) { 32 | HttpServiceProxyFactory factory = HttpServiceProxyFactory 33 | .builderFor(RestClientAdapter.create(builder.build())) 34 | .build(); 35 | return factory.createClient(PostApi.class); 36 | } 37 | // highlight-end 38 | 39 | // Imagine there are 100 HttpExchange clients 😇 40 | 41 | @Bean 42 | ApplicationRunner runner(PostApi api) { 43 | return args -> api.getPost(1); 44 | } 45 | } 46 | ``` 47 | ## Identified Issues 48 | 49 | - Lack of Autoconfiguration 50 | 51 | Currently, autoconfiguration is not available for clients, requiring manual instantiation through client beans. 52 | This process can become particularly cumbersome when managing numerous clients. 53 | 54 | For users familiar with `Spring Cloud OpenFeign`, 55 | the `@EnableFeignClients` annotation is highly beneficial, 56 | significantly reducing repetitive code. 57 | 58 | - Absence of Support for Spring Web Annotations 59 | 60 | Although native support for declarative HTTP clients is a valuable addition, it introduces a new set of 61 | annotations like `@GetExchange`, `@PostExchange`, etc. Unfortunately, `HttpServiceProxyFactory` does not support Spring web 62 | annotations, such as `@GetMapping` and `@PostMapping`. This lack of support can be a significant 63 | obstacle for users accustomed to `Spring Cloud OpenFeign` who are considering migrating to Spring 6.x, making the 64 | transition process more challenging. 65 | 66 | ## Quick Start 67 | 68 | 69 | 70 | ```groovy 71 | implementation("io.github.danielliu1123:httpexchange-spring-boot-starter:") 72 | ``` 73 | 74 | 75 | ```xml 76 | 77 | io.github.danielliu1123 78 | httpexchange-spring-boot-starter 79 | latest 80 | 81 | ``` 82 | 83 | 84 | 85 | ```java 86 | @HttpExchange("https://my-json-server.typicode.com") 87 | interface PostApi { 88 | @GetExchange("/typicode/demo/posts/{id}") 89 | Post getPost(@PathVariable("id") int id); 90 | } 91 | 92 | @SpringBootApplication 93 | // highlight-next-line-as-added 94 | @EnableExchangeClients 95 | public class App { 96 | public static void main(String[] args) { 97 | SpringApplication.run(App.class, args); 98 | } 99 | 100 | @Bean 101 | ApplicationRunner runner(PostApi api) { 102 | return args -> api.getPost(1); 103 | } 104 | } 105 | ``` 106 | 107 | :::success 108 | No more boilerplate code! 🎉 109 | ::: 110 | -------------------------------------------------------------------------------- /website/docs/10-core/10-autoconfiguration.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 10 3 | --- 4 | 5 | # Autoconfiguration 6 | 7 | This section shows how to set up autoconfiguration. 8 | 9 | Steps to autoconfigure `@HttpExchange` clients: 10 | 1. Specify clients. 11 | 2. Configure base-url. 12 | 13 | Then you can inject the clients using whatever method you prefer. 14 | 15 | ## Specify Clients 16 | 17 | You can specify clients in two ways. 18 | 19 | ### Using Annotation 20 | 21 | To set up autoconfiguration, just use the [`@EnableExchangeClients`](https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/blob/main/httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/EnableExchangeClients.java) 22 | annotation. This works like `@EnableFeignClients` and tells the framework where to find `HttpExchange` clients, then registering them as beans. 23 | 24 | By default, it looks for clients in the same package as the annotated class. 25 | 26 | - Specifying `basePackages`: 27 | 28 | ```java 29 | @EnableExchangeClients(basePackages = "com.example") 30 | ``` 31 | 32 | :::info 33 | If you specify packages with `basePackages`, it will only look in those packages, not the one with the annotated class. 34 | ::: 35 | 36 | - Specifying `clients`: 37 | 38 | ```java 39 | @EnableExchangeClients(clients = {PostApi.class, UserApi.class}) 40 | ``` 41 | 42 | :::info 43 | This is quicker than `basePackages` because it doesn't have to scan the classpath. 44 | ::: 45 | 46 | - Combining `basePackages` with `clients`: 47 | 48 | ```java 49 | @EnableExchangeClients(basePackages = "com.example", clients = {PostApi.class, UserApi.class}) 50 | ``` 51 | 52 | ### Using Configuration 53 | 54 | If you don't want to use annotations, you can set up using configuration. 55 | 56 | ```yaml title="application.yml" 57 | http-exchange: 58 | base-packages: [ com.example ] 59 | clients: 60 | - com.foo.PostApi 61 | - com.bar.UserApi 62 | ``` 63 | 64 | :::info 65 | If you use both the annotation and config file, the settings from the annotation are used first. 66 | ::: 67 | 68 | ## Configure base-url 69 | 70 | ```yaml title="application.yaml" 71 | http-exchange: 72 | base-packages: [ com.example ] 73 | channels: 74 | - base-url: http://user 75 | read-timeout: 3000 76 | clients: 77 | - com.example.user.api.*Api # Ant-style pattern 78 | - base-url: http://order 79 | read-timeout: 5000 80 | clients: 81 | - com.example.order.api.*Api 82 | ``` 83 | 84 | ## Note 85 | 86 | **IDEA can’t recognize the automatically registered client as a Spring Bean without extra plugin support, 87 | so you might see a red squiggly line, but this _doesn’t affect functionality_.** 88 | 89 | See [issues#87](https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/issues/87). 90 | -------------------------------------------------------------------------------- /website/docs/10-core/30-configuration.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 30 3 | --- 4 | 5 | # Configuration 6 | 7 | This library provides a lot of configuration properties to customize the behavior of the client. 8 | You can configure the `base-url`, `read-timeout` for each channel, and each channel can apply to multiple clients. 9 | 10 | ## Basic Usage 11 | 12 | ```yaml title="application.yaml" 13 | spring: 14 | http: 15 | client: 16 | settings: 17 | read-timeout: 5s 18 | 19 | http-exchange: 20 | channels: 21 | - base-url: http://user 22 | read-timeout: 3000 # Channel-level timeout (in milliseconds) 23 | clients: 24 | - com.example.user.api.*Api 25 | - base-url: http://order 26 | clients: 27 | - com.example.order.api.*Api 28 | ``` 29 | 30 | Using property `clients` or `classes` to identify the client, use `classes` first if configured. 31 | 32 | For example, there is a http client interface: `com.example.PostApi`, you can use the following configuration to identify the client 33 | 34 | ```yaml title="application.yaml" 35 | http-exchange: 36 | channels: 37 | - base-url: http://service 38 | clients: [com.example.PostApi] # Class canonical name 39 | # clients: [post-api] Class simple name (Kebab-case) 40 | # clients: [PostApi] Class simple name (Pascal-case) 41 | # clients: [com.**.*Api] (Ant-style pattern) 42 | classes: [com.example.PostApi] # Class canonical name 43 | ``` 44 | 45 | ## Detailed Configuration 46 | 47 | For an exhaustive list of all available configuration properties, please refer to the [Configuration Properties](../40-configuration-properties.md) documentation. 48 | 49 | ## Example 50 | 51 | See [configuration example](https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/blob/main/httpexchange-spring-boot-autoconfigure/src/main/resources/application-http-exchange-statrer-example.yml) for usage example. 52 | -------------------------------------------------------------------------------- /website/docs/10-core/40-validation.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 40 3 | --- 4 | 5 | 6 | # Validation 7 | 8 | Support work with `spring-boot-starter-validation`. 9 | 10 | ```java 11 | @HttpExchange("${api.post.url}") 12 | @Validated 13 | public interface PostApi { 14 | @GetExchange("/typicode/demo/posts/{id}") 15 | Post getPost(@PathVariable("id") @Min(1) @Max(3) int id); 16 | } 17 | ``` 18 | 19 | This approach ensures that validation rules are consistent across both client and server. 20 | -------------------------------------------------------------------------------- /website/docs/10-core/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Core", 3 | "position": 10, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "5 minutes to learn the most important features" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /website/docs/20-extensions/10-request-mapping-annotation-support.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 10 3 | --- 4 | 5 | # `@RequestMapping` Support 6 | 7 | Support to use spring web annotations to generate HTTP clients, e.g., `@RequestMapping`, `@GetMapping`, `@PostMapping` etc. 8 | 9 | Supports all features of `@HttpExchange`. 10 | 11 | ```java 12 | @RequestMapping("/typicode/demo") 13 | public interface PostApi { 14 | @GetMapping("/posts/{id}") 15 | Post getPost(@PathVariable("id") int id); 16 | } 17 | ``` 18 | 19 | :::info 20 | Since 3.2.0, `@RequestMapping` support is disabled by default, you can set `http-exchange.request-mapping-support-enabled=true` to enable it. 21 | 22 | Please using `@HttpExchange` instead of `@RequestMapping`. 23 | ::: 24 | -------------------------------------------------------------------------------- /website/docs/20-extensions/20-loadbalancer.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 20 3 | --- 4 | 5 | 6 | import Tabs from '@theme/Tabs'; 7 | import TabItem from '@theme/TabItem'; 8 | 9 | # LoadBalancer 10 | 11 | Support to work with `spring-cloud-starter-loadbalancer` to achieve client side load balancing. 12 | 13 | ## Enable LoadBalancer 14 | 15 | This feature is automatically enabled when the `spring-cloud-starter-loadbalancer` is present on the classpath. 16 | 17 | 18 | 19 | ```groovy 20 | implementation("org.springframework.cloud:spring-cloud-starter-loadbalancer") 21 | ``` 22 | 23 | 24 | ```xml 25 | 26 | org.springframework.cloud 27 | spring-cloud-starter-loadbalancer 28 | 29 | ``` 30 | 31 | 32 | 33 | ## Disable LoadBalancer 34 | 35 | Set `http-exchange.loadbalancer-enabled` to `false` to disable the load balancer for all HttpExchange clients. 36 | 37 | ```yaml title="application.yml" 38 | http-exchange: 39 | loadbalancer-enabled: false 40 | ``` 41 | 42 | ### Disable for Specific Channel 43 | 44 | Disable the load balancer for a specific channel by setting `loadbalancer-enabled` to `false`. 45 | 46 | ```yaml title="application.yml" 47 | http-exchange: 48 | channels: 49 | - base-url: user 50 | # highlight-next-line-as-added 51 | loadbalancer-enabled: false 52 | clients: 53 | - com.example.user.api.*Api 54 | ``` 55 | 56 | See [loadbalancer](https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/tree/main/examples/loadbalancer) example. 57 | -------------------------------------------------------------------------------- /website/docs/20-extensions/30-bean-to-query.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 30 3 | --- 4 | 5 | # Convert Bean to Query Parameter 6 | 7 | In Spring Web/WebFlux (server side), it will automatically convert query parameters to Java Bean, 8 | but `Spring Cloud OpenFeign` and `@HttpExchange` does not support to convert Java bean to query parameters by default. 9 | In `Spring Cloud OpenFeign` you need `@SpringQueryMap` to achieve this feature. 10 | 11 | `httpexhange-spring-boot-starter` supports this feature, and you don't need any additional annotations. 12 | 13 | :::info 14 | In order not to change the default behavior of Spring, this feature is disabled by default, 15 | you can set `http-exchange.bean-to-query-enabled=true` to enable it. 16 | ::: 17 | 18 | ```java 19 | public interface PostApi { 20 | @GetExchange("/posts") 21 | List findAll(Post condition); 22 | } 23 | ``` 24 | 25 | Auto convert non-null *value type* fields of condition to query parameters. Such as primitive/wrapper types, String, etc. 26 | 27 | If you don't want to enable this feature globally, you can use `@BeanParam`, 28 | it is an equivalent replacement for [`@SpringQueryMap`](https://docs.spring.io/spring-cloud-openfeign/reference/spring-cloud-openfeign.html#feign-querymap-support). 29 | For easier migration from Spring Cloud OpenFeign, `@SpringQueryMap` is also supported, though `@BeanParam` is recommended. 30 | 31 | :::tip 32 | The `@BeanParam` annotation's naming is inspired by [JAX-RS](https://docs.oracle.com/javaee%2F7%2Fapi%2F%2F/javax/ws/rs/BeanParam.html). 33 | ::: 34 | -------------------------------------------------------------------------------- /website/docs/20-extensions/40-dynamic-refresh.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 40 3 | --- 4 | 5 | # Dynamic Refresh 6 | 7 | Support to dynamically refresh the configuration of clients, you can put the configuration in the configuration 8 | center ([Consul](https://github.com/hashicorp/consul), [Apollo](https://github.com/apolloconfig/apollo), [Nacos](https://github.com/alibaba/nacos), 9 | etc.), and change the configuration (e.g. `base-url`, `timeout`, `headers`), the client will be refreshed automatically 10 | without restarting the application. 11 | 12 | Use the following configuration to enable this feature: 13 | 14 | ```yaml title="application.yml" 15 | http-exchange: 16 | refresh: 17 | enabled: true # default is false 18 | ``` 19 | 20 | :::tip 21 | This feature needs `spring-cloud-context` in the classpath and a `RefreshEvent` was published. 22 | ::: -------------------------------------------------------------------------------- /website/docs/20-extensions/50-url-variables.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 50 3 | --- 4 | 5 | # Url Variables 6 | 7 | You can use url variables to define the base url of the client. 8 | 9 | ```java 10 | @HttpExchange("${api.post.url}") 11 | public interface PostApi { 12 | @GetExchange("/typicode/demo/posts/{id}") 13 | Post getPost(@PathVariable("id") int id); 14 | } 15 | ``` 16 | 17 | ```yaml title="application.yml" 18 | api: 19 | post: 20 | url: https://jsonplaceholder.typicode.com 21 | ``` 22 | -------------------------------------------------------------------------------- /website/docs/20-extensions/70-native-image.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 70 3 | --- 4 | 5 | # Native Image 6 | 7 | Support [GraalVM](https://www.graalvm.org/) native image. 8 | 9 | See [native-image](https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/tree/main/examples/native-image) example. 10 | -------------------------------------------------------------------------------- /website/docs/20-extensions/80-customization.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 80 3 | --- 4 | 5 | # Customization 6 | 7 | This library is designed to be highly customizable. You can customize the behavior of the library by providing your own implementations. 8 | 9 | ## Custom [HttpServiceArgumentResolver](https://docs.spring.io/spring-framework/docs/current//javadoc-api/org/springframework/web/service/invoker/class-use/HttpServiceArgumentResolver.html) 10 | 11 | ```java 12 | @Bean 13 | HttpServiceArgumentResolver yourHttpServiceArgumentResolver() { 14 | return new YourHttpServiceArgumentResolver(); 15 | } 16 | ``` 17 | 18 | Auto-detect all the `HttpServiceArgumentResolver` beans, then apply them to build the `HttpServiceProxyFactory`. 19 | 20 | ## Change Client Type 21 | 22 | There are two adapters for HttpExchange client: `RestClientAdapter` and `WebClientAdapter`. 23 | 24 | ```yaml title="application.yml" 25 | http-exchange: 26 | client-type: REST_CLIENT 27 | ``` 28 | 29 | The framework will choose the appropriate adapter according to the http client interface. 30 | If any method in the interface returns a reactive type (Mono/Flux), then `WebClient` will be used, otherwise `RestClient` will be used. 31 | **In most cases, you don't need to explicitly specify the client type.** 32 | 33 | :::warning 34 | The `connectTimeout` settings are not supported by `WEB_CLIENT` when using version < `3.5.0`. 35 | ::: 36 | 37 | ## HttpClientCustomizer 38 | 39 | Using [`HttpClientCustomizer`](https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/blob/main/httpexchange-spring-boot-autoconfigure/src/main/java/io/github/danielliu1123/httpexchange/HttpClientCustomizer.java), 40 | you can more freely customize the underlying Http client, such as setting up a proxy, setting up SSL, etc. 41 | 42 | ```java 43 | // For RestClient 44 | @Bean 45 | HttpClientCustomizer.RestClientCustomizer restClientCustomizer() { 46 | return (restClientBuilder, channel) -> { 47 | if (Objects.equals(channel.getName(), "whichChannelYouWantToCustomize")) { 48 | var httpClient = HttpClient.newBuilder().build(); 49 | restClientBuilder.requestFactory(new JdkClientHttpRequestFactory(httpClient)); 50 | } 51 | }; 52 | } 53 | ``` 54 | 55 | ```java 56 | // For WebClient 57 | @Bean 58 | HttpClientCustomizer.WebClientCustomizer webClientCustomizer() { 59 | return (webClientBuilder, channel) -> { 60 | if (Objects.equals(channel.getName(), "whichChannelYouWantToCustomize")) { 61 | var httpClient = HttpClient.newBuilder().build(); 62 | webClientBuilder.clientConnector(new JdkClientHttpConnector(httpClient)); 63 | } 64 | }; 65 | } 66 | ``` 67 | 68 | ## Deep Customization 69 | 70 | If you're not happy with the autoconfigured Http client bean, 71 | you can configure it using the ["original way"](https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-http-interface). 72 | 73 | If you manually create the Http client bean, the autoconfigured Http client bean will not be created. 74 | 75 | ```java 76 | interface RepositoryService { 77 | @GetExchange("/repos/{owner}/{repo}") 78 | Repository getRepository(@PathVariable String owner, @PathVariable String repo); 79 | } 80 | 81 | @Configuration 82 | class RepositoryServiceConfiguration { 83 | @Bean 84 | public RepositoryService repositoryService(RestClient.Builder restClientBuilder) { 85 | RestClient restClient = RestClient.builder().baseUrl("https://api.github.com/").build(); 86 | RestClientAdapter adapter = RestClientAdapter.create(restClient); 87 | HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); 88 | return factory.createClient(RepositoryService.class); 89 | } 90 | } 91 | ``` 92 | -------------------------------------------------------------------------------- /website/docs/20-extensions/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Extensions", 3 | "position": 20, 4 | "link": { 5 | "type": "generated-index" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /website/docs/40-configuration-properties.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 40 3 | --- 4 | 5 | # Configuration Properties 6 | 7 | Configuration properties for the httpexchange-spring-boot-starter project. 8 | 9 | This page was generated by [spring-configuration-property-documenter](https://github.com/rodnansol/spring-configuration-property-documenter/blob/master/docs/modules/ROOT/pages/gradle-plugin.adoc). 10 | 11 | ## Table of Contents 12 | * [**httpexchange-spring-boot-autoconfigure**](#httpexchange-spring-boot-autoconfigure) 13 | * [**http-exchange** - `io.github.danielliu1123.httpexchange.HttpExchangeProperties`](#http-exchange) 14 | 15 | * [**http-exchange.refresh** - `io.github.danielliu1123.httpexchange.HttpExchangeProperties$Refresh`](#http-exchange.refresh) 16 | 17 | ## httpexchange-spring-boot-autoconfigure 18 | ### http-exchange 19 | **Class:** `io.github.danielliu1123.httpexchange.HttpExchangeProperties` 20 | 21 | |Key|Type|Description|Default value|Deprecation| 22 | |---|----|-----------|-------------|-----------| 23 | | base-packages| java.util.Set<java.lang.String>| Base packages to scan, use \{@link EnableExchangeClients#basePackages} first if configured.| | | 24 | | base-url| java.lang.String| Default base url, 'http' scheme can be omitted. <p> If loadbalancer is enabled, this value means the service id. <ul> <li> localhost:8080 </li> <li> http://localhost:8080 </li> <li> https://localhost:8080 </li> <li> localhost:8080/api </li> <li> user(service id) </li> </ul>| | | 25 | | bean-to-query-enabled| java.lang.Boolean| Whether to convert Java bean to query parameters, default value is \{@code false}.| false| | 26 | | channels| java.util.List<io.github.danielliu1123.httpexchange.HttpExchangeProperties$Channel>| Channels configuration.| | | 27 | | client-type| io.github.danielliu1123.httpexchange.HttpExchangeProperties$ClientType| Client Type, if not specified, an appropriate client type will be set. <ul> <li> Use \{@link ClientType#REST_CLIENT} if none of the methods in the client return Reactive type. <li> Use \{@link ClientType#WEB_CLIENT} if any method in the client returns Reactive type. </ul> <p> In most cases, you don't need to explicitly specify the client type. @see ClientType @since 3.2.0| | | 28 | | clients| java.util.Set<java.lang.Class<?>>| Exchange client interfaces to register as beans, use \{@link EnableExchangeClients#clients} first if configured. @since 3.2.0| | | 29 | | enabled| java.lang.Boolean| Whether to enable http exchange autoconfiguration, default \{@code true}.| true| | 30 | | headers| java.util.List<io.github.danielliu1123.httpexchange.HttpExchangeProperties$Header>| Default headers will be added to all the requests.| | | 31 | | http-client-reuse-enabled| java.lang.Boolean| Whether to enable http client reuse, default \{@code true}. <p> Same \{@link Channel} configuration will share the same http client if enabled. @since 3.2.2| true| | 32 | | loadbalancer-enabled| java.lang.Boolean| Whether to enable loadbalancer, default \{@code true}. <p> Prerequisites: <ul> <li> \{@code spring-cloud-starter-loadbalancer} dependency in the classpath.</li> <li> \{@code spring.cloud.loadbalancer.enabled=true}</li> </ul> @since 3.2.0| true| | 33 | | request-mapping-support-enabled| java.lang.Boolean| whether to process \{@link RequestMapping} based annotation, default \{@code false}. <p color="red"> Recommending to use \{@link HttpExchange} instead of \{@link RequestMapping}. @since 3.2.0| false| | 34 | | warn-unused-config-enabled| java.lang.Boolean| Whether to check unused configuration, default \{@code true}. @since 3.2.0| true| | 35 | ### http-exchange.refresh 36 | **Class:** `io.github.danielliu1123.httpexchange.HttpExchangeProperties$Refresh` 37 | 38 | |Key|Type|Description|Default value|Deprecation| 39 | |---|----|-----------|-------------|-----------| 40 | | enabled| java.lang.Boolean| Whether to enable refresh exchange clients, default \{@code false}. <p> This feature needs \{@code spring-cloud-context} dependency in the classpath. <p color="orange"> NOTE: This feature is not supported by native image. @see <a href="https://github.com/spring-cloud/spring-cloud-release/wiki/AOT-transformations-and-native-image-support#refresh-scope">Refresh Scope</a>| false| | 41 | 42 | -------------------------------------------------------------------------------- /website/docs/45-best-practice.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 45 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | 8 | # Best Practices 9 | 10 | This section shows some best practices for using `@HttpExchange`. 11 | 12 | ## Contract-Driven Development 13 | 14 | [`@HttpExchange` also provides server endpoints](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-requestmapping.html#mvc-ann-httpexchange-annotation), just like `@RequestMapping`. 15 | This makes it ideal for defining interface contracts and supporting contract-driven development. 16 | 17 | **Project structure:** 18 | 19 | ``` 20 | . 21 | ├── order-service 22 | │ ├── order-api 23 | │ └── order-server 24 | └── user-service 25 | ├── user-api 26 | └── user-server 27 | ``` 28 | 29 | 30 | 31 | ```java 32 | @HttpExchange("/orders") 33 | public interface OrderApi { 34 | @GetExchange("/by_user/{userId}") 35 | List getOrdersByUserId(@PathVariable("userId") String userId); 36 | } 37 | ``` 38 | 39 | 40 | ```java 41 | @RestController 42 | public class OrderApiImpl implements OrderApi { 43 | 44 | @Autowired 45 | private UserApi userApi; 46 | 47 | @Override 48 | public List getOrdersByUserId(String userId) { 49 | UserDTO user = userApi.getUser(userId); 50 | if (user.getStatus() == INACTIVE) { 51 | throw new UserInactiveException(); 52 | } 53 | // Ignore the implementation 54 | } 55 | } 56 | ``` 57 | 58 | 59 | ```java 60 | @HttpExchange("/users") 61 | public interface UserApi { 62 | @GetExchange("/{id}") 63 | UserDTO getUser(@PathVariable("id") String id); 64 | } 65 | ``` 66 | 67 | 68 | ```java 69 | @RestController 70 | public class UserApiImpl implements UserApi { 71 | @Override 72 | public UserDTO getById(String id) { 73 | // Ignore the implementation 74 | } 75 | } 76 | ``` 77 | 78 | 79 | -------------------------------------------------------------------------------- /website/docs/50-version.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 50 3 | --- 4 | 5 | # Version 6 | 7 | | Spring Boot | httpexchange-spring-boot-starter | 8 | |-------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 9 | | 3.5.x | [![Maven Central](https://img.shields.io/maven-central/v/io.github.danielliu1123/httpexchange-spring-boot-starter?versionPrefix=3.5.)](https://search.maven.org/artifact/io.github.danielliu1123/httpexchange-spring-boot-starter) | 10 | | 3.4.x | [![Maven Central](https://img.shields.io/maven-central/v/io.github.danielliu1123/httpexchange-spring-boot-starter?versionPrefix=3.4.)](https://search.maven.org/artifact/io.github.danielliu1123/httpexchange-spring-boot-starter) | 11 | | 3.3.x | [![Maven Central](https://img.shields.io/maven-central/v/io.github.danielliu1123/httpexchange-spring-boot-starter?versionPrefix=3.3.)](https://search.maven.org/artifact/io.github.danielliu1123/httpexchange-spring-boot-starter) | 12 | | 3.2.x | [![Maven Central](https://img.shields.io/maven-central/v/io.github.danielliu1123/httpexchange-spring-boot-starter?versionPrefix=3.2.)](https://search.maven.org/artifact/io.github.danielliu1123/httpexchange-spring-boot-starter) | | 13 | | 3.1.x | [![Maven Central](https://img.shields.io/maven-central/v/com.freemanan/httpexchange-spring-boot-starter?versionPrefix=3.1.)](https://search.maven.org/artifact/com.freemanan/httpexchange-spring-boot-starter) | | 14 | | 3.0.x | [![Maven Central](https://img.shields.io/maven-central/v/com.freemanan/httpexchange-spring-boot-starter?versionPrefix=3.0.)](https://search.maven.org/artifact/com.freemanan/httpexchange-spring-boot-starter) | | 15 | 16 | :::tip 17 | The version of `httpexchange-spring-boot-starter` is aligned with Spring Boot, 18 | we check the latest version of Spring Boot daily and release the corresponding version. 19 | ::: 20 | 21 | ## Notable Releases 22 | 23 | ### [3.5.0](https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/releases/tag/v3.5.0) 24 | 25 | - **Backward Compatibility Deprecation**: Starting from version 3.5.0, backward compatibility with Spring Boot versions below 3.5.0 has been dropped. This decision was made due to extensive internal refactoring in Spring Boot 3.5.0. 26 | - If you're using a Spring Boot version < 3.5.0, please continue using `httpexchange-spring-boot-starter` version 3.4.x. 27 | 28 | ### [3.2.0](https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/releases/tag/v3.2.0) 29 | 30 | - **Group ID Change**: The groupId has been updated from `com.freemanan` to `io.github.danielliu1123`. 31 | -------------------------------------------------------------------------------- /website/docusaurus.config.ts: -------------------------------------------------------------------------------- 1 | import {themes as prismThemes} from 'prism-react-renderer'; 2 | import type {Config} from '@docusaurus/types'; 3 | import type * as Preset from '@docusaurus/preset-classic'; 4 | 5 | const config: Config = { 6 | title: 'HttpExchange Spring Boot Starter', 7 | tagline: 'The best way to use @HttpExchange in Spring Boot', 8 | favicon: 'img/favicon.ico', 9 | 10 | // Set the production url of your site here 11 | url: 'https://danielliu1123.github.io', 12 | // Set the // pathname under which your site is served 13 | // For GitHub pages deployment, it is often '//' 14 | baseUrl: '/httpexchange-spring-boot-starter/', 15 | 16 | // GitHub pages deployment config. 17 | // If you aren't using GitHub pages, you don't need these. 18 | organizationName: 'danielliu1123', // Usually your GitHub org/user name. 19 | projectName: 'httpexchange-spring-boot-starter', // Usually your repo name. 20 | 21 | onBrokenLinks: 'throw', 22 | onBrokenMarkdownLinks: 'warn', 23 | 24 | // Even if you don't use internationalization, you can use this field to set 25 | // useful metadata like html lang. For example, if your site is Chinese, you 26 | // may want to replace "en" with "zh-Hans". 27 | i18n: { 28 | defaultLocale: 'en', 29 | locales: ['en'], 30 | }, 31 | 32 | presets: [ 33 | [ 34 | 'classic', 35 | { 36 | docs: { 37 | sidebarPath: './sidebars.ts', 38 | // Please change this to your repo. 39 | // Remove this to remove the "edit this page" links. 40 | editUrl: 'https://github.com/DanielLiu1123/httpexchange-spring-boot-starter/tree/main/website', 41 | }, 42 | theme: { 43 | customCss: './src/css/custom.css', 44 | }, 45 | } satisfies Preset.Options, 46 | ], 47 | ], 48 | 49 | themeConfig: { 50 | // Replace with your project's social card 51 | navbar: { 52 | title: 'Home', 53 | items: [ 54 | { 55 | type: 'docSidebar', 56 | sidebarId: 'tutorialSidebar', 57 | position: 'left', 58 | label: 'Docs', 59 | }, 60 | // { 61 | // type: 'localeDropdown', 62 | // position: 'right', 63 | // }, 64 | // { 65 | // type: 'docsVersionDropdown', 66 | // position: 'right', 67 | // }, 68 | // Copy from https://github.com/facebook/docusaurus/blob/main/website/docusaurus.config.ts 69 | { 70 | href: 'https://github.com/danielliu1123/httpexchange-spring-boot-starter', 71 | position: 'right', 72 | className: 'header-github-link', 73 | 'aria-label': 'GitHub repository', 74 | }, 75 | ], 76 | }, 77 | algolia: { 78 | // The application ID provided by Algolia 79 | appId: 'B5TXVPY7SP', 80 | // Public API key: it is safe to commit it 81 | apiKey: 'd1d0662c9bcd12936152178add34706d', 82 | indexName: 'danielliu1123io', 83 | // Optional: see doc section below 84 | contextualSearch: true, 85 | // Optional: path for search page that enabled by default (`false` to disable it) 86 | searchPagePath: 'search', 87 | }, 88 | prism: { 89 | theme: prismThemes.github, 90 | darkTheme: prismThemes.dracula, 91 | additionalLanguages: ['java'], 92 | magicComments: [ 93 | // Remember to extend the default highlight class name as well! 94 | { 95 | className: 'theme-code-block-highlighted-line', 96 | line: 'highlight-next-line', 97 | block: {start: 'highlight-start', end: 'highlight-end'}, 98 | }, 99 | // Customized 100 | { 101 | className: 'code-line-deleted', 102 | line: 'highlight-next-line-as-deleted', 103 | block: {start: 'highlight-deleted-start', end: 'highlight-deleted-end'}, 104 | }, 105 | { 106 | className: 'code-line-added', 107 | line: 'highlight-next-line-as-added', 108 | block: {start: 'highlight-added-start', end: 'highlight-added-end'}, 109 | }, 110 | ], 111 | }, 112 | } satisfies Preset.ThemeConfig, 113 | }; 114 | 115 | export default config; 116 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "^3.7.0", 19 | "@docusaurus/preset-classic": "^3.7.0", 20 | "@mdx-js/react": "^3.0.0", 21 | "clsx": "^2.0.0", 22 | "prism-react-renderer": "^2.3.0", 23 | "react": "^18.0.0", 24 | "react-dom": "^18.0.0" 25 | }, 26 | "devDependencies": { 27 | "@docusaurus/module-type-aliases": "^3.7.0", 28 | "@docusaurus/tsconfig": "3.1.1", 29 | "@docusaurus/types": "^3.7.0", 30 | "typescript": "~5.2.2" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.5%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 3 chrome version", 40 | "last 3 firefox version", 41 | "last 5 safari version" 42 | ] 43 | }, 44 | "engines": { 45 | "node": ">=18.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /website/sidebars.ts: -------------------------------------------------------------------------------- 1 | import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; 2 | 3 | /** 4 | * Creating a sidebar enables you to: 5 | - create an ordered group of docs 6 | - render a sidebar for each doc of that group 7 | - provide next/previous navigation 8 | 9 | The sidebars can be generated from the filesystem, or explicitly defined here. 10 | 11 | Create as many sidebars as you want. 12 | */ 13 | const sidebars: SidebarsConfig = { 14 | // By default, Docusaurus generates a sidebar from the docs folder structure 15 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 16 | }; 17 | 18 | export default sidebars; 19 | -------------------------------------------------------------------------------- /website/src/components/HomepageFeatures/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import Heading from '@theme/Heading'; 3 | import styles from './styles.module.css'; 4 | import React from "react"; 5 | 6 | type FeatureItem = { 7 | title: string; 8 | Svg?: React.ComponentType>; 9 | description: React.ReactElement; 10 | }; 11 | 12 | const FeatureList: FeatureItem[] = [ 13 | { 14 | title: 'Eliminate Boilerplate Code', 15 | Svg: null, 16 | description: ( 17 | <> 18 | Design for eliminating boilerplate code for @HttpExchange clients, providing Spring Cloud OpenFeign like experience. 19 | 20 | ), 21 | }, 22 | { 23 | title: 'Code Generation', 24 | Svg: null, 25 | description: ( 26 | <> 27 | Generate default server implementations from @HttpExchange interfaces, decoupling the server implementation from the API definition. 28 | 29 | ), 30 | }, 31 | { 32 | title: 'Designed for Extension', 33 | Svg: null, 34 | description: ( 35 | <> 36 | This library is designed to be extended and customized to suit your needs. 37 | 38 | ), 39 | }, 40 | ]; 41 | 42 | function Feature({title, Svg, description}: FeatureItem) { 43 | return ( 44 |

45 | {Svg &&
46 | 47 |
} 48 |
49 | {title} 50 |

{description}

51 |
52 |
53 | ); 54 | } 55 | 56 | export default function HomepageFeatures(): JSX.Element { 57 | return ( 58 |
59 |
60 |
61 | {FeatureList.map((props, idx) => ( 62 | 63 | ))} 64 |
65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /website/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureSvg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /website/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #2e8555; 10 | --ifm-color-primary-dark: #29784c; 11 | --ifm-color-primary-darker: #277148; 12 | --ifm-color-primary-darkest: #205d3b; 13 | --ifm-color-primary-light: #33925d; 14 | --ifm-color-primary-lighter: #359962; 15 | --ifm-color-primary-lightest: #3cad6e; 16 | --ifm-code-font-size: 95%; 17 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 18 | } 19 | 20 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 21 | [data-theme='dark'] { 22 | --ifm-color-primary: #25c2a0; 23 | --ifm-color-primary-dark: #21af90; 24 | --ifm-color-primary-darker: #1fa588; 25 | --ifm-color-primary-darkest: #1a8870; 26 | --ifm-color-primary-light: #29d5b0; 27 | --ifm-color-primary-lighter: #32d8b4; 28 | --ifm-color-primary-lightest: #4fddbf; 29 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 30 | } 31 | 32 | .code-line-deleted { 33 | background-color: #ff000020; 34 | display: block; 35 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 36 | padding: 0 var(--ifm-pre-padding); 37 | border-left: 3px solid #ff000080; 38 | } 39 | 40 | .code-line-added { 41 | background-color: #00ff0020; 42 | display: block; 43 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 44 | padding: 0 var(--ifm-pre-padding); 45 | border-left: 3px solid #00ff0080; 46 | } 47 | 48 | /* Copy from https://github.com/facebook/docusaurus/blob/main/website/src/css/custom.css */ 49 | .header-github-link::before { 50 | content: ''; 51 | width: 24px; 52 | height: 24px; 53 | display: flex; 54 | background-color: var(--ifm-navbar-link-color); 55 | mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E"); 56 | transition: background-color var(--ifm-transition-fast) 57 | var(--ifm-transition-timing-default); 58 | } 59 | 60 | .header-github-link:hover::before { 61 | background-color: var(--ifm-navbar-link-hover-color); 62 | } 63 | -------------------------------------------------------------------------------- /website/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | } 12 | 13 | @media screen and (max-width: 996px) { 14 | .heroBanner { 15 | padding: 2rem; 16 | } 17 | } 18 | 19 | .buttons { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | -------------------------------------------------------------------------------- /website/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import Link from '@docusaurus/Link'; 3 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 4 | import Layout from '@theme/Layout'; 5 | import HomepageFeatures from '@site/src/components/HomepageFeatures'; 6 | import Heading from '@theme/Heading'; 7 | 8 | import styles from './index.module.css'; 9 | 10 | function HomepageHeader() { 11 | const {siteConfig} = useDocusaurusContext(); 12 | return ( 13 |
14 |
15 | 16 | {siteConfig.title} 17 | 18 |

{siteConfig.tagline}

19 |
20 | 23 | Tutorial - 15min ⏱️ 24 | 25 |
26 |
27 |
28 | ); 29 | } 30 | 31 | export default function Home(): JSX.Element { 32 | const {siteConfig} = useDocusaurusContext(); 33 | return ( 34 | 37 | 38 |
39 | 40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /website/src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /website/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielLiu1123/httpexchange-spring-boot-starter/b377729609fab5e92d20277b647bf6a1d0b04dc1/website/static/.nojekyll -------------------------------------------------------------------------------- /website/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanielLiu1123/httpexchange-spring-boot-starter/b377729609fab5e92d20277b647bf6a1d0b04dc1/website/static/img/favicon.ico -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | } 7 | } 8 | --------------------------------------------------------------------------------