This is an automated email from the ASF dual-hosted git repository.

adamsaghy pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/fineract.git


The following commit(s) were added to refs/heads/develop by this push:
     new 2047553366 FINERACT-2081: add api verification workflow
2047553366 is described below

commit 20475533667dded7a00fa1dabf5b608888b8b750
Author: Attila Budai <[email protected]>
AuthorDate: Fri Feb 20 15:49:36 2026 +0100

    FINERACT-2081: add api verification workflow
---
 .../verify-api-backward-compatibility.yml          | 225 +++++++++++++++++++++
 build.gradle                                       |   1 +
 .../architecture/api-backward-compatibility.adoc   | 190 +++++++++++++++++
 .../src/docs/en/chapters/architecture/index.adoc   |   2 +
 .../documentmanagement/api/ImagesApiResource.java  |   3 +-
 fineract-provider/build.gradle                     |  20 ++
 6 files changed, 440 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/verify-api-backward-compatibility.yml 
b/.github/workflows/verify-api-backward-compatibility.yml
new file mode 100644
index 0000000000..d154cb58b7
--- /dev/null
+++ b/.github/workflows/verify-api-backward-compatibility.yml
@@ -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
diff --git a/build.gradle b/build.gradle
index 6df313b1a3..bc1361269a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -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"
diff --git 
a/fineract-doc/src/docs/en/chapters/architecture/api-backward-compatibility.adoc
 
b/fineract-doc/src/docs/en/chapters/architecture/api-backward-compatibility.adoc
new file mode 100644
index 0000000000..59eb81c4f2
--- /dev/null
+++ 
b/fineract-doc/src/docs/en/chapters/architecture/api-backward-compatibility.adoc
@@ -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
diff --git a/fineract-doc/src/docs/en/chapters/architecture/index.adoc 
b/fineract-doc/src/docs/en/chapters/architecture/index.adoc
index 01a635c6f9..212f8b452f 100644
--- a/fineract-doc/src/docs/en/chapters/architecture/index.adoc
+++ b/fineract-doc/src/docs/en/chapters/architecture/index.adoc
@@ -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]
diff --git 
a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/api/ImagesApiResource.java
 
b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/api/ImagesApiResource.java
index b29b993462..f72b6350d8 100644
--- 
a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/api/ImagesApiResource.java
+++ 
b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/api/ImagesApiResource.java
@@ -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,
diff --git a/fineract-provider/build.gradle b/fineract-provider/build.gradle
index e5751e020a..4ab05300f1 100644
--- a/fineract-provider/build.gradle
+++ b/fineract-provider/build.gradle
@@ -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')
 

Reply via email to