
001package org.hl7.fhir.r5.utils; 002 003/* 004 Copyright (c) 2011+, HL7, Inc. 005 All rights reserved. 006 007 Redistribution and use in source and binary forms, with or without modification, 008 are permitted provided that the following conditions are met: 009 010 * Redistributions of source code must retain the above copyright notice, this 011 list of conditions and the following disclaimer. 012 * Redistributions in binary form must reproduce the above copyright notice, 013 this list of conditions and the following disclaimer in the documentation 014 and/or other materials provided with the distribution. 015 * Neither the name of HL7 nor the names of its contributors may be used to 016 endorse or promote products derived from this software without specific 017 prior written permission. 018 019 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 020 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 021 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 022 IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 023 INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 024 NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 025 PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 026 WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 027 ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 028 POSSIBILITY OF SUCH DAMAGE. 029 030 */ 031 032 033 034import java.io.BufferedOutputStream; 035import java.io.ByteArrayOutputStream; 036import java.io.File; 037import java.io.IOException; 038import java.io.UnsupportedEncodingException; 039import java.text.SimpleDateFormat; 040import java.util.ArrayList; 041import java.util.Calendar; 042import java.util.Date; 043import java.util.GregorianCalendar; 044import java.util.HashSet; 045import java.util.List; 046import java.util.Locale; 047import java.util.Map; 048import java.util.Set; 049import java.util.TimeZone; 050import java.util.UUID; 051 052import lombok.extern.slf4j.Slf4j; 053import org.apache.commons.compress.archivers.tar.TarArchiveEntry; 054import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; 055import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; 056import org.hl7.fhir.exceptions.FHIRException; 057import org.hl7.fhir.r5.model.Coding; 058import org.hl7.fhir.r5.model.ContactDetail; 059import org.hl7.fhir.r5.model.ContactPoint; 060import org.hl7.fhir.r5.model.ContactPoint.ContactPointSystem; 061import org.hl7.fhir.r5.model.Enumeration; 062import org.hl7.fhir.r5.model.Enumerations.FHIRVersion; 063import org.hl7.fhir.r5.model.ImplementationGuide; 064import org.hl7.fhir.r5.model.ImplementationGuide.ImplementationGuideDependsOnComponent; 065import org.hl7.fhir.utilities.CommaSeparatedStringBuilder; 066import org.hl7.fhir.utilities.FileUtilities; 067import org.hl7.fhir.utilities.MarkedToMoveToAdjunctPackage; 068import org.hl7.fhir.utilities.Utilities; 069import org.hl7.fhir.utilities.filesystem.ManagedFileAccess; 070import org.hl7.fhir.utilities.json.model.JsonArray; 071import org.hl7.fhir.utilities.json.model.JsonObject; 072import org.hl7.fhir.utilities.json.model.JsonString; 073import org.hl7.fhir.utilities.json.parser.JsonParser; 074import org.hl7.fhir.utilities.npm.NpmPackageIndexBuilder; 075import org.hl7.fhir.utilities.npm.PackageGenerator.PackageType; 076 077import com.google.gson.Gson; 078import com.google.gson.GsonBuilder; 079 080import org.hl7.fhir.utilities.npm.ToolsVersion; 081 082@MarkedToMoveToAdjunctPackage 083@Slf4j 084public class NPMPackageGenerator { 085 086 public enum Category { 087 RESOURCE, EXAMPLE, OPENAPI, SCHEMATRON, RDF, OTHER, TOOL, TEMPLATE, JEKYLL, TEST, ADL, CUSTOM; 088 089 private String getDirectory() { 090 switch (this) { 091 case RESOURCE: return "package/"; 092 case EXAMPLE: return "package/example/"; 093 case OPENAPI: return "package/openapi/"; 094 case SCHEMATRON: return "package/xml/"; 095 case RDF: return "package/rdf/"; 096 case OTHER: return "package/other/"; 097 case ADL: return "package/adl/"; 098 case TEMPLATE: return "package/other/"; 099 case JEKYLL: return "package/jekyll/"; 100 case TEST: return "package/tests/"; 101 case TOOL: return "package/bin/"; 102 case CUSTOM: return "package/custom/"; 103 } 104 return "/"; 105 } 106 } 107 108 private String destFile; 109 private Set<String> created = new HashSet<String>(); 110 public Set<String> getCreated() { 111 return created; 112 } 113 114 private TarArchiveOutputStream tar; 115 private ByteArrayOutputStream OutputStream; 116 private BufferedOutputStream bufferedOutputStream; 117 private GzipCompressorOutputStream gzipOutputStream; 118 private JsonObject packageJ; 119 public JsonObject getPackageJ() { 120 return packageJ; 121 } 122 123 private JsonObject packageManifest; 124 private NpmPackageIndexBuilder indexer; 125 private String igVersion; 126 private String indexdb; 127 128 129 public NPMPackageGenerator(String pid, String destFile, String canonical, String url, PackageType kind, ImplementationGuide ig, Date date, Map<String, String> relatedIgs, boolean notForPublication) throws FHIRException, IOException { 130 super(); 131 this.destFile = destFile; 132 start(); 133 List<String> fhirVersion = new ArrayList<>(); 134 for (Enumeration<FHIRVersion> v : ig.getFhirVersion()) 135 fhirVersion.add(v.asStringValue()); 136 buildPackageJson(pid, canonical, kind, url, date, ig, fhirVersion, notForPublication, relatedIgs); 137 } 138 139 public NPMPackageGenerator(String pid, String destFile, String canonical, String url, PackageType kind, ImplementationGuide ig, Date date, Map<String, String> relatedIgs, boolean notForPublication, String fhirVersion) throws FHIRException, IOException { 140 super(); 141 this.destFile = destFile; 142 start(); 143 List<String> fhirVersions = new ArrayList<>(); 144 fhirVersions.add(fhirVersion); 145 buildPackageJson(pid, canonical, kind, url, date, ig, fhirVersions, notForPublication, relatedIgs); 146 } 147 148 public static NPMPackageGenerator subset(NPMPackageGenerator master, String destFile, String id, String name, Date date, boolean notForPublication) throws FHIRException, IOException { 149 JsonObject p = master.packageJ.deepCopy(); 150 p.remove("name"); 151 p.add("name", id); 152 p.remove("type"); 153 p.add("type", PackageType.CONFORMANCE.getCode()); 154 p.remove("title"); 155 p.add("title", name); 156 if (notForPublication) { 157 p.add("notForPublication", true); 158 } 159 160 return new NPMPackageGenerator(destFile, p, date, notForPublication); 161 } 162 163 public NPMPackageGenerator(String destFile, String canonical, String url, PackageType kind, ImplementationGuide ig, Date date, List<String> fhirVersion, Map<String, String> relatedIgs, boolean notForPublication) throws FHIRException, IOException { 164 super(); 165 this.destFile = destFile; 166 start(); 167 buildPackageJson(ig.getPackageId(), canonical, kind, url, date, ig, fhirVersion, notForPublication, relatedIgs); 168 } 169 170 public NPMPackageGenerator(String destFile, JsonObject npm) throws FHIRException, IOException { 171 super(); 172 log.info("create package file at " + destFile); 173 this.destFile = destFile; 174 start(); 175 String json =JsonParser.compose(npm, true); 176 try { 177 addFile(Category.RESOURCE, "package.json", json.getBytes("UTF-8")); 178 } catch (UnsupportedEncodingException e) { 179 } 180 packageJ = npm; 181 } 182 183 public NPMPackageGenerator(String destFile, JsonObject npm, Date date, boolean notForPublication) throws FHIRException, IOException { 184 super(); 185 String dt = new SimpleDateFormat("yyyyMMddHHmmss").format(date); 186 packageJ = npm; 187 packageManifest = new JsonObject(); 188 packageManifest.set("version", npm.asString("version")); 189 packageManifest.set("date", dt); 190 if (notForPublication) { 191 packageManifest.add("notForPublication", true); 192 } 193 npm.set("date", dt); 194 packageManifest.set("name", npm.asString("name")); 195 this.destFile = destFile; 196 start(); 197 String json = JsonParser.compose(npm, true); 198 try { 199 addFile(Category.RESOURCE, "package.json", json.getBytes("UTF-8")); 200 } catch (UnsupportedEncodingException e) { 201 } 202 } 203 204 private void buildPackageJson(String pid, String canonical, PackageType kind, String web, Date date, ImplementationGuide ig, List<String> fhirVersion, boolean notForPublication, Map<String, String> relatedIgs) throws FHIRException, IOException { 205 String dtHuman = new SimpleDateFormat("EEE, MMM d, yyyy HH:mmZ", new Locale("en", "US")).format(date); 206 String dt = new SimpleDateFormat("yyyyMMddHHmmss").format(date); 207 208 CommaSeparatedStringBuilder b = new CommaSeparatedStringBuilder(); 209 if (!ig.hasPackageId()) { 210 b.append("packageId"); 211 } 212 if (!ig.hasVersion()) { 213 b.append("version"); 214 } 215 if (!ig.hasFhirVersion()) { 216 b.append("fhirVersion"); 217 } 218 if (!ig.hasLicense()) { 219 b.append("license"); 220 } 221 for (ImplementationGuideDependsOnComponent d : ig.getDependsOn()) { 222 if (!d.hasVersion()) { 223 b.append("dependsOn.version("+d.getUri()+")"); 224 } 225 } 226 227 JsonObject npm = new JsonObject(); 228 npm.add("name", pid); 229 npm.add("version", ig.getVersion()); 230 igVersion = ig.getVersion(); 231 npm.add("tools-version", ToolsVersion.TOOLS_VERSION); 232 npm.add("type", kind.getCode()); 233 npm.add("date", dt); 234 if (ig.hasLicense()) { 235 npm.add("license", ig.getLicense().toCode()); 236 } 237 npm.add("canonical", canonical); 238 if (notForPublication) { 239 npm.add("notForPublication", true); 240 } 241 npm.add("url", web); 242 if (ig.hasTitle()) { 243 npm.add("title", ig.getTitle()); 244 } 245 if (ig.hasDescription()) { 246 npm.add("description", ig.getDescription()+ " (built "+dtHuman+timezone()+")"); 247 } 248 JsonArray vl = new JsonArray(); 249 250 npm.add("fhirVersions", vl); 251 for (String v : fhirVersion) { 252 vl.add(new JsonString(v)); 253 } 254 255 if (kind != PackageType.CORE) { 256 JsonObject dep = new JsonObject(); 257 npm.add("dependencies", dep); 258 for (String v : fhirVersion) { 259 String vp = packageForVersion(v); 260 if (vp != null ) { 261 dep.add(vp, v); 262 } 263 } 264 for (ImplementationGuideDependsOnComponent d : ig.getDependsOn()) { 265 if (d.getPackageIdElement().hasUserData(UserDataNames.IG_DEP_ALIASED)) { 266 dep.add(d.getId()+"@npm:"+d.getPackageId(), d.getVersion()); 267 } else { 268 dep.add(d.getPackageId(), d.getVersion()); 269 } 270 } 271 } 272 if (ig.hasPublisher()) { 273 npm.add("author", ig.getPublisher()); 274 } 275 JsonArray m = new JsonArray(); 276 for (ContactDetail t : ig.getContact()) { 277 String email = email(t.getTelecom()); 278 String url = url(t.getTelecom()); 279 if (t.hasName() & (email != null || url != null)) { 280 JsonObject md = new JsonObject(); 281 m.add(md); 282 md.add("name", t.getName()); 283 if (email != null) 284 md.add("email", email); 285 if (url != null) 286 md.add("url", url); 287 } 288 } 289 if (m.size() > 0) 290 npm.add("maintainers", m); 291 if (ig.getManifest().hasRendering()) 292 npm.add("homepage", ig.getManifest().getRendering()); 293 JsonObject dir = new JsonObject(); 294 npm.add("directories", dir); 295 dir.add("lib", "package"); 296 dir.add("example", "example"); 297 if (ig.hasJurisdiction() && ig.getJurisdiction().size() == 1 && ig.getJurisdictionFirstRep().getCoding().size() == 1) { 298 Coding c = ig.getJurisdictionFirstRep().getCodingFirstRep(); 299 npm.add("jurisdiction", c.getSystem()+"#"+c.getCode()); 300 } 301 if (relatedIgs != null) { 302 JsonObject pd = npm.forceObject("peerDependencies"); 303 for (String n : relatedIgs.keySet()) { 304 pd.add(n, relatedIgs.get(n)); 305 } 306 } 307 String json = JsonParser.compose(npm, true); 308 try { 309 addFile(Category.RESOURCE, "package.json", json.getBytes("UTF-8")); 310 } catch (UnsupportedEncodingException e) { 311 } 312 packageJ = npm; 313 314 packageManifest = new JsonObject(); 315 packageManifest.add("version", ig.getVersion()); 316 JsonArray fv = new JsonArray(); 317 for (String v : fhirVersion) { 318 fv.add(v); 319 } 320 packageManifest.add("fhirVersion", fv); 321 packageManifest.add("date", dt); 322 packageManifest.add("name", ig.getPackageId()); 323 if (ig.hasJurisdiction() && ig.getJurisdiction().size() == 1 && ig.getJurisdictionFirstRep().getCoding().size() == 1) { 324 Coding c = ig.getJurisdictionFirstRep().getCodingFirstRep(); 325 packageManifest.add("jurisdiction", c.getSystem()+"#"+c.getCode()); 326 } 327 } 328 329 330 private String packageForVersion(String v) { 331 if (v == null) 332 return null; 333 if (v.startsWith("1.0")) 334 return "hl7.fhir.r2.core"; 335 if (v.startsWith("1.4")) 336 return "hl7.fhir.r2b.core"; 337 if (v.startsWith("3.0")) 338 return "hl7.fhir.r3.core"; 339 if (v.startsWith("4.0")) 340 return "hl7.fhir.r4.core"; 341 if (v.startsWith("4.1") || v.startsWith("4.3")) 342 return "hl7.fhir.r4b.core"; 343 return null; 344 } 345 346 private String timezone() { 347 TimeZone tz = TimeZone.getDefault(); 348 Calendar cal = GregorianCalendar.getInstance(tz); 349 int offsetInMillis = tz.getOffset(cal.getTimeInMillis()); 350 351 String offset = String.format("%02d:%02d", Math.abs(offsetInMillis / 3600000), Math.abs((offsetInMillis / 60000) % 60)); 352 offset = (offsetInMillis >= 0 ? "+" : "-") + offset; 353 354 return offset; 355 } 356 357 358 private String url(List<ContactPoint> telecom) { 359 for (ContactPoint cp : telecom) { 360 if (cp.getSystem() == ContactPointSystem.URL) 361 return cp.getValue(); 362 } 363 return null; 364 } 365 366 367 private String email(List<ContactPoint> telecom) { 368 for (ContactPoint cp : telecom) { 369 if (cp.getSystem() == ContactPointSystem.EMAIL) 370 return cp.getValue(); 371 } 372 return null; 373 } 374 375 private void start() throws IOException { 376 OutputStream = new ByteArrayOutputStream(); 377 bufferedOutputStream = new BufferedOutputStream(OutputStream); 378 gzipOutputStream = new GzipCompressorOutputStream(bufferedOutputStream); 379 tar = new TarArchiveOutputStream(gzipOutputStream); 380 indexdb = Utilities.path("[tmp]", "tmp-"+UUID.randomUUID().toString()+".db"); 381 indexer = new NpmPackageIndexBuilder(); 382 indexer.start(indexdb); 383 } 384 385 386 public boolean hasFile(Category cat, String name) throws IOException { 387 String path = cat.getDirectory()+name; 388 if (path.length() > 100) { 389 name = name.substring(0, name.indexOf("-"))+"-"+UUID.randomUUID().toString()+".json"; 390 path = cat.getDirectory()+name; 391 } 392 393 return created.contains(path); 394 } 395 396 public void addFile(Category cat, String name, byte[] content) throws IOException { 397 String path = cat.getDirectory()+name; 398 if (path.length() > 100) { 399 name = name.substring(0, name.indexOf("-"))+"-"+UUID.randomUUID().toString()+".json"; 400 path = cat.getDirectory()+name; 401 } 402 403 if (created.contains(path)) { 404 log.warn("Duplicate package file "+path); 405 } else { 406 created.add(path); 407 TarArchiveEntry entry = new TarArchiveEntry(path); 408 entry.setSize(content.length); 409 tar.putArchiveEntry(entry); 410 tar.write(content); 411 tar.closeArchiveEntry(); 412 if(cat == Category.RESOURCE) { 413 indexer.seeFile(name, content); 414 } 415 } 416 } 417 418 public void addFile(String folder, String name, byte[] content) throws IOException { 419 if (!folder.equals("package")) { 420 folder = "package/"+folder; 421 } 422 String path = folder+"/"+name; 423 if (path.length() > 100) { 424 name = name.substring(0, name.indexOf("-"))+"-"+UUID.randomUUID().toString()+".json"; 425 path = folder+"/"+name; 426 } 427 428 if (created.contains(path)) { 429 log.warn("Duplicate package file "+path); 430 } else { 431 created.add(path); 432 TarArchiveEntry entry = new TarArchiveEntry(path); 433 entry.setSize(content.length); 434 tar.putArchiveEntry(entry); 435 tar.write(content); 436 tar.closeArchiveEntry(); 437 if(folder == "package") { 438 indexer.seeFile(name, content); 439 } 440 } 441 } 442 443 public void finish() throws IOException { 444 buildIndexJson(); 445 tar.finish(); 446 tar.close(); 447 gzipOutputStream.close(); 448 bufferedOutputStream.close(); 449 OutputStream.close(); 450 FileUtilities.bytesToFile(OutputStream.toByteArray(), destFile); 451 // also, for cache management on current builds, generate a little manifest 452 String json = JsonParser.compose(packageManifest, true); 453 FileUtilities.stringToFile(json, FileUtilities.changeFileExt(destFile, ".manifest.json")); 454 } 455 456 private void buildIndexJson() throws IOException { 457 byte[] content = FileUtilities.stringToBytes(indexer.build()); 458 addFile(Category.RESOURCE, ".index.json", content); 459 content = FileUtilities.fileToBytes(indexdb); 460 ManagedFileAccess.file(indexdb).delete(); 461 addFile(Category.RESOURCE, ".index.db", content); 462 } 463 464 public String filename() { 465 return destFile; 466 } 467 468 public void loadDir(String rootDir, String name) throws IOException { 469 loadFiles(rootDir, ManagedFileAccess.file(Utilities.path(rootDir, name))); 470 } 471 472 public void loadFiles(String root, File dir, String... noload) throws IOException { 473 for (File f : dir.listFiles()) { 474 if (!Utilities.existsInList(f.getName(), noload)) { 475 if (f.isDirectory()) { 476 loadFiles(root, f); 477 } else { 478 String path = f.getAbsolutePath().substring(root.length()+1); 479 byte[] content = FileUtilities.fileToBytes(f); 480 if (created.contains(path)) 481 log.warn("Duplicate package file "+path); 482 else { 483 created.add(path); 484 TarArchiveEntry entry = new TarArchiveEntry(path); 485 entry.setSize(content.length); 486 tar.putArchiveEntry(entry); 487 tar.write(content); 488 tar.closeArchiveEntry(); 489 } 490 } 491 } 492 } 493 } 494 495 public String version() { 496 return igVersion; 497 } 498 499 500}