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