Compatibilidad con las acciones de OData en ASP.NET Web API 2

Por Mike Wasson

Descargar el proyecto completado

En OData, las acciones son una manera de agregar comportamientos del lado servidor que no se definen fácilmente como operaciones CRUD en entidades. Entre los usos de las acciones, se incluyen:

  • Implementar transacciones complejas.
  • Manipular varias entidades a la vez.
  • Permitir actualizaciones solo a determinadas propiedades de una entidad.
  • Enviar información al servidor que no está definida en una entidad.

Versiones de software usadas en el tutorial

  • Web API 2
  • OData versión 3
  • Entity Framework 6

Ejemplo: calificación de un producto

En este ejemplo, queremos permitir a los usuarios calificar los productos y, a continuación, exponer las calificaciones medias de cada producto. En la base de datos, almacenaremos una lista de calificaciones vinculadas a los productos.

Este es el modelo que podríamos usar para representar las calificaciones en Entity Framework:

public class ProductRating
{
    public int ID { get; set; }

    [ForeignKey("Product")]
    public int ProductID { get; set; }
    public virtual Product Product { get; set; }  // Navigation property

    public int Rating { get; set; }
}

Pero no queremos que los clientes envíen mediante una solicitud POST un objeto ProductRating en una colección "Ratings". Intuitivamente, la calificación está asociada a la colección Products y el cliente solo debe tener que publicar el valor de la calificación.

Por lo tanto, en lugar de usar las operaciones CRUD normales, definimos una acción que un cliente puede invocar en un producto. En la terminología de OData, la acción está enlazada a entidades Product.

Las acciones tienen efectos secundarios en el servidor. Por este motivo, se invocan mediante solicitudes HTTP POST. Las acciones pueden tener parámetros y tipos de valor devuelto, que se describen en los metadatos del servicio. El cliente envía los parámetros en el cuerpo de la solicitud y el servidor envía el valor devuelto en el cuerpo de la respuesta. Para invocar a la acción "Rate Product", el cliente envía una solicitud POST a un identificador URI como el siguiente:

http://localhost/odata/Products(1)/RateProduct

Los datos de la solicitud POST son simplemente la calificación del producto:

{"Rating":2}

Declaración de la acción en el Entity Data Model

En la configuración de la API web, agregue la acción al Entity Data Model (EDM):

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        ODataConventionModelBuilder builder = new ODataConventionModelBuilder();
        builder.EntitySet<Product>("Products");
        builder.EntitySet<Supplier>("Suppliers");
        builder.EntitySet<ProductRating>("Ratings");

        // New code: Add an action to the EDM, and define the parameter and return type.
        ActionConfiguration rateProduct = builder.Entity<Product>().Action("RateProduct");
        rateProduct.Parameter<int>("Rating");
        rateProduct.Returns<double>();

        config.Routes.MapODataRoute("odata", "odata", builder.GetEdmModel());
    }
}

Este código define "RateProduct" como una acción que se puede realizar en entidades Product. También declara que la acción acepta un parámetro de tipo int llamado "Rating" y devuelve un valor de tipo int.

Adición de la acción al controlador

La acción "RateProduct" está enlazada a entidades Product. Para implementar la acción, agregue un método llamado RateProduct al controlador de Products:

[HttpPost]
public async Task<IHttpActionResult> RateProduct([FromODataUri] int key, ODataActionParameters parameters)
{
    if (!ModelState.IsValid)
    {
        return BadRequest();
    }

    int rating = (int)parameters["Rating"];

    Product product = await db.Products.FindAsync(key);
    if (product == null)
    {
        return NotFound();
    }

    product.Ratings.Add(new ProductRating() { Rating = rating });
    db.SaveChanges();

    double average = product.Ratings.Average(x => x.Rating);

    return Ok(average);
}

Observe que el nombre del método coincide con el nombre de la acción en el EDM. El método tiene dos parámetros:

  • key: clave del producto que se va a calificar.
  • parameters: diccionario de valores de parámetro de acción.

Si usa las convenciones de enrutamiento predeterminadas, el parámetro de clave se debe llamar "key". También es importante incluir el atributo [FromOdataUri], como se muestra. Este atributo indica a Web API que use las reglas de sintaxis de OData cuando analiza la clave del identificador URI de la solicitud.

Use el diccionario parameters para obtener los parámetros de la acción:

if (!ModelState.IsValid)
{
    return BadRequest();
}
int rating = (int)parameters["Rating"];

Si el cliente envía los parámetros de la acción en el formato correcto, el valor de ModelState.IsValid es true. En ese caso, puede usar el diccionario ODataActionParameters para obtener los valores del parámetro. En este ejemplo, la acción RateProduct toma un único parámetro llamado "Rating".

Metadatos de la acción

Para ver los metadatos del servicio, envíe una solicitud GET a /odata/$metadata. Esta es la parte de los metadatos que declara la acción RateProduct:

<FunctionImport Name="RateProduct" m:IsAlwaysBindable="true" IsBindable="true" ReturnType="Edm.Double">
  <Parameter Name="bindingParameter" Type="ProductService.Models.Product"/>
  <Parameter Name="Rating" Nullable="false" Type="Edm.Int32"/>
</FunctionImport>

