Powered by AppSignal & Oban Pro

Getting Started with Lather SOAP Library

livebooks/getting_started.livemd

Getting Started with Lather SOAP Library

Mix.install([
  {:lather, "~> 1.0"},
  {:bandit, "~> 1.0"},
  {:kino, "~> 0.12"}
])

Introduction

Welcome to the Lather SOAP Library interactive tutorial! Lather is a full-featured SOAP library for Elixir that supports both client and server functionality.

In this Livebook, you’ll learn:

  • How to create a SOAP server with Lather
  • How to connect to SOAP services using WSDL
  • Making SOAP calls with dynamic parameters
  • Handling responses and errors

Creating a SOAP Server

First, let’s define a simple Calculator SOAP service that we’ll use throughout this tutorial:

defmodule CalculatorService do
  use Lather.Server

  @namespace "http://example.com/calculator"
  @service_name "CalculatorService"

  soap_operation "Add" do
    description "Adds two numbers together"

    input do
      parameter "a", :decimal, required: true, description: "First number"
      parameter "b", :decimal, required: true, description: "Second number"
    end

    output do
      parameter "result", :decimal, description: "Sum of a and b"
    end

    soap_action "Add"
  end

  def add(%{"a" => a, "b" => b}) do
    num_a = parse_number(a)
    num_b = parse_number(b)
    {:ok, %{"result" => num_a + num_b}}
  end

  soap_operation "Subtract" do
    description "Subtracts the second number from the first"

    input do
      parameter "a", :decimal, required: true
      parameter "b", :decimal, required: true
    end

    output do
      parameter "result", :decimal
    end

    soap_action "Subtract"
  end

  def subtract(%{"a" => a, "b" => b}) do
    {:ok, %{"result" => parse_number(a) - parse_number(b)}}
  end

  soap_operation "Multiply" do
    description "Multiplies two numbers"

    input do
      parameter "a", :decimal, required: true
      parameter "b", :decimal, required: true
    end

    output do
      parameter "result", :decimal
    end

    soap_action "Multiply"
  end

  def multiply(%{"a" => a, "b" => b}) do
    {:ok, %{"result" => parse_number(a) * parse_number(b)}}
  end

  soap_operation "Divide" do
    description "Divides the first number by the second"

    input do
      parameter "dividend", :decimal, required: true
      parameter "divisor", :decimal, required: true
    end

    output do
      parameter "quotient", :decimal
    end

    soap_action "Divide"
  end

  def divide(%{"dividend" => dividend, "divisor" => divisor}) do
    d = parse_number(divisor)

    if d == 0 do
      Lather.Server.soap_fault("Client", "Division by zero is not allowed")
    else
      {:ok, %{"quotient" => parse_number(dividend) / d}}
    end
  end

  defp parse_number(val) when is_number(val), do: val
  defp parse_number(val) when is_binary(val) do
    case Float.parse(val) do
      {num, _} -> num
      :error -> String.to_integer(val)
    end
  end
end

IO.puts("Calculator service module defined!")

Starting the SOAP Server

Now let’s start our Calculator service on a local port. We’ll use EnhancedPlug which provides an interactive web interface for testing operations - similar to .NET ASMX web services.

# Start required applications
{:ok, _} = Application.ensure_all_started(:lather)
{:ok, _} = Application.ensure_all_started(:bandit)

# Define a simple Plug router for our service using EnhancedPlug
defmodule CalculatorRouter do
  use Plug.Router

  plug :match
  plug :dispatch

  # EnhancedPlug provides web forms, WSDL, and multi-protocol support
  forward "/calculator",
    to: Lather.Server.EnhancedPlug,
    init_opts: [service: CalculatorService, base_path: "/calculator"]

  match _ do
    send_resp(conn, 404, "Not found")
  end
end

# Stop any existing server on this port
case Process.whereis(:calculator_server) do
  nil -> :ok
  pid -> Supervisor.stop(pid)
end

# Start the server
{:ok, pid} = Bandit.start_link(plug: CalculatorRouter, port: 4040, scheme: :http)
Process.register(pid, :calculator_server)

