adllm Insights logo adllm Insights logo

Building an F# Type Provider for Legacy SOAP APIs with WSDL 1.1 Extensions

Published on by The adllm Team. Last modified: . Tags: F# Type Provider SOAP WSDL Legacy Integration .NET Web Services

Legacy SOAP APIs, often described by WSDL 1.1 and sometimes featuring custom extensions, remain prevalent in many enterprise environments. Consuming these services in modern .NET, particularly with F#, can present challenges. While tools like dotnet-svcutil generate C# client code, this output often lacks F# idiomatic grace and doesn’t inherently offer the dynamic, schema-aware benefits that F# Type Providers bring to the table. Moreover, standard tools may struggle to interpret or leverage proprietary WSDL extensions.

This article guides you through the process of building a custom F# Type Provider tailored for such legacy SOAP APIs. We’ll explore how a Type Provider can offer a superior developer experience by providing compile-time type safety, F#-idiomatic access, and the potential to gracefully handle non-standard WSDL extensions, significantly reducing boilerplate and enhancing integration.

We will cover the foundational concepts, the rationale behind choosing a Type Provider, the core components involved in its construction, and a step-by-step conceptual implementation guide with illustrative code examples.

Understanding the Landscape: F#, SOAP, WSDL, and Extensions

Before diving into the implementation, let’s clarify the key technologies involved.

F# Type Providers

F# Type Providers are a compile-time mechanism that generates types, properties, and methods on-the-fly based on external data sources or schemas. This allows developers to interact with data sources in a strongly-typed manner without pre-generating static code files. For more details, refer to the official Microsoft F# Type Provider documentation.

SOAP and WSDL 1.1

SOAP (Simple Object Access Protocol) is an XML-based messaging protocol for exchanging structured information in web services. WSDL (Web Services Description Language) is an XML format for describing network services. WSDL 1.1, though an older specification (W3C WSDL 1.1 Specification), is commonly found in legacy systems. It defines:

  • Types: Data type definitions, typically using XML Schema (XSD).
  • Messages: Abstract definitions of the data being exchanged.
  • PortTypes (Interfaces): Abstract sets of operations.
  • Bindings: Concrete protocol and data format specifications for a particular port type (e.g., SOAP over HTTP).
  • Services: Collections of related endpoints (ports).

WSDL Extensions

WSDL is extensible. This means custom XML elements can be embedded within standard WSDL elements (like bindings, operations, or services) to provide additional metadata or define custom behaviors not covered by the WSDL 1.1 specification. These extensions are a primary reason why standard tooling might fall short, as they often require specific parsing logic.

Why a Type Provider for Legacy SOAP?

While .NET provides dotnet-svcutil for generating C# client code from WSDL, an F# Type Provider offers distinct advantages for F# developers, especially with complex or non-standard legacy SOAP services:

  1. F#-Idiomatic Usage: Generated types and methods naturally align with F# conventions (e.g., records, option types for nillable elements, asynchronous workflows).
  2. Compile-Time Safety: The schema is checked at compile time, providing immediate feedback on API compatibility.
  3. Reduced Boilerplate: No need for verbose C# proxy classes that then require F# wrappers.
  4. Handling WSDL Extensions: A custom Type Provider can be designed to parse and interpret specific WSDL extensions, influencing the generated types or client behavior (e.g., custom authentication hints, data mapping rules).
  5. “Live” Schema (Potentially): Type Providers can re-evaluate schemas, offering a more dynamic view if the WSDL source is updated (though caching for performance is essential).

The core thesis is that the effort to build a Type Provider can be justified by the enhanced developer experience, robustness, and adaptability it provides when interacting with these legacy services.

Core Components of a SOAP Type Provider

A Type Provider for a SOAP service typically involves several key components working together:

  1. Design-Time (Compile-Time) Logic:

    • WSDL Parsing: Reads and interprets the WSDL document, including embedded XML Schemas and any relevant extensions.
    • Type Generation: Dynamically creates F# type definitions (e.g., records for complex types, options for nillable elements) based on the WSDL’s schema information.
    • Method Generation: Dynamically creates F# method signatures corresponding to WSDL operations, including parameters and return types.
  2. Runtime Logic (Embedded in Generated Types/Methods):

    • SOAP Request Construction: Assembles the XML SOAP envelope for an operation call.
    • HTTP Communication: Sends the SOAP request (typically an HTTP POST) to the service endpoint and receives the response.
    • SOAP Response Parsing: Parses the XML SOAP response, extracting data or SOAP faults.
    • Data Mapping: Converts XML data to the generated F# types.

