Eigene Webservices in SharePoint 2010 und 2013 – Deep Dive

In SharePoint 2007 war das Deployment von eigenen Webdiensten bereits ein echter Kampf. Kopieren, disco.exe, Text durch Variablen ersetzen etc. Wer das öfter getan hat weiß, wovon ich rede. Wenn der Dienst dann aber mal installiert war, dann war die Verwendung sehr intuitiv und stabil. Ich hatte die Hoffnung, dass sich alles in 2010 – und erst recht in 2013 – verbessern würde. Doch nachdem ich nun seid langem mal wieder eigene Dienste verfügbar machen musste, habe ich festgestellt, dass alles noch viel komplizierter geworden ist. Schuld ist oft nur die mangelnde Dokumentation. Es gibt zwar zick arten einen Dienst zu Verfügung zu stellen – wenn man aber nicht weiß, wie man ihn konsumieren soll, dann hilft es leider nichts.

Deployment

In den Vorlagen von CKSDev gibt es eine Vorlage für WCF-Dienste. Leider funktioniert diese bei mir nur in VS 2010 und nicht in 2012. Deshalb beschreibe ich hier der manuellen Weg. Wer die Tools verwendet, der kann die nächsten Schritte überspringen und direkt bei SOAP Service oder REST Service weiter machen.

Webdienste stehen in SharePoint im virtuellen Ordner _vti_bin zur Verfügung. Dieser mapped auf den Ordner {SharePointRoot}\ISAPI. Um dorthin etwas zu deployen fügen wir in einem SharePoint-Projekt über Add –> SharePoint Mapped Folder –> ISAPI einen “Mapped Folder” unserem Projekt hinzu. In diesem Ordner erstellen wir einen Unterordner (i.d.R. mit dem selben Namen wie das Projekt) um unsere Artefakte zu isolieren.

AddMappedFolder

image

Um nun einen Webdienst zu erstellen benötigen wir noch Referenzen auf

  • System.ServiceModel
  • System.ServiceModel.Web
  • Microsoft.SharePoint.Client.ServerRuntime

Letztere ist leider nur im GAC verfügbar und kann aus dem entsprechenden Unterordner in “%Windows%\assembly\GAC_MSIL\Microsoft.SharePoint.Client.ServerRuntime kopert werden.

Nun Erstellen wir den Service Contract in form eines Interface.

namespace SharePointProject1.Service
{
    using System.ServiceModel;

    [ServiceContract]
    public interface ITestService
    {
        [OperationContract]
        string Ping();
    }
}

Es empfiehlt sich die Dienste in SharePoint nicht über eine app.config zu konfigurieren – auch wenn das theoretisch (durch Deployment einer web.config in den selben Ordner unter ISAPI) möglich ist. Den Kampf mit der Konfiguration kann man aber nur verlieren. Statt der Konfiguration verwenden wir nach Factories von SharePoint. Dazu braucht unsere Service Klasse noch ein paar Attribute (Siehe Zeile 6 und 7).

namespace SharePointProject1.Service
{
    using System.ServiceModel.Activation;
    using Microsoft.SharePoint.Client.Services;

    [BasicHttpBindingServiceMetadataExchangeEndpoint]
    [AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Required)]
    public class TestService : ITestService
    {
        public string Ping()
        {
            return "Pong";
        }
    }
}

Nun fügen wir dem Ordner eine Textdatei mit der Endung .svc (TestService.svc) hinzu. Die Datei bekommt folgenden Inhalt.

<%@ ServiceHost Language="C#" Debug="true"
    Service="SharePointProject1.Service.TestService, $SharePoint.Project.AssemblyFullName$"
    Factory="Microsoft.SharePoint.Client.Services.MultipleBaseAddressBasicHttpBindingServiceHostFactory, Microsoft.SharePoint.Client.ServerRuntime, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>

Wichtig ist dabei die Klasse der Factory. Diese bestimmt nacher, was es für ein Dienst wird. Folgende Möglichkeiten sind vorhanden:

 

MultipleBaseAddressBasicHttpBindingServiceHostFactory SOAP
MultipleBaseAddressWebServiceHostFactory REST
MultipleBaseAddressWebServiceHostFactory ADO Data Service

