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