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 'me.champeau.jmh' version '0.7.1' 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"
@@ -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::advanced-payment-allocation.adoc[leveloffset=+1]
include::api-backward-compatibility.adoc[leveloffset=+1]
@@ -197,7 +197,8 @@ public class ImagesApiResource {
@PUT
@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,
@PathParam(DOCUMENT_API_PARAM_ENTITY_ID) final Long entityId, @HeaderParam(CONTENT_LENGTH) final Long fileSize,
@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: 'org.springframework.boot'
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')