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  public boolean hasFile(Category cat, String name) throws IOException {
386    String path = cat.getDirectory()+name;
387    if (path.length() > 100) {
388      name = name.substring(0, name.indexOf("-"))+"-"+UUID.randomUUID().toString()+".json";
389      path = cat.getDirectory()+name;      
390    }
391      
392    return created.contains(path);    
393  }
394  
395  public void addFile(Category cat, String name, byte[] content) throws IOException {
396    String path = cat.getDirectory()+name;
397    if (path.length() > 100) {
398      name = name.substring(0, name.indexOf("-"))+"-"+UUID.randomUUID().toString()+".json";
399      path = cat.getDirectory()+name;      
400    }
401      
402    if (created.contains(path)) {
403      log.warn("Duplicate package file "+path);
404    } else {
405      created.add(path);
406      TarArchiveEntry entry = new TarArchiveEntry(path);
407      entry.setSize(content.length);
408      tar.putArchiveEntry(entry);
409      tar.write(content);
410      tar.closeArchiveEntry();
411      if(cat == Category.RESOURCE) {
412        indexer.seeFile(name, content);
413      }
414    }
415  }
416
417  public void addFile(String folder, String name, byte[] content) throws IOException {
418    if (!folder.equals("package")) {
419      folder = "package/"+folder;
420    }
421    String path = folder+"/"+name;
422    if (path.length() > 100) {
423      name = name.substring(0, name.indexOf("-"))+"-"+UUID.randomUUID().toString()+".json";
424      path = folder+"/"+name;      
425    }
426      
427    if (created.contains(path)) {
428      log.warn("Duplicate package file "+path);
429    } else {
430      created.add(path);
431      TarArchiveEntry entry = new TarArchiveEntry(path);
432      entry.setSize(content.length);
433      tar.putArchiveEntry(entry);
434      tar.write(content);
435      tar.closeArchiveEntry();
436      if(folder == "package") {
437        indexer.seeFile(name, content);
438      }
439    }
440  }
441
442  public void finish() throws IOException {
443    buildIndexJson();
444    tar.finish();
445    tar.close();
446    gzipOutputStream.close();
447    bufferedOutputStream.close();
448    OutputStream.close();
449    FileUtilities.bytesToFile(OutputStream.toByteArray(), destFile);
450    // also, for cache management on current builds, generate a little manifest
451    String json = JsonParser.compose(packageManifest, true);
452    FileUtilities.stringToFile(json, FileUtilities.changeFileExt(destFile, ".manifest.json"));
453  }
454
455  private void buildIndexJson() throws IOException {
456    byte[] content = FileUtilities.stringToBytes(indexer.build());
457    addFile(Category.RESOURCE, ".index.json", content); 
458    content = FileUtilities.fileToBytes(indexdb);
459    ManagedFileAccess.file(indexdb).delete();
460    addFile(Category.RESOURCE, ".index.db", content); 
461  }
462
463  public String filename() {
464    return destFile;
465  }
466
467  public void loadDir(String rootDir, String name) throws IOException {
468    loadFiles(rootDir, ManagedFileAccess.file(Utilities.path(rootDir, name)));
469  }
470
471  public void loadFiles(String root, File dir, String... noload) throws IOException {
472    for (File f : dir.listFiles()) {
473      if (!Utilities.existsInList(f.getName(), noload)) {
474        if (f.isDirectory()) {
475          loadFiles(root, f);
476        } else {
477          String path = f.getAbsolutePath().substring(root.length()+1);
478          byte[] content = FileUtilities.fileToBytes(f);
479          if (created.contains(path)) 
480            log.warn("Duplicate package file "+path);
481          else {
482            created.add(path);
483            TarArchiveEntry entry = new TarArchiveEntry(path);
484            entry.setSize(content.length);
485            tar.putArchiveEntry(entry);
486            tar.write(content);
487            tar.closeArchiveEntry();
488          }
489        }
490      }
491    }
492  }
493
494  public String version() {
495    return igVersion;
496  }
497
498
499}