National Weather Service SOAP API with Lather
Mix.install([
{:lather, "~> 1.0"},
{:kino, "~> 0.12.0"},
{:jason, "~> 1.4"}
])
Introduction
In this Livebook, weβll explore how to use Lather to interact with the National Weather Serviceβs NDFD XML SOAP server. This is a real-world, production SOAP service provided by the U.S. National Weather Service that offers detailed weather forecast data.
The service provides:
- Current weather conditions
- Detailed forecasts (temperature, precipitation, wind, etc.)
- Weather warnings and alerts
- Historical weather data
- Geographic weather information
π€οΈ About the National Weather Service API
The National Digital Forecast Database (NDFD) XML web service provides access to weather forecast data in a structured, machine-readable format. Itβs completely free and doesnβt require authentication, making it perfect for learning SOAP integration.
Service Details:
- WSDL URL: https://graphical.weather.gov/xml/SOAP_server/ndfdXMLserver.php?wsdl
- Provider: U.S. National Weather Service
- Authentication: None required
- Rate Limits: Reasonable use policy
- Data Format: XML with weather forecast elements
π‘ Setting Up the SOAP Client
Letβs start by creating a Lather client connected to the National Weather Service SOAP endpoint:
alias Lather.DynamicClient
# National Weather Service NDFD XML SOAP server
wsdl_url = "https://graphical.weather.gov/xml/SOAP_server/ndfdXMLserver.php?wsdl"
# Create the SOAP client
{:ok, weather_client} = DynamicClient.new(wsdl_url, timeout: 10_000)
IO.puts("β
Successfully connected to National Weather Service SOAP API!")
IO.puts("π WSDL URL: #{wsdl_url}")
π Exploring Available Operations
Letβs see what operations the National Weather Service provides:
operations = DynamicClient.list_operations(weather_client)
IO.puts("π οΈ Available Weather Service Operations:\n")
operations
|> Enum.with_index(1)
|> Enum.each(fn {operation, index} ->
IO.puts("#{index}. #{operation.name}")
if operation.description do
IO.puts(" π #{operation.description}")
end
IO.puts("")
end)
IO.puts("Total operations available: #{length(operations)}")
πΊοΈ Getting Weather Data for a Location
Letβs start with the most common operation - getting weather forecast data for a specific latitude and longitude. Weβll use San Francisco, CA as our example location.
# San Francisco coordinates
san_francisco_lat = 37.7749
san_francisco_lon = -122.4194
# Weather elements we want to retrieve
weather_elements = [
"maxt", # Maximum temperature
"mint", # Minimum temperature
"temp", # Current temperature
"pop12", # 12-hour probability of precipitation
"wspd", # Wind speed
"wdir", # Wind direction
"sky", # Sky cover
"wx" # Weather conditions
]
# Time format for the request
start_time = DateTime.utc_now() |> DateTime.to_iso8601()
end_time = DateTime.utc_now() |> DateTime.add(7 * 24 * 3600) |> DateTime.to_iso8601()
# Prepare the request parameters
weather_params = %{
"latitude" => san_francisco_lat,
"longitude" => san_francisco_lon,
"product" => "time-series",
"XMLformat" => "1", # XML format version
"startTime" => start_time,
"endTime" => end_time,
"Unit" => "e", # English units
"weatherParameters" => %{
"maxt" => "true",
"mint" => "true",
"temp" => "true",
"pop12" => "true",
"wspd" => "true",
"wdir" => "true",
"sky" => "true",
"wx" => "true"
}
}
IO.puts("π Requesting weather data for San Francisco, CA")
IO.puts("π Coordinates: #{san_francisco_lat}, #{san_francisco_lon}")
IO.puts("π
Time range: #{String.slice(start_time, 0, 10)} to #{String.slice(end_time, 0, 10)}")
# Make the SOAP request for weather data
case DynamicClient.call(weather_client, "NDFDgen", weather_params) do
{:ok, response} ->
IO.puts("β
Successfully retrieved weather data!")
IO.puts("\nπ Response structure:")
# The response contains XML data with weather forecasts
if response["XMLOut"] do
xml_data = response["XMLOut"]
IO.puts("π¦ Received weather XML data (#{String.length(xml_data)} characters)")
# Show first 500 characters to see the structure
preview = String.slice(xml_data, 0, 500)
IO.puts("\nπ XML Preview:")
IO.puts(preview <> "...")
else
IO.puts("π Response keys: #{inspect(Map.keys(response))}")
IO.inspect(response, limit: :infinity)
end
{:error, reason} ->
IO.puts("β Error retrieving weather data:")
IO.inspect(reason)
end
π‘οΈ Parsing Weather Response
Letβs create a helper to parse the weather XML response and extract useful information:
defmodule WeatherParser do
@moduledoc """
Helper module to parse National Weather Service XML responses.
"""
def parse_weather_xml(xml_string) when is_binary(xml_string) do
try do
case Lather.Xml.Parser.parse(xml_string) do
{:ok, parsed} -> extract_weather_data(parsed)
{:error, reason} -> {:error, "XML parsing failed: #{inspect(reason)}"}
end
rescue
e -> {:error, "Parsing exception: #{inspect(e)}"}
end
end
def parse_weather_xml(_), do: {:error, "Invalid XML data"}
defp extract_weather_data(parsed) do
# The NDFD XML structure is quite complex
# Let's extract basic information
case get_in(parsed, ["dwml"]) do
nil -> {:error, "No DWML data found"}
dwml ->
location = extract_location(dwml)
parameters = extract_parameters(dwml)
{:ok, %{location: location, weather_data: parameters}}
end
end
defp extract_location(dwml) do
case get_in(dwml, ["head", "product", "location"]) do
location when is_map(location) ->
%{
description: get_in(location, ["description"]) || "Unknown",
latitude: get_in(location, ["point", "@latitude"]) || "Unknown",
longitude: get_in(location, ["point", "@longitude"]) || "Unknown"
}
_ -> %{description: "Location data unavailable"}
end
end
defp extract_parameters(dwml) do
case get_in(dwml, ["data", "parameters"]) do
nil -> %{}
params when is_map(params) ->
# Extract temperature data
temps = extract_temperatures(params)
precipitation = extract_precipitation(params)
wind = extract_wind(params)
%{
temperatures: temps,
precipitation: precipitation,
wind: wind
}
_ -> %{}
end
end
defp extract_temperatures(params) do
temps = get_in(params, ["temperature"]) || []
case temps do
temp when is_map(temp) -> [parse_temperature_element(temp)]
temp_list when is_list(temp_list) -> Enum.map(temp_list, &parse_temperature_element/1)
_ -> []
end
end
defp parse_temperature_element(temp) when is_map(temp) do
%{
type: get_in(temp, ["@type"]) || "unknown",
units: get_in(temp, ["@units"]) || "unknown",
values: extract_values(temp)
}
end
defp extract_precipitation(params) do
pop = get_in(params, ["probability-of-precipitation"])
if pop do
%{
type: "probability-of-precipitation",
units: get_in(pop, ["@units"]) || "%",
values: extract_values(pop)
}
else
nil
end
end
defp extract_wind(params) do
wind_speed = get_in(params, ["wind-speed"])
wind_direction = get_in(params, ["direction"])
%{
speed: if(wind_speed, do: %{
units: get_in(wind_speed, ["@units"]) || "unknown",
values: extract_values(wind_speed)
}),
direction: if(wind_direction, do: %{
type: get_in(wind_direction, ["@type"]) || "unknown",
units: get_in(wind_direction, ["@units"]) || "degrees",
values: extract_values(wind_direction)
})
}
end
defp extract_values(element) when is_map(element) do
case get_in(element, ["value"]) do
value when is_binary(value) -> [value]
values when is_list(values) -> values
values when is_map(values) -> [values]
_ -> []
end
end
defp extract_values(_), do: []
end
IO.puts("π§ WeatherParser module loaded and ready!")
π Complete Weather Data Example
Letβs make a complete weather request and parse the results:
# Different location - New York City
nyc_params = %{
"latitude" => 40.7128,
"longitude" => -74.0060,
"product" => "time-series",
"XMLformat" => "1",
"startTime" => DateTime.utc_now() |> DateTime.to_iso8601(),
"endTime" => DateTime.utc_now() |> DateTime.add(3 * 24 * 3600) |> DateTime.to_iso8601(),
"Unit" => "e",
"weatherParameters" => %{
"maxt" => "true",
"mint" => "true",
"pop12" => "true",
"wspd" => "true"
}
}
IO.puts("π½ Getting weather forecast for New York City...")
case DynamicClient.call(weather_client, "NDFDgen", nyc_params) do
{:ok, response} ->
if xml_data = response["XMLOut"] do
case WeatherParser.parse_weather_xml(xml_data) do
{:ok, weather_info} ->
IO.puts("β
Weather data successfully parsed!")
IO.puts("\nπ Location Information:")
IO.inspect(weather_info.location, pretty: true)
IO.puts("\nπ‘οΈ Weather Parameters:")
IO.inspect(weather_info.weather_data, pretty: true, limit: :infinity)
{:error, reason} ->
IO.puts("β Failed to parse weather data: #{reason}")
IO.puts("π Raw XML (first 1000 chars):")
IO.puts(String.slice(xml_data, 0, 1000))
end
else
IO.puts("π No weather XML data in response")
IO.inspect(response, limit: :infinity)
end
{:error, reason} ->
IO.puts("β SOAP request failed:")
IO.inspect(reason)
end
π― Exploring Other Weather Operations
Letβs try a different operation to get available weather elements for a location:
# Try the GmlLatLonList operation to get location information
location_params = %{
"latitude" => 37.7749, # San Francisco
"longitude" => -122.4194
}
case DynamicClient.call(weather_client, "GmlLatLonList", location_params) do
{:ok, response} ->
IO.puts("β
GmlLatLonList operation successful!")
IO.puts("π Response for San Francisco location data:")
IO.inspect(response, pretty: true)
{:error, reason} ->
IO.puts("β GmlLatLonList operation failed:")
IO.inspect(reason)
end
π Advanced Example: Multiple Locations
Letβs request weather data for multiple locations at once:
# Multiple cities with their coordinates
cities = [
{"Los Angeles, CA", 34.0522, -118.2437},
{"Chicago, IL", 41.8781, -87.6298},
{"Miami, FL", 25.7617, -80.1918}
]
IO.puts("π Getting weather data for multiple cities...")
cities
|> Enum.each(fn {city_name, lat, lon} ->
params = %{
"latitude" => lat,
"longitude" => lon,
"product" => "time-series",
"XMLformat" => "1",
"startTime" => DateTime.utc_now() |> DateTime.to_iso8601(),
"endTime" => DateTime.utc_now() |> DateTime.add(24 * 3600) |> DateTime.to_iso8601(),
"Unit" => "e",
"weatherParameters" => %{
"maxt" => "true",
"mint" => "true",
"pop12" => "true"
}
}
IO.puts("\nποΈ #{city_name} (#{lat}, #{lon}):")
case DynamicClient.call(weather_client, "NDFDgen", params) do
{:ok, response} ->
if xml_data = response["XMLOut"] do
IO.puts(" β
Weather data received (#{String.length(xml_data)} chars)")
# Try to extract some basic info
if String.contains?(xml_data, " String.split(" length() - 1
IO.puts(" π‘οΈ Found #{temp_count} temperature datasets")
end
if String.contains?(xml_data, "
IO.puts(" β Failed: #{inspect(reason)}")
end
# Small delay to be respectful to the API
Process.sleep(500)
end)
π‘οΈ Error Handling and Best Practices
Letβs demonstrate proper error handling when working with SOAP services:
defmodule WeatherServiceDemo do
@moduledoc """
Demonstrates best practices for using the National Weather Service SOAP API.
"""
def get_weather_safely(client, latitude, longitude, options \\ []) do
# Validate inputs
with :ok <- validate_coordinates(latitude, longitude),
{:ok, params} <- build_weather_params(latitude, longitude, options),
{:ok, response} <- make_soap_request(client, params) do
process_weather_response(response)
else
{:error, reason} -> {:error, reason}
error -> {:error, "Unexpected error: #{inspect(error)}"}
end
end
defp validate_coordinates(lat, lon) do
cond do
not is_number(lat) or lat < -90 or lat > 90 ->
{:error, "Invalid latitude: must be between -90 and 90"}
not is_number(lon) or lon < -180 or lon > 180 ->
{:error, "Invalid longitude: must be between -180 and 180"}
true -> :ok
end
end
defp build_weather_params(lat, lon, options) do
start_time = Keyword.get(options, :start_time, DateTime.utc_now())
days = Keyword.get(options, :days, 3)
end_time = DateTime.add(start_time, days * 24 * 3600)
params = %{
"latitude" => lat,
"longitude" => lon,
"product" => "time-series",
"XMLformat" => "1",
"startTime" => DateTime.to_iso8601(start_time),
"endTime" => DateTime.to_iso8601(end_time),
"Unit" => "e",
"weatherParameters" => %{
"maxt" => "true",
"mint" => "true",
"temp" => "true",
"pop12" => "true",
"wspd" => "true"
}
}
{:ok, params}
end
defp make_soap_request(client, params) do
case DynamicClient.call(client, "NDFDgen", params) do
{:ok, response} -> {:ok, response}
{:error, {:soap_fault, fault}} -> {:error, "SOAP fault: #{fault.fault_string}"}
{:error, {:http_error, status, _body}} -> {:error, "HTTP error: #{status}"}
{:error, reason} -> {:error, "Request failed: #{inspect(reason)}"}
end
end
defp process_weather_response(response) do
case response do
%{"XMLOut" => xml_data} when is_binary(xml_data) ->
{:ok, %{
status: :success,
data_size: String.length(xml_data),
has_temperature: String.contains?(xml_data, "
{:error, "Unexpected response format: #{inspect(Map.keys(response))}"}
end
end
end
# Test the safe weather function
IO.puts("π§ͺ Testing safe weather data retrieval...")
# Test with valid coordinates
case WeatherServiceDemo.get_weather_safely(weather_client, 40.7589, -73.9851) do
{:ok, result} ->
IO.puts("β
New York weather data retrieved successfully:")
IO.inspect(result, pretty: true)
{:error, reason} ->
IO.puts("β Error: #{reason}")
end
# Test with invalid coordinates
case WeatherServiceDemo.get_weather_safely(weather_client, 999, -999) do
{:ok, _result} ->
IO.puts("β οΈ Unexpected success with invalid coordinates")
{:error, reason} ->
IO.puts("β
Properly handled invalid coordinates: #{reason}")
end
π Service Information Summary
Letβs gather some final information about the National Weather Service API:
service_info = DynamicClient.get_service_info(weather_client)
IO.puts("π National Weather Service SOAP API Summary")
IO.puts("=" |> String.duplicate(50))
IO.puts("π Service Name: #{service_info.service_name}")
IO.puts("π Endpoint: #{service_info.endpoint}")
IO.puts("π Namespace: #{service_info.target_namespace}")
IO.puts("βοΈ Operations Count: #{length(service_info.operations)}")
IO.puts("\nπ οΈ Key Operations:")
service_info.operations
|> Enum.take(5)
|> Enum.each(fn op ->
IO.puts(" β’ #{op.name}")
if op.input && length(op.input.parts) > 0 do
input_params = op.input.parts |> Enum.map(& &1.name) |> Enum.join(", ")
IO.puts(" π₯ Inputs: #{input_params}")
end
end)
IO.puts("\nπ― Perfect for:")
IO.puts(" β’ Weather forecasting applications")
IO.puts(" β’ Agricultural planning systems")
IO.puts(" β’ Emergency preparedness tools")
IO.puts(" β’ Climate research projects")
IO.puts(" β’ Mobile weather applications")
π Key Takeaways
This example demonstrated several important SOAP integration concepts:
β What We Learned
- Real-World SOAP Integration: Connected to a production government API
- Dynamic Client Creation: Used Lather to auto-discover SOAP operations
- Complex Parameter Handling: Built nested parameter structures for weather requests
- XML Response Processing: Parsed complex XML weather data responses
- Error Handling: Implemented robust error handling for network and data issues
- Best Practices: Showed input validation, rate limiting, and graceful degradation
π Lather Features Showcased
- Automatic WSDL Discovery: No manual configuration needed
- Dynamic Operation Calling: Discovered and called operations at runtime
- Complex Data Handling: Managed nested weather parameters seamlessly
- Error Management: Comprehensive error handling for various failure modes
- XML Processing: Built-in XML parsing and generation capabilities
π Next Steps
You can extend this example by:
- Adding data visualization with Kino charts
- Implementing caching for frequently requested locations
- Creating a simple weather dashboard
- Adding more sophisticated XML parsing
- Integrating with other weather services for comparison
The National Weather Service API is an excellent example of how SOAP services can provide rich, structured data thatβs perfect for programmatic access!