You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@fineract.apache.org by ad...@apache.org on 2026/07/09 09:23:21 UTC
(fineract) 01/02: FINERACT-2679: Ensure backward compatible fineract-client method names
This is an automated email from the ASF dual-hosted git repository.
adamsaghy pushed a commit to branch FINERACT-2679
in repository https://gitbox.apache.org/repos/asf/fineract.git
commit ca354b4e4db43220fe7daf501d5ca28910997298
Author: Adam Saghy <ad...@gmail.com>
AuthorDate: Tue Jul 7 14:59:35 2026 +0200
FINERACT-2679: Ensure backward compatible fineract-client method names
---
.../check-generated-client-method-compatibility.py | 302 +++++++++++++++++++++
.github/workflows/full-build-ci.yml | 6 +
...y-generated-client-api-method-compatibility.yml | 90 ++++++
.../src/main/resources/templates/java/api.mustache | 107 ++++++++
.../src/main/resources/templates/java/api.mustache | 122 ++++++++-
.../core/annotation/AlternativeOperationId.java | 39 +++
.../openapi/FineractOperationIdReader.java | 130 ++++++++-
.../openapi/FineractOperationIdReaderTest.java | 137 ++++++++++
.../integrationtests/client/IntegrationTest.java | 2 +-
9 files changed, 932 insertions(+), 3 deletions(-)
diff --git a/.github/scripts/check-generated-client-method-compatibility.py b/.github/scripts/check-generated-client-method-compatibility.py
new file mode 100644
index 0000000000..f6d57a5639
--- /dev/null
+++ b/.github/scripts/check-generated-client-method-compatibility.py
@@ -0,0 +1,302 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Check generated Fineract client API method-name compatibility."""
+
+import argparse
+import os
+import re
+import sys
+from collections import OrderedDict
+from pathlib import Path
+
+
+CLIENT_API_DIRECTORIES = OrderedDict(
+ [
+ (
+ "fineract-client",
+ Path("fineract-client/build/generated/java/src/main/java/org/apache/fineract/client/services"),
+ ),
+ (
+ "fineract-client-feign",
+ Path("fineract-client-feign/build/generated/java/src/main/java/org/apache/fineract/client/feign/services"),
+ ),
+ ]
+)
+
+INTERFACE_PATTERN = re.compile(r"\binterface\s+([A-Za-z_$][\w$]*)\b")
+METHOD_PATTERN = re.compile(r"\b([A-Za-z_$][\w$]*)\s*\(")
+TYPE_DECLARATION_PATTERN = re.compile(r"\b(class|interface|enum|record)\b")
+
+
+def strip_comments_and_literals(source):
+ result = []
+ i = 0
+ while i < len(source):
+ if source.startswith("//", i):
+ i += 2
+ while i < len(source) and source[i] != "\n":
+ i += 1
+ if i < len(source):
+ result.append("\n")
+ i += 1
+ continue
+
+ if source.startswith("/*", i):
+ i += 2
+ while i < len(source) and not source.startswith("*/", i):
+ if source[i] == "\n":
+ result.append("\n")
+ i += 1
+ i += 2
+ continue
+
+ if source.startswith('"""', i):
+ result.append('""')
+ i += 3
+ while i < len(source) and not source.startswith('"""', i):
+ if source[i] == "\n":
+ result.append("\n")
+ i += 1
+ i += 3
+ continue
+
+ if source[i] == '"':
+ result.append('""')
+ i += 1
+ escaped = False
+ while i < len(source):
+ char = source[i]
+ i += 1
+ if escaped:
+ escaped = False
+ elif char == "\\":
+ escaped = True
+ elif char == '"':
+ break
+ continue
+
+ if source[i] == "'":
+ result.append("''")
+ i += 1
+ escaped = False
+ while i < len(source):
+ char = source[i]
+ i += 1
+ if escaped:
+ escaped = False
+ elif char == "\\":
+ escaped = True
+ elif char == "'":
+ break
+ continue
+
+ result.append(source[i])
+ i += 1
+
+ return "".join(result)
+
+
+def strip_annotations(source):
+ result = []
+ i = 0
+ while i < len(source):
+ if source[i] != "@":
+ result.append(source[i])
+ i += 1
+ continue
+
+ i += 1
+ while i < len(source) and (source[i].isalnum() or source[i] in "_$."):
+ i += 1
+ while i < len(source) and source[i].isspace():
+ i += 1
+
+ if i < len(source) and source[i] == "(":
+ depth = 1
+ i += 1
+ while i < len(source) and depth > 0:
+ if source[i] == "(":
+ depth += 1
+ elif source[i] == ")":
+ depth -= 1
+ i += 1
+
+ result.append(" ")
+
+ return "".join(result)
+
+
+def method_name_from_member(member):
+ compact = " ".join(member.split())
+ if not compact or "(" not in compact:
+ return None
+ if TYPE_DECLARATION_PATTERN.search(compact):
+ return None
+
+ match = METHOD_PATTERN.search(compact)
+ if not match:
+ return None
+ return match.group(1)
+
+
+def extract_api_methods(java_file):
+ source = java_file.read_text(encoding="utf-8")
+ source = strip_annotations(strip_comments_and_literals(source))
+
+ interface_match = INTERFACE_PATTERN.search(source)
+ if not interface_match:
+ return set()
+
+ body_start = source.find("{", interface_match.end())
+ if body_start < 0:
+ return set()
+
+ methods = set()
+ depth = 1
+ member = []
+ i = body_start + 1
+ while i < len(source) and depth > 0:
+ char = source[i]
+ if depth == 1:
+ if char == "{":
+ name = method_name_from_member("".join(member))
+ if name:
+ methods.add(name)
+ member = []
+ depth += 1
+ elif char == ";":
+ name = method_name_from_member("".join(member))
+ if name:
+ methods.add(name)
+ member = []
+ elif char == "}":
+ depth -= 1
+ member = []
+ else:
+ member.append(char)
+ else:
+ if char == "{":
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ i += 1
+
+ return methods
+
+
+def collect_api_surface(source_root):
+ if not source_root.is_dir():
+ raise FileNotFoundError(f"Generated API directory does not exist: {source_root}")
+
+ surface = OrderedDict()
+ for java_file in sorted(source_root.rglob("*Api.java")):
+ relative_path = java_file.relative_to(source_root).as_posix()
+ methods = extract_api_methods(java_file)
+ if methods:
+ surface[relative_path] = methods
+ return surface
+
+
+def compare_surfaces(baseline_root, current_root):
+ violations = []
+ for client_name, api_directory in CLIENT_API_DIRECTORIES.items():
+ baseline_surface = collect_api_surface(baseline_root / api_directory)
+ current_surface = collect_api_surface(current_root / api_directory)
+
+ for api_file, baseline_methods in baseline_surface.items():
+ current_methods = current_surface.get(api_file, set())
+ missing_methods = sorted(baseline_methods - current_methods)
+ if missing_methods:
+ violations.append((client_name, api_file, missing_methods))
+
+ return violations
+
+
+def markdown_methods(methods):
+ rendered = ", ".join(f"`{method}`" for method in methods[:20])
+ if len(methods) > 20:
+ rendered += f" +{len(methods) - 20} more"
+ return rendered
+
+
+def build_report(violations):
+ lines = ["## Generated Client API Method Compatibility", ""]
+ if not violations:
+ lines.append("No generated API method names were removed from `fineract-client` or `fineract-client-feign`.")
+ return "\n".join(lines) + "\n"
+
+ lines.extend(
+ [
+ "The generated client API method names are not backward compatible.",
+ "",
+ "Existing generated method names that disappear should be preserved with `alternativeOperationId`.",
+ "",
+ "| Client | API | Removed method names |",
+ "|--------|-----|----------------------|",
+ ]
+ )
+
+ for client_name, api_file, missing_methods in violations:
+ lines.append(f"| `{client_name}` | `{api_file}` | {markdown_methods(missing_methods)} |")
+
+ lines.extend(
+ [
+ "",
+ f"**Total: {sum(len(methods) for _, _, methods in violations)} removed method names across {len(violations)} API files.**",
+ ]
+ )
+ return "\n".join(lines) + "\n"
+
+
+def append_step_summary(report):
+ summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
+ if summary_file:
+ with open(summary_file, "a", encoding="utf-8") as output:
+ output.write(report)
+
+
+def parse_args():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--baseline-root", required=True, type=Path)
+ parser.add_argument("--current-root", required=True, type=Path)
+ parser.add_argument("--report-file", required=True, type=Path)
+ return parser.parse_args()
+
+
+def main():
+ args = parse_args()
+ violations = compare_surfaces(args.baseline_root, args.current_root)
+ report = build_report(violations)
+
+ args.report_file.write_text(report, encoding="utf-8")
+ append_step_summary(report)
+ print(report)
+
+ if violations:
+ print(
+ "::error::Generated client API method names were removed. "
+ "Add alternativeOperationId entries to preserve backward-compatible methods."
+ )
+ return 1
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/workflows/full-build-ci.yml b/.github/workflows/full-build-ci.yml
index b75c43becf..093857e948 100644
--- a/.github/workflows/full-build-ci.yml
+++ b/.github/workflows/full-build-ci.yml
@@ -121,6 +121,12 @@ jobs:
secrets:
DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
+ run-generated-client-api-method-compatibility:
+ needs: build-core
+ uses: ./.github/workflows/verify-generated-client-api-method-compatibility.yml
+ secrets:
+ DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
+
run-liquibase-backward-compatibility:
needs: build-core
uses: ./.github/workflows/verify-liquibase-backward-compatibility.yml
diff --git a/.github/workflows/verify-generated-client-api-method-compatibility.yml b/.github/workflows/verify-generated-client-api-method-compatibility.yml
new file mode 100644
index 0000000000..21e9208757
--- /dev/null
+++ b/.github/workflows/verify-generated-client-api-method-compatibility.yml
@@ -0,0 +1,90 @@
+name: Verify Generated Client API Method Compatibility
+
+on:
+ workflow_call:
+ secrets:
+ DEVELOCITY_ACCESS_KEY:
+ required: false
+
+permissions:
+ contents: read
+
+jobs:
+ generated-client-method-compatibility-check:
+ if: ${{ github.event_name == 'pull_request' }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 60
+
+ env:
+ TZ: Asia/Kolkata
+ DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
+
+ steps:
+ - name: Checkout base branch for baseline
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ repository: ${{ github.event.pull_request.base.repo.full_name }}
+ ref: ${{ github.event.pull_request.base.ref }}
+ fetch-depth: 0
+ path: baseline
+
+ - name: Checkout base branch for merged PR
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ repository: ${{ github.event.pull_request.base.repo.full_name }}
+ ref: ${{ github.event.pull_request.base.ref }}
+ fetch-depth: 0
+ path: current
+
+ - name: Merge PR into current base
+ working-directory: current
+ env:
+ PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
+ PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: |
+ git config user.name "github-actions"
+ git config user.email "github-actions@github.com"
+
+ case "$PR_HEAD_SHA" in
+ ""|*[!0-9a-fA-F]*)
+ echo "::error::Invalid pull request head SHA: $PR_HEAD_SHA"
+ exit 1
+ ;;
+ esac
+
+ git fetch "https://github.com/${PR_HEAD_REPO}.git" "$PR_HEAD_SHA" --no-tags
+
+ git merge --no-commit --no-ff FETCH_HEAD
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
+ with:
+ distribution: 'zulu'
+ java-version: '21'
+
+ - name: Generate baseline clients
+ working-directory: baseline
+ run: ./gradlew --no-daemon :fineract-client:cleanupGeneratedJavaFiles :fineract-client-feign:cleanupGeneratedJavaFiles
+
+ - name: Generate merged PR clients
+ working-directory: current
+ run: ./gradlew --no-daemon :fineract-client:cleanupGeneratedJavaFiles :fineract-client-feign:cleanupGeneratedJavaFiles
+
+ - name: Check generated client API method compatibility
+ run: |
+ python3 current/.github/scripts/check-generated-client-method-compatibility.py \
+ --baseline-root "${GITHUB_WORKSPACE}/baseline" \
+ --current-root "${GITHUB_WORKSPACE}/current" \
+ --report-file "${GITHUB_WORKSPACE}/generated-client-method-compatibility-report.md"
+
+ - name: Archive generated client compatibility report
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: generated-client-method-compatibility-report
+ path: generated-client-method-compatibility-report.md
+ if-no-files-found: ignore
+ retention-days: 5
+ compression-level: 9
diff --git a/fineract-client-feign/src/main/resources/templates/java/api.mustache b/fineract-client-feign/src/main/resources/templates/java/api.mustache
index 47f42a5244..093f713d55 100644
--- a/fineract-client-feign/src/main/resources/templates/java/api.mustache
+++ b/fineract-client-feign/src/main/resources/templates/java/api.mustache
@@ -103,6 +103,38 @@ public interface {{classname}} {
})
ApiResponse<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Void{{/returnType}}> {{nickname}}WithHttpInfo({{#allParams}}{{^isBodyParam}}{{^isFormParam}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/isFormParam}}{{#isFormParam}}@Param("{{baseName}}") {{/isFormParam}}{{/isBodyParam}}{{{dataType}}} {{paramName}}, {{/allParams}}@HeaderMap Map<String, String> headers);
+{{#vendorExtensions.x-alternative-operation-id}}
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}</code>.
+ * @deprecated Use <code>{{operationId}}</code> instead.
+ */
+ @Deprecated
+ default {{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} {{vendorExtensions.x-alternative-operation-id}}({{#allParams}}{{{dataType}}} {{paramName}}, {{/allParams}}Map<String, String> headers) {
+ {{#returnType}}return {{/returnType}}{{nickname}}({{#allParams}}{{paramName}}, {{/allParams}}headers);
+ }
+
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}</code> with empty headers.
+ * @deprecated Use <code>{{operationId}}</code> instead.
+ */
+ @Deprecated
+ default {{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} {{vendorExtensions.x-alternative-operation-id}}({{#allParams}}{{{dataType}}} {{paramName}}{{^-last}}, {{/-last}}{{/allParams}}) {
+ {{#returnType}}return {{/returnType}}{{nickname}}({{#allParams}}{{paramName}}{{^-last}}, {{/-last}}{{/allParams}});
+ }
+
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}WithHttpInfo</code>.
+ * @deprecated Use <code>{{operationId}}WithHttpInfo</code> instead.
+ */
+ @Deprecated
+ default ApiResponse<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Void{{/returnType}}> {{vendorExtensions.x-alternative-operation-id}}WithHttpInfo({{#allParams}}{{{dataType}}} {{paramName}}, {{/allParams}}Map<String, String> headers) {
+ return {{nickname}}WithHttpInfo({{#allParams}}{{paramName}}, {{/allParams}}headers);
+ }
+
+{{/vendorExtensions.x-alternative-operation-id}}
{{#hasQueryParams}}
/**
@@ -257,6 +289,58 @@ public interface {{classname}} {
})
ApiResponse<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Void{{/returnType}}> {{nickname}}WithHttpInfo({{#allParams}}{{^isQueryParam}}{{^isBodyParam}}{{^isFormParam}}{{^legacyDates}}@Param("{{paramName}}") {{/legacyDates}}{{#legacyDates}}@Param(value="{{paramName}}", expander=ParamExpander.class) {{/legacyDates}}{{/isFormParam}}{{#isFormParam}}@Param("{{baseName}}") {{/isFormParam}}{{/isBodyParam}}{{{dataType}}} {{paramName}}, {{/isQueryParam}}{{/allParams}}@QueryMap(encoded=tr [...]
+{{#vendorExtensions.x-alternative-operation-id}}
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}</code> using typed query parameters.
+ * @deprecated Use <code>{{operationId}}</code> instead.
+ */
+ @Deprecated
+ default {{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} {{vendorExtensions.x-alternative-operation-id}}({{#allParams}}{{^isQueryParam}}{{{dataType}}} {{paramName}}, {{/isQueryParam}}{{/allParams}}{{operationIdCamelCase}}QueryParams queryParams, Map<String, String> headers) {
+ {{#returnType}}return {{/returnType}}{{nickname}}({{#allParams}}{{^isQueryParam}}{{paramName}}, {{/isQueryParam}}{{/allParams}}queryParams, headers);
+ }
+
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}</code> using typed query parameters and empty headers.
+ * @deprecated Use <code>{{operationId}}</code> instead.
+ */
+ @Deprecated
+ default {{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} {{vendorExtensions.x-alternative-operation-id}}({{#allParams}}{{^isQueryParam}}{{{dataType}}} {{paramName}}, {{/isQueryParam}}{{/allParams}}{{operationIdCamelCase}}QueryParams queryParams) {
+ {{#returnType}}return {{/returnType}}{{nickname}}({{#allParams}}{{^isQueryParam}}{{paramName}}, {{/isQueryParam}}{{/allParams}}queryParams);
+ }
+
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}</code> using generic query parameters.
+ * @deprecated Use <code>{{operationId}}</code> instead.
+ */
+ @Deprecated
+ default {{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} {{vendorExtensions.x-alternative-operation-id}}({{#allParams}}{{^isQueryParam}}{{{dataType}}} {{paramName}}, {{/isQueryParam}}{{/allParams}}Map<String, Object> queryParams, Map<String, String> headers) {
+ {{#returnType}}return {{/returnType}}{{nickname}}({{#allParams}}{{^isQueryParam}}{{paramName}}, {{/isQueryParam}}{{/allParams}}queryParams, headers);
+ }
+
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}</code> using generic query parameters and empty headers.
+ * @deprecated Use <code>{{operationId}}</code> instead.
+ */
+ @Deprecated
+ default {{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} {{vendorExtensions.x-alternative-operation-id}}({{#allParams}}{{^isQueryParam}}{{{dataType}}} {{paramName}}, {{/isQueryParam}}{{/allParams}}Map<String, Object> queryParams) {
+ {{#returnType}}return {{/returnType}}{{nickname}}({{#allParams}}{{^isQueryParam}}{{paramName}}, {{/isQueryParam}}{{/allParams}}queryParams);
+ }
+
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}WithHttpInfo</code> using typed query parameters.
+ * @deprecated Use <code>{{operationId}}WithHttpInfo</code> instead.
+ */
+ @Deprecated
+ default ApiResponse<{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Void{{/returnType}}> {{vendorExtensions.x-alternative-operation-id}}WithHttpInfo({{#allParams}}{{^isQueryParam}}{{{dataType}}} {{paramName}}, {{/isQueryParam}}{{/allParams}}{{operationIdCamelCase}}QueryParams queryParams, Map<String, String> headers) {
+ return {{nickname}}WithHttpInfo({{#allParams}}{{^isQueryParam}}{{paramName}}, {{/isQueryParam}}{{/allParams}}queryParams, headers);
+ }
+
+{{/vendorExtensions.x-alternative-operation-id}}
/**
* A convenience class for generating query parameters for the
@@ -321,6 +405,29 @@ public interface {{classname}} {
{{#returnType}}return {{/returnType}}{{nickname}}Universal({{#allParams}}{{^isQueryParam}}{{paramName}}, {{/isQueryParam}}{{/allParams}}queryParams, Map.of());
}
+{{#vendorExtensions.x-alternative-operation-id}}
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}Universal</code>.
+ * @deprecated Use <code>{{operationId}}Universal</code> instead.
+ */
+ @Deprecated
+ default {{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} {{vendorExtensions.x-alternative-operation-id}}Universal({{#allParams}}{{^isQueryParam}}{{{dataType}}} {{paramName}}, {{/isQueryParam}}{{/allParams}}Map<String, Object> queryParams, Map<String, String> headers) {
+ {{#returnType}}return {{/returnType}}{{nickname}}Universal({{#allParams}}{{^isQueryParam}}{{paramName}}, {{/isQueryParam}}{{/allParams}}queryParams, headers);
+ }
+
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}Universal</code> with empty headers.
+ * @deprecated Use <code>{{operationId}}Universal</code> instead.
+ */
+ @Deprecated
+ default {{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}} {{vendorExtensions.x-alternative-operation-id}}Universal({{#allParams}}{{^isQueryParam}}{{{dataType}}} {{paramName}}, {{/isQueryParam}}{{/allParams}}Map<String, Object> queryParams) {
+ {{#returnType}}return {{/returnType}}{{nickname}}Universal({{#allParams}}{{^isQueryParam}}{{paramName}}, {{/isQueryParam}}{{/allParams}}queryParams);
+ }
+
+{{/vendorExtensions.x-alternative-operation-id}}
+
{{/operation}}
{{/operations}}
}
diff --git a/fineract-client/src/main/resources/templates/java/api.mustache b/fineract-client/src/main/resources/templates/java/api.mustache
index e16793e38d..4a5a691470 100644
--- a/fineract-client/src/main/resources/templates/java/api.mustache
+++ b/fineract-client/src/main/resources/templates/java/api.mustache
@@ -73,6 +73,36 @@ public interface {{classname}} {
@{{httpMethod}}("{{{path}}}")
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i [...]
+{{#vendorExtensions.x-alternative-operation-id}}
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}</code>.
+ {{#allParams}}
+ * @param {{paramName}} {{description}}{{#required}} (required){{/required}}{{^required}} (optional{{#defaultValue}}, default to {{.}}{{/defaultValue}}){{/required}}
+ {{/allParams}}
+ * @return {{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doN [...]
+ * @deprecated Use <code>{{operationId}}</code> instead.
+ */
+ @Deprecated
+ {{#formParams}}
+ {{#-first}}
+ {{#isMultipart}}@retrofit2.http.Multipart{{/isMultipart}}{{^isMultipart}}@retrofit2.http.FormUrlEncoded{{/isMultipart}}
+ {{/-first}}
+ {{/formParams}}
+ {{^formParams}}
+ {{#prioritizedContentTypes}}
+ {{#-first}}
+ @Headers({
+ "Content-Type:{{{mediaType}}}"
+ })
+ {{/-first}}
+ {{/prioritizedContentTypes}}
+ {{/formParams}}
+ @{{httpMethod}}("{{{path}}}")
+ {{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i [...]
+
+{{/vendorExtensions.x-alternative-operation-id}}
+
{{/operation}}
{{#operation}}
@@ -113,6 +143,35 @@ public interface {{classname}} {
{{/formParams}}
@{{httpMethod}}("{{{path}}}")
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i [...]
+{{#vendorExtensions.x-alternative-operation-id}}
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}</code>.
+ {{#allParams}}
+ * @param {{paramName}} {{description}}{{#required}} (required){{/required}}{{^required}} (optional{{#defaultValue}}, default to {{.}}{{/defaultValue}}){{/required}}
+ {{/allParams}}
+ * @return {{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{ [...]
+ * @deprecated Use <code>{{operationId}}</code> instead.
+ */
+ @Deprecated
+ {{#formParams}}
+ {{#-first}}
+ {{#isMultipart}}@retrofit2.http.Multipart{{/isMultipart}}{{^isMultipart}}@retrofit2.http.FormUrlEncoded{{/isMultipart}}
+ {{/-first}}
+ {{/formParams}}
+ {{^formParams}}
+ {{#prioritizedContentTypes}}
+ {{#-first}}
+ @Headers({
+ "Content-Type:{{{mediaType}}}"
+ })
+ {{/-first}}
+ {{/prioritizedContentTypes}}
+ {{/formParams}}
+ @{{httpMethod}}("{{{path}}}")
+ {{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i [...]
+
+{{/vendorExtensions.x-alternative-operation-id}}
{{/required}}
{{/isBodyParam}}
{{/allParams}}
@@ -154,6 +213,37 @@ public interface {{classname}} {
@{{httpMethod}}("{{{path}}}")
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i [...]
+{{#vendorExtensions.x-alternative-operation-id}}
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}</code> with request headers.
+ {{#allParams}}
+ * @param {{paramName}} {{description}}{{#required}} (required){{/required}}{{^required}} (optional{{#defaultValue}}, default to {{.}}{{/defaultValue}}){{/required}}
+ {{/allParams}}
+ * @param headers Custom headers to include in the request
+ * @return {{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{ [...]
+ * @deprecated Use <code>{{operationId}}</code> instead.
+ */
+ @Deprecated
+ {{#formParams}}
+ {{#-first}}
+ {{#isMultipart}}@retrofit2.http.Multipart{{/isMultipart}}{{^isMultipart}}@retrofit2.http.FormUrlEncoded{{/isMultipart}}
+ {{/-first}}
+ {{/formParams}}
+ {{^formParams}}
+ {{#prioritizedContentTypes}}
+ {{#-first}}
+ @Headers({
+ "Content-Type:{{{mediaType}}}"
+ })
+ {{/-first}}
+ {{/prioritizedContentTypes}}
+ {{/formParams}}
+ @{{httpMethod}}("{{{path}}}")
+ {{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i [...]
+
+{{/vendorExtensions.x-alternative-operation-id}}
+
{{/operation}}
{{#operation}}
@@ -194,9 +284,39 @@ public interface {{classname}} {
{{/formParams}}
@{{httpMethod}}("{{{path}}}")
{{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i [...]
+{{#vendorExtensions.x-alternative-operation-id}}
+ /**
+ * {{summary}}
+ * Alias for <code>{{operationId}}</code> with request headers.
+ {{#allParams}}
+ * @param {{paramName}} {{description}}{{#required}} (required){{/required}}{{^required}} (optional{{#defaultValue}}, default to {{.}}{{/defaultValue}}){{/required}}
+ {{/allParams}}
+ * @param headers Custom headers to include in the request
+ * @return {{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{returnType}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{ [...]
+ * @deprecated Use <code>{{operationId}}</code> instead.
+ */
+ @Deprecated
+ {{#formParams}}
+ {{#-first}}
+ {{#isMultipart}}@retrofit2.http.Multipart{{/isMultipart}}{{^isMultipart}}@retrofit2.http.FormUrlEncoded{{/isMultipart}}
+ {{/-first}}
+ {{/formParams}}
+ {{^formParams}}
+ {{#prioritizedContentTypes}}
+ {{#-first}}
+ @Headers({
+ "Content-Type:{{{mediaType}}}"
+ })
+ {{/-first}}
+ {{/prioritizedContentTypes}}
+ {{/formParams}}
+ @{{httpMethod}}("{{{path}}}")
+ {{^doNotUseRx}}{{#useRxJava2}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava2}}{{#useRxJava3}}{{#returnType}}Observable<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{{returnType}}}{{/isResponseFile}}>{{/returnType}}{{^returnType}}Completable{{/returnType}}{{/useRxJava3}}{{/doNotUseRx}}{{#doNotUseRx}}Call<{{#i [...]
+
+{{/vendorExtensions.x-alternative-operation-id}}
{{/required}}
{{/isBodyParam}}
{{/allParams}}
{{/operation}}
}
-{{/operations}}
\ No newline at end of file
+{{/operations}}
diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/annotation/AlternativeOperationId.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/annotation/AlternativeOperationId.java
new file mode 100644
index 0000000000..585ada4249
--- /dev/null
+++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/annotation/AlternativeOperationId.java
@@ -0,0 +1,39 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.infrastructure.core.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Adds an alternate generated client method name for an OpenAPI operation.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface AlternativeOperationId {
+
+ /**
+ * Java method name to generate as an alias for the Swagger {@code operationId}.
+ *
+ * @return the alternate operation ID
+ */
+ String value();
+}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReader.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReader.java
index 4721d10fd2..000ff1a108 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReader.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReader.java
@@ -30,14 +30,21 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import javax.lang.model.SourceVersion;
+import org.apache.fineract.infrastructure.core.annotation.AlternativeOperationId;
public final class FineractOperationIdReader extends Reader {
+ public static final String ALTERNATIVE_OPERATION_ID_EXTENSION = "x-alternative-operation-id";
+
// Check explicit @Operation ids first, then let Swagger build the spec.
@Override
public OpenAPI read(Set<Class<?>> classes, Map<String, Object> resources) {
ExplicitOperationValidator.validate(classes);
- return super.read(classes, resources);
+ OpenAPI openAPI = super.read(classes, resources);
+ OperationIdValidator.validate(openAPI);
+ AlternativeOperationIdExtension.apply(classes, openAPI);
+ return openAPI;
}
static final class ExplicitOperationValidator {
@@ -74,6 +81,127 @@ public final class FineractOperationIdReader extends Reader {
}
}
+ static final class OperationIdValidator {
+
+ static void validate(OpenAPI openAPI) {
+ Map<String, List<String>> operationIds = collectOperationIds(openAPI);
+
+ List<String> duplicates = operationIds.entrySet().stream().filter(e -> e.getValue().size() > 1)
+ .map(e -> e.getKey() + " -> " + String.join(", ", e.getValue())).sorted().toList();
+ if (!duplicates.isEmpty()) {
+ throw new IllegalStateException("Duplicate OpenAPI operationIds detected:\n - " + String.join("\n - ", duplicates));
+ }
+ }
+
+ static Map<String, List<String>> collectOperationIds(OpenAPI openAPI) {
+ Map<String, List<String>> operationIds = new LinkedHashMap<>();
+ if (openAPI == null || openAPI.getPaths() == null) {
+ return operationIds;
+ }
+
+ openAPI.getPaths().forEach((path, pathItem) -> pathItem.readOperationsMap().forEach((httpMethod, operation) -> {
+ String operationId = trimToNull(operation.getOperationId());
+ if (operationId != null) {
+ operationIds.computeIfAbsent(operationId, ignored -> new ArrayList<>()).add(httpMethod + " " + path);
+ }
+ }));
+ return operationIds;
+ }
+ }
+
+ static final class AlternativeOperationIdExtension {
+
+ static void apply(Set<Class<?>> classes, OpenAPI openAPI) {
+ Map<String, String> alternativeOperationIds = collect(classes);
+ Map<String, List<String>> operationIds = OperationIdValidator.collectOperationIds(openAPI);
+ validateAlternativeOperationIdTargets(alternativeOperationIds, operationIds);
+ validateNoCollisionWithOperationIds(alternativeOperationIds, operationIds);
+
+ if (alternativeOperationIds.isEmpty() || openAPI == null || openAPI.getPaths() == null) {
+ return;
+ }
+
+ openAPI.getPaths().values().forEach(pathItem -> pathItem.readOperations().forEach(operation -> {
+ String alternativeOperationId = alternativeOperationIds.get(operation.getOperationId());
+ if (alternativeOperationId != null) {
+ operation.addExtension(ALTERNATIVE_OPERATION_ID_EXTENSION, alternativeOperationId);
+ }
+ }));
+ }
+
+ private static Map<String, String> collect(Set<Class<?>> classes) {
+ Map<String, String> alternativeOperationIds = new LinkedHashMap<>();
+ Map<String, List<String>> duplicateAlternativeOperationIds = new LinkedHashMap<>();
+
+ for (Class<?> resourceClass : classes) {
+ if (resourceClass.getAnnotation(Path.class) == null) {
+ continue;
+ }
+
+ for (Method method : resourceClass.getMethods()) {
+ Operation operation = method.getAnnotation(Operation.class);
+ String operationId = trimToNull(operation == null ? null : operation.operationId());
+
+ AlternativeOperationId alternativeOperationId = method.getAnnotation(AlternativeOperationId.class);
+ if (alternativeOperationId == null) {
+ continue;
+ }
+
+ String source = resourceClass.getSimpleName() + "#" + method.getName();
+ if (!hasHttpMethod(method)) {
+ throw new IllegalStateException("@AlternativeOperationId can only be used on JAX-RS resource methods: " + source);
+ }
+
+ String value = trimToNull(alternativeOperationId.value());
+ if (value == null) {
+ throw new IllegalStateException("@AlternativeOperationId value must not be blank: " + source);
+ }
+ if (!SourceVersion.isIdentifier(value) || SourceVersion.isKeyword(value)) {
+ throw new IllegalStateException(
+ "@AlternativeOperationId must be a valid Java method name: " + source + " -> " + value);
+ }
+
+ String resolvedOperationId = operationId == null ? method.getName() : operationId;
+ if (resolvedOperationId.equals(value)) {
+ continue;
+ }
+
+ duplicateAlternativeOperationIds.computeIfAbsent(value, ignored -> new ArrayList<>()).add(source);
+ alternativeOperationIds.put(resolvedOperationId, value);
+ }
+ }
+
+ List<String> duplicates = duplicateAlternativeOperationIds.entrySet().stream().filter(e -> e.getValue().size() > 1)
+ .map(e -> e.getKey() + " -> " + String.join(", ", e.getValue())).sorted().toList();
+ if (!duplicates.isEmpty()) {
+ throw new IllegalStateException(
+ "Duplicate explicit OpenAPI alternativeOperationIds detected:\n - " + String.join("\n - ", duplicates));
+ }
+ return alternativeOperationIds;
+ }
+
+ private static void validateAlternativeOperationIdTargets(Map<String, String> alternativeOperationIds,
+ Map<String, List<String>> operationIds) {
+ List<String> missing = alternativeOperationIds.keySet().stream().filter(operationId -> !operationIds.containsKey(operationId))
+ .sorted().toList();
+ if (!missing.isEmpty()) {
+ throw new IllegalStateException(
+ "OpenAPI alternativeOperationIds could not be matched to operationIds:\n - " + String.join("\n - ", missing));
+ }
+ }
+
+ private static void validateNoCollisionWithOperationIds(Map<String, String> alternativeOperationIds,
+ Map<String, List<String>> operationIds) {
+ List<String> conflicts = alternativeOperationIds.entrySet().stream().filter(e -> operationIds.containsKey(e.getValue()))
+ .map(e -> e.getKey() + " -> " + e.getValue() + " conflicts with " + String.join(", ", operationIds.get(e.getValue())))
+ .sorted().toList();
+ if (!conflicts.isEmpty()) {
+ throw new IllegalStateException(
+ "OpenAPI alternativeOperationIds conflict with operationIds:\n - " + String.join("\n - ", conflicts));
+ }
+ }
+ }
+
// GET, POST, etc. are meta-annotated with @HttpMethod.
private static boolean hasHttpMethod(Method method) {
for (Annotation annotation : method.getAnnotations()) {
diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReaderTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReaderTest.java
new file mode 100644
index 0000000000..f58e40ce5b
--- /dev/null
+++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/openapi/FineractOperationIdReaderTest.java
@@ -0,0 +1,137 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.infrastructure.openapi;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.PathItem;
+import io.swagger.v3.oas.models.Paths;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import java.util.Map;
+import java.util.Set;
+import org.apache.fineract.infrastructure.core.annotation.AlternativeOperationId;
+import org.junit.jupiter.api.Test;
+
+class FineractOperationIdReaderTest {
+
+ @Test
+ void addsAlternativeOperationIdExtension() {
+ OpenAPI openAPI = new FineractOperationIdReader().read(Set.of(ResourceWithAlternativeOperationId.class), Map.of());
+
+ Object alternativeOperationId = openAPI.getPaths().get("/test").getGet().getExtensions()
+ .get(FineractOperationIdReader.ALTERNATIVE_OPERATION_ID_EXTENSION);
+
+ assertEquals("retrieveTestByLegacyName", alternativeOperationId);
+ }
+
+ @Test
+ void addsAlternativeOperationIdExtensionForImplicitOperationId() {
+ OpenAPI openAPI = new FineractOperationIdReader().read(Set.of(ResourceWithImplicitOperationIdAlternative.class), Map.of());
+
+ Object alternativeOperationId = openAPI.getPaths().get("/implicit").getGet().getExtensions()
+ .get(FineractOperationIdReader.ALTERNATIVE_OPERATION_ID_EXTENSION);
+
+ assertEquals("retrieveImplicitTestByLegacyName", alternativeOperationId);
+ }
+
+ @Test
+ void rejectsInvalidAlternativeOperationId() {
+ IllegalStateException exception = assertThrows(IllegalStateException.class,
+ () -> new FineractOperationIdReader().read(Set.of(ResourceWithInvalidAlternativeOperationId.class), Map.of()));
+
+ assertTrue(exception.getMessage().contains("valid Java method name"));
+ }
+
+ @Test
+ void rejectsAlternativeOperationIdConflictingWithImplicitOperationId() {
+ IllegalStateException exception = assertThrows(IllegalStateException.class, () -> new FineractOperationIdReader()
+ .read(Set.of(ResourceWithAlternativeOperationIdConflict.class, ResourceWithImplicitOperationId.class), Map.of()));
+
+ assertTrue(exception.getMessage().contains("alternativeOperationIds conflict with operationIds"));
+ }
+
+ @Test
+ void rejectsDuplicateFinalOperationIds() {
+ OpenAPI openAPI = new OpenAPI().paths(new Paths()
+ .addPathItem("/first", new PathItem().get(new io.swagger.v3.oas.models.Operation().operationId("duplicateOperation")))
+ .addPathItem("/second", new PathItem().post(new io.swagger.v3.oas.models.Operation().operationId("duplicateOperation"))));
+
+ IllegalStateException exception = assertThrows(IllegalStateException.class,
+ () -> FineractOperationIdReader.OperationIdValidator.validate(openAPI));
+
+ assertTrue(exception.getMessage().contains("Duplicate OpenAPI operationIds"));
+ }
+
+ @Path("/test")
+ public static class ResourceWithAlternativeOperationId {
+
+ @GET
+ @Operation(operationId = "retrieveTest")
+ @AlternativeOperationId("retrieveTestByLegacyName")
+ public String retrieveTest() {
+ return "test";
+ }
+ }
+
+ @Path("/implicit")
+ public static class ResourceWithImplicitOperationIdAlternative {
+
+ @GET
+ @AlternativeOperationId("retrieveImplicitTestByLegacyName")
+ public String retrieveImplicitTest() {
+ return "implicit";
+ }
+ }
+
+ @Path("/invalid")
+ public static class ResourceWithInvalidAlternativeOperationId {
+
+ @GET
+ @Operation(operationId = "retrieveInvalid")
+ @AlternativeOperationId("class")
+ public String retrieveInvalid() {
+ return "invalid";
+ }
+ }
+
+ @Path("/conflict")
+ public static class ResourceWithAlternativeOperationIdConflict {
+
+ @GET
+ @Operation(operationId = "retrieveConflict")
+ @AlternativeOperationId("retrieveImplicitConflict")
+ public String retrieveConflict() {
+ return "conflict";
+ }
+ }
+
+ @Path("/implicit-conflict")
+ public static class ResourceWithImplicitOperationId {
+
+ @GET
+ public String retrieveImplicitConflict() {
+ return "implicit-conflict";
+ }
+ }
+}
diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/IntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/IntegrationTest.java
index 4031caeb5d..180e7ea201 100644
--- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/IntegrationTest.java
+++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/IntegrationTest.java
@@ -133,7 +133,7 @@ public abstract class IntegrationTest {
try {
globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE,
new PutGlobalConfigurationsRequest().enabled(true));
- businessDateHelper.updateBusinessDate(new BusinessDateUpdateRequest().type(BusinessDateUpdateRequest.TypeEnum.BUSINESS_DATE)
+ BusinessDateHelper.updateBusinessDate(new BusinessDateUpdateRequest().type(BusinessDateUpdateRequest.TypeEnum.BUSINESS_DATE)
.date(date).dateFormat(DATETIME_PATTERN).locale("en"));
runnable.run();
} finally {