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}