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