// Definition for the SOAP structure required for UPnP's SOAP usage. package soap import ( "bytes" "context" "encoding/xml" "fmt" "io/ioutil" "net/http" "net/url" "reflect" "regexp" ) const ( soapEncodingStyle = "http://schemas.xmlsoap.org/soap/encoding/" soapPrefix = xml.Header + `` soapSuffix = `` ) type SOAPClient struct { EndpointURL url.URL HTTPClient http.Client } func NewSOAPClient(endpointURL url.URL) *SOAPClient { return &SOAPClient{ EndpointURL: endpointURL, } } // PerformSOAPAction makes a SOAP request, with the given action. // inAction and outAction must both be pointers to structs with string fields // only. func (client *SOAPClient) PerformActionCtx(ctx context.Context, actionNamespace, actionName string, inAction interface{}, outAction interface{}) error { requestBytes, err := encodeRequestAction(actionNamespace, actionName, inAction) if err != nil { return err } req := &http.Request{ Method: "POST", URL: &client.EndpointURL, Header: http.Header{ "SOAPACTION": []string{`"` + actionNamespace + "#" + actionName + `"`}, "CONTENT-TYPE": []string{"text/xml; charset=\"utf-8\""}, }, Body: ioutil.NopCloser(bytes.NewBuffer(requestBytes)), // Set ContentLength to avoid chunked encoding - some servers might not support it. ContentLength: int64(len(requestBytes)), } req = req.WithContext(ctx) response, err := client.HTTPClient.Do(req) if err != nil { return fmt.Errorf("goupnp: error performing SOAP HTTP request: %v", err) } defer response.Body.Close() if response.StatusCode != 200 && response.ContentLength == 0 { return fmt.Errorf("goupnp: SOAP request got HTTP %s", response.Status) } responseEnv := newSOAPEnvelope() decoder := xml.NewDecoder(response.Body) if err := decoder.Decode(responseEnv); err != nil { return fmt.Errorf("goupnp: error decoding response body: %v", err) } if responseEnv.Body.Fault != nil { return responseEnv.Body.Fault } else if response.StatusCode != 200 { return fmt.Errorf("goupnp: SOAP request got HTTP %s", response.Status) } if outAction != nil { if err := xml.Unmarshal(responseEnv.Body.RawAction, outAction); err != nil { return fmt.Errorf("goupnp: error unmarshalling out action: %v, %v", err, responseEnv.Body.RawAction) } } return nil } // PerformAction is the legacy version of PerformActionCtx, which uses // context.Background. func (client *SOAPClient) PerformAction(actionNamespace, actionName string, inAction interface{}, outAction interface{}) error { return client.PerformActionCtx(context.Background(), actionNamespace, actionName, inAction, outAction) } // newSOAPAction creates a soapEnvelope with the given action and arguments. func newSOAPEnvelope() *soapEnvelope { return &soapEnvelope{ EncodingStyle: soapEncodingStyle, } } // encodeRequestAction is a hacky way to create an encoded SOAP envelope // containing the given action. Experiments with one router have shown that it // 500s for requests where the outer default xmlns is set to the SOAP // namespace, and then reassigning the default namespace within that to the // service namespace. Hand-coding the outer XML to work-around this. func encodeRequestAction(actionNamespace, actionName string, inAction interface{}) ([]byte, error) { requestBuf := new(bytes.Buffer) requestBuf.WriteString(soapPrefix) requestBuf.WriteString(``) if inAction != nil { if err := encodeRequestArgs(requestBuf, inAction); err != nil { return nil, err } } requestBuf.WriteString(``) requestBuf.WriteString(soapSuffix) return requestBuf.Bytes(), nil } func encodeRequestArgs(w *bytes.Buffer, inAction interface{}) error { in := reflect.Indirect(reflect.ValueOf(inAction)) if in.Kind() != reflect.Struct { return fmt.Errorf("goupnp: SOAP inAction is not a struct but of type %v", in.Type()) } enc := xml.NewEncoder(w) nFields := in.NumField() inType := in.Type() for i := 0; i < nFields; i++ { field := inType.Field(i) argName := field.Name if nameOverride := field.Tag.Get("soap"); nameOverride != "" { argName = nameOverride } value := in.Field(i) if value.Kind() != reflect.String { return fmt.Errorf("goupnp: SOAP arg %q is not of type string, but of type %v", argName, value.Type()) } elem := xml.StartElement{Name: xml.Name{Space: "", Local: argName}, Attr: nil} if err := enc.EncodeToken(elem); err != nil { return fmt.Errorf("goupnp: error encoding start element for SOAP arg %q: %v", argName, err) } if err := enc.Flush(); err != nil { return fmt.Errorf("goupnp: error flushing start element for SOAP arg %q: %v", argName, err) } if _, err := w.Write([]byte(escapeXMLText(value.Interface().(string)))); err != nil { return fmt.Errorf("goupnp: error writing value for SOAP arg %q: %v", argName, err) } if err := enc.EncodeToken(elem.End()); err != nil { return fmt.Errorf("goupnp: error encoding end element for SOAP arg %q: %v", argName, err) } } enc.Flush() return nil } var xmlCharRx = regexp.MustCompile("[<>&]") // escapeXMLText is used by generated code to escape text in XML, but only // escaping the characters `<`, `>`, and `&`. // // This is provided in order to work around SOAP server implementations that // fail to decode XML correctly, specifically failing to decode `"`, `'`. Note // that this can only be safely used for injecting into XML text, but not into // attributes or other contexts. func escapeXMLText(s string) string { return xmlCharRx.ReplaceAllStringFunc(s, replaceEntity) } func replaceEntity(s string) string { switch s { case "<": return "<" case ">": return ">" case "&": return "&" } return s } type soapEnvelope struct { XMLName xml.Name `xml:"http://schemas.xmlsoap.org/soap/envelope/ Envelope"` EncodingStyle string `xml:"http://schemas.xmlsoap.org/soap/envelope/ encodingStyle,attr"` Body soapBody `xml:"http://schemas.xmlsoap.org/soap/envelope/ Body"` } type soapBody struct { Fault *SOAPFaultError `xml:"Fault"` RawAction []byte `xml:",innerxml"` } // SOAPFaultError implements error, and contains SOAP fault information. type SOAPFaultError struct { FaultCode string `xml:"faultCode"` FaultString string `xml:"faultString"` Detail struct { Raw []byte `xml:",innerxml"` } `xml:"detail"` } func (err *SOAPFaultError) Error() string { return fmt.Sprintf("SOAP fault: %s", err.FaultString) }