The Type Provider SDK (FSharp.TypeProviders.SDK) provides the necessary tools like ProvidedTypeDefinition, ProvidedMethod, and ProvidedProperty to construct these types and members at compile time.

Step-by-Step Implementation Guide (Conceptual)

Let’s outline the conceptual steps to build such a Type Provider. The code examples are illustrative and simplified for clarity.

1. Project Setup

Create an F# library project for your Type Provider. Add the following NuGet packages:

  • FSharp.TypeProviders.SDK: For the core Type Provider APIs.
  • System.ServiceModel.Web.Wsdl: For parsing WSDL 1.1 documents.
  • System.Xml.Linq: For robust XML manipulation (SOAP message construction/parsing).
1
2
3
4
5
6
// In your .fsproj file (example)
// <ItemGroup>
//   <PackageReference Include="FSharp.TypeProviders.SDK" Version="X.Y.Z" />
//   <PackageReference Include="System.ServiceModel.Web.Wsdl" Version="X.Y.Z" />
//   <PackageReference Include="System.Xml.Linq" Version="X.Y.Z" />
// </ItemGroup>

2. The Type Provider Boilerplate

Your Type Provider will inherit from TypeProviderForNamespaces (or TypeProviderForIndividuals if not generating a whole namespace). The constructor typically takes static parameters, such as the path or URL to the WSDL file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
namespace LegacySoapProvider

open System
open System.Reflection
open Microsoft.FSharp.Core.CompilerServices
open Microsoft.FSharp.Quotations
// Add other necessary 'open' declarations for WSDL/XML parsing here

[<TypeProvider>]
type public LegacySoapServiceTypeProvider(config: TypeProviderConfig) as this =
    inherit TypeProviderForNamespaces()

    let thisAssembly = Assembly.GetExecutingAssembly()
    let rootNamespace = "LegacySoapServices" // Or from config

    // Placeholder for WSDL path/URL from static parameters
    // In a real TP, you'd get this from config.StaticParameters
    let wsdlUrl = "http://example.com/legacyservice?wsdl" 
    // Or: let wsdlFilePath = @"C:\path\to\your\service.wsdl"

    let createTypes() =
        // Main logic to parse WSDL and generate types will go here
        let serviceType = ProvidedTypeDefinition(thisAssembly, rootNamespace, 
                                                 "MyServiceClient", 
                                                 Some typeof<obj>)
        
        // Example: Add a sample method (details later)
        let sampleMethod = 
            ProvidedMethod("GetHelloWorld", [], typeof<Async<string>>, 
                           IsStaticMethod = false)
        sampleMethod.AddXmlDoc "Gets a hello world string from the service."
        // Implement invocation logic for sampleMethod later
        
        serviceType.AddMember sampleMethod
        [serviceType]

    do 
        this.AddNamespace(rootNamespace, createTypes())

// To consume this (in another project):
// type MyService = LegacySoapServices.MyServiceClient<"some_static_param_val">
// let client = MyService()
// let! result = client.GetHelloWorld()

This basic structure sets up the Type Provider. The config.StaticParameters would be used to pass the WSDL location.

3. Parsing the WSDL

The System.Web.Services.Description.ServiceDescription class from the System.ServiceModel.Web.Wsdl package is key for parsing WSDL 1.1.

In your createTypes() function or a helper:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
open System.Web.Services.Description
open System.Xml

// ... inside createTypes() or a helper called from there ...

let loadAndParseWsdl (wsdlInput: string) : ServiceDescription =
    try
        // Determine if wsdlInput is a URL or a file path
        if Uri.IsWellFormedUriString(wsdlInput, UriKind.Absolute) then
            use reader = XmlReader.Create(wsdlInput)
            ServiceDescription.Read(reader)
        else
            use stream = System.IO.File.OpenRead(wsdlInput)
            ServiceDescription.Read(stream)
    with
    | ex -> 
        // Use TypeProviderError for user-facing errors
        this.AddError(sprintf "Failed to load/parse WSDL: %s" ex.Message)
        null // Or re-throw if you want the TP to fail catastrophically

// Example usage:
// let serviceDescription = loadAndParseWsdl wsdlUrl
// if serviceDescription <> null then
//    // Proceed to analyze serviceDescription.Services, .PortTypes, .Messages
//    // .Bindings, .Types.Schemas etc.
//    () 

