Merge remote-tracking branch 'remotes/origin/master' into im_20200316_lastn_operation_elasticsearch

This commit is contained in:
ianmarshall 2020-05-25 11:37:34 -04:00
commit ee87a2e2d0
15 changed files with 508 additions and 19 deletions

View File

@ -0,0 +1,5 @@
---
type: add
issue: 1637
title: "An index has been added on the TRM_CONCEPT table. This index was previously missing, meaning that rebuilding large
code systems took an abnormally long amount of time. Thanks to Jacob Stampe Mikkelsen for the pull request!"

View File

@ -0,0 +1,6 @@
---
type: add
issue: 1826
title: "A new servce has been added to the JPA server that fetches FHIR Implementation Guide NPM
packages and installs the contained conformance resources into the JPA repository. Thanks to
Martin Zacho Grønhøj for the pull request!"

View File

@ -12,7 +12,7 @@ On servers where a large amount of data will be ingested, the following consider
# Disabling :text Indexing
On servers storing large numbers of Codings and CodeableConcepts (as well as any other token SearchParameter target where the `:text` modifier is supported), the indexes required to support the `:text` modifier can consume a large amount of index space, and cause a masurable impact on write times.
On servers storing large numbers of Codings and CodeableConcepts (as well as any other token SearchParameter target where the `:text` modifier is supported), the indexes required to support the `:text` modifier can consume a large amount of index space, and cause a measurable impact on write times.
This modifier can be disabled globally by using the ModelConfig#setSuppressStringIndexingInTokens setting.

View File

