Skip to content

Commit 55ec78a

Browse files
committed
FINERACT-2679: Ensure backward compatible fineract-client method names
1 parent 4614d87 commit 55ec78a

9 files changed

Lines changed: 932 additions & 3 deletions

File tree

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Licensed to the Apache Software Foundation (ASF) under one
4+
# or more contributor license agreements. See the NOTICE file
5+
# distributed with this work for additional information
6+
# regarding copyright ownership. The ASF licenses this file
7+
# to you under the Apache License, Version 2.0 (the
8+
# "License"); you may not use this file except in compliance
9+
# with the License. You may obtain a copy of the License at
10+
#
11+
# http://www.apache.org/licenses/LICENSE-2.0
12+
#
13+
# Unless required by applicable law or agreed to in writing,
14+
# software distributed under the License is distributed on an
15+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
# KIND, either express or implied. See the License for the
17+
# specific language governing permissions and limitations
18+
# under the License.
19+
"""Check generated Fineract client API method-name compatibility."""
20+
21+
import argparse
22+
import os
23+
import re
24+
import sys
25+
from collections import OrderedDict
26+
from pathlib import Path
27+
28+
29+
CLIENT_API_DIRECTORIES = OrderedDict(
30+
[
31+
(
32+
"fineract-client",
33+
Path("fineract-client/build/generated/java/src/main/java/org/apache/fineract/client/services"),
34+
),
35+
(
36+
"fineract-client-feign",
37+
Path("fineract-client-feign/build/generated/java/src/main/java/org/apache/fineract/client/feign/services"),
38+
),
39+
]
40+
)
41+
42+
INTERFACE_PATTERN = re.compile(r"\binterface\s+([A-Za-z_$][\w$]*)\b")
43+
METHOD_PATTERN = re.compile(r"\b([A-Za-z_$][\w$]*)\s*\(")
44+
TYPE_DECLARATION_PATTERN = re.compile(r"\b(class|interface|enum|record)\b")
45+
46+
47+
def strip_comments_and_literals(source):
48+
result = []
49+
i = 0
50+
while i < len(source):
51+
if source.startswith("//", i):
52+
i += 2
53+
while i < len(source) and source[i] != "\n":
54+
i += 1
55+
if i < len(source):
56+
result.append("\n")
57+
i += 1
58+
continue
59+
60+
if source.startswith("/*", i):
61+
i += 2
62+
while i < len(source) and not source.startswith("*/", i):
63+
if source[i] == "\n":
64+
result.append("\n")
65+
i += 1
66+
i += 2
67+
continue
68+
69+
if source.startswith('"""', i):
70+
result.append('""')
71+
i += 3
72+
while i < len(source) and not source.startswith('"""', i):
73+
if source[i] == "\n":
74+
result.append("\n")
75+
i += 1
76+
i += 3
77+
continue
78+
79+
if source[i] == '"':
80+
result.append('""')
81+
i += 1
82+
escaped = False
83+
while i < len(source):
84+
char = source[i]
85+
i += 1
86+
if escaped:
87+
escaped = False
88+
elif char == "\\":
89+
escaped = True
90+
elif char == '"':
91+
break
92+
continue
93+
94+
if source[i] == "'":
95+
result.append("''")
96+
i += 1
97+
escaped = False
98+
while i < len(source):
99+
char = source[i]
100+
i += 1
101+
if escaped:
102+
escaped = False
103+
elif char == "\\":
104+
escaped = True
105+
elif char == "'":
106+
break
107+
continue
108+
109+
result.append(source[i])
110+
i += 1
111+
112+
return "".join(result)
113+
114+
115+
def strip_annotations(source):
116+
result = []
117+
i = 0
118+
while i < len(source):
119+
if source[i] != "@":
120+
result.append(source[i])
121+
i += 1
122+
continue
123+
124+
i += 1
125+
while i < len(source) and (source[i].isalnum() or source[i] in "_$."):
126+
i += 1
127+
while i < len(source) and source[i].isspace():
128+
i += 1
129+
130+
if i < len(source) and source[i] == "(":
131+
depth = 1
132+
i += 1
133+
while i < len(source) and depth > 0:
134+
if source[i] == "(":
135+
depth += 1
136+
elif source[i] == ")":
137+
depth -= 1
138+
i += 1
139+
140+
result.append(" ")
141+
142+
return "".join(result)
143+
144+
145+
def method_name_from_member(member):
146+
compact = " ".join(member.split())
147+
if not compact or "(" not in compact:
148+
return None
149+
if TYPE_DECLARATION_PATTERN.search(compact):
150+
return None
151+
152+
match = METHOD_PATTERN.search(compact)
153+
if not match:
154+
return None
155+
return match.group(1)
156+
157+
158+
def extract_api_methods(java_file):
159+
source = java_file.read_text(encoding="utf-8")
160+
source = strip_annotations(strip_comments_and_literals(source))
161+
162+
interface_match = INTERFACE_PATTERN.search(source)
163+
if not interface_match:
164+
return set()
165+
166+
body_start = source.find("{", interface_match.end())
167+
if body_start < 0:
168+
return set()
169+
170+
methods = set()
171+
depth = 1
172+
member = []
173+
i = body_start + 1
174+
while i < len(source) and depth > 0:
175+
char = source[i]
176+
if depth == 1:
177+
if char == "{":
178+
name = method_name_from_member("".join(member))
179+
if name:
180+
methods.add(name)
181+
member = []
182+
depth += 1
183+
elif char == ";":
184+
name = method_name_from_member("".join(member))
185+
if name:
186+
methods.add(name)
187+
member = []
188+
elif char == "}":
189+
depth -= 1
190+
member = []
191+
else:
192+
member.append(char)
193+
else:
194+
if char == "{":
195+
depth += 1
196+
elif char == "}":
197+
depth -= 1
198+
i += 1
199+
200+
return methods
201+
202+
203+
def collect_api_surface(source_root):
204+
if not source_root.is_dir():
205+
raise FileNotFoundError(f"Generated API directory does not exist: {source_root}")
206+
207+
surface = OrderedDict()
208+
for java_file in sorted(source_root.rglob("*Api.java")):
209+
relative_path = java_file.relative_to(source_root).as_posix()
210+
methods = extract_api_methods(java_file)
211+
if methods:
212+
surface[relative_path] = methods
213+
return surface
214+
215+
216+
def compare_surfaces(baseline_root, current_root):
217+
violations = []
218+
for client_name, api_directory in CLIENT_API_DIRECTORIES.items():
219+
baseline_surface = collect_api_surface(baseline_root / api_directory)
220+
current_surface = collect_api_surface(current_root / api_directory)
221+
222+
for api_file, baseline_methods in baseline_surface.items():
223+
current_methods = current_surface.get(api_file, set())
224+
missing_methods = sorted(baseline_methods - current_methods)
225+
if missing_methods:
226+
violations.append((client_name, api_file, missing_methods))
227+
228+
return violations
229+
230+
231+
def markdown_methods(methods):
232+
rendered = ", ".join(f"`{method}`" for method in methods[:20])
233+
if len(methods) > 20:
234+
rendered += f" +{len(methods) - 20} more"
235+
return rendered
236+
237+
238+
def build_report(violations):
239+
lines = ["## Generated Client API Method Compatibility", ""]
240+
if not violations:
241+
lines.append("No generated API method names were removed from `fineract-client` or `fineract-client-feign`.")
242+
return "\n".join(lines) + "\n"
243+
244+
lines.extend(
245+
[
246+
"The generated client API method names are not backward compatible.",
247+
"",
248+
"Existing generated method names that disappear should be preserved with `alternativeOperationId`.",
249+
"",
250+
"| Client | API | Removed method names |",
251+
"|--------|-----|----------------------|",
252+
]
253+
)
254+
255+
for client_name, api_file, missing_methods in violations:
256+
lines.append(f"| `{client_name}` | `{api_file}` | {markdown_methods(missing_methods)} |")
257+
258+
lines.extend(
259+
[
260+
"",
261+
f"**Total: {sum(len(methods) for _, _, methods in violations)} removed method names across {len(violations)} API files.**",
262+
]
263+
)
264+
return "\n".join(lines) + "\n"
265+
266+
267+
def append_step_summary(report):
268+
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
269+
if summary_file:
270+
with open(summary_file, "a", encoding="utf-8") as output:
271+
output.write(report)
272+
273+
274+
def parse_args():
275+
parser = argparse.ArgumentParser()
276+
parser.add_argument("--baseline-root", required=True, type=Path)
277+
parser.add_argument("--current-root", required=True, type=Path)
278+
parser.add_argument("--report-file", required=True, type=Path)
279+
return parser.parse_args()
280+
281+
282+
def main():
283+
args = parse_args()
284+
violations = compare_surfaces(args.baseline_root, args.current_root)
285+
report = build_report(violations)
286+
287+
args.report_file.write_text(report, encoding="utf-8")
288+
append_step_summary(report)
289+
print(report)
290+
291+
if violations:
292+
print(
293+
"::error::Generated client API method names were removed. "
294+
"Add alternativeOperationId entries to preserve backward-compatible methods."
295+
)
296+
return 1
297+
298+
return 0
299+
300+
301+
if __name__ == "__main__":
302+
sys.exit(main())

.github/workflows/full-build-ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ jobs:
121121
secrets:
122122
DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
123123

124+
run-generated-client-api-method-compatibility:
125+
needs: build-core
126+
uses: ./.github/workflows/verify-generated-client-api-method-compatibility.yml
127+
secrets:
128+
DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
129+
124130
run-liquibase-backward-compatibility:
125131
needs: build-core
126132
uses: ./.github/workflows/verify-liquibase-backward-compatibility.yml

0 commit comments

Comments
 (0)