feat: restructure project layout and move SOAP implementation to internal package

This commit is contained in:
ProtoTess
2025-11-12 19:13:36 +00:00
parent 64ce3192a4
commit 52352dacd4
10 changed files with 431 additions and 6 deletions
+241
View File
@@ -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
}
+284
View File
@@ -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()
}
}