From 65d5794edceb5d4161670630c935ed265a6a10e9 Mon Sep 17 00:00:00 2001 From: Wikum Weerakutti Date: Fri, 24 Jul 2026 16:47:53 +0530 Subject: [PATCH 1/8] ME-29: Enable REST-triggered export jobs with named collections & ZIP download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add export packages: named, persisted definitions of what to export (domain + optional item uuids per entry), with a versioned build history - Add REST endpoints under /ws/rest/v1/metadataexport to manage packages, trigger builds, poll status, and download the result as a ZIP - Run builds async on a daemon thread (QUEUED → RUNNING → COMPLETED/FAILED); each ZIP contains the Initializer configuration/ tree plus a package.json manifest - Add MetadataExportService, DAO, entities + liquibase tables, ExportPackageValidator, ExporterService.exportSeeds, DomainExporter.getInstancesByUuids, ZipUtils - Add two privileges; remove the dead AdminList extension stub; 180 tests (unit, context-sensitive, MockMvc) --- README.md | 63 ++++- .../MetadataExportActivator.java | 20 +- .../MetadataExportDaemonToken.java | 32 +++ .../api/ActiveBuildException.java | 27 ++ .../metadataexport/api/ExportJobRunner.java | 124 +++++++++ .../metadataexport/api/ExporterService.java | 5 + .../api/MetadataExportService.java | 40 +++ .../api/db/MetadataExportDao.java | 35 +++ .../hibernate/HibernateMetadataExportDao.java | 107 ++++++++ .../api/impl/ExporterServiceImpl.java | 13 +- .../api/impl/MetadataExportServiceImpl.java | 174 +++++++++++++ .../metadataexport/api/model/ExportBuild.java | 77 ++++++ .../api/model/ExportPackage.java | 50 ++++ .../api/model/ExportPackageEntry.java | 67 +++++ .../api/model/ExportStatus.java | 22 ++ .../api/validator/ExportPackageValidator.java | 59 +++++ .../metadataexport/export/BuildManifest.java | 119 +++++++++ .../metadataexport/export/DomainExporter.java | 20 ++ .../metadataexport/export/ZipUtils.java | 45 ++++ api/src/main/resources/liquibase.xml | 178 ++++++++++--- api/src/main/resources/messages.properties | 4 + api/src/main/resources/messages_es.properties | 4 + api/src/main/resources/messages_fr.properties | 4 + .../resources/moduleApplicationContext.xml | 32 +++ .../api/ExportJobRunnerTest.java | 119 +++++++++ .../api/MetadataExportIntegrationTest.java | 13 +- .../api/MetadataExportServiceTest.java | 180 +++++++++++++ .../export/BuildManifestTest.java | 65 +++++ .../export/DomainExporterTest.java | 84 ++++++ .../metadataexport/export/ZipUtilsTest.java | 83 ++++++ omod/pom.xml | 33 ++- .../web/controller/ExportBuildController.java | 74 ++++++ .../controller/ExportDomainController.java | 43 ++++ .../controller/ExportPackageController.java | 163 ++++++++++++ .../MetadataExportControllerAdvice.java | 77 ++++++ .../MetadataExportRestConstants.java | 22 ++ .../web/controller/dto/ExportBuildDto.java | 76 ++++++ .../web/controller/dto/ExportPackageDto.java | 60 +++++ .../controller/dto/ExportPackageEntryDto.java | 33 +++ .../controller/dto/ExportPackageRequest.java | 27 ++ omod/src/main/resources/config.xml | 16 +- .../controller/ExportBuildControllerTest.java | 152 +++++++++++ .../ExportDomainControllerTest.java | 58 +++++ .../ExportPackageControllerTest.java | 242 ++++++++++++++++++ 44 files changed, 2879 insertions(+), 62 deletions(-) create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/MetadataExportDaemonToken.java create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/api/ActiveBuildException.java create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/api/ExportJobRunner.java create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/api/MetadataExportService.java create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/api/db/MetadataExportDao.java create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/api/db/hibernate/HibernateMetadataExportDao.java create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/api/impl/MetadataExportServiceImpl.java create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportBuild.java create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportPackage.java create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportPackageEntry.java create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportStatus.java create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/api/validator/ExportPackageValidator.java create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/export/BuildManifest.java create mode 100644 api/src/main/java/org/openmrs/module/metadataexport/export/ZipUtils.java create mode 100644 api/src/test/java/org/openmrs/module/metadataexport/api/ExportJobRunnerTest.java create mode 100644 api/src/test/java/org/openmrs/module/metadataexport/api/MetadataExportServiceTest.java create mode 100644 api/src/test/java/org/openmrs/module/metadataexport/export/BuildManifestTest.java create mode 100644 api/src/test/java/org/openmrs/module/metadataexport/export/DomainExporterTest.java create mode 100644 api/src/test/java/org/openmrs/module/metadataexport/export/ZipUtilsTest.java create mode 100644 omod/src/main/java/org/openmrs/module/metadataexport/web/controller/ExportBuildController.java create mode 100644 omod/src/main/java/org/openmrs/module/metadataexport/web/controller/ExportDomainController.java create mode 100644 omod/src/main/java/org/openmrs/module/metadataexport/web/controller/ExportPackageController.java create mode 100644 omod/src/main/java/org/openmrs/module/metadataexport/web/controller/MetadataExportControllerAdvice.java create mode 100644 omod/src/main/java/org/openmrs/module/metadataexport/web/controller/MetadataExportRestConstants.java create mode 100644 omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportBuildDto.java create mode 100644 omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportPackageDto.java create mode 100644 omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportPackageEntryDto.java create mode 100644 omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportPackageRequest.java create mode 100644 omod/src/test/java/org/openmrs/module/metadataexport/web/controller/ExportBuildControllerTest.java create mode 100644 omod/src/test/java/org/openmrs/module/metadataexport/web/controller/ExportDomainControllerTest.java create mode 100644 omod/src/test/java/org/openmrs/module/metadataexport/web/controller/ExportPackageControllerTest.java diff --git a/README.md b/README.md index b3be953..7fe69cd 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,65 @@ The export is built in two separated stages: 2. Export. Each `DomainExporter` writes its bucket in its own format. The service holds a registry of these and contains no per-domain logic. +Export packages (REST) +---------------------- +Besides the export-everything-on-startup behaviour, named *export packages* can be defined and +built over REST. A package describes what to export — a list of entries, each an Initializer +domain optionally narrowed to specific item uuids (empty list = the whole domain) — so e.g. a +"Site A locations" package exports just one site's locations (plus dependency closure). A +package with *no entries at all* exports every registered domain; `GET /domains` lists which +domains are registered on the server. Package +definitions are stored in the database; every build of a package gets an incrementing version, a +status (`QUEUED` → `RUNNING` → `COMPLETED`/`FAILED`), and a downloadable zip containing the +`configuration/` tree plus a `package.json` manifest recording exactly what was exported. + +Builds run asynchronously on a daemon thread; trigger, then poll. Endpoints (all under +`/openmrs/ws/rest/v1/metadataexport`, plain Spring controllers — the webservices.rest module is +not required, but its authentication filter covers these URLs when it is installed): + +| Method | Path | Action | +|--------|------------------------------|--------------------------------------------| +| GET | `/domains` | list the registered, exportable domains | +| GET | `/packages?includeRetired=` | list packages | +| POST | `/packages` | create a package (201) | +| GET | `/packages/{uuid}` | fetch one, incl. its latest build | +| PUT | `/packages/{uuid}` | update name/description/entries | +| DELETE | `/packages/{uuid}?reason=` | retire (204) | +| POST | `/packages/{uuid}/builds` | trigger a build (202; 409 if one is active)| +| GET | `/packages/{uuid}/builds` | build history, newest first | +| GET | `/builds/{uuid}` | poll status, incl. manifest when done | +| GET | `/builds/{uuid}/download` | the zip (409 unless COMPLETED, 410 if gone)| + +Example flow: + +```bash +# define a package scoped to two locations +curl -u admin:pw -H 'Content-Type: application/json' -d '{ + "name": "Site A locations", + "description": "Everything Site A needs", + "entries": [ { "domain": "LOCATIONS", "itemUuids": ["", ""] } ] +}' http://localhost:8080/openmrs/ws/rest/v1/metadataexport/packages + +# trigger a build, poll until COMPLETED, then download +curl -u admin:pw -X POST .../packages//builds +curl -u admin:pw .../builds/ +curl -u admin:pw -OJ .../builds//download +``` + +Reads require the `Get Metadata Export Packages` privilege; creating, updating, retiring, +triggering and downloading require `Manage Metadata Export Packages`. The curl examples use +basic auth, which is provided by webservices.rest's filter — without that module, authenticate +with a session instead. + +If the server restarts mid-build, the activator marks any stranded QUEUED/RUNNING builds as +FAILED on startup so they never block future builds of their package. + +Zips and their unzipped working copies accumulate under +`/metadataexport/packages///` — there is no retention policy +yet, so clean up old builds manually if disk space matters. Note also that these endpoints are +session-authenticated but not CSRF-protected (nothing under `/ws/*` is); treat them as an +admin-only API. + Requirements ------------ The Initializer module must be installed (declared in `config.xml` `require_modules`); this module @@ -201,8 +260,8 @@ Known limitations ----------------- * Concept description UUIDs and index-term names are not round-trip-able (Initializer format/loader limitations), so they are not preserved or re-loadable. -* Selection currently exports all instances of the registered domains; instance-level seed - selection is not yet exposed. +* The startup export always exports all instances of the registered domains; instance-level + selection is available through export packages (see "Export packages (REST)"). * Cross-domain closure only pulls in objects whose domain has a registered exporter. Building from source diff --git a/api/src/main/java/org/openmrs/module/metadataexport/MetadataExportActivator.java b/api/src/main/java/org/openmrs/module/metadataexport/MetadataExportActivator.java index 5957e4e..06b5bfe 100644 --- a/api/src/main/java/org/openmrs/module/metadataexport/MetadataExportActivator.java +++ b/api/src/main/java/org/openmrs/module/metadataexport/MetadataExportActivator.java @@ -16,6 +16,7 @@ import org.openmrs.module.DaemonToken; import org.openmrs.module.DaemonTokenAware; import org.openmrs.module.metadataexport.api.ExporterService; +import org.openmrs.module.metadataexport.api.MetadataExportService; import org.openmrs.util.OpenmrsUtil; import java.io.File; @@ -28,11 +29,28 @@ public class MetadataExportActivator extends BaseModuleActivator implements Daem @Override public void setDaemonToken(DaemonToken token) { this.daemonToken = token; + MetadataExportDaemonToken.set(token); } @Override public void started() { - Daemon.runInDaemonThreadWithoutResult(this::exportAllMetadata, daemonToken); + Daemon.runInDaemonThreadWithoutResult(() -> { + recoverStrandedBuilds(); + exportAllMetadata(); + }, daemonToken); + } + + private void recoverStrandedBuilds() { + try { + int recovered = Context.getService(MetadataExportService.class) + .failStrandedBuilds("Interrupted by a server restart"); + if (recovered > 0) { + log.warn("Metadata Export: marked {} stranded QUEUED/RUNNING build(s) as FAILED", recovered); + } + } + catch (Exception e) { + log.error("Metadata Export: failed to recover stranded builds on startup", e); + } } private void exportAllMetadata() { diff --git a/api/src/main/java/org/openmrs/module/metadataexport/MetadataExportDaemonToken.java b/api/src/main/java/org/openmrs/module/metadataexport/MetadataExportDaemonToken.java new file mode 100644 index 0000000..da17639 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/MetadataExportDaemonToken.java @@ -0,0 +1,32 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport; + +import org.openmrs.module.DaemonToken; + +/** + * Holds the module's {@link DaemonToken} (handed to the activator by the module framework) so that + * Spring components can start daemon threads without depending on the activator instance. + */ +public final class MetadataExportDaemonToken { + + private static volatile DaemonToken token; + + private MetadataExportDaemonToken() { + } + + public static void set(DaemonToken daemonToken) { + token = daemonToken; + } + + public static DaemonToken get() { + return token; + } +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/ActiveBuildException.java b/api/src/main/java/org/openmrs/module/metadataexport/api/ActiveBuildException.java new file mode 100644 index 0000000..d734677 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/ActiveBuildException.java @@ -0,0 +1,27 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.api; + +import org.openmrs.api.APIException; + +/** + * A build was requested for a package that already has a QUEUED or RUNNING build. The REST layer + * maps exactly this type to 409; other {@link APIException}s keep their generic handling. + */ +public class ActiveBuildException extends APIException { + + public ActiveBuildException(String message) { + super(message); + } + + public ActiveBuildException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/ExportJobRunner.java b/api/src/main/java/org/openmrs/module/metadataexport/api/ExportJobRunner.java new file mode 100644 index 0000000..f1f65c8 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/ExportJobRunner.java @@ -0,0 +1,124 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.api; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.hibernate.exception.ConstraintViolationException; +import org.openmrs.api.APIException; +import org.openmrs.api.context.Context; +import org.openmrs.api.context.Daemon; +import org.openmrs.module.metadataexport.MetadataExportDaemonToken; +import org.openmrs.module.metadataexport.api.model.ExportBuild; +import org.openmrs.module.metadataexport.api.model.ExportPackage; +import org.openmrs.module.metadataexport.api.model.ExportStatus; +import org.springframework.stereotype.Component; + +import java.util.Date; +import java.util.List; + +/** + * Triggers export builds and runs them on a daemon thread. Deliberately not transactional: the + * QUEUED build must be committed before the daemon thread starts, and each status change goes + * through the transactional {@link MetadataExportService} proxy so polling clients see progress. A + * build must never be left QUEUED/RUNNING with nothing running it — that would block every future + * build of its package — so every failure path here ends in a FAILED build or a logged error, and + * the activator sweeps stranded builds on startup. + */ +@Slf4j +@Component +public class ExportJobRunner { + + public ExportBuild trigger(String packageUuid) { + MetadataExportService service = service(); + ExportPackage exportPackage = service.getPackageByUuid(packageUuid); + if (exportPackage == null) { + throw new APIException("No export package with uuid " + packageUuid); + } + List builds = service.getBuilds(packageUuid); + for (ExportBuild existing : builds) { + if (!existing.getExportStatus().isTerminal()) { + throw new ActiveBuildException("Build v" + existing.getVersion() + " of '" + exportPackage.getName() + + "' is already " + existing.getExportStatus()); + } + } + ExportBuild build = new ExportBuild(); + build.setExportPackage(exportPackage); + build.setVersion(builds.isEmpty() ? 1 : builds.get(0).getVersion() + 1); + build.setExportStatus(ExportStatus.QUEUED); + final ExportBuild queued; + try { + queued = service.saveExportBuild(build); + } + catch (RuntimeException e) { + // two concurrent triggers can both pass the scan above; the unique + // (package_id, version) constraint catches the loser + if (ExceptionUtils.indexOfType(e, ConstraintViolationException.class) != -1) { + throw new ActiveBuildException( + "A build of '" + exportPackage.getName() + "' was just triggered concurrently", e); + } + throw e; + } + + try { + Daemon.runInDaemonThreadWithoutResult(() -> execute(queued.getUuid()), MetadataExportDaemonToken.get()); + } + catch (Exception e) { + failBuild(queued.getUuid(), + "Could not start the export daemon thread: " + ExceptionUtils.getRootCauseMessage(e)); + throw new APIException("Could not start the export daemon thread for build " + queued.getUuid(), e); + } + return queued; + } + + // package-private so context-sensitive tests can run a build synchronously + void execute(String buildUuid) { + try { + MetadataExportService service = service(); + ExportBuild build = service.getBuildByUuid(buildUuid); + build.setExportStatus(ExportStatus.RUNNING); + build.setDateStarted(new Date()); + service.saveExportBuild(build); + + service.runBuild(buildUuid); + } + catch (Throwable t) { + log.error("Metadata Export: build {} failed", buildUuid, t); + failBuild(buildUuid, ExceptionUtils.getRootCauseMessage(t)); + if (t instanceof Error) { + throw (Error) t; + } + } + } + + private void failBuild(String buildUuid, String errorMessage) { + try { + MetadataExportService service = service(); + ExportBuild build = service.getBuildByUuid(buildUuid); + if (build == null) { + log.error("Metadata Export: cannot mark unknown build {} as FAILED", buildUuid); + return; + } + build.setExportStatus(ExportStatus.FAILED); + build.setDateCompleted(new Date()); + build.setErrorMessage(errorMessage); + service.saveExportBuild(build); + } + catch (Exception e) { + log.error("Metadata Export: build {} failed AND could not be marked FAILED; it will block future builds" + + " of its package until the module restarts", + buildUuid, e); + } + } + + private MetadataExportService service() { + return Context.getService(MetadataExportService.class); + } +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/ExporterService.java b/api/src/main/java/org/openmrs/module/metadataexport/api/ExporterService.java index 31fe94e..6b34002 100644 --- a/api/src/main/java/org/openmrs/module/metadataexport/api/ExporterService.java +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/ExporterService.java @@ -9,7 +9,9 @@ */ package org.openmrs.module.metadataexport.api; +import org.openmrs.OpenmrsObject; import org.openmrs.module.initializer.Domain; +import org.openmrs.module.metadataexport.select.ExportManifest; import java.io.File; import java.io.IOException; @@ -24,4 +26,7 @@ public interface ExporterService { * itself. */ void export(File outDir, Collection domains) throws IOException; + + ExportManifest exportSeeds(File outDir, Collection seeds) throws IOException; + } diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/MetadataExportService.java b/api/src/main/java/org/openmrs/module/metadataexport/api/MetadataExportService.java new file mode 100644 index 0000000..417def0 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/MetadataExportService.java @@ -0,0 +1,40 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.api; + +import org.openmrs.api.OpenmrsService; +import org.openmrs.module.metadataexport.api.model.ExportBuild; +import org.openmrs.module.metadataexport.api.model.ExportPackage; + +import java.io.File; +import java.util.List; + +public interface MetadataExportService extends OpenmrsService { + + ExportPackage saveExportPackage(ExportPackage exportPackage); + + ExportPackage getPackageByUuid(String uuid); + + List getAllPackages(boolean includeRetired); + + ExportPackage retireExportPackage(ExportPackage exportPackage, String reason); + + ExportBuild saveExportBuild(ExportBuild build); + + ExportBuild getBuildByUuid(String uuid); + + List getBuilds(String packageUuid); + + ExportBuild runBuild(String buildUuid); + + int failStrandedBuilds(String reason); + + File getBuildZip(ExportBuild build); +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/db/MetadataExportDao.java b/api/src/main/java/org/openmrs/module/metadataexport/api/db/MetadataExportDao.java new file mode 100644 index 0000000..47b54f3 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/db/MetadataExportDao.java @@ -0,0 +1,35 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.api.db; + +import org.openmrs.module.metadataexport.api.model.ExportBuild; +import org.openmrs.module.metadataexport.api.model.ExportPackage; + +import java.util.List; + +public interface MetadataExportDao { + + ExportPackage savePackage(ExportPackage exportPackage); + + ExportPackage getPackageByUuid(String uuid); + + ExportPackage getPackageByName(String name); + + List getAllPackages(boolean includeRetired); + + ExportBuild saveBuild(ExportBuild exportBuild); + + ExportBuild getBuildByUuid(String uuid); + + List getBuilds(ExportPackage exportPackage); + + List getActiveBuilds(); + +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/db/hibernate/HibernateMetadataExportDao.java b/api/src/main/java/org/openmrs/module/metadataexport/api/db/hibernate/HibernateMetadataExportDao.java new file mode 100644 index 0000000..2ec6179 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/db/hibernate/HibernateMetadataExportDao.java @@ -0,0 +1,107 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.api.db.hibernate; + +import lombok.RequiredArgsConstructor; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.openmrs.module.metadataexport.api.db.MetadataExportDao; +import org.openmrs.module.metadataexport.api.model.ExportBuild; +import org.openmrs.module.metadataexport.api.model.ExportPackage; +import org.openmrs.module.metadataexport.api.model.ExportStatus; + +import javax.persistence.TypedQuery; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@RequiredArgsConstructor +public class HibernateMetadataExportDao implements MetadataExportDao { + + private final SessionFactory sessionFactory; + + @Override + public ExportPackage savePackage(ExportPackage exportPackage) { + sessionFactory.getCurrentSession().saveOrUpdate(exportPackage); + return exportPackage; + } + + @Override + public ExportPackage getPackageByUuid(String uuid) { + TypedQuery query = sessionFactory.getCurrentSession() + .createQuery("from ExportPackage pkg where pkg.uuid = :uuid", ExportPackage.class); + query.setParameter("uuid", uuid); + return query.getResultStream().findFirst().orElse(null); + } + + @Override + public ExportPackage getPackageByName(String name) { + TypedQuery query = sessionFactory.getCurrentSession() + .createQuery("from ExportPackage pkg where pkg.name = :name", ExportPackage.class); + query.setParameter("name", name); + return query.getResultStream().findFirst().orElse(null); + } + + @Override + public List getAllPackages(boolean includeRetired) { + Session session = sessionFactory.getCurrentSession(); + CriteriaBuilder cb = session.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(ExportPackage.class); + Root root = cq.from(ExportPackage.class); + + List predicates = new ArrayList<>(); + + if (!includeRetired) { + predicates.add(cb.isFalse(root.get("retired"))); + } + + if (!predicates.isEmpty()) { + cq.where(predicates.toArray(new Predicate[0])); + } + + TypedQuery query = session.createQuery(cq); + return query.getResultList(); + } + + @Override + public ExportBuild saveBuild(ExportBuild exportBuild) { + sessionFactory.getCurrentSession().saveOrUpdate(exportBuild); + return exportBuild; + } + + @Override + public ExportBuild getBuildByUuid(String uuid) { + TypedQuery query = sessionFactory.getCurrentSession() + .createQuery("from ExportBuild build where build.uuid = :uuid", ExportBuild.class); + query.setParameter("uuid", uuid); + return query.getResultStream().findFirst().orElse(null); + } + + @Override + public List getBuilds(ExportPackage exportPackage) { + TypedQuery query = sessionFactory.getCurrentSession().createQuery( + "from ExportBuild build where build.exportPackage = :exportPackage order by build.version desc", + ExportBuild.class); + query.setParameter("exportPackage", exportPackage); + return query.getResultList(); + } + + @Override + public List getActiveBuilds() { + TypedQuery query = sessionFactory.getCurrentSession() + .createQuery("from ExportBuild build where build.exportStatus in (:statuses)", ExportBuild.class); + query.setParameter("statuses", Arrays.asList(ExportStatus.QUEUED, ExportStatus.RUNNING)); + return query.getResultList(); + } +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/impl/ExporterServiceImpl.java b/api/src/main/java/org/openmrs/module/metadataexport/api/impl/ExporterServiceImpl.java index 2c3ee0c..c1a075d 100644 --- a/api/src/main/java/org/openmrs/module/metadataexport/api/impl/ExporterServiceImpl.java +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/impl/ExporterServiceImpl.java @@ -9,6 +9,7 @@ */ package org.openmrs.module.metadataexport.api.impl; +import lombok.AllArgsConstructor; import org.openmrs.OpenmrsObject; import org.openmrs.module.initializer.Domain; import org.openmrs.module.metadataexport.api.ExporterService; @@ -24,14 +25,11 @@ import java.util.Collection; import java.util.List; +@AllArgsConstructor public class ExporterServiceImpl implements ExporterService { private final DomainExporterRegistry registry; - public ExporterServiceImpl(DomainExporterRegistry registry) { - this.registry = registry; - } - @Override public void export(File outDir, Collection domains) throws IOException { List seeds = new ArrayList<>(); @@ -40,13 +38,18 @@ public void export(File outDir, Collection domains) throws IOException { seeds.addAll(exporter.getAllInstances()); } } - + exportSeeds(outDir, seeds); + } + + @Override + public ExportManifest exportSeeds(File outDir, Collection seeds) throws IOException { ExportManifest manifest = new Selector(registry).select(seeds); ExportContext context = new ExportContext(outDir); for (Domain domain : manifest.getDomains()) { writeDomain(registry.forDomain(domain), manifest.get(domain), context); } + return manifest; } private static boolean isSelected(Collection domains, Domain domain) { diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/impl/MetadataExportServiceImpl.java b/api/src/main/java/org/openmrs/module/metadataexport/api/impl/MetadataExportServiceImpl.java new file mode 100644 index 0000000..48e1936 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/impl/MetadataExportServiceImpl.java @@ -0,0 +1,174 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.api.impl; + +import lombok.AllArgsConstructor; +import org.openmrs.OpenmrsObject; +import org.openmrs.api.APIException; +import org.openmrs.api.impl.BaseOpenmrsService; +import org.openmrs.module.metadataexport.api.ExporterService; +import org.openmrs.module.metadataexport.api.MetadataExportService; +import org.openmrs.module.metadataexport.api.db.MetadataExportDao; +import org.openmrs.module.metadataexport.api.model.ExportBuild; +import org.openmrs.module.metadataexport.api.model.ExportPackage; +import org.openmrs.module.metadataexport.api.model.ExportPackageEntry; +import org.openmrs.module.metadataexport.api.model.ExportStatus; +import org.openmrs.module.metadataexport.export.BuildManifest; +import org.openmrs.module.metadataexport.export.DomainExporter; +import org.openmrs.module.metadataexport.export.DomainExporterRegistry; +import org.openmrs.module.metadataexport.export.ZipUtils; +import org.openmrs.module.metadataexport.select.ExportManifest; +import org.openmrs.util.OpenmrsUtil; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +@AllArgsConstructor +@Transactional +public class MetadataExportServiceImpl extends BaseOpenmrsService implements MetadataExportService { + + private final MetadataExportDao metadataExportDao; + + private final DomainExporterRegistry domainExporterRegistry; + + private final ExporterService exporterService; + + @Override + public ExportPackage saveExportPackage(ExportPackage exportPackage) { + for (ExportPackageEntry entry : exportPackage.getEntries()) { + entry.setExportPackage(exportPackage); + } + return metadataExportDao.savePackage(exportPackage); + } + + @Override + @Transactional(readOnly = true) + public ExportPackage getPackageByUuid(String uuid) { + return metadataExportDao.getPackageByUuid(uuid); + } + + @Override + @Transactional(readOnly = true) + public List getAllPackages(boolean includeRetired) { + return metadataExportDao.getAllPackages(includeRetired); + } + + @Override + public ExportPackage retireExportPackage(ExportPackage exportPackage, String reason) { + return metadataExportDao.savePackage(exportPackage); + } + + @Override + public ExportBuild saveExportBuild(ExportBuild build) { + return metadataExportDao.saveBuild(build); + } + + @Override + @Transactional(readOnly = true) + public ExportBuild getBuildByUuid(String uuid) { + return metadataExportDao.getBuildByUuid(uuid); + } + + @Override + @Transactional(readOnly = true) + public List getBuilds(String packageUuid) { + ExportPackage exportPackage = metadataExportDao.getPackageByUuid(packageUuid); + if (exportPackage == null) { + throw new APIException("No export package with uuid " + packageUuid); + } + return metadataExportDao.getBuilds(exportPackage); + } + + @Override + public ExportBuild runBuild(String buildUuid) { + ExportBuild build = metadataExportDao.getBuildByUuid(buildUuid); + if (build == null) { + throw new APIException("No export build with uuid " + buildUuid); + } + ExportPackage exportPackage = build.getExportPackage(); + + List seeds = new ArrayList<>(); + if (exportPackage.getEntries().isEmpty()) { + // no entries = every registered domain, like the startup export + for (DomainExporter exporter : domainExporterRegistry.all()) { + seeds.addAll(exporter.getAllInstances()); + } + } else { + for (ExportPackageEntry entry : exportPackage.getEntries()) { + DomainExporter exporter = domainExporterRegistry.forDomain(entry.getDomainEnum()); + if (exporter == null) { + throw new APIException("No exporter registered for domain " + entry.getDomain()); + } + if (entry.getItemUuids().isEmpty()) { + seeds.addAll(exporter.getAllInstances()); + } else { + seeds.addAll(exporter.getInstancesByUuids(entry.getItemUuids())); + } + } + } + + File appDataDir = new File(OpenmrsUtil.getApplicationDataDirectory()); + File versionDir = Paths.get(appDataDir.getPath(), "metadataexport", "packages", exportPackage.getUuid(), + String.valueOf(build.getVersion())).toFile(); + File contentDir = new File(versionDir, "content"); + try { + ExportManifest exported = exporterService.exportSeeds(contentDir, seeds); + + String manifestJson = BuildManifest.of(exportPackage, build, exported).toJson(); + Files.createDirectories(contentDir.toPath()); + Files.write(new File(contentDir, "package.json").toPath(), manifestJson.getBytes(StandardCharsets.UTF_8)); + + File zip = new File(versionDir, zipFileName(exportPackage.getName(), build.getVersion())); + ZipUtils.zipDirectory(contentDir, zip); + + build.setExportStatus(ExportStatus.COMPLETED); + build.setDateCompleted(new Date()); + build.setZipPath(appDataDir.toPath().relativize(zip.toPath()).toString().replace(File.separatorChar, '/')); + build.setManifestJson(manifestJson); + return metadataExportDao.saveBuild(build); + } + catch (IOException e) { + throw new APIException("Export of build " + buildUuid + " failed", e); + } + } + + private static String zipFileName(String packageName, Integer version) { + String slug = packageName.trim().toLowerCase().replaceAll("[^a-z0-9._-]+", "-"); + return "metadataexport-" + slug + "-v" + version + ".zip"; + } + + @Override + public int failStrandedBuilds(String reason) { + List stranded = metadataExportDao.getActiveBuilds(); + for (ExportBuild build : stranded) { + build.setExportStatus(ExportStatus.FAILED); + build.setDateCompleted(new Date()); + build.setErrorMessage(reason); + metadataExportDao.saveBuild(build); + } + return stranded.size(); + } + + @Override + @Transactional(readOnly = true) + public File getBuildZip(ExportBuild build) { + if (build.getZipPath() == null) { + return null; + } + return new File(OpenmrsUtil.getApplicationDataDirectory(), build.getZipPath()); + } +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportBuild.java b/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportBuild.java new file mode 100644 index 0000000..a92af65 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportBuild.java @@ -0,0 +1,77 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.api.model; + +import lombok.Getter; +import lombok.Setter; +import org.openmrs.BaseOpenmrsData; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.Lob; +import javax.persistence.ManyToOne; +import javax.persistence.Table; +import java.util.Date; + +@Entity +@Table(name = "metadataexport_build") +@Getter +@Setter +public class ExportBuild extends BaseOpenmrsData { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "build_id") + private Integer buildId; + + @ManyToOne + @JoinColumn(name = "package_id") + private ExportPackage exportPackage; + + @Column(name = "version") + private Integer version; + + @Enumerated(EnumType.STRING) + @Column(name = "export_status", length = 16) + private ExportStatus exportStatus; + + @Column(name = "date_started") + private Date dateStarted; + + @Column(name = "date_completed") + private Date dateCompleted; + + @Column(name = "zip_path", length = 512) + private String zipPath; + + @Lob + @Column(name = "error_message") + private String errorMessage; + + @Lob + @Column(name = "manifest_json") + private String manifestJson; + + @Override + public Integer getId() { + return getBuildId(); + } + + @Override + public void setId(Integer id) { + setBuildId(id); + } +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportPackage.java b/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportPackage.java new file mode 100644 index 0000000..33ff015 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportPackage.java @@ -0,0 +1,50 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.api.model; + +import lombok.Getter; +import lombok.Setter; +import org.openmrs.BaseChangeableOpenmrsMetadata; + +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.OneToMany; +import javax.persistence.Table; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "metadataexport_package") +@Getter +@Setter +public class ExportPackage extends BaseChangeableOpenmrsMetadata { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "package_id") + private Integer packageId; + + @OneToMany(mappedBy = "exportPackage", cascade = CascadeType.ALL, orphanRemoval = true) + private List entries = new ArrayList<>(); + + @Override + public Integer getId() { + return getPackageId(); + } + + @Override + public void setId(Integer id) { + setPackageId(id); + } +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportPackageEntry.java b/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportPackageEntry.java new file mode 100644 index 0000000..6cd002e --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportPackageEntry.java @@ -0,0 +1,67 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.api.model; + +import lombok.Getter; +import lombok.Setter; +import org.openmrs.BaseOpenmrsObject; +import org.openmrs.module.initializer.Domain; + +import javax.persistence.CollectionTable; +import javax.persistence.Column; +import javax.persistence.ElementCollection; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "metadataexport_package_entry") +@Getter +@Setter +public class ExportPackageEntry extends BaseOpenmrsObject { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "entry_id") + private Integer entryId; + + @ManyToOne + @JoinColumn(name = "package_id") + private ExportPackage exportPackage; + + @Column(name = "domain") + private String domain; + + @ElementCollection + @CollectionTable(name = "metadataexport_package_entry_item", joinColumns = @JoinColumn(name = "entry_id")) + @Column(name = "item_uuid") + private List itemUuids = new ArrayList<>(); // Empty = whole domain + + public Domain getDomainEnum() { + return Domain.valueOf(domain); + } + + @Override + public Integer getId() { + return getEntryId(); + } + + @Override + public void setId(Integer id) { + setEntryId(id); + } + +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportStatus.java b/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportStatus.java new file mode 100644 index 0000000..c8449e7 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportStatus.java @@ -0,0 +1,22 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.api.model; + +public enum ExportStatus { + + QUEUED, + RUNNING, + COMPLETED, + FAILED; + + public boolean isTerminal() { + return this == COMPLETED || this == FAILED; + } +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/validator/ExportPackageValidator.java b/api/src/main/java/org/openmrs/module/metadataexport/api/validator/ExportPackageValidator.java new file mode 100644 index 0000000..2d02287 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/validator/ExportPackageValidator.java @@ -0,0 +1,59 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.api.validator; + +import lombok.AllArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.openmrs.annotation.Handler; +import org.openmrs.module.metadataexport.api.db.MetadataExportDao; +import org.openmrs.module.metadataexport.api.model.ExportPackage; +import org.openmrs.module.metadataexport.api.model.ExportPackageEntry; +import org.springframework.stereotype.Component; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +@Component +@AllArgsConstructor +@Handler(supports = { ExportPackage.class }, order = 50) +public class ExportPackageValidator implements Validator { + + private final MetadataExportDao metadataExportDao; + + @Override + public boolean supports(Class clazz) { + return ExportPackage.class.isAssignableFrom(clazz); + } + + @Override + public void validate(Object target, Errors errors) { + ExportPackage exportPackage = (ExportPackage) target; + + if (StringUtils.isBlank(exportPackage.getName())) { + errors.rejectValue("name", "metadataexport.package.name.required", "An export package requires a name"); + } else { + ExportPackage sameName = metadataExportDao.getPackageByName(exportPackage.getName()); + if (sameName != null && !sameName.getUuid().equals(exportPackage.getUuid())) { + errors.rejectValue("name", "metadataexport.package.name.duplicate", + "An export package with this name already exists"); + } + } + + for (int i = 0; i < exportPackage.getEntries().size(); i++) { + ExportPackageEntry entry = exportPackage.getEntries().get(i); + try { + entry.getDomainEnum(); + } + catch (IllegalArgumentException | NullPointerException e) { + errors.rejectValue("entries[" + i + "].domain", "metadataexport.package.entry.domain.unknown", + "Unknown domain '" + entry.getDomain() + "'"); + } + } + } +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/export/BuildManifest.java b/api/src/main/java/org/openmrs/module/metadataexport/export/BuildManifest.java new file mode 100644 index 0000000..dfe1ba2 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/export/BuildManifest.java @@ -0,0 +1,119 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.export; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.Hibernate; +import org.openmrs.OpenmrsMetadata; +import org.openmrs.OpenmrsObject; +import org.openmrs.module.initializer.Domain; +import org.openmrs.module.metadataexport.api.model.ExportBuild; +import org.openmrs.module.metadataexport.api.model.ExportPackage; +import org.openmrs.module.metadataexport.api.model.ExportPackageEntry; +import org.openmrs.module.metadataexport.select.ExportManifest; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; + +/** + * The human- and machine-readable record of one build: the package identity, the entries as the + * user defined them, and every item that actually got exported (including dependency-pulled ones). + * Written as {@code package.json} at the zip root and stored on the build row as + * {@code manifest_json} — the metadatasharing header.xml analogue. + */ +@Getter +@Setter +public class BuildManifest { + + private String name; + + private String description; + + private String packageUuid; + + private String buildUuid; + + private Integer version; + + private Date dateCreated; + + private List entries = new ArrayList<>(); + + private Map> resolvedItems = new LinkedHashMap<>(); + + @Getter + @Setter + public static class Entry { + + private String domain; + + private List itemUuids = new ArrayList<>(); + } + + @Getter + @Setter + public static class Item { + + private String type; + + private String uuid; + + private String display; + } + + public static BuildManifest of(ExportPackage exportPackage, ExportBuild build, ExportManifest exported) { + BuildManifest manifest = new BuildManifest(); + manifest.setName(exportPackage.getName()); + manifest.setDescription(exportPackage.getDescription()); + manifest.setPackageUuid(exportPackage.getUuid()); + manifest.setBuildUuid(build.getUuid()); + manifest.setVersion(build.getVersion()); + manifest.setDateCreated(build.getDateCreated()); + + for (ExportPackageEntry packageEntry : exportPackage.getEntries()) { + Entry entry = new Entry(); + entry.setDomain(packageEntry.getDomain()); + entry.setItemUuids(new ArrayList<>(packageEntry.getItemUuids())); + manifest.getEntries().add(entry); + } + + for (Domain domain : exported.getDomains()) { + List items = new ArrayList<>(); + for (OpenmrsObject object : exported.get(domain)) { + Item item = new Item(); + // same class resolution as DomainExporter.identityKey, so proxies report the real type + item.setType(Hibernate.getClass(object).getName()); + item.setUuid(object.getUuid()); + if (object instanceof OpenmrsMetadata) { + item.setDisplay(((OpenmrsMetadata) object).getName()); + } + items.add(item); + } + manifest.getResolvedItems().put(domain.name(), items); + } + return manifest; + } + + public String toJson() throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + SimpleDateFormat iso = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + iso.setTimeZone(TimeZone.getTimeZone("UTC")); + mapper.setDateFormat(iso); + return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(this); + } +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/export/DomainExporter.java b/api/src/main/java/org/openmrs/module/metadataexport/export/DomainExporter.java index 694faf9..d368588 100644 --- a/api/src/main/java/org/openmrs/module/metadataexport/export/DomainExporter.java +++ b/api/src/main/java/org/openmrs/module/metadataexport/export/DomainExporter.java @@ -11,10 +11,16 @@ import org.hibernate.Hibernate; import org.openmrs.OpenmrsObject; +import org.openmrs.api.APIException; import org.openmrs.module.initializer.Domain; import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; /** * A self-describing, format-neutral exporter for one Iniz {@link Domain}. The ExporterService holds @@ -60,4 +66,18 @@ default String identityKey(T instance) { } return Hibernate.getClass(instance).getName() + ' ' + instance.getUuid(); } + + default Collection getInstancesByUuids(Collection uuids) { + Set wanted = new HashSet<>(uuids); + List found = new ArrayList<>(); + for (T instance : getAllInstances()) { + if (wanted.remove(instance.getUuid())) { + found.add(instance); + } + } + if (!wanted.isEmpty()) { + throw new APIException("Unknown uuids in domain " + getDomain() + ": " + wanted); + } + return found; + } } diff --git a/api/src/main/java/org/openmrs/module/metadataexport/export/ZipUtils.java b/api/src/main/java/org/openmrs/module/metadataexport/export/ZipUtils.java new file mode 100644 index 0000000..42a3489 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/export/ZipUtils.java @@ -0,0 +1,45 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.export; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +public final class ZipUtils { + + private ZipUtils() { + } + + public static void zipDirectory(File sourceDir, File zipFile) throws IOException { + Path source = sourceDir.toPath(); + Files.createDirectories(zipFile.toPath().getParent()); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(zipFile.toPath()))) { + try (Stream paths = Files.walk(source)) { + List files = paths.filter(Files::isRegularFile).filter(p -> !p.equals(zipFile.toPath())).sorted() + .collect(Collectors.toList()); + + for (Path file : files) { + String entryName = source.relativize(file).toString().replace(File.separatorChar, '/'); + zip.putNextEntry(new ZipEntry(entryName)); + Files.copy(file, zip); + zip.closeEntry(); + } + } + } + } + +} diff --git a/api/src/main/resources/liquibase.xml b/api/src/main/resources/liquibase.xml index bf11f73..83e0f8d 100644 --- a/api/src/main/resources/liquibase.xml +++ b/api/src/main/resources/liquibase.xml @@ -10,41 +10,153 @@ graphic logo is a trademark of OpenMRS Inc. --> - + - - - - - - + + + + + + Create the metadataexport_package table (export package definitions) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Create the metadataexport_package_entry table (one row per domain in a package) + + + + + + + + + + + + + + + + + + + + + + Create the metadataexport_package_entry_item table (item uuids scoping an entry; no rows = whole domain) + + + + + + + + + + + + + + + + + Create the metadataexport_build table (one row per export run of a package) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/api/src/main/resources/messages.properties b/api/src/main/resources/messages.properties index 544d3e3..0dda446 100644 --- a/api/src/main/resources/messages.properties +++ b/api/src/main/resources/messages.properties @@ -9,3 +9,7 @@ # metadataexport.title=Metadata Export + +metadataexport.package.name.required=An export package requires a name +metadataexport.package.name.duplicate=An export package with this name already exists +metadataexport.package.entry.domain.unknown=Unknown domain diff --git a/api/src/main/resources/messages_es.properties b/api/src/main/resources/messages_es.properties index 544d3e3..0dda446 100644 --- a/api/src/main/resources/messages_es.properties +++ b/api/src/main/resources/messages_es.properties @@ -9,3 +9,7 @@ # metadataexport.title=Metadata Export + +metadataexport.package.name.required=An export package requires a name +metadataexport.package.name.duplicate=An export package with this name already exists +metadataexport.package.entry.domain.unknown=Unknown domain diff --git a/api/src/main/resources/messages_fr.properties b/api/src/main/resources/messages_fr.properties index 544d3e3..0dda446 100644 --- a/api/src/main/resources/messages_fr.properties +++ b/api/src/main/resources/messages_fr.properties @@ -9,3 +9,7 @@ # metadataexport.title=Metadata Export + +metadataexport.package.name.required=An export package requires a name +metadataexport.package.name.duplicate=An export package with this name already exists +metadataexport.package.entry.domain.unknown=Unknown domain diff --git a/api/src/main/resources/moduleApplicationContext.xml b/api/src/main/resources/moduleApplicationContext.xml index 1dc6b24..a1615b4 100644 --- a/api/src/main/resources/moduleApplicationContext.xml +++ b/api/src/main/resources/moduleApplicationContext.xml @@ -28,6 +28,11 @@ + + + + @@ -53,4 +58,31 @@ + + + + + + + + + + + + + + + + + + + + + org.openmrs.module.metadataexport.api.MetadataExportService + + + + + diff --git a/api/src/test/java/org/openmrs/module/metadataexport/api/ExportJobRunnerTest.java b/api/src/test/java/org/openmrs/module/metadataexport/api/ExportJobRunnerTest.java new file mode 100644 index 0000000..7af94f5 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/metadataexport/api/ExportJobRunnerTest.java @@ -0,0 +1,119 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.api; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openmrs.api.APIException; +import org.openmrs.api.context.Context; +import org.openmrs.module.initializer.Domain; +import org.openmrs.module.metadataexport.api.model.ExportBuild; +import org.openmrs.module.metadataexport.api.model.ExportPackage; +import org.openmrs.module.metadataexport.api.model.ExportPackageEntry; +import org.openmrs.module.metadataexport.api.model.ExportStatus; +import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest; +import org.openmrs.util.OpenmrsUtil; +import org.springframework.beans.factory.annotation.Autowired; + +import java.io.File; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ExportJobRunnerTest extends BaseModuleContextSensitiveTest { + + @Autowired + private ExportJobRunner runner; + + private MetadataExportService service; + + @BeforeEach + void setUp(@TempDir File appDataDir) { + service = Context.getService(MetadataExportService.class); + OpenmrsUtil.setApplicationDataDirectory(appDataDir.getAbsolutePath()); + } + + @Test + void execute_completesABuild() { + ExportBuild build = queuedBuild(savePackage("Happy", Domain.LOCATIONS.name())); + + runner.execute(build.getUuid()); + + ExportBuild reloaded = service.getBuildByUuid(build.getUuid()); + assertEquals(ExportStatus.COMPLETED, reloaded.getExportStatus()); + assertNotNull(reloaded.getDateStarted()); + assertNotNull(reloaded.getDateCompleted()); + assertTrue(service.getBuildZip(reloaded).exists()); + } + + @Test + void execute_marksAFailedBuildWithTheRootCause() { + ExportBuild build = queuedBuild(savePackage("Broken", Domain.LOCATIONS.name(), "no-such-uuid")); + + runner.execute(build.getUuid()); + + ExportBuild reloaded = service.getBuildByUuid(build.getUuid()); + assertEquals(ExportStatus.FAILED, reloaded.getExportStatus()); + assertNotNull(reloaded.getDateCompleted()); + assertTrue(reloaded.getErrorMessage().contains("no-such-uuid"), reloaded.getErrorMessage()); + } + + @Test + void trigger_rejectsASecondBuildWhileOneIsActive() { + ExportPackage exportPackage = savePackage("Busy", Domain.LOCATIONS.name()); + ExportBuild running = queuedBuild(exportPackage); + running.setExportStatus(ExportStatus.RUNNING); + service.saveExportBuild(running); + + assertThrows(ActiveBuildException.class, () -> runner.trigger(exportPackage.getUuid())); + } + + @Test + void trigger_assignsTheNextVersionAndFailsTheBuildWhenTheDaemonCannotStart() { + ExportPackage exportPackage = savePackage("Versioned", Domain.LOCATIONS.name()); + ExportBuild first = queuedBuild(exportPackage); + first.setExportStatus(ExportStatus.COMPLETED); + service.saveExportBuild(first); + + // no daemon token in tests, so the launch fails after the QUEUED build is saved + assertThrows(APIException.class, () -> runner.trigger(exportPackage.getUuid())); + + List builds = service.getBuilds(exportPackage.getUuid()); + assertEquals(2, builds.size()); + ExportBuild second = builds.get(0); + assertEquals(2, second.getVersion()); + assertEquals(ExportStatus.FAILED, second.getExportStatus()); + assertTrue(second.getErrorMessage().contains("daemon"), second.getErrorMessage()); + } + + private ExportPackage savePackage(String name, String domain, String... itemUuids) { + ExportPackage exportPackage = new ExportPackage(); + exportPackage.setName(name); + exportPackage.setDescription("test"); + ExportPackageEntry entry = new ExportPackageEntry(); + entry.setDomain(domain); + entry.getItemUuids().addAll(Arrays.asList(itemUuids)); + exportPackage.getEntries().add(entry); + return service.saveExportPackage(exportPackage); + } + + private ExportBuild queuedBuild(ExportPackage exportPackage) { + ExportBuild build = new ExportBuild(); + build.setExportPackage(exportPackage); + build.setVersion(1); + build.setExportStatus(ExportStatus.QUEUED); + return service.saveExportBuild(build); + } +} diff --git a/api/src/test/java/org/openmrs/module/metadataexport/api/MetadataExportIntegrationTest.java b/api/src/test/java/org/openmrs/module/metadataexport/api/MetadataExportIntegrationTest.java index a2af938..acc067c 100644 --- a/api/src/test/java/org/openmrs/module/metadataexport/api/MetadataExportIntegrationTest.java +++ b/api/src/test/java/org/openmrs/module/metadataexport/api/MetadataExportIntegrationTest.java @@ -12,18 +12,13 @@ import com.opencsv.CSVReader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.openmrs.api.context.Context; import org.openmrs.module.initializer.Domain; -import org.openmrs.module.metadataexport.domain.concept.ConceptDomainExporter; -import org.openmrs.module.metadataexport.domain.concept.ConceptSetDomainExporter; -import org.openmrs.module.metadataexport.domain.encounter.EncounterTypeDomainExporter; -import org.openmrs.module.metadataexport.export.DomainExporterRegistry; -import org.openmrs.module.metadataexport.api.impl.ExporterServiceImpl; import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest; import java.io.File; import java.io.FileReader; import java.nio.file.Paths; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -40,8 +35,7 @@ class MetadataExportIntegrationTest extends BaseModuleContextSensitiveTest { @Test public void export_writesEncounterTypesFromTheRealDatabase(@TempDir File outDir) throws Exception { - ExporterService service = new ExporterServiceImpl( - new DomainExporterRegistry(Collections.singletonList(new EncounterTypeDomainExporter()))); + ExporterService service = Context.getService(ExporterService.class); service.export(outDir, Collections.singletonList(Domain.ENCOUNTER_TYPES)); @@ -69,8 +63,7 @@ public void export_writesEncounterTypesFromTheRealDatabase(@TempDir File outDir) @Test public void export_pullsSetMembershipRowsWhenOnlyConceptsAreSelected(@TempDir File outDir) throws Exception { - ExporterService service = new ExporterServiceImpl( - new DomainExporterRegistry(Arrays.asList(new ConceptDomainExporter(), new ConceptSetDomainExporter()))); + ExporterService service = Context.getService(ExporterService.class); service.export(outDir, Collections.singletonList(Domain.CONCEPTS)); diff --git a/api/src/test/java/org/openmrs/module/metadataexport/api/MetadataExportServiceTest.java b/api/src/test/java/org/openmrs/module/metadataexport/api/MetadataExportServiceTest.java new file mode 100644 index 0000000..002c057 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/metadataexport/api/MetadataExportServiceTest.java @@ -0,0 +1,180 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.api; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openmrs.Location; +import org.openmrs.api.ValidationException; +import org.openmrs.api.context.Context; +import org.openmrs.module.initializer.Domain; +import org.openmrs.module.metadataexport.api.model.ExportBuild; +import org.openmrs.module.metadataexport.api.model.ExportPackage; +import org.openmrs.module.metadataexport.api.model.ExportPackageEntry; +import org.openmrs.module.metadataexport.api.model.ExportStatus; +import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest; +import org.openmrs.util.OpenmrsUtil; + +import java.io.File; +import java.util.Arrays; +import java.util.zip.ZipFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MetadataExportServiceTest extends BaseModuleContextSensitiveTest { + + private MetadataExportService service; + + @BeforeEach + void setUp(@TempDir File appDataDir) { + service = Context.getService(MetadataExportService.class); + OpenmrsUtil.setApplicationDataDirectory(appDataDir.getAbsolutePath()); + } + + @Test + void savePackage_roundTripsTheDefinitionWithEntries() { + ExportPackage saved = service + .saveExportPackage(packageWith("Site A locations", Domain.LOCATIONS.name(), "u-1", "u-2")); + + ExportPackage loaded = service.getPackageByUuid(saved.getUuid()); + + assertEquals("Site A locations", loaded.getName()); + assertEquals(1, loaded.getEntries().size()); + ExportPackageEntry entry = loaded.getEntries().get(0); + assertEquals(Domain.LOCATIONS.name(), entry.getDomain()); + assertEquals(Arrays.asList("u-1", "u-2"), entry.getItemUuids()); + } + + @Test + void savePackage_rejectsADuplicateName() { + service.saveExportPackage(packageWith("Dup", Domain.LOCATIONS.name())); + + assertThrows(ValidationException.class, + () -> service.saveExportPackage(packageWith("Dup", Domain.LOCATIONS.name()))); + } + + @Test + void savePackage_rejectsAnUnknownDomain() { + assertThrows(ValidationException.class, () -> service.saveExportPackage(packageWith("Bad", "NOT_A_DOMAIN"))); + } + + @Test + void savePackage_allowsResavingUnderItsOwnName() { + ExportPackage saved = service.saveExportPackage(packageWith("Same", Domain.LOCATIONS.name())); + saved.setDescription("updated"); + + service.saveExportPackage(saved); + + assertEquals("updated", service.getPackageByUuid(saved.getUuid()).getDescription()); + } + + @Test + void failStrandedBuilds_marksActiveBuildsFailedAndLeavesTerminalOnesAlone() { + ExportPackage saved = service.saveExportPackage(packageWith("Stranded", Domain.LOCATIONS.name())); + ExportBuild running = new ExportBuild(); + running.setExportPackage(saved); + running.setVersion(1); + running.setExportStatus(ExportStatus.RUNNING); + service.saveExportBuild(running); + ExportBuild completed = new ExportBuild(); + completed.setExportPackage(saved); + completed.setVersion(2); + completed.setExportStatus(ExportStatus.COMPLETED); + service.saveExportBuild(completed); + + int recovered = service.failStrandedBuilds("Interrupted by a server restart"); + + assertEquals(1, recovered); + ExportBuild reloaded = service.getBuildByUuid(running.getUuid()); + assertEquals(ExportStatus.FAILED, reloaded.getExportStatus()); + assertEquals("Interrupted by a server restart", reloaded.getErrorMessage()); + assertEquals(ExportStatus.COMPLETED, service.getBuildByUuid(completed.getUuid()).getExportStatus()); + } + + @Test + void retirePackage_fillsTheRetireFieldsViaAop() { + ExportPackage saved = service.saveExportPackage(packageWith("Old", Domain.LOCATIONS.name())); + + service.retireExportPackage(saved, "obsolete"); + + ExportPackage reloaded = service.getPackageByUuid(saved.getUuid()); + assertTrue(reloaded.getRetired()); + assertEquals("obsolete", reloaded.getRetireReason()); + assertNotNull(reloaded.getRetiredBy()); + assertNotNull(reloaded.getDateRetired()); + } + + @Test + void runBuild_exportsTheScopedLocationsAndZipsThem() throws Exception { + Location target = Context.getLocationService().getAllLocations().get(0); + ExportPackage saved = service + .saveExportPackage(packageWith("Site A locations", Domain.LOCATIONS.name(), target.getUuid())); + + ExportBuild build = new ExportBuild(); + build.setExportPackage(saved); + build.setVersion(1); + build.setExportStatus(ExportStatus.QUEUED); + build = service.saveExportBuild(build); + + ExportBuild completed = service.runBuild(build.getUuid()); + + assertEquals(ExportStatus.COMPLETED, completed.getExportStatus()); + assertNotNull(completed.getDateCompleted()); + assertNotNull(completed.getManifestJson()); + assertTrue(completed.getManifestJson().contains(target.getUuid())); + + File zip = service.getBuildZip(completed); + assertNotNull(zip); + assertTrue(zip.exists(), "expected " + zip); + try (ZipFile zipFile = new ZipFile(zip)) { + assertNotNull(zipFile.getEntry("package.json"), "package.json should sit at the zip root"); + assertNotNull(zipFile.getEntry("configuration/locations/locations.csv"), + "the Initializer tree should sit beside it"); + } + } + + @Test + void runBuild_withNoEntriesExportsEveryRegisteredDomain() throws Exception { + ExportPackage everything = new ExportPackage(); + everything.setName("Everything"); + everything.setDescription("test"); + ExportPackage saved = service.saveExportPackage(everything); + + ExportBuild build = new ExportBuild(); + build.setExportPackage(saved); + build.setVersion(1); + build.setExportStatus(ExportStatus.QUEUED); + build = service.saveExportBuild(build); + + ExportBuild completed = service.runBuild(build.getUuid()); + + assertEquals(ExportStatus.COMPLETED, completed.getExportStatus()); + try (ZipFile zipFile = new ZipFile(service.getBuildZip(completed))) { + assertNotNull(zipFile.getEntry("configuration/locations/locations.csv")); + assertNotNull(zipFile.getEntry("configuration/encountertypes/encounterTypes.csv")); + assertNotNull(zipFile.getEntry("package.json")); + } + } + + private static ExportPackage packageWith(String name, String domain, String... itemUuids) { + ExportPackage exportPackage = new ExportPackage(); + exportPackage.setName(name); + exportPackage.setDescription("test package"); + ExportPackageEntry entry = new ExportPackageEntry(); + entry.setDomain(domain); + entry.getItemUuids().addAll(Arrays.asList(itemUuids)); + exportPackage.getEntries().add(entry); + return exportPackage; + } +} diff --git a/api/src/test/java/org/openmrs/module/metadataexport/export/BuildManifestTest.java b/api/src/test/java/org/openmrs/module/metadataexport/export/BuildManifestTest.java new file mode 100644 index 0000000..038f38e --- /dev/null +++ b/api/src/test/java/org/openmrs/module/metadataexport/export/BuildManifestTest.java @@ -0,0 +1,65 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.export; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.openmrs.Location; +import org.openmrs.module.initializer.Domain; +import org.openmrs.module.metadataexport.api.model.ExportBuild; +import org.openmrs.module.metadataexport.api.model.ExportPackage; +import org.openmrs.module.metadataexport.api.model.ExportPackageEntry; +import org.openmrs.module.metadataexport.select.ExportManifest; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BuildManifestTest { + + @Test + void toJson_capturesPackageEntriesAndResolvedItems() throws Exception { + ExportPackage exportPackage = new ExportPackage(); + exportPackage.setName("Site A locations"); + exportPackage.setDescription("Locations for Site A"); + ExportPackageEntry entry = new ExportPackageEntry(); + entry.setDomain(Domain.LOCATIONS.name()); + entry.getItemUuids().addAll(Arrays.asList("loc-1", "loc-2")); + exportPackage.getEntries().add(entry); + + ExportBuild build = new ExportBuild(); + build.setExportPackage(exportPackage); + build.setVersion(3); + + Location siteA = new Location(); + siteA.setName("Site A"); + siteA.setUuid("loc-1"); + ExportManifest exported = new ExportManifest(); + exported.add(Domain.LOCATIONS, Location.class.getName() + " loc-1", siteA); + + String json = BuildManifest.of(exportPackage, build, exported).toJson(); + + JsonNode root = new ObjectMapper().readTree(json); + assertEquals("Site A locations", root.get("name").asText()); + assertEquals(exportPackage.getUuid(), root.get("packageUuid").asText()); + assertEquals(build.getUuid(), root.get("buildUuid").asText()); + assertEquals(3, root.get("version").asInt()); + + JsonNode entryNode = root.get("entries").get(0); + assertEquals("LOCATIONS", entryNode.get("domain").asText()); + assertEquals("loc-2", entryNode.get("itemUuids").get(1).asText()); + + JsonNode item = root.get("resolvedItems").get("LOCATIONS").get(0); + assertEquals(Location.class.getName(), item.get("type").asText()); + assertEquals("loc-1", item.get("uuid").asText()); + assertEquals("Site A", item.get("display").asText()); + } +} diff --git a/api/src/test/java/org/openmrs/module/metadataexport/export/DomainExporterTest.java b/api/src/test/java/org/openmrs/module/metadataexport/export/DomainExporterTest.java new file mode 100644 index 0000000..2053d6e --- /dev/null +++ b/api/src/test/java/org/openmrs/module/metadataexport/export/DomainExporterTest.java @@ -0,0 +1,84 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.export; + +import org.junit.jupiter.api.Test; +import org.openmrs.EncounterType; +import org.openmrs.OpenmrsObject; +import org.openmrs.api.APIException; +import org.openmrs.module.initializer.Domain; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DomainExporterTest { + + @Test + void getInstancesByUuids_returnsOnlyTheRequestedInstances() { + DomainExporter exporter = exporterWith(type("et-1"), type("et-2"), type("et-3")); + + Collection found = exporter.getInstancesByUuids(Arrays.asList("et-1", "et-3")); + + assertEquals(Arrays.asList("et-1", "et-3"), found.stream().map(OpenmrsObject::getUuid).collect(Collectors.toList())); + } + + @Test + void getInstancesByUuids_throwsNamingDomainAndEveryUnknownUuid() { + DomainExporter exporter = exporterWith(type("et-1")); + + APIException e = assertThrows(APIException.class, + () -> exporter.getInstancesByUuids(Arrays.asList("et-1", "nope-1", "nope-2"))); + + assertTrue(e.getMessage().contains(Domain.ENCOUNTER_TYPES.toString()), e.getMessage()); + assertTrue(e.getMessage().contains("nope-1"), e.getMessage()); + assertTrue(e.getMessage().contains("nope-2"), e.getMessage()); + } + + private static EncounterType type(String uuid) { + EncounterType type = new EncounterType(); + type.setUuid(uuid); + return type; + } + + private static DomainExporter exporterWith(EncounterType... instances) { + return new DomainExporter() { + + @Override + public Domain getDomain() { + return Domain.ENCOUNTER_TYPES; + } + + @Override + public boolean handles(OpenmrsObject instance) { + return instance instanceof EncounterType; + } + + @Override + public Collection getAllInstances() { + return Arrays.asList(instances); + } + + @Override + public Collection getDependencies(EncounterType instance) { + return Collections.emptyList(); + } + + @Override + public void export(Collection toExport, ExportContext context) { + } + }; + } +} diff --git a/api/src/test/java/org/openmrs/module/metadataexport/export/ZipUtilsTest.java b/api/src/test/java/org/openmrs/module/metadataexport/export/ZipUtilsTest.java new file mode 100644 index 0000000..0bc367d --- /dev/null +++ b/api/src/test/java/org/openmrs/module/metadataexport/export/ZipUtilsTest.java @@ -0,0 +1,83 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.export; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.List; +import java.util.Scanner; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ZipUtilsTest { + + @Test + void zipDirectory_zipsTreeRelativeToSourceWithForwardSlashes(@TempDir File dir) throws IOException { + File source = new File(dir, "content"); + write(new File(source, "package.json"), "{\"version\":1}"); + write(new File(source, "configuration/locations/locations.csv"), "uuid,name"); + write(new File(source, "configuration/encountertypes/encounterTypes.csv"), "uuid"); + File zip = new File(dir, "out.zip"); + + ZipUtils.zipDirectory(source, zip); + + try (ZipFile zipFile = new ZipFile(zip)) { + assertEquals(Arrays.asList("configuration/encountertypes/encounterTypes.csv", + "configuration/locations/locations.csv", "package.json"), entryNames(zipFile)); + assertEquals("{\"version\":1}", content(zipFile, "package.json")); + } + } + + @Test + void zipDirectory_overwritesAnExistingZip(@TempDir File dir) throws IOException { + File source = new File(dir, "content"); + write(new File(source, "a.txt"), "first"); + File zip = new File(dir, "out.zip"); + ZipUtils.zipDirectory(source, zip); + + write(new File(source, "b.txt"), "second"); + ZipUtils.zipDirectory(source, zip); + + try (ZipFile zipFile = new ZipFile(zip)) { + assertEquals(Arrays.asList("a.txt", "b.txt"), entryNames(zipFile)); + } + } + + private static void write(File file, String content) throws IOException { + Files.createDirectories(file.getParentFile().toPath()); + Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); + } + + private static List entryNames(ZipFile zipFile) { + List names = new ArrayList<>(); + for (Enumeration entries = zipFile.entries(); entries.hasMoreElements();) { + names.add(entries.nextElement().getName()); + } + return names; + } + + private static String content(ZipFile zipFile, String entryName) throws IOException { + try (InputStream in = zipFile.getInputStream(zipFile.getEntry(entryName)); + Scanner scanner = new Scanner(in, StandardCharsets.UTF_8.name()).useDelimiter("\\A")) { + return scanner.hasNext() ? scanner.next() : ""; + } + } +} diff --git a/omod/pom.xml b/omod/pom.xml index d216620..99d9bfa 100644 --- a/omod/pom.xml +++ b/omod/pom.xml @@ -17,17 +17,48 @@ org.openmrs.module metadataexport-api 1.0.0-SNAPSHOT + + + org.openmrs.module + metadataexport-api + 1.0.0-SNAPSHOT + test-jar + test org.openmrs.web openmrs-web - provided + provided + + + javax.servlet + servlet-api + + org.openmrs.web openmrs-web provided tests + + + javax.servlet + servlet-api + + + + + org.springframework + spring-test + 5.3.30 + test + + + javax.servlet + javax.servlet-api + 4.0.1 + provided diff --git a/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/ExportBuildController.java b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/ExportBuildController.java new file mode 100644 index 0000000..44f1b29 --- /dev/null +++ b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/ExportBuildController.java @@ -0,0 +1,74 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.web.controller; + +import org.openmrs.api.context.Context; +import org.openmrs.module.metadataexport.api.MetadataExportService; +import org.openmrs.module.metadataexport.api.model.ExportBuild; +import org.openmrs.module.metadataexport.api.model.ExportStatus; +import org.openmrs.module.metadataexport.web.controller.dto.ExportBuildDto; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Collections; + +@Controller("metadataexport.ExportBuildController") +@RequestMapping(MetadataExportRestConstants.BASE + "/builds") +public class ExportBuildController { + + @GetMapping("/{uuid}") + @ResponseBody + public ResponseEntity getBuild(@PathVariable String uuid) { + Context.requirePrivilege(MetadataExportRestConstants.GET_PRIVILEGE); + ExportBuild build = service().getBuildByUuid(uuid); + if (build == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(ExportBuildDto.detailFrom(build)); + } + + @GetMapping("/{uuid}/download") + @ResponseBody + public ResponseEntity download(@PathVariable String uuid, HttpServletResponse response) throws IOException { + Context.requirePrivilege(MetadataExportRestConstants.MANAGE_PRIVILEGE); + ExportBuild build = service().getBuildByUuid(uuid); + if (build == null) { + return ResponseEntity.notFound().build(); + } + if (build.getExportStatus() != ExportStatus.COMPLETED) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Collections.singletonMap("error", "Build is " + build.getExportStatus() + ", not COMPLETED")); + } + File zip = service().getBuildZip(build); + if (zip == null || !zip.exists()) { + return ResponseEntity.status(HttpStatus.GONE) + .body(Collections.singletonMap("error", "The zip of this build no longer exists on the server")); + } + response.setContentType("application/zip"); + response.setHeader("Content-Length", String.valueOf(zip.length())); + response.setHeader("Content-Disposition", "attachment; filename=\"" + zip.getName() + "\""); + Files.copy(zip.toPath(), response.getOutputStream()); + response.flushBuffer(); + return null; + } + + private static MetadataExportService service() { + return Context.getService(MetadataExportService.class); + } +} diff --git a/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/ExportDomainController.java b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/ExportDomainController.java new file mode 100644 index 0000000..4c5836f --- /dev/null +++ b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/ExportDomainController.java @@ -0,0 +1,43 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.web.controller; + +import lombok.AllArgsConstructor; +import org.openmrs.api.context.Context; +import org.openmrs.module.metadataexport.export.DomainExporter; +import org.openmrs.module.metadataexport.export.DomainExporterRegistry; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Controller("metadataexport.ExportDomainController") +@RequestMapping(MetadataExportRestConstants.BASE + "/domains") +@AllArgsConstructor +public class ExportDomainController { + + private final DomainExporterRegistry domainExporterRegistry; + + @GetMapping + @ResponseBody + public List listDomains() { + Context.requirePrivilege(MetadataExportRestConstants.GET_PRIVILEGE); + List domains = new ArrayList<>(); + for (DomainExporter exporter : domainExporterRegistry.all()) { + domains.add(exporter.getDomain().name()); + } + Collections.sort(domains); + return domains; + } +} diff --git a/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/ExportPackageController.java b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/ExportPackageController.java new file mode 100644 index 0000000..9409c36 --- /dev/null +++ b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/ExportPackageController.java @@ -0,0 +1,163 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.web.controller; + +import lombok.AllArgsConstructor; +import org.openmrs.api.context.Context; +import org.openmrs.module.metadataexport.api.ActiveBuildException; +import org.openmrs.module.metadataexport.api.ExportJobRunner; +import org.openmrs.module.metadataexport.api.MetadataExportService; +import org.openmrs.module.metadataexport.api.model.ExportBuild; +import org.openmrs.module.metadataexport.api.model.ExportPackage; +import org.openmrs.module.metadataexport.api.model.ExportPackageEntry; +import org.openmrs.module.metadataexport.web.controller.dto.ExportBuildDto; +import org.openmrs.module.metadataexport.web.controller.dto.ExportPackageDto; +import org.openmrs.module.metadataexport.web.controller.dto.ExportPackageEntryDto; +import org.openmrs.module.metadataexport.web.controller.dto.ExportPackageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +@Controller("metadataexport.ExportPackageController") +@RequestMapping(MetadataExportRestConstants.BASE + "/packages") +@AllArgsConstructor +public class ExportPackageController { + + private final ExportJobRunner exportJobRunner; + + @GetMapping + @ResponseBody + public List listPackages( + @RequestParam(value = "includeRetired", defaultValue = "false") boolean includeRetired) { + Context.requirePrivilege(MetadataExportRestConstants.GET_PRIVILEGE); + List dtos = new ArrayList<>(); + for (ExportPackage exportPackage : service().getAllPackages(includeRetired)) { + dtos.add(ExportPackageDto.from(exportPackage, latestBuild(exportPackage))); + } + return dtos; + } + + @PostMapping + @ResponseBody + public ResponseEntity createPackage(@RequestBody ExportPackageRequest request) { + Context.requirePrivilege(MetadataExportRestConstants.MANAGE_PRIVILEGE); + ExportPackage exportPackage = new ExportPackage(); + apply(request, exportPackage); + ExportPackage saved = service().saveExportPackage(exportPackage); + return ResponseEntity.status(HttpStatus.CREATED).body(ExportPackageDto.from(saved)); + } + + @GetMapping("/{uuid}") + @ResponseBody + public ResponseEntity getPackage(@PathVariable String uuid) { + Context.requirePrivilege(MetadataExportRestConstants.GET_PRIVILEGE); + ExportPackage exportPackage = service().getPackageByUuid(uuid); + if (exportPackage == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(ExportPackageDto.from(exportPackage, latestBuild(exportPackage))); + } + + @PutMapping("/{uuid}") + @ResponseBody + public ResponseEntity updatePackage(@PathVariable String uuid, + @RequestBody ExportPackageRequest request) { + Context.requirePrivilege(MetadataExportRestConstants.MANAGE_PRIVILEGE); + ExportPackage exportPackage = service().getPackageByUuid(uuid); + if (exportPackage == null) { + return ResponseEntity.notFound().build(); + } + apply(request, exportPackage); + ExportPackage saved = service().saveExportPackage(exportPackage); + return ResponseEntity.ok(ExportPackageDto.from(saved, latestBuild(saved))); + } + + @DeleteMapping("/{uuid}") + @ResponseBody + public ResponseEntity retirePackage(@PathVariable String uuid, + @RequestParam(value = "reason", defaultValue = "web service call") String reason) { + Context.requirePrivilege(MetadataExportRestConstants.MANAGE_PRIVILEGE); + ExportPackage exportPackage = service().getPackageByUuid(uuid); + if (exportPackage == null) { + return ResponseEntity.notFound().build(); + } + service().retireExportPackage(exportPackage, reason); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{uuid}/builds") + @ResponseBody + public ResponseEntity triggerBuild(@PathVariable String uuid) { + Context.requirePrivilege(MetadataExportRestConstants.MANAGE_PRIVILEGE); + if (service().getPackageByUuid(uuid) == null) { + return ResponseEntity.notFound().build(); + } + try { + ExportBuild queued = exportJobRunner.trigger(uuid); + return ResponseEntity.accepted().body(ExportBuildDto.from(queued)); + } + catch (ActiveBuildException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(Collections.singletonMap("error", e.getMessage())); + } + } + + @GetMapping("/{uuid}/builds") + @ResponseBody + public ResponseEntity> listBuilds(@PathVariable String uuid) { + Context.requirePrivilege(MetadataExportRestConstants.GET_PRIVILEGE); + if (service().getPackageByUuid(uuid) == null) { + return ResponseEntity.notFound().build(); + } + List dtos = new ArrayList<>(); + for (ExportBuild build : service().getBuilds(uuid)) { + dtos.add(ExportBuildDto.from(build)); + } + return ResponseEntity.ok(dtos); + } + + private static void apply(ExportPackageRequest request, ExportPackage exportPackage) { + exportPackage.setName(request.getName()); + exportPackage.setDescription(request.getDescription()); + exportPackage.getEntries().clear(); + if (request.getEntries() != null) { + for (ExportPackageEntryDto entryDto : request.getEntries()) { + ExportPackageEntry entry = new ExportPackageEntry(); + entry.setDomain(entryDto.getDomain() == null ? null : entryDto.getDomain().trim().toUpperCase(Locale.ROOT)); + if (entryDto.getItemUuids() != null) { + entry.getItemUuids().addAll(entryDto.getItemUuids()); + } + exportPackage.getEntries().add(entry); + } + } + } + + private ExportBuild latestBuild(ExportPackage exportPackage) { + List builds = service().getBuilds(exportPackage.getUuid()); + return builds.isEmpty() ? null : builds.get(0); + } + + private static MetadataExportService service() { + return Context.getService(MetadataExportService.class); + } +} diff --git a/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/MetadataExportControllerAdvice.java b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/MetadataExportControllerAdvice.java new file mode 100644 index 0000000..09807a5 --- /dev/null +++ b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/MetadataExportControllerAdvice.java @@ -0,0 +1,77 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.web.controller; + +import lombok.extern.slf4j.Slf4j; +import org.openmrs.api.APIAuthenticationException; +import org.openmrs.api.APIException; +import org.openmrs.api.ValidationException; +import org.openmrs.api.context.Context; +import org.openmrs.api.context.ContextAuthenticationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@RestControllerAdvice(basePackages = "org.openmrs.module.metadataexport.web.controller") +public class MetadataExportControllerAdvice { + + @ExceptionHandler(ValidationException.class) + public ResponseEntity> handleValidation(ValidationException e) { + Map body = new LinkedHashMap<>(); + body.put("error", "Validation failed"); + Map fieldErrors = new LinkedHashMap<>(); + List globalErrors = new ArrayList<>(); + if (e.getErrors() != null) { + for (FieldError fieldError : e.getErrors().getFieldErrors()) { + fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + for (ObjectError globalError : e.getErrors().getGlobalErrors()) { + globalErrors.add(globalError.getDefaultMessage()); + } + } + body.put("fieldErrors", fieldErrors); + body.put("globalErrors", globalErrors); + return ResponseEntity.badRequest().body(body); + } + + @ExceptionHandler({ APIAuthenticationException.class, ContextAuthenticationException.class }) + public ResponseEntity> handleAuthentication(APIException e) { + HttpStatus status = Context.isAuthenticated() ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED; + return ResponseEntity.status(status).body(Collections.singletonMap("error", e.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleBadRequest(IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Collections.singletonMap("error", e.getMessage())); + } + + @ExceptionHandler(APIException.class) + public ResponseEntity> handleApiException(APIException e) { + log.warn("Metadata Export: service exception handling a REST request", e); + return ResponseEntity.badRequest().body(Collections.singletonMap("error", e.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception e) { + log.error("Metadata Export: unexpected error handling a REST request", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Collections.singletonMap("error", "An unexpected error occurred; see the server log")); + } +} diff --git a/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/MetadataExportRestConstants.java b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/MetadataExportRestConstants.java new file mode 100644 index 0000000..429f976 --- /dev/null +++ b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/MetadataExportRestConstants.java @@ -0,0 +1,22 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.web.controller; + +public final class MetadataExportRestConstants { + + public static final String BASE = "/rest/v1/metadataexport"; + + public static final String GET_PRIVILEGE = "Get Metadata Export Packages"; + + public static final String MANAGE_PRIVILEGE = "Manage Metadata Export Packages"; + + private MetadataExportRestConstants() { + } +} diff --git a/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportBuildDto.java b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportBuildDto.java new file mode 100644 index 0000000..6918b13 --- /dev/null +++ b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportBuildDto.java @@ -0,0 +1,76 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.web.controller.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.openmrs.module.metadataexport.api.model.ExportBuild; +import org.openmrs.module.metadataexport.api.model.ExportStatus; +import org.openmrs.module.metadataexport.web.controller.MetadataExportRestConstants; + +import java.util.Date; + +@Slf4j +@Getter +@Setter +public class ExportBuildDto { + + private String uuid; + + private String packageUuid; + + private Integer version; + + private String status; + + private Date dateCreated; + + private Date dateStarted; + + private Date dateCompleted; + + private String errorMessage; + + private String downloadUrl; + + private JsonNode manifest; + + public static ExportBuildDto from(ExportBuild build) { + ExportBuildDto dto = new ExportBuildDto(); + dto.setUuid(build.getUuid()); + dto.setPackageUuid(build.getExportPackage().getUuid()); + dto.setVersion(build.getVersion()); + dto.setStatus(build.getExportStatus().name()); + dto.setDateCreated(build.getDateCreated()); + dto.setDateStarted(build.getDateStarted()); + dto.setDateCompleted(build.getDateCompleted()); + dto.setErrorMessage(build.getErrorMessage()); + if (build.getExportStatus() == ExportStatus.COMPLETED) { + dto.setDownloadUrl("/ws" + MetadataExportRestConstants.BASE + "/builds/" + build.getUuid() + "/download"); + } + return dto; + } + + public static ExportBuildDto detailFrom(ExportBuild build) { + ExportBuildDto dto = from(build); + if (build.getManifestJson() != null) { + try { + dto.setManifest(new ObjectMapper().readTree(build.getManifestJson())); + } + catch (Exception e) { + log.warn("Metadata Export: could not parse manifest of build {}", build.getUuid(), e); + } + } + return dto; + } +} diff --git a/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportPackageDto.java b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportPackageDto.java new file mode 100644 index 0000000..46e7478 --- /dev/null +++ b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportPackageDto.java @@ -0,0 +1,60 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.web.controller.dto; + +import lombok.Getter; +import lombok.Setter; +import org.openmrs.module.metadataexport.api.model.ExportBuild; +import org.openmrs.module.metadataexport.api.model.ExportPackage; +import org.openmrs.module.metadataexport.api.model.ExportPackageEntry; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +@Getter +@Setter +public class ExportPackageDto { + + private String uuid; + + private String name; + + private String description; + + private Boolean retired; + + private Date dateCreated; + + private List entries = new ArrayList<>(); + + private ExportBuildDto latestBuild; + + public static ExportPackageDto from(ExportPackage exportPackage) { + ExportPackageDto dto = new ExportPackageDto(); + dto.setUuid(exportPackage.getUuid()); + dto.setName(exportPackage.getName()); + dto.setDescription(exportPackage.getDescription()); + dto.setRetired(exportPackage.getRetired()); + dto.setDateCreated(exportPackage.getDateCreated()); + for (ExportPackageEntry entry : exportPackage.getEntries()) { + dto.getEntries().add(ExportPackageEntryDto.from(entry)); + } + return dto; + } + + public static ExportPackageDto from(ExportPackage exportPackage, ExportBuild latestBuild) { + ExportPackageDto dto = from(exportPackage); + if (latestBuild != null) { + dto.setLatestBuild(ExportBuildDto.from(latestBuild)); + } + return dto; + } +} diff --git a/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportPackageEntryDto.java b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportPackageEntryDto.java new file mode 100644 index 0000000..3954bfa --- /dev/null +++ b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportPackageEntryDto.java @@ -0,0 +1,33 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.web.controller.dto; + +import lombok.Getter; +import lombok.Setter; +import org.openmrs.module.metadataexport.api.model.ExportPackageEntry; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +public class ExportPackageEntryDto { + + private String domain; + + private List itemUuids = new ArrayList<>(); + + public static ExportPackageEntryDto from(ExportPackageEntry entry) { + ExportPackageEntryDto dto = new ExportPackageEntryDto(); + dto.setDomain(entry.getDomain()); + dto.setItemUuids(new ArrayList<>(entry.getItemUuids())); + return dto; + } +} diff --git a/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportPackageRequest.java b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportPackageRequest.java new file mode 100644 index 0000000..1509586 --- /dev/null +++ b/omod/src/main/java/org/openmrs/module/metadataexport/web/controller/dto/ExportPackageRequest.java @@ -0,0 +1,27 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.web.controller.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +public class ExportPackageRequest { + + private String name; + + private String description; + + private List entries = new ArrayList<>(); +} diff --git a/omod/src/main/resources/config.xml b/omod/src/main/resources/config.xml index f389e59..e31780a 100644 --- a/omod/src/main/resources/config.xml +++ b/omod/src/main/resources/config.xml @@ -24,7 +24,6 @@ org.openmrs.module.metadataexport.MetadataExportActivator - @@ -34,11 +33,6 @@ org.openmrs.module.initializer - - - org.openmrs.module.metadataexport.extension.html.AdminList - - org.openmrs.module.legacyui org.openmrs.module.emrapi @@ -55,12 +49,14 @@ /AOP --> - + http://www.springframework.org/schema/context/spring-context-3.0.xsd"> + + + + + + + + + diff --git a/omod/src/test/java/org/openmrs/module/metadataexport/web/controller/RealDispatcherErrorHandlingTest.java b/omod/src/test/java/org/openmrs/module/metadataexport/web/controller/RealDispatcherErrorHandlingTest.java new file mode 100644 index 0000000..c774d54 --- /dev/null +++ b/omod/src/test/java/org/openmrs/module/metadataexport/web/controller/RealDispatcherErrorHandlingTest.java @@ -0,0 +1,98 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.web.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openmrs.api.context.Context; +import org.openmrs.module.initializer.Domain; +import org.openmrs.module.metadataexport.api.MetadataExportService; +import org.openmrs.module.metadataexport.api.model.ExportPackage; +import org.openmrs.module.metadataexport.api.model.ExportPackageEntry; +import org.openmrs.web.test.jupiter.BaseModuleWebContextSensitiveTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** + * Drives the real dispatcher chain (openmrs-servlet.xml + this module's web context) instead of a + * standalone setup, so these assertions cover the resolver production actually uses. Without the + * ExceptionHandlerExceptionResolver registered in webModuleApplicationContext.xml, the advice never + * runs here: a logged-out request comes back 200 with a stack-trace body. + */ +class RealDispatcherErrorHandlingTest extends BaseModuleWebContextSensitiveTest { + + private static final String PACKAGES = MetadataExportRestConstants.BASE + "/packages"; + + @Autowired + private WebApplicationContext webApplicationContext; + + private MockMvc mockMvc; + + @BeforeEach + void setUpMockMvc() { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + } + + @Test + void unauthenticatedRequestReturns401() throws Exception { + Context.logout(); + try { + assertEquals(401, mockMvc.perform(get(PACKAGES)).andReturn().getResponse().getStatus()); + } + finally { + authenticate(); + } + } + + @Test + void validationFailureReturns400WithFieldErrors() throws Exception { + ExportPackage existing = new ExportPackage(); + existing.setName("Dup"); + existing.setDescription("test"); + ExportPackageEntry entry = new ExportPackageEntry(); + entry.setDomain(Domain.LOCATIONS.name()); + existing.getEntries().add(entry); + Context.getService(MetadataExportService.class).saveExportPackage(existing); + + MockHttpServletResponse response = mockMvc.perform(post(PACKAGES).contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Dup\",\"entries\":[{\"domain\":\"locations\"}]}")).andReturn().getResponse(); + + assertEquals(400, response.getStatus()); + JsonNode body = new ObjectMapper().readTree(response.getContentAsString()); + assertNotNull(body.get("fieldErrors").get("name")); + } + + @Test + void missingEntriesReturns400() throws Exception { + MockHttpServletResponse response = mockMvc + .perform(post(PACKAGES).contentType(MediaType.APPLICATION_JSON).content("{\"name\":\"Partial\"}")) + .andReturn().getResponse(); + + assertEquals(400, response.getStatus()); + } + + @Test + void malformedJsonReturns400() throws Exception { + assertEquals(400, + mockMvc.perform(post(PACKAGES).contentType(MediaType.APPLICATION_JSON).content("{ this is not json")).andReturn() + .getResponse().getStatus()); + } +} From e8ae7496e0e781ff411d023995eae0e228c5e363 Mon Sep 17 00:00:00 2001 From: Wikum Weerakutti Date: Wed, 5 Aug 2026 19:58:27 +0530 Subject: [PATCH 8/8] Make itemUuids not nullable --- .../api/model/ExportPackageEntry.java | 2 +- .../api/validator/ExportPackageValidator.java | 7 +++++++ api/src/main/resources/messages.properties | 1 + api/src/main/resources/messages_es.properties | 1 + api/src/main/resources/messages_fr.properties | 1 + .../api/MetadataExportServiceTest.java | 14 ++++++++++++++ 6 files changed, 25 insertions(+), 1 deletion(-) diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportPackageEntry.java b/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportPackageEntry.java index e3025c6..59d2c9c 100644 --- a/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportPackageEntry.java +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/model/ExportPackageEntry.java @@ -49,7 +49,7 @@ public class ExportPackageEntry extends BaseOpenmrsObject { @ElementCollection @CollectionTable(name = "metadataexport_package_entry_item", joinColumns = @JoinColumn(name = "entry_id"), uniqueConstraints = @UniqueConstraint(columnNames = { "entry_id", "item_uuid" })) - @Column(name = "item_uuid") + @Column(name = "item_uuid", length = 38, nullable = false) private List itemUuids = new ArrayList<>(); // Empty = whole domain public Domain getDomainEnum() { diff --git a/api/src/main/java/org/openmrs/module/metadataexport/api/validator/ExportPackageValidator.java b/api/src/main/java/org/openmrs/module/metadataexport/api/validator/ExportPackageValidator.java index 98d904e..2cfe04e 100644 --- a/api/src/main/java/org/openmrs/module/metadataexport/api/validator/ExportPackageValidator.java +++ b/api/src/main/java/org/openmrs/module/metadataexport/api/validator/ExportPackageValidator.java @@ -63,6 +63,13 @@ public void validate(Object target, Errors errors) { errors.rejectValue("entries[" + i + "].domain", "metadataexport.package.entry.domain.unknown", "Unknown domain '" + entry.getDomain() + "'"); } + for (String itemUuid : entry.getItemUuids()) { + if (StringUtils.isBlank(itemUuid) || itemUuid.length() > 38) { + errors.rejectValue("entries[" + i + "].itemUuids", "metadataexport.package.entry.itemUuid.invalid", + "Item uuids must be non-blank and at most 38 characters"); + break; + } + } } } } diff --git a/api/src/main/resources/messages.properties b/api/src/main/resources/messages.properties index 1dea642..ed2ec6b 100644 --- a/api/src/main/resources/messages.properties +++ b/api/src/main/resources/messages.properties @@ -14,3 +14,4 @@ metadataexport.package.name.required=An export package requires a name metadataexport.package.name.duplicate=An export package with this name already exists metadataexport.package.entry.domain.unknown=Unknown domain metadataexport.package.entry.domain.unsupported=No exporter supports this domain +metadataexport.package.entry.itemUuid.invalid=Item uuids must be non-blank and at most 38 characters diff --git a/api/src/main/resources/messages_es.properties b/api/src/main/resources/messages_es.properties index 1dea642..ed2ec6b 100644 --- a/api/src/main/resources/messages_es.properties +++ b/api/src/main/resources/messages_es.properties @@ -14,3 +14,4 @@ metadataexport.package.name.required=An export package requires a name metadataexport.package.name.duplicate=An export package with this name already exists metadataexport.package.entry.domain.unknown=Unknown domain metadataexport.package.entry.domain.unsupported=No exporter supports this domain +metadataexport.package.entry.itemUuid.invalid=Item uuids must be non-blank and at most 38 characters diff --git a/api/src/main/resources/messages_fr.properties b/api/src/main/resources/messages_fr.properties index 1dea642..ed2ec6b 100644 --- a/api/src/main/resources/messages_fr.properties +++ b/api/src/main/resources/messages_fr.properties @@ -14,3 +14,4 @@ metadataexport.package.name.required=An export package requires a name metadataexport.package.name.duplicate=An export package with this name already exists metadataexport.package.entry.domain.unknown=Unknown domain metadataexport.package.entry.domain.unsupported=No exporter supports this domain +metadataexport.package.entry.itemUuid.invalid=Item uuids must be non-blank and at most 38 characters diff --git a/api/src/test/java/org/openmrs/module/metadataexport/api/MetadataExportServiceTest.java b/api/src/test/java/org/openmrs/module/metadataexport/api/MetadataExportServiceTest.java index 72118e6..c564f65 100644 --- a/api/src/test/java/org/openmrs/module/metadataexport/api/MetadataExportServiceTest.java +++ b/api/src/test/java/org/openmrs/module/metadataexport/api/MetadataExportServiceTest.java @@ -75,6 +75,20 @@ void savePackage_rejectsADomainWithoutARegisteredExporter() { assertThrows(ValidationException.class, () -> service.saveExportPackage(packageWith("Forms pkg", "HTML_FORMS"))); } + @Test + void savePackage_rejectsAnOverlongItemUuid() { + String overlong = String.join("", java.util.Collections.nCopies(61, "x")); + + assertThrows(ValidationException.class, + () -> service.saveExportPackage(packageWith("Overlong", Domain.LOCATIONS.name(), overlong))); + } + + @Test + void savePackage_rejectsANullItemUuid() { + assertThrows(ValidationException.class, + () -> service.saveExportPackage(packageWith("Null uuid", Domain.LOCATIONS.name(), (String) null))); + } + @Test void savePackage_allowsReusingTheNameOfARetiredPackage() { ExportPackage old = service.saveExportPackage(packageWith("Reused name", Domain.LOCATIONS.name()));