Asp.net web api 2

De Banane Atomic
(Redirigé depuis Web api)
Aller à la navigationAller à la recherche

Liens

Web API vs MVC

MVC est designé pour des applications web standard qui servent des pages HTML.
Web API est designé pour des applications REST qui servent des objets sérialisés (JSON, XML).

Routing

App_Start/WebApiConfig.cs
public static void Register(HttpConfiguration config)
{
    config.MapHttpAttributeRoutes();

    config.Routes.MapHttpRoute(
        name: "DefaultApi",
        routeTemplate: "api/{controller}/{action}",
        defaults: new { action = RouteParameter.Optional }
    );
Global.asax.cs
public class WebApiApplication : HttpApplication
{
    protected void Application_Start()
    {
        GlobalConfiguration.Configure(WebApiConfig.Register);
    }

Api Controller

GET

TestController.cs
[ApiVersion("1")]
[RoutePrefix("api/v{version:apiVersion}/test")]  // prefix pour toutes les méthodes du controller
public class TestController : ApiController
{
    // api/v1/test/result/{query}
    [HttpGet]  // force GET (optinal, GET by default)
    [Route("result/{query}")]  // route avec 1 paramètre
    public IHttpActionResult Get(string query) { }

    // api/v1/test/result/{query1}/{query2}
    [Route("result/{query1}/{query2}")]  // route avec 2 paramètres
    public IHttpActionResult Get(string query1, string query2) { }

    // api/v1/test/result/{query}/{optionalQuery?}
    [Route("result/{query}/{optionalQuery?}")]  // route avec 1 paramètre optionnel
    public IHttpActionResult Get(string query, string optionalQuery = null) { }

    // api/v1/test?query=text
    [Route("")]
    public IHttpActionResult Get([FromUri]string query) { }

    // api/v1/test/result?Prop1=1&Prop2=2
    [Route("")]
    public IHttpActionResult Get([FromUri]ComplexType ct)
    {
        return this.Ok(ct);
        // { "Prop1": 1, "Prop2": 2 }
    }

    // api/v1/test?cts[0].Prop1=1&cts[0].Prop2=2&cts[1].Prop1=3&cts[1].Prop2=4
    [Route("")]
    public IHttpActionResult Get([FromUri]IEnumerable<ComplexType> cts)
    {
        return this.Ok(cts);
        // [
        //     { "Prop1": 1, "Prop2": 2 },
        //     { "Prop1": 3, "Prop2": 4 }
        // ]
    }

    // api/other, use ~ to overwrite route prefix
    [Route("~/api/other")]
    public IHttpActionResult Get()
    { }

POST

TestController.cs
// api/v1/test
// item is passed in Body
[Post]
[Route("")]
[ResponseType(typeof(Item))]
public IHttpActionResult Post(Item item)
{
    if (item == null || item.Id == 0 || string.IsNullOrWhiteSpace(item.Name))
    {
        return this.BadRequest();
    }

    if (items.Select(i => i.Id).Contains(item.Id))
    {
        return this.Conflict();
    }
    else
    {
        return this.Created(
            this.Request.RequestUri + $"?id={item.Id}",
            item);
    }

Content Negociation

Permet de spécifier sous quelle forme on souhaite la réponse.
Le HTTP Header Accept permet de spécifier le format attendu.

Bash.svg
curl http://localhost:111/api/values -H "Accept: application/xml"
L'extension pour Firefox RESTED permet de tester les requêtes.
Global.asax.cs
protected void Application_Start()
{
    // return json by default (ajouter à la fin de la méthode)
    GlobalConfiguration.Configuration.Formatters.Clear();
    GlobalConfiguration.Configuration.Formatters.Add(new JsonMediaTypeFormatter());
App_Start/WebApiConfig.cs
// return html by default instead of xml
config.Formatters.JsonFormatter.SupportedMediaTypes.Add(new MediaTypeHeaderValue("text/html"));

Header

MyClient.cs
var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Headers.Add("HeaderName", headerValues);
request.Content = new StringContent(JsonConvert.SerializeObject(myObject));

var response = await this.httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
MyController.cs
[HttpGet]
[Route("route")]
[OpenApiTag(ControllerName)]
[SwaggerResponse(HttpStatusCode.OK, typeof(bool))]
[SwaggerResponse(HttpStatusCode.BadRequest, typeof(void))]
public async Task<IHttpActionResult> TestAsync([FromUri]SomeQuery someQuery, CancellationToken cancellationToken)
{
    if (this.Request.Headers.TryGetValues("HeaderName", out var headerValues))
    {}

JsonConverter

Allow to customize the generated JSON.

ItemClient.cs
public async Task<IReadOnlyList<Item>> GetByIdsAsync(IReadOnlyCollection<int> ids, CancellationToken cancellationToken)
{
    var serializedIds = QueryStringSerializer.Serialize(ids, new ListJsonConverter());
    var uri = new Uri($"items?ids={serializedIds}", UriKind.Relative);

Model Binder

Permet de redéfinir la manière dont sera déserialisé l'url.

TestController.cs
// association 1, fonctionne seulement si ComplexType est passé en paramètre
// api/test?ct=1,2
public IHttpActionResult Get([ModelBinder(typeof(ComplexTypeModelBinder))] ComplexType ct) { }

// api/test?cts[0]=1,2&cts[1]=3,4
public IHttpActionResult Get([FromUri] IEnumerable<ComplexType> cts) { }

// api/test?ComplexTypes[0]=1,2&ComplexTypes[1]=3,4
public IHttpActionResult Get([FromUri] Container container) { }
ComplexTypeModelBinder.cs
using System.Web.Http.ModelBinding;

// System.Web.Http.ModelBinding.IModelBinder
public class ComplexTypeModelBinder : IModelBinder
{
    public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
    {
        if (bindingContext.ModelType != typeof(ComplexType))
        {
            return false;
        }

        ValueProviderResult val = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
        if (val == null)
        {
            return false;
        }

        // doesn't seem useful
        if (!(val.RawValue is string key))
        {
            bindingContext.ModelState.AddModelError(bindingContext.ModelName, "Wrong value type");
            return false;
        }

        var splittedValues = valueProviderResult.AttemptedValue.Split(',');
        if (splittedValues.Length != 2)
        {
            bindingContext.ModelState.AddModelError(bindingContext.ModelName,
                "Cannot convert value to ComplexType");
            return false;
        }

        var result = new ComplexType
        {
            Prop1 = int.Parse(values[0]),
            Prop2 = int.Parse(values[1])
        };
        bindingContext.Model = result;
        return true;
    }
}
ComplexType.cs
// association 2, fonctionne si ComplexType est passé en paramètre mais aussi s'il est un sous-élément du paramètre
[ModelBinder(typeof(ComplexTypeModelBinder))]
public class ComplexType
{
    public int Prop1 { get; set; }
    public int Prop2 { get; set; }
}

public class Container
{
    public IEnumerable<ComplexType> ComplexTypes { get; set; }
}
WebApiConfig.cs
public static void Register(HttpConfiguration config)
{
    // association 3, fonctionne seulement si ComplexType est passé en paramètre
    // la méthode du controller ne doit pas contenir FromUri
    config.ParameterBindingRules.Add(
      typeof(ComplexType),
      descriptor => descriptor.BindWithModelBinding(new ComplexTypeModelBinder()));

    // association 4, fonctionne si ComplexType est passé en paramètre mais aussi s'il est un sous-élément du paramètre
    // la méthode du controller doit contenir FromUri
    config.Services.Insert(typeof(ModelBinderProvider), 0, new ComplexTypeModelBinderProvider());
}
ComplexTypeModelBinderProvider.cs
public sealed class ComplexTypeModelBinderProvider : ModelBinderProvider
{
    public override IModelBinder GetBinder(HttpConfiguration configuration, Type modelType)
    {
        return modelType == typeof(ComplexType) ? new ComplexTypeModelBinder() : null;
    }
}

Documentation

Project Properties → Build → Output → cocher XML Documentation file → App_Data\[ProjectName].xml

Areas/HelpPage/App_Start/HelpPageConfig.cs
public static class HelpPageConfig
{
    public static void Register(HttpConfiguration config)
    {
        config.SetDocumentationProvider(new XmlDocumentationProvider(HttpContext.Current.Server.MapPath("~/App_Data/[ProjectName].xml")));

Client

GET

Use JsonConverter to build a custom uri with parameters.

Cs.svg
var httpClient = new HttpClient();

// demande explicitement du xml ("application/xml") ou du json ("application/json")
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml"));

// requête en une ligne utilisant Result, ce qui brise l'async
string content = httpClient.GetAsync(url).Result.Content.ReadAsStringAsync().Result;

// requête en 2 temps avec test du status code
HttpResponseMessage response = await httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
    // XML
    var doc = XDocument.Load(await response.Content.ReadAsStreamAsync());
    var ns = (XNamespace)"http://schemas.datacontract.org/2004/07/WebAPITest.Models";
    foreach (var name in doc.Descendants(ns + "Name"))
    {
        Console.WriteLine(name.Value);
    }

    // récupération du contenu comme un string
    string content = await response.Content.ReadAsStringAsync();
    // désérialisation du contenu
    // ReadAsAsync<T> nécessite l'assembly System.Net.Http.Formatting
    // disponible dans le paquet NuGet Microsoft.AspNet.WebApi.Client
    // et le using System.Net.Http;
    MyDto myDto = await response.Content.ReadAsAsync<MyDto>();
}
else
{
    // retourne un Task<T> avec un contenu null
    return await Task.FromResult<MyDto>(null);
}
Xml.svg
<ArrayOfPerson xmlns:i="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://schemas.datacontract.org/2004/07/WebAPITest.Models">
  <Person>
    <Name>John</Name>
  </Person>
  <Person>
    <Name>Billy</Name>
  </Person>
</ArrayOfPerson>

POST

Cs.svg
// content dans l'url
var content = new FormUrlEncodedContent(new[]
{
    new KeyValuePair<string, string>("clé", "valeur")
});

// content dans le body
var content = new StringContent(
    new JavaScriptSerializer().Serialize(myObject), 
    Encoding.UTF8, 
    "application/json");

HttpResponseMessage responseMessage = await httpClient.PostAsync(url, content);
string result = await responseMessage.Content.ReadAsStringAsync();

Token

Cs.svg
// GET
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
// POST
var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = content;

HttpResponseMessage responseMessage = await httpClient.SendAsync(request);
string result = await responseMessage.Content.ReadAsStringAsync();

Query Builder

Cs.svg
var queryString = HttpUtility.ParseQueryString(string.Empty);
queryString.Add("key", "value");
var uri = new Uri($"http://api.domain.net/query?{queryString}", UriKind.Relative);
// http://api.domain.net/query?key=value

HttpResponseException

By default, most exceptions are translated into an HTTP response with status code 500, Internal Server Error.
Cs.svg
// HttpResponseException permet de spécifier le code d'erreur, ici 404
throw new HttpResponseException(HttpStatusCode.NotFound);

// spécifie en plus le Content et la ReasonPhrase
var responseMessage = new HttpResponseMessage(HttpStatusCode.NotFound)
{
    Content = new StringContent("Error content."),
    ReasonPhrase = "Error reason."
}
throw new HttpResponseException(responseMessage);

Dependencies Injection

  • Install the DryIoc.WebApi nuget package
Global.asax.cs
public class WebApiApplication : HttpApplication
{
    protected void Application_Start()
    {
        GlobalConfiguration.Configure(DryIocConfig.Register);
App_Start/DryIocConfig.cs
public static class DryIocConfig
{
    public static void Register(HttpConfiguration config)
    {
        var container = new Container();
        container.Register<IMyService, MyService>();
        container.WithWebApi(config);
    }
}

Loguer les exceptions non-gérées

Nuget: Microsoft.AspNet.WebApi.Core
App_Start\WebApiConfig.cs
public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        /* ... */
        // plusieurs ExceptionLogger peuvent être ajoutés, permet juste de loguer
        config.Services.Add(typeof(IExceptionLogger), new MyExceptionLogger());
        // un seul ExceptionHandler, permet de modifier la réponse
        config.Services.Replace(typeof(IExceptionHandler), new GlobalExceptionHandler());
MyExceptionLogger.cs
public class MyExceptionLogger : ExceptionLogger
{
    public override void Log(ExceptionLoggerContext context)
    {
        // log it
        base.Log(context);
GlobalExceptionHandler.cs
public class GlobalExceptionHandler : ExceptionHandler
{
    public override void Handle(ExceptionHandlerContext context)
    {
        // réécrit le message d'erreur
        context.Result = new TextErrorResult
        {
            RequestMessage = context.ExceptionContext.Request,
            Content = context.Exception.GetAllMessages()
        };

private class TextErrorResult : IHttpActionResult
{
    public HttpRequestMessage RequestMessage { get; set; }

    public string Content { get; set; }

    public Task<HttpResponseMessage> ExecuteAsync(CancellationToken cancellationToken)
    {
        var response = new HttpResponseMessage(HttpStatusCode.InternalServerError);
        response.Content = new StringContent(Content);
        response.RequestMessage = RequestMessage;
        return Task.FromResult(response);

Authorize

Pour accéder à une méthode Authorize, il faut passer un token d'accès.
MyController.cs
[Authorize]
[HttpGet]
[Route("userinfo")]
public string UserInfo()
{
    string name = ClaimsPrincipal.Current.FindFirst(ClaimTypes.Name).Value;
    string givenName = ClaimsPrincipal.Current.FindFirst(ClaimTypes.GivenName).Value;
    string surname = ClaimsPrincipal.Current.FindFirst(ClaimTypes.Surname).Value;
    string email = ClaimsPrincipal.Current.FindFirst(ClaimTypes.Email);
    string upn = ClaimsPrincipal.Current.FindFirst(ClaimTypes.Upn).Value; // user principal name
    string scope = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/scope").Value;
    string objectId = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value;

    // using Microsoft.AspNet.Identity;
    // packaget NuGet Microsoft ASP.NET Identity Core
    string userId = User.Identity.GetUserId();
}

Cross-Origin Requests

Permet à d'autres sites d'appeler mon API Web, contournant ainsi la same-origin policy.

Swagger / NSwag

Install Nuget packages: Microsoft.Owin.Host.SystemWeb NSwag.AspNet.Owin

Global.asax.cs
protected void Application_Start()
{
    RouteTable.Routes.MapOwinPath("swagger", app =>
    {
        app.UseSwaggerReDoc(typeof(WebApiApplication).Assembly, s =>
        {
            s.GeneratorSettings.DefaultUrlTemplate = "api/{controller}/{id}";
            s.MiddlewareBasePath = "/swagger";
        });
    });
Web.config
<configuration>
  <appSettings>
    <add key="owin:AutomaticAppStartup" value="false" />
  </appSettings>

  <system.webServer>
    <!-- choice 1: Pipe all request to the .NET pipeline -->
    <modules runAllManagedModulesForAllRequests="true" />

    <!-- choice 2: Pipe only the Swagger request to the specific middlewares -->
    <handlers>
      <add name="NSwag" path="swagger" verb="*" type="System.Web.Handlers.TransferRequestHandler" preCondition="integratedMode,runtimeVersionv4.0" />
    </handlers>
  </system.webServer>
  • Url: http://localhost:{port}/swagger
  • Right-click on the project → Properties → Web → Start Action → Specific Page → /swagger

Visual Studio et un projet Web API

  1. New Project → ASP.NET Web Application → Template: Empty → cocher Web API
  2. Clique-droit sur le dossier Controllers → Add Controller → Web API 2 Controller - Empty

Erreurs

Les appels consécutifs à plusieurs Web API échappent plusieurs fois les caractères

Les appels consécutifs à GetStringAsync échappent autant de fois les caractères.
Utiliser plutôt GetAsync.

Cs.svg
// autre solution
string json = await httpClient.GetStringAsync(url);
HttpResponseMessage response = Request.CreateResponse(HttpStatusCode.OK);
response.Content = new StringContent(json, Encoding.UTF8, "application/json");
// retourner response au lieu de json

The file or directory does not exist on the server

Le verbe utilisé n'est pas autorisé sur le serveur. Changer la configuration du serveur.

Documents\IISExpress\config\applicationhost.xml
<add 
  name="ExtensionlessUrl-Integrated-4.0" 
  path="*." verb="GET,HEAD,POST,DEBUG" 
  type="System.Web.Handlers.TransferRequestHandler" 
  preCondition="integratedMode,runtimeVersionv4.0" />
VS utilise [Dossier solution]\.vs\config\applicationhost.xml en surcharge du fichier de config global.