How to make PSI Extensions in Project Server 2010 - Part II
In this part, we will actually do something useful with the extension service by filling out the skeleton service that was the result of the last post. One of the places where the extension services come in handy is when you want to aggregate data before sending it back to the client. In this post, I will develop exactly such a service: My service will, when activated by a client, get the user ID of the caller and with that call the PSIs to get the time sheet list for that particular user. I know this is not too fancy, but I think it illustrates the purpose of my extension service.
Knowing that we will call the PSIs ourselves, this leads to the first decision that has to be made: How will we call the back-end from the extension service? There are a couple of options. One option is to generate proxy objects that match the services exposed by the PSIs. Another option is to use the interface assemblies directly and do more of the plumbing yourself. It seems to me that the former method is well described; I will use the latter approach. Personally, I prefer this method for a couple of reasons: I always find the code that is generated off the MEX data is clunky and ugly. And, too much of it is generated – you typically end up with more files than strictly needed when you go this route. Further, it gives me a sense of being more in control when using the interfaces explicitly and creating the channels myself etc.
The next thing that has to be decided is the signature of the operation. The interesting thing to decide is how to handle return types and parameters. Project Server relies heavily on datasets to pass data between client and server. Therefore, it would be a natural approach to use the typed datasets and rely on the assemblies that are installed with Project Server. Another approach is to serialize everything to XML yourself and so be totally platform independent. This is the approach that I have chosen: My extension service is relatively simple, and I don’t want the hassle of assemblies that have to be deployed. So, with that in mind, I can rely on simple types such as a string containing all the data of the retrieved dataset as XML.
With the decisions behind us, we can start to write the code. First, we have to change the contract of the extension service. Instead of having a method called EchoHello, let’s call it something more appropriate: ReadTimesheetListForCurrentUser makes the entire contract definition look like this:
namespace Project.Server.Extensions
{
[ServiceContract]
internal interface IExtensionPSIService
{
[OperationContract]
string ReadTimesheetListForCurrentUser();
}
}
Also change the actual implementation so that it looks like this:
// Other using declarations omitted for brevity
using Microsoft.Office.Project.Server.Interfaces;
using Microsoft.Office.Project.Server.Schema;
namespace Project.Server.Extensions
{
[AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Allowed)]
internal class ExtensionPSIService : IExtensionPSIService
{
private static string serverUri;
private static BasicHttpBinding binding;
#region IExtensionPSIService Members
[OperationBehavior(Impersonation = ImpersonationOption.Required)]
public string ReadTimesheetListForCurrentUser()
{
try
{
// Get the user ID of the calling user
var resourceChannel = CreateClientChannel<IResource>();
Guid userUid = resourceChannel.GetCurrentUserUid();
((IClientChannel)resourceChannel).Close();
((IClientChannel)resourceChannel).Dispose();
// Get the time sheet for the user
var timeSheetChannel = CreateClientChannel<ITimeSheet>();
var timeSheetData = timeSheetChannel.ReadTimesheetList(userUid,
DateTime.Now.AddDays(-7),
DateTime.Now,
1);
((IClientChannel)timeSheetChannel).Close();
((IClientChannel)timeSheetChannel).Dispose();
// Get a reader of the data...
var xmlData = timeSheetData.GetXml();
return xmlData;
}
catch (Exception)
{
throw;
}
}
#endregion
…
Let’s go through the code in greater detail:
I start by declaring a couple of variables that I will need during operation: serverUri is going to hold the Uri to the Project Server front-end routing service and binding is the binding we use to connect to the Project Server front-end service.
I have adorned the ReadTimesheetListForCurrentUser method with an operation behavior that specifies that impersonation is required for this method. This is because I want to retain the identity of the caller of my extension service when I call Project Server. The method includes the actual calls that are to be aggregated. First I call the Resource business object to get the unique ID of the calling user, and then I call the Timesheet business object to retrieve the timesheet list for the calling user. You may want to allow the caller to specify the range of the timesheet list in more flexible way in your production code.
Finally, after retrieving the dataset that contains the timesheet list, I read the XML representation of that dataset and return it to the caller. To make this work, I use a few helper functions which I will go through now. First, I use CreateClientChannel<T> to ease the work associated with getting a channel to the targeted service. T is the “shape” of the channel, or the interface type you are using. The helper function looks like this:
private static TChannel CreateClientChannel<TChannel>()
{
var channelFactory = new ChannelFactory<TChannel>(
Binding,
new EndpointAddress(ServerUri));
channelFactory.Credentials.Windows.AllowedImpersonationLevel = TokenImpersonationLevel.Impersonation;
return channelFactory.CreateChannel();
}
The helper internally makes use of two properties, Binding and ServerUri, which are just the variables explained above exposed as properties with a lazy initialization pattern on top of them. Note that in production code, you’ll probably want to cache the ChannelFactory<T> because that is the most expensive object to create. The methods used to lazily initialize the two Binding and ServerUri properties are written like this:
private static string GetServerUri(HttpContext context)
{
var schemeAndServer = context.Request.Url.GetComponents(
UriComponents.SchemeAndServer,
UriFormat.SafeUnescaped);
var requestUrl = context.Request.Url;
int portNo = 80;
var portString = requestUrl.GetComponents(UriComponents.Port, UriFormat.SafeUnescaped);
if (!string.IsNullOrEmpty(portString))
{
portNo = int.Parse(portString);
}
var uriBuilder = new UriBuilder(
requestUrl.GetComponents(UriComponents.Scheme, UriFormat.SafeUnescaped),
requestUrl.GetComponents(UriComponents.Host, UriFormat.SafeUnescaped),
portNo,
context.Request.RawUrl.Replace("Service.svc", "ProjectServer.svc"));
return uriBuilder.Uri.ToString();
}
private static BasicHttpBinding GetServerBinding()
{
var serverBinding = new BasicHttpBinding(BasicHttpSecurityMode.TransportCredentialOnly);
serverBinding = new BasicHttpBinding(BasicHttpSecurityMode.TransportCredentialOnly);
serverBinding.AllowCookies = true;
serverBinding.MessageEncoding = WSMessageEncoding.Text;
serverBinding.TransferMode = TransferMode.StreamedResponse;
serverBinding.Security.Transport.ClientCredentialType = HttpClientCredentialType.Ntlm;
serverBinding.Security.Transport.ProxyCredentialType = HttpProxyCredentialType.Ntlm;
serverBinding.Security.Transport.Realm = "foo.acme.com";
serverBinding.MaxBufferSize = int.MaxValue;
serverBinding.MaxReceivedMessageSize = long.MaxValue;
serverBinding.ReaderQuotas.MaxArrayLength = 50000000;
serverBinding.ReaderQuotas.MaxNameTableCharCount = 5000000;
serverBinding.ReaderQuotas.MaxStringContentLength = 640000;
serverBinding.ReceiveTimeout = TimeSpan.FromHours(1);
serverBinding.OpenTimeout = TimeSpan.FromHours(1);
serverBinding.SendTimeout = TimeSpan.FromHours(1);
return serverBinding;
}
As always, the code is provided as-is: not tuned for performance nor made robust to handle failures. In production code, you’ll probably want to use different sizes for the maximum buffer size, etc. Also, make sure your security parameters match what your setup has.
Now, at the end of the post, let’s just throw together a client that can call our extension service. It turns out that this task is extremely simple: We have all the parts needed. Start a new console project, and add a reference to System.ServiceModel. Copy the declaration of the contract (IExtensionPSIService) into your client project. This is one of the really cool things about WCF: it does not have to be the same type, as long as the types are wire-compliant. To us, this means that you can have the interface in a totally different assembly as long as the operations and their signatures (and also SOAP namespaces – a feature that we have not used here) are the same. You’ll also need a way to create a binding; for this purpose, you can just copy the code that we have in the extension service. Your client now looks like this:
static void Main(string[] args)
{
IExtensionPSIService channel = null;
var binding = CreateCustomBinding();
try
{
var channelFactory = new ChannelFactory<IExtensionPSIService>(binding);
channelFactory.Credentials.Windows.AllowNtlm = true;
channelFactory.Credentials.Windows.AllowedImpersonationLevel = TokenImpersonationLevel.Impersonation;
channel = channelFactory.CreateChannel(
new EndpointAddress("https://<servername>[:port]/pwa/_vti_bin/PSI/Service.svc"));
var result = channel.ReadTimesheetListForCurrentUser();
Console.WriteLine(result);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
finally
{
((IClientChannel)channel).Close();
((IClientChannel)channel).Dispose();
}
}
Easy, eh?
Comments
- Anonymous
July 06, 2010
For another approach to doing the same operations, along with complete code, see the Project_Programmability blog article: blogs.msdn.com/.../writing-a-psi-extension-for-project-server-2010.aspx