feat: restructure project layout and move SOAP implementation to internal package
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
package soap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Envelope represents a SOAP envelope
|
||||
type Envelope struct {
|
||||
XMLName xml.Name `xml:"http://www.w3.org/2003/05/soap-envelope Envelope"`
|
||||
Header *Header `xml:"http://www.w3.org/2003/05/soap-envelope Header,omitempty"`
|
||||
Body Body `xml:"http://www.w3.org/2003/05/soap-envelope Body"`
|
||||
}
|
||||
|
||||
// Header represents a SOAP header
|
||||
type Header struct {
|
||||
Security *Security `xml:"Security,omitempty"`
|
||||
}
|
||||
|
||||
// Body represents a SOAP body
|
||||
type Body struct {
|
||||
Content interface{} `xml:",omitempty"`
|
||||
Fault *Fault `xml:"Fault,omitempty"`
|
||||
}
|
||||
|
||||
// Fault represents a SOAP fault
|
||||
type Fault struct {
|
||||
XMLName xml.Name `xml:"http://www.w3.org/2003/05/soap-envelope Fault"`
|
||||
Code string `xml:"Code>Value"`
|
||||
Reason string `xml:"Reason>Text"`
|
||||
Detail string `xml:"Detail,omitempty"`
|
||||
}
|
||||
|
||||
// Security represents WS-Security header
|
||||
type Security struct {
|
||||
XMLName xml.Name `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd Security"`
|
||||
MustUnderstand string `xml:"http://www.w3.org/2003/05/soap-envelope mustUnderstand,attr,omitempty"`
|
||||
UsernameToken *UsernameToken `xml:"UsernameToken,omitempty"`
|
||||
}
|
||||
|
||||
// UsernameToken represents a WS-Security username token
|
||||
type UsernameToken struct {
|
||||
XMLName xml.Name `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd UsernameToken"`
|
||||
Username string `xml:"Username"`
|
||||
Password Password `xml:"Password"`
|
||||
Nonce Nonce `xml:"Nonce"`
|
||||
Created string `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd Created"`
|
||||
}
|
||||
|
||||
// Password represents a WS-Security password
|
||||
type Password struct {
|
||||
Type string `xml:"Type,attr"`
|
||||
Password string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// Nonce represents a WS-Security nonce
|
||||
type Nonce struct {
|
||||
Type string `xml:"EncodingType,attr"`
|
||||
Nonce string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// Client represents a SOAP client
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
username string
|
||||
password string
|
||||
debug bool
|
||||
logger func(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// NewClient creates a new SOAP client
|
||||
func NewClient(httpClient *http.Client, username, password string) *Client {
|
||||
return &Client{
|
||||
httpClient: httpClient,
|
||||
username: username,
|
||||
password: password,
|
||||
debug: false,
|
||||
logger: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDebug enables debug logging with a custom logger
|
||||
func (c *Client) SetDebug(enabled bool, logger func(format string, args ...interface{})) {
|
||||
c.debug = enabled
|
||||
c.logger = logger
|
||||
}
|
||||
|
||||
// logDebug logs debug information if debug mode is enabled
|
||||
func (c *Client) logDebug(format string, args ...interface{}) {
|
||||
if c.debug && c.logger != nil {
|
||||
c.logger(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Call makes a SOAP call to the specified endpoint
|
||||
func (c *Client) Call(ctx context.Context, endpoint string, action string, request interface{}, response interface{}) error {
|
||||
// Build SOAP envelope
|
||||
envelope := &Envelope{
|
||||
Body: Body{
|
||||
Content: request,
|
||||
},
|
||||
}
|
||||
|
||||
// Add security header if credentials are provided
|
||||
if c.username != "" && c.password != "" {
|
||||
envelope.Header = &Header{
|
||||
Security: c.createSecurityHeader(),
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal envelope to XML
|
||||
body, err := xml.MarshalIndent(envelope, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal SOAP envelope: %w", err)
|
||||
}
|
||||
|
||||
// Add XML declaration
|
||||
xmlBody := append([]byte(xml.Header), body...)
|
||||
|
||||
// Log request if debug is enabled
|
||||
c.logDebug("=== SOAP Request ===\nEndpoint: %s\nAction: %s\n%s\n", endpoint, action, string(xmlBody))
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(xmlBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8")
|
||||
if action != "" {
|
||||
req.Header.Set("SOAPAction", action)
|
||||
}
|
||||
|
||||
// Send request
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send HTTP request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// Read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Log response if debug is enabled
|
||||
c.logDebug("=== SOAP Response ===\nStatus: %d\n%s\n", resp.StatusCode, string(respBody))
|
||||
|
||||
// Check HTTP status
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP request failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// If response is empty, return immediately
|
||||
if len(respBody) == 0 {
|
||||
return fmt.Errorf("received empty response body")
|
||||
}
|
||||
|
||||
// Unmarshal response content if response is provided
|
||||
if response != nil {
|
||||
// Create a flexible envelope structure for parsing responses
|
||||
var envelope struct {
|
||||
Body struct {
|
||||
Content []byte `xml:",innerxml"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(respBody, &envelope); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal SOAP envelope: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal the body content into the response
|
||||
if err := xml.Unmarshal(envelope.Body.Content, response); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSecurityHeader creates a WS-Security header with username token digest
|
||||
func (c *Client) createSecurityHeader() *Security {
|
||||
// Generate nonce
|
||||
nonceBytes := make([]byte, 16)
|
||||
_, _ = rand.Read(nonceBytes) // rand.Read always returns len(nonceBytes), nil
|
||||
nonce := base64.StdEncoding.EncodeToString(nonceBytes)
|
||||
|
||||
// Get current timestamp
|
||||
created := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
// Calculate password digest: Base64(SHA1(nonce + created + password))
|
||||
hash := sha1.New()
|
||||
hash.Write(nonceBytes)
|
||||
hash.Write([]byte(created))
|
||||
hash.Write([]byte(c.password))
|
||||
digest := base64.StdEncoding.EncodeToString(hash.Sum(nil))
|
||||
|
||||
return &Security{
|
||||
MustUnderstand: "1",
|
||||
UsernameToken: &UsernameToken{
|
||||
Username: c.username,
|
||||
Password: Password{
|
||||
Type: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest",
|
||||
Password: digest,
|
||||
},
|
||||
Nonce: Nonce{
|
||||
Type: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary",
|
||||
Nonce: nonce,
|
||||
},
|
||||
Created: created,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildEnvelope builds a SOAP envelope with the given body content
|
||||
func BuildEnvelope(body interface{}, username, password string) (*Envelope, error) {
|
||||
envelope := &Envelope{
|
||||
Body: Body{
|
||||
Content: body,
|
||||
},
|
||||
}
|
||||
|
||||
if username != "" && password != "" {
|
||||
client := &Client{username: username, password: password}
|
||||
envelope.Header = &Header{
|
||||
Security: client.createSecurityHeader(),
|
||||
}
|
||||
}
|
||||
|
||||
return envelope, nil
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package soap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
}{
|
||||
{
|
||||
name: "with credentials",
|
||||
username: "admin",
|
||||
password: "password123",
|
||||
},
|
||||
{
|
||||
name: "without credentials",
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
client := NewClient(httpClient, tt.username, tt.password)
|
||||
|
||||
if client == nil {
|
||||
t.Fatal("NewClient() returned nil")
|
||||
}
|
||||
|
||||
if client.username != tt.username {
|
||||
t.Errorf("username = %v, want %v", client.username, tt.username)
|
||||
}
|
||||
|
||||
if client.password != tt.password {
|
||||
t.Errorf("password = %v, want %v", client.password, tt.password)
|
||||
}
|
||||
|
||||
if client.httpClient != httpClient {
|
||||
t.Error("httpClient not set correctly")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEnvelope(t *testing.T) {
|
||||
type testRequest struct {
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body interface{}
|
||||
username string
|
||||
password string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "with authentication",
|
||||
body: &testRequest{Value: "test"},
|
||||
username: "admin",
|
||||
password: "password",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "without authentication",
|
||||
body: &testRequest{Value: "test"},
|
||||
username: "",
|
||||
password: "",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
envelope, err := BuildEnvelope(tt.body, tt.username, tt.password)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("BuildEnvelope() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if envelope == nil {
|
||||
t.Fatal("BuildEnvelope() returned nil envelope")
|
||||
}
|
||||
|
||||
if tt.username != "" && envelope.Header == nil {
|
||||
t.Error("Expected Header to be set with credentials")
|
||||
}
|
||||
|
||||
if tt.username == "" && envelope.Header != nil {
|
||||
t.Error("Expected Header to be nil without credentials")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupServer func(*testing.T) *httptest.Server
|
||||
username string
|
||||
password string
|
||||
wantErr bool
|
||||
wantStatusCode int
|
||||
}{
|
||||
{
|
||||
name: "successful request",
|
||||
setupServer: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/soap+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0"?>
|
||||
<Envelope xmlns="http://www.w3.org/2003/05/soap-envelope">
|
||||
<Body>
|
||||
<TestResponse>
|
||||
<Value>success</Value>
|
||||
</TestResponse>
|
||||
</Body>
|
||||
</Envelope>`))
|
||||
}))
|
||||
},
|
||||
username: "admin",
|
||||
password: "password",
|
||||
wantErr: false,
|
||||
wantStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "unauthorized request",
|
||||
setupServer: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
},
|
||||
username: "admin",
|
||||
password: "wrong",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "http error status",
|
||||
setupServer: func(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Internal Server Error"))
|
||||
}))
|
||||
},
|
||||
username: "admin",
|
||||
password: "password",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := tt.setupServer(t)
|
||||
defer server.Close()
|
||||
|
||||
httpClient := &http.Client{Timeout: 5 * time.Second}
|
||||
client := NewClient(httpClient, tt.username, tt.password)
|
||||
|
||||
type testRequest struct {
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
|
||||
type testResponse struct {
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
|
||||
req := &testRequest{Value: "test"}
|
||||
var resp testResponse
|
||||
|
||||
ctx := context.Background()
|
||||
err := client.Call(ctx, server.URL, "", req, &resp)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Call() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCallWithTimeout(t *testing.T) {
|
||||
// Server that delays response
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
httpClient := &http.Client{Timeout: 5 * time.Second}
|
||||
client := NewClient(httpClient, "admin", "password")
|
||||
|
||||
type testRequest struct {
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
|
||||
req := &testRequest{Value: "test"}
|
||||
var resp interface{}
|
||||
|
||||
// Context with very short timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := client.Call(ctx, server.URL, "", req, &resp)
|
||||
if err == nil {
|
||||
t.Error("Expected timeout error, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaderCreation(t *testing.T) {
|
||||
httpClient := &http.Client{}
|
||||
client := NewClient(httpClient, "testuser", "testpass")
|
||||
|
||||
security := client.createSecurityHeader()
|
||||
|
||||
if security == nil {
|
||||
t.Fatal("createSecurityHeader() returned nil")
|
||||
}
|
||||
|
||||
if security.UsernameToken == nil {
|
||||
t.Fatal("UsernameToken is nil")
|
||||
}
|
||||
|
||||
if security.UsernameToken.Username != "testuser" {
|
||||
t.Errorf("Username = %v, want %v", security.UsernameToken.Username, "testuser")
|
||||
}
|
||||
|
||||
if security.UsernameToken.Password.Type != "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest" {
|
||||
t.Error("Password type not set correctly")
|
||||
}
|
||||
|
||||
if security.UsernameToken.Nonce.Type != "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary" {
|
||||
t.Error("Nonce type not set correctly")
|
||||
}
|
||||
|
||||
if security.UsernameToken.Created == "" {
|
||||
t.Error("Created timestamp is empty")
|
||||
}
|
||||
|
||||
if security.UsernameToken.Password.Password == "" {
|
||||
t.Error("Password digest is empty")
|
||||
}
|
||||
|
||||
if security.UsernameToken.Nonce.Nonce == "" {
|
||||
t.Error("Nonce is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNewClient(b *testing.B) {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = NewClient(httpClient, "admin", "password")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBuildEnvelope(b *testing.B) {
|
||||
type testRequest struct {
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
req := &testRequest{Value: "test"}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = BuildEnvelope(req, "admin", "password")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCreateSecurityHeader(b *testing.B) {
|
||||
httpClient := &http.Client{}
|
||||
client := NewClient(httpClient, "admin", "password")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = client.createSecurityHeader()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user