Add a basic SOAP client.

This commit is contained in:
John Beisley 2022-03-24 08:18:59 +00:00 committed by Huin
parent 06ea566a85
commit 0a37edf714
2 changed files with 255 additions and 0 deletions

140
v2/soap/client/client.go Normal file
View File

@ -0,0 +1,140 @@
// Package client provides a basic SOAP client.
package client
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"github.com/huin/goupnp/v2/soap/envelope"
)
// HttpClient defines the interface required of an HTTP client. It is a subset of *http.Client.
type HttpClient interface {
Do(req *http.Request) (*http.Response, error)
}
// Option is the type for optional configuration of a Client.
type Option func(*options)
// WithHTTPClient specifies an *http.Client to use instead of
// http.DefaultClient.
func WithHTTPClient(httpClient HttpClient) Option {
return func(o *options) {
o.httpClient = httpClient
}
}
type options struct {
httpClient HttpClient
}
// Client is a SOAP client, attached to a specific SOAP endpoint.
// the zero value is not usable, use NewClient() to create an instance.
type Client struct {
httpClient HttpClient
endpointURL string
maxErrorResponseBytes int
}
// New creates a new SOAP client, which will POST its requests to the
// given URL.
func New(endpointURL string, opts ...Option) *Client {
co := options{
httpClient: http.DefaultClient,
}
for _, opt := range opts {
opt(&co)
}
return &Client{
httpClient: co.httpClient,
endpointURL: endpointURL,
}
}
// PerformAction makes a SOAP request, with the given action values to provide
// arguments (`args`) and capture the `reply` into. If `client` is nil, then
// http.DefaultClient is used.
func (c *Client) PerformAction(
ctx context.Context,
args, reply *envelope.Action,
) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpointURL, nil)
if err != nil {
return err
}
if err := SetRequestAction(req, args); err != nil {
return err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("SOAP request got HTTP %s (%d)",
resp.Status, resp.StatusCode)
}
return ParseResponseAction(resp, reply)
}
// SetRequestAction updates fields in `req` with the given SOAP action.
// Specifically it sets Body, ContentLength, Method, and the SOAPACTION and
// CONTENT-TYPE headers.
func SetRequestAction(req *http.Request, args *envelope.Action) error {
buf := &bytes.Buffer{}
err := envelope.Write(buf, args)
if err != nil {
return fmt.Errorf("encoding envelope: %w", err)
}
req.Body = io.NopCloser(buf)
req.ContentLength = int64(buf.Len())
req.Method = http.MethodPost
req.Header["SOAPACTION"] = []string{fmt.Sprintf(
`"%s#%s"`, args.XMLName.Space, args.XMLName.Local)}
req.Header["CONTENT-TYPE"] = []string{`text/xml; charset="utf-8"`}
return nil
}
// ParseResponse extracts a parsed action from an HTTP response.
// The caller is responsible for calling resp.Body.Close(), but this function
// will consume the entire response body.
func ParseResponseAction(resp *http.Response, reply *envelope.Action) error {
if resp.Body == nil {
return errors.New("missing response body")
}
buf := &bytes.Buffer{}
if _, err := io.Copy(buf, resp.Body); err != nil {
return fmt.Errorf("reading response body: %w", err)
}
if err := envelope.Read(buf, reply); err != nil {
if _, ok := err.(*envelope.Fault); ok {
// Parsed cleanly, got SOAP fault.
return err
}
// Parsing problem, provide some information for context.
dispLen := buf.Len()
truncMessage := ""
if dispLen > 1024 {
dispLen = 1024
truncMessage = fmt.Sprintf("first %d bytes: ", dispLen)
}
return fmt.Errorf(
"parsing response body (%s%q): %w",
truncMessage, buf.Bytes()[:dispLen],
err,
)
}
return nil
}

View File

@ -0,0 +1,115 @@
package client
import (
"context"
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/huin/goupnp/v2/soap/envelope"
)
type ActionArgs struct {
Name string
}
type ActionReply struct {
Greeting string
}
type actionKey struct {
endpointURL string
action string
}
var _ http.Handler = &fakeSoapServer{}
type fakeSoapServer struct {
responses map[actionKey]*envelope.Action
errors []error
}
func (fss *fakeSoapServer) badRequest(w http.ResponseWriter, err error) {
fss.errors = append(fss.errors, err)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(err.Error()))
}
func (fss *fakeSoapServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fss.badRequest(w, fmt.Errorf("want POST, got %q", r.Method))
return
}
actions := r.Header.Values("SOAPACTION")
if len(actions) != 1 {
fss.badRequest(w, fmt.Errorf("want exactly 1 SOAPACTION, got %d: %q", len(actions), actions))
return
}
headerAction := actions[0]
key := actionKey{
endpointURL: r.URL.Path,
action: headerAction,
}
response, ok := fss.responses[key]
if !ok {
fss.badRequest(w, fmt.Errorf("no response known for %#v", key))
return
}
reqArgs := &ActionArgs{}
reqAction := envelope.Action{Args: reqArgs}
if err := envelope.Read(r.Body, &reqAction); err != nil {
fss.badRequest(w, fmt.Errorf("reading envelope from request: %w", err))
return
}
envelopeAction := fmt.Sprintf("\"%s#%s\"", reqAction.XMLName.Space, reqAction.XMLName.Local)
if envelopeAction != headerAction {
fss.badRequest(w, fmt.Errorf("mismatch in header/envelope action: %q/%q", headerAction, envelopeAction))
return
}
w.Header().Add("CONTENT-TYPE", `text/xml; charset="utf-8"`)
if err := envelope.Write(w, response); err != nil {
fss.errors = append(fss.errors, fmt.Errorf("writing envelope: %w", err))
}
}
func TestPerformAction(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Cleanup(cancel)
service := &fakeSoapServer{
responses: map[actionKey]*envelope.Action{
{"/endpointpath", "\"http://example.com/endpointns#Foo\""}: {
Args: &ActionReply{Greeting: "Hello, World!"},
},
},
}
ts := httptest.NewServer(service)
t.Cleanup(ts.Close)
c := New(ts.URL + "/endpointpath")
reqAction := &envelope.Action{
XMLName: xml.Name{Space: "http://example.com/endpointns", Local: "Foo"},
Args: &ActionArgs{
Name: "World",
},
}
reply := &ActionReply{}
replyAction := &envelope.Action{Args: reply}
if err := c.PerformAction(ctx, reqAction, replyAction); err != nil {
t.Errorf("got error: %v, want success", err)
} else {
if got, want := reply.Greeting, "Hello, World!"; got != want {
t.Errorf("got %q, want %q", got, want)
}
}
for _, err := range service.errors {
t.Errorf("Service error: %v", err)
}
}