IO.puts("Calculator SOAP server started!")
IO.puts("")
IO.puts("Browse to these URLs:")
IO.puts("  http://localhost:4040/calculator          - Service overview with operation list")
IO.puts("  http://localhost:4040/calculator?op=Add   - Interactive form to test Add operation")
IO.puts("  http://localhost:4040/calculator?wsdl     - WSDL document")
IO.puts("")
IO.puts("API Endpoints:")
IO.puts("  POST /calculator       - SOAP 1.1")
IO.puts("  POST /calculator/v1.2  - SOAP 1.2")
IO.puts("  POST /calculator/api   - JSON/REST")

Connecting as a SOAP Client

Now let’s connect to our own service using the DynamicClient:

# Create a dynamic client from the WSDL
wsdl_url = "http://localhost:4040/calculator?wsdl"

calculator_client = case Lather.DynamicClient.new(wsdl_url, timeout: 30_000) do
  {:ok, client} ->
    IO.puts("✅ Successfully connected to Calculator service!")

    # Show available operations
    operations = Lather.DynamicClient.list_operations(client)
    IO.puts("\nAvailable operations:")
    Enum.each(operations, fn op -> IO.puts("   • #{op.name}") end)

    client

  {:error, error} ->
    IO.puts("❌ Failed to connect: #{inspect(error)}")
    IO.puts("\nMake sure the server cell above has been run first!")
    nil
end

Exploring Service Operations

Let’s examine the details of available operations:

if calculator_client do
  # Get detailed information about an operation
  case Lather.DynamicClient.get_operation_info(calculator_client, "Add") do
    {:ok, info} ->
      IO.puts("Operation: #{info.name}")
      IO.puts("Description: #{info.documentation}")

      IO.puts("\nRequired Parameters:")
      Enum.each(info.required_parameters, fn param ->
        IO.puts("   • #{param}")
      end)

      if info.optional_parameters != [] do
        IO.puts("\nOptional Parameters:")
        Enum.each(info.optional_parameters, fn param ->
          IO.puts("   • #{param}")
        end)
      end

      IO.puts("\nReturn Type: #{info.return_type}")
      IO.puts("SOAP Action: #{info.soap_action}")

    {:error, error} ->
      IO.puts("Error getting operation info: #{inspect(error)}")
  end
else
  IO.puts("⚠️ No client available. Run the connection cell first.")
end

Making Your First SOAP Call

Now let’s make actual SOAP calls to our calculator service:

if calculator_client do
  IO.puts("Testing Calculator Operations\n")

  # Test Add
  IO.puts("1. Add: 10 + 5")
  case Lather.DynamicClient.call(calculator_client, "Add", %{"a" => 10, "b" => 5}) do
    {:ok, response} ->
      IO.puts("   Result: #{response["result"]}")
    {:error, error} ->
      IO.puts("   Error: #{inspect(error)}")
  end

  # Test Subtract
  IO.puts("\n2. Subtract: 100 - 37")
  case Lather.DynamicClient.call(calculator_client, "Subtract", %{"a" => 100, "b" => 37}) do
    {:ok, response} ->
      IO.puts("   Result: #{response["result"]}")
    {:error, error} ->
      IO.puts("   Error: #{inspect(error)}")
  end

  # Test Multiply
  IO.puts("\n3. Multiply: 7 * 8")
  case Lather.DynamicClient.call(calculator_client, "Multiply", %{"a" => 7, "b" => 8}) do
    {:ok, response} ->
      IO.puts("   Result: #{response["result"]}")
    {:error, error} ->
      IO.puts("   Error: #{inspect(error)}")
  end

  # Test Divide
  IO.puts("\n4. Divide: 100 / 4")
  case Lather.DynamicClient.call(calculator_client, "Divide", %{"dividend" => 100, "divisor" => 4}) do
    {:ok, response} ->
      IO.puts("   Result: #{response["quotient"]}")
    {:error, error} ->
      IO.puts("   Error: #{inspect(error)}")
  end
else
  IO.puts("⚠️ No client available. Run the connection cell first.")
end

Interactive Calculator

Try different calculations by changing the values below and re-running the cell (Ctrl+Enter):

# Change these values and re-run (Ctrl+Enter)
a = 10
b = 5
operation = "Add"  # Options: "Add", "Subtract", "Multiply", "Divide"

