Using Visual FoxPro to call
.Net Web Services for Data Access
By Rick Strahl
www.west-wind.com
rstrahl@west-wind.com
Last Update:
February 14, 2009
Source Code for this article:
http://www.west-wind.com/presentations/FoxProNetWebServices/FoxProNetWebServices.zip
West Wind Web Service Proxy Generator Tool:
http://www.west-wind.com/wsdlgenerator/
Using Web Services from Visual FoxPro is not difficult, but
dealing with Data or Complex objects is not quite as straightforward as it
could be. In this article, I’ll describe how you can work with .Net Web Services
and pass complex data between VFP and .Net and handle updating scenarios for
Data between the two.
Accessing Web Services from Visual FoxPro is nothing new.
But effectively leveraging Web Services and interacting with them especially
when dealing with .Net requires a more complete understanding of the mechanics
and the tools available to Visual FoxPro developers, than merely creating the
service and firing off method calls
Using the SOAP Toolkit from VFP is straight forward and
calling .Net Services is no different than calling any other service. But things
get sticky when we’re not running simple ‘Hello World’ type examples that return
little more than simple types. The issues involved here deal with the fact that
the SOAP Tooolkit (and most other tools SOAP Tools available to Visual FoxPro)
are woefully underequipped to effectively pass and translate complex types. In
this article
I’ll specifically look at passing DataSets and objects
between Visual FoxPro and a .Net Web Service and demonstrate how the simple call
model falls apart quickly when using complex types and datasets. It’s not
difficult to work around these issues, but you as the Visual FoxPro developer
has to realize that there will be extra work and likely some manual XML parsing
to make this happen.
In this article I’ll keep things pretty simple from an
application point of view to demonstrate the Infrastructure mechanics. I’m going
to skip over some architectural issues such as business object usage and defer
that to the simple business object that is included in the source
, but realize that these same principles also work with
more complex data scenarios that involve multiple tables or hierarchical
objects. To follow along with this article you’ll need Visual FoxPro 8 (or
later), Microsoft .Net 1.1, Microsoft IIS and Sql Server or MSDE with the Pubs
Sample Database installed.
Calling .Net Web Services from VFP
Let’s start with a quick review on how to create a .Net Web
Service and call it from Visual FoxPro. I’m going to use Visual Studio to do
this, but you can also do this without it, manually creating the project and
virtual directories and compile your Web Service and support files. You can
check the .Net framework documentation on how to do this. VS.Net makes this
process much easier and so this is what we’ll use.
Start by creating a new Web Service project. I call mine
FoxWebServiceInterop:
- Start the File | New | Project and select new Visual
C# Sharp Project.
- Select Web Service and type in your project name and
Web Server path. Figure 1 shows this dialog.
Figure 1 – The new project dialog creates a new
VS.Net Solution and project and creates a virtual directory on your local IIS
Web Server to host it.
The New Project Wizard then proceeds to create your
solution and project by creating a virtual directory on your Web Server and
copying a default web.config file and global.asax file which are application
specific files that are required for any Web application. A .Net Web Service is
really little more than a specialized Web application that uses an HTTP Handler
to process Web Service requests. In fact if you later choose to add ASP. Net Web
Forms to your project, they too can run out of this same directory.
The first thing I usually do to any ASP. Net or Web Service
project is remove the WebService1.asmx page and instead create a new one with a
proper name called FoxInteropService.asmx. In Figure 2 I also added a new folder
to project and stuck a few support classes into it that I will use later on to
handle data access. This is optional of course, but it’s a good idea to organize
your projects as soon as you start adding files to the project.
Figure 2 – The new project created with the
FoxInteropService.asmx Web Service file as well as a couple of supporting
business object classes I’ll use later on
Get to the code
So now we’re ready to create our Web Service logic. Let’s
start very simple with the mandatory Hello World example. Open the
FoxInteropService.asmx page in Code View. What you will find is a class that was
created for you with the same name as the service.
[WebService]
public
class FoxInteropService :
System.Web.Services.WebService
{
… Component Initialization code omitted for brevity
}
The [WebService] attribute designates that this class will
act as an endpoint for a Web Service and that any method that is marked up with
the [WebMethod] attribute will accessible through the service. One of the things
you probably will want to do right away is set a few additional options on the
WebService attribute to tailor the Web Service for your application:
[WebService(Namespace="http://www.west-wind.com/FoxWebServiceInterop/",
Description="Small
Sample Service that demonstrates interaction between .Net Web Services and
Visual FoxPro")]
public
class FoxInteropService :
System.Web.Services.WebService
This sets a specific and hopefully unique namespace for
your Web Service and provides a description which will be visible in the WSDL
file for the Web Service as well as the Service Description test page. Remember
that a namespace is a URI that is used as a unique identifier – it doesn’t have
to be a valid URL or even a URL at all although by custom URI tend to point at
something that looks like a URL. Use any unique value that can identify the
service.
Ok, time to make the service do something,so let’s add add
the following code (or modify the default commented out HelloWorld method):
[WebMethod(Description="The
Quintessential Hello World Method")]
public
string HelloWorld(string
Name)
{
return
"Hello World, " + Name +
"! The time is: " +
DateTime.Now.ToString();
}
The Description parameter is optional, but again a good
idea to provide the WSDL and Test Page with some information about your method –
[WebMethod] by itself is valid.
Go ahead and compile the code. Assuming the code compiled
you can now test the Web Service interactively by accessing the Web Service ASMX
page through a browser with:
http://localhost/FoxWebServiceInterop/FoxInteropService.asmx
Figure 3 shows what the resulting HTML test interface looks
like. This handy test page allows you to quickly see the methods available and
for the most part lets you test the Web Service functionality through a
simplified non-SOAP interface. Make sure that you can test the Service through
this interface first to make sure everything’s working on the server.
Figure 3 – A .Net Web Service provides you with
a nice Service Description page that shows all available methods and lets you
test them (assuming the methods use simple parameters as input). Note that any
descriptions you provide in the WebService attributes show up here.
And off we go to access the service from Visual FoxPro. The
following code requires that you have the SOAP Toolkit Version 3.0 installed.
From the command window or in a small PRG file you can type:
o = CREATEOBJECT("MSSoap.SoapClient30")
? o.MSSoapInit("http://localhost/FoxWebServiceInterop/"
+
"FoxInteropService.asmx?WSDL")
? o.HelloWorld("Rick")
To which you’ll get the super exciting response:
Hello World, Rick! The time is: 4/8/2004 8:14:38 AM
And that’s all it takes for basic access of the Web
Service. Note that you can make several method calls against the service after
the initial MSSoapInit call, but you cannot call MSSoapInit more than once – for
example to connect to a different Web Service. You’ll need a new instance of the
SoapClient instead.
Note that this sort of thing will work just fine with any
kind of simple parameters and return values. The Soap Toolkit will automatically
convert strings, integers, floats, Booleans and dates etc. into the proper XML
types and pass that data across the wire without a problem. For example, the
following simple Web method:
[WebMethod]
public DateTime
GetServerTime()
{
return DateTime.Now;
}
when called with:
lcWSDL = "http://localhost/FoxWebServiceInterop/" + ;
"FoxInteropService.asmx?WSDL")
o = CREATEOBJECT("MSSOAP.SoapClient30")
o.MSSoapInit(lcWSDL)
ltTime = o.GetServerTime()
? VARTYPE(ltTime) && T
will return a DateTime value natively. Conversely any
DateTime parameters you pass in are automatically marshaled to the server in the
proper type. This is really one of the big benefits of using Web Services over
simply firing XML at a server (which you can do BTW using HTTP GET or POST as
opposed to SOAP as the protocol). It provides a simple interface both on the
client and the server to have the two sides communicate with simple method
interfaces.
However, things get more tricky when you’re not dealing
with simple values. When passing more complex types like objects or datasets the
client and the server have to know what these types are in order to marshal the
content around. Both have to agree on the schema and the protocol to serialize
the object over the wire.
Getting friendly with Data
Let’s start with a special and very common case for
transferring data between .Net and Visual FoxPro. The .Net DataSet has become a
very common structure both for .Net and Visual FoxPro. In .Net the DataSet is
ADO.Net’s primary data container that holds data retrieved from queries. It is
ADO.Net’s primary data interface that is accessed by the user interface. In
Visual FoxPro DataSets are not directly supported, but Visual FoxPro 8.0
introduced the XMLADAPTER class which allows you to easily convert to and from
DataSets and VFP Cursors.
To demonstrate how we can utilize a DataSet to provide
offline data editing through a Web Service I’ll use a simple form based example
using the trusted old Sql Server Pubs sample database. I’ll start by retrieving
a DataSet from .Net into a Visual FoxPro cursor over the Web Service.
[WebMethod]
public DataSet
GetAuthors(string Name)
{
busAuthor Author = new
busAuthor();
int Result =
Author.GetAuthors(Name);
if (Author.Error)
throw
new SoapException(Author.ErrorMsg,;
SoapException.ServerFaultCode);
if (Result < 0)
{
return
null;
}
return
Author.DataSet;
}
Listing 1 demonstrates what the Web Service method looks
like on the .Net Server Side with C# code. Notice that I opted to use a business
object here to retrieve the DataSet. The business object runs the actual SQL to
retrieve the Author data and stores the resulting DataTable in the DataSet
property.
public
int GetAuthors(string
Name)
{
if (Name ==
null)
Name = "";
Name += "%";
return
this.Execute("select
* from " + this.TableName +
" where au_lname like
@Name",
"AuthorList",
new SqlParameter("@Name",Name)
);
}
Listing 2 demonstrates the business object method which
relies on the SimpleBusObj base class to handle the actual Execution of the Sql
statement. busAuthor inherits from SimpleBusObj which includes the Execute()
method to run a generic SQL command and return a result into the business
object’s DataSet property. Specifically in this case it generates an AuthorList
Table in this DataSet. You can take a look at the included source code to check
out the details of this reusable Sql code.
The DataSet stored in the Authors.DataSet property is then
sent wholesale back to the client via the Web Service method.
The Web Service method basically wraps the business object
method, but it provides a number of modifications in the way it presents itself
to the world from the underlying business method. As you can see it’s returning
a DataSet instead of the count of records since a Web Service method needs to be
stateless, and it throws a SoapException when an error occurs so that the client
can know what went wrong with the Service call. I’ll talk more about Error
handling later on in the article.
The main change is the DataSet result which provides the
marshalling mechanism to send the data to the client. Sending a DataSet is the
easiest way to share this data with Visual FoxPro. You could also pass the
entire business object, but VFP wouldn’t know what to do with this object and
you’d have to manually parse it on the VFP end. But you can easily pass a
DataSet which can contain one or more tables of data embedded inside of it which
in most cases is sufficient for the client side application.
Ok, let’s see what it takes on the Visual FoxPro end to
pick up this DataSet. This is not as simple as it was with the result value from
our previous methods because in essence we are returning an object from .Net.
Worse, the SOAP Toolkit has no idea how to deal with this object.
o = CREATEOBJECT("MSSOAP.SoapClient30")
o.MSSoapInit(lcWSDL)
loResult = o.GetAuthors("")
You will find that loResult returns an object. Specifically
loResult will contain an XMLNodeList object that points at the SOAP result node.
This seems like an odd choice, but this is how MSSOAP returns all embedded
object types. Even so, you can trace backwards to retrieve the XML of the
embedded Response (in this case the DataSet) XML or even the entire SOAP XML
document.
You can retrieve the the entire SOAP Response XML as an XML
string with this code:
lcXML = loResult.item(0).ownerDocument.Xml
Note that the returned object is of type NodeList which is
a collection of Node objects. We have to drill into the first one (if it exists)
and from there can walk backwards up the hierarchy to retrieve the ownerDocument,
or the parentNode.
The parentNode is really what we’re interested in as the
parentNode contains the actual XML for the our return value which is in this
case our DataSet.
lcXML = loResult.item(0).parentNode.Xml
The XML for this result is shown in Listing 3.
<GetAuthorsResult
xmlns="http://www.west-wind.com/FoxWebServiceInterop/">
<xs:schema
id="NewDataSet"
xmlns=""
xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xs:element
name="NewDataSet"
msdata:IsDataSet="true">
<xs:complexType>
<xs:choice
maxOccurs="unbounded">
<xs:element
name="AuthorList">
<xs:complexType>
<xs:sequence>
<xs:element
name="au_id"
type="xs:string"
minOccurs="0"
/>
<xs:element
name="au_lname"
type="xs:string"
minOccurs="0"
/>
<xs:element
name="au_fname"
type="xs:string"
minOccurs="0"
/>
<xs:element
name="phone"
type="xs:string"
minOccurs="0"
/>
<xs:element
name="address"
type="xs:string"
minOccurs="0"
/>
<xs:element
name="city"
type="xs:string"
minOccurs="0"
/>
<xs:element
name="state"
type="xs:string"
minOccurs="0"
/>
<xs:element
name="zip"
type="xs:string"
minOccurs="0"
/>
<xs:element
name="contract"
type="xs:boolean"
minOccurs="0"
/>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:choice>
</xs:complexType>
</xs:element>
</xs:schema>
<diffgr:diffgram
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"
xmlns:diffgr="urn:schemas-microsoft-com:xml-diffgram-v1">
<NewDataSet
xmlns="">
<AuthorList
diffgr:id="AuthorList1"
msdata:rowOrder="0">
<au_id>648-92-1872</au_id>
<au_lname>Blotchet-Halls</au_lname>
<au_fname>Reginald
</au_fname>
<phone>503
745-6402</phone>
<address>55
Hillsdale Bl.</address>
<city>Corvallis</city>
<state>OR</state>
<zip>97330</zip>
<contract>true</contract>
</AuthorList>
... More entries
</NewDataSet>
</diffgr:diffgram>
</GetAuthorsResult>
Note that the root node here is the name of the method plus
Result: GetAuthorsResult. This is the root node for the NodeList that is
returned to you. In the above document you have two nodes in this nodelist: The
xs:schema and diffgram:diffgram nodes which map to
loResult.Item(0) and loResult.Item(1) respectively.
Sounds complicated but once you’ve done this once or twice
it comes easy. This mechanism does give you control since you have access to the
entire SOAP packet through it.
So, now let’s see how we can retrieve this DataSet returned
from .Net into a cursor we can browse. Listing 4 shows the code to do this.
LOCAL o as MSSOAP.SoapClient30
o = CREATEOBJECT("MSSOAP.SoapClient30")
loException = null
llError=.f.
TRY
o.MSSoapInit(lcUrl)
CATCH TO loException
llError = .t.
ENDTRY
IF llError
? "Unable to load WSDL file from " + lcUrl
return
ENDIF
*** Retrieve authors - returns DataSet returned as
NodeList
loException = null
TRY
loNL = o.GetAuthors("")
CATCH TO loException
llError = .t.
ENDTRY
*** Check for SOAP Error first - set even if Exception
IF (o.FaultCode != "") && Client or Server (usually
Server)
? o.FaultString
return
ENDIF
*** If no SOAP Error check Exception
IF !ISNULL(loException)
? loException.Message
? loException.ErrorNo
RETURN
ENDIF
*** Call went throuh OK
*** Grab the XML - should be a dataset
loDOM = loNL.item(0).parentNode
lcXML = loDOM.Xml
LOCAL oXA AS XMLADAPTER
oXA = CREATEOBJECT("XMLAdapter")
oXA.LOADXML(lcXML,.F.,.T.) && from string
* oXA.Attach(loDOM) && Prefer using the DOM
IF (USED("Authors"))
USE IN AUTHORS
ENDIF
oXA.TABLES[1].TOCURSOR(.F.,"Authors")
BROWSE
I’ve included rudimentary error checking in this code.
Remember that you are pulling data from a Web Service so it’s a very good idea
to wrap every call to it in an exception block to make sure that you capture any
potential errors and handle them accordingly. I have more on this later.
Once you’ve retrieved the DataSet we still need to convert
it using the XMLAdapter object. XMLAdapter was introduced in Visual FoxPro 8 and
allows you to load an XML document and among other things convert it back into a
cursor. You can load XML from a URL, a DOM object or an XML string. The code
above shows how to get at a DOM node or an XML string from the returned data. In
this case, since a DOM node is available let’s use it and use the
oXA.Attach(loDOM) method to load the DataSet Xml.
Note that using a DOM object won’t always work due to the
fact that the SOAP Toolkit and XMLAdapter don’t always use the same version of
the XMLDOM. In my experience, I got flakey results where sometimes I’d get an
error and others not – I can’t really explain why other than that the SOAP
Toolkit was not consistent in returning XMLDOM 4.0 documents that the XMLAdpater
requires. The workaround for this is to load the XML as a string using
LoadXml():
oXA.LOADXML(lcXML,.F.,.T.) && from string
even though this incurs a little more overhead.
Not quite there yet
If you run the code above you should end up with a cursor
for the SQL Server Authors table. But this result probably won’t satisfy you –
the result you get is formatted wrong with all fields showing up as Memo fields.
The problem here is that we’re retrieving generic type data from that DataSet.
Type Data that is generated on the fly. If you look at Listing 3 again and look
at the XML schema you can see that the types are reported but none of the
lengths are. So any string value is returned as a Memo field, any numeric value
as a generic number and so on.
To get complete type information we need to make a change
to how ADO.Net retrieves our data from the Database. In our business object code
I can add this directive:
busAuthor Author = new
busAuthor();
// *** Force Complete Schema to
be loaded
Author.ExecuteWithSchema = true;
This translates into ADO.Net code that tells an ADO.Net
DataAdapter to retrieve data with full schema information:
SqlDataAdapter Adapter = new
SqlDataAdapter();
if (this.ExecuteWithSchema)
Adapter.MissingSchemaAction =
MissingSchemaAction.AddWithKey;
Adapter.SelectCommand = new
SqlCommand(SQLCommandString,Conn);
This code lives in the SimpleBusObj class’ Execute() method
which executes a SQL command. I made ExecuteWithSchema an optional flag on the
business object, so that I can choose when to include the schema. Normally you
don’t want to load Schema information because it causes extra data to be sent
down from SQL Server. But in Web Service front ends like our sample WebService
here, we always want the schema to be there so that disconnected clients can
properly parse the DataSet into local data structures that get dynamically
created such as Visual FoxPro cursors. As an alternative you can use pre-created
tables with the XMLAdapter in which case the tables act as the ‘schema’ to
figure out the data types.
Updating the Data on the Client
Now that we have the data on the client, what can we do
with it? Well, it’s a VFP writable cursor so you can party on the data all you
want! You can edit, update and add new records to your heart’s content. But
remember that the data is completely disconnected. We downloaded a DataSet from
the server and we turned it into a cursor, but there’s no direct connection
between this cursor and the Web Service or the Author data source sitting on the
server.
So in order to be able to work with this data offline and
sync with the server we need to track the changes we’re making and then submit
those changes back to the Web Server. Using the tools in Visual FoxPro 8 this
involves the following steps:
- Setting multi-row buffering on in the table
- Working with the Cursor and updating the data as
needed
- Using the XMLAdapter to generate a DiffGram DataSet of
the changed data
- Sending the Diffgram XML back to the server
The first step after the data has been downloaded as shown
in Listing 4 is to enable multi-row buffering in the new cursor so that changes
can be tracked. You do this with these two simple lines of code:
SET MULTILOCKS ON
CURSORSETPROP("Buffering",5)
At this point you’re free to make changes to the Cursor as
needed. When it comes time to update the data the first step is to generate a
DiffGram from this data using the XML Adapter. You’ll want to use the XML
Adapter so you can return XML in a format that is similar to the DataSet’s
GetChanges() method in ADO.Net. Listing 5 shows how to do this with the Authors
table in the Sample application.
LOCAL oXA as XMLAdapter
oXA = CREATEOBJECT("XMLAdapter")
oXA.UTF8Encoded = .t.
oXA.AddTableSchema("Authors")
oXA.IsDiffgram = .T.
lcXML = ""
oXA.ToXML("lcXML",,.f.,.T.,.T.)
RETURN lcXML
The key function here is AddTableSchema and the IsDiffGram
property. AddTableSchema adds a table to the XML Adapter for processing.
IsDiffGram makes sure that when you execute any ToXml() methods that the XML is
generated in DiffGram format – it only shows rows that have changed. Listing 6
shows the content of this DiffGram XML with a single record changed.
<?xml
version
= "1.0"
encoding="Windows-1252"
standalone="yes"?>
<VFPData>
<xsd:schema
id="VFPDataSet"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
… Data Structure Schema ommitted for space
</xsd:schema>
<diffgr:diffgram
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"
xmlns:diffgr="urn:schemas-microsoft-com:xml-diffgram-v1">
<VFPDataSet
xmlns="">
<Authors
diffgr:id="authors_1"
msdata:rowOrder="1"
diffgr:hasChanges="modified">
<au_id>648-92-1872</au_id>
<au_lname>Blotchet-Halls
II</au_lname>
<au_fname>Reginald</au_fname>
<phone>503
745-6402</phone>
<address>55
Hillsdale Blvd.</address>
<city>Corvallis</city>
<state>OR</state>
<zip>97330</zip>
<contract>true</contract>
</Authors>
</VFPDataSet>
<diffgr:before>
<Authors
diffgr:id="authors_1"
msdata:rowOrder="1"
xmlns="">
<au_id>648-92-1872</au_id>
<au_lname>Blotchet-Halls</au_lname>
<au_fname>Reginald</au_fname>
<phone>503
745-6402</phone>
<address>55
Hillsdale Bl.</address>
<city>Corvallis</city>
<state>OR</state>
<zip>97330</zip>
<contract>true</contract>
</Authors>
</diffgr:before>
</diffgr:diffgram>
</VFPData>
As you can see the XMLAdapter is pretty verbose in its
creation of the DiffGram – it includes a full schema of the data structure and
includes a full record for each change instead of just the fields. If you’ve
used the XMLUPDATEGRAM() function in VFP before you might be surprised at the
amount of XML generated. Unfortunately all of this is required in order for .Net
to be able to create a DataSet from your data in the same format as you would
get from the DataSet.GetChanges() method which also produces a ‘diffgram’
DataSet.
Passing XML DataSets back to the server
Now that we have our XML DiffGram we need to pass it back
to the server. We have a few problems here with Visual FoxPro that makes this
process a little less than optimal. The problem is that the MSSOAP Toolkit does
not deal well with objects passed from the Client to the server in a SOAP
method. There’s no problem passing simple types, but the SOAP client cannot
convert a Fox (or other COM object) into something that can be turned into a
SOAP representation automatically. There are ways that you can do this by
creating a COM object and generating a custom WSDL file, but this is a lot of
work and in most cases not worth the effort.
So with this limitation in mind you have a couple of
options for passing Complex Objects and DataSets: Passing an XMLNodeList which
like the return parameters is properly parsed into the SOAP document as raw XML.
Or you can pass the data as a string to the server, but this requires that the
Web Service includes a separate method that knows how to accept a string instead
of an object.
Let me demonstrate the former in all the gory details.
Let’s start by creating a Web Service method that can accept our changes in the
form of a DataSet as shown in Listing 7. The DataSet in this case will be a
DataSet of our DiffGram XML we generated.
[WebMethod]
public
bool UpdateAuthorData(DataSet Ds)
{
busAuthor Author = new
busAuthor();
Author.DataSet = Ds;
bool Result
= Author.Save("Authors");
return
Result;
}
// *** Business object Save Behavior (separate source
file SimpleBusObj.cs)
public
bool Save(string
Table)
{
if (!this.Open())
return
true;
SqlDataAdapter oAdapter =
new
SqlDataAdapter("select * from " +
this.TableName,this.Conn);
///
This builds the Update/InsertCommands for the data adapter
SqlCommandBuilder oCmdBuilder =
new SqlCommandBuilder(oAdapter);
int lnRows =
0;
try
{
///
sync changes to the database
lnRows = oAdapter.Update(this.DataSet,Table);
}
catch(Exception
ex)
{
this.SetError(ex.Message);
return
false;
}
finally
{
this.Close();
}
if ( lnRows
< 1 )
return
false;
return
true;
}
The Web Service method is pretty simple as it once again
uses the base functionality in the business object base class (shown on the
bottom for completeness) to persist its changes. The base behavior is pretty
straight forward using the stock DataAdapter/CommandBuilder syntax that
automatically builds the appropriate update/insert/delete statements for any
updated or added records in the DataTable.
If we want to call this method from Visual FoxPro over the
Web Service we need to first get our DiffGram XMLADAPTER Xml returned to us, and
then figure out how to to pass the DataSet as an object – not as a simple string
parameter – to the Web Service.
The MSSOAP Client allows sending of raw XML in the form of
an XMLNodeList, which is a collection of childnodes in the DOM. You basically
need to provide the nodes underneath the Document Element node of the document,
which can be retrieved by using:
loNL = loDom.DocumentElement.ChildNodes()
This NodeList can now be passed as the parameter for a
complex type or in this case a .Net DataSet. If we put all the pieces together
the code looks like shown in Listing 8.
oSOAP = CREATEOBJECT("MSSOAP.SoapClient30")
oSOAP.MSSoapInit(lcWSDL)
SELE AUTHORS && Assume Authors is loaded
*** Generate the DataSet XML
LOCAL oXA as XMLAdapter
oXA = CREATEOBJECT("XMLAdapter")
oXA.UTF8Encoded = .t.
oXA.AddTableSchema("Authors")
oXA.IsDiffgram = .T.
lcXML = ""
oXA.ToXML("lcXML",,.f.,.T.,.T.)
*** Convert the XML into a NodeList
loDOM = CREATEOBJECT("MSXML2.DomDocument")
loDom.LoadXMl(lcXML)
loNodeList = loDom.DocumentElement.ChildNodes()
*** Retrieve authors - returns DataSet returned as
NodeList
LOCAL loException as Exception
loException = null
TRY
*** Pass the NodeList as the DataSet parameter
llResult = oSOAP.UpdateAuthorData(loNodeList)
CATCH TO loException
llError = .t.
ENDTRY
*** Always check for errors
IF !ISNULL(loException)
lcError = oSOAP.FaultString
if EMPTY(lcError)
lcError = loException.Message
ENDIF
? lcError
? loException.ErrorNo
RETURN
ENDIF
*** Print the reslt - .t. or .f.
? llResult
RETURN
There are a couple of important pieces in that code. First
when you use the XMLAdapter always make sure that you use UTF8Encoded = .T.. The
XMLAdapter by default generated Ansi-1252 XML which will fail inside of the SOAP
Envelope it gets embedded into if you have any extended characters in your data.
SOAP usually encodes in UTF-8 so make sure your generated XML matches that.
You have to call ToXML() and then load the resulting XML
into an XMLDOM object. From there you navigate down to the ChildNodes() of the
DocumentElement which provides the NodeList parameter that the SOAPClient uses
to embed the XML into the document as an object – a DataSet object that the .Net
Web Service will recognize as a DataSet.
Rethinking Data Access
Ok now that we have the basics down let’s think about what
we’re doing here. We’re downloading data to the client side and we’re running
this data in a complete offline scenario potentially making changes to many
records at a time.
You might think right about now, what happens in conflict
scenarios? Well, the answer it’s not pretty. Basically the stock .Net update
mechanism will run updates until a failure occurs then fail and return an error.
This means by default at least you’re updating some records, but not all of
them, which is probably not a good way to go. ADO.Net supports the ability to
see exactly what failed by using the HasErrors property on each DataRow in a
DataTable as well as on the DataTable. There is also a ContinueUpdateOnError
method on the DataAdapter that allows you to submit all records and mark only
the failed ones instead of the default behavior that just stops (who thought
that this would be useful?). I’m not going to go into detail on the complex
topic of conflict resolution – I would point you to Chapter 11 of David Sceppa’s
excellent ADO.Net book from Microsoft Press if you want to see a number of
different ways to deal with update conflicts.
What I’m getting at is that ADO.Net has strong features for
detecting and negotiating update errors, but it requires fairly complicated
logic to make this work well. Doing this sort of thing remotely over a Web
Service becomes even more complex because you can’t easily pass the error
information and detail data back and forth and in most cases there’s no state
that would allow you to actually deal with conflicts based on a standard ADO.Net
DataSet scheme.
So what to do? First is to review whether update conflicts
are really an issue in your application. In many applications update conflicts
are extremely rare and when they do occur a last one wins is often not
unreasonable.
The next thing is to try and make sure that you issue data
updates as atomically as possible. In other words try to make sure you read
data, edit it and write it back immediately instead of hanging on to it on the
client. If your application is designed with always-on in mind then this
approach is reasonable.
.
Let’s examine what this looks like with the sample
application and code I showed so far. So far, I pulled down all the customer
data and then allowed the client to make changes to the data – multiple records
and send the changes back up to the server. First pulling down ALL of the data
in a huge batch is probably not a good idea as it takes time to pull this data
down, so a better approach might be to pull down just a list of all the authors
and then cause each click on an author to download the current author
information one record at a time.
To demonstrate the scenarios I created two samples – one
using batch updates and one using atomic updates in two separate form based
applications that use the FoxInteropService Web Service. Figure 4 shows the
Atomic version.
Figure 4 – The Web Service Authors sample form
demonstrates atomic data access
To handle the Atomic scenario, I added a couple of methods
to the Web Service shown in Listing 9.
[WebMethod]
public DataSet
GetAuthor(string ID)
{
busAuthor Author = new
busAuthor();
Author.ExecuteWithSchema =
true;
if (
!Author.Load(ID) )
throw
new
SoapException(Author.ErrorMsg,SoapException.ServerFaultCode);
// *** Must return entire
Data Set
// *** will be in the
'Authors' table
return
Author.DataSet;
}
[WebMethod]
public DataSet
GetAuthorsList()
{
busAuthor Author = new
busAuthor();
Author.ExecuteWithSchema =
true;
int Result =
Author.GetAuthors("","au_id,au_lname,au_fname",
"AuthorList");
if
(Author.Error)
throw
new SoapException(Author.ErrorMsg,
SoapException.ServerFaultCode);
if (Result <
0)
return
null;
return
Author.DataSet;
}
GetAuthor retrieves a single Author which will be used to
pull the author data as the user navigates to it in the list. GetAuthorsList()
pulls down just the data that is displayed in the list – ie. the first and last
names as opposed to all of the data for each record. For the Authors table this
would not be a big difference to pulling all of the data, but consider a list of
complex invoices with many fields where the display fields would be a tiny
percentage of the overall size of the data pulled.
Note that I didn’t create a new UpdateAuthors method – the
same logic used previously will still work now, because although we now will be
updating a single record instead of multiples, we’re still working with a
DataSet that updates the server. The update logic is identical.
On the Fox End the Authors form now includes the following
Init code shown in Listing 10:
* FUNCTION Form.Init
LPARAMETERS lcWSDLURL
IF EMPTY(lcWSDLURL)
lcWSDLURL =
"http://localhost/foxwebserviceInterop/foxinteropservice.asmx?WSDL"
ENDIF
SET PROCEDURE TO wwUtils ADDITIVE
SET DELETED On
SET SAFETY OFF
SET TALK OFF
SET EXACT OFF
THISFORM.oNet = CREATEOBJECT("MSSOAP.SoapClient30")
THISFORM.oNet.MSSOAPInit(lcWSDLURL)
*** Let SOAP inherit our System Proxy Settings
thisform.oNet.ConnectorProperty("ProxyServer")="<CURRENT_USER>"
thisform.oNet.ConnectorProperty("ConnectTimeout")=5000
thisform.oNet.ConnectorProperty("Timeout")=10000
IF THISFORM.LoadList()
THISFORM.oCustList.Value = 1
lcID = AuthorList.au_id
thisform.LoadAuthor (lcID)
ENDIF
THISFORM.BindControls = .T.
RETURN
Note that I pass in the WSDL file as a parameter so that I
can easily switch between test and production environments if necessary. I use a
Form property to hold a reference to the SOAP Client, so we can reuse this
single instance to make multiple Web Service calls. Keep in mind that the call
to MSSOAPInit always retrieves the WSDL file and forces parsing of the WSDL
which has some overhead. If your app calls the same Web Services frequently
you’ll want to cache that reference somewhere for reuse as I am doing here with
a form property.
Notice the use of the ConnectorProperty which allows you to
configure the HTTP Connection for the client. This method allows you set things
like the Proxy Server, Authentication information Timeouts and more as well as
providing the ability to modify the HTTP Header sent to the server. Using the
ProxyServer property with the “<CURRENT_USER>” value is a good idea to make the
client use the configured Proxy settings from Internet Explorer. Using this
default should handle most proxy scenarios and allow you to get away without any
custom configuration via code. Even so, custom configuration can be done via
other properties available on the HttpConnector object. For more information on
what’s available see the HTTPConnector30 topic in the MSSOAP Help file.
Once initialized we’re ready to make calls against the Web
Service and this is done in two methods, LoadList() which loads the listbox and
LoadAuthor which loads an individual Author.
**********************************************************
FUNCTION LoadList
*****************
*** Retrive the data from the COM object as an XML string
llError = .F.
this.cErrorMsg = ""
TRY
loNL = THISFORM.ONET.GetAuthorsList()
CATCH
llError = .T.
this.cErrorMsg = this.oNet.FaultString
IF EMPTY(THIS.cErrorMsg)
THIS.cErrorMsg = loException.Message
ENDIF
ENDTRY
*** Always check for errors
IF llError
MESSAGEBOX("Error Loading Data" +CHR(13) + ;
this.cErrorMsg)
RETURN .F.
ENDIF
IF USED("AuthorList")
USE IN AuthorList
ENDIF
lcXML = loNL.Item(0).ParentNode.Xml
*** Convert the data downloaded into a cursor from the
XML
LOCAL oXA AS XMLADAPTER
oXA = CREATEOBJECT("XMLAdapter")
oXA.LOADXML(lcXML,.F.,.T.)
oXA.TABLES[1].TOCURSOR(.F.,"AuthorList")
*** Set the Buffermode to 5 for the cursor
*** so we can send diffgrams
SET MULTILOCKS ON
CURSORSETPROP("Buffering",5)
RETURN .T.
**********************************************************
FUNCTION LoadAuthor
*******************
LPARAMETERS lcID
llError = .F.
this.cErrorMsg = ""
loException = null
TRY
loNL = this.oNet.GetAuthor(lcID)
CATCH
llError = .T.
this.cErrorMsg = this.oNet.FaultString
IF EMPTY(THIS.cErrorMsg)
THIS.cErrorMsg = loException.Message
ENDIF
ENDTRY
*** Always check for errors
IF llError
MESSAGEBOX("Error Loading Author" +CHR(13) + ;
this.cErrorMsg)
RETURN .F.
ENDIF
IF USED("Authors")
USE IN Authors
ENDIF
lcXML = loNL.Item(0).ParentNode.Xml
*** Convert the data downloaded into a cursor from the
XML
LOCAL oXA AS XMLADAPTER
oXA = CREATEOBJECT("XMLAdapter")
oXA.LOADXML(lcXML,.F.,.T.)
oXA.TABLES[1].TOCURSOR(.F.,"Authors")
*** Set the Buffermode to 5 for the cursor
*** so we can send diffgram for this single record
SET MULTILOCKS ON
CURSORSETPROP("Buffering",5)
THISFORM.Refresh()
This code is similar to what we saw before. The code for
both of these methods is similar too as they both pull data to the client in the
same way: By pulling down a dataset.
Notice also that I always use error handling around the Web
Service, which is crucial. Since you have no control over the Web Service you
always want to check the result from the Web Service as well as trapping any
requests in an Exception block. You can retrieve server side error messages by
reading the FaultString property on the SoapClient object. Note that the server
must throw a SoapException in .Net in order for a clean message to be retrieved
on the client side:
bool
Result = Author.Save("Authors");
if
(!Result)
throw new
SoapException(Author.ErrorMsg,SoapException.ServerFaultCode);
If a simple Exception is thrown or an unhandled error
occurs on the server you will end up with a stack trace on the client instead,
which is pretty much useless other than for debugging. Therefore it’s very
important that your Web Service handles all exceptions itself and re-throws any
internal exceptions as SoapExceptions so that the client can pick it up.
Finally the SaveAuthors method is called when the user
updates a single record as shown in Listing 12.
*** Don't update if we don't have to
IF GETNEXTMODIFIED(0) = 0
WAIT WINDOW "Nothing to save..." NOWAIT
RETURN
ENDIF
llError = .f.
thisform.cErrorMsg = ""
Result = .F.
*** Retrieve the Diffgram XML
lcXML = thisform.GetDiffGram()
loDOM = CREATEOBJECT("MSXML2.DomDocument")
loDOM.LoadXML(lcXML)
loNL = loDOM.DocumentElement.ChildNodes()
TRY
Result = thisform.oNet.UpdateAuthorData(loNL)
CATCH
llError = .T.
thisform.cErrorMsg = thisform.oNet.FaultString
ENDTRY
IF llError
MESSAGEBOX(thisform.cErrorMsg,0 + 48,"Update Error")
RETURN
ENDIF
IF !Result
MESSAGEBOX("Unable to save Customers",0+48,"Update
Error")
return
ENDIF
*** Update our table state so further updates
TABLEUPDATE(.T.)
*** Force the form to rebind
THISFORM.Refresh()
WAIT WINDOW "Author Saved..." TIMEOUT 4
RETURN
*************************************************************
FUNCTION GetDiffGram
********************
LPARAMETERS llReturnXmlAdapter
LOCAL oXA as XMLAdapter
oXA = CREATEOBJECT("XMLAdapter")
OXA.UTF8Encoded = .t.
oXA.AddTableSchema("Authors")
oXA.IsDiffgram = .T.
IF llReturnXmlAdapter
RETURN oXA
ENDIF
lcXML = ""
oXA.ToXML("lcXML",,.f.,.T.,.T.)
RETURN lcXML
Note that the first step here is to make sure that we
actually have an update to do. If you send an empty dataset to the server you
will actually get an error, aside from the fact that you’re wasting bandwidth
when you do this. GetDiffGram then uses the XMLAdapter to generate the XML for
the changed or inserted record. We then generate the NodeList and send it up to
the server with the same UpdateAuthors() method we called previously on the Web
Service.
Note that once the update is done we have to call
TableUpdate() to reset our internal tracking of the table buffer. Remember we’re
not writing the data locally, but on the server, but we have to keep the update
state in sync on the client side as well, so TableUpdate() is required here so
if we make another change to another record we don’t end up with both of them
sent up to the server.
The final piece is adding a new record. Adding a new record
is really just as simple as inserting a new record into the Authors table as
shown in Listing 13. All we have to make sure of is that the buffering is
properly set in this scenario.
lcSoc = INPUTBOX("Please Enter the Social Security
number:")
IF EMPTY(lcSoc)
RETURN
ENDIF
SELECT AUTHORS
*** Throw away other changes so we can start over
TABLEREVERT(.T.)
APPEND BLANK
REPLACE AU_ID WITH lcSoc
THISFORM.Refresh()
Atomicity is very common in Web Service as you want to
minimize the amount of data that comes over the wire as well minimizing the
possibility of update conflicts. As it turns out if you browse the data heavily
it’s quite possible that the bandwidth requirements are actually more than using
the all at once approach, but the big advantage is that you’re not tying up the
server for long requests by retrieving data in small chunks. This keeps
applications responsive and allows your client application to provide constant
feedback. In this respect, Web Service based applications are very different
from Windows applications and require a different thinking about data unlike
desktop apps where we often rely on instant access and the ability to statefully
lock data. That luxury is not available for Web based data access…
Other Complex DataTypes
DataSets are very useful in this scenario because they
translate really well into FoxPro cursors and back. But DataSets are not an
official SOAP data type – the type and format is specific to .Net and other
Microsoft tools. If you return a DataSet to a Java Application for example it
won’t know what to do with it, as it has no concept of a DataSet. For a Java
Application this means the XML has to be manually parsed.
You can also return objects from a .Net Web Service and
unlike DataSets if your objects are made of simple types you can expect most
SOAP clients to be able to consume and make sense of the objects on the client
side.
Unfortunately for Visual FoxPro (and other COM clients like
VB6) complex types mean problems. Specifically Visual FoxPro will not be able to
automatically receive a complex object because there’s no automatic mapping
mechanism available in the SOAP toolkit. Instead the SOAP Toolkit provides
complex objects in the same NodeList format that the DataSet was provided.
There’s no automatic parser available for this sort of object in the Visual
FoxPro language.
Let’s take a look at another example and use the
wwSOAP class that can provide some relief with parsing result objects.
Rather than returning a customer record as a DataSet row, let’s return the
record as an object. Listing 14 shows an AuthorEntity class. To make things a
little more interesting I also added a child object that contains Phonenumbers
to demonstrate hierarchical objects in this context.
[Serializable()]
public
class AuthorEntity : SimpleEntity
{
public
AuthorEntity()
{}
protected
DataRow Row;
public
PhoneInfo PhoneNumbers = new PhoneInfo();
public
String Au_id;
public
String Au_lname;
public
String Au_fname;
public
String Phone;
public
String Address;
public
String City;
public
String State;
public
String Zip;
public
Boolean Contract;
}
[Serializable()]
public
class PhoneInfo
{
public
string HomePhone =
"808 579-8342";
public
string WorkPhone =
"808 579-8342";
public
string Fax =
"808 801-1231";
public
DateTime ServiceStarted = Convert.ToDateTime("01/01/1900");
}
[Serializable()]
public
class SimpleEntity
{
public
void LoadRow(DataRow Row)
{
wwDataUtils.CopyObjectFromDataRow(Row,this);
}
public
void SaveRow(DataRow Row)
{
wwDataUtils.CopyObjectToDataRow(Row,this);
}
public ArrayList CreateEntityList(DataTable Table)
{
ArrayList EntityList =
new ArrayList();
foreach(DataRow
Row in Table.Rows)
{
// *** Must
dynamically create an instance of this type
SimpleEntity Entity =
(SimpleEntity)
Activator.CreateInstance(this.GetType());
Entity.LoadRow(Row);
EntityList.Add( Entity );
}
return
EntityList;
}
}
This is real simple implementation of an Entity class
that’s based on an underlying DataRow which is implemented in the LoadRow and
SaveRow methods that read and write the data from the object generically back to
the underlying DataRow using Reflection. With this class in place we can now
return an object from the Web Service as shown in Listing 15.
[WebMethod]
public
AuthorEntity GetAuthorEntity(string Id)
{
busAuthor Author = new
busAuthor();
if
(!Author.Load(Id))
return
null;
AuthorEntity Auth = new
AuthorEntity();
Auth.LoadRow(Author.DataRow);
return Auth;
}
Note that the object passed is not the business object itself, but rather an
entity object that serves as a Data Container. The reason for this is that the
business object is pretty heavy weight in what it contains. So we really just
want to pass down the data graph of this object which in this case is the entity
object (which could also be part of the business object itself).
This is also consistent with .Net’s client side Web Service
model. Any objects that are accessed on the client are treated as Proxy objects,
which are mere generated copies of the full objects returned from the Web
Service. In other words, a .Net Web Service Client receives an object of type
AuthorEntity, but it contains only the properties and fields of the object –
none of the methods, events or other logic.
The advantage of using an object return over a DataSet is
that this is much more lightweight than sending down a complete DataSet to the
client. Objects are persisted into the SOAP package without schemas as the
schema is provided in the WSDL document. In theory at least the client
application should be able to parse the object based on this WSDL description.
To pick up this object in the SOAP Toolkit we can now use
trusted code using the SOAP Toolkit:
o = CREATEOBJECT("MSSOAP.SoapClient30")
o.MSSoapInit(lcWSDL)
loNL = o.GetAuthorEntity("486-29-1786")
loRoot = loNL.Item(0).ParentNode
lcXML = loRoot.XML
With the SOAP Toolkit alone this as far as it will take
you. It provides no easy mechanism to take this object XML and provide you a
real object reference from it unless you create a custom type mapper which is
more of a hassle than parsing the XML in the first place. So, from here you have
to manually parse the XML using the XMLDOM to do anything useful with it.
However, there are a couple of alternatives you can use by
using the free wwSOAP and wwXML classes from
West Wind Technologies. With the XML returned you can use wwXML to parse the
XML to an object as long as you have an object that has a matching structure:
oSOAP = CREATEOBJECT("MSSOAP.SoapClient30")
oSOAP.MSSoapInit(lcWSDL)
loNL = o.GetAuthorEntity("486-29-1786")
loRoot = loNL.Item(0).ParentNode
*** Create an object with the server’s structure
loAuthor = CREATEOBJECT("Relation")
loAuthor.AddProperty("Au_id","")
loAuthor.AddProperty("Contract",.f.)
…
loAuthor.AddProperty("PhoneNumbers",CREATEOBJECT("RELATION"))
loAuthor.PhoneNumbers.AddProperty("HomePhone","")
loAuthor.PhoneNumbers.AddProperty("ServiceStarted",DATETIME())
oXML = CREATEOBJECT("wwXML")
oXML.lrecurseobjects = .t.
*** Parse XML into loAuthor object with
CaseInsensitive Parsing
oXML.ParseXmlToObject(loRoot,loAuthor,.t.)
? loauthor.au_lname
? loAuthor.PhoneNumbers.HomePhone
? loAuthor.PhoneNUmbers.ServiceStarted
wwXML includes a number of parsing methods that can create
XML from an object and vice versa. Here one of the low level methods
ParseXmlToObject() is used to turn XML into an object that has been provided.
wwXML’s object parsing can work without a Schema as long an object is provided
as input – the input object serves as the Schema in this case. wwXML parses
through the object properties and looks for the corresponding XML elements to
find a match. It retrieves the string value and converts it to the proper type.
Note that the last .T. flag in the call specifies that the
parsing is to occur case insensitively. VFP property names are never case
sensitive (they’re always lower case), but of course XML nodes are. This poses a
big problem as it’s difficult to match up a VFP object’s properties with those
of a .Net object that uses common CamelCase for property names. I REALLY wish
VFP would support proper casing for objects internally! Anyway, the workaround
is to use the llParseCaseInsensitive flag which is more time consuming as wwXML
loops through all elements for each property to find a match – XPATH has no way
to do case-insensitive searches. Bad as it sounds this parsing is relatively
fast because objects property lists tend to be fairly small. On single objects
this certainly won’t be a problem, but it might get slow if you’re parsing many
objects in a loop.
Making Life easier for Object Parsing with wwSOAP
If you want to get an object created for you on the fly
rather than pre-creating one you can use the wwSOAP class in many cases. wwSOAP
includes a method called ParseObject() which can take a DOM element as input and
parse an object based on the WSDL description.
o = CREATEOBJECT("MSSOAP.SoapClient30")
o.MSSoapInit(lcWSDL)
loNL = o.GetAuthorEntity("486-29-1786")
loSoap = CREATEOBJECT("wwSoap")
loSOAP.Parseservicewsdl(lcWSDL,.t.)
loAuthor = loSOAP.ParseObject(loNL.Item(0).ParentNode,
"AuthorEntity")
? loauthor.au_lname
? loAuthor.PhoneNUmbers.ServiceStarted
You first call ParseServiceWsdl to parse the WSDL file
which creates an internal structure that describes the methods and object types
available. The ParseObject method then is called with a type name as the second
parameter. If the type is found wwSOAP creates the type based on the WSDL
structure and updates the values from the XML structure (using wwXML as shown
above internally).
If you want to take this one step further you can skip
using the MSSOAP client altogether and use the wwSOAP class and a few helper
classes to return data to you directly. Let’s call this same Web Service one
more time with wwSOAP as shown in Listing 17.
oSOAP = CREATEOBJECT("wwSOAP")
oSOAP.nHttpConnectTimeout = 5
oSOAP.lParseReturnedObject = .T.
oSOAP.AddParameter("Id","486-29-1786")
loAuthor = oSOAP.CallWSDLMethod("GetAuthorEntity",lcWSDL)
IF oSOAP.lError
? oSOAP.cErrorMsg
ENDIF
? loauthor.au_lname
? loAuthor.PhoneNumbers.HomePhone
? loAuthor.PhoneNUmbers.ServiceStarted
This is lot easier – this mechanism automatically creates
the underlying object. The lParseReturnedObject property determines whether any
objects are parsed on return, which makes wwSOAP try to parse the returned
object. It does this by looking at the WSDL description, creating an object on
the fly and reading values in one at a time. This works with plain or nested
objects, but it doesn’t work with collections or arrays at this time. If there
are any arrays or collections in the object returned these won’t be parsed
properly.
If lParseReturnedObject is set to .F. or wwSOAP can’t find
a matching object structure in the WSDL file it returns an XMLDOMNode. Unlike
MSSOAP wwSOAP returns the top level node object of the Return value (to get the
same value as MSSOAP returns you can use loNode.ChildNodes()), since this makes
much more sense for further parsing if you’re using tools like wwXML or you’re
manually walking through the XML using the XMLDOM.
wwSOAP also handles exceptions internally – you can check
the lError and cErrorMsg properties instead to retrieve error information. In
addition wwSOAP also makes it easy to retrieve the Request and Response Xml via
a simple property for easier debugging.
Updating the Business object with an Object parameter
Ok, this solves one part of the problem – retrieving
parameters. But what about sending them to the server? wwSOAP can help with this
task as well, but it takes a little more work.
Mapping Visual FoxPro objects to WSDL structures is a mess
because VFP doesn’t support mixed case property values. So there’s not a one to
one mapping from VFP type to the XML object structure. wwSOAP however provides a
method called ParseObject() which uses the WSDL to construct an object
dynamically on the fly adding properties to the object and then reading the
values from the XML into it.
LOCAL loSoap
as wwSOAP
loSoap = CREATEOBJECT('wwSoap')
loSoap.Parseservicewsdl(lcWSDL,.T.)
loAuthor = GetAuthorProxyObject()
loAuthor.au_lname = "Strahl"
loAuthor.au_fname = "Rick"
loAuthor.Phone = "808 123-1211"
loAuthor.Address = "32 Kaiea Place"
loAuthor.City = "Paia"
loAuthor.State = "HI"
loAUthor.Zip = "96779"
loAuthor.Contract = 0
loAuthor.PhoneNumbers.HomePhone = "(808) 579-3121"
loAuthor.PhoneNumbers.ServiceStarted = DateTime()
lcXML =
loSOAP.CreateObjectXmlFromSchema(loAuthor,"AuthorEntity")
CreateObjectXmlFromSchema looks at the WSDL file and then
parses the specified object from the passed in object reference. The result of
this call is shown in Figure 20 - notice the proper casing of the XML. The only
downside here is that wwSOAP requires access to the WSDL file so if you’re using
MSSOAP in conjunction with this you end up with two trips to read the WSDL file.
Note also that you have to create the object as a Fox Object first and this
object should match the structure of the server side object exactly.
Let’s look at another example that uses an AuthorEntity
object to update the Authors from the client. Listing 19 shows the
UpdateAuthorEntity method which receives the same AuthorEntity object shown in
Listing 14 as a parameter.
[WebMethod]
public
bool UpdateAuthorEntity(AuthorEntity
UpdatedAuthor)
{
if (UpdatedAuthor
== null)
return
false;
busAuthor Author = new
busAuthor();
if (!Author.Load(UpdatedAuthor.Au_id))
{
if (!Author.New())
return
false;
}
UpdatedAuthor.SaveRow(Author.DataRow);
if (!Author.Save())
return
false;
return
true;
}
This code loads the business object based on the Author’s
Id. If not found a new Author record is created. Once our author record is
loaded – existing or new – we update the data with the data retrieved from the
Web Service parameter by saving the contents to the business object’s DataRow
member. SaveRow copies the Entity properties to the DataRow fields thus updating
the business object. The Business object then can simply save the order.
On the FoxPro end we have more work to call this Web
Service method. As previously mentioned there’s no built-in mechanism to create
an object graph into XML that matches. wwXML does support a method called
ObjectToXml but it can only generate element names in lower case since VFP
doesn’t support mixed case in property names. So, we’re stuck with manually
generating XML in some way. The XML to generate must look as shown in Listing
20.
<AuthorsEntity>
<UseColumns>false</UseColumns>
<PhoneNumbers>
<HomePhone>808 579-8342</HomePhone>
<WorkPhone>808 579-8342</WorkPhone>
<Fax>808 801-1231</Fax>
<ServiceStarted>1900-01-01T00:00:00.0000000-10:00</ServiceStarted>
</PhoneNumbers>
<Au_id>486-29-1789</Au_id>
<Au_lname>Locksley IV</Au_lname>
<Au_fname>Charlene</Au_fname>
<Phone>415 585-4620</Phone>
<Address>18 Broadway Av.</Address>
<City>San Francisco</City>
<State>CA</State>
<Zip>94130</Zip>
<Contract>true</Contract>
</AuthorsEntity>
To make the Web Service call is shown in Listing 21.
LOCAL loSoap
as wwSOAP
loSoap = CREATEOBJECT('wwSoap')
loSoap.Parseservicewsdl(lcWSDL,.T.)
&& Parse objects
loAuthor = GetAuthorProxyObject()
loAuthor.au_lname = "Strahl"
loAuthor.au_fname = "Rick"
loAuthor.Address = "32 Kaiea Place"
loAuthor.City = "Paia"
loAuthor.State = "HI"
loAUthor.Zip = "96779"
loAuthor.Contract = 0
loAuthor.PhoneNumbers.HomePhone = "(808) 579-3121"
loAuthor.PhoneNumbers.ServiceStarted = DateTime()
*** Array of Invoices
loAuthor.Invoices[1] = loInv1
loAuthor.Invoices[2] = loInv2
lcXML =
loSOAP.CreateObjectXmlFromSchema(loAuthor,"AuthorEntity")
loDom = CREATEOBJECT("MSXML2.DomDocument")
loDom.LoadXml(lcXML)
ShowXml(loDom.DocumentElement.Xml)
#IF .T.
loSOAP = CREATEOBJECT("MSSOAP.SoapClient30")
loSOAP.MSSoapInit(lcWSDL)
llResult =
loSOAP.UpdateAuthorEntity(loDom.DocumentElement.ChildNodes)
? llResult
? loSOAP.FaultString
RETURN
#ENDIF
loSOAP.AddParameter("UpdatedAuthor",loDom.DocumentElement.ChildNodes,"NodeList")
llResult =
loSOAP.CallWsdlMethod("UpdateAuthorEntity",lcWSDL)
? loSOAP.cErrorMsg
? llResult
? loSoap.cRequestXml
I showed both wwSOAP and MSSOAP in this example. Notice
that with wwSOAP you have more options for displaying the XML that was sent and
retrieved and you only need to access the WSDL file once.
Error Handling in Web Services
I’ve been mentioning error handling off and on throughout
this document and it should be fairly obvious that dealing with data that comes
from a Web Service is very different than data that comes from a local source.
You should ALWAYS check for errors after a Web Service call.
The first thing you should do on the server is to make sure
that you capture all Exceptions and wrap them into a SoapException. You’ve seen
the code above like this:
if (Author.Error)
throw
new SoapException(Author.ErrorMsg,
SoapException.ServerFaultCode);
Plain exceptions don’t get properly formatted into SOAP
exceptions so you might want to capture any Exceptions and rethrow them as
SoapExceptions(). Non-SoapException exceptions get thrown back as more complex
messages that must be parsed on the client so using SoapException on the server
is a must and part of the requirements for WebMethods in general.
Avoid this Gotcha:
Here’s a real head scratcher if you don’t know: In your typical ASP. Net
debugging environment you might have the following in your web.config file:
<configuration>
<system.web>
<customErrors
mode="RemoteOnly"
/>
</system.web>
</configuration>
This setting enables detailed error pages to be displayed
with ASP.Net pages. It also causes detailed stack trace information to be sent
to a Web Service client which makes for less than usable error messages. If
you’re debugging Web Services you’ll want to change the value of RemoteOnly
to On, which displays ‘friendly’ error messages for ASP.Net errors. For
Web Services this means only the immediate Error information is returned to the
client, rather than a full stack trace.
On the client side the MSSOAP toolkit is pretty messy in
picking up Soap errors as it throws a COM exception AND sets a number of
properties that parse the error information more cleanly.
Luckily in VFP 8 and later this has gotten a lot easier to
deal with by simply using a TRY/CATCH block around the code. To review Listing
22 shows typical MSSOAP error handling.
o = CREATEOBJECT("MSSOAP.SoapClient30")
loException = null
llError=.f.
TRY
o.MSSoapInit(lcUrl)
CATCH TO loException
llError = .t.
ENDTRY
IF llError
? "Unable to load WSDL file from " + lcUrl
return
ENDIF
TRY
loNL = o.GetAuthors("")
CATCH TO loException
llError = .t.
ENDTRY
IF llError
lcError = loNL.FaultString
IF EMPTY(lcError)
lcError = loException.Message
ENDIF
? lcError
? loException.ErrorNo
RETURN
ENDIF
*** No we’re ready to something with our result
Exception handling should start with the MSSoapInit call.
Remember we’re talking about a Web Service here and the MSSoapInit call is going
out to the Web to read the WSDL file and then parses it. A lot of things can go
wrong here, so don’t just assume this call will succeed.
You can capture actual SOAP Method Exceptions around a
call and first check the FaultString which returns a parsed error message from
the Web Service. This message gets set even if the SOAP client is throwing an
exception so the TRY/CATCH serves more as a handler to ignore the error rather
than really catching the Exception.
That’s still a lot of code you have to deal with. If you’re
using wwSOAP, it makes error handling quite a bit easier as it handles any Web
Service errors internally and simply sets a flag you can check as shown in
Listing 23.
oSOAP = CREATEOBJECT("wwSOAP")
loAuthor = oSOAP.CallWSDLMethod("GetAuthors",lcWSDL)
IF oSOAP.lError
? oSOAP.cErrorMessage
RETURN
ENDIF
*** Now we’re ready
? loAuthor.au_id
Using Wrapper Classes to provide Web Service logic
When using Web Services you should treat them like you
treat business objects. They aren’t business objects, but they are abstracting a
remote application that happens to be accessed over a Web Service. Because you
probably will call the Web Service from many locations in your application you
probably should create a wrapper that abstracts the usage of the Web Service to
a large degree.
In particular you should isolate the underlying logic that
controls how the Web Service is accessed out of your front end code. Just like
the front end should never talk to the database directly in good business object
design application, the front end should never talk directly to the SOAP proxy.
There’s no reason you should ever have a reference to the MSSOAP or wwSOAP
object directly in your front end application code. Instead create a class that
wrappers the Web Service and then use that class instead.
To help with this process I’ve created a Web Service Proxy
generator that creates a class consisting of all the methods of the Web Service.
Figure 5 shows this tool in action.
Figure 5 – Generating a Visual FoxPro Proxy
class that wraps the SOAP client provides Intellisense and abstracts calling the
Web Service.
This utility creates a wrapper class for the Web Service, a
loader and a small block of test code you can run. Once you have created this
wrapper class you can very easily call your Web Service like this:
DO foxInteropSerivceProxy && Load classes
o = CREATEOBJECT("foxInteropServiceProxy",0) && wwSOAP
loAuthor = o.GetAuthorEntity("431-12-1221")
IF o.lError
? o.cErrorMsg
RETURN
ENDIF
? loAuthor.au_lname
? loAuthor.PhoneNumbers.WorkPhone
If you type the above you’ll also notice that because this
is a full Visual FoxPro class you get real Intellisense on it (instead of the
hokey Web Service Intellisense through the Web Service Wizard).
In this case I’m using wwSOAP and it automatically parsed
the object into return value. You can also switch transparently between using
wwSOAP and MSSOAP by setting the client mode which is passed with the Init to 1
instead of 0. The code will work the same except that in the example above
MSSOAP will return an XML Nodelist instead of the actual object which wwSOAP
automatically parsed.
The code generated for each method looks something like the
code shown in Listing 24.
FUNCTION GetAuthorEntity(Id as string) AS variant
LOCAL lvResult
DO CASE
*** wwSOAP Client
CASE THIS.nClientMode = 0
THIS.SetError()
THIS.oSOAP.AddParameter("RESET")
THIS.oSOAP.AddParameter("Id",Id)
lvResult=THIS.oSOAP.CallWSDLMethod("GetAuthorEntity",THIS.oSDL)
IF THIS.oSOAP.lError
THIS.SetError(THIS.oSOAP.cErrorMsg)
RETURN .F.
ENDIF
RETURN lvResult
*** MSSOAP Client
CASE THIS.nClientMode = 1
LOCAL loException
TRY
lvResult = THIS.oSOAP.GetAuthorEntity(Id)
CATCH TO loException
this.lError = .t.
this.cErrorMsg = this.oSoap.FaultString
IF EMPTY(this.cErrorMsg)
this.cErrorMsg = loException.Message
ENDIF
ENDTRY
IF this.lError
RETURN .F.
ENDIF
RETURN lvResult
ENDCASE
ENDFUNC
As you can see the wrapper methods standardize the method
calls and automatically perform the error handling for you so you drastically
reduce the amount of error checking code you have to write for each request. The
wwSOAPProxy base class also provides easy access properties for setting username
and password, proxy configuration options and handles errors for loading the
WSDL file.
The generated proxy code also contains a section where you
can store code that you’ve changed. If you make changes to a specific method you
can move the method to this protected area. Then when you regenerate the class
the generator preserves the changes – however, it will also generate another
copy of your overridden method which you have to delete. Still this goes a long
way to let you modify this file.
I personally prefer another approach though, which is to
create yet another wrapper class for the Web Service. In this subclass I usually
perform additional tasks that make it easier to call my Web Service. For
example, consider the GetAuthors() method described earlier. This method
retrieves a result as a DataSet, but this value is returned to as an XML DOM
item rather than the cursor that we really would like to use. So a wrapper class
could address this scenario nicely. Listing 25 demonstrates the wrapper method
for GetAuthors().
*************************************************************
DEFINE CLASS FoxInteropServiceClient AS
FoxInteropServiceProxy
*************************************************************
************************************************************************
* foxInteropServiceClient :: GetAuthors
****************************************
*** Function: retrieves all authors into a cursor named
Authors
*** Pass: Name
*** Return:
************************************************************************
FUNCTION GetAuthors(Name as string) as Boolean
*** Call the base PRoxy
loNL = DODEFAULT(Name)
IF this.lError
RETURN .f.
ENDIF
*** Grab the XML - should be a dataset
IF this.nClientMode = 1
loDOM = loNL.item(0).parentNode
ELSE
loDOM = loNL
ENDIF
lcXML = loDOM.Xml
LOCAL oXA AS XMLADAPTER
oXA = CREATEOBJECT("XMLAdapter")
oXA.LOADXML(lcXML,.F.,.T.)
IF (USED("Authors"))
USE IN AUTHORS
ENDIF
oXA.TABLES[1].TOCURSOR(.F.,"Authors")
RETURN .t.
ENDDEFINE
So now we can call this method much more easily in any
front end code:
o = CREATEOBJECT("foxInteropserviceClient")
llResult = o.GetAuthors("")
IF o.lError
? o.cErrorMsg
RETURN
ENDIF
*** Authors table exists now
BROWSE
The FoxInterop Client now behaves like any other business
component in a typical application. Through this approach we’ve accomplished a
lot of functionality:
- Encapsulation
All logic related to the Web Service is now centralized in 1 top level
class.
- Abstraction
We’re never talking to the underlying SOAP protocol directly. If we need to
update or switch SOAP clients later we can make changes in one place.
- Error Handling
The error handling is provided internal to the class so you don’t have
to deal with the same few lines of code over and over again.
- Type conversion
The wrapper can provide a clean data interface to your application.
You’re not passing around XML strings or DOM nodes but rather can input or
output cursors or pass or return objects.
This is really very similar to a ‘middle tier’
implementation where the Web Service Proxy (MSSOAPClient or wwSoap Client) is
the data access layer, and where the ‘wrapper’ class is your business logic
layer. Conceptually the wrapper could now be accessed directly or still plug
into your business object layer to provide even more abstraction to the front
end.
Ready for Service
Wow – a lot of information in this article. Armed with this
information you should be ready to tackle your .Net Web Services from Visual
FoxPro. Web Services are relatively easy to use and powerful, but it’s important
to keep in mind that although they mimic local method calls, they are
fundamentally different. The disconnected nature means that data passed over the
wire is only a transport and not real objects or cursors. Conversion is required
in almost all situations. But armed with good understanding you can pass data in
a large variety of ways between the client and server.
Which approach is best? Object or DataSet or even arrays or
collections of objects? It depends on your application. For Visual FoxPro
applications I think that using DataSets and XMLAdapters provide the easiest
path of sharing data between the two platforms as this makes it easy for VFP to
consume the data in cursor format. The fact that the synchronization mechanism
is built into ADO.Net (DataSet) and VFP both (via XMLAdapter/Table Buffering)
makes it easy to pass changed data back and forth. DataSets are expensive in
terms of content being shipped over the wire, but for the functionality they
provide this may be well worth it.
Objects are more light weight but they are a little more
difficult to work with from Visual FoxPro and if you do things like update
multiple records in tables to send up to the server you will have to manage your
own update logic and conflict resolution. This maybe OK – it’s essentially more
low level. It’s a good idea to be consistent with your approach, but at the same
time remember that you can mix and match when necessary. Use whatever works best
for the situation.
As always if you have comments or questions, please post
them on the West Wind Message Board (www.west-wind.com/wwThreads/)
in the White Paper section.
Source Code for this article:
http://www.west-wind.com/presentations/FoxProNetWebServices/FoxProNetWebServices.zip
West Wind Web Service Proxy Generator Tool:
http://www.west-wind.com/wsdlgenerator/
|