From 0a37edf714ea3d3f2abd0ac091c086d9ab6c81e2 Mon Sep 17 00:00:00 2001 From: John Beisley Date: Thu, 24 Mar 2022 08:18:59 +0000 Subject: [PATCH] Add a basic SOAP client. --- v2/soap/client/client.go | 140 ++++++++++++++++++++++++++++++++++ v2/soap/client/client_test.go | 115 ++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 v2/soap/client/client.go create mode 100644 v2/soap/client/client_test.go diff --git a/v2/soap/client/client.go b/v2/soap/client/client.go new file mode 100644 index 0000000..1ea01b4 --- /dev/null +++ b/v2/soap/client/client.go @@ -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 +} diff --git a/v2/soap/client/client_test.go b/v2/soap/client/client_test.go new file mode 100644 index 0000000..3cb5d30 --- /dev/null +++ b/v2/soap/client/client_test.go @@ -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) + } +}