This snippet demonstrates loading the WSDL. You would then iterate through its collections (Services, PortTypes, Operations, Messages, Types.Schemas) to understand the service structure.

4. Generating F# Types from XML Schema

WSDL types sections usually contain XML Schema Definitions (XSD). You’ll need to map XSD complex types to F# records (or classes) and simple types to F# primitives, strings, enums, etc. System.Xml.Schema.XmlSchema objects are found in serviceDescription.Types.Schemas.

Conceptual code for generating an F# record from an XmlSchemaComplexType:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
open System.Xml.Schema
open Microsoft.FSharp.Reflection // For FSharpType.MakeRecordType

// ... within your type generation logic ...

let mapSchemaTypeToFSharpType (schemaType: XmlSchemaType) : Type =
    // Simplified mapping
    match schemaType.QualifiedName.Name with
    | "string" -> typeof<string>
    | "int" -> typeof<int>
    | "boolean" -> typeof<bool>
    | "dateTime" -> typeof<DateTime>
    // Add more XSD built-in types
    | _ -> 
        // For complex types, you'd look up or generate a ProvidedTypeDefinition
        // For this example, assume it's an unknown simple type
        typeof<obj> 

let createProvidedRecordFromComplexType (asm: Assembly) 
                                      (namespace: string) 
                                      (complexType: XmlSchemaComplexType) =
    let typeName = complexType.Name
    // Ensure typeName is valid for F#
    let fsharpTypeName = if String.IsNullOrEmpty(typeName) then "UnnamedComplexType" 
                                                            else typeName
    
    let record = ProvidedTypeDefinition(asm, namespace, fsharpTypeName, 
                                        Some typeof<obj>)
    record.SetIsRecordType(true) // Mark as an F# record

    match complexType.ContentTypeParticle with
    | :? XmlSchemaSequence as seq ->
        for item in seq.Items do
            match item with
            | :? XmlSchemaElement as elem ->
                let propName = elem.Name
                // Ensure propName is valid for F#
                let fsharpPropName = if String.IsNullOrEmpty(propName) then "UnnamedProperty"
                                                                       else propName
                let propType = mapSchemaTypeToFSharpType elem.ElementSchemaType
                
                // For optional elements (minOccurs="0") or nillable="true"
                // you'd use typeof<Option<_>>. For simplicity, not shown here.
                let providedProp = ProvidedProperty(fsharpPropName, propType, 
                                                    IsStatic=false)
                providedProp.AddXmlDoc (sprintf "Property from XSD element %s" 
                                                elem.Name)
                record.AddMember(providedProp)
            | _ -> () // Handle other kinds of items like XmlSchemaChoice
    | _ -> () // Handle other content types like XmlSchemaAll, XmlSchemaChoice

    record

This illustrates creating a ProvidedTypeDefinition and marking it as an F# record. Properties are added based on elements in an XmlSchemaSequence. Real-world mapping would need to handle namespaces, minOccurs, nillable, choices, attributes, and much more.

5. Generating Client Methods for SOAP Operations

For each WSDL operation, you’ll create a ProvidedMethod. The parameters and return type of this method will correspond to the F# types generated from the operation’s input and output messages. Network calls should be asynchronous, typically returning Async<T>.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// ... inside createTypes(), populating your main serviceType ...

// Assume 'serviceType' is the ProvidedTypeDefinition for your client
// Assume 'op' is an 'Operation' from 'serviceDescription.PortTypes[...].Operations'
// Assume 'inputFSharpType' and 'outputFSharpType' are 'Type' objects
// you've already mapped/generated from WSDL messages.

let methodName = op.Name + "Async" // Convention for async methods
let parameters = 
    // Example: create a single ProvidedParameter from 'inputFSharpType'
    // A real implementation would parse 'op.Messages.Input.Message.Parts'
    [ProvidedParameter("request", inputFSharpType)] 

let returnType = typedefof<Async<_>>.MakeGenericType([| outputFSharpType |])

let method = ProvidedMethod(methodName, parameters, returnType, 
                            IsStaticMethod = false)
method.AddXmlDoc (sprintf "Invokes the %s SOAP operation asynchronously." op.Name)

// The crucial part: defining the code that executes at runtime
method.InvokeCode <- fun args ->
    // 'args' is a list of F# quotations representing the method arguments.
    // e.g., args.[0] would be the 'request' object.
    // This quotation will construct the actual runtime call.
    // Example:
    // <@@ CallMyRuntimeHelper(wsdlUrl, op.Name, %%(args.[0])) @@>
    // where CallMyRuntimeHelper is a static method in your TP assembly or
    // a referenced helper library. This helper performs the actual SOAP call.
    Expr.DefaultValue(returnType) // Placeholder, replace with actual Expr

