Please excuse my shallow knowledge of WSDL and SOAP, this is the first time I'm working with it. I'm trying to send a query to UK's National Rail live train timetable (https://realtime.nationalrail.co.uk/OpenLDBWS/, LDBWS OpenAPI JSON Spec file: https://realtime.nationalrail.co.uk/LDBWS/static/ldbws.json). Server also expects a personal API token, which I have requested and it is working. I've managed to get a response from the server with python using zeep:
import json
import argparse
from zeep import Client
import zeep.helpers
from zeep.transports import Transport
from requests import Session
WSDL_URL = "https://realtime.nationalrail.co.uk/OpenLDBWS/wsdl.aspx?ver=2021-11-01"
class NationalRailService:
def __init__(self, user_token):
self.user_token = user_token
self.session = Session()
self.transport = Transport(session=self.session)
self.client = Client(WSDL_URL, transport=self.transport)
self.service = self.client.service
self.header = {
"AccessToken": {
"TokenValue": self.user_token
}
}
def get_departure_board(self, from_crs, to_crs, num_rows=10):
"""
Queries the National Rail API for departure information.
Args:
from_crs (str): The departure station CRS code.
to_crs (str): The destination station CRS code.
num_rows (int, optional): The number of results to return. Defaults to 10.
Returns:
dict: A dictionary containing the query results, or an error message.
"""
try:
response = self.service.GetDepartureBoard(
numRows=num_rows,
crs=from_crs,
filterCrs=to_crs if to_crs else None,
filterType="to" if to_crs else None,
_soapheaders=self.header
)
raw_services = (
response.trainServices.service
if hasattr(response.trainServices, "service")
else []
)
results = [self._remove_nulls(zeep.helpers.serialize_object(s)) for s in raw_services]
return {
"from": from_crs,
"to": to_crs,
"count": len(results),
"results": results
}
except Exception as e:
return {"error": str(e)}
def _remove_nulls(self, data):
"""Recursively removes null values from a dictionary."""
if isinstance(data, dict):
return {k: self._remove_nulls(v) for k, v in data.items() if v is not None}
elif isinstance(data, list):
return [self._remove_nulls(item) for item in data]
else:
return data
def test_response(): # test method with command line args
parser = argparse.ArgumentParser(description="Query National Rail API")
parser.add_argument("--token", required=True, help="Your National Rail API token")
parser.add_argument("--from_crs", required=True, help="Departure station CRS code")
parser.add_argument("--to_crs", required=True, help="Destination station CRS code (optional)")
parser.add_argument("--num_rows", type=int, default=10, help="Number of results to return (default: 10)")
args = parser.parse_args()
event = {
"headers": {
"X-API-TOKEN": args.token
},
"queryStringParameters": {
"from": args.from_crs,
"to": args.to_crs,
"numRows": args.num_rows
}
}
response = lambda_handler(event)
print(json.dumps(response, indent=2, ensure_ascii=False))
Since dart doesn't have an automatic ways to create an envelope (like with using zeep in python above), I need to create the envelope manually. Here's what I have tried (it does accept my API token, the problem seems to be in the SOAPAction header, even when it's empty):
import 'dart:developer';
import 'package:http/http.dart' as http;
import 'package:xml/xml.dart' as xml;
class NationalRailService {
final String soapEndpoint = "https://realtime.nationalrail.co.uk/OpenLDBWS/ldb12.asmx";
final String userToken;
final Map<String, String> namespaces = {
'soap': 'http://schemas.xmlsoap.org/soap/envelope/',
'ldb': 'http://thalesgroup.com/RTTI/2021-11-01/ldb/',
'typ': 'http://thalesgroup.com/RTTI/2013-11-28/Token/types'
};
NationalRailService(this.userToken);
Future<Map<String, dynamic>> getDepartureBoard({
required String fromCrs,
String? toCrs,
int numRows = 10,
}) async {
try {
// Build SOAP envelope
final envelope = _buildGetDepartureBoardEnvelope(
fromCrs: fromCrs.toUpperCase(),
toCrs: toCrs?.toUpperCase(),
numRows: numRows,
);
// Send request
final response = await http.post(
Uri.parse(soapEndpoint),
headers: {
'Content-Type': 'text/xml; charset=utf-8',
'SOAPAction': 'http://thalesgroup.com/RTTI/2021-11-01/ldb/GetDepartureBoard' // error here - HTTP Error 500: Server did not recognize the value of HTTP Header SOAPAction
},
body: envelope,
);
if (response.statusCode != 200) {
return {
'error': 'HTTP Error: ${response.statusCode}',
'details': response.body
};
}
// Parse XML response
final parsedResponse = _parseGetDepartureBoardResponse(response.body);
return {
'from': fromCrs,
'to': toCrs,
'count': parsedResponse['results'].length,
'results': parsedResponse['results'],
};
} catch (e) {
return {'error': e.toString()};
}
}
String _buildGetDepartureBoardEnvelope({
required String fromCrs,
String? toCrs,
required int numRows,
}) {
return '''
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:ldb="http://thalesgroup.com/RTTI/2021-11-01/ldb/"
xmlns:typ="http://thalesgroup.com/RTTI/2013-11-28/Token/types">
<soap:Header>
<typ:AccessToken>
<typ:TokenValue>$userToken</typ:TokenValue>
</typ:AccessToken>
</soap:Header>
<soap:Body>
<ldb:GetDepartureBoard>
<ldb:numRows>$numRows</ldb:numRows>
<ldb:crs>$fromCrs</ldb:crs>
${toCrs != null ? '<ldb:filterCrs>$toCrs</ldb:filterCrs><ldb:filterType>to</ldb:filterType>' : ''}
</ldb:GetDepartureBoard>
</soap:Body>
</soap:Envelope>
''';
}
}
Thank you for your help in advance.
Thanks to Richard Heap and his suggestion, I was able to adjust the soap envelope using SoapUI. Turns out, there is no SOAPAction header, because the action is actually passed in Content-Type header:
Header values can be checked like this:
Create a mock service and start it
Create a request and send it
Open the message log in the bottom of the mock service window
As it also turns out, different actions have different namespace versions, so you cannot just use one namespace like
'http://thalesgroup.com/RTTI/2015-05-14/ldb/$action'
and expect it to work with all actions by adding them in the end.