Um das Deployment zu ermöglichen, muss man noch die Projektdatei editieren (Rechtsklick auf das Projekt –> Unload –> Edit ). Dann fügt man unter der globalen PropertyGroup folgende Zeile ein:

<TokenReplacementFileExtensions>$(TokenReplacementFileExtensions);xml;aspx;ascx;webpart;dwp;svc;</TokenReplacementFileExtensions>

Dies ermöglicht die Verwendung von Variablen wie $SharePoint.Project.AssemblyFullName$ in den entsprechenden Dateitypen.

Wer es gerne aufgeräumt mag, der kann die Dateien im Projekt noch Gruppieren. Dies geschieht über “DependentUpon”. Dies ist aber Geschmackssache und nicht nötig.

  <ItemGroup>
    <Compile Include="ISAPI\SharePointProject1\ITestService.cs">
      <DependentUpon>TestService.svc</DependentUpon>
    </Compile>
    <Compile Include="ISAPI\SharePointProject1\TestService.cs">
      <DependentUpon>TestService.svc</DependentUpon>
    </Compile>
    <Compile Include="Properties\AssemblyInfo.cs" />
  </ItemGroup>

Die Solution sollte dann wie folgt aussehen.

image

Nach dem Deployment sollte dann auch der entsprechende Ordner mit der SVC-Datei unter {SharePointRoot}\ISAPI vorhanden sein. Ansonsten sollte die Deployment Location der Eigenschaften der Datei überprüft werden.

SOAP Service

Hat man wie oben beschrieben die MultipleBaseAddressBasicHttpBindingServiceHostFactory verwendet, dann erhält man einen SOAP-Dienst. Um zu testen, ob der Dienst verfügbar ist, kann man im Browser folgende URL eingeben:

http://<sitecollectionurl>/_vti_bin/SharePointProject1/TestService.svc/mex

Es sollte dann ein XML angezeigt werden. Die URL ohne mex (ebenso mit ?wsdl oder /wsdl) ergibt einen Fehler. Wird kein XML angezeigt, dann ist mit dem Deployment etwas schief gegangen.

Um nun den Dienst zu konsumieren ist einiges zu beachten. Will man das Tool “Add Service Reference” verwenden, dann ist zu beachten, dass die URL zusammen mit dem /mex angegeben werden muss. ich werden den Proxy hier lieber von Hand erstellen, da der Code so übersichtlicher ist.

Als erstes benötigt unser Testprojekt noch eine Referenz auf System.ServiceModel und System.ServiceModel.Web. Dann können wir den Proxy für unseren Dienst erstellen (Linie 30 bis 43). Die Konstruktoren habe ich hier für die Übersichtlichkeit weggelassen.

Um den Dienst aufzurufen  müssen wir diesmal die URL ohne /mex verwenden. Außerdem muss der SecurityMode (Line 17 und 18) und die Clientcredentials (23) angepasst werden.

namespace SharePointProject1Test
{
    using System.Security.Principal;
    using System.ServiceModel;
    using Microsoft.VisualStudio.TestTools.UnitTesting;
    using SharePointProject1.Service;

    [TestClass]
    public class PingTest
    {
        [TestMethod]
        public void CanPingService()
        {
            string url = "http://<siteurl>/_vti_bin/SharePointProject1/TestService.svc";

            var binding = new BasicHttpBinding();
            binding.Security.Mode = BasicHttpSecurityMode.TransportCredentialOnly;
            binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.Ntlm;
            var endPoint = new EndpointAddress(url);

            using (var proxy = new TestServiceClient(binding, endPoint))
            {
                proxy.ClientCredentials.Windows.AllowedImpersonationLevel = TokenImpersonationLevel.Impersonation;
                string pong = proxy.Ping();
                Assert.AreEqual<string>("Pong", pong);
            }
        }
    }

    public interface ITestServiceChannel : ITestService, System.ServiceModel.IClientChannel
    {
    }

    public partial class TestServiceClient : System.ServiceModel.ClientBase<ITestService>, ITestService
    {
        #region All Constructors
        #endregion

        public string Ping()
        {
            return base.Channel.Ping();
        }
    }
}

Wenn man das mal hat, dann gehen die SOAP Dienste gut von der Hand. Ich habe hier noch ein paar Beispiele mit komplexeren Ein- und Rückgabewerten hinzugefügt. An der Verwendung ändert sich aber nichts.

namespace SharePointProject1.Service
{
    using System;
    using System.Runtime.Serialization;
    using System.ServiceModel;
    using System.ServiceModel.Web;

    [ServiceContract]
    public interface ITestService
    {
        [OperationContract]
        string Ping();

        [OperationContract]
        string GetUrl();

        [OperationContract]
        WebInfo GetWebInfo();

        [OperationContract]
        void UpdateWeb(WebUpdateInfo info);
    }

    [DataContract]
    [Serializable]
    public class WebInfo
    {
        [DataMember]
        public string Title { get; set; }

        [DataMember]
        public string Url { get; set; }
    }

    [DataContract]
    public class WebUpdateInfo
    {
        [DataMember]
        public string Title { get; set; }

        [DataMember]
        public string Description { get; set; }
    }
}
namespace SharePointProject1.Service
{
    using System.ServiceModel.Activation;
    using Microsoft.SharePoint;
    using Microsoft.SharePoint.Client.Services;

    [BasicHttpBindingServiceMetadataExchangeEndpoint]
    [AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Required)]
    public class TestService : ITestService
    {
        public string Ping()
        {
            return "Pong";
        }

        public string GetUrl()
        {
            return SPContext.Current.Web.Url;
        }

        public WebInfo GetWebInfo()
        {
            return new WebInfo
            {
                Title = SPContext.Current.Web.Title,
                Url = SPContext.Current.Web.Url
            };
        }

        public void UpdateWeb(WebUpdateInfo info)
        {
            SPContext.Current.Web.Title = info.Title;
            SPContext.Current.Web.Description = info.Description;
            SPContext.Current.Web.Update();
        }
    }
}
[TestClass]
public class SoapTest
{
    [TestMethod]
    public void CanPingServiceUsingSoa()
    {
        string url = "http://<siteurl>/_vti_bin/SharePointProject1/TestService.svc";

        var binding = new BasicHttpBinding();
        binding.Security.Mode = BasicHttpSecurityMode.TransportCredentialOnly;
        binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.Ntlm;
        var endPoint = new EndpointAddress(url);

        using (var proxy = new TestServiceClient(binding, endPoint))
        {
            proxy.ClientCredentials.Windows.AllowedImpersonationLevel = TokenImpersonationLevel.Impersonation;
            string pong = proxy.Ping();
            Assert.AreEqual<string>("Pong", pong);
        }
    }

    [TestMethod]
    public void CanAccesContextUsingSoap()
    {
        string url = "http://<siteurl>/subsite";

        var binding = new BasicHttpBinding();
        binding.Security.Mode = BasicHttpSecurityMode.TransportCredentialOnly;
        binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.Ntlm;
        var endPoint = new EndpointAddress(url + "/_vti_bin/SharePointProject1/TestService.svc");

        using (var proxy = new TestServiceClient(binding, endPoint))
        {
            proxy.ClientCredentials.Windows.AllowedImpersonationLevel = TokenImpersonationLevel.Impersonation;
            string result = proxy.GetUrl();
            Assert.AreEqual<string>(url, result);
        }
    }

    [TestMethod]
    public void CanGetComplexObjectUsingSoap()
    {
        string url = "http://<siteurl>/subsite";

        var binding = new BasicHttpBinding();
        binding.Security.Mode = BasicHttpSecurityMode.TransportCredentialOnly;
        binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.Ntlm;
        var endPoint = new EndpointAddress(url + "/_vti_bin/SharePointProject1/TestService.svc");

        using (var proxy = new TestServiceClient(binding, endPoint))
        {
            proxy.ClientCredentials.Windows.AllowedImpersonationLevel = TokenImpersonationLevel.Impersonation;
            var result = proxy.GetWebInfo();
            Assert.AreEqual<string>(url, result.Url);
        }
    }

    [TestMethod]
    public void CanSendComplexObectUsingSoap()
    {
        string url = "http://<siteurl>/subsite";

        var binding = new BasicHttpBinding();
        binding.Security.Mode = BasicHttpSecurityMode.TransportCredentialOnly;
        binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.Ntlm;
        var endPoint = new EndpointAddress(url + "/_vti_bin/SharePointProject1/TestService.svc");

        using (var proxy = new TestServiceClient(binding, endPoint))
        {
            proxy.ClientCredentials.Windows.AllowedImpersonationLevel = TokenImpersonationLevel.Impersonation;
            proxy.UpdateWeb(new WebUpdateInfo { Title = "New Title", Description = "Desccription." });
            var result = proxy.GetWebInfo();
            Assert.AreEqual<string>("New Title", result.Title);
        }
    }
}

REST Service

Um einen Dienst als REST-Dienst zu verwenden, muss man in der SVC-Datei die Klasse MultipleBaseAddressWebServiceHostFactory verwenden. Im Service Contract kann man jetzt der Methode ein zusätzliches Attribut WebGet hinzufügen.

[OperationContract]
[WebGet(ResponseFormat = WebMessageFormat.Json, BodyStyle = WebMessageBodyStyle.Bare)]
string GetUrl();

Die Implementierung sieht in unserem Beispiel so aus.

public string GetUrl()
{
    return SPContext.Current.Web.Url;
}

Nach dem Deployment kann die Methode über den Browser getestet werden. Dabei ist der Methodennamen an die SVC-Datei anzuhängen.

http://<siteurl>/_vti_bin/SharePointProject1/RestService.svc/geturl

Um die Methode zu Testen kann man einen WebRequest verwenden. Wichtig ist, die URL wieder mit der Methode anzugeben. Außerdem muss die Methode und der ContentType festgelegt werden. UseDefaultCredentials sendet die Windowsanmeldung mit zum Server.

[TestMethod]
public void CanAccesContextUsingRest()
{
    string siteUrl = "http://<siteurl>/subsite";
    var uri = new Uri(siteUrl + "/_vti_bin/SharePointProject1/RestService.svc/GetUrl");

    var request = WebRequest.Create(uri);
    request.UseDefaultCredentials = true;
    request.Method = "Get";
    request.ContentType = "application/json; charset=utf-8";

    using (var stream = request.GetResponse().GetResponseStream())
    {
        var sx = new DataContractJsonSerializer(typeof(string));
        var result = (string)sx.ReadObject(stream);

        Assert.AreEqual<string>(siteUrl, result);
    }
}

Um nun Parameter im REST-Style zu übergeben, verwendet man die Eigenschaft UriTemplate des WebGet-Attributes. Es können einfache Parameter genauso wie komplexe Json-Objeckte übergeben werden.

[OperationContract]
[WebGet(UriTemplate = "UpdateWeb?info={info}", ResponseFormat = WebMessageFormat.Json, BodyStyle = WebMessageBodyStyle.Bare)]
void UpdateWeb(string info);

Eine Implementierung könnte so aussehen.

public void UpdateWeb(string info)
{
    byte[] byteArray = Encoding.UTF8.GetBytes(info);

    using (var stream = new MemoryStream(byteArray))
    {
        var sx = new DataContractJsonSerializer(typeof(WebUpdateInfo));
        var converted = (WebUpdateInfo)sx.ReadObject(stream);
        SPContext.Current.Web.Title = converted.Title;
        SPContext.Current.Web.Description = converted.Description;
        SPContext.Current.Web.AllowUnsafeUpdates = true;
        SPContext.Current.Web.Update();
        SPContext.Current.Web.AllowUnsafeUpdates = false;
    }
}

Dies kann ebenfalls über die URL im Browser getestet werden:

http://<siteurl>/subsite/_vti_bin/SharePointProject1/RestService.svc/updateWeb?info={“Description”:”New Description”,”Title”:”New Title”}

Nachdem die URL aufgerufen wurde muss sich der Titel des entsprechenden Subwebs geändert haben.

Zum Testen kann man den DataContractJsonSerialize verwenden. Wieder wird der ganze Aufruf über die URL gesteuert.

[TestMethod]
public void CanSendComplexObectUsingRest()
{
    string siteUrl = "http://<siteurl>/subsite";

    var info = new WebUpdateInfo { Title = "New Title REST", Description = "New Description" };
    var sx = new DataContractJsonSerializer(typeof(WebUpdateInfo));

    using (var stream = new MemoryStream())
    {
        sx.WriteObject(stream, info);
        var uri = new Uri(siteUrl + "/_vti_bin/SharePointProject1/RestService.svc/UpdateWeb?info=" + Encoding.UTF8.GetString(stream.ToArray()));
        var request = WebRequest.Create(uri);
        request.UseDefaultCredentials = true;
        request.Method = "Get";
        request.ContentType = "application/json; charset=utf-8";
        var response = (HttpWebResponse)request.GetResponse();
        Assert.AreEqual<HttpStatusCode>(HttpStatusCode.OK, response.StatusCode);
    }

    var title = GetTitle(siteUrl);
    Assert.AreEqual<string>("New Title REST", title);
}

Was mich viel Zeit und Nerven gekostet hat, ist die Implementierung einer POST oder PUT Methode. Dazu muss man statt dem WebGet ein WebInvoke Attribut verwenden.

[OperationContract]
[WebInvoke(
    Method = "POST",
    ResponseFormat = WebMessageFormat.Json,
    RequestFormat = WebMessageFormat.Json,
    BodyStyle = WebMessageBodyStyle.Wrapped)]
void UpdateWebInPost(WebUpdateInfo info);

Wichtig ist, dass der BodyStyle auf wrapped gesetzt wird – ansonsten erhält man den Fehler 400 Bad Request.

public void UpdateWebInPost(WebUpdateInfo info)
{
    SPContext.Current.Web.Title = info.Title;
    SPContext.Current.Web.Description = info.Description;
    SPContext.Current.Web.AllowUnsafeUpdates = true;
    SPContext.Current.Web.Update();
    SPContext.Current.Web.AllowUnsafeUpdates = false;
}

Damit der Parameter (hier info) dann nicht immer null ist, muss er in ein Json-Objekt “verpackt” werden. Dies geschieht hier in zeile 20: {info: + serialisiertesObject + }

[TestMethod]
public void CanSendComplexObjectInPostBodyUsingRest()
{
    string siteUrl = "http://<siteurl>/subsite";

    var uri = new Uri(siteUrl + "/_vti_bin/SharePointProject1/RestService.svc/UpdateWebInPost");
    var request = WebRequest.Create(uri);
    request.UseDefaultCredentials = true;
    request.Method = "POST";
    request.ContentType = "text/json";

    byte[] buffer = null;

    using (var stream = new MemoryStream())
    {
        var info = new WebUpdateInfo { Title = "New Title REST", Description = "New Description" };
        var sx = new DataContractJsonSerializer(typeof(WebUpdateInfo));
        sx.WriteObject(stream, info);
        var inner = Encoding.UTF8.GetString(stream.ToArray());
        // this is important: we have to wrap the object using the parameter name!
        buffer = Encoding.UTF8.GetBytes("{\"info\":" + inner + "}");
    }

    request.ContentLength = buffer.Length;
    using (var rs = request.GetRequestStream())
    {
        rs.Write(buffer, 0, buffer.Length);
    }

    var response = request.GetResponse();
    Assert.AreEqual<HttpStatusCode>(HttpStatusCode.OK, ((HttpWebResponse)response).StatusCode);

    var title = GetTitle(siteUrl);
    Assert.AreEqual<string>("New Title REST", title);
}

Der gesamte Beispielcode kann hier heruntergeladen werden. Vielleicht reiche ich die nächsten Wochen noch einen Poste mit Beispielen zum konsumieren der Dienste aus JavaScript nach.

Fazit

Der Weg ist steinig – hat man sich aber erst mal zurecht gefunden, dann gehen weitere Dienste gut von der Hand. Das Problem ist, dass es wenig bis keine vollständige Dokumentation gibt. Hier noch mal die wichtigsten Punkte zusammengefasst:

  • Verwende weder die app.config noch das Service Configuration Tool
  • Setze die entsprechenden Attribute auf der Dienstklasse
  • Mach die klar, welche Factory du verwenden willst (SOAP oder REST)
  • Verwende bei SOAP keine zusätzlichen Attribute mit den OperationContract
  • Verwende bei SOAP das binding ohne /mex und setze TransportCredentialOnly und HttpClientCredentialType.Ntlm
  • Deserialisiere bei REST Ergebnisse und Parameter mit einem Serialisieren für Json.
  • Setze bei POST oder Put den BodyStyle auf Wrapped
  • Wrappe bei BodyStyleWrapped Parameter in ein Objekt

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s