FINERACT-2081: add api verification workflow

This commit is contained in:
Attila Budai
2026-02-23 15:43:14 +00:00
committed by Adam Saghy
parent 4a02c26059
commit 2047553366
6 changed files with 440 additions and 1 deletions
@@ -0,0 +1,225 @@
name: Verify API Backward Compatibility
on: [pull_request]
permissions:
contents: read
pull-requests: write
jobs:
api-compatibility-check:
runs-on: ubuntu-24.04
timeout-minutes: 15
env:
TZ: Asia/Kolkata
steps:
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.ref }}
fetch-depth: 0
path: baseline
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: '21'
- name: Generate baseline spec
working-directory: baseline
run: ./gradlew :fineract-provider:resolve --no-daemon
- name: Checkout PR branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
path: current
- name: Generate PR spec
working-directory: current
run: ./gradlew :fineract-provider:resolve --no-daemon
- name: Sanitize specs
run: |
python3 -c "
import json, sys
def sanitize(path):
with open(path) as f:
spec = json.load(f)
fixed = 0
for path_item in spec.get('paths', {}).values():
for op in path_item.values():
if not isinstance(op, dict) or 'requestBody' not in op:
continue
for media in op['requestBody'].get('content', {}).values():
if 'schema' not in media:
media['schema'] = {'type': 'object'}
fixed += 1
if fixed:
with open(path, 'w') as f:
json.dump(spec, f)
print(f'{path}: fixed {fixed} entries')
sanitize('${GITHUB_WORKSPACE}/baseline/fineract-provider/build/resources/main/static/fineract.json')
sanitize('${GITHUB_WORKSPACE}/current/fineract-provider/build/resources/main/static/fineract.json')
"
- name: Check breaking changes
id: breaking-check
continue-on-error: true
working-directory: current
run: |
set -o pipefail
./gradlew :fineract-provider:checkBreakingChanges \
-PapiBaseline="${GITHUB_WORKSPACE}/baseline/fineract-provider/build/resources/main/static/fineract.json" \
--no-daemon --quiet 2>&1 | tail -50
- name: Build report
if: steps.breaking-check.outcome == 'failure'
id: report
run: |
REPORT_DIR="current/fineract-provider/build/swagger-brake"
python3 -c "
import json, glob, os
from collections import defaultdict
RULE_DESC = {
'R001': 'Standard API changed to beta',
'R002': 'Path deleted',
'R003': 'Request media type deleted',
'R004': 'Request parameter deleted',
'R005': 'Request parameter enum value deleted',
'R006': 'Request parameter location changed',
'R007': 'Request parameter made required',
'R008': 'Request parameter type changed',
'R009': 'Request attribute removed',
'R010': 'Request type changed',
'R011': 'Request enum value deleted',
'R012': 'Response code deleted',
'R013': 'Response media type deleted',
'R014': 'Response attribute removed',
'R015': 'Response type changed',
'R016': 'Response enum value deleted',
'R017': 'Request parameter constraint changed',
}
report_dir = '${REPORT_DIR}'
files = sorted(glob.glob(os.path.join(report_dir, '*.json')))
if not files:
body = 'Breaking change detected but no report file found.'
else:
with open(files[0]) as f:
data = json.load(f)
all_changes = []
for items in data.get('breakingChanges', {}).values():
all_changes.extend(items)
if not all_changes:
body = 'Breaking change detected but no details available in report.'
else:
def detail(c):
for key in ('attributeName', 'attribute', 'name', 'mediaType', 'enumValue', 'code'):
v = c.get(key)
if v:
val = v.rsplit('.', 1)[-1]
if key in ('attributeName', 'attribute', 'name'):
return val
return f'{key}={val}'
return '-'
groups = defaultdict(list)
for c in all_changes:
groups[(c.get('ruleCode', '?'), detail(c))].append(c)
lines = []
lines.append('| Rule | Description | Detail | Affected endpoints | Count |')
lines.append('|------|-------------|--------|--------------------|-------|')
for (rule, det), items in sorted(groups.items()):
desc = RULE_DESC.get(rule, '')
eps = sorted(set(
f'{c.get(\"method\",\"\")} {c.get(\"path\",\"\")}'
for c in items if c.get('path')
))
ep_str = ', '.join(f'\`{e}\`' for e in eps[:5])
if len(eps) > 5:
ep_str += f' +{len(eps)-5} more'
lines.append(f'| {rule} | {desc} | \`{det}\` | {ep_str} | {len(items)} |')
lines.append('')
lines.append(f'**Total: {len(all_changes)} violations across {len(groups)} unique changes**')
body = '\n'.join(lines)
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write('has_report=true\n')
report_file = '${GITHUB_WORKSPACE}/breaking-changes-report.md'
with open(report_file, 'w') as f:
f.write('## Breaking API Changes Detected\n\n')
f.write(body)
f.write('\n\n> **Note:** This check is informational only and does not block the PR.\n')
# Also write to step summary
with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as f:
f.write('## Breaking API Changes Detected\n\n')
f.write(body)
f.write('\n\n> **Note:** This check is informational only and does not block the PR.\n')
"
- name: Comment on PR
if: always()
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
MARKER="<!-- swagger-brake-report -->"
# Find existing comment by marker
COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
--jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" | head -1)
if [ "${{ steps.breaking-check.outcome }}" == "failure" ] && [ -f "${GITHUB_WORKSPACE}/breaking-changes-report.md" ]; then
# Prepend marker to the report
BODY="${MARKER}
$(cat ${GITHUB_WORKSPACE}/breaking-changes-report.md)"
if [ -n "$COMMENT_ID" ]; then
gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
-X PATCH -f body="${BODY}"
else
gh pr comment "${PR_NUMBER}" --repo ${{ github.repository }} --body "${BODY}"
fi
elif [ -n "$COMMENT_ID" ]; then
# No breaking changes anymore, delete the old comment
gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" -X DELETE
fi
- name: Report no breaking changes
if: steps.breaking-check.outcome == 'success'
run: |
echo "## No Breaking API Changes Detected" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The API contract is backward compatible." >> $GITHUB_STEP_SUMMARY
- name: Archive breaking change report
if: always()
uses: actions/upload-artifact@v4
with:
name: api-compatibility-report
path: current/fineract-provider/build/swagger-brake/
retention-days: 30
- name: Fail if breaking changes detected
if: steps.breaking-check.outcome == 'failure'
run: |
echo "::error::Breaking API changes detected. See the report above for details."
exit 1
+1
View File
@@ -125,6 +125,7 @@ plugins {
id 'com.gradleup.shadow' version '8.3.5' apply false id 'com.gradleup.shadow' version '8.3.5' apply false
id 'me.champeau.jmh' version '0.7.1' apply false id 'me.champeau.jmh' version '0.7.1' apply false
id 'org.cyclonedx.bom' version '3.1.0' apply false id 'org.cyclonedx.bom' version '3.1.0' apply false
id 'com.docktape.swagger-brake' version '2.7.0' apply false
} }
apply from: "${rootDir}/buildSrc/src/main/groovy/org.apache.fineract.release.gradle" apply from: "${rootDir}/buildSrc/src/main/groovy/org.apache.fineract.release.gradle"
@@ -0,0 +1,190 @@
= API Backward Compatibility
== Overview
Apache Fineract enforces API backward compatibility using https://github.com/docktape/swagger-brake[swagger-brake], an automated tool that compares OpenAPI specifications between the base branch and a pull request to detect breaking changes. This ensures that existing API consumers are not broken when new changes are deployed.
The check runs automatically on every pull request via the `verify-api-backward-compatibility.yml` GitHub Actions workflow.
== How It Works
The workflow follows these steps:
. **Generate baseline spec** — Checks out the base branch (e.g. `develop`) and runs `./gradlew :fineract-provider:resolve` to generate the current OpenAPI specification.
. **Generate PR spec** — Checks out the PR branch and generates its OpenAPI specification.
. **Sanitize specs** — Patches known issues in the generated specs (e.g. missing `schema` entries in `requestBody` content) to prevent false positives.
. **Compare** — Runs `checkBreakingChanges` via the swagger-brake Gradle plugin to compare old vs new specs.
. **Report** — If breaking changes are found:
** A deduplicated summary table is written to the GitHub Actions Step Summary (visible on the workflow run page).
** A comment is posted on the PR (when token permissions allow).
** The full JSON report is archived as a build artifact.
** The workflow **fails**, blocking the PR.
== Breaking Change Rules
swagger-brake detects the following categories of breaking changes:
=== Endpoint Rules
[cols="1,3", options="header"]
|===
| Rule | Description
| R001 | A stable (non-beta) API was changed to beta
| R002 | An API path was deleted
|===
=== Request Rules
[cols="1,3", options="header"]
|===
| Rule | Description
| R003 | A request media type (content type) was removed
| R004 | A request parameter was deleted
| R005 | An enum value was removed from a request parameter
| R006 | A parameter location changed (e.g. `query` to `header`)
| R007 | A parameter was made required
| R008 | A parameter type was changed
| R009 | An attribute was removed from a request body schema
| R010 | A property type was changed in a request schema
| R011 | An enum value was removed from a request body schema
|===
=== Response Rules
[cols="1,3", options="header"]
|===
| Rule | Description
| R012 | A response code was deleted
| R013 | A response media type was removed
| R014 | An attribute was removed from a response schema
| R015 | A property type was changed in a response schema
| R016 | An enum value was removed from a response schema
|===
=== Constraint Rules
[cols="1,3", options="header"]
|===
| Rule | Description
| R017 | A request parameter constraint was tightened (covers `maxLength`, `minLength`, `maximum`, `minimum`, `maxItems`, `minItems`, `uniqueItems`)
|===
== Gradle Configuration
The swagger-brake plugin is configured in `fineract-provider/build.gradle`:
[source,groovy]
----
apply plugin: 'com.docktape.swagger-brake'
swaggerBrake {
newApi = "${project.buildDir}/resources/main/static/fineract.json"
oldApi = findProperty('apiBaseline') ?: "${projectDir}/config/swagger/fineract-baseline.json"
outputFormats = ['JSON']
outputFilePath = "${project.buildDir}/swagger-brake"
deprecatedApiDeletionAllowed = true
strictValidation = false
}
----
=== Configuration Options
[cols="2,1,4", options="header"]
|===
| Option | Default | Description
| `newApi` | — | Path to the new (PR branch) OpenAPI spec. Generated by the `resolve` task.
| `oldApi` | — | Path to the baseline OpenAPI spec. Provided via `-PapiBaseline` in CI, or falls back to a local file.
| `outputFormats` | `['STDOUT', 'HTML']` | Report formats. We use `['JSON']` to avoid STDOUT spam and parse the report programmatically.
| `outputFilePath` | `build/swagger-brake` | Directory for generated reports.
| `deprecatedApiDeletionAllowed` | `true` | When `true`, removing a deprecated endpoint is NOT a breaking change.
| `strictValidation` | `true` | When `false`, schemas without an explicit `type` field log a warning instead of failing. Set to `false` for Fineract because the generated spec has many type-less schemas.
| `excludedPaths` | `[]` | List of path prefixes to skip (e.g. `['/v1/smscampaigns', '/v1/internal']`). Useful for excluding endpoints undergoing cleanup.
| `ignoredBreakingChangeRules` | `[]` | List of rule codes to suppress entirely (e.g. `['R001']`).
| `betaApiExtensionName` | `x-beta-api` | Vendor extension name for marking beta APIs. Beta endpoints can be freely modified without triggering violations.
| `maxLogSerializationDepth` | `3` | Controls nested object serialization depth in logs (range 1-20). Increase if you see `StackOverflowError` from circular schema references.
|===
== Running Locally
To run the check locally, you need a baseline spec to compare against:
[source,bash]
----
# 1. Generate the baseline from develop
git stash
git checkout develop
./gradlew :fineract-provider:resolve --no-daemon
cp fineract-provider/build/resources/main/static/fineract.json /tmp/baseline.json
git checkout -
git stash pop
# 2. Generate your current spec and compare
./gradlew :fineract-provider:checkBreakingChanges \
-PapiBaseline="/tmp/baseline.json" \
--no-daemon
----
The JSON report is written to `fineract-provider/build/swagger-brake/`.
== Handling Breaking Changes
=== Intentional Breaking Changes
If your PR intentionally introduces a breaking API change (e.g. removing a deprecated field):
. The workflow will fail and report the violations.
. Document the breaking change in the PR description with justification.
. A committer will review and approve the PR with the understanding that the API contract is changing.
=== Excluding Paths Under Cleanup
If you need to fix incorrect API annotations on endpoints that are not yet stable, use `excludedPaths` to temporarily exclude them from checking:
[source,groovy]
----
swaggerBrake {
excludedPaths = [
'/v1/smscampaigns',
'/v1/email',
]
}
----
Path exclusion is **prefix-based** — excluding `/v1/smscampaigns` will skip all paths starting with that prefix.
Remove the exclusion once the cleanup is complete.
=== Marking Endpoints as Beta
For endpoints that are experimental or under active development, mark them as beta in the Java code:
[source,java]
----
@Operation(
summary = "...",
extensions = @Extension(
properties = @ExtensionProperty(name = "x-beta-api", value = "true")
)
)
----
Beta endpoints can be freely modified, created, or removed without triggering violations. Promoting a beta endpoint to stable (removing the extension) is also non-breaking. However, demoting a stable endpoint to beta **is** a breaking change (R001).
== Report Format
When breaking changes are detected, the workflow produces a deduplicated summary table:
[cols="1,2,1,3,1", options="header"]
|===
| Rule | Description | Detail | Affected endpoints | Count
| R014 | Response attribute removed | `totalOverpaid` | `GET /v1/loans`, `GET /v1/loans/{loanId}`, `GET /v1/loans/external-id/{loanExternalId}` | 3
|===
The deduplication groups violations by rule code and affected attribute, collapsing multiple endpoint occurrences into a single row. This is important because a single schema change (e.g. removing a field from a shared response type) can generate dozens of raw violations — one per endpoint that uses that schema.
== Tool Reference
* **Tool**: https://github.com/docktape/swagger-brake[swagger-brake] v2.7.0
* **Gradle plugin**: `com.docktape.swagger-brake`
* **Documentation**: https://docktape.github.io/swagger-brake/
* **License**: Apache 2.0
@@ -35,3 +35,5 @@ include::business-date.adoc[leveloffset=+1]
include::reliable-event-framework.adoc[leveloffset=+1] include::reliable-event-framework.adoc[leveloffset=+1]
include::advanced-payment-allocation.adoc[leveloffset=+1] include::advanced-payment-allocation.adoc[leveloffset=+1]
include::api-backward-compatibility.adoc[leveloffset=+1]
@@ -197,7 +197,8 @@ public class ImagesApiResource {
@PUT @PUT
@Consumes(MediaType.MULTIPART_FORM_DATA) @Consumes(MediaType.MULTIPART_FORM_DATA)
@RequestBody(description = "Update image", content = { @Content(mediaType = MediaType.MULTIPART_FORM_DATA) }) @RequestBody(description = "Update image", content = {
@Content(mediaType = MediaType.MULTIPART_FORM_DATA, schema = @io.swagger.v3.oas.annotations.media.Schema(type = "object")) })
public ImageCreateResponse updateImage(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityName, public ImageCreateResponse updateImage(@PathParam(DOCUMENT_API_PARAM_ENTITY_TYPE) final String entityName,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId, @HeaderParam(CONTENT_LENGTH) final Long fileSize, @PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId, @HeaderParam(CONTENT_LENGTH) final Long fileSize,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final InputStream inputStream, @FormDataParam(DOCUMENT_API_PARAM_FILE) final InputStream inputStream,
+20
View File
@@ -27,6 +27,26 @@ apply plugin: 'io.swagger.core.v3.swagger-gradle-plugin'
apply plugin: 'com.google.cloud.tools.jib' apply plugin: 'com.google.cloud.tools.jib'
apply plugin: 'org.springframework.boot' apply plugin: 'org.springframework.boot'
apply plugin: 'se.thinkcode.cucumber-runner' apply plugin: 'se.thinkcode.cucumber-runner'
apply plugin: 'com.docktape.swagger-brake'
swaggerBrake {
newApi = "${project.buildDir}/resources/main/static/fineract.json"
oldApi = findProperty('apiBaseline') ?: "${projectDir}/config/swagger/fineract-baseline.json"
outputFormats = ['JSON']
outputFilePath = "${project.buildDir}/swagger-brake"
deprecatedApiDeletionAllowed = true
strictValidation = false
}
checkBreakingChanges.dependsOn resolve
checkBreakingChanges.onlyIf {
def baseline = findProperty('apiBaseline') ?: "${projectDir}/config/swagger/fineract-baseline.json"
def exists = file(baseline).exists()
if (!exists) {
logger.lifecycle("Skipping checkBreakingChanges: baseline file not found at ${baseline}")
}
exists
}
check.dependsOn('cucumber') check.dependsOn('cucumber')