
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; 076import org.hl7.fhir.utilities.npm.ToolsVersion; 077 078@MarkedToMoveToAdjunctPackage 079@Slf4j 080public class NPMPackageGenerator { 081 082 public enum Category { 083 RESOURCE, EXAMPLE, OPENAPI, SCHEMATRON, RDF, OTHER, TOOL, TEMPLATE, JEKYLL, TEST, ADL, CUSTOM; 084 085 private String getDirectory() { 086 switch (this) { 087 case RESOURCE: return "package/"; 088 case EXAMPLE: return "package/example/"; 089 case OPENAPI: return "package/openapi/"; 090 case SCHEMATRON: return "package/xml/"; 091 case RDF: return "package/rdf/"; 092 case OTHER: return "package/other/"; 093 case ADL: return "package/adl/"; 094 case TEMPLATE: return "package/other/"; 095 case JEKYLL: return "package/jekyll/"; 096 case TEST: return "package/tests/"; 097 case TOOL: return "package/bin/"; 098 case CUSTOM: return "package/custom/"; 099 } 100 return "/"; 101 } 102 } 103 104 private String destFile; 105 private Set<String> created = new HashSet<String>(); 106 public Set<String> getCreated() { 107 return created; 108 } 109 110 private TarArchiveOutputStream tar; 111 private ByteArrayOutputStream OutputStream; 112 private BufferedOutputStream bufferedOutputStream; 113 private GzipCompressorOutputStream gzipOutputStream; 114 private JsonObject packageJ; 115 public JsonObject getPackageJ() { 116 return packageJ; 117 } 118 119 private JsonObject packageManifest; 120 private NpmPackageIndexBuilder indexer; 121 private String igVersion; 122 private String indexdb; 123 124 125 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 { 126 super(); 127 this.destFile = destFile; 128 start(); 129 List<String> fhirVersion = new ArrayList<>(); 130 for (Enumeration<FHIRVersion> v : ig.getFhirVersion()) 131 fhirVersion.add(v.asStringValue()); 132 buildPackageJson(pid, canonical, kind, url, date, ig, fhirVersion, notForPublication, relatedIgs); 133 } 134 135 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 { 136 super(); 137 this.destFile = destFile; 138 start(); 139 List<String> fhirVersions = new ArrayList<>(); 140 fhirVersions.add(fhirVersion); 141 buildPackageJson(pid, canonical, kind, url, date, ig, fhirVersions, notForPublication, relatedIgs); 142 } 143 144 public static NPMPackageGenerator subset(NPMPackageGenerator master, String destFile, String id, String name, Date date, boolean notForPublication) throws FHIRException, IOException { 145 JsonObject p = master.packageJ.deepCopy(); 146 p.remove("name"); 147 p.add("name", id); 148 p.remove("type"); 149 p.add("type", PackageType.CONFORMANCE.getCode()); 150 p.remove("title"); 151 p.add("title", name); 152 if (notForPublication) { 153 p.add("notForPublication", true); 154 } 155 156 return new NPMPackageGenerator(destFile, p, date, notForPublication); 157 } 158 159 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 { 160 super(); 161 this.destFile = destFile; 162 start(); 163 buildPackageJson(ig.getPackageId(), canonical, kind, url, date, ig, fhirVersion, notForPublication, relatedIgs); 164 } 165 166 public NPMPackageGenerator(String destFile, JsonObject npm, Date date, boolean notForPublication) throws FHIRException, IOException { 167 super(); 168 String dt = new SimpleDateFormat("yyyyMMddHHmmss").format(date); 169 packageJ = npm; 170 packageManifest = new JsonObject(); 171 packageManifest.set("version", npm.asString("version")); 172 packageManifest.set("date", dt); 173 if (notForPublication) { 174 packageManifest.add("notForPublication", true); 175 } 176 npm.set("date", dt); 177 packageManifest.set("name", npm.asString("name")); 178 this.destFile = destFile; 179 start(); 180 String json = JsonParser.compose(npm, true); 181 try { 182 addFile(Category.RESOURCE, "package.json", json.getBytes("UTF-8")); 183 } catch (UnsupportedEncodingException e) { 184 } 185 } 186 187 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 { 188 String dtHuman = new SimpleDateFormat("EEE, MMM d, yyyy HH:mmZ", new Locale("en", "US")).format(date); 189 String dt = new SimpleDateFormat("yyyyMMddHHmmss").format(date); 190 191 CommaSeparatedStringBuilder b = new CommaSeparatedStringBuilder(); 192 if (!ig.hasPackageId()) { 193 b.append("packageId"); 194 } 195 if (!ig.hasVersion()) { 196 b.append("version"); 197 } 198 if (!ig.hasFhirVersion()) { 199 b.append("fhirVersion"); 200 } 201 if (!ig.hasLicense()) { 202 b.append("license"); 203 } 204 for (ImplementationGuideDependsOnComponent d : ig.getDependsOn()) { 205 if (!d.hasVersion()) { 206 b.append("dependsOn.version("+d.getUri()+")"); 207 } 208 } 209 210 JsonObject npm = new JsonObject(); 211 npm.add("name", pid); 212 npm.add("version", ig.getVersion()); 213 igVersion = ig.getVersion(); 214 npm.add("tools-version", ToolsVersion.TOOLS_VERSION); 215 npm.add("type", kind.getCode()); 216 npm.add("date", dt); 217 if (ig.hasLicense()) { 218 npm.add("license", ig.getLicense().toCode()); 219 } 220 npm.add("canonical", canonical); 221 if (notForPublication) { 222 npm.add("notForPublication", true); 223 } 224 npm.add("url", web); 225 if (ig.hasTitle()) { 226 npm.add("title", ig.getTitle()); 227 } 228 if (ig.hasDescription()) { 229 npm.add("description", ig.getDescription()+ " (built "+dtHuman+timezone()+")"); 230 } 231 JsonArray vl = new JsonArray(); 232 233 npm.add("fhirVersions", vl); 234 for (String v : fhirVersion) { 235 vl.add(new JsonString(v)); 236 } 237 238 if (kind != PackageType.CORE) { 239 JsonObject dep = new JsonObject(); 240 npm.add("dependencies", dep); 241 for (String v : fhirVersion) { 242 String vp = packageForVersion(v); 243 if (vp != null ) { 244 dep.add(vp, v); 245 } 246 } 247 for (ImplementationGuideDependsOnComponent d : ig.getDependsOn()) { 248 if (d.getPackageIdElement().hasUserData(UserDataNames.IG_DEP_ALIASED)) { 249 dep.add(d.getId()+"@npm:"+d.getPackageId(), d.getVersion()); 250 } else { 251 dep.add(d.getPackageId(), d.getVersion()); 252 } 253 } 254 } 255 if (ig.hasPublisher()) { 256 npm.add("author", ig.getPublisher()); 257 } 258 JsonArray m = new JsonArray(); 259 for (ContactDetail t : ig.getContact()) { 260 String email = email(t.getTelecom()); 261 String url = url(t.getTelecom()); 262 if (t.hasName() & (email != null || url != null)) { 263 JsonObject md = new JsonObject(); 264 m.add(md); 265 md.add("name", t.getName()); 266 if (email != null) 267 md.add("email", email); 268 if (url != null) 269 md.add("url", url); 270 } 271 } 272 if (m.size() > 0) 273 npm.add("maintainers", m); 274 if (ig.getManifest().hasRendering()) 275 npm.add("homepage", ig.getManifest().getRendering()); 276 JsonObject dir = new JsonObject(); 277 npm.add("directories", dir); 278 dir.add("lib", "package"); 279 dir.add("example", "example"); 280 if (ig.hasJurisdiction() && ig.getJurisdiction().size() == 1 && ig.getJurisdictionFirstRep().getCoding().size() == 1) { 281 Coding c = ig.getJurisdictionFirstRep().getCodingFirstRep(); 282 npm.add("jurisdiction", c.getSystem()+"#"+c.getCode()); 283 } 284 if (relatedIgs != null) { 285 JsonObject pd = npm.forceObject("peerDependencies"); 286 for (String n : relatedIgs.keySet()) { 287 pd.add(n, relatedIgs.get(n)); 288 } 289 } 290 String json = JsonParser.compose(npm, true); 291 try { 292 addFile(Category.RESOURCE, "package.json", json.getBytes("UTF-8")); 293 } catch (UnsupportedEncodingException e) { 294 } 295 packageJ = npm; 296 297 packageManifest = new JsonObject(); 298 packageManifest.add("version", ig.getVersion()); 299 JsonArray fv = new JsonArray(); 300 for (String v : fhirVersion) { 301 fv.add(v); 302 } 303 packageManifest.add("fhirVersion", fv); 304 packageManifest.add("date", dt); 305 packageManifest.add("name", ig.getPackageId()); 306 if (ig.hasJurisdiction() && ig.getJurisdiction().size() == 1 && ig.getJurisdictionFirstRep().getCoding().size() == 1) { 307 Coding c = ig.getJurisdictionFirstRep().getCodingFirstRep(); 308 packageManifest.add("jurisdiction", c.getSystem()+"#"+c.getCode()); 309 } 310 } 311 312 313 private String packageForVersion(String v) { 314 if (v == null) 315 return null; 316 if (v.startsWith("1.0")) 317 return "hl7.fhir.r2.core"; 318 if (v.startsWith("1.4")) 319 return "hl7.fhir.r2b.core"; 320 if (v.startsWith("3.0")) 321 return "hl7.fhir.r3.core"; 322 if (v.startsWith("4.0")) 323 return "hl7.fhir.r4.core"; 324 if (v.startsWith("4.1") || v.startsWith("4.3")) 325 return "hl7.fhir.r4b.core"; 326 return null; 327 } 328 329 private String timezone() { 330 TimeZone tz = TimeZone.getDefault(); 331 Calendar cal = GregorianCalendar.getInstance(tz); 332 int offsetInMillis = tz.getOffset(cal.getTimeInMillis()); 333 334 String offset = String.format("%02d:%02d", Math.abs(offsetInMillis / 3600000), Math.abs((offsetInMillis / 60000) % 60)); 335 offset = (offsetInMillis >= 0 ? "+" : "-") + offset; 336 337 return offset; 338 } 339 340 341 private String url(List<ContactPoint> telecom) { 342 for (ContactPoint cp : telecom) { 343 if (cp.getSystem() == ContactPointSystem.URL) 344 return cp.getValue(); 345 } 346 return null; 347 } 348 349 350 private String email(List<ContactPoint> telecom) { 351 for (ContactPoint cp : telecom) { 352 if (cp.getSystem() == ContactPointSystem.EMAIL) 353 return cp.getValue(); 354 } 355 return null; 356 } 357 358 private void start() throws IOException { 359 OutputStream = new ByteArrayOutputStream(); 360 bufferedOutputStream = new BufferedOutputStream(OutputStream); 361 gzipOutputStream = new GzipCompressorOutputStream(bufferedOutputStream); 362 tar = new TarArchiveOutputStream(gzipOutputStream); 363 indexdb = Utilities.path("[tmp]", "tmp-"+UUID.randomUUID().toString()+".db"); 364 indexer = new NpmPackageIndexBuilder(); 365 indexer.start(indexdb); 366 } 367 368 369 public boolean hasFile(Category cat, String name) throws IOException { 370 String path = cat.getDirectory()+name; 371 if (path.length() > 100) { 372 name = name.substring(0, name.indexOf("-"))+"-"+UUID.randomUUID().toString()+".json"; 373 path = cat.getDirectory()+name; 374 } 375 376 return created.contains(path); 377 } 378 379 public void addFile(Category cat, String name, byte[] content) throws IOException { 380 String path = cat.getDirectory()+name; 381 if (path.length() > 100) { 382 name = name.substring(0, name.indexOf("-"))+"-"+UUID.randomUUID().toString()+".json"; 383 path = cat.getDirectory()+name; 384 } 385 386 if (created.contains(path)) { 387 log.warn("Duplicate package file "+path); 388 } else { 389 created.add(path); 390 TarArchiveEntry entry = new TarArchiveEntry(path); 391 entry.setSize(content.length); 392 tar.putArchiveEntry(entry); 393 tar.write(content); 394 tar.closeArchiveEntry(); 395 if(cat == Category.RESOURCE) { 396 indexer.seeFile(name, content); 397 } 398 } 399 } 400 401 public void addFile(String folder, String name, byte[] content) throws IOException { 402 if (!folder.equals("package")) { 403 folder = "package/"+folder; 404 } 405 String path = folder+"/"+name; 406 if (path.length() > 100) { 407 name = name.substring(0, name.indexOf("-"))+"-"+UUID.randomUUID().toString()+".json"; 408 path = folder+"/"+name; 409 } 410 411 if (created.contains(path)) { 412 log.warn("Duplicate package file "+path); 413 } else { 414 created.add(path); 415 TarArchiveEntry entry = new TarArchiveEntry(path); 416 entry.setSize(content.length); 417 tar.putArchiveEntry(entry); 418 tar.write(content); 419 tar.closeArchiveEntry(); 420 if(folder == "package") { 421 indexer.seeFile(name, content); 422 } 423 } 424 } 425 426 public void finish() throws IOException { 427 buildIndexJson(); 428 tar.finish(); 429 tar.close(); 430 gzipOutputStream.close(); 431 bufferedOutputStream.close(); 432 OutputStream.close(); 433 FileUtilities.bytesToFile(OutputStream.toByteArray(), destFile); 434 // also, for cache management on current builds, generate a little manifest 435 String json = JsonParser.compose(packageManifest, true); 436 FileUtilities.stringToFile(json, FileUtilities.changeFileExt(destFile, ".manifest.json")); 437 } 438 439 private void buildIndexJson() throws IOException { 440 byte[] content = FileUtilities.stringToBytes(indexer.build()); 441 addFile(Category.RESOURCE, ".index.json", content); 442 content = FileUtilities.fileToBytes(indexdb); 443 ManagedFileAccess.file(indexdb).delete(); 444 addFile(Category.RESOURCE, ".index.db", content); 445 } 446 447 public String filename() { 448 return destFile; 449 } 450 451 public void loadDir(String rootDir, String name) throws IOException { 452 loadFiles(rootDir, ManagedFileAccess.file(Utilities.path(rootDir, name))); 453 } 454 455 public void loadFiles(String root, File dir, String... noload) throws IOException { 456 for (File f : dir.listFiles()) { 457 if (!Utilities.existsInList(f.getName(), noload)) { 458 if (f.isDirectory()) { 459 loadFiles(root, f); 460 } else { 461 String path = f.getAbsolutePath().substring(root.length()+1); 462 byte[] content = FileUtilities.fileToBytes(f); 463 if (created.contains(path)) 464 log.warn("Duplicate package file "+path); 465 else { 466 created.add(path); 467 TarArchiveEntry entry = new TarArchiveEntry(path); 468 entry.setSize(content.length); 469 tar.putArchiveEntry(entry); 470 tar.write(content); 471 tar.closeArchiveEntry(); 472 } 473 } 474 } 475 } 476 } 477 478 public String version() { 479 return igVersion; 480 } 481 482 483}