From cda767d00d17981a2d9eb86e705562876d9afccd Mon Sep 17 00:00:00 2001 From: "Keith W. Boone" Date: Wed, 24 Jun 2026 21:50:04 -0400 Subject: [PATCH 01/10] docs: Correct README build section Module builds standalone as a library JAR for CI/CD and GitHub Packages publication. Clarify distinction between standalone library build and full SQL-enabled service image build in izgw-transform. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6e82edd..a31b285 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,23 @@ backends alongside the existing IZ Gateway path. ## Building -This module is not built standalone. It is included as a profile-activated -dependency in `izgw-transform`: +```cmd +mvn clean package +mvn test +mvn dependency-check:check +``` + +This module builds as a library JAR and publishes to GitHub Packages. It does +not produce a runnable service image on its own. + +To produce a SQL-enabled `izgw-transform` service image, activate the +`sql-support` profile and a driver profile in `izgw-transform`: ``` mvn package -P sql-support,sql-mssql ``` -See `izgw-transform` for full build and deployment documentation. +See `izgw-transform` for full service build and deployment documentation. ## Documentation From 002150163eb4a51afb9c33f376799efbb6b168b4 Mon Sep 17 00:00:00 2001 From: "Keith W. Boone" Date: Thu, 25 Jun 2026 00:49:58 -0400 Subject: [PATCH 02/10] ci: Add Maven CI/CD workflow and update pom.xml for library publishing Adds the standard IZ Gateway library CI/CD workflow (build, test, deploy to GitHub Packages on develop). Updates pom.xml with repository declarations, distribution management, and test/web/lombok dependencies needed for Stage 1B. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/maven.yml | 143 ++++++++++++++++++++++++++++++++++++ pom.xml | 51 +++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 .github/workflows/maven.yml diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 0000000..17509e6 --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,143 @@ +name: Java CI with Maven + +on: + push: + branches: + - develop + + pull_request: + branches: + - develop + + schedule: + - cron: '0 6 * * 1-5' + + workflow_dispatch: + +concurrency: izgw-transform-sql-dev + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + + - name: Checkout the software + uses: actions/checkout@v6 + with: + ssh-key: ${{secrets.ACTIONS_KEY}} + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + + - name: Set up Maven + uses: stCarolas/setup-maven@v5.1 + with: + maven-version: 3.9.0 + + - name: Set up Toolchain + shell: bash + run: | + mkdir -p ~/.m2 \ + && cat << EOF > ~/.m2/toolchains.xml + + + + jdk + + 21 + sun + + + $JAVA_HOME_21_X64 + + + + EOF + + cat << EOF > ~/.m2/settings.xml + + + + + github + ${{github.actor}} + ${{ secrets.GITHUB_TOKEN }} + + + github-bom + ${{github.actor}} + ${{ secrets.GITHUB_TOKEN }} + + + github-core + ${{github.actor}} + ${{ secrets.GITHUB_TOKEN }} + + + + EOF + + echo BASE_TAG=`mvn help:evaluate -Dexpression=project.version -q -DforceStdout` >> $GITHUB_ENV + echo COMPUTERNAME=`hostname` >> $GITHUB_ENV + + - name: Sets env vars for push or pull request to develop + run: | + echo IMAGE_TAG=`mvn help:evaluate -Dexpression=project.version -q -DforceStdout | sed "s/-SNAPSHOT$/-SNAPSHOT-${{github.run_number}}/"` >> $GITHUB_ENV + echo DO_REVISION_CHECK=true >> $GITHUB_ENV + echo SKIP_DEPENDENCY_CHECK=true >> $GITHUB_ENV + + - name: Sets env vars for pull to main + if: ${{ github.base_ref == 'main' }} + run: | + IMAGE_TAG=`mvn help:evaluate -Dexpression=project.version -q -DforceStdout | sed "s/-.*$/-IZGW-SNAPSHOT-${{github.run_number}}/"` + echo IMAGE_TAG=$IMAGE_TAG >> $GITHUB_ENV + echo DO_REVISION_CHECK=false >> $GITHUB_ENV + echo SKIP_DEPENDENCY_CHECK=true >> $GITHUB_ENV + + - name: Sets env vars for push to main + if: ${{ github.ref_name == 'main' }} + run: | + IMAGE_TAG=`mvn help:evaluate -Dexpression=project.version -q -DforceStdout | sed "s/-.*$/-IZGW-RELEASE-${{github.run_number}}/"` + echo IMAGE_TAG=$IMAGE_TAG >> $GITHUB_ENV + echo DO_REVISION_CHECK=false >> $GITHUB_ENV + echo SKIP_DEPENDENCY_CHECK=false >> $GITHUB_ENV + + - name: List env + shell: bash + run: | + echo BASE_REF: ${{ github.base_ref }} + echo HEAD_REF: ${{ github.head_ref }} + echo REF_NAME: ${{ github.ref_name }} + echo REF: ${{ github.ref }} + echo EVENT_NAME ${{ github.event_name }} + echo TAG: $BASE_TAG + echo DO_REVISION_CHECK: $DO_REVISION_CHECK + echo SKIP_DEPENDENCY_CHECK: $SKIP_DEPENDENCY_CHECK + echo IMAGE_TAG: $IMAGE_TAG + + - name: Maven Build, Test, and Deploy + run: | + mvn -B -U clean package deploy \ + -Dbuildno=${{github.run_number}} \ + -DdoRevisionCheck=${{env.DO_REVISION_CHECK}} \ + -DskipDependencyCheck=${{env.SKIP_DEPENDENCY_CHECK}} + + - name: Upload build environment on failure + uses: actions/upload-artifact@v7 + if: ${{ failure() }} + with: + name: build-failure + path: . + + - name: Upload dependency check report + uses: actions/upload-artifact@v7 + if: ${{ ! env.SKIP_DEPENDENCY_CHECK }} + with: + name: DependencyCheck + path: ./target/site/dependency-check-report.html diff --git a/pom.xml b/pom.xml index ad8893c..cdca112 100644 --- a/pom.xml +++ b/pom.xml @@ -18,17 +18,68 @@ 21 UTF-8 + ${maven.build.timestamp} + yyyyMMddHHmm + ${project.artifactId}-${project.version}-${timestamp} + ${timestamp} + + + central + Maven Central Repository + https://repo.maven.apache.org/maven2 + true + false + + + github-bom + GitHub Packages + https://maven.pkg.github.com/IZGateway/izgw-bom + true + true + + + github-core + izgw-core + https://maven.pkg.github.com/IZGateway/izgw-core + true + true + + + + + + github + GitHub IZGateway Apache Maven Packages + https://maven.pkg.github.com/IZGateway/izgw-transform-sql + + + gov.cdc.izgateway izgw-core + 3.2.0-SNAPSHOT org.springframework.boot spring-boot-starter-jdbc + + org.springframework.boot + spring-boot-starter-web + + + org.projectlombok + lombok + provided + + + org.springframework.boot + spring-boot-starter-test + test + From 5500bf7ffc3a708b1c339666e80fa3ac91b3052b Mon Sep 17 00:00:00 2001 From: "Keith W. Boone" Date: Thu, 25 Jun 2026 00:57:33 -0400 Subject: [PATCH 03/10] fix: Use izgw-core 3.1.0 release (3.2.0-SNAPSHOT not yet published) Co-Authored-By: Claude Sonnet 4.6 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index cdca112..5c77e29 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ gov.cdc.izgateway izgw-core - 3.2.0-SNAPSHOT + 3.1.0 org.springframework.boot From a89808f1c7305226773746512a79f5da5005dd1d Mon Sep 17 00:00:00 2001 From: "Keith W. Boone" Date: Thu, 25 Jun 2026 01:04:02 -0400 Subject: [PATCH 04/10] ci: Add packages permission, remove ACTIONS_KEY and revision check ACTIONS_KEY is only needed for protected-branch pushes (not needed here). packages: write permission enables cross-repo package reads via GITHUB_TOKEN. Remove doRevisionCheck which requires custom plugin configuration. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/maven.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 17509e6..49e5350 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -16,6 +16,10 @@ on: concurrency: izgw-transform-sql-dev +permissions: + contents: write + packages: write + jobs: build: @@ -25,8 +29,6 @@ jobs: - name: Checkout the software uses: actions/checkout@v6 - with: - ssh-key: ${{secrets.ACTIONS_KEY}} - name: Set up JDK 21 uses: actions/setup-java@v5 @@ -125,7 +127,6 @@ jobs: run: | mvn -B -U clean package deploy \ -Dbuildno=${{github.run_number}} \ - -DdoRevisionCheck=${{env.DO_REVISION_CHECK}} \ -DskipDependencyCheck=${{env.SKIP_DEPENDENCY_CHECK}} - name: Upload build environment on failure From 4e12e5d200113e28c5e769de9577c0310afe6989 Mon Sep 17 00:00:00 2001 From: "Keith W. Boone" Date: Thu, 25 Jun 2026 01:09:14 -0400 Subject: [PATCH 05/10] revert: Restore -DdoRevisionCheck flag Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/maven.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 49e5350..42f40bf 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -127,6 +127,7 @@ jobs: run: | mvn -B -U clean package deploy \ -Dbuildno=${{github.run_number}} \ + -DdoRevisionCheck=${{env.DO_REVISION_CHECK}} \ -DskipDependencyCheck=${{env.SKIP_DEPENDENCY_CHECK}} - name: Upload build environment on failure From 0317918f6b717a97eca174089aa9ddf6cf8196f0 Mon Sep 17 00:00:00 2001 From: "Keith W. Boone" Date: Thu, 25 Jun 2026 01:20:10 -0400 Subject: [PATCH 06/10] fix: Restore izgw-core 3.2.0-SNAPSHOT (3.1.0 not in GitHub Packages) 3.2.0-SNAPSHOT is actively published by izgw-core develop CI and used successfully by izgw-transform. 3.1.0 was never deployed to GitHub Packages. Co-Authored-By: Claude Sonnet 4.6 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5c77e29..cdca112 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ gov.cdc.izgateway izgw-core - 3.1.0 + 3.2.0-SNAPSHOT org.springframework.boot From 2853169b109580f8c3cd81b2ec8248ea7672d987 Mon Sep 17 00:00:00 2001 From: "Keith W. Boone" Date: Thu, 25 Jun 2026 01:22:20 -0400 Subject: [PATCH 07/10] ci: Trigger CI after package access grants From 81c0c59deb98dbc02f878b4900376534f6bf6a57 Mon Sep 17 00:00:00 2001 From: "Keith W. Boone" Date: Thu, 25 Jun 2026 01:25:21 -0400 Subject: [PATCH 08/10] fix: Use lowercase org name in github-core repository URL Matches izgw-transform pattern: izgateway (not IZGateway). GitHub Packages URLs are case-sensitive on the org segment. Co-Authored-By: Claude Sonnet 4.6 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index cdca112..2f7b32b 100644 --- a/pom.xml +++ b/pom.xml @@ -42,7 +42,7 @@ github-core izgw-core - https://maven.pkg.github.com/IZGateway/izgw-core + https://maven.pkg.github.com/izgateway/izgw-core true true From 26395cc44877ca93065da496b53406bf389c58df Mon Sep 17 00:00:00 2001 From: "Keith W. Boone" Date: Thu, 25 Jun 2026 01:28:38 -0400 Subject: [PATCH 09/10] fix: Correct izgw-core groupId to gov.cdc.izgw (not gov.cdc.izgateway) Co-Authored-By: Claude Sonnet 4.6 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 2f7b32b..c480d5e 100644 --- a/pom.xml +++ b/pom.xml @@ -58,7 +58,7 @@ - gov.cdc.izgateway + gov.cdc.izgw izgw-core 3.2.0-SNAPSHOT From d92b07fbb4891afc46a0900765bb315ff691a481 Mon Sep 17 00:00:00 2001 From: "Keith W. Boone" Date: Thu, 25 Jun 2026 01:38:35 -0400 Subject: [PATCH 10/10] feat: Implement Stage 1B endpoint skeletons - AutoConfiguration.imports registering SqlBackendAutoConfiguration - SqlFhirController: /sql/fhir/{name}/** returning empty Bundles (Stage 1) - SqlUnavailableController: /sql/** and /bulk/sql/** return 503 when SQL absent - BulkExportJob model with Status enum and OutputFile inner class - BulkExportJobStore and BulkExportOutputStore interfaces - InMemoryBulkExportJobStore (V1, single-instance) - TempFileBulkExportOutputStore (V1, single-instance) - BulkExportController: POST /bulk/sql/fhir/$export, GET/DELETE $export-status - Unit tests for SqlFhirController and BulkExportController Co-Authored-By: Claude Sonnet 4.6 --- .../sql/SqlBackendAutoConfiguration.java | 9 ++ .../xform/sql/SqlFhirController.java | 69 ++++++++++ .../xform/sql/SqlUnavailableController.java | 33 +++++ .../xform/sql/bulk/BulkExportController.java | 118 ++++++++++++++++++ .../xform/sql/bulk/BulkExportJob.java | 49 ++++++++ .../xform/sql/bulk/BulkExportJobStore.java | 10 ++ .../xform/sql/bulk/BulkExportOutputStore.java | 11 ++ .../sql/bulk/InMemoryBulkExportJobStore.java | 37 ++++++ .../bulk/TempFileBulkExportOutputStore.java | 37 ++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../xform/sql/SqlFhirControllerTests.java | 48 +++++++ .../sql/bulk/BulkExportControllerTests.java | 77 ++++++++++++ 12 files changed, 499 insertions(+) create mode 100644 src/main/java/gov/cdc/izgateway/xform/sql/SqlBackendAutoConfiguration.java create mode 100644 src/main/java/gov/cdc/izgateway/xform/sql/SqlFhirController.java create mode 100644 src/main/java/gov/cdc/izgateway/xform/sql/SqlUnavailableController.java create mode 100644 src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportController.java create mode 100644 src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportJob.java create mode 100644 src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportJobStore.java create mode 100644 src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportOutputStore.java create mode 100644 src/main/java/gov/cdc/izgateway/xform/sql/bulk/InMemoryBulkExportJobStore.java create mode 100644 src/main/java/gov/cdc/izgateway/xform/sql/bulk/TempFileBulkExportOutputStore.java create mode 100644 src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 src/test/java/gov/cdc/izgateway/xform/sql/SqlFhirControllerTests.java create mode 100644 src/test/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportControllerTests.java diff --git a/src/main/java/gov/cdc/izgateway/xform/sql/SqlBackendAutoConfiguration.java b/src/main/java/gov/cdc/izgateway/xform/sql/SqlBackendAutoConfiguration.java new file mode 100644 index 0000000..e3626b9 --- /dev/null +++ b/src/main/java/gov/cdc/izgateway/xform/sql/SqlBackendAutoConfiguration.java @@ -0,0 +1,9 @@ +package gov.cdc.izgateway.xform.sql; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan(basePackages = "gov.cdc.izgateway.xform.sql") +public class SqlBackendAutoConfiguration { +} diff --git a/src/main/java/gov/cdc/izgateway/xform/sql/SqlFhirController.java b/src/main/java/gov/cdc/izgateway/xform/sql/SqlFhirController.java new file mode 100644 index 0000000..7bb2231 --- /dev/null +++ b/src/main/java/gov/cdc/izgateway/xform/sql/SqlFhirController.java @@ -0,0 +1,69 @@ +package gov.cdc.izgateway.xform.sql; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.annotation.security.RolesAllowed; +import jakarta.servlet.http.HttpServletRequest; +import org.hl7.fhir.r4.model.Bundle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * Handles single-patient FHIR queries against named SQL backends. + * Owns all paths under /sql/fhir/{name}/**, entirely distinct from + * FhirController at /fhir/**. + */ +@RestController +@RequestMapping("/sql/fhir/{name}") +@RolesAllowed({"XFORM_SENDING_SYSTEM", "ADMIN"}) +public class SqlFhirController { + + private static final Logger log = LoggerFactory.getLogger(SqlFhirController.class); + + @Operation(summary = "SQL-backed FHIR patient/immunization query") + @ApiResponse(responseCode = "200", description = "Query completed") + @GetMapping( + value = {"/{resourceType}", "/{resourceType}/_search"}, + produces = {"application/fhir+json", "application/fhir+xml", "application/json", "application/xml"} + ) + public ResponseEntity query( + @PathVariable String name, + @PathVariable String resourceType, + HttpServletRequest req + ) { + log.debug("SQL FHIR query: backend={} resource={}", name, resourceType); + Bundle empty = new Bundle(); + empty.setType(Bundle.BundleType.SEARCHSET); + empty.setTotal(0); + return new ResponseEntity<>(empty, HttpStatus.OK); + } + + @GetMapping("/{resourceType}/{id}") + public ResponseEntity read( + @PathVariable String name, + @PathVariable String resourceType, + @PathVariable String id, + HttpServletRequest req + ) { + Bundle empty = new Bundle(); + empty.setType(Bundle.BundleType.SEARCHSET); + return new ResponseEntity<>(empty, HttpStatus.OK); + } + + @PostMapping( + value = {"/{resourceType}/$match"}, + produces = {"application/fhir+json", "application/fhir+xml", "application/json"} + ) + public ResponseEntity patientMatch( + @PathVariable String name, + @PathVariable String resourceType, + HttpServletRequest req + ) { + Bundle empty = new Bundle(); + empty.setType(Bundle.BundleType.SEARCHSET); + return new ResponseEntity<>(empty, HttpStatus.OK); + } +} diff --git a/src/main/java/gov/cdc/izgateway/xform/sql/SqlUnavailableController.java b/src/main/java/gov/cdc/izgateway/xform/sql/SqlUnavailableController.java new file mode 100644 index 0000000..338a672 --- /dev/null +++ b/src/main/java/gov/cdc/izgateway/xform/sql/SqlUnavailableController.java @@ -0,0 +1,33 @@ +package gov.cdc.izgateway.xform.sql; + +import jakarta.servlet.http.HttpServletRequest; +import org.hl7.fhir.r4.model.OperationOutcome; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Stub controller activated when the SQL module is not configured. + * Returns 503 for all /sql/** and /bulk/sql/** paths so callers + * receive a meaningful error rather than a 404. + */ +@RestController +@ConditionalOnMissingBean(SqlBackendAutoConfiguration.class) +@Configuration +public class SqlUnavailableController { + + @RequestMapping({"/sql/**", "/bulk/sql/**"}) + public ResponseEntity unavailable(HttpServletRequest req) { + OperationOutcome oo = new OperationOutcome(); + OperationOutcome.OperationOutcomeIssueComponent issue = oo.addIssue(); + issue.setSeverity(OperationOutcome.IssueSeverity.ERROR); + issue.setCode(OperationOutcome.IssueType.NOTSUPPORTED); + issue.setDiagnostics( + "SQL backend is not configured. Deploy the izgw-transform-sql module " + + "and configure a datasource to enable this endpoint."); + return new ResponseEntity<>(oo, HttpStatus.SERVICE_UNAVAILABLE); + } +} diff --git a/src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportController.java b/src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportController.java new file mode 100644 index 0000000..2f93ee2 --- /dev/null +++ b/src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportController.java @@ -0,0 +1,118 @@ +package gov.cdc.izgateway.xform.sql.bulk; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.annotation.security.RolesAllowed; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.util.Map; +import java.util.UUID; + +/** + * HL7 Bulk FHIR $export endpoints at /bulk/sql/fhir/$export. + * Follows the HL7 Bulk Data Access specification. + */ +@RestController +@RequestMapping("/bulk/sql/fhir") +@RolesAllowed({"XFORM_SENDING_SYSTEM", "BULK_EXPORT", "ADMIN"}) +public class BulkExportController { + + private static final Logger log = LoggerFactory.getLogger(BulkExportController.class); + + private final BulkExportJobStore jobStore; + private final BulkExportOutputStore outputStore; + + public BulkExportController( + @Autowired BulkExportJobStore jobStore, + @Autowired BulkExportOutputStore outputStore + ) { + this.jobStore = jobStore; + this.outputStore = outputStore; + } + + @Operation(summary = "Kick off a Bulk FHIR export job") + @PostMapping("/$export") + public ResponseEntity kickoff( + @RequestHeader(value = "Accept", required = false) String accept, + @RequestHeader(value = "Prefer", required = false) String prefer, + @RequestParam(value = "_since", required = false) String since, + @RequestParam(value = "_type", required = false) String type, + @RequestParam(value = "_typeFilter", required = false) String typeFilter, + HttpServletRequest req + ) { + if (!"respond-async".equals(prefer)) { + return ResponseEntity.badRequest().build(); + } + + if (typeFilter != null && !isSupportedTypeFilter(typeFilter)) { + return ResponseEntity.badRequest().build(); + } + + BulkExportJob job = new BulkExportJob(); + job.setSinceParam(since); + job.setTypeParam(type); + job.setTypeFilter(typeFilter); + jobStore.create(job); + + URI statusUrl = URI.create(req.getRequestURL().toString() + .replace("/$export", "/$export-status/" + job.getId())); + log.info("Bulk export job created: {}", job.getId()); + + return ResponseEntity.accepted() + .location(statusUrl) + .build(); + } + + @Operation(summary = "Poll bulk export job status") + @GetMapping("/$export-status/{jobId}") + public ResponseEntity status(@PathVariable UUID jobId) { + BulkExportJob job = jobStore.get(jobId); + if (job == null) { + return ResponseEntity.notFound().build(); + } + + return switch (job.getStatus()) { + case PENDING, RUNNING -> ResponseEntity.accepted() + .header("X-Progress", job.getStatus().name()) + .build(); + case COMPLETE -> ResponseEntity.ok(buildManifest(job)); + case FAILED -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", job.getErrorMessage())); + }; + } + + @Operation(summary = "Signal completion and schedule cleanup") + @DeleteMapping("/$export-status/{jobId}") + public ResponseEntity complete(@PathVariable UUID jobId) { + BulkExportJob job = jobStore.get(jobId); + if (job == null) { + return ResponseEntity.notFound().build(); + } + try { + outputStore.delete(jobId); + } catch (Exception e) { + log.warn("Failed to delete output files for job {}: {}", jobId, e.getMessage()); + } + jobStore.delete(jobId); + return ResponseEntity.accepted().build(); + } + + private boolean isSupportedTypeFilter(String typeFilter) { + return typeFilter.startsWith("Immunization?") || typeFilter.startsWith("Patient?"); + } + + private Map buildManifest(BulkExportJob job) { + return Map.of( + "transactionTime", job.getTransactionTime() != null ? job.getTransactionTime().toString() : "", + "requiresAccessToken", true, + "output", job.getOutputFiles(), + "error", java.util.List.of() + ); + } +} diff --git a/src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportJob.java b/src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportJob.java new file mode 100644 index 0000000..3518a71 --- /dev/null +++ b/src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportJob.java @@ -0,0 +1,49 @@ +package gov.cdc.izgateway.xform.sql.bulk; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class BulkExportJob { + + public enum Status { PENDING, RUNNING, COMPLETE, FAILED } + + private final UUID id = UUID.randomUUID(); + private volatile Status status = Status.PENDING; + private final Instant kickoffTime = Instant.now(); + private Instant transactionTime; + private String sinceParam; + private String typeFilter; + private String typeParam; + private final List outputFiles = new ArrayList<>(); + private String errorMessage; + + public UUID getId() { return id; } + public Status getStatus() { return status; } + public void setStatus(Status status) { this.status = status; } + public Instant getKickoffTime() { return kickoffTime; } + public Instant getTransactionTime() { return transactionTime; } + public void setTransactionTime(Instant transactionTime) { this.transactionTime = transactionTime; } + public String getSinceParam() { return sinceParam; } + public void setSinceParam(String sinceParam) { this.sinceParam = sinceParam; } + public String getTypeFilter() { return typeFilter; } + public void setTypeFilter(String typeFilter) { this.typeFilter = typeFilter; } + public String getTypeParam() { return typeParam; } + public void setTypeParam(String typeParam) { this.typeParam = typeParam; } + public List getOutputFiles() { return outputFiles; } + public String getErrorMessage() { return errorMessage; } + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + + public static class OutputFile { + private final String type; + private final String url; + private final int count; + public OutputFile(String type, String url, int count) { + this.type = type; this.url = url; this.count = count; + } + public String getType() { return type; } + public String getUrl() { return url; } + public int getCount() { return count; } + } +} diff --git a/src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportJobStore.java b/src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportJobStore.java new file mode 100644 index 0000000..1428377 --- /dev/null +++ b/src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportJobStore.java @@ -0,0 +1,10 @@ +package gov.cdc.izgateway.xform.sql.bulk; + +import java.util.UUID; + +public interface BulkExportJobStore { + BulkExportJob create(BulkExportJob job); + BulkExportJob get(UUID id); + BulkExportJob update(BulkExportJob job); + void delete(UUID id); +} diff --git a/src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportOutputStore.java b/src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportOutputStore.java new file mode 100644 index 0000000..3d914db --- /dev/null +++ b/src/main/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportOutputStore.java @@ -0,0 +1,11 @@ +package gov.cdc.izgateway.xform.sql.bulk; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.UUID; + +public interface BulkExportOutputStore { + void write(UUID jobId, int fileIndex, InputStream data) throws Exception; + void stream(UUID jobId, int fileIndex, OutputStream out) throws Exception; + void delete(UUID jobId) throws Exception; +} diff --git a/src/main/java/gov/cdc/izgateway/xform/sql/bulk/InMemoryBulkExportJobStore.java b/src/main/java/gov/cdc/izgateway/xform/sql/bulk/InMemoryBulkExportJobStore.java new file mode 100644 index 0000000..da328d4 --- /dev/null +++ b/src/main/java/gov/cdc/izgateway/xform/sql/bulk/InMemoryBulkExportJobStore.java @@ -0,0 +1,37 @@ +package gov.cdc.izgateway.xform.sql.bulk; + +import org.springframework.stereotype.Component; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * V1 in-memory job store. Job state is lost on restart and not shared + * across instances. Single-instance deployment only. + */ +@Component +public class InMemoryBulkExportJobStore implements BulkExportJobStore { + + private final ConcurrentHashMap jobs = new ConcurrentHashMap<>(); + + @Override + public BulkExportJob create(BulkExportJob job) { + jobs.put(job.getId(), job); + return job; + } + + @Override + public BulkExportJob get(UUID id) { + return jobs.get(id); + } + + @Override + public BulkExportJob update(BulkExportJob job) { + jobs.put(job.getId(), job); + return job; + } + + @Override + public void delete(UUID id) { + jobs.remove(id); + } +} diff --git a/src/main/java/gov/cdc/izgateway/xform/sql/bulk/TempFileBulkExportOutputStore.java b/src/main/java/gov/cdc/izgateway/xform/sql/bulk/TempFileBulkExportOutputStore.java new file mode 100644 index 0000000..25afb1f --- /dev/null +++ b/src/main/java/gov/cdc/izgateway/xform/sql/bulk/TempFileBulkExportOutputStore.java @@ -0,0 +1,37 @@ +package gov.cdc.izgateway.xform.sql.bulk; + +import org.springframework.stereotype.Component; +import java.io.*; +import java.nio.file.*; +import java.util.UUID; + +/** + * V1 temp-file output store. Files are local to this instance and lost + * on restart. Single-instance deployment only. + */ +@Component +public class TempFileBulkExportOutputStore implements BulkExportOutputStore { + + private Path filePath(UUID jobId, int fileIndex) { + return Path.of(System.getProperty("java.io.tmpdir"), + "izg-bulk-" + jobId + "-" + fileIndex + ".ndjson"); + } + + @Override + public void write(UUID jobId, int fileIndex, InputStream data) throws Exception { + Files.copy(data, filePath(jobId, fileIndex), StandardCopyOption.REPLACE_EXISTING); + } + + @Override + public void stream(UUID jobId, int fileIndex, OutputStream out) throws Exception { + Files.copy(filePath(jobId, fileIndex), out); + } + + @Override + public void delete(UUID jobId) throws Exception { + for (int i = 0; ; i++) { + Path p = filePath(jobId, i); + if (!Files.deleteIfExists(p)) break; + } + } +} diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..b41fcc2 --- /dev/null +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +gov.cdc.izgateway.xform.sql.SqlBackendAutoConfiguration diff --git a/src/test/java/gov/cdc/izgateway/xform/sql/SqlFhirControllerTests.java b/src/test/java/gov/cdc/izgateway/xform/sql/SqlFhirControllerTests.java new file mode 100644 index 0000000..2f5cb65 --- /dev/null +++ b/src/test/java/gov/cdc/izgateway/xform/sql/SqlFhirControllerTests.java @@ -0,0 +1,48 @@ +package gov.cdc.izgateway.xform.sql; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.*; + +class SqlFhirControllerTests { + + private SqlFhirController controller; + + @BeforeEach + void setUp() { + controller = new SqlFhirController(); + } + + @Test + void query_returnsEmptySearchsetBundle() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/sql/fhir/dev/Patient"); + ResponseEntity response = controller.query("dev", "Patient", req); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + } + + @Test + void query_forImmunization_returnsOk() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/sql/fhir/waiis/Immunization"); + ResponseEntity response = controller.query("waiis", "Immunization", req); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + void read_returnsOk() { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/sql/fhir/dev/Patient/123"); + ResponseEntity response = controller.read("dev", "Patient", "123", req); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + void patientMatch_returnsOk() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/sql/fhir/dev/Patient/$match"); + ResponseEntity response = controller.patientMatch("dev", "Patient", req); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } +} diff --git a/src/test/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportControllerTests.java b/src/test/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportControllerTests.java new file mode 100644 index 0000000..95ab445 --- /dev/null +++ b/src/test/java/gov/cdc/izgateway/xform/sql/bulk/BulkExportControllerTests.java @@ -0,0 +1,77 @@ +package gov.cdc.izgateway.xform.sql.bulk; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.*; + +class BulkExportControllerTests { + + private BulkExportController controller; + private InMemoryBulkExportJobStore jobStore; + private TempFileBulkExportOutputStore outputStore; + + @BeforeEach + void setUp() { + jobStore = new InMemoryBulkExportJobStore(); + outputStore = new TempFileBulkExportOutputStore(); + controller = new BulkExportController(jobStore, outputStore); + } + + @Test + void kickoff_missingPreferHeader_returns400() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/bulk/sql/fhir/$export"); + req.setServerName("localhost"); + req.setServerPort(443); + req.setScheme("https"); + ResponseEntity response = controller.kickoff( + "application/fhir+json", null, null, null, null, req); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + void kickoff_validRequest_returns202WithContentLocation() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/bulk/sql/fhir/$export"); + req.setServerName("localhost"); + req.setServerPort(443); + req.setScheme("https"); + ResponseEntity response = controller.kickoff( + "application/fhir+json", "respond-async", null, null, null, req); + assertEquals(HttpStatus.ACCEPTED, response.getStatusCode()); + assertNotNull(response.getHeaders().getLocation()); + } + + @Test + void kickoff_unsupportedTypeFilter_returns400() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/bulk/sql/fhir/$export"); + req.setServerName("localhost"); + req.setServerPort(443); + req.setScheme("https"); + ResponseEntity response = controller.kickoff( + "application/fhir+json", "respond-async", null, null, "Observation?status=final", req); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + void status_inProgress_returns202() { + BulkExportJob job = jobStore.create(new BulkExportJob()); + ResponseEntity status = controller.status(job.getId()); + assertEquals(HttpStatus.ACCEPTED, status.getStatusCode()); + } + + @Test + void delete_returns202() { + BulkExportJob job = jobStore.create(new BulkExportJob()); + ResponseEntity response = controller.complete(job.getId()); + assertEquals(HttpStatus.ACCEPTED, response.getStatusCode()); + } + + @Test + void delete_unknownJob_returns404() { + ResponseEntity response = controller.complete(java.util.UUID.randomUUID()); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + } +}