The first step I used to solve this was Visual Studio's Connected Services and WCF. This method generates some classes that are similar to WebReferences but are based on System.DataModel
instead of System.Web
. I should note you can generate these same classes using the SvcUtil.exe
tool if for whatever reason you don't have Visual Studio.
When using VS2017 <15.5, you need to grab the extension for adding WCF service references from Visual Studio Marketplace. For VS2017 >= 15.5, it's now built in. After which you should have a new entry when right-clicking on Connected Service, helpfully called Add Connected Service.... One of the entries on the next screen should be the Microsoft WCF Web Service Reference Provider (At the time of this writing, the extension is in Preview).
Enter the URI to your service endpoint, which for me was in the form of http://[SERVERNAME-OR-IP]/ReportServer/ReportExecution2005.asmx?wsdl
, and set your Namespace at the bottom. I left all the other defaults, and then hit Finish. I don't recall what I used in that field when I first got this setup but I wanted it to be [MyProjectNamespace].ReportingServices
when it was done.
That will give you your class(es).
Again, this can alternatively be done using SvcUtil.exe too.
The code/classes I used in addition to my new reference are as follows. I did my best to make the code as comprehensive as possible. My actual implementation is a lot more refactored, but it just adds complexity that isn't needed to grasp how this all works. So I've tried to make this code as linear as possible. If there are errors with my code, feel free to let me know :-)
public async Task<byte[]> RenderReport(string report, IDictionary<string, object> parameters, string exportFormat = null)
{
//My binding setup, since ASP.NET Core apps don't use a web.config file
var binding = new BasicHttpBinding(BasicHttpSecurityMode.TransportCredentialOnly);
binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.Ntlm;
binding.MaxReceivedMessageSize = 10485760; //I wanted a 10MB size limit on response to allow for larger PDFs
//Create the execution service SOAP Client
var rsExec = new ReportExecutionServiceSoapClient(binding, new EndpointAddress(reportingServicesUrl));
//Setup access credentials. I use windows credentials, yours may differ
var clientCredentials = new NetworkCredential(reportingServicesUserName, reportingServicesPassword, reportingServicesDomain);
rsExec.ClientCredentials.Windows.AllowedImpersonationLevel = System.Security.Principal.TokenImpersonationLevel.Impersonation;
rsExec.ClientCredentials.Windows.ClientCredential = clientCredentials;
//This handles the problem of "Missing session identifier"
rsExec.Endpoint.Behaviors.Add(new ReportingServicesEndpointBehavior());
//Load the report
var taskLoadReport = await rsExec.LoadReportAsync(report, null);
//Set the parameteres asked for by the report
var reportParameters = taskLoadReport.Parameters.Where(x => parameters.ContainsKey(x.Name)).Select(x => new ParameterValue() { Name = x.Name, Value = parameters[x.Name].ToString() }).ToArray();
await rsExec.SetExecutionParametersAsync(reportParameters, "en-us");
//run the report
const string deviceInfo = @"<DeviceInfo><Toolbar>False</Toolbar></DeviceInfo>";
var response = await rsExec.RenderAsync(new RenderRequest(exportFormat ?? "PDF", deviceInfo));
//spit out the result
return response.Result;
}
Most of this is self explanatory, but I wanted to call out the endpoint behavior I'm adding. See, when loading the report details and subsequently using that information to set the parameters of the report using the values I have in my parameters... well parameter, and then rendering the report, you need to have a session identifier set to connect the calls to be all part of the same session context. The session identifier its looking for is a SOAP header value called ExecutionHeader with a value called 'ExecutionID'. This is provided on the response to my call to LoadReportAsync
, but isn't automatically carried over to all future calls to the API. I've tried multiple ways to do this but ran into problems because of the inherent classes trying to set the XML Namespace to something other than what I want. Ultimately the EndpointBehavior was the least intrusive solution (and the only one I got working). The classes that support this look like this.
using System.ServiceModel.Channels;
using System.ServiceModel.Description;
using System.ServiceModel.Dispatcher;
internal class ReportingServicesEndpointBehavior : IEndpointBehavior
{
public void AddBindingParameters(ServiceEndpoint endpoint, BindingParameterCollection bindingParameters) { }
public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime)
{
clientRuntime.ClientMessageInspectors.Add(new ReportingServicesExecutionInspector());
}
public void ApplyDispatchBehavior(ServiceEndpoint endpoint, EndpointDispatcher endpointDispatcher) { }
public void Validate(ServiceEndpoint endpoint) { }
}
internal class ReportingServicesExecutionInspector : IClientMessageInspector
{
private MessageHeaders headers;
public void AfterReceiveReply(ref Message reply, object correlationState)
{
var index = reply.Headers.FindHeader("ExecutionHeader", "http://schemas.microsoft.com/sqlserver/2005/06/30/reporting/reportingservices");
if (index >= 0 && headers == null)
{
headers = new MessageHeaders(MessageVersion.Soap11);
headers.CopyHeaderFrom(reply, reply.Headers.FindHeader("ExecutionHeader", "http://schemas.microsoft.com/sqlserver/2005/06/30/reporting/reportingservices"));
}
}
public object BeforeSendRequest(ref Message request, IClientChannel channel)
{
if(headers != null)
request.Headers.CopyHeadersFrom(headers);
return Guid.NewGuid(); //https://msdn.microsoft.com/en-us/library/system.servicemodel.dispatcher.iclientmessageinspector.beforesendrequest(v=vs.110).aspx#Anchor_0
}
}
There's two classes in here; one is a EndpointBehavior, and the other is a MessageInspector. The EndpointBehavior's sole purpose is to hookup the MessageInspector. I could found no way around that extra step. But what the MessageInspector does is, every time a response comes back, if we don't have an ExecutionHeader yet saved from a past response, we save the one from this response. Subsequently, every time we send a request, if we have an ExecutionHeader saved from a past response, I append it to the Headers for this new request. By doing it this way, I ensure that the Namespace and all the other intricacies around this session identifer are exactly the way the service provided them to begin with, so I'm as confident as I can be they will be valid.
Hopefully this helps anyone looking for solutions to this. I saw lots of other questions on the topic online but none had the answers/explanation I needed.