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 <[email protected]> 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 "[email protected]" + + 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 {
