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}