@ -21,6 +21,7 @@ import ca.uhn.fhir.jpa.entity.Search;
import ca.uhn.fhir.jpa.graphql.JpaStorageServices;
import ca.uhn.fhir.jpa.interceptor.JpaConsentContextServices;
import ca.uhn.fhir.jpa.model.sched.ISchedulerService;
import ca.uhn.fhir.jpa.packages.IgInstallerSvc;
import ca.uhn.fhir.jpa.partition.IPartitionLookupSvc;
import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc;
import ca.uhn.fhir.jpa.partition.PartitionLookupSvcImpl;
@ -250,6 +251,9 @@ public abstract class BaseConfig {
return myDaoRegistry.getResourceDaoOrNull(theResourceType) != null;
}
@Bean
public IgInstallerSvc igInstallerSvc() { return new IgInstallerSvc(); }
@Bean
public IConsentContextServices consentContextServices() {
return new JpaConsentContextServices();

View File

@ -37,6 +37,7 @@ import static org.apache.commons.lang3.StringUtils.length;
@Entity
@Table(name = "TRM_CONCEPT_PROPERTY", uniqueConstraints = {
}, indexes = {
@Index(name = "IDX_CONCEPTPROP_CONCEPTPID", columnList = "CONCEPT_PID")
})
public class TermConceptProperty implements Serializable {
private static final long serialVersionUID = 1L;

View File

@ -0,0 +1,346 @@
package ca.uhn.fhir.jpa.packages;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.support.IValidationSupport;
import ca.uhn.fhir.context.support.ValidationSupportContext;
import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
import ca.uhn.fhir.rest.api.server.IBundleProvider;
import ca.uhn.fhir.rest.param.StringParam;
import ca.uhn.fhir.rest.param.TokenParam;
import ca.uhn.fhir.rest.param.UriParam;
import ca.uhn.fhir.util.FhirTerser;
import com.google.gson.Gson;
import org.hl7.fhir.instance.model.api.IBase;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IPrimitiveType;
import org.hl7.fhir.utilities.cache.NpmPackage;
import org.hl7.fhir.utilities.cache.PackageCacheManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class IgInstallerSvc {
private static final Logger ourLog = LoggerFactory.getLogger(IgInstallerSvc.class);
boolean enabled = true;
@Autowired
private FhirContext fhirContext;
@Autowired
private DaoRegistry daoRegistry;
@Autowired
private IValidationSupport validationSupport;
private PackageCacheManager packageCacheManager;
private String[] SUPPORTED_RESOURCE_TYPES = new String[]
{ "NamingSystem",
"CodeSystem",
"ValueSet",
"StructureDefinition",
"ConceptMap",
"SearchParameter",
"Subscription" };
@PostConstruct
public void initialize() {
switch (fhirContext.getVersion().getVersion()) {
case R5:
case R4:
case DSTU3:
break;
case DSTU2:
case DSTU2_HL7ORG:
case DSTU2_1:
default: {
ourLog.info("IG installation not supported for version: {}", fhirContext.getVersion().getVersion());
enabled = false;
}
}
try {
packageCacheManager = new PackageCacheManager(true, 1);
} catch (IOException e) {
ourLog.error("Unable to initialize PackageCacheManager: {}", e);
enabled = false;
}
}
/**
* Loads and installs an IG tarball (with its dependencies) from the specified url.
*
* Installs the IG by persisting instances of the following types of resources:
*
* - NamingSystem, CodeSystem, ValueSet, StructureDefinition (with snapshots),
* ConceptMap, SearchParameter, Subscription
*
* Creates the resources if non-existent, updates them otherwise.
*
* @param url of IG tarball
* @throws ImplementationGuideInstallationException if installation fails
*/
public void install(String url) throws ImplementationGuideInstallationException {
if (enabled) {
try {
install(NpmPackage.fromPackage(toInputStream(url)));
} catch (IOException e) {
ourLog.error("Could not load implementation guide from URL {}", url, e);
}
}
}
private InputStream toInputStream(String url) throws IOException {
URL u = new URL(url);
URLConnection c = u.openConnection();
return c.getInputStream();
}
/**
* Loads and installs an IG from a file on disk or the Simplifier repo using
* the {@link PackageCacheManager}.
*
* Installs the IG by persisting instances of the following types of resources:
*
* - NamingSystem, CodeSystem, ValueSet, StructureDefinition (with snapshots),
* ConceptMap, SearchParameter, Subscription
*
* Creates the resources if non-existent, updates them otherwise.
*
* @param id of the package, or name of folder in filesystem
* @param version of package, or path to folder in filesystem
* @throws ImplementationGuideInstallationException if installation fails
*/
public void install(String id, String version) throws ImplementationGuideInstallationException {
if (enabled) {
try {
install(packageCacheManager.loadPackage(id, version));
} catch (IOException e) {
ourLog.error("Could not load implementation guide from packages.fhir.org or " +
"file on disk using ID {} and version {}", id, version, e);
}
}
}
/**
* Installs a package and its dependencies.
*
* Fails fast if one of its dependencies could not be installed.
*
* @throws ImplementationGuideInstallationException if installation fails
*/
private void install(NpmPackage npmPackage) throws ImplementationGuideInstallationException {
String name = npmPackage.getNpm().get("name").getAsString();
String version = npmPackage.getNpm().get("version").getAsString();
String fhirVersion = npmPackage.fhirVersion();
String currentFhirVersion = fhirContext.getVersion().getVersion().getFhirVersionString();
assertFhirVersionsAreCompatible(fhirVersion, currentFhirVersion);
fetchAndInstallDependencies(npmPackage);
ourLog.info("Installing package: {}#{}", name, version);
int[] count = new int[SUPPORTED_RESOURCE_TYPES.length];
for (int i = 0; i < SUPPORTED_RESOURCE_TYPES.length; i++) {
Collection<IBaseResource> resources = parseResourcesOfType(SUPPORTED_RESOURCE_TYPES[i], npmPackage);
count[i] = resources.size();
try {
resources.stream()
.map(r -> isStructureDefinitionWithoutSnapshot(r) ? generateSnapshot(r) : r)
.forEach(r -> createOrUpdate(r));
} catch (Exception e) {
throw new ImplementationGuideInstallationException(String.format(
"Error installing IG %s#%s: ", name, version), e);
}
}
ourLog.info(String.format("Finished installation of package %s#%s:", name, version));
for (int i = 0; i < count.length; i++) {
ourLog.info(String.format("-- Created or updated %s resources of type %s", count[i], SUPPORTED_RESOURCE_TYPES[i]));
}
}
private void fetchAndInstallDependencies(NpmPackage npmPackage) throws ImplementationGuideInstallationException {
if (npmPackage.getNpm().has("dependencies")) {
Map<String, String> dependencies = new Gson().fromJson(npmPackage.getNpm().get("dependencies"), HashMap.class);
for (Map.Entry<String, String> d : dependencies.entrySet()) {
String id = d.getKey();
String ver = d.getValue();
if (id.startsWith("hl7.fhir")) {
continue; // todo : which packages to ignore?
}
if (packageCacheManager == null) {
throw new ImplementationGuideInstallationException(String.format(
"Cannot install dependency %s#%s due to PacketCacheManager initialization error", id, ver));
}
try {
// resolve in local cache or on packages.fhir.org
NpmPackage dependency = packageCacheManager.loadPackage(id, ver);
// recursive call to install dependencies of a package before
// installing the package
fetchAndInstallDependencies(dependency);
install(dependency);
} catch (IOException e) {
throw new ImplementationGuideInstallationException(String.format(
"Cannot resolve dependency %s#%s", id, ver), e);
}
}
}
}
/**
* Asserts if package FHIR version is compatible with current FHIR version
* by using semantic versioning rules.
*/
private void assertFhirVersionsAreCompatible(String fhirVersion, String currentFhirVersion)
throws ImplementationGuideInstallationException {
boolean compatible = fhirVersion.charAt(0) == currentFhirVersion.charAt(0) &&
currentFhirVersion.compareTo(fhirVersion) >= 0;
if (!compatible) {
throw new ImplementationGuideInstallationException(String.format(
"Cannot install implementation guide: FHIR versions mismatch (expected <=%s, package uses %s)",
currentFhirVersion, fhirVersion));
}
}
/**
* ============================= Utility methods ===============================
*/
private Collection<IBaseResource> parseResourcesOfType(String type, NpmPackage pkg) {
if (!pkg.getFolders().containsKey("package")) {
return Collections.EMPTY_LIST;
}
ArrayList<IBaseResource> resources = new ArrayList<>();
for (String file : pkg.getFolders().get("package").listFiles()) {
if (file.contains(type)) {
try {
byte[] content = pkg.getFolders().get("package").fetchFile(file);
resources.add(fhirContext.newJsonParser().parseResource(new String(content)));
} catch (IOException e) {
ourLog.error("Cannot install resource of type {}: Could not fetch file {}", type, file);
}
}
}
return resources;
}
/**
* Create a resource or update it, if its already existing.
*/
private void createOrUpdate(IBaseResource resource) {
IFhirResourceDao dao = daoRegistry.getResourceDao(resource.getClass());
IBundleProvider searchResult = dao.search(createSearchParameterMapFor(resource));
if (searchResult.isEmpty()) {
dao.create(resource);
} else {
IBaseResource existingResource = verifySearchResultFor(resource, searchResult);
if (existingResource != null) {
resource.setId(existingResource.getIdElement().getValue());
dao.update(resource);
}
}
}
private boolean isStructureDefinitionWithoutSnapshot(IBaseResource r) {
FhirTerser terser = fhirContext.newTerser();
return r.getClass().getSimpleName().equals("StructureDefinition") &&
terser.getSingleValueOrNull(r, "snapshot") == null;
}
private IBaseResource generateSnapshot(IBaseResource sd) {
try {
return validationSupport.generateSnapshot(new ValidationSupportContext(validationSupport), sd, null, null, null);
} catch (Exception e) {
throw new ImplementationGuideInstallationException(String.format(
"Failure when generating snapshot of StructureDefinition: %s", sd.getIdElement()), e);
}
}
private SearchParameterMap createSearchParameterMapFor(IBaseResource resource) {
FhirTerser terser = fhirContext.newTerser();
if (resource.getClass().getSimpleName().equals("NamingSystem")) {
String uniqueId = extractUniqeIdFromNamingSystem(resource);
return new SearchParameterMap().add("value", new StringParam(uniqueId).setExact(true));
} else if (resource.getClass().getSimpleName().equals("Subscription")) {
String id = extractIdFromSubscription(resource);
return new SearchParameterMap().add("_id", new TokenParam(id));
} else {
String url = extractUniqueUrlFromMetadataResouce(resource);
return new SearchParameterMap().add("url", new UriParam(url));
}
}
private IBaseResource verifySearchResultFor(IBaseResource resource, IBundleProvider searchResult) {
FhirTerser terser = fhirContext.newTerser();
if (resource.getClass().getSimpleName().equals("NamingSystem")) {
if (searchResult.size() > 1) {
ourLog.warn("Expected 1 NamingSystem with unique ID {}, found {}. Will not attempt to update resource.",
extractUniqeIdFromNamingSystem(resource), searchResult.size());
return null;
}
return getFirstResourceFrom(searchResult);
} else if (resource.getClass().getSimpleName().equals("Subscription")) {
if (searchResult.size() > 1) {
ourLog.warn("Expected 1 Subscription with ID {}, found {}. Will not attempt to update resource.",
extractIdFromSubscription(resource), searchResult.size());
return null;
}
return getFirstResourceFrom(searchResult);
} else {
// Resource is of type CodeSystem, ValueSet, StructureDefinition, ConceptMap or SearchParameter
if (searchResult.size() > 1) {
ourLog.warn("Expected 1 MetadataResource with globally unique URL {}, found {}. " +
"Will not attempt to update resource.", extractUniqueUrlFromMetadataResouce(resource), searchResult.size());
return null;
}
return getFirstResourceFrom(searchResult);
}
}
private static IBaseResource getFirstResourceFrom(IBundleProvider searchResult) {
try {
return searchResult.getResources(0, 0).get(0);
} catch (IndexOutOfBoundsException e) {
ourLog.warn("Error when extracting resource from search result " +
"(search result should have been non-empty))", e);
return null;
}
}
private String extractUniqeIdFromNamingSystem(IBaseResource resource) {
FhirTerser terser = fhirContext.newTerser();
IBase uniqueIdComponent = (IBase) terser.getSingleValueOrNull(resource, "uniqueId");
if (uniqueIdComponent == null) {
throw new ImplementationGuideInstallationException("NamingSystem does not have uniqueId component.");
}
IPrimitiveType asPrimitiveType = (IPrimitiveType) terser.getSingleValueOrNull(uniqueIdComponent, "value");
return (String) asPrimitiveType.getValue();
}
private String extractIdFromSubscription(IBaseResource resource) {
FhirTerser terser = fhirContext.newTerser();
IPrimitiveType asPrimitiveType = (IPrimitiveType) terser.getSingleValueOrNull(resource, "id");
return (String) asPrimitiveType.getValue();
}
private String extractUniqueUrlFromMetadataResouce(IBaseResource resource) {
FhirTerser terser = fhirContext.newTerser();
IPrimitiveType asPrimitiveType = (IPrimitiveType) terser.getSingleValueOrNull(resource, "url");
return (String) asPrimitiveType.getValue();
}
}

View File

@ -0,0 +1,15 @@
package ca.uhn.fhir.jpa.packages;
/**
* Used internally to indicate a failure to install the implementation guide
*/
public class ImplementationGuideInstallationException extends RuntimeException {
public ImplementationGuideInstallationException(String message) {
super(message);
}
public ImplementationGuideInstallationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,53 @@
package ca.uhn.fhir.jpa.packages;
import ca.uhn.fhir.jpa.api.config.DaoConfig;
import ca.uhn.fhir.jpa.dao.dstu3.BaseJpaDstu3Test;
import org.hl7.fhir.utilities.cache.PackageCacheManager;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.springframework.beans.factory.annotation.Autowired;
import java.io.IOException;
import java.io.InputStream;
public class IgInstallerTestDstu3 extends BaseJpaDstu3Test {
@Autowired
private DaoConfig daoConfig;
@Autowired
private IgInstallerSvc igInstaller;
@Rule
public final ExpectedException expectedException = ExpectedException.none();
@Before
public void before() throws IOException {
PackageCacheManager packageCacheManager = new PackageCacheManager(true, 1);
InputStream stream;
stream = IgInstallerTestDstu3.class.getResourceAsStream("erroneous-ig.tar.gz");
packageCacheManager.addPackageToCache("erroneous-ig", "1.0.0", stream, "erroneous-ig");
stream = IgInstallerTestDstu3.class.getResourceAsStream("NHSD.Assets.STU3.tar.gz");
packageCacheManager.addPackageToCache("NHSD.Assets.STU3", "1.0.0", stream, "NHSD.Assets.STU3");
stream = IgInstallerTestDstu3.class.getResourceAsStream("basisprofil.de.tar.gz");
packageCacheManager.addPackageToCache("basisprofil.de", "0.2.40", stream, "basisprofil.de");
}
@Test(expected = ImplementationGuideInstallationException.class)
public void negativeTestInstallFromCache() {
// Unknown base of StructureDefinitions
igInstaller.install("erroneous-ig", "1.0.0");
}
@Test
public void installFromCache() {
daoConfig.setAllowExternalReferences(true);
igInstaller.install("NHSD.Assets.STU3", "1.2.0");
}
@Test
public void installFromCache2() {
igInstaller.install("basisprofil.de", "0.2.40");
}
}

View File

@ -0,0 +1,35 @@
package ca.uhn.fhir.jpa.packages;
import ca.uhn.fhir.jpa.api.config.DaoConfig;
import ca.uhn.fhir.jpa.dao.r4.BaseJpaR4Test;
import org.hl7.fhir.utilities.cache.PackageCacheManager;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.io.IOException;
import java.io.InputStream;
import static org.junit.Assert.assertTrue;
public class IgInstallerTestR4 extends BaseJpaR4Test {
@Autowired
public DaoConfig daoConfig;
@Autowired
public IgInstallerSvc igInstaller;
@Before
public void before() throws IOException {
PackageCacheManager packageCacheManager = new PackageCacheManager(true, 1);
InputStream stream;
stream = IgInstallerTestDstu3.class.getResourceAsStream("NHSD.Assets.STU3.tar.gz");
packageCacheManager.addPackageToCache("NHSD.Assets.STU3", "1.0.0", stream, "NHSD.Assets.STU3");
}
@Test(expected = ImplementationGuideInstallationException.class)
public void negativeTestInstallFromCacheVersionMismatch() {
daoConfig.setAllowExternalReferences(true);
igInstaller.install("NHSD.Assets.STU3", "1.2.0");
}
}

View File

@ -180,6 +180,9 @@ public class HapiFhirJpaMigrationTasks extends BaseMigrationTasks<VersionEnum> {
.addCalculator("SP_VALUE_HIGH_DATE_ORDINAL", t -> ResourceIndexedSearchParamDate.calculateOrdinalValue(t.getDate("SP_VALUE_HIGH")))
.setColumnName("SP_VALUE_LOW_DATE_ORDINAL") //It doesn't matter which of the two we choose as they will both be null.
);
// TRM_CONCEPT_PROPERTY
version.onTable("TRM_CONCEPT_PROPERTY").addIndex("20200523.1", "IDX_CONCEPTPROP_CONCEPTPID").unique(false).withColumns("CONCEPT_PID");
}
/**
@ -244,7 +247,7 @@ public class HapiFhirJpaMigrationTasks extends BaseMigrationTasks<VersionEnum> {
protected void init420() { // 20191015 - 20200217
Builder version = forVersion(VersionEnum.V4_2_0);
// TermValueSetConceptDesignation
// TermValueSetConceptDesignation
version.onTable("TRM_VALUESET_C_DESIGNATION").dropIndex("20200202.1", "IDX_VALUESET_C_DSGNTN_VAL").failureAllowed();
Builder.BuilderWithTableName searchTable = version.onTable("HFJ_SEARCH");
searchTable.dropIndex("20200203.1", "IDX_SEARCH_LASTRETURNED");

View File

@ -258,7 +258,7 @@ public class GenericClientR4Test extends BaseGenericClientR4Test {
Bundle resp = client
.history()
.onType(Patient.class)
.andReturnBundle(Bundle.class)
.returnBundle(Bundle.class)
.execute();
assertEquals("foo=bar", capt.getAllValues().get(0).getFirstHeader("Cookie").getValue());
@ -449,7 +449,7 @@ public class GenericClientR4Test extends BaseGenericClientR4Test {
Bundle resp = client
.history()
.onType(CustomTypeR4Test.MyCustomPatient.class)
.andReturnBundle(Bundle.class)
.returnBundle(Bundle.class)
.execute();
assertEquals(1, resp.getEntry().size());
@ -692,7 +692,7 @@ public class GenericClientR4Test extends BaseGenericClientR4Test {
Bundle outcome = client
.history()
.onServer().andReturnBundle(Bundle.class)
.onServer().returnBundle(Bundle.class)
.at(new DateRangeParam().setLowerBound("2011").setUpperBound("2018"))
.execute();
@ -2296,7 +2296,7 @@ public class GenericClientR4Test extends BaseGenericClientR4Test {
IGenericClient client = ourCtx.newRestfulGenericClient("http://example.com/fhir");
client.fetchConformance().ofType(CapabilityStatement.class).execute();
client.capabilities().ofType(CapabilityStatement.class).execute();
assertEquals("http://example.com/fhir/metadata", capt.getAllValues().get(0).getURI().toASCIIString());
validateUserAgent(capt);
}
@ -2321,7 +2321,7 @@ public class GenericClientR4Test extends BaseGenericClientR4Test {
Bundle resp = client
.history()
.onType(Patient.class)
.andReturnBundle(Bundle.class)
.returnBundle(Bundle.class)
.execute();
}

45
pom.xml
View File

@ -97,7 +97,7 @@
<developer>
<id>jamesagnew</id>
<name>James Agnew</name>
<organization>University Health Network</organization>
<organization>Smile CDR</organization>
</developer>
<developer>
<id>grahamegrieve</id>
@ -243,6 +243,7 @@
<developer>
<id>karlmdavis</id>
<name>Karl M. Davis</name>
<organization>CMS</organization>
</developer>
<developer>
<id>matt-blanchette</id>
@ -294,7 +295,7 @@
<developer>
<id>vadi2</id>
<name>Vadim Peretokin</name>
<organization>Furore Informatica</organization>
<organization>Firely</organization>
</developer>
<developer>
<id>lawley</id>
@ -320,7 +321,7 @@
<developer>
<id>daliboz</id>
<name>Jenny Syed</name>
<organization>Cerner</organization>
<organization>Cerner Corporation</organization>
</developer>
<developer>
<id>sekaijin</id>
@ -341,6 +342,7 @@
<developer>
<id>joelsch</id>
<name>Joel Schneider</name>
<organization>National Marrow Donor Program</organization>
</developer>
<developer>
<id>dangerousben</id>
@ -353,6 +355,7 @@
<developer>
<id>ohr</id>
<name>Christian Ohr</name>
<organization>InterComponentWare AG</organization>
</developer>
<developer>
<id>eug48</id>
@ -439,10 +442,6 @@
<id>t4deon</id>
<name>Andreas Keil</name>
</developer>
<developer>
<id>dgileadi</id>
<name>David Gileadi</name>
</developer>
<developer>
<id>RuthAlk</id>
<name>Ruth Alkema</name>
@ -489,6 +488,7 @@
<developer>
<id>volsch</id>
<name>Volker Schmidt</name>
<organization>DHIS2 / University of Oslo</organization>
</developer>
<developer>
<id>magnuswatn</id>
@ -528,7 +528,7 @@
<developer>
<id>tadgh</id>
<name>Gary Graham</name>
<organization>Centre for Global eHealth Innovation</organization>
<organization>Smile CDR</organization>
</developer>
<developer>
<id>nerdydrew</id>
@ -551,10 +551,6 @@
<id>uurl</id>
<name>Raul Estrada</name>
</developer>
<developer>
<id>anthonys123</id>
<name>Anthony Sute</name>
</developer>
<developer>
<id>nickrobison-usds</id>
<name>Nick Robison</name>
@ -595,6 +591,11 @@
<organization>Trifork</organization>
<name>Tue Toft Nørgård</name>
</developer>
<developer>
<id>mzgtrifork</id>
<organization>Trifork</organization>
<name>Martin Zacho Grønhøj</name>
</developer>
<developer>
<id>augla</id>
<name>August Langhout</name>
@ -633,6 +634,26 @@
<id>ibacher</id>
<name>Ian</name>
</developer>
<developer>
<id>jasmdk</id>
<name>Jacob Stampe Mikkelsen</name>
<organization>Systematik A/S</organization>
</developer>
<developer>
<id>craigappl</id>
<name>Craig Appl</name>
<organization>ONA</organization>
</developer>
<developer>
<id>IanMMarshall</id>
<name>Ian Marshall</name>
<organization>Smile CDR</organization>
</developer>
<developer>
<id>markiantorno</id>
<name>Mark Iantorno</name>
<organization>Smile CDR</organization>
</developer>
</developers>
<licenses>