serviceType.AddMember(method)

The InvokeCode property is where the magic happens: it defines an F# quotation representing the code to be executed when the provided method is called at runtime. This code typically calls a runtime helper function.

6. Implementing the Runtime Logic (Invoking Methods)

The runtime helper (e.g., CallMyRuntimeHelper from the previous step) will:

  1. Construct the SOAP request XML.
  2. Make an HTTP POST request.
  3. Parse the SOAP response XML.

Basic SOAP Envelope Structure (using System.Xml.Linq.XDocument):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
open System.Xml.Linq

let createSoapEnvelope (operationName: string) (soapBodyContent: XElement) : XDocument =
    let soapNS = XNamespace.Get("http://schemas.xmlsoap.org/soap/envelope/")
    // You might need a specific namespace for your service body
    let serviceNS = XNamespace.Get("http://example.com/legacyservice/types")

    let body = XElement(soapNS + "Body", soapBodyContent)
    let envelope = XElement(soapNS + "Envelope",
                            XAttribute(XNamespace.Xmlns + "soap", soapNS),
                            // Add other namespaces if needed, e.g.,
                            // XAttribute(XNamespace.Xmlns + "ser", serviceNS),
                            body)
    XDocument(envelope)

// Example body content:
// let requestPayload = 
//     XElement(serviceNS + operationName + "Request",
//         XElement(serviceNS + "Parameter1", "Value1"),
//         XElement(serviceNS + "Parameter2", 42)
//     )
// let soapDoc = createSoapEnvelope operationName requestPayload
// let soapRequestString = soapDoc.ToString()

Remember to use correct namespaces as defined in your WSDL.

Conceptual HttpClient Call: This would be in a static helper method in your Type Provider’s assembly or a separate runtime library.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
open System.Net.Http