El elemento FunctionImport declara la acción. La mayoría de los campos se explican por sí mismos, pero cabe destacar dos:

  • IsBindable significa que la acción se puede invocar en la entidad de destino, al menos parte del tiempo.
  • IsAlwaysBindable significa que la acción se puede invocar siempre en la entidad de destino.

La diferencia es que algunas acciones siempre están disponibles para los clientes, pero otras acciones pueden depender del estado de la entidad. Por ejemplo, supongamos que define una acción "Comprar". Solo puede comprar un artículo que esté en existencias. Si el artículo está sin existencias, un cliente no puede invocar esa acción.

Al definir el EDM, el método Action crea una acción que siempre es enlazable:

builder.Entity<Product>().Action("RateProduct"); // Always bindable

Hablaré sobre las acciones que no siempre son enlazables (también llamadas acciones transitorias) más adelante en este tema.

Invocación de la acción

Ahora, vamos a ver cómo invocaría un cliente esta acción. Supongamos que el cliente quiere dar una calificación de 2 al producto con id. = 4. Este es un mensaje de solicitud de ejemplo en formato JSON para el cuerpo de la solicitud:

POST http://localhost/odata/Products(4)/RateProduct HTTP/1.1
Content-Type: application/json
Content-Length: 12

{"Rating":2}

Este es el mensaje de respuesta:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
DataServiceVersion: 3.0
Date: Tue, 22 Oct 2013 19:04:00 GMT
Content-Length: 89

{
  "odata.metadata":"http://localhost:21900/odata/$metadata#Edm.Double","value":2.75
}

Enlace de una acción a un conjunto de entidades

En el ejemplo anterior, la acción está enlazada a una sola entidad: el cliente evalúa un único producto. También puede enlazar una acción a una colección de entidades. Solo tiene que realizar los siguientes cambios:

En el EDM, agregue la acción a la propiedad Collection de la entidad.

var rateAllProducts = builder.Entity<Product>().Collection.Action("RateAllProducts");

En el método del controlador, omita el parámetro key.

[HttpPost]
public int RateAllProducts(ODataActionParameters parameters)
{
    // ....
}

Ahora, el cliente invoca la acción en el conjunto de entidades Products:

http://localhost/odata/Products/RateAllProducts

Acciones con parámetros de colección

Las acciones pueden tener parámetros que toman una colección de valores. En el EDM, use CollectionParameter<T> para declarar el parámetro.

rateAllProducts.CollectionParameter<int>("Ratings");

Esto declara un parámetro llamado "Ratings" que toma una colección de valores de tipo int. En el método del controlador, también se obtiene el valor del parámetro del objeto ODataActionParameters, pero ahora el valor es de tipo ICollection<int>:

[HttpPost]
public void RateAllProducts(ODataActionParameters parameters)
{
    if (!ModelState.IsValid)
    {
        throw new HttpResponseException(HttpStatusCode.BadRequest);
    }

    var ratings = parameters["Ratings"] as ICollection<int>; 

    // ...
}

Acciones transitorias

En el ejemplo "RateProduct", los usuarios siempre pueden calificar un producto, por lo que la acción siempre está disponible. Pero algunas acciones dependen del estado de la entidad. Por ejemplo, en un servicio de alquiler de vídeo, la acción "CheckOut" no siempre está disponible. (Depende de si hay disponible una copia de ese vídeo). Este tipo de acción se llama acción transitoria.

En los metadatos del servicio, en una acción transitoria el valor de IsAlwaysBindable siempre es false. Es realmente el valor predeterminado, por lo que los metadatos tendrán este aspecto:

<FunctionImport Name="CheckOut" IsBindable="true">
    <Parameter Name="bindingParameter" Type="ProductsService.Models.Product" />
</FunctionImport>

Este es el motivo por el que esto es importante: si una acción es transitoria, el servidor debe indicar al cliente cuándo está disponible la acción. Para ello, incluye un vínculo a la acción en la entidad. Este es un ejemplo de una entidad Movie:

{
  "odata.metadata":"http://localhost:17916/odata/$metadata#Movies/@Element",
  "#CheckOut":{ "target":"http://localhost:17916/odata/Movies(1)/CheckOut" },
  "ID":1,"Title":"Sudden Danger 3","Year":2012,"Genre":"Action"
}

La propiedad "#CheckOut" contiene un vínculo a la acción CheckOut. Si la acción no está disponible, el servidor omite el vínculo.

Para declarar una acción transitoria en el EDM, llame al método TransientAction:

var checkoutAction = builder.Entity<Movie>().TransientAction("CheckOut");

Además, debe proporcionar una función que devuelva un vínculo de acción para una entidad determinada. Para establecer esta función, llame a HasActionLink. Puede escribir la función como una expresión lambda:

checkoutAction.HasActionLink(ctx =>
{
    var movie = ctx.EntityInstance as Movie;
    if (movie.IsAvailable) {
        return new Uri(ctx.Url.ODataLink(
            new EntitySetPathSegment(ctx.EntitySet), 
            new KeyValuePathSegment(movie.ID.ToString()),
            new ActionPathSegment(checkoutAction.Name)));
    }
    else
    {
        return null;
    }
}, followsConventions: true);

Si la acción está disponible, la expresión lambda devuelve un vínculo a la acción. El serializador de OData incluye este vínculo cuando serializa la entidad. Cuando la acción no está disponible, la función devuelve null.

Recursos adicionales