/* ***** BEGIN LICENSE BLOCK *****
* Version: MPL 1.1/GPL 2.0/LGPL 2.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is part of dcm4che, an implementation of DICOM(TM) in
* Java(TM), hosted at https://github.com/gunterze/dcm4che.
*
* The Initial Developer of the Original Code is
* Agfa Healthcare.
* Portions created by the Initial Developer are Copyright (C) 2012
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
* See @authors listed below
*
* Alternatively, the contents of this file may be used under the terms of
* either the GNU General Public License Version 2 or later (the "GPL"), or
* the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
* in which case the provisions of the GPL or the LGPL are applicable instead
* of those above. If you wish to allow use of your version of this file only
* under the terms of either the GPL or the LGPL, and not to allow others to
* use your version of this file under the terms of the MPL, indicate your
* decision by deleting the provisions above and replace them with the notice
* and other provisions required by the GPL or the LGPL. If you do not delete
* the provisions above, a recipient may use your version of this file under
* the terms of any one of the MPL, the GPL or the LGPL.
*
* ***** END LICENSE BLOCK ***** */
package org.dcm4che3.tool.stowrs;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.ResourceBundle;
import java.util.UUID;
import javax.json.Json;
import javax.json.stream.JsonGenerator;
import javax.ws.rs.core.MediaType;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParserFactory;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.stream.StreamResult;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.MissingArgumentException;
import org.apache.commons.cli.OptionBuilder;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
import org.dcm4che3.data.Attributes;
import org.dcm4che3.data.Attributes.Visitor;
import org.dcm4che3.data.BulkData;
import org.dcm4che3.data.Fragments;
import org.dcm4che3.data.Tag;
import org.dcm4che3.data.VR;
import org.dcm4che3.io.ContentHandlerAdapter;
import org.dcm4che3.io.SAXReader;
import org.dcm4che3.io.SAXTransformer;
import org.dcm4che3.json.JSONReader;
import org.dcm4che3.json.JSONWriter;
import org.dcm4che3.tool.common.CLIUtils;
import org.dcm4che3.tool.stowrs.test.StowRSResponse;
import org.dcm4che3.tool.stowrs.test.StowRSTool.StowMetaDataType;
import org.dcm4che3.util.SafeClose;
import org.dcm4che3.util.StreamUtils;
import org.dcm4che3.ws.rs.MediaTypes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.SAXException;
/**
* STOW-RS client.
*
* @author Hesham Elbadawi <bsdreko@gmail.com>
* @author Hermann Czedik-Eysenberg <hermann-agfa@czedik.net>
*/
public class StowRS {
private static final Logger LOG = LoggerFactory.getLogger(StowRS.class);
private static final String MULTIPART_BOUNDARY = "-------gc0p4Jq0M2Yt08jU534c0p";
private Attributes keys = new Attributes();
private static Options opts;
private String URL;
private final List<StowRSResponse> responses = new ArrayList<StowRSResponse>();
private static ResourceBundle rb = ResourceBundle.getBundle("org.dcm4che3.tool.stowrs.messages");
private StowMetaDataType mediaType;
private String transferSyntax;
private List<File> files = new ArrayList<File>();
public StowRS() {
// empty
}
public StowRS(Attributes overrideAttrs, StowMetaDataType mediaType, List<File> files, String url, String ts) {
this.URL = url;
this.keys = overrideAttrs;
this.transferSyntax = ts;
this.mediaType = mediaType;
this.files = files;
}
@SuppressWarnings("unchecked")
public static void main(String[] args) {
CommandLine cl = null;
try {
cl = parseComandLine(args);
StowRS instance = new StowRS();
if (cl.hasOption("m"))
instance.keys = configureKeys(instance, cl);
if (!cl.hasOption("u")) {
throw new IllegalArgumentException("Missing url");
} else {
instance.URL = cl.getOptionValue("u");
}
if (cl.hasOption("t")) {
if (!cl.hasOption("ts")) {
throw new MissingArgumentException("Missing option required option ts when sending metadata");
} else {
instance.setTransferSyntax(cl.getOptionValue("ts"));
}
String mediaTypeString = cl.getOptionValue("t");
if ("JSON".equalsIgnoreCase(mediaTypeString)) {
instance.mediaType = StowMetaDataType.JSON;
} else if ("XML".equalsIgnoreCase(mediaTypeString)) {
instance.mediaType = StowMetaDataType.XML;
} else {
throw new IllegalArgumentException("Bad Type " + mediaTypeString + " specified for metadata, specify either XML or JSON");
}
}
else {
instance.mediaType = StowMetaDataType.NO_METADATA_DICOM;
}
for (Iterator<String> iter = cl.getArgList().iterator(); iter.hasNext();) {
instance.files.add(new File(iter.next()));
}
if (instance.files.isEmpty())
throw new IllegalArgumentException("Missing files");
instance.stow();
} catch (Exception e) {
if (!cl.hasOption("u")) {
LOG.error("stowrs: missing required option -u");
LOG.error("Try 'stowrs --help' for more information.");
System.exit(2);
} else {
LOG.error("Error: \n", e);
e.printStackTrace();
}
}
}
public void stow() {
for (File file : files) {
LOG.info("Sending {}", file);
if (mediaType == StowMetaDataType.NO_METADATA_DICOM) {
stowDicomFile(file);
} else {
stowMetaDataAndBulkData(file);
}
}
}
private void stowMetaDataAndBulkData(File file) {
Attributes metadata;
if (mediaType == StowMetaDataType.JSON) {
try {
metadata = parseJSON(file.getPath());
} catch (Exception e) {
LOG.error("error parsing metadata JSON file {}", file, e);
return;
}
} else if (mediaType == StowMetaDataType.XML) {
metadata = new Attributes();
try {
ContentHandlerAdapter ch = new ContentHandlerAdapter(metadata);
SAXParserFactory.newInstance().newSAXParser().parse(file, ch);
Attributes fmi = ch.getFileMetaInformation();
if (fmi != null)
metadata.addAll(fmi);
} catch (Exception e) {
LOG.error("error parsing metadata XML file {}", file, e);
return;
}
} else {
throw new IllegalArgumentException("Unsupported media type " + mediaType);
}
ExtractedBulkData extractedBulkData = extractBulkData(metadata);
if (isMultiFrame(metadata)) {
if (extractedBulkData.pixelDataBulkData.size() > 1) {
// multiple fragments - reject
LOG.error("Compressed multiframe with multiple fragments in file {} is not supported by STOW-RS in the current DICOM standard (2015b)", file);
return;
}
}
if (!extractedBulkData.pixelDataBulkData.isEmpty()) {
// replace the pixel data bulk data URI, because we might have to merge multiple fragments into one
metadata.setValue(Tag.PixelData, metadata.getVR(Tag.PixelData), new BulkData(null, extractedBulkData.pixelDataBulkDataURI, extractedBulkData.pixelDataBulkData.get(0).bigEndian));
}
try {
addResponse(sendMetaDataAndBulkData(metadata, extractedBulkData));
} catch (IOException e) {
LOG.error("Error for file {}", file, e);
}
}
private void stowDicomFile(File file) {
try {
addResponse(sendDicomFile(URL, file));
LOG.info(file.getPath() + " with size : " + file.length());
} catch (IOException e) {
LOG.error("Error for file {}", file, e);
}
}
private static class ExtractedBulkData {
final List<BulkData> pixelDataBulkData = new ArrayList<BulkData>();
final List<BulkData> otherBulkDataChunks = new ArrayList<BulkData>();
final String pixelDataBulkDataURI = createRandomBulkDataURI();
}
private static String createRandomBulkDataURI() {
return UUID.randomUUID().toString().replace("-", "");
}
private ExtractedBulkData extractBulkData(Attributes dataset) {
final ExtractedBulkData extractedBulkData = new ExtractedBulkData();
try {
dataset.accept(new Visitor() {
@Override
public boolean visit(Attributes attrs, int tag, VR vr, Object value) {
if (attrs.isRoot() && tag == Tag.PixelData) {
if (value instanceof BulkData) {
extractedBulkData.pixelDataBulkData.add((BulkData) value);
} else if (value instanceof Fragments) {
Fragments frags = (Fragments) value;
if (frags.size() > 1 && frags.get(1) instanceof BulkData) {
// please note that we are ignoring the first fragment (offset table) here
// (it's okay as we are anyways not supporting fragmented multi-frames at the moment)
for (int i = 1; i < frags.size(); i++) {
if (frags.get(i) instanceof BulkData) {
extractedBulkData.pixelDataBulkData.add((BulkData) frags.get(i));
}
}
}
}
} else {
// other bulk data tags (not top-level pixel data)
if (value instanceof BulkData) {
extractedBulkData.otherBulkDataChunks.add((BulkData) value);
}
// Note: at the moment we support fragments only for top-level PixelData.
// Maybe we should also support it for others, seems to be at least allowed for PixelData inside sequences
// (see DICOM PS3.5 2015b A.4 Transfer Syntaxes For Encapsulation of Encoded Pixel Data)
}
return true;
}
}, true);
} catch (Exception e) {
throw new RuntimeException(e); // should not happen
}
return extractedBulkData;
}
public List<StowRSResponse> getResponses() {
return responses;
}
public void addResponse(StowRSResponse response) {
this.responses.add(response);
}
private static Attributes parseJSON(String fname) throws Exception {
Attributes attrs = new Attributes();
Attributes fmi = parseJSON(fname, attrs);
if (fmi != null)
attrs.addAll(fmi);
return attrs;
}
@SuppressWarnings("static-access")
private static CommandLine parseComandLine(String[] args)
throws ParseException {
opts = new Options();
opts.addOption(OptionBuilder.hasArgs(2).withArgName("[seq/]attr=value")
.withValueSeparator().withDescription(rb.getString("metadata"))
.create("m"));
opts.addOption("u", "url", true, rb.getString("url"));
opts.addOption("t", "metadata-type", true,
rb.getString("metadata-type"));
opts.addOption("ts", "transfer-syntax", true,
rb.getString("transfer-syntax"));
CLIUtils.addCommonOptions(opts);
return CLIUtils.parseComandLine(args, opts, rb, StowRS.class);
}
private static Attributes configureKeys(StowRS main, CommandLine cl) {
Attributes temp = new Attributes();
CLIUtils.addAttributes(temp, cl.getOptionValues("m"));
LOG.info("added keys for coercion: \n" + main.keys.toString());
return temp;
}
private static boolean isMultiFrame(Attributes metadata) {
return metadata.contains(Tag.NumberOfFrames)
&& metadata.getInt(Tag.NumberOfFrames, 1) > 1;
}
private static void coerceAttributes(Attributes metadata, Attributes keys) {
if (!keys.isEmpty()) {
LOG.info("Coercing the following keys from specified attributes to metadata:");
metadata.update(keys, null);
LOG.info(keys.toString());
}
}
private StowRSResponse sendMetaDataAndBulkData(Attributes metadata, ExtractedBulkData extractedBulkData) throws IOException {
Attributes responseAttrs = new Attributes();
URL newUrl;
try {
newUrl = new URL(URL);
} catch (MalformedURLException e2) {
throw new RuntimeException(e2);
}
HttpURLConnection connection = (HttpURLConnection) newUrl.openConnection();
connection.setChunkedStreamingMode(2048);
connection.setDoOutput(true);
connection.setDoInput(true);
connection.setInstanceFollowRedirects(false);
connection.setRequestMethod("POST");
String metaDataType = mediaType == StowMetaDataType.XML ? "application/dicom+xml" : "application/json";
connection.setRequestProperty("Content-Type", "multipart/related; type=\"" + metaDataType + "\"; boundary=" + MULTIPART_BOUNDARY);
String bulkDataTransferSyntax = "transfer-syntax=" + transferSyntax;
MediaType pixelDataMediaType = getBulkDataMediaType(metadata);
connection.setRequestProperty("Accept", "application/dicom+xml");
connection.setRequestProperty("charset", "utf-8");
connection.setUseCaches(false);
DataOutputStream wr = new DataOutputStream(connection.getOutputStream());
// write metadata
wr.writeBytes("\r\n--" + MULTIPART_BOUNDARY + "\r\n");
if (mediaType == StowMetaDataType.XML)
wr.writeBytes("Content-Type: application/dicom+xml; " + bulkDataTransferSyntax + " \r\n");
else
wr.writeBytes("Content-Type: application/json; " + bulkDataTransferSyntax + " \r\n");
wr.writeBytes("\r\n");
coerceAttributes(metadata, keys);
try {
if (mediaType == StowMetaDataType.XML)
SAXTransformer.getSAXWriter(new StreamResult(wr)).write(metadata);
else {
JsonGenerator gen = Json.createGenerator(wr);
JSONWriter writer = new JSONWriter(gen);
writer.write(metadata);
gen.flush();
}
} catch (TransformerConfigurationException e) {
throw new IOException(e);
} catch (SAXException e) {
throw new IOException(e);
}
// write bulkdata
for (BulkData chunk : extractedBulkData.otherBulkDataChunks) {
writeBulkDataPart(MediaType.APPLICATION_OCTET_STREAM_TYPE, wr, chunk.getURIOrUUID(), Collections.singletonList(chunk));
}
if (!extractedBulkData.pixelDataBulkData.isEmpty()) {
// pixeldata as a single bulk data part
if (extractedBulkData.pixelDataBulkData.size() > 1) {
LOG.info("Combining bulk data of multiple pixel data fragments");
}
writeBulkDataPart(pixelDataMediaType, wr, extractedBulkData.pixelDataBulkDataURI, extractedBulkData.pixelDataBulkData);
}
// end of multipart message
wr.writeBytes("\r\n--" + MULTIPART_BOUNDARY + "--\r\n");
wr.close();
String response = connection.getResponseMessage();
int rspCode = connection.getResponseCode();
LOG.info("response: " + response);
try {
responseAttrs = SAXReader.parse(connection.getInputStream());
} catch (Exception e) {
LOG.error("Error creating response attributes", e);
}
connection.disconnect();
return new StowRSResponse(rspCode, response, responseAttrs);
}
private static void writeBulkDataPart(MediaType mediaType, DataOutputStream wr, String uri, List<BulkData> chunks) throws IOException {
wr.writeBytes("\r\n--" + MULTIPART_BOUNDARY + "\r\n");
wr.writeBytes("Content-Type: " + toContentType(mediaType) + " \r\n");
wr.writeBytes("Content-Location: " + uri + " \r\n");
wr.writeBytes("\r\n");
for (BulkData chunk : chunks) {
writeBulkDataToStream(chunk, wr);
}
}
private static String toContentType(MediaType mediaType) {
StringBuilder sb = new StringBuilder();
sb.append(mediaType.getType()).append('/').append(mediaType.getSubtype());
String tsuid = mediaType.getParameters().get("transfer-syntax");
if (tsuid != null ) {
sb.append("; transfer-syntax=").append(tsuid);
}
return sb.toString();
}
private MediaType getBulkDataMediaType(Attributes metadata) {
return MediaTypes.forTransferSyntax(metadata.getString(Tag.TransferSyntaxUID, getTransferSyntax()));
}
private static void writeBulkDataToStream(BulkData bulkData, DataOutputStream wr) throws IOException {
InputStream in = null;
try {
in = bulkData.openStream();
int length = bulkData.length();
if (length >= 0) {
StreamUtils.copy(in, wr, length);
} else { // unspecified length
StreamUtils.copy(in, wr);
}
} finally {
if (in != null)
try {
in.close();
} catch (IOException e) {
LOG.error("Error closing stream", e);
}
}
}
private static StowRSResponse sendDicomFile(String url, File f) throws IOException {
int rspCode = 0;
String rspMessage = null;
URL newUrl = new URL(url);
HttpURLConnection connection = (HttpURLConnection) newUrl.openConnection();
connection.setDoOutput(true);
connection.setDoInput(true);
connection.setInstanceFollowRedirects(false);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "multipart/related; type=\"application/dicom\"; boundary=" + MULTIPART_BOUNDARY);
connection.setRequestProperty("Accept", "application/dicom+xml");
connection.setRequestProperty("charset", "utf-8");
connection.setUseCaches(false);
DataOutputStream wr;
wr = new DataOutputStream(connection.getOutputStream());
wr.writeBytes("\r\n--" + MULTIPART_BOUNDARY + "\r\n");
wr.writeBytes("Content-Disposition: inline; name=\"file[]\"; filename=\"" + f.getName() + "\"\r\n");
wr.writeBytes("Content-Type: application/dicom \r\n");
wr.writeBytes("\r\n");
FileInputStream fis = new FileInputStream(f);
StreamUtils.copy(fis, wr);
fis.close();
wr.writeBytes("\r\n--" + MULTIPART_BOUNDARY + "--\r\n");
wr.flush();
wr.close();
String response = connection.getResponseMessage();
rspCode = connection.getResponseCode();
rspMessage = connection.getResponseMessage();
LOG.info("response: " + response);
Attributes responseAttrs = null;
try {
InputStream in;
boolean isErrorCase = rspCode >= HttpURLConnection.HTTP_BAD_REQUEST;
if (!isErrorCase) {
in = connection.getInputStream();
} else {
in = connection.getErrorStream();
}
if (!isErrorCase || rspCode == HttpURLConnection.HTTP_CONFLICT)
responseAttrs = SAXReader.parse(in);
} catch (SAXException e) {
throw new IOException(e);
} catch (ParserConfigurationException e) {
throw new IOException(e);
}
connection.disconnect();
return new StowRSResponse(rspCode, rspMessage, responseAttrs);
}
private static Attributes parseJSON(String fname, Attributes attrs)
throws IOException {
InputStream in = fname.equals("-") ? System.in : new FileInputStream(fname);
try {
JSONReader reader = new JSONReader(
Json.createParser(new InputStreamReader(in, "UTF-8")));
reader.readDataset(attrs);
Attributes fmi = reader.getFileMetaInformation();
return fmi;
} finally {
if (in != System.in)
SafeClose.close(in);
}
}
public String getTransferSyntax() {
return transferSyntax;
}
public void setTransferSyntax(String transferSyntax) {
this.transferSyntax = transferSyntax;
}
}