if calculator_client do
  params = case operation do
    "Divide" -> %{"dividend" => a, "divisor" => b}
    _ -> %{"a" => a, "b" => b}
  end

  case Lather.DynamicClient.call(calculator_client, operation, params) do
    {:ok, response} ->
      result = response["result"] || response["quotient"]
      Kino.Markdown.new("""
      ## 🧮 #{operation}(#{a}, #{b})

      **Result: #{result}**
      """)
    {:error, error} ->
      Kino.Markdown.new("❌ **Error:** #{inspect(error)}")
  end
else
  Kino.Markdown.new("⚠️ **Run the connection cell first**, then re-run this cell.")
end

Error Handling Examples

Let’s explore how Lather handles different types of errors:

if calculator_client do
  IO.puts("Testing Error Scenarios\n")

  # Helper to extract error message from various error structures
  get_message = fn error ->
    cond do
      is_map(error) and Map.has_key?(error, :details) and is_map(error.details) ->
        error.details[:message] || inspect(error.details)
      is_map(error) and Map.has_key?(error, :message) ->
        error.message
      is_map(error) and Map.has_key?(error, :reason) ->
        inspect(error.reason)
      true ->
        inspect(error)
    end
  end

  # Test 1: Division by zero (SOAP fault from server)
  IO.puts("1. Division by zero:")
  case Lather.DynamicClient.call(calculator_client, "Divide", %{"dividend" => 100, "divisor" => 0}) do
    {:ok, response} ->
      IO.puts("   Unexpected success: #{inspect(response)}")
    {:error, error} ->
      IO.puts("   Error type: #{error[:type] || "unknown"}")
      IO.puts("   Message: #{get_message.(error)}")
  end

  # Test 2: Invalid operation name
  IO.puts("\n2. Invalid operation name:")
  case Lather.DynamicClient.call(calculator_client, "NonExistentOperation", %{}) do
    {:ok, _response} ->
      IO.puts("   Unexpected success")
    {:error, error} ->
      IO.puts("   Error type: #{error[:type] || "unknown"}")
      IO.puts("   Message: #{get_message.(error)}")
  end

  # Test 3: Missing required parameters
  IO.puts("\n3. Missing required parameters:")
  case Lather.DynamicClient.call(calculator_client, "Add", %{}) do
    {:ok, response} ->
      IO.puts("   Response: #{inspect(response)}")
    {:error, error} ->
      IO.puts("   Error type: #{error[:type] || "unknown"}")
      IO.puts("   Message: #{get_message.(error)}")
  end
else
  IO.puts("⚠️ No client available. Run the connection cell first.")
end

Performance: Concurrent Requests

Let’s see how Lather handles multiple concurrent requests:

if calculator_client do
  # List of calculations to perform
  calculations = [
    {"Add", %{"a" => 10, "b" => 20}},
    {"Subtract", %{"a" => 100, "b" => 45}},
    {"Multiply", %{"a" => 7, "b" => 8}},
    {"Divide", %{"dividend" => 144, "divisor" => 12}},
    {"Add", %{"a" => 1000, "b" => 2000}}
  ]

  IO.puts("Making #{length(calculations)} concurrent requests...")
  start_time = System.monotonic_time(:millisecond)

  # Create async tasks for each calculation
  client = calculator_client
  tasks = Enum.map(calculations, fn {operation, params} ->
    Task.async(fn ->
      result = Lather.DynamicClient.call(client, operation, params)
      {operation, params, result}
    end)
  end)

  # Wait for all tasks to complete
  results = Task.await_many(tasks, 30_000)
  end_time = System.monotonic_time(:millisecond)

  IO.puts("\nResults (completed in #{end_time - start_time}ms):\n")

  Enum.each(results, fn {operation, params, result} ->
    case result do
      {:ok, response} ->
        value = response["result"] || response["quotient"]
        IO.puts("   #{operation}: #{inspect(params)} = #{value}")
      {:error, error} ->
        IO.puts("   #{operation}: Error - #{inspect(error)}")
    end
  end)

  success_count = Enum.count(results, fn {_, _, result} -> match?({:ok, _}, result) end)
  IO.puts("\nSummary: #{success_count}/#{length(calculations)} requests successful")
else
  IO.puts("⚠️ No client available. Run the connection cell first.")
end

