flutterdartsoapsoap-client

Flutter Dart SOAP service: UK NationalRail OpenLDBWS envelope generation


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.


Solution

  • 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:
    enter image description here

    Header values can be checked like this:

    enter image description here

    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.

    enter image description here