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}