Service Information Summary

Let’s examine the full service metadata:

if calculator_client do
  service_info = calculator_client.service_info

  IO.puts("SOAP Service Summary")
  IO.puts(String.duplicate("=", 50))

  # Extract info from various possible locations in the structure
  name = service_info[:name] || service_info[:service_name] ||
         get_in(service_info, [:services, Access.at(0), :name]) || "CalculatorService"
  namespace = service_info[:namespace] || service_info[:target_namespace] ||
              get_in(service_info, [:namespaces, "tns"]) || "N/A"
  endpoint = get_in(service_info, [:services, Access.at(0), :ports, Access.at(0), :address]) ||
             get_in(service_info, [:services, Access.at(0), :endpoint]) || "N/A"

  IO.puts("Service Name: #{name}")
  IO.puts("Namespace: #{namespace}")
  IO.puts("Endpoint: #{endpoint}")

  # Get operations from bindings or list_operations
  operations = Lather.DynamicClient.list_operations(calculator_client)

  IO.puts("\nAvailable Operations (#{length(operations)}):")
  Enum.each(operations, fn op ->
    op_name = if is_map(op), do: op[:name] || op.name, else: op
    case Lather.DynamicClient.get_operation_info(calculator_client, op_name) do
      {:ok, info} ->
        param_count = length(info.required_parameters) + length(info.optional_parameters)
        doc = info.documentation || ""
        IO.puts("   • #{op_name} (#{param_count} params) #{doc}")
      {:error, _} ->
        IO.puts("   • #{op_name}")
    end
  end)

  # Show raw structure for debugging
  IO.puts("\n" <> String.duplicate("-", 50))
  IO.puts("Raw service_info keys: #{inspect(Map.keys(service_info))}")
else
  IO.puts("⚠️ No service information available. Run the connection cell first.")
end

Cleanup

Stop the server when done:

case Process.whereis(:calculator_server) do
  nil ->
    IO.puts("Server already stopped")
  pid ->
    Supervisor.stop(pid)
    IO.puts("Server stopped")
end

Next Steps

Congratulations! You’ve learned the basics of using the Lather SOAP library for both server and client functionality.

Here’s what you can explore next:

  1. SOAP Server Development Livebook: Deep dive into server features in soap_server_development.livemd
  2. Authentication: Add WS-Security or Basic Auth to your services
  3. Complex Types: Define custom types with soap_type for structured data
  4. MTOM Attachments: Handle binary data with MTOM support

Quick Reference

reference = """
LATHER SOAP LIBRARY - QUICK REFERENCE

SERVER SIDE:
   defmodule MyService do
     use Lather.Server
     @namespace "http://example.com/myservice"
     @service_name "MyService"

     soap_operation "MyOp" do
       input do
         parameter "param1", :string, required: true
       end
       output do
         parameter "result", :string
       end
     end

     def my_op(%{"param1" => val}), do: {:ok, %{"result" => val}}
   end

ENHANCED PLUG (Web Forms + Multi-Protocol):
   # In Plug.Router:
   forward "/myservice",
     to: Lather.Server.EnhancedPlug,
     init_opts: [service: MyService, base_path: "/myservice"]

   # URL Patterns:
   GET  /myservice           -> Service overview (HTML)
   GET  /myservice?op=MyOp   -> Operation test form (HTML)
   GET  /myservice?wsdl      -> WSDL document (XML)
   POST /myservice           -> SOAP 1.1 endpoint
   POST /myservice/v1.2      -> SOAP 1.2 endpoint
   POST /myservice/api       -> JSON/REST endpoint

CLIENT SIDE:
   {:ok, client} = Lather.DynamicClient.new(wsdl_url)
   operations = Lather.DynamicClient.list_operations(client)
   {:ok, info} = Lather.DynamicClient.get_operation_info(client, "OpName")
   {:ok, response} = Lather.DynamicClient.call(client, "OpName", params)

ERROR HANDLING:
   case result do
     {:ok, response} -> # Success
     {:error, %{type: :soap_fault}} -> # SOAP fault
     {:error, %{type: :http_error}} -> # HTTP error
     {:error, %{type: :transport_error}} -> # Network error
   end
"""

IO.puts(reference)

Happy SOAP-ing with Lather!