├── dicomweb ├── stow.go ├── wado.go ├── example_test.go ├── dicomweb.go └── dicomweb_test.go ├── go.mod ├── .github └── workflows │ └── ci.yml ├── LICENSE.md ├── go.sum └── README.md /dicomweb/stow.go: -------------------------------------------------------------------------------- 1 | package dicomweb 2 | 3 | // STOWRequest defines the filter option used in STOW queries. 4 | type STOWRequest struct { 5 | StudyInstanceUID string 6 | Parts [][]byte 7 | } 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/toastcheng/dicomweb-go 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/philippfranke/multipart-related v0.0.0-20170217130855-01d28b2a1769 7 | github.com/stretchr/testify v1.6.1 8 | ) 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: Test with Coverage 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Set up Go 9 | uses: actions/setup-go@v1 10 | with: 11 | go-version: '1.13' 12 | - name: Check out code 13 | uses: actions/checkout@v2 14 | - name: Install dependencies 15 | run: | 16 | cd dicomweb 17 | go mod download 18 | - name: Run Unit tests 19 | run: | 20 | cd dicomweb 21 | go test -race -covermode atomic -coverprofile=covprofile ./... 22 | - name: Send coverage 23 | env: 24 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | run: | 26 | cd dicomweb 27 | go get github.com/mattn/goveralls 28 | /home/runner/go/bin/goveralls -coverprofile=covprofile -service=github 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 ShihCheng Tu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/philippfranke/multipart-related v0.0.0-20170217130855-01d28b2a1769 h1:zPeWKlq1lDuvoxDC8WQeljv5wc1aea9C9eTdhZ8hUs8= 4 | github.com/philippfranke/multipart-related v0.0.0-20170217130855-01d28b2a1769/go.mod h1:xCezMER3Qd4/X4YS5skzs3taqFZ71Ymoq//kyKiS/BI= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 8 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 9 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 12 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 13 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 14 | -------------------------------------------------------------------------------- /dicomweb/wado.go: -------------------------------------------------------------------------------- 1 | package dicomweb 2 | 3 | // WADORequest defines the filter option used in WADO queries. 4 | type WADORequest struct { 5 | Type WADOType 6 | StudyInstanceUID string 7 | SeriesInstanceUID string 8 | SOPInstanceUID string 9 | PatientName string 10 | FrameID int 11 | RetrieveURL string 12 | Annotation string 13 | Quality int 14 | Viewport string 15 | Window string 16 | } 17 | 18 | // Validate validates if the request is valid. 19 | func (r WADORequest) Validate() bool { 20 | switch r.Type { 21 | case StudyRaw: 22 | return r.StudyInstanceUID != "" && r.SeriesInstanceUID == "" && r.SOPInstanceUID == "" 23 | case StudyRendered: 24 | return r.StudyInstanceUID != "" && r.SeriesInstanceUID == "" && r.SOPInstanceUID == "" 25 | case SeriesRaw: 26 | return r.StudyInstanceUID != "" && r.SeriesInstanceUID != "" && r.SOPInstanceUID == "" 27 | case SeriesRendered: 28 | return r.StudyInstanceUID != "" && r.SeriesInstanceUID != "" && r.SOPInstanceUID == "" 29 | case SeriesMetadata: 30 | return r.StudyInstanceUID != "" && r.SeriesInstanceUID != "" && r.SOPInstanceUID == "" 31 | case InstanceRaw: 32 | return r.StudyInstanceUID != "" && r.SeriesInstanceUID != "" && r.SOPInstanceUID != "" 33 | case InstanceRendered: 34 | return r.StudyInstanceUID != "" && r.SeriesInstanceUID != "" && r.SOPInstanceUID != "" 35 | case InstanceMetadata: 36 | return r.StudyInstanceUID != "" && r.SeriesInstanceUID != "" && r.SOPInstanceUID != "" 37 | case Frame: 38 | return r.StudyInstanceUID != "" && r.SeriesInstanceUID != "" && r.SOPInstanceUID != "" && r.FrameID != 0 39 | case URIReference: 40 | return r.RetrieveURL != "" 41 | } 42 | return false 43 | } 44 | 45 | // WADOType defines the object to query. 46 | type WADOType int 47 | 48 | const ( 49 | // StudyRaw raw study. 50 | StudyRaw WADOType = iota + 1 51 | // StudyRendered rendered study. 52 | StudyRendered 53 | // SeriesRaw raw series. 54 | SeriesRaw 55 | // SeriesRendered rendered series. 56 | SeriesRendered 57 | // SeriesMetadata series metadata. 58 | SeriesMetadata 59 | // InstanceRaw raw instance. 60 | InstanceRaw 61 | // InstanceRendered rendered instance. 62 | InstanceRendered 63 | // InstanceMetadata instance metadata. 64 | InstanceMetadata 65 | // Frame frame. 66 | Frame 67 | // URIReference URI reference. 68 | URIReference 69 | ) 70 | -------------------------------------------------------------------------------- /dicomweb/example_test.go: -------------------------------------------------------------------------------- 1 | package dicomweb_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "strconv" 8 | 9 | "github.com/toastcheng/dicomweb-go/dicomweb" 10 | ) 11 | 12 | func ExampleClient_Query_allStudy() { 13 | c := dicomweb.NewClient(dicomweb.ClientOption{ 14 | QIDOEndpoint: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", 15 | }) 16 | 17 | qido := dicomweb.QIDORequest{ 18 | Type: dicomweb.Study, 19 | } 20 | resp, err := c.Query(qido) 21 | if err != nil { 22 | fmt.Printf("faild to query: %v", err) 23 | return 24 | } 25 | fmt.Println(resp) 26 | } 27 | 28 | func ExampleClient_Query_certainStudy() { 29 | c := dicomweb.NewClient(dicomweb.ClientOption{ 30 | QIDOEndpoint: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", 31 | }) 32 | 33 | studyInstanceUID := "1.3.6.1.4.1.25403.345050719074.3824.20170125112931.11" 34 | qido := dicomweb.QIDORequest{ 35 | Type: dicomweb.Study, 36 | StudyInstanceUID: studyInstanceUID, 37 | } 38 | resp, err := c.Query(qido) 39 | if err != nil { 40 | fmt.Printf("faild to query: %v", err) 41 | return 42 | } 43 | fmt.Println(resp[0].StudyInstanceUID.Value[0].(string)) 44 | } 45 | 46 | func ExampleClient_Query_certainSeries() { 47 | c := dicomweb.NewClient(dicomweb.ClientOption{ 48 | QIDOEndpoint: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", 49 | }) 50 | 51 | studyInstanceUID := "1.3.6.1.4.1.25403.345050719074.3824.20170126085406.1" 52 | seriesInstanceUID := "2.25.720409440530442732085780991589110433975" 53 | qido := dicomweb.QIDORequest{ 54 | Type: dicomweb.Series, 55 | StudyInstanceUID: studyInstanceUID, 56 | SeriesInstanceUID: seriesInstanceUID, 57 | } 58 | resp, err := c.Query(qido) 59 | if err != nil { 60 | fmt.Printf("faild to query: %v", err) 61 | return 62 | } 63 | fmt.Println(resp) 64 | } 65 | 66 | func ExampleClient_Query_certainInstance() { 67 | c := dicomweb.NewClient(dicomweb.ClientOption{ 68 | QIDOEndpoint: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", 69 | }) 70 | 71 | studyInstanceUID := "1.3.6.1.4.1.25403.345050719074.3824.20170126085406.1" 72 | seriesInstanceUID := "2.25.687032174858108535882385160051760343725" 73 | instanceUID := "773645909590137995838355818619864160367" 74 | qido := dicomweb.QIDORequest{ 75 | Type: dicomweb.Instance, 76 | StudyInstanceUID: studyInstanceUID, 77 | SeriesInstanceUID: seriesInstanceUID, 78 | SOPInstanceUID: instanceUID, 79 | } 80 | resp, err := c.Query(qido) 81 | if err != nil { 82 | fmt.Printf("faild to query: %v", err) 83 | return 84 | } 85 | fmt.Println(resp) 86 | } 87 | 88 | func ExampleClient_Retrieve() { 89 | c := dicomweb.NewClient(dicomweb.ClientOption{ 90 | WADOEndpoint: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", 91 | }) 92 | 93 | studyInstanceUID := "1.3.6.1.4.1.25403.345050719074.3824.20170126085406.1" 94 | seriesInstanceUID := "1.3.6.1.4.1.25403.345050719074.3824.20170126085406.2" 95 | instanceUID := "1.3.6.1.4.1.25403.345050719074.3824.20170126085406.3" 96 | 97 | wado := dicomweb.WADORequest{ 98 | Type: dicomweb.InstanceRaw, 99 | StudyInstanceUID: studyInstanceUID, 100 | SeriesInstanceUID: seriesInstanceUID, 101 | SOPInstanceUID: instanceUID, 102 | FrameID: 1, 103 | } 104 | parts, err := c.Retrieve(wado) 105 | if err != nil { 106 | fmt.Printf("faild to query: %v", err) 107 | return 108 | } 109 | 110 | for i, p := range parts { 111 | // save it into file like this: 112 | err := ioutil.WriteFile("/tmp/test_"+strconv.Itoa(i)+".dcm", p, 0666) 113 | if err != nil { 114 | fmt.Printf("faild to retrieve: %v", err) 115 | return 116 | } 117 | } 118 | } 119 | 120 | func ExampleClient_Store() { 121 | c := dicomweb.NewClient(dicomweb.ClientOption{ 122 | STOWEndpoint: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", 123 | }) 124 | 125 | parts := [][]byte{} 126 | // read your data like this: 127 | for i := 0; i < 1; i++ { 128 | fname := fmt.Sprintf("/tmp/test_%d.dcm", i) 129 | b, err := ioutil.ReadFile(fname) 130 | if err != nil { 131 | log.Fatal(err) 132 | } 133 | parts = append(parts, b) 134 | } 135 | 136 | stow := dicomweb.STOWRequest{ 137 | StudyInstanceUID: "1.2.840.113820.0.20200429.174041.3", 138 | Parts: parts, 139 | } 140 | resp, err := c.Store(stow) 141 | if err != nil { 142 | fmt.Printf("faild to query: %v", err) 143 | return 144 | } 145 | fmt.Println(resp) 146 | } 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DICOMweb Go 2 | 3 | [![license](https://img.shields.io/badge/license-MIT-blue)](https://github.com/toastcheng/dicomweb-go/blob/master/LICENSE.md) 4 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/toastcheng/dicomweb-go/dicomweb)](https://pkg.go.dev/github.com/toastcheng/dicomweb-go/dicomweb) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/toastcheng/dicomweb-go)](https://goreportcard.com/report/github.com/toastcheng/dicomweb-go) 6 | [![Coverage Status](https://coveralls.io/repos/github/ToastCheng/dicomweb-go/badge.svg?branch=master)](https://coveralls.io/github/ToastCheng/dicomweb-go?branch=master) 7 | [![GitHub Actions](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Ftoastcheng%2Fdicomweb-go%2Fbadge&style=flat-square)](https://actions-badge.atrox.dev/toastcheng/dicomweb-go/goto) 8 | 9 | 10 | ## Introduction 11 | A DICOMweb client for Golang. 12 | 13 | There are plenty of packages that allow you to read DICOM files in Go whereas not much for communicating with DICOM server. 14 | 15 | Currently there are DICOM servers such as dcm4chee, Orthanc, etc., that support read/write DICOM by HTTP protocol, known as [DICOMweb](https://www.dicomstandard.org/dicomweb). 16 | 17 | This package provides a simple DICOMweb client that allows you to query DICOM info (QIDO), retrieve DICOM files (WADO), and store DICOM files (STOW). 18 | 19 | 20 | ## Documentation 21 | * pkg.go.dev : https://pkg.go.dev/github.com/toastcheng/dicomweb-go/dicomweb 22 | * Dicomweb : https://www.dicomstandard.org/dicomweb 23 | 24 | ## Getting Started 25 | ### Installation 26 | ``` 27 | go get github.com/toastcheng/dicomweb-go/dicomweb 28 | ``` 29 | 30 | ### Requirements 31 | * Go 1.12+ 32 | 33 | ### Quick Examples 34 | 35 | note: for demonstration, the endpoint is set to a `dcm4chee` server hosted by `dcmjs.org`. Change it to your DICOM server instead. 36 | #### Query all study 37 | ```go 38 | client := dicomweb.NewClient("https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs") 39 | 40 | qido := dicomweb.QIDORequest{ 41 | Type: dicomweb.Study, 42 | } 43 | resp, err := client.Query(qido) 44 | if err != nil { 45 | log.Fatalf("faild to query: %v", err) 46 | } 47 | ``` 48 | 49 | #### Query all series under specific study 50 | ```go 51 | 52 | client := dicomweb.NewClient(dicomweb.ClientOption{ 53 | QIDOEndpoint: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", 54 | WADOEndpoint: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", 55 | STOWEndpoint: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", 56 | }) 57 | 58 | studyInstanceUID := "1.3.6.1.4.1.25403.345050719074.3824.20170126085406.1" 59 | qido := dicomweb.QIDORequest{ 60 | Type: dicomweb.Series, 61 | StudyInstanceUID: studyInstanceUID, 62 | 63 | } 64 | resp, err := client.Query(qido) 65 | if err != nil { 66 | log.Fatalf("faild to query: %v", err) 67 | } 68 | log.Println(resp) 69 | ``` 70 | 71 | ##### Retrieve the DICOM file 72 | ```go 73 | client := dicomweb.NewClient(dicomweb.ClientOption{ 74 | QIDOEndpoint: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", 75 | WADOEndpoint: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", 76 | STOWEndpoint: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", 77 | }) 78 | studyInstanceUID := "1.3.6.1.4.1.25403.345050719074.3824.20170126085406.1" 79 | seriesInstanceUID := "1.3.6.1.4.1.25403.345050719074.3824.20170126085406.2" 80 | instanceUID := "1.3.6.1.4.1.25403.345050719074.3824.20170126085406.3" 81 | 82 | wado := dicomweb.WADORequest{ 83 | Type: dicomweb.InstanceRaw, 84 | StudyInstanceUID: studyInstanceUID, 85 | SeriesInstanceUID: seriesInstanceUID, 86 | SOPInstanceUID: instanceUID, 87 | FrameID: 1, 88 | } 89 | parts, err := client.Retrieve(wado) 90 | if err != nil { 91 | log.Fatalf("faild to query: %v", err) 92 | } 93 | 94 | for i, p := range parts { 95 | // save it into file like this: 96 | err := ioutil.WriteFile("/tmp/test_"+strconv.Itoa(i)+".dcm", p, 0666) 97 | if err != nil { 98 | log.Fatalf("faild to retrieve: %v", err) 99 | } 100 | } 101 | ``` 102 | 103 | ##### Store the DICOM file 104 | 105 | ```go 106 | client := dicomweb.NewClient(dicomweb.ClientOption{ 107 | STOWEndpoint: "https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs", 108 | }) 109 | 110 | parts := [][]byte{} 111 | // read your data like this: 112 | for i := 0; i < 10; i++ { 113 | fname := fmt.Sprintf("data_%d.dcm", i) 114 | b, err := ioutil.ReadFile(fname) 115 | if err != nil { 116 | log.Fatal(err) 117 | } 118 | parts = append(parts, b) 119 | } 120 | 121 | stow := dicomweb.STOWRequest{ 122 | StudyInstanceUID: "1.2.840.113820.0.20200429.174041.3", 123 | Parts: parts, 124 | } 125 | resp, err := c.Store(stow) 126 | if err != nil { 127 | log.Fatalf("faild to query: %v", err) 128 | } 129 | log.Println(resp) 130 | ``` 131 | 132 | ## Contributing 133 | 134 | This project is still in development, any contributions, issues and feature requests are welcome! 135 | Please check out the [issues page](https://github.com/toastcheng/dicomweb-go/issues). 136 | 137 | ## License 138 | 139 | `dicomweb-go` is available under the [MIT](https://github.com/toastcheng/dicomweb-go/blob/master/LICENSE.md) license. 140 | -------------------------------------------------------------------------------- /dicomweb/dicomweb.go: -------------------------------------------------------------------------------- 1 | package dicomweb 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "encoding/base64" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "log" 13 | "mime" 14 | "mime/multipart" 15 | "net/http" 16 | "net/textproto" 17 | "strconv" 18 | "strings" 19 | 20 | "github.com/philippfranke/multipart-related/related" 21 | ) 22 | 23 | // Client defines the client for connecting to dicom server. 24 | // For the naming of the member function such as Query, Retrieve, etc., see 25 | // https://www.dicomstandard.org/wp-content/uploads/2018/04/DICOMweb-Cheatsheet.pdf 26 | // for more detail. 27 | type Client struct { 28 | httpClient *http.Client 29 | qidoEndpoint string 30 | wadoEndpoint string 31 | stowEndpoint string 32 | authorization string 33 | boundary string 34 | optionFuncs *[]OptionFunc 35 | } 36 | 37 | // OptionFunc is a signature for methods which can modify dicom requests 38 | // before they are executed. And example would be to inject custom HTTP headers 39 | type OptionFunc func(*http.Request) error 40 | 41 | // ClientOption specifies the option for the DICOMweb client. 42 | type ClientOption struct { 43 | // QIDOEndpoint endpoint for QIDO. 44 | QIDOEndpoint string 45 | // WADOEndpoint endpoint for WADO. 46 | WADOEndpoint string 47 | // STOWEndpoint endpoint for STOW. 48 | STOWEndpoint string 49 | // HTTPClient to perform requests. Uses http.DefaultClient otherwise 50 | HTTPClient *http.Client 51 | // OptionFuncs is an array of OptionFunc which are called before each request 52 | OptionFuncs *[]OptionFunc 53 | } 54 | 55 | // WithAuthentication configures the client. 56 | func (c *Client) WithAuthentication(auth string) *Client { 57 | data := []byte(auth) 58 | authStr := "Basic " + base64.StdEncoding.EncodeToString(data) 59 | c.authorization = authStr 60 | return c 61 | } 62 | 63 | // WithInsecure create a http client that skip verifying, do not use it in production. 64 | func (c *Client) WithInsecure() *Client { 65 | tr := &http.Transport{ 66 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 67 | } 68 | client := &http.Client{Transport: tr} 69 | c.httpClient = client 70 | return c 71 | } 72 | 73 | // NewClient creates a new client. 74 | func NewClient(option ClientOption) *Client { 75 | httpClient := http.DefaultClient 76 | if option.HTTPClient != nil { 77 | httpClient = option.HTTPClient 78 | } 79 | return &Client{ 80 | httpClient: httpClient, 81 | optionFuncs: option.OptionFuncs, 82 | qidoEndpoint: option.QIDOEndpoint, 83 | wadoEndpoint: option.WADOEndpoint, 84 | stowEndpoint: option.STOWEndpoint, 85 | boundary: "dicomwebgoWxkTrZ", 86 | } 87 | } 88 | 89 | // Query based on QIDO, query a list of either matched studies, series or instances. 90 | func (c *Client) Query(req QIDORequest) ([]QIDOResponse, error) { 91 | url := c.qidoEndpoint 92 | switch req.Type { 93 | case Study: 94 | url += "/studies" 95 | case Series: 96 | url += "/studies/" + req.StudyInstanceUID 97 | url += "/series" 98 | case Instance: 99 | url += "/studies/" + req.StudyInstanceUID 100 | url += "/series/" + req.SeriesInstanceUID 101 | url += "/instances" 102 | default: 103 | return nil, errors.New("failed to query: need to specify query type") 104 | } 105 | 106 | r, err := http.NewRequest("GET", url, nil) 107 | if err != nil { 108 | return nil, err 109 | } 110 | q := r.URL.Query() 111 | mp := map[string]interface{}{} 112 | databytes, _ := json.Marshal(req) 113 | json.Unmarshal(databytes, &mp) 114 | 115 | for k, v := range mp { 116 | if k == "Type" || k == "0020000D" || k == "0020000E" || k == "00080018" { 117 | continue 118 | } 119 | switch t := v.(type) { 120 | case float64: 121 | q.Add(k, fmt.Sprintf("%.0f", t)) 122 | case string: 123 | q.Add(k, t) 124 | } 125 | } 126 | 127 | r.URL.RawQuery = q.Encode() 128 | 129 | if c.authorization != "" { 130 | r.Header.Set("Authorization", c.authorization) 131 | } 132 | if c.optionFuncs != nil { 133 | for _, fn := range *c.optionFuncs { 134 | if fn == nil { 135 | continue 136 | } 137 | if err := fn(r); err != nil { 138 | return nil, err 139 | } 140 | } 141 | } 142 | resp, err := c.httpClient.Do(r) 143 | if err != nil { 144 | return nil, err 145 | } 146 | defer resp.Body.Close() 147 | 148 | if resp.StatusCode/100 != 2 { 149 | return nil, errors.New(resp.Status) 150 | } 151 | 152 | result := []QIDOResponse{} 153 | json.NewDecoder(resp.Body).Decode(&result) 154 | return result, nil 155 | } 156 | 157 | // Retrieve based on WADO, retrieve the DICOM image of given id. 158 | func (c *Client) Retrieve(req WADORequest) ([][]byte, error) { 159 | if ok := req.Validate(); !ok { 160 | return nil, errors.New("parameters does not match the given type") 161 | } 162 | 163 | url := c.wadoEndpoint 164 | 165 | switch req.Type { 166 | case StudyRaw: 167 | url += "/studies/" + req.StudyInstanceUID 168 | case StudyRendered: 169 | url += "/studies/" + req.StudyInstanceUID 170 | url += "/rendered" 171 | case SeriesRaw: 172 | url += "/studies/" + req.StudyInstanceUID 173 | url += "/series/" + req.SeriesInstanceUID 174 | case SeriesRendered: 175 | url += "/studies/" + req.StudyInstanceUID 176 | url += "/series/" + req.SeriesInstanceUID 177 | url += "/rendered" 178 | case SeriesMetadata: 179 | url += "/studies/" + req.StudyInstanceUID 180 | url += "/series/" + req.SeriesInstanceUID 181 | url += "/metadata" 182 | case InstanceRaw: 183 | url += "/studies/" + req.StudyInstanceUID 184 | url += "/series/" + req.SeriesInstanceUID 185 | url += "/instances/" + req.SOPInstanceUID 186 | case InstanceRendered: 187 | url += "/studies/" + req.StudyInstanceUID 188 | url += "/series/" + req.SeriesInstanceUID 189 | url += "/instances/" + req.SOPInstanceUID 190 | url += "/rendered" 191 | case InstanceMetadata: 192 | url += "/studies/" + req.StudyInstanceUID 193 | url += "/series/" + req.SeriesInstanceUID 194 | url += "/instances/" + req.SOPInstanceUID 195 | url += "/metadata" 196 | case Frame: 197 | url += "/studies/" + req.StudyInstanceUID 198 | url += "/series/" + req.SeriesInstanceUID 199 | url += "/instances/" + req.SOPInstanceUID 200 | url += "/frames/" + strconv.Itoa(req.FrameID) 201 | case URIReference: 202 | url = req.RetrieveURL 203 | } 204 | 205 | r, err := http.NewRequest("GET", url, nil) 206 | if err != nil { 207 | return nil, err 208 | } 209 | if c.authorization != "" { 210 | r.Header.Set("Authorization", c.authorization) 211 | } 212 | if c.optionFuncs != nil { 213 | for _, fn := range *c.optionFuncs { 214 | if fn == nil { 215 | continue 216 | } 217 | if err := fn(r); err != nil { 218 | return nil, err 219 | } 220 | } 221 | } 222 | resp, err := c.httpClient.Do(r) 223 | if err != nil { 224 | return nil, err 225 | } 226 | defer resp.Body.Close() 227 | 228 | if resp.StatusCode/100 != 2 { 229 | // b, _ := ioutil.ReadAll(resp.Body) 230 | return nil, errors.New(resp.Status) 231 | } 232 | 233 | parts := [][]byte{} 234 | mediaType, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) 235 | if err != nil { 236 | return nil, err 237 | } 238 | if !strings.HasPrefix(mediaType, "multipart/") { 239 | return nil, errors.New("unexpected Content-Type, should be multipart/related") 240 | } 241 | 242 | if params["start"] == "" { 243 | mr := multipart.NewReader(resp.Body, params["boundary"]) 244 | for { 245 | p, err := mr.NextPart() 246 | if err == io.EOF { 247 | return parts, nil 248 | } else if err != nil { 249 | log.Fatalf("failed to read next multipart: %v", err) 250 | return nil, err 251 | } 252 | 253 | data, err := ioutil.ReadAll(p) 254 | if err != nil { 255 | log.Fatalf("failed to read multipart response: %v", err) 256 | return nil, err 257 | } 258 | parts = append(parts, data) 259 | } 260 | } else { 261 | r := related.NewReader(resp.Body, params) 262 | obj, err := r.ReadObject() 263 | if err != nil { 264 | return nil, err 265 | } 266 | for _, part := range obj.Values { 267 | data, err := ioutil.ReadAll(part) 268 | if err != nil { 269 | return nil, err 270 | } 271 | parts = append(parts, data) 272 | } 273 | } 274 | 275 | return parts, nil 276 | } 277 | 278 | // Store based on STOW, store the DICOM study to PACS server. 279 | func (c *Client) Store(req STOWRequest) (interface{}, error) { 280 | url := c.stowEndpoint + "/studies/" 281 | 282 | if req.StudyInstanceUID != "" { 283 | url += req.StudyInstanceUID 284 | } 285 | 286 | body := &bytes.Buffer{} 287 | writer := multipart.NewWriter(body) 288 | 289 | writer.SetBoundary(c.boundary) 290 | header := textproto.MIMEHeader{} 291 | header.Set("Content-Type", "application/dicom") 292 | 293 | for _, p := range req.Parts { 294 | w, err := writer.CreatePart(header) 295 | if err != nil { 296 | return nil, err 297 | } 298 | if _, err = w.Write(p); err != nil { 299 | return nil, err 300 | } 301 | } 302 | 303 | if err := writer.Close(); err != nil { 304 | return nil, err 305 | } 306 | 307 | r, err := http.NewRequest("POST", url, body) 308 | if err != nil { 309 | return nil, err 310 | } 311 | 312 | // The RFC 2045 doc states that certain values cannot be used as parameter values in the Content-Type header, 313 | // which includes '/', so the `application/dicom` needs to be wrapped by double quotes. 314 | r.Header.Set("Content-Type", fmt.Sprintf("multipart/related; type=\"application/dicom\"; boundary=%s", c.boundary)) 315 | if c.authorization != "" { 316 | r.Header.Set("Authorization", c.authorization) 317 | } 318 | if c.optionFuncs != nil { 319 | for _, fn := range *c.optionFuncs { 320 | if fn == nil { 321 | continue 322 | } 323 | if err := fn(r); err != nil { 324 | return nil, err 325 | } 326 | } 327 | } 328 | resp, err := c.httpClient.Do(r) 329 | if err != nil { 330 | return nil, err 331 | } 332 | 333 | var result interface{} 334 | json.NewDecoder(resp.Body).Decode(&result) 335 | 336 | return result, nil 337 | } 338 | -------------------------------------------------------------------------------- /dicomweb/dicomweb_test.go: -------------------------------------------------------------------------------- 1 | package dicomweb 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "mime" 9 | "mime/multipart" 10 | "net/http" 11 | "net/http/httptest" 12 | "testing" 13 | 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func TestClientWithAuthentication(t *testing.T) { 18 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | assert.Equal(t, "Basic dXNlcjpwYXNzd29yZA==", r.Header.Get("Authorization")) 20 | })) 21 | c := NewClient(ClientOption{ 22 | QIDOEndpoint: ts.URL, 23 | WADOEndpoint: ts.URL, 24 | STOWEndpoint: ts.URL, 25 | }).WithAuthentication("user:password") 26 | 27 | // just make an arbitrary request to mock server. 28 | qido := QIDORequest{ 29 | Type: Study, 30 | StudyInstanceUID: "study-instance-id", 31 | } 32 | c.Query(qido) 33 | } 34 | 35 | func TestClientWithInsecure(t *testing.T) { 36 | c := NewClient(ClientOption{}).WithInsecure() 37 | 38 | insecure := c.httpClient.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify 39 | assert.Equal(t, true, insecure) 40 | } 41 | 42 | func TestClientWithCustomHttpClient(t *testing.T) { 43 | custom := &http.Client{} 44 | c := NewClient(ClientOption{ 45 | HTTPClient: custom, 46 | }) 47 | assert.Equal(t, custom, c.httpClient) 48 | } 49 | 50 | func TestClientWithOptionFunc(t *testing.T) { 51 | bearer := "Bearer f2c45335-6bb1-4caf-99d1-7e0849bcad0d" 52 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 | assert.Equal(t, bearer, r.Header.Get("Authorization")) 54 | })) 55 | c := NewClient(ClientOption{ 56 | QIDOEndpoint: ts.URL, 57 | WADOEndpoint: ts.URL, 58 | STOWEndpoint: ts.URL, 59 | OptionFuncs: &[]OptionFunc{ 60 | func(req *http.Request) error { 61 | req.Header.Set("Authorization", bearer) 62 | return nil 63 | }, 64 | }, 65 | }) 66 | 67 | // just make an arbitrary request to mock server. 68 | qido := QIDORequest{ 69 | Type: Study, 70 | StudyInstanceUID: "study-instance-id", 71 | } 72 | c.Query(qido) 73 | } 74 | 75 | func TestClientWithFailingOptionFuncs(t *testing.T) { 76 | simulated := fmt.Errorf("simulated error") 77 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 78 | })) 79 | c := NewClient(ClientOption{ 80 | QIDOEndpoint: ts.URL, 81 | WADOEndpoint: ts.URL, 82 | STOWEndpoint: ts.URL, 83 | OptionFuncs: &[]OptionFunc{ 84 | nil, 85 | func(req *http.Request) error { 86 | return simulated 87 | }, 88 | }, 89 | }) 90 | 91 | _, err := c.Query(QIDORequest{ 92 | Type: Study, 93 | StudyInstanceUID: "study-instance-id", 94 | }) 95 | assert.Equal(t, simulated, err) 96 | _, err = c.Store(STOWRequest{}) 97 | assert.Equal(t, simulated, err) 98 | wado := WADORequest{ 99 | Type: StudyRaw, 100 | StudyInstanceUID: "test", 101 | } 102 | _, err = c.Retrieve(wado) 103 | assert.Equal(t, simulated, err) 104 | } 105 | 106 | func TestQIDOQueryAllStudy(t *testing.T) { 107 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 108 | assert.Equal(t, "/studies", r.URL.String()) 109 | })) 110 | 111 | c := NewClient(ClientOption{ 112 | QIDOEndpoint: ts.URL, 113 | }) 114 | 115 | qido := QIDORequest{ 116 | Type: Study, 117 | } 118 | _, err := c.Query(qido) 119 | assert.NoError(t, err) 120 | 121 | } 122 | 123 | func TestQIDOQueryAllStudyWithOptions(t *testing.T) { 124 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 125 | assert.Equal(t, "/studies?00080050=an&limit=1", r.URL.String()) 126 | })) 127 | 128 | c := NewClient(ClientOption{ 129 | QIDOEndpoint: ts.URL, 130 | }) 131 | 132 | qido := QIDORequest{ 133 | Type: Study, 134 | Limit: 1, 135 | AccessionNumber: "an", 136 | } 137 | _, err := c.Query(qido) 138 | assert.NoError(t, err) 139 | 140 | } 141 | 142 | func TestQIDOQuerySeries(t *testing.T) { 143 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 144 | assert.Equal(t, "/studies/study-id/series", r.URL.String()) 145 | })) 146 | 147 | c := NewClient(ClientOption{ 148 | QIDOEndpoint: ts.URL, 149 | }) 150 | 151 | studyInstanceUID := "study-id" 152 | qido := QIDORequest{ 153 | Type: Series, 154 | StudyInstanceUID: studyInstanceUID, 155 | } 156 | _, err := c.Query(qido) 157 | assert.NoError(t, err) 158 | } 159 | 160 | func TestQIDOQueryInstance(t *testing.T) { 161 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 162 | assert.Equal(t, "/studies/study-id/series/series-id/instances", r.URL.String()) 163 | })) 164 | c := NewClient(ClientOption{ 165 | QIDOEndpoint: ts.URL, 166 | }) 167 | 168 | studyInstanceUID := "study-id" 169 | seriesInstanceUID := "series-id" 170 | qido := QIDORequest{ 171 | Type: Instance, 172 | StudyInstanceUID: studyInstanceUID, 173 | SeriesInstanceUID: seriesInstanceUID, 174 | } 175 | _, err := c.Query(qido) 176 | assert.NoError(t, err) 177 | } 178 | 179 | func TestQIDOQueryUnspecifyType(t *testing.T) { 180 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 181 | assert.Equal(t, "/studies/study-id/series/series-id/instances", r.URL.String()) 182 | })) 183 | c := NewClient(ClientOption{ 184 | QIDOEndpoint: ts.URL, 185 | }) 186 | 187 | studyInstanceUID := "study-id" 188 | seriesInstanceUID := "series-id" 189 | qido := QIDORequest{ 190 | StudyInstanceUID: studyInstanceUID, 191 | SeriesInstanceUID: seriesInstanceUID, 192 | } 193 | _, err := c.Query(qido) 194 | if assert.Error(t, err) { 195 | assert.Equal(t, errors.New("failed to query: need to specify query type"), err) 196 | } 197 | } 198 | 199 | func TestQIDOQueryInternalServerError(t *testing.T) { 200 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 201 | w.WriteHeader(http.StatusInternalServerError) 202 | })) 203 | c := NewClient(ClientOption{ 204 | QIDOEndpoint: ts.URL, 205 | }) 206 | 207 | studyInstanceUID := "study-id" 208 | seriesInstanceUID := "series-id" 209 | qido := QIDORequest{ 210 | Type: Instance, 211 | StudyInstanceUID: studyInstanceUID, 212 | SeriesInstanceUID: seriesInstanceUID, 213 | } 214 | _, err := c.Query(qido) 215 | if assert.Error(t, err) { 216 | assert.Equal(t, errors.New("500 Internal Server Error"), err) 217 | } 218 | } 219 | 220 | func TestQIDOQueryInvalidURL(t *testing.T) { 221 | c := NewClient(ClientOption{ 222 | QIDOEndpoint: "%$^", 223 | }) 224 | 225 | qido := QIDORequest{ 226 | Type: Study, 227 | } 228 | _, err := c.Query(qido) 229 | if assert.Error(t, err) { 230 | assert.Contains(t, err.Error(), "invalid URL escape \"%$^\"") 231 | } 232 | } 233 | 234 | func TestWADORetrieveWithAuthenticate(t *testing.T) { 235 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 236 | boundary := "TOAST" 237 | w.Header().Set("Content-Type", fmt.Sprintf("multipart/related; type=\"application/dicom\"; boundary=%s", boundary)) 238 | fmt.Fprint(w, `--TOAST 239 | Content-Type: application/dicom 240 | 241 | part: 0 242 | --TOAST 243 | Content-Type: application/dicom 244 | 245 | part: 1 246 | --TOAST--`) 247 | })) 248 | c := NewClient(ClientOption{ 249 | WADOEndpoint: ts.URL, 250 | OptionFuncs: &[]OptionFunc{ 251 | func(req *http.Request) error { 252 | // Noop 253 | return nil 254 | }, 255 | }, 256 | }).WithAuthentication("user:name") 257 | 258 | studyInstanceUID := "study-id" 259 | 260 | wado := WADORequest{ 261 | Type: StudyRaw, 262 | StudyInstanceUID: studyInstanceUID, 263 | } 264 | _, err := c.Retrieve(wado) 265 | assert.NoError(t, err) 266 | } 267 | 268 | func TestWADOQueryInvalidURL(t *testing.T) { 269 | c := NewClient(ClientOption{ 270 | WADOEndpoint: "%$^", 271 | }) 272 | 273 | studyInstanceUID := "study-id" 274 | 275 | wado := WADORequest{ 276 | Type: StudyRaw, 277 | StudyInstanceUID: studyInstanceUID, 278 | } 279 | _, err := c.Retrieve(wado) 280 | if assert.Error(t, err) { 281 | assert.Contains(t, err.Error(), "invalid URL escape \"%$^\"") 282 | } 283 | } 284 | 285 | func TestWADORetrieveStudyRawWithStartAttribute(t *testing.T) { 286 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 287 | boundary := "TOAST" 288 | w.Header().Set("Content-Type", fmt.Sprintf("multipart/related; type=\"application/dicom\"; start=FIRST; boundary=%s", boundary)) 289 | fmt.Fprint(w, `--TOAST 290 | Content-Type: application/dicom 291 | Content-ID: FIRST 292 | 293 | part: 0 294 | --TOAST 295 | Content-Type: application/dicom 296 | Content-ID: SECOND 297 | 298 | part: 1 299 | --TOAST--`) 300 | })) 301 | c := NewClient(ClientOption{ 302 | WADOEndpoint: ts.URL, 303 | }) 304 | 305 | studyInstanceUID := "study-id" 306 | 307 | wado := WADORequest{ 308 | Type: StudyRaw, 309 | StudyInstanceUID: studyInstanceUID, 310 | } 311 | parts, err := c.Retrieve(wado) 312 | assert.NoError(t, err) 313 | 314 | for i, p := range parts { 315 | assert.Equal(t, fmt.Sprintf("part: %d", i), string(p)) 316 | } 317 | } 318 | 319 | func TestWADORetrieveStudyRaw(t *testing.T) { 320 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 321 | boundary := "TOAST" 322 | w.Header().Set("Content-Type", fmt.Sprintf("multipart/related; type=\"application/dicom\"; boundary=%s", boundary)) 323 | fmt.Fprint(w, `--TOAST 324 | Content-Type: application/dicom 325 | 326 | part: 0 327 | --TOAST 328 | Content-Type: application/dicom 329 | 330 | part: 1 331 | --TOAST--`) 332 | })) 333 | c := NewClient(ClientOption{ 334 | WADOEndpoint: ts.URL, 335 | }) 336 | 337 | studyInstanceUID := "study-id" 338 | 339 | wado := WADORequest{ 340 | Type: StudyRaw, 341 | StudyInstanceUID: studyInstanceUID, 342 | } 343 | parts, err := c.Retrieve(wado) 344 | assert.NoError(t, err) 345 | 346 | for i, p := range parts { 347 | assert.Equal(t, fmt.Sprintf("part: %d", i), string(p)) 348 | } 349 | } 350 | 351 | func TestWADORetrieveStudyRendered(t *testing.T) { 352 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 353 | boundary := "TOAST" 354 | w.Header().Set("Content-Type", fmt.Sprintf("multipart/related; type=\"application/dicom\"; boundary=%s", boundary)) 355 | fmt.Fprint(w, `--TOAST 356 | Content-Type: application/dicom 357 | 358 | part: 0 359 | --TOAST 360 | Content-Type: application/dicom 361 | 362 | part: 1 363 | --TOAST--`) 364 | })) 365 | c := NewClient(ClientOption{ 366 | WADOEndpoint: ts.URL, 367 | }) 368 | 369 | studyInstanceUID := "study-id" 370 | 371 | wado := WADORequest{ 372 | Type: StudyRendered, 373 | StudyInstanceUID: studyInstanceUID, 374 | } 375 | parts, err := c.Retrieve(wado) 376 | assert.NoError(t, err) 377 | 378 | for i, p := range parts { 379 | assert.Equal(t, fmt.Sprintf("part: %d", i), string(p)) 380 | } 381 | } 382 | 383 | func TestWADORetrieveSeriesRaw(t *testing.T) { 384 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 385 | boundary := "TOAST" 386 | w.Header().Set("Content-Type", fmt.Sprintf("multipart/related; type=\"application/dicom\"; boundary=%s", boundary)) 387 | fmt.Fprint(w, `--TOAST 388 | Content-Type: application/dicom 389 | 390 | part: 0 391 | --TOAST 392 | Content-Type: application/dicom 393 | 394 | part: 1 395 | --TOAST--`) 396 | })) 397 | c := NewClient(ClientOption{ 398 | WADOEndpoint: ts.URL, 399 | }) 400 | 401 | studyInstanceUID := "study-id" 402 | seriesInstanceUID := "series-id" 403 | 404 | wado := WADORequest{ 405 | Type: SeriesRaw, 406 | StudyInstanceUID: studyInstanceUID, 407 | SeriesInstanceUID: seriesInstanceUID, 408 | } 409 | parts, err := c.Retrieve(wado) 410 | assert.NoError(t, err) 411 | 412 | for i, p := range parts { 413 | assert.Equal(t, fmt.Sprintf("part: %d", i), string(p)) 414 | } 415 | } 416 | 417 | func TestWADORetrieveSeriesRendered(t *testing.T) { 418 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 419 | boundary := "TOAST" 420 | w.Header().Set("Content-Type", fmt.Sprintf("multipart/related; type=\"application/dicom\"; boundary=%s", boundary)) 421 | fmt.Fprint(w, `--TOAST 422 | Content-Type: application/dicom 423 | 424 | part: 0 425 | --TOAST 426 | Content-Type: application/dicom 427 | 428 | part: 1 429 | --TOAST--`) 430 | })) 431 | c := NewClient(ClientOption{ 432 | WADOEndpoint: ts.URL, 433 | }) 434 | 435 | studyInstanceUID := "study-id" 436 | seriesInstanceUID := "series-id" 437 | 438 | wado := WADORequest{ 439 | Type: SeriesRendered, 440 | StudyInstanceUID: studyInstanceUID, 441 | SeriesInstanceUID: seriesInstanceUID, 442 | } 443 | parts, err := c.Retrieve(wado) 444 | assert.NoError(t, err) 445 | 446 | for i, p := range parts { 447 | assert.Equal(t, fmt.Sprintf("part: %d", i), string(p)) 448 | } 449 | } 450 | 451 | func TestWADORetrieveSeriesMetadata(t *testing.T) { 452 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 453 | boundary := "TOAST" 454 | w.Header().Set("Content-Type", fmt.Sprintf("multipart/related; type=\"application/dicom\"; boundary=%s", boundary)) 455 | fmt.Fprint(w, `--TOAST 456 | Content-Type: application/dicom 457 | 458 | part: 0 459 | --TOAST 460 | Content-Type: application/dicom 461 | 462 | part: 1 463 | --TOAST--`) 464 | })) 465 | c := NewClient(ClientOption{ 466 | WADOEndpoint: ts.URL, 467 | }) 468 | 469 | studyInstanceUID := "study-id" 470 | seriesInstanceUID := "series-id" 471 | 472 | wado := WADORequest{ 473 | Type: SeriesMetadata, 474 | StudyInstanceUID: studyInstanceUID, 475 | SeriesInstanceUID: seriesInstanceUID, 476 | } 477 | parts, err := c.Retrieve(wado) 478 | assert.NoError(t, err) 479 | 480 | for i, p := range parts { 481 | assert.Equal(t, fmt.Sprintf("part: %d", i), string(p)) 482 | } 483 | } 484 | 485 | func TestWADORetrieveInstanceRaw(t *testing.T) { 486 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 487 | boundary := "TOAST" 488 | w.Header().Set("Content-Type", fmt.Sprintf("multipart/related; type=\"application/dicom\"; boundary=%s", boundary)) 489 | fmt.Fprint(w, `--TOAST 490 | Content-Type: application/dicom 491 | 492 | part: 0 493 | --TOAST 494 | Content-Type: application/dicom 495 | 496 | part: 1 497 | --TOAST--`) 498 | })) 499 | c := NewClient(ClientOption{ 500 | WADOEndpoint: ts.URL, 501 | }) 502 | 503 | studyInstanceUID := "study-id" 504 | seriesInstanceUID := "series-id" 505 | instanceUID := "instance-id" 506 | 507 | wado := WADORequest{ 508 | Type: InstanceRaw, 509 | StudyInstanceUID: studyInstanceUID, 510 | SeriesInstanceUID: seriesInstanceUID, 511 | SOPInstanceUID: instanceUID, 512 | } 513 | parts, err := c.Retrieve(wado) 514 | assert.NoError(t, err) 515 | 516 | for i, p := range parts { 517 | assert.Equal(t, fmt.Sprintf("part: %d", i), string(p)) 518 | } 519 | } 520 | 521 | func TestWADORetrieveInstanceRendered(t *testing.T) { 522 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 523 | boundary := "TOAST" 524 | w.Header().Set("Content-Type", fmt.Sprintf("multipart/related; type=\"application/dicom\"; boundary=%s", boundary)) 525 | fmt.Fprint(w, `--TOAST 526 | Content-Type: application/dicom 527 | 528 | part: 0 529 | --TOAST 530 | Content-Type: application/dicom 531 | 532 | part: 1 533 | --TOAST--`) 534 | })) 535 | c := NewClient(ClientOption{ 536 | WADOEndpoint: ts.URL, 537 | }) 538 | 539 | studyInstanceUID := "study-id" 540 | seriesInstanceUID := "series-id" 541 | instanceUID := "instance-id" 542 | 543 | wado := WADORequest{ 544 | Type: InstanceRendered, 545 | StudyInstanceUID: studyInstanceUID, 546 | SeriesInstanceUID: seriesInstanceUID, 547 | SOPInstanceUID: instanceUID, 548 | } 549 | parts, err := c.Retrieve(wado) 550 | assert.NoError(t, err) 551 | 552 | for i, p := range parts { 553 | assert.Equal(t, fmt.Sprintf("part: %d", i), string(p)) 554 | } 555 | } 556 | 557 | func TestWADORetrieveInstanceMetadata(t *testing.T) { 558 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 559 | boundary := "TOAST" 560 | w.Header().Set("Content-Type", fmt.Sprintf("multipart/related; type=\"application/dicom\"; boundary=%s", boundary)) 561 | fmt.Fprint(w, `--TOAST 562 | Content-Type: application/dicom 563 | 564 | part: 0 565 | --TOAST 566 | Content-Type: application/dicom 567 | 568 | part: 1 569 | --TOAST--`) 570 | })) 571 | c := NewClient(ClientOption{ 572 | WADOEndpoint: ts.URL, 573 | }) 574 | 575 | studyInstanceUID := "study-id" 576 | seriesInstanceUID := "series-id" 577 | instanceUID := "instance-id" 578 | 579 | wado := WADORequest{ 580 | Type: InstanceMetadata, 581 | StudyInstanceUID: studyInstanceUID, 582 | SeriesInstanceUID: seriesInstanceUID, 583 | SOPInstanceUID: instanceUID, 584 | } 585 | parts, err := c.Retrieve(wado) 586 | assert.NoError(t, err) 587 | 588 | for i, p := range parts { 589 | assert.Equal(t, fmt.Sprintf("part: %d", i), string(p)) 590 | } 591 | } 592 | 593 | func TestWADORetrieveFrame(t *testing.T) { 594 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 595 | boundary := "TOAST" 596 | w.Header().Set("Content-Type", fmt.Sprintf("multipart/related; type=\"application/dicom\"; boundary=%s", boundary)) 597 | fmt.Fprint(w, `--TOAST 598 | Content-Type: application/dicom 599 | 600 | part: 0 601 | --TOAST 602 | Content-Type: application/dicom 603 | 604 | part: 1 605 | --TOAST--`) 606 | })) 607 | c := NewClient(ClientOption{ 608 | WADOEndpoint: ts.URL, 609 | }) 610 | 611 | studyInstanceUID := "study-id" 612 | seriesInstanceUID := "series-id" 613 | instanceUID := "instance-id" 614 | 615 | wado := WADORequest{ 616 | Type: Frame, 617 | StudyInstanceUID: studyInstanceUID, 618 | SeriesInstanceUID: seriesInstanceUID, 619 | SOPInstanceUID: instanceUID, 620 | FrameID: 1, 621 | } 622 | parts, err := c.Retrieve(wado) 623 | assert.NoError(t, err) 624 | 625 | for i, p := range parts { 626 | assert.Equal(t, fmt.Sprintf("part: %d", i), string(p)) 627 | } 628 | } 629 | 630 | func TestWADORetrieveInternalServerError(t *testing.T) { 631 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 632 | w.WriteHeader(http.StatusInternalServerError) 633 | })) 634 | c := NewClient(ClientOption{ 635 | WADOEndpoint: ts.URL, 636 | }) 637 | 638 | studyInstanceUID := "study-id" 639 | seriesInstanceUID := "series-id" 640 | instanceUID := "instance-id" 641 | 642 | wado := WADORequest{ 643 | Type: InstanceRaw, 644 | StudyInstanceUID: studyInstanceUID, 645 | SeriesInstanceUID: seriesInstanceUID, 646 | SOPInstanceUID: instanceUID, 647 | FrameID: 1, 648 | } 649 | _, err := c.Retrieve(wado) 650 | if assert.Error(t, err) { 651 | assert.Equal(t, errors.New("500 Internal Server Error"), err) 652 | } 653 | } 654 | 655 | func TestWADORetrieveInternalUnsupportContentType(t *testing.T) { 656 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 657 | w.Header().Set("Content-Type", "application/json") 658 | })) 659 | c := NewClient(ClientOption{ 660 | WADOEndpoint: ts.URL, 661 | }) 662 | 663 | studyInstanceUID := "study-id" 664 | seriesInstanceUID := "series-id" 665 | instanceUID := "instance-id" 666 | 667 | wado := WADORequest{ 668 | Type: InstanceRaw, 669 | StudyInstanceUID: studyInstanceUID, 670 | SeriesInstanceUID: seriesInstanceUID, 671 | SOPInstanceUID: instanceUID, 672 | FrameID: 1, 673 | } 674 | _, err := c.Retrieve(wado) 675 | if assert.Error(t, err) { 676 | assert.Equal(t, errors.New("unexpected Content-Type, should be multipart/related"), err) 677 | } 678 | } 679 | 680 | func TestWADORetrieveInvalidRequest(t *testing.T) { 681 | c := NewClient(ClientOption{}) 682 | 683 | wado := WADORequest{ 684 | Type: InstanceRaw, 685 | } 686 | _, err := c.Retrieve(wado) 687 | if assert.Error(t, err) { 688 | assert.Equal(t, errors.New("parameters does not match the given type"), err) 689 | } 690 | } 691 | 692 | func TestWADOURIReference(t *testing.T) { 693 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 694 | assert.Equal(t, "/uri", r.URL.String()) 695 | })) 696 | c := NewClient(ClientOption{ 697 | WADOEndpoint: ts.URL, 698 | }) 699 | 700 | wado := WADORequest{ 701 | Type: URIReference, 702 | RetrieveURL: "/uri", 703 | } 704 | c.Retrieve(wado) 705 | } 706 | 707 | func TestWADOUnspecifiedType(t *testing.T) { 708 | c := NewClient(ClientOption{}) 709 | 710 | wado := WADORequest{} 711 | _, err := c.Retrieve(wado) 712 | if assert.Error(t, err) { 713 | assert.Equal(t, errors.New("parameters does not match the given type"), err) 714 | } 715 | } 716 | 717 | func TestSTOWStore(t *testing.T) { 718 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 719 | contentType, params, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) 720 | assert.Contains(t, contentType, "multipart/") 721 | 722 | multipartReader := multipart.NewReader(r.Body, params["boundary"]) 723 | defer r.Body.Close() 724 | 725 | idx := 0 726 | for { 727 | part, err := multipartReader.NextPart() 728 | if err == io.EOF { 729 | break 730 | } 731 | assert.NoError(t, err) 732 | defer part.Close() 733 | 734 | fileBytes, err := ioutil.ReadAll(part) 735 | assert.NoError(t, err) 736 | 737 | assert.Equal(t, fmt.Sprintf("part: %d", idx), string(fileBytes)) 738 | idx++ 739 | } 740 | })) 741 | 742 | c := NewClient(ClientOption{ 743 | STOWEndpoint: ts.URL, 744 | OptionFuncs: &[]OptionFunc{ 745 | func(req *http.Request) error { 746 | // Noop 747 | return nil 748 | }, 749 | }, 750 | }) 751 | 752 | parts := [][]byte{} 753 | for i := 0; i < 3; i++ { 754 | p := []byte(fmt.Sprintf("part: %d", i)) 755 | parts = append(parts, p) 756 | } 757 | 758 | stow := STOWRequest{ 759 | StudyInstanceUID: "1.2.840.113820.0.20200429.174041.3", 760 | Parts: parts, 761 | } 762 | _, err := c.Store(stow) 763 | assert.NoError(t, err) 764 | } 765 | 766 | func TestSTOWStoreWithAuthenticate(t *testing.T) { 767 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 768 | w.WriteHeader(http.StatusOK) 769 | })) 770 | 771 | c := NewClient(ClientOption{ 772 | STOWEndpoint: ts.URL, 773 | }).WithAuthentication("user:name") 774 | 775 | parts := [][]byte{} 776 | for i := 0; i < 3; i++ { 777 | p := []byte(fmt.Sprintf("part: %d", i)) 778 | parts = append(parts, p) 779 | } 780 | 781 | stow := STOWRequest{ 782 | StudyInstanceUID: "1.2.840.113820.0.20200429.174041.3", 783 | Parts: parts, 784 | } 785 | _, err := c.Store(stow) 786 | assert.NoError(t, err) 787 | } 788 | --------------------------------------------------------------------------------