// This is a simplified runtime helper function
// It would be called by the code generated in method.InvokeCode
asyncপ static member ExecuteSoapRequestAsync<'TRequest, 'TResponse>
        (endpointUrl: string, soapAction: string, 
         requestPayloadGenerator: 'TRequest -> XElement, 
         responseParser: XElement -> 'TResponse, 
         requestData: 'TRequest) : Async<'TResponse> =
    
    async {
        use client = new HttpClient()
        let soapBodyContent = requestPayloadGenerator requestData
        
        // Ensure operationName here is just the name, not with "Request" suffix
        // The operation name is often part of the body's first child element
        // or derived from context.
        let operationNameForEnvelope = 
            if soapBodyContent.Name.LocalName.EndsWith("Request") then
                soapBodyContent.Name.LocalName.Replace("Request", "")
            else
                soapBodyContent.Name.LocalName

        let soapDoc = createSoapEnvelope operationNameForEnvelope soapBodyContent
        let content = new StringContent(soapDoc.ToString(), 
                                        System.Text.Encoding.UTF8, 
                                        "text/xml") // Or "application/soap+xml" for SOAP 1.2
        
        // SOAPAction is often required for WSDL 1.1 SOAP 1.1 bindings
        if not (String.IsNullOrEmpty(soapAction)) then
            content.Headers.Add("SOAPAction", soapAction)

        try
            let! response = client.PostAsync(endpointUrl, content) |> Async.AwaitTask
            response.EnsureSuccessStatusCode()
            
            let! responseString = response.Content.ReadAsStringAsync() |> Async.AwaitTask
            let responseXml = XDocument.Parse(responseString)
            
            // Navigate to SOAP body and then parse the specific response element
            let soapNS = XNamespace.Get("http://schemas.xmlsoap.org/soap/envelope/")
            let bodyElement = responseXml.Root.Element(soapNS + "Body")
            
            // Check for SOAP Fault
            let faultElement = bodyElement.Element(soapNS + "Fault")
            if faultElement <> null then
                let faultString = faultElement.Element(faultNS + "faultstring")?.Value
                let faultCode = faultElement.Element(faultNS + "faultcode")?.Value
                // Customize exception type as needed
                return raise (Exception(sprintf "SOAP Fault: %s (Code: %s)" 
                                                faultString faultCode))
            else
                // Assuming the first element in Body is the response payload
                let responsePayloadElement = bodyElement.Elements().FirstOrDefault()
                if responsePayloadElement = null then
                    return raise (Exception("SOAP response body is empty or malformed."))
                else
                    return responseParser responsePayloadElement
        with
        | ex -> 
            // Log ex or wrap it
            return raise (Exception(sprintf "SOAP request failed: %s" ex.Message, ex))
    }

endpointUrl and soapAction would come from parsing the WSDL’s service and binding sections. requestPayloadGenerator and responseParser would be functions that serialize/deserialize F# types to/from XElement.

7. Handling WSDL Extensions

WSDL extensions are custom XML elements within standard WSDL constructs. You need to identify where they might appear (e.g., wsdl:binding/wsdl:operation/ext:myExtension, or wsdl:portType/ext:customAnnotation).

Accessing extensions on a WSDL OperationBinding (from System.Web.Services.Description):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Assume 'opBinding' is an OperationBinding from a WSDL Binding
let myExtensionNamespace = "http://example.com/myextensions/v1"

for ext in opBinding.Extensions do
    match ext with
    | :? XmlElement as xmlExt ->
        if xmlExt.NamespaceURI = myExtensionNamespace && xmlExt.LocalName = "MyCustomConfig" then
            let targetSystem = xmlExt.GetAttribute("targetSystem")
            let retryAttempts = xmlExt.GetAttribute("retryAttempts")
            // Use these values to influence generated method behavior or annotations
            printfn "Found MyCustomConfig: Target=%s, Retries=%s" 
                    targetSystem retryAttempts
    | _ -> () // Other types of extensibility elements

You would parse xmlExt using XDocument.Parse(xmlExt.OuterXml) or by inspecting its Attributes and ChildNodes to extract meaningful information. This information could then be used to:

  • Add custom attributes to generated F# types/methods.
  • Pass specific configuration to the runtime helper functions.
  • Modify how types are generated (e.g., if an extension specifies a custom F# type mapping).

Advanced Considerations & Best Practices

  • Error Handling:
    • Compile-Time: Use config.AddError(errorMessage) or throw exceptions from the Type Provider constructor to report issues with WSDL parsing or type generation directly in the IDE.
    • Runtime: Parse SOAP Faults correctly and translate them into meaningful F# exceptions. Handle network errors gracefully.
  • Caching: Parsing WSDLs can be slow. Cache the ServiceDescription object and generated ProvidedTypeDefinitions. Use config.ResolutionFolder and config.ReferencedAssemblies to manage dependencies and cache invalidation if needed.
  • Configuration: Expose static parameters to the Type Provider for WSDL location, timeouts, credentials (handled securely!), or flags to control how extensions are processed.
  • SOAP Headers (Authentication, etc.): Allow users to pass custom headers or authentication tokens. This might involve adding optional parameters to generated methods or providing a way to configure client-wide headers.
  • Complex XSD Mapping: Properly handling xs:choice, xs:any, XSD inheritance, nillable="true", minOccurs="0" / "unbounded" (map to Option<'T> and 'T[] or list<'T>) requires significant effort.
  • Testing: Create a separate test project that consumes your Type Provider. Test against a live (or mock) SOAP service. Debugging Type Providers can be tricky; often, it involves attaching the debugger to the F# compiler host process (e.g., devenv.exe or dotnet.exe fsc.dll).

Limitations and Alternatives

  • Complexity: Building a robust Type Provider for the full WSDL & XSD specs is a significant undertaking.
  • dotnet-svcutil: For simpler SOAP services where F#-idiomatic code is less critical or WSDL extensions are not a factor, using dotnet-svcutil to generate C# code and then consuming that from F# might be a more pragmatic approach.
  • FSharp.Data.WsdlProvider: Historically, FSharp.Data had a WSDL Type Provider, but it is no longer actively maintained for modern .NET versions and faced challenges with WCF dependencies.

A custom Type Provider is most valuable when the out-of-the-box tools are insufficient due to the WSDL’s complexity, the presence of critical extensions, or the strong desire for a deeply F#-idiomatic client.

Conclusion

Building an F# Type Provider for legacy SOAP APIs with WSDL 1.1 extensions is a powerful way to bridge the gap between older enterprise services and modern F# development. It allows for the creation of type-safe, idiomatic, and highly adaptable client code that can intelligently interpret the nuances of specific WSDLs, including proprietary extensions.

While the development requires a solid understanding of WSDL, XML Schema, SOAP messaging, and the F# Type Provider SDK, the result is a significantly improved developer experience and more robust integration. By taking control of the client generation process, you can craft a solution perfectly tailored to the legacy API and your F# application’s needs.