001/* ***** BEGIN LICENSE BLOCK *****
002 * Version: MPL 1.1/GPL 2.0/LGPL 2.1
003 *
004 * The contents of this file are subject to the Mozilla Public License Version
005 * 1.1 (the "License"); you may not use this file except in compliance with
006 * the License. You may obtain a copy of the License at
007 * http://www.mozilla.org/MPL/
008 *
009 * Software distributed under the License is distributed on an "AS IS" basis,
010 * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
011 * for the specific language governing rights and limitations under the
012 * License.
013 *
014 * The Original Code is part of dcm4che, an implementation of DICOM(TM) in
015 * Java(TM), hosted at https://github.com/gunterze/dcm4che.
016 *
017 * The Initial Developer of the Original Code is
018 * Agfa Healthcare.
019 * Portions created by the Initial Developer are Copyright (C) 2012
020 * the Initial Developer. All Rights Reserved.
021 *
022 * Contributor(s):
023 * See @authors listed below
024 *
025 * Alternatively, the contents of this file may be used under the terms of
026 * either the GNU General Public License Version 2 or later (the "GPL"), or
027 * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
028 * in which case the provisions of the GPL or the LGPL are applicable instead
029 * of those above. If you wish to allow use of your version of this file only
030 * under the terms of either the GPL or the LGPL, and not to allow others to
031 * use your version of this file under the terms of the MPL, indicate your
032 * decision by deleting the provisions above and replace them with the notice
033 * and other provisions required by the GPL or the LGPL. If you do not delete
034 * the provisions above, a recipient may use your version of this file under
035 * the terms of any one of the MPL, the GPL or the LGPL.
036 *
037 * ***** END LICENSE BLOCK ***** */
038
039package org.dcm4che3.tool.stowrs;
040
041import java.io.DataOutputStream;
042import java.io.File;
043import java.io.FileInputStream;
044import java.io.IOException;
045import java.io.InputStream;
046import java.io.InputStreamReader;
047import java.net.HttpURLConnection;
048import java.net.MalformedURLException;
049import java.net.URL;
050import java.util.ArrayList;
051import java.util.Collections;
052import java.util.Iterator;
053import java.util.List;
054import java.util.ResourceBundle;
055import java.util.UUID;
056
057import javax.json.Json;
058import javax.json.stream.JsonGenerator;
059import javax.ws.rs.core.MediaType;
060import javax.xml.parsers.ParserConfigurationException;
061import javax.xml.parsers.SAXParserFactory;
062import javax.xml.transform.TransformerConfigurationException;
063import javax.xml.transform.stream.StreamResult;
064
065import org.apache.commons.cli.CommandLine;
066import org.apache.commons.cli.MissingArgumentException;
067import org.apache.commons.cli.OptionBuilder;
068import org.apache.commons.cli.Options;
069import org.apache.commons.cli.ParseException;
070import org.dcm4che3.data.Attributes;
071import org.dcm4che3.data.Attributes.Visitor;
072import org.dcm4che3.data.BulkData;
073import org.dcm4che3.data.Fragments;
074import org.dcm4che3.data.Tag;
075import org.dcm4che3.data.VR;
076import org.dcm4che3.io.ContentHandlerAdapter;
077import org.dcm4che3.io.SAXReader;
078import org.dcm4che3.io.SAXTransformer;
079import org.dcm4che3.json.JSONReader;
080import org.dcm4che3.json.JSONWriter;
081import org.dcm4che3.tool.common.CLIUtils;
082import org.dcm4che3.tool.stowrs.test.StowRSResponse;
083import org.dcm4che3.tool.stowrs.test.StowRSTool.StowMetaDataType;
084import org.dcm4che3.util.SafeClose;
085import org.dcm4che3.util.StreamUtils;
086import org.dcm4che3.ws.rs.MediaTypes;
087import org.slf4j.Logger;
088import org.slf4j.LoggerFactory;
089import org.xml.sax.SAXException;
090
091/**
092 * STOW-RS client.
093 * 
094 * @author Hesham Elbadawi <bsdreko@gmail.com>
095 * @author Hermann Czedik-Eysenberg <hermann-agfa@czedik.net>
096 */
097public class StowRS {
098
099    private static final Logger LOG = LoggerFactory.getLogger(StowRS.class);
100
101    private static final String MULTIPART_BOUNDARY = "-------gc0p4Jq0M2Yt08jU534c0p";
102
103    private Attributes keys = new Attributes();
104    private static Options opts;
105    private String URL;
106    private final List<StowRSResponse> responses = new ArrayList<StowRSResponse>();
107    private static ResourceBundle rb = ResourceBundle.getBundle("org.dcm4che3.tool.stowrs.messages");
108
109    private StowMetaDataType mediaType;
110    private String transferSyntax;
111    private List<File> files = new ArrayList<File>();
112
113    public StowRS() {
114        // empty
115    }
116
117    public StowRS(Attributes overrideAttrs, StowMetaDataType mediaType, List<File> files, String url, String ts) {
118        this.URL = url;
119        this.keys = overrideAttrs;
120        this.transferSyntax = ts;
121        this.mediaType = mediaType;
122        this.files = files;
123    }
124
125    @SuppressWarnings("unchecked")
126    public static void main(String[] args) {
127        CommandLine cl = null;
128        try {
129
130            cl = parseComandLine(args);
131            StowRS instance = new StowRS();
132            if (cl.hasOption("m"))
133                instance.keys = configureKeys(instance, cl);
134            if (!cl.hasOption("u")) {
135                throw new IllegalArgumentException("Missing url");
136            } else {
137                instance.URL = cl.getOptionValue("u");
138            }
139            
140            if (cl.hasOption("t")) {
141                if (!cl.hasOption("ts")) {
142                    throw new MissingArgumentException("Missing option required option ts when sending metadata");
143                } else {
144                    instance.setTransferSyntax(cl.getOptionValue("ts"));
145                }
146
147                String mediaTypeString = cl.getOptionValue("t");
148                if ("JSON".equalsIgnoreCase(mediaTypeString)) {
149                    instance.mediaType = StowMetaDataType.JSON;
150                } else if ("XML".equalsIgnoreCase(mediaTypeString)) {
151                    instance.mediaType = StowMetaDataType.XML;
152                } else {
153                    throw new IllegalArgumentException("Bad Type " + mediaTypeString + " specified for metadata, specify either XML or JSON");
154                }
155            }
156            else {
157                instance.mediaType = StowMetaDataType.NO_METADATA_DICOM;
158            }
159
160            for (Iterator<String> iter = cl.getArgList().iterator(); iter.hasNext();) {
161                instance.files.add(new File(iter.next()));
162            }
163
164            if (instance.files.isEmpty())
165                throw new IllegalArgumentException("Missing files");
166            
167            instance.stow();
168
169        } catch (Exception e) {
170            if (!cl.hasOption("u")) {
171                LOG.error("stowrs: missing required option -u");
172                LOG.error("Try 'stowrs --help' for more information.");
173                System.exit(2);
174            } else {
175                LOG.error("Error: \n", e);
176                e.printStackTrace();
177            }
178
179        }
180    }
181
182    public void stow() {
183
184        for (File file : files) {
185            
186            LOG.info("Sending {}", file);
187            
188            if (mediaType == StowMetaDataType.NO_METADATA_DICOM) {
189                stowDicomFile(file);
190            } else {
191                stowMetaDataAndBulkData(file);
192            }
193        }
194    }
195
196    private void stowMetaDataAndBulkData(File file) {
197
198        Attributes metadata;
199        if (mediaType == StowMetaDataType.JSON) {
200            try {
201                metadata = parseJSON(file.getPath());
202            } catch (Exception e) {
203                LOG.error("error parsing metadata JSON file {}", file, e);
204                return;
205            }
206        } else if (mediaType == StowMetaDataType.XML) {
207
208            metadata = new Attributes();
209            try {
210                ContentHandlerAdapter ch = new ContentHandlerAdapter(metadata);
211                SAXParserFactory.newInstance().newSAXParser().parse(file, ch);
212                Attributes fmi = ch.getFileMetaInformation();
213                if (fmi != null)
214                        metadata.addAll(fmi);
215            } catch (Exception e) {
216                LOG.error("error parsing metadata XML file {}", file, e);
217                return;
218            }
219        } else {
220            throw new IllegalArgumentException("Unsupported media type " + mediaType);
221        }
222
223        ExtractedBulkData extractedBulkData = extractBulkData(metadata);
224
225        if (isMultiFrame(metadata)) {
226
227            if (extractedBulkData.pixelDataBulkData.size() > 1) {
228
229                // multiple fragments - reject
230                LOG.error("Compressed multiframe with multiple fragments in file {} is not supported by STOW-RS in the current DICOM standard (2015b)", file);
231
232                return;
233            }
234        }
235
236        if (!extractedBulkData.pixelDataBulkData.isEmpty()) {
237
238            // replace the pixel data bulk data URI, because we might have to merge multiple fragments into one
239
240            metadata.setValue(Tag.PixelData, metadata.getVR(Tag.PixelData), new BulkData(null, extractedBulkData.pixelDataBulkDataURI, extractedBulkData.pixelDataBulkData.get(0).bigEndian));
241        }
242
243        try {
244            addResponse(sendMetaDataAndBulkData(metadata, extractedBulkData));
245        } catch (IOException e) {
246            LOG.error("Error for file {}", file, e);
247        }
248    }
249
250    private void stowDicomFile(File file) {
251        try {
252
253            addResponse(sendDicomFile(URL, file));
254            LOG.info(file.getPath() + " with size : " + file.length());
255
256        } catch (IOException e) {
257            LOG.error("Error for file {}", file, e);
258        }
259    }
260
261    private static class ExtractedBulkData {
262        final List<BulkData> pixelDataBulkData = new ArrayList<BulkData>();
263        final List<BulkData> otherBulkDataChunks = new ArrayList<BulkData>();
264
265        final String pixelDataBulkDataURI = createRandomBulkDataURI();
266    }
267
268    private static String createRandomBulkDataURI() {
269        return UUID.randomUUID().toString().replace("-", "");
270    }
271
272    private ExtractedBulkData extractBulkData(Attributes dataset) {
273
274        final ExtractedBulkData extractedBulkData = new ExtractedBulkData();
275
276        try {
277            dataset.accept(new Visitor() {
278
279                @Override
280                public boolean visit(Attributes attrs, int tag, VR vr, Object value) {
281
282                    if (attrs.isRoot() && tag == Tag.PixelData) {
283                        if (value instanceof BulkData) {
284                            extractedBulkData.pixelDataBulkData.add((BulkData) value);
285                        } else if (value instanceof Fragments) {
286                            Fragments frags = (Fragments) value;
287                            if (frags.size() > 1 && frags.get(1) instanceof BulkData) {
288                                // please note that we are ignoring the first fragment (offset table) here
289                                // (it's okay as we are anyways not supporting fragmented multi-frames at the moment)
290                                for (int i = 1; i < frags.size(); i++) {
291                                    if (frags.get(i) instanceof BulkData) {
292                                        extractedBulkData.pixelDataBulkData.add((BulkData) frags.get(i));
293                                    }
294                                }
295                            }
296                        }
297                    } else {
298                        // other bulk data tags (not top-level pixel data)
299
300                        if (value instanceof BulkData) {
301                            extractedBulkData.otherBulkDataChunks.add((BulkData) value);
302                        }
303
304                        // Note: at the moment we support fragments only for top-level PixelData.
305                        // Maybe we should also support it for others, seems to be at least allowed for PixelData inside sequences
306                        // (see DICOM PS3.5 2015b A.4 Transfer Syntaxes For Encapsulation of Encoded Pixel Data)
307                    }
308
309                    return true;
310                }
311            }, true);
312        } catch (Exception e) {
313            throw new RuntimeException(e); // should not happen
314        }
315
316        return extractedBulkData;
317    }
318
319    public List<StowRSResponse> getResponses() {
320        return responses;
321    }
322
323    public void addResponse(StowRSResponse response) {
324        this.responses.add(response);
325    }
326
327    private static Attributes parseJSON(String fname) throws Exception {
328        Attributes attrs = new Attributes();
329        Attributes fmi = parseJSON(fname, attrs);
330        if (fmi != null)
331                attrs.addAll(fmi);
332        return attrs;
333    }
334
335    @SuppressWarnings("static-access")
336    private static CommandLine parseComandLine(String[] args)
337            throws ParseException {
338        opts = new Options();
339        opts.addOption(OptionBuilder.hasArgs(2).withArgName("[seq/]attr=value")
340                .withValueSeparator().withDescription(rb.getString("metadata"))
341                .create("m"));
342        opts.addOption("u", "url", true, rb.getString("url"));
343        opts.addOption("t", "metadata-type", true,
344                rb.getString("metadata-type"));
345        opts.addOption("ts", "transfer-syntax", true,
346                rb.getString("transfer-syntax"));
347        CLIUtils.addCommonOptions(opts);
348        return CLIUtils.parseComandLine(args, opts, rb, StowRS.class);
349    }
350
351    private static Attributes configureKeys(StowRS main, CommandLine cl) {
352        Attributes temp = new Attributes();
353        CLIUtils.addAttributes(temp, cl.getOptionValues("m"));
354        LOG.info("added keys for coercion: \n" + main.keys.toString());
355        return temp;
356    }
357
358    private static boolean isMultiFrame(Attributes metadata) {
359        return metadata.contains(Tag.NumberOfFrames)
360                && metadata.getInt(Tag.NumberOfFrames, 1) > 1;
361    }
362
363    private static void coerceAttributes(Attributes metadata, Attributes keys) {
364        if (!keys.isEmpty()) {
365            LOG.info("Coercing the following keys from specified attributes to metadata:");
366            metadata.update(keys, null);
367            LOG.info(keys.toString());
368        }
369    }
370
371    private StowRSResponse sendMetaDataAndBulkData(Attributes metadata, ExtractedBulkData extractedBulkData) throws IOException {
372        Attributes responseAttrs = new Attributes();
373
374        URL newUrl;
375        try {
376            newUrl = new URL(URL);
377        } catch (MalformedURLException e2) {
378            throw new RuntimeException(e2);
379        }
380
381        HttpURLConnection connection = (HttpURLConnection) newUrl.openConnection();
382        connection.setChunkedStreamingMode(2048);
383        connection.setDoOutput(true);
384        connection.setDoInput(true);
385        connection.setInstanceFollowRedirects(false);
386        connection.setRequestMethod("POST");
387
388        String metaDataType = mediaType == StowMetaDataType.XML ? "application/dicom+xml" : "application/json";
389        connection.setRequestProperty("Content-Type", "multipart/related; type=\"" + metaDataType + "\"; boundary=" + MULTIPART_BOUNDARY);
390        String bulkDataTransferSyntax = "transfer-syntax=" + transferSyntax;
391
392        MediaType pixelDataMediaType = getBulkDataMediaType(metadata);
393        connection.setRequestProperty("Accept", "application/dicom+xml");
394        connection.setRequestProperty("charset", "utf-8");
395        connection.setUseCaches(false);
396
397        DataOutputStream wr = new DataOutputStream(connection.getOutputStream());
398
399        // write metadata
400        wr.writeBytes("\r\n--" + MULTIPART_BOUNDARY + "\r\n");
401
402        if (mediaType == StowMetaDataType.XML)
403            wr.writeBytes("Content-Type: application/dicom+xml; " + bulkDataTransferSyntax + " \r\n");
404        else
405            wr.writeBytes("Content-Type: application/json; " + bulkDataTransferSyntax + " \r\n");
406        wr.writeBytes("\r\n");
407
408        coerceAttributes(metadata, keys);
409
410        try {
411            if (mediaType == StowMetaDataType.XML)
412                SAXTransformer.getSAXWriter(new StreamResult(wr)).write(metadata);
413            else {
414                JsonGenerator gen = Json.createGenerator(wr);
415                JSONWriter writer = new JSONWriter(gen);
416                writer.write(metadata);
417                gen.flush();
418            }
419        } catch (TransformerConfigurationException e) {
420            throw new IOException(e);
421        } catch (SAXException e) {
422            throw new IOException(e);
423        }
424
425        // write bulkdata
426
427        for (BulkData chunk : extractedBulkData.otherBulkDataChunks) {
428            writeBulkDataPart(MediaType.APPLICATION_OCTET_STREAM_TYPE, wr, chunk.getURIOrUUID(), Collections.singletonList(chunk));
429        }
430
431
432        if (!extractedBulkData.pixelDataBulkData.isEmpty()) {
433            // pixeldata as a single bulk data part
434
435            if (extractedBulkData.pixelDataBulkData.size() > 1) {
436                LOG.info("Combining bulk data of multiple pixel data fragments");
437            }
438
439            writeBulkDataPart(pixelDataMediaType, wr, extractedBulkData.pixelDataBulkDataURI, extractedBulkData.pixelDataBulkData);
440        }
441
442        // end of multipart message
443        wr.writeBytes("\r\n--" + MULTIPART_BOUNDARY + "--\r\n");
444        wr.close();
445        String response = connection.getResponseMessage();
446        int rspCode = connection.getResponseCode();
447        LOG.info("response: " + response);
448        try {
449            responseAttrs = SAXReader.parse(connection.getInputStream());
450        } catch (Exception e) {
451            LOG.error("Error creating response attributes", e);
452        }
453        connection.disconnect();
454
455        return new StowRSResponse(rspCode, response, responseAttrs);
456    }
457
458    private static void writeBulkDataPart(MediaType mediaType, DataOutputStream wr, String uri, List<BulkData> chunks) throws IOException {
459        wr.writeBytes("\r\n--" + MULTIPART_BOUNDARY + "\r\n");
460        wr.writeBytes("Content-Type: " + toContentType(mediaType) + " \r\n");
461        wr.writeBytes("Content-Location: " + uri + " \r\n");
462        wr.writeBytes("\r\n");
463
464        for (BulkData chunk : chunks) {
465            writeBulkDataToStream(chunk, wr);
466        }
467    }
468
469    private static String toContentType(MediaType mediaType) {
470        StringBuilder sb = new StringBuilder();
471        sb.append(mediaType.getType()).append('/').append(mediaType.getSubtype());
472        String tsuid = mediaType.getParameters().get("transfer-syntax");
473        if (tsuid != null ) {
474                sb.append("; transfer-syntax=").append(tsuid);
475        }
476                return sb.toString();
477        }
478
479        private MediaType getBulkDataMediaType(Attributes metadata) {
480        return MediaTypes.forTransferSyntax(metadata.getString(Tag.TransferSyntaxUID, getTransferSyntax()));
481    }
482
483    private static void writeBulkDataToStream(BulkData bulkData, DataOutputStream wr) throws IOException {
484        InputStream in = null;
485        try {
486            in = bulkData.openStream();
487
488            int length = bulkData.length();
489
490            if (length >= 0) {
491                StreamUtils.copy(in, wr, length);
492            } else { // unspecified length
493                StreamUtils.copy(in, wr);
494            }
495
496        } finally {
497            if (in != null)
498                try {
499                    in.close();
500                } catch (IOException e) {
501                    LOG.error("Error closing stream", e);
502                }
503        }
504    }
505
506    private static StowRSResponse sendDicomFile(String url, File f) throws IOException {
507        int rspCode = 0;
508        String rspMessage = null;
509
510        URL newUrl = new URL(url);
511        HttpURLConnection connection = (HttpURLConnection) newUrl.openConnection();
512        connection.setDoOutput(true);
513        connection.setDoInput(true);
514        connection.setInstanceFollowRedirects(false);
515        connection.setRequestMethod("POST");
516        connection.setRequestProperty("Content-Type", "multipart/related; type=\"application/dicom\"; boundary=" + MULTIPART_BOUNDARY);
517        connection.setRequestProperty("Accept", "application/dicom+xml");
518        connection.setRequestProperty("charset", "utf-8");
519        connection.setUseCaches(false);
520
521        DataOutputStream wr;
522        wr = new DataOutputStream(connection.getOutputStream());
523        wr.writeBytes("\r\n--" + MULTIPART_BOUNDARY + "\r\n");
524        wr.writeBytes("Content-Disposition: inline; name=\"file[]\"; filename=\"" + f.getName() + "\"\r\n");
525        wr.writeBytes("Content-Type: application/dicom \r\n");
526        wr.writeBytes("\r\n");
527        FileInputStream fis = new FileInputStream(f);
528        StreamUtils.copy(fis, wr);
529        fis.close();
530        wr.writeBytes("\r\n--" + MULTIPART_BOUNDARY + "--\r\n");
531        wr.flush();
532        wr.close();
533        String response = connection.getResponseMessage();
534        rspCode = connection.getResponseCode();
535        rspMessage = connection.getResponseMessage();
536        LOG.info("response: " + response);
537        Attributes responseAttrs = null;
538        try {
539            InputStream in;
540            boolean isErrorCase = rspCode >= HttpURLConnection.HTTP_BAD_REQUEST;
541            if (!isErrorCase) {
542                in = connection.getInputStream();
543            } else {
544                in = connection.getErrorStream();
545            }
546            if (!isErrorCase || rspCode == HttpURLConnection.HTTP_CONFLICT)
547                responseAttrs = SAXReader.parse(in);
548        } catch (SAXException e) {
549            throw new IOException(e);
550        } catch (ParserConfigurationException e) {
551            throw new IOException(e);
552        }
553        connection.disconnect();
554        
555        return new StowRSResponse(rspCode, rspMessage, responseAttrs);
556    }
557
558    private static Attributes parseJSON(String fname, Attributes attrs)
559            throws IOException {
560        InputStream in = fname.equals("-") ? System.in : new FileInputStream(fname);
561        try {
562            JSONReader reader = new JSONReader(
563                    Json.createParser(new InputStreamReader(in, "UTF-8")));
564            reader.readDataset(attrs);
565            Attributes fmi = reader.getFileMetaInformation();
566            return fmi;
567        } finally {
568            if (in != System.in)
569                SafeClose.close(in);
570        }
571    }
572
573    public String getTransferSyntax() {
574        return transferSyntax;
575    }
576
577    public void setTransferSyntax(String transferSyntax) {
578        this.transferSyntax = transferSyntax;
579    }
580
581}